Skip to content

Commit 1947737

Browse files
committed
implement resource attributes for geneva logs
1 parent 52c2873 commit 1947737

File tree

9 files changed

+351
-81
lines changed

9 files changed

+351
-81
lines changed

src/OpenTelemetry.Exporter.Geneva/CHANGELOG.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,13 @@
22

33
## Unreleased
44

5+
* Support for specifying resource attributes, including
6+
`service.name`, `service.instanceId`, and custom attributes in log fields.
7+
([#TODO](https://github.com/open-telemetry/opentelemetry-dotnet-contrib))
8+
* Logs: Custom log fields take precedence over prepopulated fields,
9+
preventing duplicate keys in exported logs.
10+
([#TODO](https://github.com/open-telemetry/opentelemetry-dotnet-contrib))
11+
512
## 1.14.0
613

714
Released 2025-Nov-13

src/OpenTelemetry.Exporter.Geneva/GenevaExporterOptions.cs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,8 @@ public IReadOnlyDictionary<string, string>? TableNameMappings
100100

101101
/// <summary>
102102
/// Gets or sets prepopulated fields.
103+
///
104+
/// Pre-populated fields are fields that are added as dedicated fields to every record, unless it would conflict with a log or trace field that is marked as a custom field.
103105
/// </summary>
104106
public IReadOnlyDictionary<string, object> PrepopulatedFields
105107
{

src/OpenTelemetry.Exporter.Geneva/GenevaLogExporter.cs

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -88,9 +88,14 @@ public GenevaLogExporter(GenevaExporterOptions options)
8888
throw new NotSupportedException($"Protocol '{connectionStringBuilder.Protocol}' is not supported");
8989
}
9090

91+
Resources.Resource ResourceProvider()
92+
{
93+
return connectionStringBuilder.HonorResourceAttributes ? this.ParentProvider.GetResource() : Resources.Resource.Empty;
94+
}
95+
9196
if (useMsgPackExporter)
9297
{
93-
var msgPackLogExporter = new MsgPackLogExporter(options);
98+
var msgPackLogExporter = new MsgPackLogExporter(options, ResourceProvider);
9499
this.IsUsingUnixDomainSocket = msgPackLogExporter.IsUsingUnixDomainSocket;
95100
this.exportLogRecord = msgPackLogExporter.Export;
96101
this.exporter = msgPackLogExporter;

src/OpenTelemetry.Exporter.Geneva/Internal/MsgPack/MsgPackLogExporter.cs

Lines changed: 102 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
using OpenTelemetry.Exporter.Geneva.Transports;
1313
using OpenTelemetry.Internal;
1414
using OpenTelemetry.Logs;
15+
using OpenTelemetry.Resources;
1516

1617
namespace OpenTelemetry.Exporter.Geneva.MsgPack;
1718

@@ -33,26 +34,33 @@ internal sealed class MsgPackLogExporter : MsgPackExporter, IDisposable
3334

3435
#if NET
3536
private readonly FrozenSet<string>? customFields;
36-
private readonly FrozenDictionary<string, object>? prepopulatedFields;
3737
#else
3838
private readonly HashSet<string>? customFields;
39-
private readonly Dictionary<string, object>? prepopulatedFields;
4039
#endif
4140

4241
private readonly ExceptionStackExportMode exportExceptionStack;
43-
private readonly List<string>? prepopulatedFieldKeys;
4442
private readonly byte[] bufferEpilogue;
4543
private readonly IDataTransport dataTransport;
44+
private readonly Func<Resource> resourceProvider;
45+
46+
// These are values that are always added to the body as dedicated fields
47+
private readonly Dictionary<string, object> prepopulatedFields;
48+
49+
// These are values that are always added to env_properties
50+
private readonly Dictionary<string, object> propertiesEntries;
4651
private readonly int stringFieldSizeLimitCharCount; // the maximum string size limit for MsgPack strings
4752

4853
// This is used for Scopes
4954
private readonly ThreadLocal<SerializationDataForScopes> serializationData = new();
5055

5156
private bool isDisposed;
5257

53-
public MsgPackLogExporter(GenevaExporterOptions options)
58+
public MsgPackLogExporter(GenevaExporterOptions options, Func<Resource> resourceProvider)
5459
{
5560
Guard.ThrowIfNull(options);
61+
Guard.ThrowIfNull(resourceProvider);
62+
63+
this.resourceProvider = resourceProvider;
5664

5765
this.tableNameSerializer = new(options, defaultTableName: "Log");
5866
this.exportExceptionStack = options.ExceptionStackExportMode;
@@ -88,21 +96,17 @@ public MsgPackLogExporter(GenevaExporterOptions options)
8896
}
8997

9098
this.stringFieldSizeLimitCharCount = connectionStringBuilder.PrivatePreviewLogMessagePackStringSizeLimit;
99+
100+
this.propertiesEntries = [];
101+
102+
this.prepopulatedFields = new Dictionary<string, object>(options.PrepopulatedFields.Count, StringComparer.Ordinal);
103+
91104
if (options.PrepopulatedFields != null)
92105
{
93-
this.prepopulatedFieldKeys = [];
94-
var tempPrepopulatedFields = new Dictionary<string, object>(options.PrepopulatedFields.Count, StringComparer.Ordinal);
95106
foreach (var kv in options.PrepopulatedFields)
96107
{
97-
tempPrepopulatedFields[kv.Key] = kv.Value;
98-
this.prepopulatedFieldKeys.Add(kv.Key);
108+
this.prepopulatedFields[kv.Key] = kv.Value;
99109
}
100-
101-
#if NET
102-
this.prepopulatedFields = tempPrepopulatedFields.ToFrozenDictionary(StringComparer.Ordinal);
103-
#else
104-
this.prepopulatedFields = tempPrepopulatedFields;
105-
#endif
106110
}
107111

108112
// TODO: Validate custom fields (reserved name? etc).
@@ -174,27 +178,67 @@ public void Dispose()
174178
this.isDisposed = true;
175179
}
176180

181+
internal void AddResourceAttributesToPrepopulated()
182+
{
183+
// This function needs to be idempotent
184+
185+
foreach (var entry in this.resourceProvider().Attributes)
186+
{
187+
string key = entry.Key;
188+
bool isDedicatedField = false;
189+
if (entry.Value is string)
190+
{
191+
switch (key)
192+
{
193+
case "service.name":
194+
key = Schema.V40.PartA.Extensions.Cloud.Role;
195+
isDedicatedField = true;
196+
break;
197+
case "service.instanceId":
198+
key = Schema.V40.PartA.Extensions.Cloud.RoleInstance;
199+
isDedicatedField = true;
200+
break;
201+
}
202+
}
203+
204+
if (isDedicatedField || this.customFields == null || this.customFields.Contains(key))
205+
{
206+
if (!this.prepopulatedFields.ContainsKey(key))
207+
{
208+
this.prepopulatedFields.Add(key, entry.Value);
209+
}
210+
}
211+
else
212+
{
213+
if (!this.propertiesEntries.ContainsKey(key))
214+
{
215+
this.propertiesEntries.Add(key, entry.Value);
216+
}
217+
}
218+
}
219+
}
220+
177221
internal ArraySegment<byte> SerializeLogRecord(LogRecord logRecord)
178222
{
179223
// `LogRecord.State` and `LogRecord.StateValues` were marked Obsolete in https://github.com/open-telemetry/opentelemetry-dotnet/pull/4334
180224
#pragma warning disable 0618
181-
IReadOnlyList<KeyValuePair<string, object?>>? listKvp;
225+
IReadOnlyList<KeyValuePair<string, object?>>? logFields;
182226
if (logRecord.StateValues != null)
183227
{
184-
listKvp = logRecord.StateValues;
228+
logFields = logRecord.StateValues;
185229
}
186230
else
187231
{
188232
// Attempt to see if State could be ROL_KVP.
189-
listKvp = logRecord.State as IReadOnlyList<KeyValuePair<string, object?>>;
233+
logFields = logRecord.State as IReadOnlyList<KeyValuePair<string, object?>> ?? [];
190234
}
191235
#pragma warning restore 0618
192236

193237
var buffer = Buffer.Value ??= new byte[BUFFER_SIZE]; // TODO: handle OOM
194238

195239
/* Fluentd Forward Mode:
196240
[
197-
"Log",
241+
"Log", // (or category name)
198242
[
199243
[ <timestamp>, { "env_ver": "4.0", ... } ]
200244
],
@@ -227,15 +271,20 @@ internal ArraySegment<byte> SerializeLogRecord(LogRecord logRecord)
227271
ushort cntFields = 0;
228272
var idxMapSizePatch = cursor - 2;
229273

230-
if (this.prepopulatedFieldKeys != null)
274+
this.AddResourceAttributesToPrepopulated();
275+
276+
foreach (var entry in this.prepopulatedFields)
231277
{
232-
for (var i = 0; i < this.prepopulatedFieldKeys.Count; i++)
278+
// A prepopulated entry should not be added if the same key exists in the log,
279+
// and customFields configuration would make it a dedicated field.
280+
if ((this.customFields == null || this.customFields.Contains(entry.Key))
281+
&& logFields.Any(kvp => kvp.Key == entry.Key))
233282
{
234-
var key = this.prepopulatedFieldKeys[i];
235-
var value = this.prepopulatedFields![key];
236-
cursor = AddPartAField(buffer, cursor, key, value);
237-
cntFields += 1;
283+
continue;
238284
}
285+
286+
cursor = AddPartAField(buffer, cursor, entry.Key, entry.Value);
287+
cntFields += 1;
239288
}
240289

241290
// Part A - core envelope
@@ -295,10 +344,8 @@ internal ArraySegment<byte> SerializeLogRecord(LogRecord logRecord)
295344
var hasEnvProperties = false;
296345
var bodyPopulated = false;
297346
var namePopulated = false;
298-
for (var i = 0; i < listKvp?.Count; i++)
347+
foreach (var entry in logFields)
299348
{
300-
var entry = listKvp[i];
301-
302349
// Iteration #1 - Get those fields which become dedicated columns
303350
// i.e all Part B fields and opt-in Part C fields.
304351
if (entry.Key == "{OriginalFormat}")
@@ -366,27 +413,44 @@ internal ArraySegment<byte> SerializeLogRecord(LogRecord logRecord)
366413
cursor = dataForScopes.Cursor;
367414
cntFields = dataForScopes.FieldsCount;
368415

369-
if (hasEnvProperties)
416+
if (hasEnvProperties || this.propertiesEntries.Count > 0)
370417
{
371-
// Iteration #2 - Get all "other" fields and collapse them into single field
372-
// named "env_properties".
418+
// Anything that's not a dedicated field gets put into a part C field called "env_properties".
373419
ushort envPropertiesCount = 0;
374420
cursor = MessagePackSerializer.SerializeAsciiString(buffer, cursor, "env_properties");
375421
cursor = MessagePackSerializer.WriteMapHeader(buffer, cursor, ushort.MaxValue);
376422
var idxMapSizeEnvPropertiesPatch = cursor - 2;
377-
for (var i = 0; i < listKvp!.Count; i++)
423+
424+
if (hasEnvProperties)
378425
{
379-
var entry = listKvp[i];
380-
if (entry.Key == "{OriginalFormat}" || this.customFields!.Contains(entry.Key))
426+
foreach (var entry in logFields)
381427
{
382-
continue;
428+
if (entry.Key == "{OriginalFormat}" || this.customFields!.Contains(entry.Key))
429+
{
430+
continue;
431+
}
432+
else
433+
{
434+
cursor = MessagePackSerializer.SerializeUnicodeString(buffer, cursor, entry.Key, this.stringFieldSizeLimitCharCount);
435+
cursor = MessagePackSerializer.Serialize(buffer, cursor, entry.Value);
436+
envPropertiesCount += 1;
437+
}
383438
}
384-
else
439+
}
440+
441+
foreach (var entry in this.propertiesEntries)
442+
{
443+
// A prepopulated env_properties entry should not be added if the same key exists in the log,
444+
// and lack of customFields configuration would place it in env_properties.
445+
if (this.customFields != null && !this.customFields.Contains(entry.Key)
446+
&& logFields.Any(kvp => kvp.Key == entry.Key))
385447
{
386-
cursor = MessagePackSerializer.SerializeUnicodeString(buffer, cursor, entry.Key, this.stringFieldSizeLimitCharCount);
387-
cursor = MessagePackSerializer.Serialize(buffer, cursor, entry.Value);
388-
envPropertiesCount += 1;
448+
continue;
389449
}
450+
451+
cursor = MessagePackSerializer.SerializeUnicodeString(buffer, cursor, entry.Key);
452+
cursor = MessagePackSerializer.Serialize(buffer, cursor, entry.Value);
453+
envPropertiesCount += 1;
390454
}
391455

392456
// Prepare state for scopes

test/OpenTelemetry.Exporter.Geneva.Benchmarks/Exporter/LogExporterBenchmarks.cs

Lines changed: 13 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
using Microsoft.Extensions.Logging;
66
using OpenTelemetry.Exporter.Geneva.MsgPack;
77
using OpenTelemetry.Logs;
8+
using OpenTelemetry.Resources;
89

910
/*
1011
BenchmarkDotNet v0.13.10, Windows 11 (10.0.23424.1000)
@@ -74,16 +75,18 @@ public LogExporterBenchmarks()
7475
// For msgpack serialization + export
7576
this.logRecord = GenerateTestLogRecord();
7677
this.batch = GenerateTestLogRecordBatch();
77-
this.exporter = new MsgPackLogExporter(new GenevaExporterOptions
78-
{
79-
ConnectionString = "EtwSession=OpenTelemetry",
80-
PrepopulatedFields = new Dictionary<string, object>
81-
{
82-
["cloud.role"] = "BusyWorker",
83-
["cloud.roleInstance"] = "CY1SCH030021417",
84-
["cloud.roleVer"] = "9.0.15289.2",
85-
},
86-
});
78+
this.exporter = new MsgPackLogExporter(
79+
new GenevaExporterOptions
80+
{
81+
ConnectionString = "EtwSession=OpenTelemetry",
82+
PrepopulatedFields = new Dictionary<string, object>
83+
{
84+
["cloud.role"] = "BusyWorker",
85+
["cloud.roleInstance"] = "CY1SCH030021417",
86+
["cloud.roleVer"] = "9.0.15289.2",
87+
},
88+
},
89+
() => Resource.Empty);
8790
}
8891

8992
[Benchmark]

test/OpenTelemetry.Exporter.Geneva.Benchmarks/Exporter/TLDLogExporterBenchmarks.cs

Lines changed: 11 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
using OpenTelemetry.Exporter.Geneva.MsgPack;
77
using OpenTelemetry.Exporter.Geneva.Tld;
88
using OpenTelemetry.Logs;
9+
using OpenTelemetry.Resources;
910
using OpenTelemetry.Trace;
1011

1112
/*
@@ -37,16 +38,18 @@ public class TLDLogExporterBenchmarks
3738

3839
public TLDLogExporterBenchmarks()
3940
{
40-
this.msgPackExporter = new MsgPackLogExporter(new GenevaExporterOptions
41-
{
42-
ConnectionString = "EtwSession=OpenTelemetry",
43-
PrepopulatedFields = new Dictionary<string, object>
41+
this.msgPackExporter = new MsgPackLogExporter(
42+
new GenevaExporterOptions
4443
{
45-
["cloud.role"] = "BusyWorker",
46-
["cloud.roleInstance"] = "CY1SCH030021417",
47-
["cloud.roleVer"] = "9.0.15289.2",
44+
ConnectionString = "EtwSession=OpenTelemetry",
45+
PrepopulatedFields = new Dictionary<string, object>
46+
{
47+
["cloud.role"] = "BusyWorker",
48+
["cloud.roleInstance"] = "CY1SCH030021417",
49+
["cloud.roleVer"] = "9.0.15289.2",
50+
},
4851
},
49-
});
52+
() => Resource.Empty);
5053

5154
this.tldExporter = new TldLogExporter(new GenevaExporterOptions()
5255
{

0 commit comments

Comments
 (0)