From aade70f2f763a0a7319eeb0f1226753b61f6b4f4 Mon Sep 17 00:00:00 2001 From: James Crosswell Date: Mon, 16 Jun 2025 14:49:39 +1200 Subject: [PATCH 1/4] Ensure HTTP Response Status Code is searchable when using OTel Resolves #4094: - https://github.com/getsentry/sentry-dotnet/issues/4094 --- .../OpenTelemetryExtensions.cs | 28 +++++-- .../SentrySpanProcessor.cs | 8 ++ .../SentrySpanProcessorTests.cs | 73 +++++++++++++++++++ 3 files changed, 102 insertions(+), 7 deletions(-) diff --git a/src/Sentry.OpenTelemetry/OpenTelemetryExtensions.cs b/src/Sentry.OpenTelemetry/OpenTelemetryExtensions.cs index c269710a4c..26970194ac 100644 --- a/src/Sentry.OpenTelemetry/OpenTelemetryExtensions.cs +++ b/src/Sentry.OpenTelemetry/OpenTelemetryExtensions.cs @@ -7,18 +7,21 @@ internal static class OpenTelemetryExtensions { public static SpanId AsSentrySpanId(this ActivitySpanId id) => SpanId.Parse(id.ToHexString()); - public static ActivitySpanId AsActivitySpanId(this SpanId id) => ActivitySpanId.CreateFromString(id.ToString().AsSpan()); + public static ActivitySpanId AsActivitySpanId(this SpanId id) => + ActivitySpanId.CreateFromString(id.ToString().AsSpan()); public static SentryId AsSentryId(this ActivityTraceId id) => SentryId.Parse(id.ToHexString()); - public static ActivityTraceId AsActivityTraceId(this SentryId id) => ActivityTraceId.CreateFromString(id.ToString().AsSpan()); + public static ActivityTraceId AsActivityTraceId(this SentryId id) => + ActivityTraceId.CreateFromString(id.ToString().AsSpan()); - public static BaggageHeader AsBaggageHeader(this IEnumerable> baggage, bool useSentryPrefix = false) => + public static BaggageHeader AsBaggageHeader(this IEnumerable> baggage, + bool useSentryPrefix = false) => BaggageHeader.Create( baggage.Where(member => member.Value != null) - .Select(kvp => (KeyValuePair)kvp!), + .Select(kvp => (KeyValuePair)kvp!), useSentryPrefix - ); + ); /// /// The names that OpenTelemetry gives to attributes, by convention, have changed over time so we often need to @@ -40,6 +43,7 @@ public static BaggageHeader AsBaggageHeader(this IEnumerable( OtelSemanticConventions.AttributeHttpRequestMethod, OtelSemanticConventions.AttributeHttpMethod // Fallback pre-1.5.0 - ); + ); public static string? UrlFullAttribute(this IDictionary attributes) => attributes.GetFirstMatchingAttribute( OtelSemanticConventions.AttributeUrlFull, OtelSemanticConventions.AttributeHttpUrl // Fallback pre-1.5.0 - ); + ); + + public static short? HttpResponseStatusCodeAttribute(this IDictionary attributes) + { + var statusCode = attributes.GetFirstMatchingAttribute( + OtelSemanticConventions.AttributeHttpResponseStatusCode + ); + return statusCode is >= short.MinValue and <= short.MaxValue + ? (short)statusCode.Value + : null; + } } diff --git a/src/Sentry.OpenTelemetry/SentrySpanProcessor.cs b/src/Sentry.OpenTelemetry/SentrySpanProcessor.cs index eba966138a..0945cb921b 100644 --- a/src/Sentry.OpenTelemetry/SentrySpanProcessor.cs +++ b/src/Sentry.OpenTelemetry/SentrySpanProcessor.cs @@ -230,10 +230,13 @@ public override void OnEnd(Activity data) span.Operation = operation; span.Description = description; + // Handle HTTP response status code specially + var statusCode = attributes.HttpResponseStatusCodeAttribute(); if (span is TransactionTracer transaction) { transaction.Name = description; transaction.NameSource = source; + transaction.Contexts.Response.StatusCode = statusCode; // Use the end timestamp from the activity data. transaction.EndTimestamp = data.StartTimeUtc + data.Duration; @@ -250,6 +253,11 @@ public override void OnEnd(Activity data) // Resource attributes do not need to be set, as they would be identical as those set on the transaction. spanTracer.SetExtras(attributes); spanTracer.SetExtra("otel.kind", data.Kind); + if (statusCode is { } responseStatusCode) + { + // Set this as a tag so that it's searchable in Sentry + span.SetTag("HTTP Response Status Code", responseStatusCode.ToString()); + } } // In ASP.NET Core the middleware finishes up (and the scope gets popped) before the activity is ended. So we diff --git a/test/Sentry.OpenTelemetry.Tests/SentrySpanProcessorTests.cs b/test/Sentry.OpenTelemetry.Tests/SentrySpanProcessorTests.cs index 15ad64b8e0..45b1122dd0 100644 --- a/test/Sentry.OpenTelemetry.Tests/SentrySpanProcessorTests.cs +++ b/test/Sentry.OpenTelemetry.Tests/SentrySpanProcessorTests.cs @@ -429,6 +429,79 @@ public void OnEnd_Unsampled_Span_DoesNotThrow() // UnsampledSpan.Finish() is basically a no-op. } + [Fact] + public void OnEnd_Transaction_SetsResponseStatusCode() + { + // Arrange + _fixture.Options.Instrumenter = Instrumenter.OpenTelemetry; + var sut = _fixture.GetSut(); + + var tags = new Dictionary { + { OtelSemanticConventions.AttributeHttpResponseStatusCode, 404 } + }; + var data = Tracer.StartActivity( + name: "test operation", + kind: ActivityKind.Server, + parentContext: default, + tags + ); + sut.OnStart(data); + + sut._map.TryGetValue(data.SpanId, out var span); + + // Act + sut.OnEnd(data); + + // Assert + if (span is not TransactionTracer transaction) { + Assert.Fail("Span is not a transaction tracer"); + return; + } + + using (new AssertionScope()) { + transaction.Contexts.Response.StatusCode.Should().Be(404); + } + } + + [Fact] + public void OnEnd_Span_SetsResponseStatusCode() + { + // Arrange + _fixture.Options.Instrumenter = Instrumenter.OpenTelemetry; + var sut = _fixture.GetSut(); + + var parent = Tracer.StartActivity(name: "transaction")!; + sut.OnStart(parent); + + var tags = new Dictionary { + { OtelSemanticConventions.AttributeHttpResponseStatusCode, 404 } + }; + var data = Tracer.StartActivity( + name: "test operation", + kind: ActivityKind.Server, + parentContext: default, + tags + ); + sut.OnStart(data); + + sut._map.TryGetValue(data.SpanId, out var span); + + // Act + sut.OnEnd(data); + + // Assert + if (span is not SpanTracer spanTracer) { + Assert.Fail("Span is not a transaction tracer"); + return; + } + + using (new AssertionScope()) { + spanTracer.Tags.TryGetValue("HTTP Response Status Code", out var responseStatusCode) + .Should().BeTrue(); + responseStatusCode.Should().Be("404"); + } + } + [Fact] public void OnEnd_Transaction_RestoresSavedScope() { From 322a1612d3efdc8c247dcf9206aacc7de2d2b046 Mon Sep 17 00:00:00 2001 From: James Crosswell Date: Mon, 16 Jun 2025 15:00:55 +1200 Subject: [PATCH 2/4] Update CHANGELOG.md --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 12ae447ec3..c80c303332 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ - Added non-allocating `ConfigureScope` and `ConfigureScopeAsync` overloads ([#4244](https://github.com/getsentry/sentry-dotnet/pull/4244)) - Add .NET MAUI `AutomationId` element information to breadcrumbs ([#4248](https://github.com/getsentry/sentry-dotnet/pull/4248)) +- The HTTP Response Status Code for spans instrumented using OpenTelemetry is now searchable ([#4283](https://github.com/getsentry/sentry-dotnet/pull/4283)) ### Fixes From 5b2158f325fb28e1e469d2468f6498120431fc83 Mon Sep 17 00:00:00 2001 From: Sentry Github Bot Date: Mon, 16 Jun 2025 03:10:43 +0000 Subject: [PATCH 3/4] Format code --- .../SentrySpanProcessorTests.cs | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/test/Sentry.OpenTelemetry.Tests/SentrySpanProcessorTests.cs b/test/Sentry.OpenTelemetry.Tests/SentrySpanProcessorTests.cs index 45b1122dd0..5675bbea0c 100644 --- a/test/Sentry.OpenTelemetry.Tests/SentrySpanProcessorTests.cs +++ b/test/Sentry.OpenTelemetry.Tests/SentrySpanProcessorTests.cs @@ -453,12 +453,14 @@ public void OnEnd_Transaction_SetsResponseStatusCode() sut.OnEnd(data); // Assert - if (span is not TransactionTracer transaction) { + if (span is not TransactionTracer transaction) + { Assert.Fail("Span is not a transaction tracer"); return; } - using (new AssertionScope()) { + using (new AssertionScope()) + { transaction.Contexts.Response.StatusCode.Should().Be(404); } } @@ -490,12 +492,14 @@ public void OnEnd_Span_SetsResponseStatusCode() sut.OnEnd(data); // Assert - if (span is not SpanTracer spanTracer) { + if (span is not SpanTracer spanTracer) + { Assert.Fail("Span is not a transaction tracer"); return; } - using (new AssertionScope()) { + using (new AssertionScope()) + { spanTracer.Tags.TryGetValue("HTTP Response Status Code", out var responseStatusCode) .Should().BeTrue(); responseStatusCode.Should().Be("404"); From 62a02b9e0a96744055f84cd6f516e09d00e4f597 Mon Sep 17 00:00:00 2001 From: James Crosswell Date: Tue, 17 Jun 2025 10:47:02 +1200 Subject: [PATCH 4/4] Review feedback --- .../SentrySpanProcessor.cs | 7 +++- .../SentrySpanProcessorTests.cs | 40 +++++++++++++++++-- 2 files changed, 42 insertions(+), 5 deletions(-) diff --git a/src/Sentry.OpenTelemetry/SentrySpanProcessor.cs b/src/Sentry.OpenTelemetry/SentrySpanProcessor.cs index 0945cb921b..5aa9758c47 100644 --- a/src/Sentry.OpenTelemetry/SentrySpanProcessor.cs +++ b/src/Sentry.OpenTelemetry/SentrySpanProcessor.cs @@ -236,7 +236,10 @@ public override void OnEnd(Activity data) { transaction.Name = description; transaction.NameSource = source; - transaction.Contexts.Response.StatusCode = statusCode; + if (statusCode is { } responseStatusCode) + { + transaction.Contexts.Response.StatusCode = responseStatusCode; + } // Use the end timestamp from the activity data. transaction.EndTimestamp = data.StartTimeUtc + data.Duration; @@ -256,7 +259,7 @@ public override void OnEnd(Activity data) if (statusCode is { } responseStatusCode) { // Set this as a tag so that it's searchable in Sentry - span.SetTag("HTTP Response Status Code", responseStatusCode.ToString()); + span.SetTag(OtelSemanticConventions.AttributeHttpResponseStatusCode, responseStatusCode.ToString()); } } diff --git a/test/Sentry.OpenTelemetry.Tests/SentrySpanProcessorTests.cs b/test/Sentry.OpenTelemetry.Tests/SentrySpanProcessorTests.cs index 5675bbea0c..360d3a6eed 100644 --- a/test/Sentry.OpenTelemetry.Tests/SentrySpanProcessorTests.cs +++ b/test/Sentry.OpenTelemetry.Tests/SentrySpanProcessorTests.cs @@ -465,6 +465,40 @@ public void OnEnd_Transaction_SetsResponseStatusCode() } } + [Fact] + public void OnEnd_Transaction_DoesNotClearResponseStatusCode() + { + // Arrange + _fixture.Options.Instrumenter = Instrumenter.OpenTelemetry; + var sut = _fixture.GetSut(); + + var data = Tracer.StartActivity( + name: "test operation", + kind: ActivityKind.Server, + parentContext: default, + new Dictionary() + ); + sut.OnStart(data); + + sut._map.TryGetValue(data.SpanId, out var span); + (span as TransactionTracer)!.Contexts.Response.StatusCode = 200; + + // Act + sut.OnEnd(data); + + // Assert + if (span is not TransactionTracer transaction) + { + Assert.Fail("Span is not a transaction tracer"); + return; + } + + using (new AssertionScope()) + { + transaction.Contexts.Response.StatusCode.Should().Be(200); + } + } + [Fact] public void OnEnd_Span_SetsResponseStatusCode() { @@ -494,14 +528,14 @@ public void OnEnd_Span_SetsResponseStatusCode() // Assert if (span is not SpanTracer spanTracer) { - Assert.Fail("Span is not a transaction tracer"); + Assert.Fail("Span is not a span tracer"); return; } using (new AssertionScope()) { - spanTracer.Tags.TryGetValue("HTTP Response Status Code", out var responseStatusCode) - .Should().BeTrue(); + spanTracer.Tags.TryGetValue(OtelSemanticConventions.AttributeHttpResponseStatusCode, + out var responseStatusCode).Should().BeTrue(); responseStatusCode.Should().Be("404"); } }