Skip to content
Merged
Show file tree
Hide file tree
Changes from 6 commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
9735bfc
feat: Send W3C traceparent using `SentryMessageHandler`
hangy Apr 5, 2025
0954802
docs: Update documentation to include 'traceparent' in HTTP header pr…
hangy Apr 5, 2025
d05d9c9
test: Refactor Sentry trace header tests to use parameterized inputs
hangy Apr 5, 2025
d572cef
fix: Typo
hangy Apr 5, 2025
23b3d52
refactor: Make SentryTraceHeaderExtensions internal
hangy Apr 5, 2025
e36a636
refactor: Move W3C header related stuff to dedicated `W3CTraceHeader`…
hangy Apr 5, 2025
cffea5c
refactor: Update W3CTraceHeader to improve parsing and error handling
hangy Apr 5, 2025
4818a25
feat: Implement TryGetW3CTraceHeader in Middlewares and Extensions
hangy Apr 5, 2025
f14cad5
refactor: Convert Sentry header tests to use [Theory] with InlineData…
hangy Apr 5, 2025
108a0fc
fix: traceparent's `trace-flags` are mandatory
hangy Apr 6, 2025
5c510a4
docs: Add changelog entry for traceparent header support in HTTP requ…
hangy Apr 7, 2025
8cf3195
test: Improve code coverage of `W3CTraceHeader` class
hangy Apr 7, 2025
66e4d5a
Merge branch 'main' into 3069-traceparent-header
jamescrosswell Apr 8, 2025
4d86280
docs: Document the priority of sentry-trace and traceparent headers
hangy Apr 8, 2025
f3cd9c7
test: Use `HeaderName` contstant to refer to HTTP tracing headers
hangy Apr 8, 2025
dc5c2cf
feat: Update W3C trace header parsing to support additional sampled flag
hangy Apr 8, 2025
cc4b563
test: Add additional test case for Sentry trace header parsing
hangy Apr 8, 2025
9c56dbb
chore: Remove redundant tests
hangy Apr 8, 2025
44c0d98
test: Add test to ensure that `sentry-trace` and `traceparent` header…
hangy Apr 8, 2025
358e1da
test: Enhance W3C trace header parsing tests to handle invalid trace …
hangy Apr 9, 2025
cb91a92
refactor: Move trace flags constants to public access in W3CTraceHead…
hangy Apr 9, 2025
5ced28c
Merge branch 'main' into pr/4084
jamescrosswell Apr 10, 2025
733544c
Update CHANGELOG.md
jamescrosswell Apr 10, 2025
a6209ee
Merge branch 'main' into 3069-traceparent-header
hangy Apr 13, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions src/Sentry.AspNet/HttpContextExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
2 changes: 2 additions & 0 deletions src/Sentry.AspNetCore/SentryMiddleware.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,8 @@ private async Task<TransactionContext> 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);
Expand Down
3 changes: 2 additions & 1 deletion src/Sentry/W3CTraceHeader.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This looks a little bit fragile. components[2] contains a hex encoded a bit flag type. The last bit in that flag indicates whether the trace is sampled or not.

So yes, 01 and 09 are examples of values where the sampled flag is 1... there are other examples as well though (e.g. FF)

HEX Binary
0x01 0b00000001
0x09 0b00001001
.. ..
0xFF 0b11111111

Currently I think the W3C spec only uses the last bit in this flag, so in practice the value will only ever be "00" or "01" (today)... but I think worth writing this code with the expectation that the other bits in the flag may be used in the future.

Something like this:

 var isSampled = Convert.ToInt32(components[3], 16) & 0x01 == 1;

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I wanted to avoid parsing the string as honestly, as long as only version 00 is supported, other bits being set would be against the W3C spec, anyways. Will look into it, though. Potentially with byte.Parse

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I wanted to avoid parsing the string

For performance reasons?

We could leave it as is (don't bother with 09 either) and just put a comment in the code warning that as only the least significant bit is used at the moment, this code only works for scenarios where all the other bits are zero.

I'm a bit concerned we might see variants like "01" and "1" in the string representation... which wouldn't be per the W3C spec either but, as Sentry is in the business of capturing bugs, we don't want to be crashing people's applications... so we want to take a pretty cautious approach about assumptions re inputs.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For performance reasons?

Yup. My assumption was that string comparison is highly optimized and will work well. I don't want to argue based solely on my gut feeling, so I just ran a benchmark that kinda surprised me:

benchmark
using System.Globalization;
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;

BenchmarkRunner.Run<Benchmarks>();

[MemoryDiagnoser, RankColumn]
public class Benchmarks
{
    [Benchmark]
    public bool StringCompareNoMatchOrdinalIgnoreCase() => "00".Equals("01", StringComparison.OrdinalIgnoreCase);

    [Benchmark]
    public bool StringCompareMatchOrdinalIgnoreCase() => "00".Equals("00", StringComparison.OrdinalIgnoreCase);

    [Benchmark]
    public bool StringCompareNoMatchOrdinal() => "00".Equals("01", StringComparison.Ordinal);

    [Benchmark]
    public bool ByteParseMatch() => byte.TryParse("01", System.Globalization.NumberStyles.HexNumber, CultureInfo.InvariantCulture, out byte b) && (b & 0x01) == 1;

    [Benchmark]
    public bool ByteParseNoMatch() => byte.TryParse("00", System.Globalization.NumberStyles.HexNumber, CultureInfo.InvariantCulture, out byte b) && (b & 0x01) == 1;

    [Benchmark]
    public bool IntParseMatch() => int.TryParse("01", System.Globalization.NumberStyles.HexNumber, CultureInfo.InvariantCulture, out int b) && (b & 0x01) == 1;

    [Benchmark]
    public bool IntParseNoMatch() => int.TryParse("00", System.Globalization.NumberStyles.HexNumber, CultureInfo.InvariantCulture, out int b) && (b & 0x01) == 1;
}
// * Summary *

BenchmarkDotNet v0.14.0, Ubuntu 24.10 (Oracular Oriole) WSL
AMD Ryzen 7 7800X3D, 1 CPU, 16 logical and 8 physical cores
.NET SDK 9.0.202
  [Host]     : .NET 9.0.3 (9.0.325.11113), X64 RyuJIT AVX-512F+CD+BW+DQ+VL+VBMI
  DefaultJob : .NET 9.0.3 (9.0.325.11113), X64 RyuJIT AVX-512F+CD+BW+DQ+VL+VBMI


| Method                                | Mean      | Error     | StdDev    | Rank | Allocated |
|-------------------------------------- |----------:|----------:|----------:|-----:|----------:|
| StringCompareNoMatchOrdinalIgnoreCase | 0.0000 ns | 0.0000 ns | 0.0000 ns |    1 |         - |
| StringCompareMatchOrdinalIgnoreCase   | 0.0000 ns | 0.0000 ns | 0.0000 ns |    1 |         - |
| StringCompareNoMatchOrdinal           | 0.0000 ns | 0.0000 ns | 0.0000 ns |    1 |         - |
| ByteParseMatch                        | 3.0734 ns | 0.0212 ns | 0.0199 ns |    3 |         - |
| ByteParseNoMatch                      | 2.6390 ns | 0.0196 ns | 0.0183 ns |    2 |         - |
| IntParseMatch                         | 3.2432 ns | 0.0161 ns | 0.0151 ns |    4 |         - |
| IntParseNoMatch                       | 2.6208 ns | 0.0158 ns | 0.0140 ns |    2 |         - |

// * Warnings *
ZeroMeasurement
  Benchmarks.StringCompareNoMatchOrdinalIgnoreCase: Default -> The method duration is indistinguishable from the empty method duration
  Benchmarks.StringCompareMatchOrdinalIgnoreCase: Default   -> The method duration is indistinguishable from the empty method duration
  Benchmarks.StringCompareNoMatchOrdinal: Default           -> The method duration is indistinguishable from the empty method duration

// * Hints *
Outliers
  Benchmarks.ByteParseMatch: Default  -> 1 outlier  was  detected (4.12 ns)
  Benchmarks.IntParseNoMatch: Default -> 1 outlier  was  removed (3.76 ns)

// * Legends *
  Mean      : Arithmetic mean of all measurements
  Error     : Half of 99.9% confidence interval
  StdDev    : Standard deviation of all measurements
  Rank      : Relative position of current benchmark mean among all benchmarks (Arabic style)
  Allocated : Allocated memory per single operation (managed only, inclusive, 1KB = 1024B)
  1 ns      : 1 Nanosecond (0.000000001 sec)

This makes it look like parsing the string to a byte/int and then checking for the bitmask is orders of magnitude slower than string compare. There's no real harm in combining this, though, so I'll probably do that. 😄

I'm a bit concerned we might see variants like "01" and "1" in the string representation... which wouldn't be per the W3C spec either but, as Sentry is in the business of capturing bugs, we don't want to be crashing people's applications... so we want to take a pretty cautious approach about assumptions re inputs.

ACK. The worst case should be that a sampled = true flag isn't captured. That should be covered by the approach, though.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please take a look at 358e1da for a proposed solution

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice, that seems like the best of both worlds. Ordinarily the most efficient code path should run. And if processes are doing weird stuff with the traceparent headers upstream, it won't break (will just burn a few more cycles to process it). 👍🏻


return new W3CTraceHeader(new SentryTraceHeader(traceId, spanId, isSampled));
}
Expand Down
47 changes: 29 additions & 18 deletions test/Sentry.Tests/SentryHttpMessageHandlerTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -11,18 +11,27 @@ public class SentryHttpMessageHandlerTests
{
[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_ByDefault(string traceHeader, string headerName, string expectedValue)
public async Task SendAsync_SentryTraceHeaderNotSet_SetsHeader_WhenUrlMatchesPropagationOptions(string traceHeader, string headerName, string expectedValue)
{
// Arrange
var hub = Substitute.For<IHub>();
var failedRequestHandler = Substitute.For<ISentryFailedRequestHandler>();
var options = new SentryOptions
{
TracePropagationTargets = new List<StringOrRegex>
{
new("localhost")
}
};

hub.GetTraceHeader().ReturnsForAnyArgs(
SentryTraceHeader.Parse(traceHeader));

using var innerHandler = new RecordingHttpMessageHandler(new FakeHttpMessageHandler());
using var sentryHandler = new SentryHttpMessageHandler(innerHandler, hub);
using var sentryHandler = new SentryHttpMessageHandler(hub, options, innerHandler, failedRequestHandler);
using var client = new HttpClient(sentryHandler);

// Act
Expand All @@ -37,10 +46,9 @@ public async Task SendAsync_SentryTraceHeaderNotSet_SetsHeader_ByDefault(string
}

[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)
[InlineData("sentry-trace")]
[InlineData("traceparent")]
public async Task SendAsync_SentryTraceHeaderNotSet_DoesntSetHeader_WhenUrlDoesntMatchesPropagationOptions(string headerName)
{
// Arrange
var hub = Substitute.For<IHub>();
Expand All @@ -49,12 +57,12 @@ public async Task SendAsync_SentryTraceHeaderNotSet_SetsHeader_WhenUrlMatchesPro
{
TracePropagationTargets = new List<StringOrRegex>
{
new("localhost")
new("foo")
}
};

hub.GetTraceHeader().ReturnsForAnyArgs(
SentryTraceHeader.Parse(traceHeader));
SentryTraceHeader.Parse("75302ac48a024bde9a3b3734a82e36c8-1000000000000000-0"));

using var innerHandler = new RecordingHttpMessageHandler(new FakeHttpMessageHandler());
using var sentryHandler = new SentryHttpMessageHandler(hub, options, innerHandler, failedRequestHandler);
Expand All @@ -66,15 +74,11 @@ public async Task SendAsync_SentryTraceHeaderNotSet_SetsHeader_WhenUrlMatchesPro
using var request = innerHandler.GetRequests().Single();

// Assert
request.Headers.Should().Contain(h =>
h.Key == headerName &&
string.Concat(h.Value) == expectedValue);
request.Headers.Should().NotContain(h => h.Key == headerName);
}

[Theory]
[InlineData("sentry-trace")]
[InlineData("traceparent")]
public async Task SendAsync_SentryTraceHeaderNotSet_DoesntSetHeader_WhenUrlDoesntMatchesPropagationOptions(string headerName)
[Fact]
public async Task SendAsync_SentryTraceHeaderNotSet_SetsBothHeadersHeader_WhenUrlMatchesPropagationOptions()
{
// Arrange
var hub = Substitute.For<IHub>();
Expand All @@ -83,12 +87,12 @@ public async Task SendAsync_SentryTraceHeaderNotSet_DoesntSetHeader_WhenUrlDoesn
{
TracePropagationTargets = new List<StringOrRegex>
{
new("foo")
new("localhost")
}
};

hub.GetTraceHeader().ReturnsForAnyArgs(
SentryTraceHeader.Parse("75302ac48a024bde9a3b3734a82e36c8-1000000000000000-0"));
SentryTraceHeader.Parse("6877cc6ac231622a3d1d518a472a65b8-5e3bc28befdb2e3c"));

using var innerHandler = new RecordingHttpMessageHandler(new FakeHttpMessageHandler());
using var sentryHandler = new SentryHttpMessageHandler(hub, options, innerHandler, failedRequestHandler);
Expand All @@ -100,7 +104,13 @@ public async Task SendAsync_SentryTraceHeaderNotSet_DoesntSetHeader_WhenUrlDoesn
using var request = innerHandler.GetRequests().Single();

// Assert
request.Headers.Should().NotContain(h => h.Key == headerName);
// 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]
Expand Down Expand Up @@ -330,6 +340,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)
Expand Down
16 changes: 8 additions & 8 deletions test/Sentry.Tests/W3CTraceHeaderTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
Loading