diff --git a/src/OpenTelemetry.Exporter.OpenTelemetryProtocol/CHANGELOG.md b/src/OpenTelemetry.Exporter.OpenTelemetryProtocol/CHANGELOG.md index 1c62bad01a..1d890a3da6 100644 --- a/src/OpenTelemetry.Exporter.OpenTelemetryProtocol/CHANGELOG.md +++ b/src/OpenTelemetry.Exporter.OpenTelemetryProtocol/CHANGELOG.md @@ -7,6 +7,10 @@ Notes](../../RELEASENOTES.md). ## Unreleased +* Added support for exporting instrumentation scope attributes from + `ActivitySource.Tags`. + ([#5897](https://github.com/open-telemetry/opentelemetry-dotnet/pull/5897)) + ## 1.10.0-beta.1 Released 2024-Sep-30 diff --git a/src/OpenTelemetry.Exporter.OpenTelemetryProtocol/Implementation/ActivityExtensions.cs b/src/OpenTelemetry.Exporter.OpenTelemetryProtocol/Implementation/ActivityExtensions.cs index 15115f0479..313a8e9f59 100644 --- a/src/OpenTelemetry.Exporter.OpenTelemetryProtocol/Implementation/ActivityExtensions.cs +++ b/src/OpenTelemetry.Exporter.OpenTelemetryProtocol/Implementation/ActivityExtensions.cs @@ -32,6 +32,8 @@ internal static void AddBatch( }; request.ResourceSpans.Add(resourceSpans); + var maxTags = sdkLimitOptions.AttributeCountLimit ?? int.MaxValue; + foreach (var activity in activityBatch) { Span? span = activity.ToOtlpSpan(sdkLimitOptions); @@ -44,15 +46,15 @@ internal static void AddBatch( } var activitySourceName = activity.Source.Name; - if (!spansByLibrary.TryGetValue(activitySourceName, out var spans)) + if (!spansByLibrary.TryGetValue(activitySourceName, out var scopeSpans)) { - spans = GetSpanListFromPool(activitySourceName, activity.Source.Version); + scopeSpans = GetSpanListFromPool(activity.Source, maxTags, sdkLimitOptions.AttributeValueLengthLimit); - spansByLibrary.Add(activitySourceName, spans); - resourceSpans.ScopeSpans.Add(spans); + spansByLibrary.Add(activitySourceName, scopeSpans); + resourceSpans.ScopeSpans.Add(scopeSpans); } - spans.Spans.Add(span); + scopeSpans.Spans.Add(span); } } @@ -65,34 +67,69 @@ internal static void Return(this ExportTraceServiceRequest request) return; } - foreach (var scope in resourceSpans.ScopeSpans) + foreach (var scopeSpan in resourceSpans.ScopeSpans) { - scope.Spans.Clear(); - SpanListPool.Add(scope); + scopeSpan.Spans.Clear(); + scopeSpan.Scope.Attributes.Clear(); + SpanListPool.Add(scopeSpan); } } [MethodImpl(MethodImplOptions.AggressiveInlining)] - internal static ScopeSpans GetSpanListFromPool(string name, string? version) + internal static ScopeSpans GetSpanListFromPool(ActivitySource activitySource, int maxTags, int? attributeValueLengthLimit) { - if (!SpanListPool.TryTake(out var spans)) + if (!SpanListPool.TryTake(out var scopeSpans)) { - spans = new ScopeSpans + scopeSpans = new ScopeSpans { Scope = new InstrumentationScope { - Name = name, // Name is enforced to not be null, but it can be empty. - Version = version ?? string.Empty, // NRE throw by proto + Name = activitySource.Name, // Name is enforced to not be null, but it can be empty. + Version = activitySource.Version ?? string.Empty, // NRE throw by proto }, }; } else { - spans.Scope.Name = name; - spans.Scope.Version = version ?? string.Empty; + scopeSpans.Scope.Name = activitySource.Name; // Name is enforced to not be null, but it can be empty. + scopeSpans.Scope.Version = activitySource.Version ?? string.Empty; // NRE throw by proto + } + + if (activitySource.Tags != null) + { + var scopeAttributes = scopeSpans.Scope.Attributes; + + if (activitySource.Tags is IReadOnlyList> activitySourceTagsList) + { + for (int i = 0; i < activitySourceTagsList.Count; i++) + { + if (scopeAttributes.Count < maxTags) + { + OtlpTagWriter.Instance.TryWriteTag(ref scopeAttributes, activitySourceTagsList[i], attributeValueLengthLimit); + } + else + { + scopeSpans.Scope.DroppedAttributesCount++; + } + } + } + else + { + foreach (var tag in activitySource.Tags) + { + if (scopeAttributes.Count < maxTags) + { + OtlpTagWriter.Instance.TryWriteTag(ref scopeAttributes, tag, attributeValueLengthLimit); + } + else + { + scopeSpans.Scope.DroppedAttributesCount++; + } + } + } } - return spans; + return scopeSpans; } [MethodImpl(MethodImplOptions.AggressiveInlining)] diff --git a/test/OpenTelemetry.Exporter.OpenTelemetryProtocol.Tests/MockCollectorIntegrationTests.cs b/test/OpenTelemetry.Exporter.OpenTelemetryProtocol.Tests/MockCollectorIntegrationTests.cs index e72a6773b8..27b64e08f6 100644 --- a/test/OpenTelemetry.Exporter.OpenTelemetryProtocol.Tests/MockCollectorIntegrationTests.cs +++ b/test/OpenTelemetry.Exporter.OpenTelemetryProtocol.Tests/MockCollectorIntegrationTests.cs @@ -75,7 +75,7 @@ public async Task TestRecoveryAfterFailedExport() await httpClient.GetAsync($"/MockCollector/SetResponseCodes/{string.Join(",", codes.Select(x => (int)x))}"); var exportResults = new List(); - var otlpExporter = new OtlpTraceExporter(new OtlpExporterOptions() { Endpoint = new Uri($"http://localhost:{testGrpcPort}") }); + using var otlpExporter = new OtlpTraceExporter(new OtlpExporterOptions() { Endpoint = new Uri($"http://localhost:{testGrpcPort}") }); var delegatingExporter = new DelegatingExporter { OnExportFunc = (batch) => @@ -183,7 +183,7 @@ public async Task GrpcRetryTests(bool useRetryTransmissionHandler, ExportResult .AddInMemoryCollection(new Dictionary { [ExperimentalOptions.OtlpRetryEnvVar] = useRetryTransmissionHandler ? "in_memory" : null }) .Build(); - var otlpExporter = new OtlpTraceExporter(exporterOptions, new SdkLimitOptions(), new ExperimentalOptions(configuration)); + using var otlpExporter = new OtlpTraceExporter(exporterOptions, new SdkLimitOptions(), new ExperimentalOptions(configuration)); var activitySourceName = "otel.grpc.retry.test"; using var source = new ActivitySource(activitySourceName); @@ -268,7 +268,7 @@ public async Task HttpRetryTests(bool useRetryTransmissionHandler, ExportResult .AddInMemoryCollection(new Dictionary { [ExperimentalOptions.OtlpRetryEnvVar] = useRetryTransmissionHandler ? "in_memory" : null }) .Build(); - var otlpExporter = new OtlpTraceExporter(exporterOptions, new SdkLimitOptions(), new ExperimentalOptions(configuration)); + using var otlpExporter = new OtlpTraceExporter(exporterOptions, new SdkLimitOptions(), new ExperimentalOptions(configuration)); var activitySourceName = "otel.http.retry.test"; using var source = new ActivitySource(activitySourceName); @@ -371,7 +371,7 @@ public async Task HttpPersistentStorageRetryTests(bool usePersistentStorageTrans transmissionHandler = new OtlpExporterTransmissionHandler(exportClient, exporterOptions.TimeoutMilliseconds); } - var otlpExporter = new OtlpTraceExporter(exporterOptions, new(), new(), transmissionHandler); + using var otlpExporter = new OtlpTraceExporter(exporterOptions, new(), new(), transmissionHandler); var activitySourceName = "otel.http.persistent.storage.retry.test"; using var source = new ActivitySource(activitySourceName); @@ -510,7 +510,7 @@ public async Task GrpcPersistentStorageRetryTests(bool usePersistentStorageTrans transmissionHandler = new OtlpExporterTransmissionHandler(exportClient, exporterOptions.TimeoutMilliseconds); } - var otlpExporter = new OtlpTraceExporter(exporterOptions, new(), new(), transmissionHandler); + using var otlpExporter = new OtlpTraceExporter(exporterOptions, new(), new(), transmissionHandler); var activitySourceName = "otel.grpc.persistent.storage.retry.test"; using var source = new ActivitySource(activitySourceName); diff --git a/test/OpenTelemetry.Exporter.OpenTelemetryProtocol.Tests/OtlpTraceExporterTests.cs b/test/OpenTelemetry.Exporter.OpenTelemetryProtocol.Tests/OtlpTraceExporterTests.cs index 9f5da4cd8d..bb76069214 100644 --- a/test/OpenTelemetry.Exporter.OpenTelemetryProtocol.Tests/OtlpTraceExporterTests.cs +++ b/test/OpenTelemetry.Exporter.OpenTelemetryProtocol.Tests/OtlpTraceExporterTests.cs @@ -221,6 +221,155 @@ void RunTest(SdkLimitOptions sdkOptions, Batch batch) } } + [Fact] + public void ScopeAttributesRemainConsistentAcrossMultipleBatches() + { + var activitySourceTags = new TagList + { + new("k0", "v0"), + }; + + using var activitySourceWithTags = new ActivitySource($"{nameof(this.ScopeAttributesRemainConsistentAcrossMultipleBatches)}_WithTags", "1.1.1.3", activitySourceTags); + using var activitySourceWithoutTags = new ActivitySource($"{nameof(this.ScopeAttributesRemainConsistentAcrossMultipleBatches)}_WithoutTags", "1.1.1.4"); + + var resourceBuilder = ResourceBuilder.CreateDefault(); + + var exportedItems = new List(); + var builder = Sdk.CreateTracerProviderBuilder() + .SetResourceBuilder(resourceBuilder) + .AddSource(activitySourceWithTags.Name) + .AddSource(activitySourceWithoutTags.Name) + .AddProcessor(new SimpleActivityExportProcessor(new InMemoryExporter(exportedItems))); + + using var openTelemetrySdk = builder.Build(); + + var parentActivity = activitySourceWithTags.StartActivity("parent", ActivityKind.Server, default(ActivityContext)); + var nestedChildActivity = activitySourceWithTags.StartActivity("nested-child", ActivityKind.Client); + parentActivity?.Dispose(); + nestedChildActivity?.Dispose(); + + Assert.Equal(2, exportedItems.Count); + var batch = new Batch(exportedItems.ToArray(), exportedItems.Count); + RunTest(DefaultSdkLimitOptions, batch, activitySourceWithTags); + + exportedItems.Clear(); + + var parentActivityNoTags = activitySourceWithoutTags.StartActivity("parent", ActivityKind.Server, default(ActivityContext)); + parentActivityNoTags?.Dispose(); + + Assert.Single(exportedItems); + batch = new Batch(exportedItems.ToArray(), exportedItems.Count); + RunTest(DefaultSdkLimitOptions, batch, activitySourceWithoutTags); + + void RunTest(SdkLimitOptions sdkOptions, Batch batch, ActivitySource activitySource) + { + var request = new OtlpCollector.ExportTraceServiceRequest(); + + request.AddBatch(sdkOptions, resourceBuilder.Build().ToOtlpResource(), batch); + + var resourceSpans = request.ResourceSpans.First(); + Assert.NotNull(request.ResourceSpans.First()); + + var scopeSpans = resourceSpans.ScopeSpans.First(); + Assert.NotNull(scopeSpans); + + var scope = scopeSpans.Scope; + Assert.NotNull(scope); + + Assert.Equal(activitySource.Name, scope.Name); + Assert.Equal(activitySource.Version, scope.Version); + Assert.Equal(activitySource.Tags?.Count() ?? 0, scope.Attributes.Count); + + foreach (var tag in activitySource.Tags ?? []) + { + Assert.Contains(scope.Attributes, (kvp) => kvp.Key == tag.Key && kvp.Value.StringValue == (string?)tag.Value); + } + + // Return and re-add batch to simulate reuse + request.Return(); + request.AddBatch(DefaultSdkLimitOptions, ResourceBuilder.CreateDefault().Build().ToOtlpResource(), batch); + + resourceSpans = request.ResourceSpans.First(); + scopeSpans = resourceSpans.ScopeSpans.First(); + scope = scopeSpans.Scope; + + Assert.Equal(activitySource.Name, scope.Name); + Assert.Equal(activitySource.Version, scope.Version); + Assert.Equal(activitySource.Tags?.Count() ?? 0, scope.Attributes.Count); + + foreach (var tag in activitySource.Tags ?? []) + { + Assert.Contains(scope.Attributes, (kvp) => kvp.Key == tag.Key && kvp.Value.StringValue == (string?)tag.Value); + } + + // Return and re-add batch to simulate reuse + request.Return(); + } + } + + [Fact] + public void ScopeAttributesLimitsTest() + { + var sdkOptions = new SdkLimitOptions() + { + AttributeValueLengthLimit = 4, + AttributeCountLimit = 3, + }; + + // ActivitySource Tags are sorted in .NET. + var activitySourceTags = new TagList + { + new("1_TruncatedSourceTag", "12345"), + new("2_TruncatedSourceStringArray", new string?[] { "12345", "1234", string.Empty, null }), + new("3_TruncatedSourceObjectTag", new object()), + new("4_OneSourceTagTooMany", 1), + }; + + var resourceBuilder = ResourceBuilder.CreateDefault(); + + using var activitySource = new ActivitySource(name: nameof(this.ScopeAttributesLimitsTest), tags: activitySourceTags); + + var exportedItems = new List(); + var builder = Sdk.CreateTracerProviderBuilder() + .SetResourceBuilder(resourceBuilder) + .AddSource(activitySource.Name) + .AddProcessor(new SimpleActivityExportProcessor(new InMemoryExporter(exportedItems))); + + using var openTelemetrySdk = builder.Build(); + + var activity = activitySource.StartActivity("parent", ActivityKind.Server, default(ActivityContext)); + activity?.Dispose(); + + Assert.Single(exportedItems); + var batch = new Batch(exportedItems.ToArray(), exportedItems.Count); + RunTest(sdkOptions, batch); + + void RunTest(SdkLimitOptions sdkOptions, Batch batch) + { + var request = new OtlpCollector.ExportTraceServiceRequest(); + + request.AddBatch(sdkOptions, resourceBuilder.Build().ToOtlpResource(), batch); + + var resourceSpans = request.ResourceSpans.First(); + Assert.NotNull(request.ResourceSpans.First()); + + var scopeSpans = resourceSpans.ScopeSpans.First(); + Assert.NotNull(scopeSpans); + + var scope = scopeSpans.Scope; + Assert.NotNull(scope); + + Assert.Equal(3, scope.Attributes.Count); + Assert.Equal(1u, scope.DroppedAttributesCount); + Assert.Equal("1234", scope.Attributes[0].Value.StringValue); + this.ArrayValueAsserts(scope.Attributes[1].Value.ArrayValue.Values); + Assert.Equal(new object().ToString()!.Substring(0, 4), scope.Attributes[2].Value.StringValue); + + // Return and re-add batch to simulate reuse + request.Return(); + } + } + [Fact] public void SpanLimitsTest() { @@ -263,7 +412,7 @@ public void SpanLimitsTest() Assert.Equal(3, otlpSpan.Attributes.Count); Assert.Equal(1u, otlpSpan.DroppedAttributesCount); Assert.Equal("1234", otlpSpan.Attributes[0].Value.StringValue); - ArrayValueAsserts(otlpSpan.Attributes[1].Value.ArrayValue.Values); + this.ArrayValueAsserts(otlpSpan.Attributes[1].Value.ArrayValue.Values); Assert.Equal(new object().ToString()!.Substring(0, 4), otlpSpan.Attributes[2].Value.StringValue); Assert.Single(otlpSpan.Events); @@ -271,7 +420,7 @@ public void SpanLimitsTest() Assert.Equal(3, otlpSpan.Events[0].Attributes.Count); Assert.Equal(1u, otlpSpan.Events[0].DroppedAttributesCount); Assert.Equal("1234", otlpSpan.Events[0].Attributes[0].Value.StringValue); - ArrayValueAsserts(otlpSpan.Events[0].Attributes[1].Value.ArrayValue.Values); + this.ArrayValueAsserts(otlpSpan.Events[0].Attributes[1].Value.ArrayValue.Values); Assert.Equal(new object().ToString()!.Substring(0, 4), otlpSpan.Events[0].Attributes[2].Value.StringValue); Assert.Single(otlpSpan.Links); @@ -279,27 +428,8 @@ public void SpanLimitsTest() Assert.Equal(3, otlpSpan.Links[0].Attributes.Count); Assert.Equal(1u, otlpSpan.Links[0].DroppedAttributesCount); Assert.Equal("1234", otlpSpan.Links[0].Attributes[0].Value.StringValue); - ArrayValueAsserts(otlpSpan.Links[0].Attributes[1].Value.ArrayValue.Values); + this.ArrayValueAsserts(otlpSpan.Links[0].Attributes[1].Value.ArrayValue.Values); Assert.Equal(new object().ToString()!.Substring(0, 4), otlpSpan.Links[0].Attributes[2].Value.StringValue); - - void ArrayValueAsserts(RepeatedField values) - { - var expectedStringArray = new string?[] { "1234", "1234", string.Empty, null }; - for (var i = 0; i < expectedStringArray.Length; ++i) - { - var expectedValue = expectedStringArray[i]; - var expectedValueCase = expectedValue != null - ? OtlpCommon.AnyValue.ValueOneofCase.StringValue - : OtlpCommon.AnyValue.ValueOneofCase.None; - - var actual = values[i]; - Assert.Equal(expectedValueCase, actual.ValueCase); - if (expectedValueCase != OtlpCommon.AnyValue.ValueOneofCase.None) - { - Assert.Equal(expectedValue, actual.StringValue); - } - } - } } [Fact] @@ -650,7 +780,7 @@ public void Shutdown_ClientShutdownIsCalled() var exporterOptions = new OtlpExporterOptions(); var transmissionHandler = new OtlpExporterTransmissionHandler(exportClientMock, exporterOptions.TimeoutMilliseconds); - var exporter = new OtlpTraceExporter(new OtlpExporterOptions(), DefaultSdkLimitOptions, DefaultExperimentalOptions, transmissionHandler); + using var exporter = new OtlpTraceExporter(new OtlpExporterOptions(), DefaultSdkLimitOptions, DefaultExperimentalOptions, transmissionHandler); exporter.Shutdown(); Assert.True(exportClientMock.ShutdownCalled); @@ -859,4 +989,23 @@ public void SpanLinkFlagsTest(bool isRecorded, bool isRemote) Assert.False(flags.HasFlag(OtlpTrace.SpanFlags.ContextIsRemoteMask)); } } + + private void ArrayValueAsserts(RepeatedField values) + { + var expectedStringArray = new string?[] { "1234", "1234", string.Empty, null }; + for (var i = 0; i < expectedStringArray.Length; ++i) + { + var expectedValue = expectedStringArray[i]; + var expectedValueCase = expectedValue != null + ? OtlpCommon.AnyValue.ValueOneofCase.StringValue + : OtlpCommon.AnyValue.ValueOneofCase.None; + + var actual = values[i]; + Assert.Equal(expectedValueCase, actual.ValueCase); + if (expectedValueCase != OtlpCommon.AnyValue.ValueOneofCase.None) + { + Assert.Equal(expectedValue, actual.StringValue); + } + } + } }