From 9735bfc0e9d0ca64dd632dd86b4e8aa827d4b37b Mon Sep 17 00:00:00 2001 From: hangy Date: Sat, 5 Apr 2025 15:24:05 +0200 Subject: [PATCH 01/21] feat: Send W3C traceparent using `SentryMessageHandler` --- src/Sentry/SentryMessageHandler.cs | 11 +++++ src/Sentry/SentryTraceHeaderExtensions.cs | 40 +++++++++++++++++++ .../SentryHttpMessageHandlerTests.cs | 1 + .../SentryTraceHeaderExtensionsTests.cs | 32 +++++++++++++++ 4 files changed, 84 insertions(+) create mode 100644 src/Sentry/SentryTraceHeaderExtensions.cs create mode 100644 test/Sentry.Tests/SentryTraceHeaderExtensionsTests.cs diff --git a/src/Sentry/SentryMessageHandler.cs b/src/Sentry/SentryMessageHandler.cs index 8784ef1822..8a58d8117e 100644 --- a/src/Sentry/SentryMessageHandler.cs +++ b/src/Sentry/SentryMessageHandler.cs @@ -136,6 +136,7 @@ private void PropagateTraceHeaders(HttpRequestMessage request, string url) if (_options?.TracePropagationTargets.MatchesSubstringOrRegex(url) is true or null) { AddSentryTraceHeader(request); + AddW3CTraceHeader(request); AddBaggageHeader(request); } } @@ -149,6 +150,16 @@ private void AddSentryTraceHeader(HttpRequestMessage request) } } + private void AddW3CTraceHeader(HttpRequestMessage request) + { + // Set trace header if it hasn't already been set + const string w3cHeaderName = "traceparent"; + if (!request.Headers.Contains(w3cHeaderName) && _hub.GetTraceHeader() is { } traceHeader) + { + request.Headers.Add(w3cHeaderName, traceHeader.AsW3CTraceContext()); + } + } + private void AddBaggageHeader(HttpRequestMessage request) { var baggage = _hub.GetBaggage(); diff --git a/src/Sentry/SentryTraceHeaderExtensions.cs b/src/Sentry/SentryTraceHeaderExtensions.cs new file mode 100644 index 0000000000..3b7289b89e --- /dev/null +++ b/src/Sentry/SentryTraceHeaderExtensions.cs @@ -0,0 +1,40 @@ +namespace Sentry; + +/// +/// Extension methods for working with Sentry trace headers. +/// +public static class SentryTraceHeaderExtensions +{ + /// + /// Converts the Sentry trace header to W3C trace context format. + /// + /// The Sentry trace header to convert. + /// A string representation of the trace header in W3C format. + /// Thrown if is null. + public static string AsW3CTraceContext(this SentryTraceHeader traceHeader) + { + if (traceHeader is null) + { + throw new ArgumentNullException(nameof(traceHeader)); + } + + const string version = "00"; + var traceFlags = ConvertSampledToTraceFlags(traceHeader.IsSampled); + if (traceFlags is null) + { + return $"{version}-{traceHeader.TraceId}-{traceHeader.SpanId}"; + } + + return $"{version}-{traceHeader.TraceId}-{traceHeader.SpanId}-{traceFlags}"; + } + + private static string? ConvertSampledToTraceFlags(bool? isSampled) + { + return isSampled switch + { + true => "01", + false => "00", + null => null + }; + } +} diff --git a/test/Sentry.Tests/SentryHttpMessageHandlerTests.cs b/test/Sentry.Tests/SentryHttpMessageHandlerTests.cs index 7cc372f5b9..268aa11c42 100644 --- a/test/Sentry.Tests/SentryHttpMessageHandlerTests.cs +++ b/test/Sentry.Tests/SentryHttpMessageHandlerTests.cs @@ -5,6 +5,7 @@ namespace Sentry.Tests; /* * NOTE: All tests should be done for both asynchronous `SendAsync` and synchronous `Send` methods. * TODO: Find a way to consolidate these tests cleanly. + * TODO @hangy: Add test for W3C trace context propagation. */ public class SentryHttpMessageHandlerTests diff --git a/test/Sentry.Tests/SentryTraceHeaderExtensionsTests.cs b/test/Sentry.Tests/SentryTraceHeaderExtensionsTests.cs new file mode 100644 index 0000000000..50ddc409d9 --- /dev/null +++ b/test/Sentry.Tests/SentryTraceHeaderExtensionsTests.cs @@ -0,0 +1,32 @@ +namespace Sentry.Tests; + +public class SentryTraceHeaderExtensionsTests +{ + [Theory] + [InlineData(true, "01")] + [InlineData(false, "00")] + public void AsW3CTraceContext_WithSampled_ConvertsToW3CFormat(bool isSampled, string traceFlags) + { + // Arrange + var traceHeader = new SentryTraceHeader(SentryId.Parse("75302ac48a024bde9a3b3734a82e36c8"), SpanId.Parse("1000000000000000"), isSampled); + + // Act + var result = traceHeader.AsW3CTraceContext(); + + // Assert + result.Should().Be($"00-75302ac48a024bde9a3b3734a82e36c8-1000000000000000-{traceFlags}"); + } + + [Fact] + public void AsW3CTraceContext_WithoutSampled_ConvertsToW3CFormat() + { + // Arrange + var traceHeader = new SentryTraceHeader(SentryId.Parse("75302ac48a024bde9a3b3734a82e36c8"), SpanId.Parse("1000000000000000"), nuint); + + // Act + var result = traceHeader.AsW3CTraceContext(); + + // Assert + result.Should().Be("00-75302ac48a024bde9a3b3734a82e36c8-1000000000000000"); + } +} From 0954802aef70e62f49ef5712588791aa65f849e5 Mon Sep 17 00:00:00 2001 From: hangy Date: Sat, 5 Apr 2025 17:12:03 +0200 Subject: [PATCH 02/21] docs: Update documentation to include 'traceparent' in HTTP header propagation --- src/Sentry/SentryOptions.cs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/Sentry/SentryOptions.cs b/src/Sentry/SentryOptions.cs index 69a87685a7..e70f16e70b 100644 --- a/src/Sentry/SentryOptions.cs +++ b/src/Sentry/SentryOptions.cs @@ -952,7 +952,8 @@ public double? ProfilesSampleRate /// /// A customizable list of objects, each containing either a /// substring or regular expression pattern that can be used to control which outgoing HTTP requests - /// will have the sentry-trace and baggage headers propagated, for purposes of distributed tracing. + /// will have the sentry-trace, traceparent, and baggage headers propagated, + /// for purposes of distributed tracing. /// The default value contains a single value of .*, which matches everything. /// To disable propagation completely, clear this collection or set it to an empty collection. /// From d05d9c997243fe56a424111d7ba4c50aadc5b188 Mon Sep 17 00:00:00 2001 From: hangy Date: Sat, 5 Apr 2025 17:26:20 +0200 Subject: [PATCH 03/21] test: Refactor Sentry trace header tests to use parameterized inputs --- .../SentryHttpMessageHandlerTests.cs | 91 +++++++++++-------- 1 file changed, 55 insertions(+), 36 deletions(-) diff --git a/test/Sentry.Tests/SentryHttpMessageHandlerTests.cs b/test/Sentry.Tests/SentryHttpMessageHandlerTests.cs index 268aa11c42..00dc452cb1 100644 --- a/test/Sentry.Tests/SentryHttpMessageHandlerTests.cs +++ b/test/Sentry.Tests/SentryHttpMessageHandlerTests.cs @@ -5,19 +5,21 @@ namespace Sentry.Tests; /* * NOTE: All tests should be done for both asynchronous `SendAsync` and synchronous `Send` methods. * TODO: Find a way to consolidate these tests cleanly. - * TODO @hangy: Add test for W3C trace context propagation. - */ + */ public class SentryHttpMessageHandlerTests { - [Fact] - public async Task SendAsync_SentryTraceHeaderNotSet_SetsHeader_ByDefault() + [Theory] + [InlineData("75302ac48a024bde9a3b3734a82e36c8-1000000000000000-0", "sentry-trace", "75302ac48a024bde9a3b3734a82e36c8-1000000000000000-0")] + [InlineData("75302ac48a024bde9a3b3734a82e36c8-1000000000000000-0", "traceparent", "00-75302ac48a024bde9a3b3734a82e36c8-1000000000000000-00")] + [InlineData("75302ac48a024bde9a3b3734a82e36c8-1000000000000000-1", "traceparent", "00-75302ac48a024bde9a3b3734a82e36c8-1000000000000000-01")] + public async Task SendAsync_SentryTraceHeaderNotSet_SetsHeader_ByDefault(string traceHeader, string headerName, string expectedValue) { // Arrange var hub = Substitute.For(); hub.GetTraceHeader().ReturnsForAnyArgs( - SentryTraceHeader.Parse("75302ac48a024bde9a3b3734a82e36c8-1000000000000000-0")); + SentryTraceHeader.Parse(traceHeader)); using var innerHandler = new RecordingHttpMessageHandler(new FakeHttpMessageHandler()); using var sentryHandler = new SentryHttpMessageHandler(innerHandler, hub); @@ -30,12 +32,15 @@ public async Task SendAsync_SentryTraceHeaderNotSet_SetsHeader_ByDefault() // Assert request.Headers.Should().Contain(h => - h.Key == "sentry-trace" && - string.Concat(h.Value) == "75302ac48a024bde9a3b3734a82e36c8-1000000000000000-0"); + h.Key == headerName && + string.Concat(h.Value) == expectedValue); } - [Fact] - public async Task SendAsync_SentryTraceHeaderNotSet_SetsHeader_WhenUrlMatchesPropagationOptions() + [Theory] + [InlineData("75302ac48a024bde9a3b3734a82e36c8-1000000000000000-0", "sentry-trace", "75302ac48a024bde9a3b3734a82e36c8-1000000000000000-0")] + [InlineData("75302ac48a024bde9a3b3734a82e36c8-1000000000000000-0", "traceparent", "00-75302ac48a024bde9a3b3734a82e36c8-1000000000000000-00")] + [InlineData("75302ac48a024bde9a3b3734a82e36c8-1000000000000000-1", "traceparent", "00-75302ac48a024bde9a3b3734a82e36c8-1000000000000000-01")] + public async Task SendAsync_SentryTraceHeaderNotSet_SetsHeader_WhenUrlMatchesPropagationOptions(string traceHeader, string headerName, string expectedValue) { // Arrange var hub = Substitute.For(); @@ -49,7 +54,7 @@ public async Task SendAsync_SentryTraceHeaderNotSet_SetsHeader_WhenUrlMatchesPro }; hub.GetTraceHeader().ReturnsForAnyArgs( - SentryTraceHeader.Parse("75302ac48a024bde9a3b3734a82e36c8-1000000000000000-0")); + SentryTraceHeader.Parse(traceHeader)); using var innerHandler = new RecordingHttpMessageHandler(new FakeHttpMessageHandler()); using var sentryHandler = new SentryHttpMessageHandler(hub, options, innerHandler, failedRequestHandler); @@ -62,12 +67,14 @@ public async Task SendAsync_SentryTraceHeaderNotSet_SetsHeader_WhenUrlMatchesPro // Assert request.Headers.Should().Contain(h => - h.Key == "sentry-trace" && - string.Concat(h.Value) == "75302ac48a024bde9a3b3734a82e36c8-1000000000000000-0"); + h.Key == headerName && + string.Concat(h.Value) == expectedValue); } - [Fact] - public async Task SendAsync_SentryTraceHeaderNotSet_DoesntSetHeader_WhenUrlDoesntMatchesPropagationOptions() + [Theory] + [InlineData("sentry-trace")] + [InlineData("traceparent")] + public async Task SendAsync_SentryTraceHeaderNotSet_DoesntSetHeader_WhenUrlDoesntMatchesPropagationOptions(string headerName) { // Arrange var hub = Substitute.For(); @@ -93,11 +100,13 @@ public async Task SendAsync_SentryTraceHeaderNotSet_DoesntSetHeader_WhenUrlDoesn using var request = innerHandler.GetRequests().Single(); // Assert - request.Headers.Should().NotContain(h => h.Key == "sentry-trace"); + request.Headers.Should().NotContain(h => h.Key == headerName); } - [Fact] - public async Task SendAsync_SentryTraceHeaderAlreadySet_NotOverwritten() + [Theory] + [InlineData("sentry-trace")] + [InlineData("traceparent")] + public async Task SendAsync_SentryTraceHeaderAlreadySet_NotOverwritten(string headerName) { // Arrange var hub = Substitute.For(); @@ -109,7 +118,7 @@ public async Task SendAsync_SentryTraceHeaderAlreadySet_NotOverwritten() using var sentryHandler = new SentryHttpMessageHandler(innerHandler, hub); using var client = new HttpClient(sentryHandler); - client.DefaultRequestHeaders.Add("sentry-trace", "foobar"); + client.DefaultRequestHeaders.Add(headerName, "foobar"); // Act await client.GetAsync("https://localhost/"); @@ -118,7 +127,7 @@ public async Task SendAsync_SentryTraceHeaderAlreadySet_NotOverwritten() // Assert request.Headers.Should().Contain(h => - h.Key == "sentry-trace" && + h.Key == headerName && string.Concat(h.Value) == "foobar"); } @@ -292,14 +301,17 @@ public void HandleResponse_SetsSpanData() } #if NET5_0_OR_GREATER - [Fact] - public void Send_SentryTraceHeaderNotSet_SetsHeader_ByDefault() + [Theory] + [InlineData("75302ac48a024bde9a3b3734a82e36c8-1000000000000000-0", "sentry-trace", "75302ac48a024bde9a3b3734a82e36c8-1000000000000000-0")] + [InlineData("75302ac48a024bde9a3b3734a82e36c8-1000000000000000-0", "traceparent", "00-75302ac48a024bde9a3b3734a82e36c8-1000000000000000-00")] + [InlineData("75302ac48a024bde9a3b3734a82e36c8-1000000000000000-1", "traceparent", "00-75302ac48a024bde9a3b3734a82e36c8-1000000000000000-01")] + public void Send_SentryTraceHeaderNotSet_SetsHeader_ByDefault(string traceHeader, string headerName, string expectedValue) { // Arrange var hub = Substitute.For(); hub.GetTraceHeader().ReturnsForAnyArgs( - SentryTraceHeader.Parse("75302ac48a024bde9a3b3734a82e36c8-1000000000000000-0")); + SentryTraceHeader.Parse(traceHeader)); using var innerHandler = new RecordingHttpMessageHandler(new FakeHttpMessageHandler()); using var sentryHandler = new SentryHttpMessageHandler(innerHandler, hub); @@ -312,12 +324,15 @@ public void Send_SentryTraceHeaderNotSet_SetsHeader_ByDefault() // Assert request.Headers.Should().Contain(h => - h.Key == "sentry-trace" && - string.Concat(h.Value) == "75302ac48a024bde9a3b3734a82e36c8-1000000000000000-0"); + h.Key == headerName && + string.Concat(h.Value) == expectedValue); } - [Fact] - public void Send_SentryTraceHeaderNotSet_SetsHeader_WhenUrlMatchesPropagationOptions() + [Theory] + [InlineData("75302ac48a024bde9a3b3734a82e36c8-1000000000000000-0", "sentry-trace", "75302ac48a024bde9a3b3734a82e36c8-1000000000000000-0")] + [InlineData("75302ac48a024bde9a3b3734a82e36c8-1000000000000000-0", "traceparent", "00-75302ac48a024bde9a3b3734a82e36c8-1000000000000000-00")] + [InlineData("75302ac48a024bde9a3b3734a82e36c8-1000000000000000-1", "traceparent", "00-75302ac48a024bde9a3b3734a82e36c8-1000000000000000-01")] + public void Send_SentryTraceHeaderNotSet_SetsHeader_WhenUrlMatchesPropagationOptions(string traceHeader, string headerName, string expectedValue) { // Arrange var hub = Substitute.For(); @@ -331,7 +346,7 @@ public void Send_SentryTraceHeaderNotSet_SetsHeader_WhenUrlMatchesPropagationOpt }; hub.GetTraceHeader().ReturnsForAnyArgs( - SentryTraceHeader.Parse("75302ac48a024bde9a3b3734a82e36c8-1000000000000000-0")); + SentryTraceHeader.Parse(traceHeader)); using var innerHandler = new RecordingHttpMessageHandler(new FakeHttpMessageHandler()); using var sentryHandler = new SentryHttpMessageHandler(hub, options, innerHandler, failedRequestHandler); @@ -344,12 +359,14 @@ public void Send_SentryTraceHeaderNotSet_SetsHeader_WhenUrlMatchesPropagationOpt // Assert request.Headers.Should().Contain(h => - h.Key == "sentry-trace" && - string.Concat(h.Value) == "75302ac48a024bde9a3b3734a82e36c8-1000000000000000-0"); + h.Key == headerName && + string.Concat(h.Value) == expectedValue); } - [Fact] - public void Send_SentryTraceHeaderNotSet_DoesntSetHeader_WhenUrlDoesntMatchesPropagationOptions() + [Theory] + [InlineData("sentry-trace")] + [InlineData("traceparent")] + public void Send_SentryTraceHeaderNotSet_DoesntSetHeader_WhenUrlDoesntMatchesPropagationOptions(string headerName) { // Arrange var hub = Substitute.For(); @@ -375,11 +392,13 @@ public void Send_SentryTraceHeaderNotSet_DoesntSetHeader_WhenUrlDoesntMatchesPro using var request = innerHandler.GetRequests().Single(); // Assert - request.Headers.Should().NotContain(h => h.Key == "sentry-trace"); + request.Headers.Should().NotContain(h => h.Key == headerName); } - [Fact] - public void Send_SentryTraceHeaderAlreadySet_NotOverwritten() + [Theory] + [InlineData("sentry-trace")] + [InlineData("traceparent")] + public void Send_SentryTraceHeaderAlreadySet_NotOverwritten(string headerName) { // Arrange var hub = Substitute.For(); @@ -391,7 +410,7 @@ public void Send_SentryTraceHeaderAlreadySet_NotOverwritten() using var sentryHandler = new SentryHttpMessageHandler(innerHandler, hub); using var client = new HttpClient(sentryHandler); - client.DefaultRequestHeaders.Add("sentry-trace", "foobar"); + client.DefaultRequestHeaders.Add(headerName, "foobar"); // Act client.Get("https://localhost/"); @@ -400,7 +419,7 @@ public void Send_SentryTraceHeaderAlreadySet_NotOverwritten() // Assert request.Headers.Should().Contain(h => - h.Key == "sentry-trace" && + h.Key == headerName && string.Concat(h.Value) == "foobar"); } From d572cef7da1479c097e94fa1b5573c13c10408e9 Mon Sep 17 00:00:00 2001 From: hangy Date: Sat, 5 Apr 2025 18:09:21 +0200 Subject: [PATCH 04/21] fix: Typo --- test/Sentry.Tests/SentryTraceHeaderExtensionsTests.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/Sentry.Tests/SentryTraceHeaderExtensionsTests.cs b/test/Sentry.Tests/SentryTraceHeaderExtensionsTests.cs index 50ddc409d9..def0ebc096 100644 --- a/test/Sentry.Tests/SentryTraceHeaderExtensionsTests.cs +++ b/test/Sentry.Tests/SentryTraceHeaderExtensionsTests.cs @@ -21,7 +21,7 @@ public void AsW3CTraceContext_WithSampled_ConvertsToW3CFormat(bool isSampled, st public void AsW3CTraceContext_WithoutSampled_ConvertsToW3CFormat() { // Arrange - var traceHeader = new SentryTraceHeader(SentryId.Parse("75302ac48a024bde9a3b3734a82e36c8"), SpanId.Parse("1000000000000000"), nuint); + var traceHeader = new SentryTraceHeader(SentryId.Parse("75302ac48a024bde9a3b3734a82e36c8"), SpanId.Parse("1000000000000000"), null); // Act var result = traceHeader.AsW3CTraceContext(); From 23b3d52ed5d15f31028e676b1237d41fdc9643fc Mon Sep 17 00:00:00 2001 From: hangy Date: Sat, 5 Apr 2025 18:40:11 +0200 Subject: [PATCH 05/21] refactor: Make SentryTraceHeaderExtensions internal Don't need this as an external API. --- src/Sentry/SentryTraceHeaderExtensions.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Sentry/SentryTraceHeaderExtensions.cs b/src/Sentry/SentryTraceHeaderExtensions.cs index 3b7289b89e..da51b9e9c5 100644 --- a/src/Sentry/SentryTraceHeaderExtensions.cs +++ b/src/Sentry/SentryTraceHeaderExtensions.cs @@ -3,7 +3,7 @@ namespace Sentry; /// /// Extension methods for working with Sentry trace headers. /// -public static class SentryTraceHeaderExtensions +internal static class SentryTraceHeaderExtensions { /// /// Converts the Sentry trace header to W3C trace context format. From e36a636bee937efd37bff79173ac86770d056f53 Mon Sep 17 00:00:00 2001 From: hangy Date: Sat, 5 Apr 2025 19:01:08 +0200 Subject: [PATCH 06/21] refactor: Move W3C header related stuff to dedicated `W3CTraceHeader` class I want to use this as a base for parsing, as an extension method doesn't really help for this. --- src/Sentry.AspNet/HttpContextExtensions.cs | 21 ++++++ src/Sentry/SentryMessageHandler.cs | 5 +- src/Sentry/SentryTraceHeaderExtensions.cs | 40 ----------- src/Sentry/W3CTraceHeader.cs | 70 +++++++++++++++++++ .../SentryTraceHeaderExtensionsTests.cs | 32 --------- test/Sentry.Tests/W3CTraceHeaderTests.cs | 34 +++++++++ 6 files changed, 127 insertions(+), 75 deletions(-) delete mode 100644 src/Sentry/SentryTraceHeaderExtensions.cs create mode 100644 src/Sentry/W3CTraceHeader.cs delete mode 100644 test/Sentry.Tests/SentryTraceHeaderExtensionsTests.cs create mode 100644 test/Sentry.Tests/W3CTraceHeaderTests.cs diff --git a/src/Sentry.AspNet/HttpContextExtensions.cs b/src/Sentry.AspNet/HttpContextExtensions.cs index e567433cdb..761de32223 100644 --- a/src/Sentry.AspNet/HttpContextExtensions.cs +++ b/src/Sentry.AspNet/HttpContextExtensions.cs @@ -33,6 +33,27 @@ public static class HttpContextExtensions } } + private static SentryTraceHeader? TryGetW3CTraceHeader(HttpContext context, SentryOptions? options) + { + var value = context.Request.Headers.Get(SentryTraceHeaderExtensions.W3CTraceContextHeaderName); + if (string.IsNullOrWhiteSpace(value)) + { + return null; + } + + options?.LogDebug("Received Sentry trace header '{0}'.", value); + + try + { + return SentryTraceHeader.Parse(value); + } + catch (Exception ex) + { + options?.LogError(ex, "Invalid Sentry trace header '{0}'.", value); + return null; + } + } + private static BaggageHeader? TryGetBaggageHeader(HttpContext context, SentryOptions? options) { var value = context.Request.Headers.Get(BaggageHeader.HttpHeaderName); diff --git a/src/Sentry/SentryMessageHandler.cs b/src/Sentry/SentryMessageHandler.cs index 8a58d8117e..69b1f6560e 100644 --- a/src/Sentry/SentryMessageHandler.cs +++ b/src/Sentry/SentryMessageHandler.cs @@ -153,10 +153,9 @@ private void AddSentryTraceHeader(HttpRequestMessage request) private void AddW3CTraceHeader(HttpRequestMessage request) { // Set trace header if it hasn't already been set - const string w3cHeaderName = "traceparent"; - if (!request.Headers.Contains(w3cHeaderName) && _hub.GetTraceHeader() is { } traceHeader) + if (!request.Headers.Contains(W3CTraceHeader.HttpHeaderName) && _hub.GetTraceHeader() is { } traceHeader) { - request.Headers.Add(w3cHeaderName, traceHeader.AsW3CTraceContext()); + request.Headers.Add(W3CTraceHeader.HttpHeaderName, new W3CTraceHeader(traceHeader).ToString()); } } diff --git a/src/Sentry/SentryTraceHeaderExtensions.cs b/src/Sentry/SentryTraceHeaderExtensions.cs deleted file mode 100644 index da51b9e9c5..0000000000 --- a/src/Sentry/SentryTraceHeaderExtensions.cs +++ /dev/null @@ -1,40 +0,0 @@ -namespace Sentry; - -/// -/// Extension methods for working with Sentry trace headers. -/// -internal static class SentryTraceHeaderExtensions -{ - /// - /// Converts the Sentry trace header to W3C trace context format. - /// - /// The Sentry trace header to convert. - /// A string representation of the trace header in W3C format. - /// Thrown if is null. - public static string AsW3CTraceContext(this SentryTraceHeader traceHeader) - { - if (traceHeader is null) - { - throw new ArgumentNullException(nameof(traceHeader)); - } - - const string version = "00"; - var traceFlags = ConvertSampledToTraceFlags(traceHeader.IsSampled); - if (traceFlags is null) - { - return $"{version}-{traceHeader.TraceId}-{traceHeader.SpanId}"; - } - - return $"{version}-{traceHeader.TraceId}-{traceHeader.SpanId}-{traceFlags}"; - } - - private static string? ConvertSampledToTraceFlags(bool? isSampled) - { - return isSampled switch - { - true => "01", - false => "00", - null => null - }; - } -} diff --git a/src/Sentry/W3CTraceHeader.cs b/src/Sentry/W3CTraceHeader.cs new file mode 100644 index 0000000000..324acfe089 --- /dev/null +++ b/src/Sentry/W3CTraceHeader.cs @@ -0,0 +1,70 @@ +namespace Sentry; + +/// +/// Extension methods for working with Sentry trace headers. +/// +internal class W3CTraceHeader +{ + /// + /// The name of the W3C trace context header used for distributed tracing. + /// This field contains the value "traceparent" which is part of the W3C Trace Context specification. + /// + public const string HttpHeaderName = "traceparent"; + + /// + /// Initializes a new instance of the class from a Sentry trace header. + /// + /// The source Sentry trace header to create the W3C trace header from. + /// Thrown when is null. + public W3CTraceHeader(SentryTraceHeader source) + { + ArgumentNullException.ThrowIfNull(source); + + SentryTraceHeader = source; + } + + /// + /// Gets the Sentry trace header containing trace identification and sampling information. + /// + /// + /// The Sentry trace header that contains the trace ID, span ID, and sampling decision. + /// + public SentryTraceHeader SentryTraceHeader { get; } + + /// + public override string ToString() + { + const string version = "00"; + var traceFlags = ConvertSampledToTraceFlags(SentryTraceHeader.IsSampled); + if (traceFlags is null) + { + return $"{version}-{SentryTraceHeader.TraceId}-{SentryTraceHeader.SpanId}"; + } + + return $"{version}-{SentryTraceHeader.TraceId}-{SentryTraceHeader.SpanId}-{traceFlags}"; + } + + /// + public override bool Equals(object? obj) + { + if (obj is W3CTraceHeader other) + { + return SentryTraceHeader.Equals(other.SentryTraceHeader); + } + + return false; + } + + /// + public override int GetHashCode() => SentryTraceHeader.GetHashCode(); + + private static string? ConvertSampledToTraceFlags(bool? isSampled) + { + return isSampled switch + { + true => "01", + false => "00", + null => null + }; + } +} diff --git a/test/Sentry.Tests/SentryTraceHeaderExtensionsTests.cs b/test/Sentry.Tests/SentryTraceHeaderExtensionsTests.cs deleted file mode 100644 index def0ebc096..0000000000 --- a/test/Sentry.Tests/SentryTraceHeaderExtensionsTests.cs +++ /dev/null @@ -1,32 +0,0 @@ -namespace Sentry.Tests; - -public class SentryTraceHeaderExtensionsTests -{ - [Theory] - [InlineData(true, "01")] - [InlineData(false, "00")] - public void AsW3CTraceContext_WithSampled_ConvertsToW3CFormat(bool isSampled, string traceFlags) - { - // Arrange - var traceHeader = new SentryTraceHeader(SentryId.Parse("75302ac48a024bde9a3b3734a82e36c8"), SpanId.Parse("1000000000000000"), isSampled); - - // Act - var result = traceHeader.AsW3CTraceContext(); - - // Assert - result.Should().Be($"00-75302ac48a024bde9a3b3734a82e36c8-1000000000000000-{traceFlags}"); - } - - [Fact] - public void AsW3CTraceContext_WithoutSampled_ConvertsToW3CFormat() - { - // Arrange - var traceHeader = new SentryTraceHeader(SentryId.Parse("75302ac48a024bde9a3b3734a82e36c8"), SpanId.Parse("1000000000000000"), null); - - // Act - var result = traceHeader.AsW3CTraceContext(); - - // Assert - result.Should().Be("00-75302ac48a024bde9a3b3734a82e36c8-1000000000000000"); - } -} diff --git a/test/Sentry.Tests/W3CTraceHeaderTests.cs b/test/Sentry.Tests/W3CTraceHeaderTests.cs new file mode 100644 index 0000000000..f6ce164438 --- /dev/null +++ b/test/Sentry.Tests/W3CTraceHeaderTests.cs @@ -0,0 +1,34 @@ +namespace Sentry.Tests; + +public class W3CTraceHeaderTests +{ + [Theory] + [InlineData(true, "01")] + [InlineData(false, "00")] + public void ToString_WithSampled_ConvertsToW3CFormat(bool isSampled, string traceFlags) + { + // Arrange + var source = new SentryTraceHeader(SentryId.Parse("75302ac48a024bde9a3b3734a82e36c8"), SpanId.Parse("1000000000000000"), isSampled); + var traceHeader = new W3CTraceHeader(source); + + // Act + var result = traceHeader.ToString(); + + // Assert + result.Should().Be($"00-75302ac48a024bde9a3b3734a82e36c8-1000000000000000-{traceFlags}"); + } + + [Fact] + public void ToString_WithoutSampled_ConvertsToW3CFormat() + { + // Arrange + var source = new SentryTraceHeader(SentryId.Parse("75302ac48a024bde9a3b3734a82e36c8"), SpanId.Parse("1000000000000000"), null); + var traceHeader = new W3CTraceHeader(source); + + // Act + var result = traceHeader.ToString(); + + // Assert + result.Should().Be("00-75302ac48a024bde9a3b3734a82e36c8-1000000000000000"); + } +} From cffea5c54b8c6fe7269dd750101b881d6b51e93b Mon Sep 17 00:00:00 2001 From: hangy Date: Sat, 5 Apr 2025 19:10:29 +0200 Subject: [PATCH 07/21] refactor: Update W3CTraceHeader to improve parsing and error handling --- src/Sentry.AspNet/HttpContextExtensions.cs | 37 +++++++-------- src/Sentry/W3CTraceHeader.cs | 54 ++++++++++++++++++++-- 2 files changed, 69 insertions(+), 22 deletions(-) diff --git a/src/Sentry.AspNet/HttpContextExtensions.cs b/src/Sentry.AspNet/HttpContextExtensions.cs index 761de32223..38e4e6f26f 100644 --- a/src/Sentry.AspNet/HttpContextExtensions.cs +++ b/src/Sentry.AspNet/HttpContextExtensions.cs @@ -33,27 +33,27 @@ public static class HttpContextExtensions } } - private static SentryTraceHeader? TryGetW3CTraceHeader(HttpContext context, SentryOptions? options) + private static W3CTraceHeader? TryGetW3CTraceHeader(HttpContext context, SentryOptions? options) + { + var value = context.Request.Headers.Get(W3CTraceHeader.HttpHeaderName); + if (string.IsNullOrWhiteSpace(value)) { - var value = context.Request.Headers.Get(SentryTraceHeaderExtensions.W3CTraceContextHeaderName); - if (string.IsNullOrWhiteSpace(value)) - { - return null; - } - - options?.LogDebug("Received Sentry trace header '{0}'.", value); - - try - { - return SentryTraceHeader.Parse(value); - } - catch (Exception ex) - { - options?.LogError(ex, "Invalid Sentry trace header '{0}'.", value); - return null; - } + return null; } + options?.LogDebug("Received Sentry trace header '{0}'.", value); + + try + { + return W3CTraceHeader.Parse(value); + } + catch (Exception ex) + { + options?.LogError(ex, "Invalid Sentry trace header '{0}'.", value); + return null; + } + } + private static BaggageHeader? TryGetBaggageHeader(HttpContext context, SentryOptions? options) { var value = context.Request.Headers.Get(BaggageHeader.HttpHeaderName); @@ -86,6 +86,7 @@ public static void StartOrContinueTrace(this HttpContext httpContext) var options = SentrySdk.CurrentOptions; var traceHeader = TryGetSentryTraceHeader(httpContext, options); + traceHeader ??= TryGetW3CTraceHeader(httpContext, options)?.SentryTraceHeader; var baggageHeader = TryGetBaggageHeader(httpContext, options); var method = httpContext.Request.HttpMethod; diff --git a/src/Sentry/W3CTraceHeader.cs b/src/Sentry/W3CTraceHeader.cs index 324acfe089..a8f4db8c39 100644 --- a/src/Sentry/W3CTraceHeader.cs +++ b/src/Sentry/W3CTraceHeader.cs @@ -5,6 +5,8 @@ namespace Sentry; /// internal class W3CTraceHeader { + private const string SupportedVersion = "00"; + /// /// The name of the W3C trace context header used for distributed tracing. /// This field contains the value "traceparent" which is part of the W3C Trace Context specification. @@ -18,7 +20,10 @@ internal class W3CTraceHeader /// Thrown when is null. public W3CTraceHeader(SentryTraceHeader source) { - ArgumentNullException.ThrowIfNull(source); + if (source is null) + { + throw new ArgumentNullException(nameof(source), "Source Sentry trace header cannot be null."); + } SentryTraceHeader = source; } @@ -31,17 +36,58 @@ public W3CTraceHeader(SentryTraceHeader source) /// public SentryTraceHeader SentryTraceHeader { get; } + /// + /// Parses a from a string representation of the Sentry trace header. + /// + /// + /// A string containing the Sentry trace header, expected to follow the format "traceId-spanId-sampled", + /// where "sampled" is optional. + /// + /// + /// A object if parsing succeeds, or null if the input string is null, empty, or whitespace. + /// + /// + /// Thrown if the input string does not contain a valid trace header format, specifically if it lacks required trace ID and span ID components. + /// + public static W3CTraceHeader? Parse(string value) + { + if (string.IsNullOrWhiteSpace(value)) + { + return null; + } + + var components = value.Split('-', StringSplitOptions.RemoveEmptyEntries); + if (components.Length < 2) + { + throw new FormatException($"Invalid W3C trace header: {value}."); + } + + var version = components[0]; + if (version != SupportedVersion) + { + throw new FormatException($"Invalid W3C trace header version: {version}."); + } + + var traceId = SentryId.Parse(components[1]); + var spanId = SpanId.Parse(components[2]); + + var isSampled = components.Length >= 4 + ? string.Equals(components[3], "01", StringComparison.OrdinalIgnoreCase) + : (bool?)null; + + return new W3CTraceHeader(new SentryTraceHeader(traceId, spanId, isSampled)); + } + /// public override string ToString() { - const string version = "00"; var traceFlags = ConvertSampledToTraceFlags(SentryTraceHeader.IsSampled); if (traceFlags is null) { - return $"{version}-{SentryTraceHeader.TraceId}-{SentryTraceHeader.SpanId}"; + return $"{SupportedVersion}-{SentryTraceHeader.TraceId}-{SentryTraceHeader.SpanId}"; } - return $"{version}-{SentryTraceHeader.TraceId}-{SentryTraceHeader.SpanId}-{traceFlags}"; + return $"{SupportedVersion}-{SentryTraceHeader.TraceId}-{SentryTraceHeader.SpanId}-{traceFlags}"; } /// From 4818a25b4f33a34ac1ad99a46f36b38f5f0f7c8f Mon Sep 17 00:00:00 2001 From: hangy Date: Sat, 5 Apr 2025 19:33:15 +0200 Subject: [PATCH 08/21] feat: Implement TryGetW3CTraceHeader in Middlewares and Extensions --- .../Extensions/HttpContextExtensions.cs | 21 ++++++++++++++++ src/Sentry.AspNetCore/SentryMiddleware.cs | 1 + .../HttpRequestDataExtensions.cs | 25 +++++++++++++++++++ .../SentryFunctionsWorkerMiddleware.cs | 1 + 4 files changed, 48 insertions(+) diff --git a/src/Sentry.AspNetCore/Extensions/HttpContextExtensions.cs b/src/Sentry.AspNetCore/Extensions/HttpContextExtensions.cs index 19e4dde3c4..bcc846571f 100644 --- a/src/Sentry.AspNetCore/Extensions/HttpContextExtensions.cs +++ b/src/Sentry.AspNetCore/Extensions/HttpContextExtensions.cs @@ -63,6 +63,27 @@ internal static class HttpContextExtensions options?.LogError(ex, "Invalid Sentry trace header '{0}'.", value); return null; } + } + + public static W3CTraceHeader? TryGetW3CTraceHeader(this HttpContext context, SentryOptions? options) + { + var value = context.Request.Headers.GetValueOrDefault(W3CTraceHeader.HttpHeaderName); + if (string.IsNullOrWhiteSpace(value)) + { + return null; + } + + options?.LogDebug("Received Sentry trace header '{0}'.", value); + + try + { + return W3CTraceHeader.Parse(value!); + } + catch (Exception ex) + { + options?.LogError(ex, "Invalid Sentry trace header '{0}'.", value); + return null; + } } public static BaggageHeader? TryGetBaggageHeader(this HttpContext context, SentryOptions? options) diff --git a/src/Sentry.AspNetCore/SentryMiddleware.cs b/src/Sentry.AspNetCore/SentryMiddleware.cs index 33fca3e622..19f4c85c72 100644 --- a/src/Sentry.AspNetCore/SentryMiddleware.cs +++ b/src/Sentry.AspNetCore/SentryMiddleware.cs @@ -106,6 +106,7 @@ public async Task InvokeAsync(HttpContext context, RequestDelegate next) } var traceHeader = context.TryGetSentryTraceHeader(_options); + traceHeader ??= context.TryGetW3CTraceHeader(_options)?.SentryTraceHeader; var baggageHeader = context.TryGetBaggageHeader(_options); var transactionContext = hub.ContinueTrace(traceHeader, baggageHeader); diff --git a/src/Sentry.Azure.Functions.Worker/HttpRequestDataExtensions.cs b/src/Sentry.Azure.Functions.Worker/HttpRequestDataExtensions.cs index 194e8dcd6f..b4a311812b 100644 --- a/src/Sentry.Azure.Functions.Worker/HttpRequestDataExtensions.cs +++ b/src/Sentry.Azure.Functions.Worker/HttpRequestDataExtensions.cs @@ -30,6 +30,31 @@ internal static class HttpRequestDataExtensions } } + public static W3CTraceHeader? TryGetW3CTraceHeader(this HttpRequestData context, IDiagnosticLogger? logger) + { + var traceHeaderValue = context.Headers.TryGetValues(W3CTraceHeader.HttpHeaderName, out var values) + ? values.FirstOrDefault() + : null; + + if (traceHeaderValue is null) + { + logger?.LogDebug("Did not receive a Sentry trace header."); + return null; + } + + logger?.LogDebug("Received Sentry trace header '{0}'.", traceHeaderValue); + + try + { + return W3CTraceHeader.Parse(traceHeaderValue); + } + catch (Exception ex) + { + logger?.LogError(ex, "Invalid Sentry trace header '{0}'.", traceHeaderValue); + return null; + } + } + public static BaggageHeader? TryGetBaggageHeader(this HttpRequestData context, IDiagnosticLogger? logger) { var baggageValue = context.Headers.TryGetValues(BaggageHeader.HttpHeaderName, out var value) diff --git a/src/Sentry.Azure.Functions.Worker/SentryFunctionsWorkerMiddleware.cs b/src/Sentry.Azure.Functions.Worker/SentryFunctionsWorkerMiddleware.cs index c3c57fe71e..9b338d88b7 100644 --- a/src/Sentry.Azure.Functions.Worker/SentryFunctionsWorkerMiddleware.cs +++ b/src/Sentry.Azure.Functions.Worker/SentryFunctionsWorkerMiddleware.cs @@ -124,6 +124,7 @@ private async Task StartOrContinueTraceAsync(FunctionContext } var traceHeader = requestData.TryGetSentryTraceHeader(_logger); + traceHeader ??= requestData.TryGetW3CTraceHeader(_logger)?.SentryTraceHeader; var baggageHeader = requestData.TryGetBaggageHeader(_logger); return SentrySdk.ContinueTrace(traceHeader, baggageHeader, transactionName, Operation); From f14cad57872095a102747f402da873975931dde3 Mon Sep 17 00:00:00 2001 From: hangy Date: Sat, 5 Apr 2025 19:57:28 +0200 Subject: [PATCH 09/21] refactor: Convert Sentry header tests to use [Theory] with InlineData for better coverage --- .../SentryMiddlewareTests.cs | 28 +++++++++++-------- 1 file changed, 17 insertions(+), 11 deletions(-) diff --git a/test/Sentry.AspNetCore.Tests/SentryMiddlewareTests.cs b/test/Sentry.AspNetCore.Tests/SentryMiddlewareTests.cs index d6cedd9b73..0ad0628f77 100644 --- a/test/Sentry.AspNetCore.Tests/SentryMiddlewareTests.cs +++ b/test/Sentry.AspNetCore.Tests/SentryMiddlewareTests.cs @@ -682,8 +682,10 @@ public async Task InvokeAsync_InstrumenterOpenTelemetry_SavesScope() } } - [Fact] - public async Task InvokeAsync_RequestContainsSentryHeaders_ContinuesTrace() + [Theory] + [InlineData("Sentry-Trace", "4b4d2878507b43d3af7dd8c4ab7a96d9-3cc6fd1337d243de", "4b4d2878507b43d3af7dd8c4ab7a96d9")] + [InlineData("traceparent", "00-4b4d2878507b43d3af7dd8c4ab7a96d8-3cc6fd1337d243de-00", "4b4d2878507b43d3af7dd8c4ab7a96d8")] + public async Task InvokeAsync_RequestContainsSentryHeaders_ContinuesTrace(string headerName, string headerValue, string expectedTraceId) { SentryTraceHeader capturedTraceHeader = null; BaggageHeader capturedBaggageHeader = null; @@ -698,7 +700,7 @@ public async Task InvokeAsync_RequestContainsSentryHeaders_ContinuesTrace() var request = Substitute.For(); var fakeHeaders = new HeaderDictionary { - { "Sentry-Trace", "4b4d2878507b43d3af7dd8c4ab7a96d9-3cc6fd1337d243de"}, + { headerName, headerValue}, { "Baggage", "sentry-trace_id=4b4d2878507b43d3af7dd8c4ab7a96d9, sentry-public_key=eb18e953812b41c3aeb042e666fd3b5c"}, }; _ = request.Headers.Returns(fakeHeaders); @@ -710,19 +712,21 @@ public async Task InvokeAsync_RequestContainsSentryHeaders_ContinuesTrace() _fixture.Hub.Received().ContinueTrace(Arg.Any(), Arg.Any()); Assert.NotNull(capturedTraceHeader); - Assert.Equal("4b4d2878507b43d3af7dd8c4ab7a96d9", capturedTraceHeader.TraceId.ToString()); + Assert.Equal(expectedTraceId, capturedTraceHeader.TraceId.ToString()); Assert.NotNull(capturedBaggageHeader); Assert.Equal("sentry-trace_id=4b4d2878507b43d3af7dd8c4ab7a96d9, sentry-public_key=eb18e953812b41c3aeb042e666fd3b5c", capturedBaggageHeader.ToString()); } - [Fact] - public async Task InvokeAsync_RequestContainsSentryHeaders_AddsHeadersAndTransactionContextToItems() + [Theory] + [InlineData("Sentry-Trace", "4b4d2878507b43d3af7dd8c4ab7a96d9-3cc6fd1337d243de", "4b4d2878507b43d3af7dd8c4ab7a96d9")] + [InlineData("traceparent", "00-4b4d2878507b43d3af7dd8c4ab7a96d8-3cc6fd1337d243de-00", "4b4d2878507b43d3af7dd8c4ab7a96d8")] + public async Task InvokeAsync_RequestContainsSentryHeaders_AddsHeadersAndTransactionContextToItems(string headerName, string headerValue, string expectedTraceId) { var sut = _fixture.GetSut(); var request = Substitute.For(); var fakeHeaders = new HeaderDictionary { - { "Sentry-Trace", "4b4d2878507b43d3af7dd8c4ab7a96d9-3cc6fd1337d243de"}, + { headerName, headerValue}, { "Baggage", "sentry-trace_id=4b4d2878507b43d3af7dd8c4ab7a96d9, sentry-public_key=eb18e953812b41c3aeb042e666fd3b5c"}, }; var contextItems = new Dictionary(); @@ -741,7 +745,7 @@ public async Task InvokeAsync_RequestContainsSentryHeaders_AddsHeadersAndTransac var traceHeader = contextItems[SentryMiddleware.TraceHeaderItemKey] as SentryTraceHeader; Assert.NotNull(traceHeader); - Assert.Equal("4b4d2878507b43d3af7dd8c4ab7a96d9", traceHeader.TraceId.ToString()); + Assert.Equal(expectedTraceId, traceHeader.TraceId.ToString()); var baggageHeader = contextItems[SentryMiddleware.BaggageHeaderItemKey] as BaggageHeader; Assert.NotNull(baggageHeader); Assert.Equal("sentry-trace_id=4b4d2878507b43d3af7dd8c4ab7a96d9, sentry-public_key=eb18e953812b41c3aeb042e666fd3b5c", baggageHeader.ToString()); @@ -749,14 +753,16 @@ public async Task InvokeAsync_RequestContainsSentryHeaders_AddsHeadersAndTransac Assert.NotNull(transactionContext); } - [Fact] - public async Task InvokeAsync_InvokingWithTheSameContextTwice_DoesNotThrow() + [Theory] + [InlineData("Sentry-Trace", "4b4d2878507b43d3af7dd8c4ab7a96d9-3cc6fd1337d243de")] + [InlineData("traceparent", "00-4b4d2878507b43d3af7dd8c4ab7a96d8-3cc6fd1337d243de-01")] + public async Task InvokeAsync_InvokingWithTheSameContextTwice_DoesNotThrow(string headerName, string headerValue) { var sut = _fixture.GetSut(); var request = Substitute.For(); var fakeHeaders = new HeaderDictionary { - { "Sentry-Trace", "4b4d2878507b43d3af7dd8c4ab7a96d9-3cc6fd1337d243de"}, + { headerName, headerValue}, { "Baggage", "sentry-trace_id=4b4d2878507b43d3af7dd8c4ab7a96d9, sentry-public_key=eb18e953812b41c3aeb042e666fd3b5c"}, }; var contextItems = new Dictionary(); From 108a0fc1831f29ff754380f87417eabe147efe0a Mon Sep 17 00:00:00 2001 From: hangy Date: Sun, 6 Apr 2025 12:08:30 +0200 Subject: [PATCH 10/21] fix: traceparent's `trace-flags` are mandatory --- .../Extensions/HttpContextExtensions.cs | 2 +- src/Sentry/W3CTraceHeader.cs | 23 ++++--------------- test/Sentry.Tests/W3CTraceHeaderTests.cs | 2 +- 3 files changed, 6 insertions(+), 21 deletions(-) diff --git a/src/Sentry.AspNetCore/Extensions/HttpContextExtensions.cs b/src/Sentry.AspNetCore/Extensions/HttpContextExtensions.cs index bcc846571f..1ba81d1444 100644 --- a/src/Sentry.AspNetCore/Extensions/HttpContextExtensions.cs +++ b/src/Sentry.AspNetCore/Extensions/HttpContextExtensions.cs @@ -63,7 +63,7 @@ internal static class HttpContextExtensions options?.LogError(ex, "Invalid Sentry trace header '{0}'.", value); return null; } - } + } public static W3CTraceHeader? TryGetW3CTraceHeader(this HttpContext context, SentryOptions? options) { diff --git a/src/Sentry/W3CTraceHeader.cs b/src/Sentry/W3CTraceHeader.cs index a8f4db8c39..3224c9ac29 100644 --- a/src/Sentry/W3CTraceHeader.cs +++ b/src/Sentry/W3CTraceHeader.cs @@ -36,7 +36,7 @@ public W3CTraceHeader(SentryTraceHeader source) /// public SentryTraceHeader SentryTraceHeader { get; } - /// + /// /// Parses a from a string representation of the Sentry trace header. /// /// @@ -57,7 +57,7 @@ public W3CTraceHeader(SentryTraceHeader source) } var components = value.Split('-', StringSplitOptions.RemoveEmptyEntries); - if (components.Length < 2) + if (components.Length < 4) { throw new FormatException($"Invalid W3C trace header: {value}."); } @@ -71,9 +71,7 @@ public W3CTraceHeader(SentryTraceHeader source) var traceId = SentryId.Parse(components[1]); var spanId = SpanId.Parse(components[2]); - var isSampled = components.Length >= 4 - ? string.Equals(components[3], "01", StringComparison.OrdinalIgnoreCase) - : (bool?)null; + var isSampled = string.Equals(components[3], "01", StringComparison.OrdinalIgnoreCase); return new W3CTraceHeader(new SentryTraceHeader(traceId, spanId, isSampled)); } @@ -82,11 +80,6 @@ public W3CTraceHeader(SentryTraceHeader source) public override string ToString() { var traceFlags = ConvertSampledToTraceFlags(SentryTraceHeader.IsSampled); - if (traceFlags is null) - { - return $"{SupportedVersion}-{SentryTraceHeader.TraceId}-{SentryTraceHeader.SpanId}"; - } - return $"{SupportedVersion}-{SentryTraceHeader.TraceId}-{SentryTraceHeader.SpanId}-{traceFlags}"; } @@ -104,13 +97,5 @@ public override bool Equals(object? obj) /// public override int GetHashCode() => SentryTraceHeader.GetHashCode(); - private static string? ConvertSampledToTraceFlags(bool? isSampled) - { - return isSampled switch - { - true => "01", - false => "00", - null => null - }; - } + private static string? ConvertSampledToTraceFlags(bool? isSampled) => isSampled ?? false ? "01" : "00"; } diff --git a/test/Sentry.Tests/W3CTraceHeaderTests.cs b/test/Sentry.Tests/W3CTraceHeaderTests.cs index f6ce164438..ab1d7b6878 100644 --- a/test/Sentry.Tests/W3CTraceHeaderTests.cs +++ b/test/Sentry.Tests/W3CTraceHeaderTests.cs @@ -29,6 +29,6 @@ public void ToString_WithoutSampled_ConvertsToW3CFormat() var result = traceHeader.ToString(); // Assert - result.Should().Be("00-75302ac48a024bde9a3b3734a82e36c8-1000000000000000"); + result.Should().Be("00-75302ac48a024bde9a3b3734a82e36c8-1000000000000000-00"); } } From 5c510a4eb8176f0b6a9fde2e71af93d2fa264423 Mon Sep 17 00:00:00 2001 From: hangy Date: Mon, 7 Apr 2025 20:35:39 +0200 Subject: [PATCH 11/21] docs: Add changelog entry for traceparent header support in HTTP requests --- CHANGELOG.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index e3dd61e5aa..110161dd36 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## Unreleased + +### Features + +* If an incoming HTTP request has the `traceparent` header, it is now parsed and interpreted like the `sentry-trace` header. Outgoing requests now contain the `traceparent` header to facilitate integration with servesr that only support the [W3C Trace Context](https://www.w3.org/TR/trace-context/). ([#4084](https://github.com/getsentry/sentry-dotnet/pull/4084)) + ## 5.5.0 ### Features From 8cf3195d2ba146545e1adafe00c6ba94f5bb4438 Mon Sep 17 00:00:00 2001 From: hangy Date: Mon, 7 Apr 2025 21:00:13 +0200 Subject: [PATCH 12/21] test: Improve code coverage of `W3CTraceHeader` class Note that equality and hashcode methods were removed, as they're not implemented in the `SentryTraceHeader` class, either. --- src/Sentry/W3CTraceHeader.cs | 14 ------- test/Sentry.Tests/W3CTraceHeaderTests.cs | 51 +++++++++++++++++++++--- 2 files changed, 45 insertions(+), 20 deletions(-) diff --git a/src/Sentry/W3CTraceHeader.cs b/src/Sentry/W3CTraceHeader.cs index 3224c9ac29..723be8719e 100644 --- a/src/Sentry/W3CTraceHeader.cs +++ b/src/Sentry/W3CTraceHeader.cs @@ -83,19 +83,5 @@ public override string ToString() return $"{SupportedVersion}-{SentryTraceHeader.TraceId}-{SentryTraceHeader.SpanId}-{traceFlags}"; } - /// - public override bool Equals(object? obj) - { - if (obj is W3CTraceHeader other) - { - return SentryTraceHeader.Equals(other.SentryTraceHeader); - } - - return false; - } - - /// - public override int GetHashCode() => SentryTraceHeader.GetHashCode(); - private static string? ConvertSampledToTraceFlags(bool? isSampled) => isSampled ?? false ? "01" : "00"; } diff --git a/test/Sentry.Tests/W3CTraceHeaderTests.cs b/test/Sentry.Tests/W3CTraceHeaderTests.cs index ab1d7b6878..2dae9b817d 100644 --- a/test/Sentry.Tests/W3CTraceHeaderTests.cs +++ b/test/Sentry.Tests/W3CTraceHeaderTests.cs @@ -5,7 +5,8 @@ public class W3CTraceHeaderTests [Theory] [InlineData(true, "01")] [InlineData(false, "00")] - public void ToString_WithSampled_ConvertsToW3CFormat(bool isSampled, string traceFlags) + [InlineData(null, "00")] + public void ToString_ConvertsToW3CFormat(bool? isSampled, string traceFlags) { // Arrange var source = new SentryTraceHeader(SentryId.Parse("75302ac48a024bde9a3b3734a82e36c8"), SpanId.Parse("1000000000000000"), isSampled); @@ -19,16 +20,54 @@ public void ToString_WithSampled_ConvertsToW3CFormat(bool isSampled, string trac } [Fact] - public void ToString_WithoutSampled_ConvertsToW3CFormat() + public void Parse_ValidW3CHeader_ReturnsW3CTraceHeader() { // Arrange - var source = new SentryTraceHeader(SentryId.Parse("75302ac48a024bde9a3b3734a82e36c8"), SpanId.Parse("1000000000000000"), null); - var traceHeader = new W3CTraceHeader(source); + var header = "00-4bc7d217a6721c0e60e85e46d25fb3e5-f51f11f284da5299-01"; // Act - var result = traceHeader.ToString(); + var result = W3CTraceHeader.Parse(header); + + // Assert + result.Should().NotBeNull(); + result.SentryTraceHeader.TraceId.ToString().Should().Be("4bc7d217a6721c0e60e85e46d25fb3e5"); + result.SentryTraceHeader.SpanId.ToString().Should().Be("f51f11f284da5299"); + result.SentryTraceHeader.IsSampled.Should().BeTrue(); + } + + [Theory] + [InlineData("")] + [InlineData(" ")] + [InlineData("\n")] + [InlineData(null)] + public void Parse_Returns_Null_WhenHeaderIsNullOrEmpty(string header) + { + // Act + var result = W3CTraceHeader.Parse(header); + + // Assert + result.Should().BeNull(); + } + + [Theory] + [InlineData("00-4bc7d217a6721c0e60e85e46d25fb3e5-1000000000000000")] + [InlineData("01-f5cb855e16344ddd1538d10f82f6a018-a7018579d434fee4")] + public void Parse_InvalidW3CHeader_ThrowsFormatException(string header) + { + // Act + Action act = () => W3CTraceHeader.Parse(header); + + // Assert + act.Should().Throw(); + } + + [Fact] + public void Null_Source_Throws_ArgumentNullException() + { + // Arrange &Act + Action act = static () => new W3CTraceHeader(null!); // Assert - result.Should().Be("00-75302ac48a024bde9a3b3734a82e36c8-1000000000000000-00"); + act.Should().Throw().WithParameterName("source").WithMessage("*source*"); } } From 4d86280d13828a6057f3d99ecabfe054acaa9c54 Mon Sep 17 00:00:00 2001 From: hangy Date: Tue, 8 Apr 2025 10:26:16 +0200 Subject: [PATCH 13/21] docs: Document the priority of sentry-trace and traceparent headers Co-authored-by: James Crosswell --- src/Sentry.AspNet/HttpContextExtensions.cs | 2 ++ src/Sentry.AspNetCore/SentryMiddleware.cs | 2 ++ .../SentryFunctionsWorkerMiddleware.cs | 2 ++ 3 files changed, 6 insertions(+) diff --git a/src/Sentry.AspNet/HttpContextExtensions.cs b/src/Sentry.AspNet/HttpContextExtensions.cs index 38e4e6f26f..220413140f 100644 --- a/src/Sentry.AspNet/HttpContextExtensions.cs +++ b/src/Sentry.AspNet/HttpContextExtensions.cs @@ -85,6 +85,8 @@ public static void StartOrContinueTrace(this HttpContext httpContext) { var options = SentrySdk.CurrentOptions; + // If both sentry-trace and traceparent headers are present, sentry-trace takes precedence. + // See: https://github.com/getsentry/team-sdks/issues/41 var traceHeader = TryGetSentryTraceHeader(httpContext, options); traceHeader ??= TryGetW3CTraceHeader(httpContext, options)?.SentryTraceHeader; var baggageHeader = TryGetBaggageHeader(httpContext, options); diff --git a/src/Sentry.AspNetCore/SentryMiddleware.cs b/src/Sentry.AspNetCore/SentryMiddleware.cs index 19f4c85c72..77bd2fc8bf 100644 --- a/src/Sentry.AspNetCore/SentryMiddleware.cs +++ b/src/Sentry.AspNetCore/SentryMiddleware.cs @@ -105,6 +105,8 @@ public async Task InvokeAsync(HttpContext context, RequestDelegate next) context.Response.OnCompleted(() => hub.FlushAsync(_options.FlushTimeout)); } + // If both sentry-trace and traceparent headers are present, sentry-trace takes precedence. + // See: https://github.com/getsentry/team-sdks/issues/41 var traceHeader = context.TryGetSentryTraceHeader(_options); traceHeader ??= context.TryGetW3CTraceHeader(_options)?.SentryTraceHeader; var baggageHeader = context.TryGetBaggageHeader(_options); diff --git a/src/Sentry.Azure.Functions.Worker/SentryFunctionsWorkerMiddleware.cs b/src/Sentry.Azure.Functions.Worker/SentryFunctionsWorkerMiddleware.cs index 9b338d88b7..2ae17d004a 100644 --- a/src/Sentry.Azure.Functions.Worker/SentryFunctionsWorkerMiddleware.cs +++ b/src/Sentry.Azure.Functions.Worker/SentryFunctionsWorkerMiddleware.cs @@ -123,6 +123,8 @@ private async Task StartOrContinueTraceAsync(FunctionContext TransactionNameCache.TryAdd(transactionNameKey, transactionName); } + // If both sentry-trace and traceparent headers are present, sentry-trace takes precedence. + // See: https://github.com/getsentry/team-sdks/issues/41 var traceHeader = requestData.TryGetSentryTraceHeader(_logger); traceHeader ??= requestData.TryGetW3CTraceHeader(_logger)?.SentryTraceHeader; var baggageHeader = requestData.TryGetBaggageHeader(_logger); From f3cd9c78dd9eab1c33e5c47ba7ad2e3ba6786e88 Mon Sep 17 00:00:00 2001 From: hangy Date: Tue, 8 Apr 2025 10:44:56 +0200 Subject: [PATCH 14/21] test: Use `HeaderName` contstant to refer to HTTP tracing headers Co-authored-by: James Crosswell --- test/Sentry.Tests/SentryHttpMessageHandlerTests.cs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/test/Sentry.Tests/SentryHttpMessageHandlerTests.cs b/test/Sentry.Tests/SentryHttpMessageHandlerTests.cs index 00dc452cb1..f9e7d4c7b4 100644 --- a/test/Sentry.Tests/SentryHttpMessageHandlerTests.cs +++ b/test/Sentry.Tests/SentryHttpMessageHandlerTests.cs @@ -10,9 +10,9 @@ namespace Sentry.Tests; public class SentryHttpMessageHandlerTests { [Theory] - [InlineData("75302ac48a024bde9a3b3734a82e36c8-1000000000000000-0", "sentry-trace", "75302ac48a024bde9a3b3734a82e36c8-1000000000000000-0")] - [InlineData("75302ac48a024bde9a3b3734a82e36c8-1000000000000000-0", "traceparent", "00-75302ac48a024bde9a3b3734a82e36c8-1000000000000000-00")] - [InlineData("75302ac48a024bde9a3b3734a82e36c8-1000000000000000-1", "traceparent", "00-75302ac48a024bde9a3b3734a82e36c8-1000000000000000-01")] + [InlineData("75302ac48a024bde9a3b3734a82e36c8-1000000000000000-0", SentryTraceHeader.HttpHeaderName, "75302ac48a024bde9a3b3734a82e36c8-1000000000000000-0")] + [InlineData("75302ac48a024bde9a3b3734a82e36c8-1000000000000000-0", W3CTraceHeader.HttpHeaderName, "00-75302ac48a024bde9a3b3734a82e36c8-1000000000000000-00")] + [InlineData("75302ac48a024bde9a3b3734a82e36c8-1000000000000000-1", W3CTraceHeader.HttpHeaderName, "00-75302ac48a024bde9a3b3734a82e36c8-1000000000000000-01")] public async Task SendAsync_SentryTraceHeaderNotSet_SetsHeader_ByDefault(string traceHeader, string headerName, string expectedValue) { // Arrange From dc5c2cf1d3b9b8f3d16a11206cc027fabab22abe Mon Sep 17 00:00:00 2001 From: hangy Date: Tue, 8 Apr 2025 23:25:34 +0200 Subject: [PATCH 15/21] feat: Update W3C trace header parsing to support additional sampled flag --- src/Sentry/W3CTraceHeader.cs | 3 ++- test/Sentry.Tests/W3CTraceHeaderTests.cs | 16 ++++++++-------- 2 files changed, 10 insertions(+), 9 deletions(-) diff --git a/src/Sentry/W3CTraceHeader.cs b/src/Sentry/W3CTraceHeader.cs index 723be8719e..13dbe8f01a 100644 --- a/src/Sentry/W3CTraceHeader.cs +++ b/src/Sentry/W3CTraceHeader.cs @@ -71,7 +71,8 @@ public W3CTraceHeader(SentryTraceHeader source) var traceId = SentryId.Parse(components[1]); var spanId = SpanId.Parse(components[2]); - var isSampled = string.Equals(components[3], "01", StringComparison.OrdinalIgnoreCase); + var isSampled = string.Equals(components[3], "01", StringComparison.Ordinal) || + string.Equals(components[3], "09", StringComparison.Ordinal); return new W3CTraceHeader(new SentryTraceHeader(traceId, spanId, isSampled)); } diff --git a/test/Sentry.Tests/W3CTraceHeaderTests.cs b/test/Sentry.Tests/W3CTraceHeaderTests.cs index 2dae9b817d..0691055cbb 100644 --- a/test/Sentry.Tests/W3CTraceHeaderTests.cs +++ b/test/Sentry.Tests/W3CTraceHeaderTests.cs @@ -19,20 +19,20 @@ public void ToString_ConvertsToW3CFormat(bool? isSampled, string traceFlags) result.Should().Be($"00-75302ac48a024bde9a3b3734a82e36c8-1000000000000000-{traceFlags}"); } - [Fact] - public void Parse_ValidW3CHeader_ReturnsW3CTraceHeader() + [Theory] + [InlineData("00-4bc7d217a6721c0e60e85e46d25fb3e5-f51f11f284da5299-01", "4bc7d217a6721c0e60e85e46d25fb3e5", "f51f11f284da5299", true)] + [InlineData("00-3d19f80b6f7da306d7b5652745ec6173-703b42311109c14e-09", "3d19f80b6f7da306d7b5652745ec6173", "703b42311109c14e", true)] + [InlineData("00-992d690c7a3691eb0f409a3ba6ecc0cc-b4f1f8cbcc61a0e5-00", "992d690c7a3691eb0f409a3ba6ecc0cc", "b4f1f8cbcc61a0e5", false)] + public void Parse_ValidW3CHeader_ReturnsW3CTraceHeader(string header, string expectedTraceId, string expectedSpanId, bool expectedIsSampled) { - // Arrange - var header = "00-4bc7d217a6721c0e60e85e46d25fb3e5-f51f11f284da5299-01"; - // Act var result = W3CTraceHeader.Parse(header); // Assert result.Should().NotBeNull(); - result.SentryTraceHeader.TraceId.ToString().Should().Be("4bc7d217a6721c0e60e85e46d25fb3e5"); - result.SentryTraceHeader.SpanId.ToString().Should().Be("f51f11f284da5299"); - result.SentryTraceHeader.IsSampled.Should().BeTrue(); + result.SentryTraceHeader.TraceId.ToString().Should().Be(expectedTraceId); + result.SentryTraceHeader.SpanId.ToString().Should().Be(expectedSpanId); + result.SentryTraceHeader.IsSampled.Should().Be(expectedIsSampled); } [Theory] From cc4b563459b69113980d00a4489d0dde8986c61f Mon Sep 17 00:00:00 2001 From: hangy Date: Tue, 8 Apr 2025 23:33:45 +0200 Subject: [PATCH 16/21] test: Add additional test case for Sentry trace header parsing --- test/Sentry.Tests/SentryHttpMessageHandlerTests.cs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/test/Sentry.Tests/SentryHttpMessageHandlerTests.cs b/test/Sentry.Tests/SentryHttpMessageHandlerTests.cs index f9e7d4c7b4..8e5f58f518 100644 --- a/test/Sentry.Tests/SentryHttpMessageHandlerTests.cs +++ b/test/Sentry.Tests/SentryHttpMessageHandlerTests.cs @@ -11,6 +11,7 @@ public class SentryHttpMessageHandlerTests { [Theory] [InlineData("75302ac48a024bde9a3b3734a82e36c8-1000000000000000-0", SentryTraceHeader.HttpHeaderName, "75302ac48a024bde9a3b3734a82e36c8-1000000000000000-0")] + [InlineData("75302ac48a024bde9a3b3734a82e36c8-1000000000000000-1", SentryTraceHeader.HttpHeaderName, "75302ac48a024bde9a3b3734a82e36c8-1000000000000000-1")] [InlineData("75302ac48a024bde9a3b3734a82e36c8-1000000000000000-0", W3CTraceHeader.HttpHeaderName, "00-75302ac48a024bde9a3b3734a82e36c8-1000000000000000-00")] [InlineData("75302ac48a024bde9a3b3734a82e36c8-1000000000000000-1", W3CTraceHeader.HttpHeaderName, "00-75302ac48a024bde9a3b3734a82e36c8-1000000000000000-01")] public async Task SendAsync_SentryTraceHeaderNotSet_SetsHeader_ByDefault(string traceHeader, string headerName, string expectedValue) @@ -38,6 +39,7 @@ public async Task SendAsync_SentryTraceHeaderNotSet_SetsHeader_ByDefault(string [Theory] [InlineData("75302ac48a024bde9a3b3734a82e36c8-1000000000000000-0", "sentry-trace", "75302ac48a024bde9a3b3734a82e36c8-1000000000000000-0")] + [InlineData("75302ac48a024bde9a3b3734a82e36c8-1000000000000000-1", "sentry-trace", "75302ac48a024bde9a3b3734a82e36c8-1000000000000000-1")] [InlineData("75302ac48a024bde9a3b3734a82e36c8-1000000000000000-0", "traceparent", "00-75302ac48a024bde9a3b3734a82e36c8-1000000000000000-00")] [InlineData("75302ac48a024bde9a3b3734a82e36c8-1000000000000000-1", "traceparent", "00-75302ac48a024bde9a3b3734a82e36c8-1000000000000000-01")] public async Task SendAsync_SentryTraceHeaderNotSet_SetsHeader_WhenUrlMatchesPropagationOptions(string traceHeader, string headerName, string expectedValue) @@ -330,6 +332,7 @@ public void Send_SentryTraceHeaderNotSet_SetsHeader_ByDefault(string traceHeader [Theory] [InlineData("75302ac48a024bde9a3b3734a82e36c8-1000000000000000-0", "sentry-trace", "75302ac48a024bde9a3b3734a82e36c8-1000000000000000-0")] + [InlineData("75302ac48a024bde9a3b3734a82e36c8-1000000000000000-1", "sentry-trace", "75302ac48a024bde9a3b3734a82e36c8-1000000000000000-1")] [InlineData("75302ac48a024bde9a3b3734a82e36c8-1000000000000000-0", "traceparent", "00-75302ac48a024bde9a3b3734a82e36c8-1000000000000000-00")] [InlineData("75302ac48a024bde9a3b3734a82e36c8-1000000000000000-1", "traceparent", "00-75302ac48a024bde9a3b3734a82e36c8-1000000000000000-01")] public void Send_SentryTraceHeaderNotSet_SetsHeader_WhenUrlMatchesPropagationOptions(string traceHeader, string headerName, string expectedValue) From 9c56dbbfac135f3677e8c455bbf547ed81554afc Mon Sep 17 00:00:00 2001 From: hangy Date: Tue, 8 Apr 2025 23:40:11 +0200 Subject: [PATCH 17/21] chore: Remove redundant tests SendAsync_SentryTraceHeaderNotSet_SetsHeader_ByDefault is already covered by SendAsync_SentryTraceHeaderNotSet_SetsHeader_WhenUrlMatchesPropagationOptions --- .../SentryHttpMessageHandlerTests.cs | 28 ------------------- 1 file changed, 28 deletions(-) diff --git a/test/Sentry.Tests/SentryHttpMessageHandlerTests.cs b/test/Sentry.Tests/SentryHttpMessageHandlerTests.cs index 8e5f58f518..b1f3ecda6b 100644 --- a/test/Sentry.Tests/SentryHttpMessageHandlerTests.cs +++ b/test/Sentry.Tests/SentryHttpMessageHandlerTests.cs @@ -9,34 +9,6 @@ namespace Sentry.Tests; public class SentryHttpMessageHandlerTests { - [Theory] - [InlineData("75302ac48a024bde9a3b3734a82e36c8-1000000000000000-0", SentryTraceHeader.HttpHeaderName, "75302ac48a024bde9a3b3734a82e36c8-1000000000000000-0")] - [InlineData("75302ac48a024bde9a3b3734a82e36c8-1000000000000000-1", SentryTraceHeader.HttpHeaderName, "75302ac48a024bde9a3b3734a82e36c8-1000000000000000-1")] - [InlineData("75302ac48a024bde9a3b3734a82e36c8-1000000000000000-0", W3CTraceHeader.HttpHeaderName, "00-75302ac48a024bde9a3b3734a82e36c8-1000000000000000-00")] - [InlineData("75302ac48a024bde9a3b3734a82e36c8-1000000000000000-1", W3CTraceHeader.HttpHeaderName, "00-75302ac48a024bde9a3b3734a82e36c8-1000000000000000-01")] - public async Task SendAsync_SentryTraceHeaderNotSet_SetsHeader_ByDefault(string traceHeader, string headerName, string expectedValue) - { - // Arrange - var hub = Substitute.For(); - - hub.GetTraceHeader().ReturnsForAnyArgs( - SentryTraceHeader.Parse(traceHeader)); - - using var innerHandler = new RecordingHttpMessageHandler(new FakeHttpMessageHandler()); - using var sentryHandler = new SentryHttpMessageHandler(innerHandler, hub); - using var client = new HttpClient(sentryHandler); - - // Act - await client.GetAsync("https://localhost/"); - - using var request = innerHandler.GetRequests().Single(); - - // Assert - request.Headers.Should().Contain(h => - h.Key == headerName && - string.Concat(h.Value) == expectedValue); - } - [Theory] [InlineData("75302ac48a024bde9a3b3734a82e36c8-1000000000000000-0", "sentry-trace", "75302ac48a024bde9a3b3734a82e36c8-1000000000000000-0")] [InlineData("75302ac48a024bde9a3b3734a82e36c8-1000000000000000-1", "sentry-trace", "75302ac48a024bde9a3b3734a82e36c8-1000000000000000-1")] From 44c0d987ba8cb5db602346002338b553502df96e Mon Sep 17 00:00:00 2001 From: hangy Date: Tue, 8 Apr 2025 23:45:01 +0200 Subject: [PATCH 18/21] test: Add test to ensure that `sentry-trace` and `traceparent` headers are both set --- .../SentryHttpMessageHandlerTests.cs | 36 +++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/test/Sentry.Tests/SentryHttpMessageHandlerTests.cs b/test/Sentry.Tests/SentryHttpMessageHandlerTests.cs index b1f3ecda6b..19619fe1b1 100644 --- a/test/Sentry.Tests/SentryHttpMessageHandlerTests.cs +++ b/test/Sentry.Tests/SentryHttpMessageHandlerTests.cs @@ -77,6 +77,42 @@ public async Task SendAsync_SentryTraceHeaderNotSet_DoesntSetHeader_WhenUrlDoesn request.Headers.Should().NotContain(h => h.Key == headerName); } + [Fact] + public async Task SendAsync_SentryTraceHeaderNotSet_SetsBothHeadersHeader_WhenUrlMatchesPropagationOptions() + { + // Arrange + var hub = Substitute.For(); + var failedRequestHandler = Substitute.For(); + var options = new SentryOptions + { + TracePropagationTargets = new List + { + new("localhost") + } + }; + + hub.GetTraceHeader().ReturnsForAnyArgs( + SentryTraceHeader.Parse("6877cc6ac231622a3d1d518a472a65b8-5e3bc28befdb2e3c")); + + using var innerHandler = new RecordingHttpMessageHandler(new FakeHttpMessageHandler()); + using var sentryHandler = new SentryHttpMessageHandler(hub, options, innerHandler, failedRequestHandler); + using var client = new HttpClient(sentryHandler); + + // Act + await client.GetAsync("https://localhost/"); + + using var request = innerHandler.GetRequests().Single(); + + // Assert + // Both headers should be set, see https://github.com/getsentry/team-sdks/issues/41 + request.Headers.Should().Contain(h => + h.Key == SentryTraceHeader.HttpHeaderName && + string.Concat(h.Value) == "6877cc6ac231622a3d1d518a472a65b8-5e3bc28befdb2e3c"); + request.Headers.Should().Contain(h => + h.Key == W3CTraceHeader.HttpHeaderName && + string.Concat(h.Value) == "00-6877cc6ac231622a3d1d518a472a65b8-5e3bc28befdb2e3c-00"); + } + [Theory] [InlineData("sentry-trace")] [InlineData("traceparent")] From 358e1da996595d42627af8097702114aa326fe68 Mon Sep 17 00:00:00 2001 From: hangy Date: Wed, 9 Apr 2025 19:25:18 +0200 Subject: [PATCH 19/21] test: Enhance W3C trace header parsing tests to handle invalid trace flags --- src/Sentry/W3CTraceHeader.cs | 43 +++++++++++++++++++++--- test/Sentry.Tests/W3CTraceHeaderTests.cs | 5 ++- 2 files changed, 43 insertions(+), 5 deletions(-) diff --git a/src/Sentry/W3CTraceHeader.cs b/src/Sentry/W3CTraceHeader.cs index 13dbe8f01a..37f09039ea 100644 --- a/src/Sentry/W3CTraceHeader.cs +++ b/src/Sentry/W3CTraceHeader.cs @@ -7,6 +7,10 @@ internal class W3CTraceHeader { private const string SupportedVersion = "00"; + private const string TraceFlagsSampled = "01"; + + private const string TraceFlagsNotSampled = "00"; + /// /// The name of the W3C trace context header used for distributed tracing. /// This field contains the value "traceparent" which is part of the W3C Trace Context specification. @@ -70,9 +74,7 @@ public W3CTraceHeader(SentryTraceHeader source) var traceId = SentryId.Parse(components[1]); var spanId = SpanId.Parse(components[2]); - - var isSampled = string.Equals(components[3], "01", StringComparison.Ordinal) || - string.Equals(components[3], "09", StringComparison.Ordinal); + var isSampled = ConvertTraceFlagsToSampled(components[3]); return new W3CTraceHeader(new SentryTraceHeader(traceId, spanId, isSampled)); } @@ -84,5 +86,38 @@ public override string ToString() return $"{SupportedVersion}-{SentryTraceHeader.TraceId}-{SentryTraceHeader.SpanId}-{traceFlags}"; } - private static string? ConvertSampledToTraceFlags(bool? isSampled) => isSampled ?? false ? "01" : "00"; + private static string? ConvertSampledToTraceFlags(bool? isSampled) => (isSampled ?? false) ? TraceFlagsSampled : TraceFlagsNotSampled; + + private static bool? ConvertTraceFlagsToSampled(string? traceFlags) + { + if (string.IsNullOrWhiteSpace(traceFlags) || traceFlags.Length != 2) + { + return null; + } + + // In version 00 of the W3C Trace Context specification, the trace flags field is 2 hex digits. + // Only the first bit is used. We use string comparison first to avoid parsing the hex value in + // the bulk of all cases. + // See https://github.com/getsentry/sentry-dotnet/pull/4084#discussion_r2035771628 + if (string.Equals(traceFlags, TraceFlagsSampled, StringComparison.Ordinal)) + { + return true; + } + else if (string.Equals(traceFlags, TraceFlagsNotSampled, StringComparison.Ordinal)) + { + return false; + } + + // If the trace flags field is not "01" or "00", we try to parse it as a hex number. + // This is a fallback for cases where the trace flags field is not in the expected format. + if (!byte.TryParse(traceFlags, NumberStyles.HexNumber, CultureInfo.InvariantCulture, out byte traceFlagsBytes)) + { + // If it's not a valid hex number, we can't parse it. + return null; + } + + // The first bit of the trace flags field indicates whether the trace is sampled. + // We use bitwise AND to check if the first bit is set. + return (traceFlagsBytes & 0x01) == 1; + } } diff --git a/test/Sentry.Tests/W3CTraceHeaderTests.cs b/test/Sentry.Tests/W3CTraceHeaderTests.cs index 0691055cbb..9f825fa3dd 100644 --- a/test/Sentry.Tests/W3CTraceHeaderTests.cs +++ b/test/Sentry.Tests/W3CTraceHeaderTests.cs @@ -23,7 +23,9 @@ public void ToString_ConvertsToW3CFormat(bool? isSampled, string traceFlags) [InlineData("00-4bc7d217a6721c0e60e85e46d25fb3e5-f51f11f284da5299-01", "4bc7d217a6721c0e60e85e46d25fb3e5", "f51f11f284da5299", true)] [InlineData("00-3d19f80b6f7da306d7b5652745ec6173-703b42311109c14e-09", "3d19f80b6f7da306d7b5652745ec6173", "703b42311109c14e", true)] [InlineData("00-992d690c7a3691eb0f409a3ba6ecc0cc-b4f1f8cbcc61a0e5-00", "992d690c7a3691eb0f409a3ba6ecc0cc", "b4f1f8cbcc61a0e5", false)] - public void Parse_ValidW3CHeader_ReturnsW3CTraceHeader(string header, string expectedTraceId, string expectedSpanId, bool expectedIsSampled) + [InlineData("00-19938c125f92c552c2e2711393725319-2ce52eea8ffe1335-xz", "19938c125f92c552c2e2711393725319", "2ce52eea8ffe1335", null)] // Invalid trace flags should not cause an exception + [InlineData("00-fba65b9f95900925670373fc2943339e-c5113ff625da6c9a-420", "fba65b9f95900925670373fc2943339e", "c5113ff625da6c9a", null)] // Invalid trace flags should not cause an exception + public void Parse_ValidW3CHeader_ReturnsW3CTraceHeader(string header, string expectedTraceId, string expectedSpanId, bool? expectedIsSampled) { // Act var result = W3CTraceHeader.Parse(header); @@ -52,6 +54,7 @@ public void Parse_Returns_Null_WhenHeaderIsNullOrEmpty(string header) [Theory] [InlineData("00-4bc7d217a6721c0e60e85e46d25fb3e5-1000000000000000")] [InlineData("01-f5cb855e16344ddd1538d10f82f6a018-a7018579d434fee4")] + [InlineData("01-7f97dee64921546b7c238cb8a0c1209d-a82702bf47683069-00")] public void Parse_InvalidW3CHeader_ThrowsFormatException(string header) { // Act From cb91a92c74aac1fff3d26f7fe752d7aacc30f4b3 Mon Sep 17 00:00:00 2001 From: hangy Date: Wed, 9 Apr 2025 19:44:07 +0200 Subject: [PATCH 20/21] refactor: Move trace flags constants to public access in W3CTraceHeader class for use in unit tests --- src/Sentry/W3CTraceHeader.cs | 16 ++++++++++++---- test/Sentry.Tests/W3CTraceHeaderTests.cs | 6 +++--- 2 files changed, 15 insertions(+), 7 deletions(-) diff --git a/src/Sentry/W3CTraceHeader.cs b/src/Sentry/W3CTraceHeader.cs index 37f09039ea..65043594a6 100644 --- a/src/Sentry/W3CTraceHeader.cs +++ b/src/Sentry/W3CTraceHeader.cs @@ -7,16 +7,24 @@ internal class W3CTraceHeader { private const string SupportedVersion = "00"; - private const string TraceFlagsSampled = "01"; - - private const string TraceFlagsNotSampled = "00"; - /// /// The name of the W3C trace context header used for distributed tracing. /// This field contains the value "traceparent" which is part of the W3C Trace Context specification. /// public const string HttpHeaderName = "traceparent"; + /// + /// Represents the sampled trace flags value ("01") in W3C Trace Context specification. + /// This flag indicates that the trace is part of the sampling set and should be recorded. + /// + public const string TraceFlagsSampled = "01"; + + /// + /// Represents the unsampled trace flags value ("00") in W3C Trace Context specification. + /// This flag indicates that the trace is not part of the sampling set and should not be recorded. + /// + public const string TraceFlagsNotSampled = "00"; + /// /// Initializes a new instance of the class from a Sentry trace header. /// diff --git a/test/Sentry.Tests/W3CTraceHeaderTests.cs b/test/Sentry.Tests/W3CTraceHeaderTests.cs index 9f825fa3dd..f20bdccbfa 100644 --- a/test/Sentry.Tests/W3CTraceHeaderTests.cs +++ b/test/Sentry.Tests/W3CTraceHeaderTests.cs @@ -3,9 +3,9 @@ namespace Sentry.Tests; public class W3CTraceHeaderTests { [Theory] - [InlineData(true, "01")] - [InlineData(false, "00")] - [InlineData(null, "00")] + [InlineData(true, W3CTraceHeader.TraceFlagsSampled)] + [InlineData(false, W3CTraceHeader.TraceFlagsNotSampled)] + [InlineData(null, W3CTraceHeader.TraceFlagsNotSampled)] public void ToString_ConvertsToW3CFormat(bool? isSampled, string traceFlags) { // Arrange From 733544ca085bb5119cfd33dc94c88a3180e51234 Mon Sep 17 00:00:00 2001 From: James Crosswell Date: Fri, 11 Apr 2025 09:09:31 +1200 Subject: [PATCH 21/21] Update CHANGELOG.md --- CHANGELOG.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 09ff9c033a..3f2e6e7dea 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,11 +1,13 @@ # Changelog -## 5.5.1 +## Unreleased ### Features * If an incoming HTTP request has the `traceparent` header, it is now parsed and interpreted like the `sentry-trace` header. Outgoing requests now contain the `traceparent` header to facilitate integration with servesr that only support the [W3C Trace Context](https://www.w3.org/TR/trace-context/). ([#4084](https://github.com/getsentry/sentry-dotnet/pull/4084)) +## 5.5.1 + ### Fixes - Fix UWP Net Native compilation ([#4085](https://github.com/getsentry/sentry-dotnet/pull/4085))