Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,10 @@

- Prevent users from disabling AndroidEnableAssemblyCompression which leads to untrappable crash ([#4089](https://github.com/getsentry/sentry-dotnet/pull/4089))

### 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
Expand Down
24 changes: 24 additions & 0 deletions src/Sentry.AspNet/HttpContextExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,27 @@ public static class HttpContextExtensions
}
}

private static W3CTraceHeader? TryGetW3CTraceHeader(HttpContext context, SentryOptions? options)
{
var value = context.Request.Headers.Get(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;
}
}

private static BaggageHeader? TryGetBaggageHeader(HttpContext context, SentryOptions? options)
{
var value = context.Request.Headers.Get(BaggageHeader.HttpHeaderName);
Expand Down Expand Up @@ -64,7 +85,10 @@ 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);

var method = httpContext.Request.HttpMethod;
Expand Down
21 changes: 21 additions & 0 deletions src/Sentry.AspNetCore/Extensions/HttpContextExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,27 @@ internal static class HttpContextExtensions
}
}

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)
{
var value = context.Request.Headers.GetValueOrDefault(BaggageHeader.HttpHeaderName);
Expand Down
3 changes: 3 additions & 0 deletions src/Sentry.AspNetCore/SentryMiddleware.cs
Original file line number Diff line number Diff line change
Expand Up @@ -105,7 +105,10 @@ 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);
var transactionContext = hub.ContinueTrace(traceHeader, baggageHeader);

Expand Down
25 changes: 25 additions & 0 deletions src/Sentry.Azure.Functions.Worker/HttpRequestDataExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -123,7 +123,10 @@ 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);

return SentrySdk.ContinueTrace(traceHeader, baggageHeader, transactionName, Operation);
Expand Down
10 changes: 10 additions & 0 deletions src/Sentry/SentryMessageHandler.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
}
Expand All @@ -149,6 +150,15 @@ private void AddSentryTraceHeader(HttpRequestMessage request)
}
}

private void AddW3CTraceHeader(HttpRequestMessage request)
{
// Set trace header if it hasn't already been set
if (!request.Headers.Contains(W3CTraceHeader.HttpHeaderName) && _hub.GetTraceHeader() is { } traceHeader)
{
request.Headers.Add(W3CTraceHeader.HttpHeaderName, new W3CTraceHeader(traceHeader).ToString());
}
}

private void AddBaggageHeader(HttpRequestMessage request)
{
var baggage = _hub.GetBaggage();
Expand Down
3 changes: 2 additions & 1 deletion src/Sentry/SentryOptions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -952,7 +952,8 @@ public double? ProfilesSampleRate
/// <summary>
/// A customizable list of <see cref="StringOrRegex"/> objects, each containing either a
/// substring or regular expression pattern that can be used to control which outgoing HTTP requests
/// will have the <c>sentry-trace</c> and <c>baggage</c> headers propagated, for purposes of distributed tracing.
/// will have the <c>sentry-trace</c>, <c>traceparent</c>, and <c>baggage</c> headers propagated,
/// for purposes of distributed tracing.
/// The default value contains a single value of <c>.*</c>, which matches everything.
/// To disable propagation completely, clear this collection or set it to an empty collection.
/// </summary>
Expand Down
131 changes: 131 additions & 0 deletions src/Sentry/W3CTraceHeader.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
namespace Sentry;

/// <summary>
/// Extension methods for working with Sentry trace headers.
/// </summary>
internal class W3CTraceHeader
{
private const string SupportedVersion = "00";

/// <summary>
/// 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.
/// </summary>
public const string HttpHeaderName = "traceparent";

/// <summary>
/// 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.
/// </summary>
public const string TraceFlagsSampled = "01";

/// <summary>
/// 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.
/// </summary>
public const string TraceFlagsNotSampled = "00";

/// <summary>
/// Initializes a new instance of the <see cref="W3CTraceHeader"/> class from a Sentry trace header.
/// </summary>
/// <param name="source">The source Sentry trace header to create the W3C trace header from.</param>
/// <exception cref="ArgumentNullException">Thrown when <paramref name="source"/> is null.</exception>
public W3CTraceHeader(SentryTraceHeader source)
{
if (source is null)
{
throw new ArgumentNullException(nameof(source), "Source Sentry trace header cannot be null.");
}

SentryTraceHeader = source;
}

/// <summary>
/// Gets the Sentry trace header containing trace identification and sampling information.
/// </summary>
/// <value>
/// The Sentry trace header that contains the trace ID, span ID, and sampling decision.
/// </value>
public SentryTraceHeader SentryTraceHeader { get; }

/// <summary>
/// Parses a <see cref="SentryTraceHeader"/> from a string representation of the Sentry trace header.
/// </summary>
/// <param name="value">
/// A string containing the Sentry trace header, expected to follow the format "traceId-spanId-sampled",
/// where "sampled" is optional.
/// </param>
/// <returns>
/// A <see cref="SentryTraceHeader"/> object if parsing succeeds, or <c>null</c> if the input string is null, empty, or whitespace.
/// </returns>
/// <exception cref="FormatException">
/// Thrown if the input string does not contain a valid trace header format, specifically if it lacks required trace ID and span ID components.
/// </exception>
public static W3CTraceHeader? Parse(string value)
{
if (string.IsNullOrWhiteSpace(value))
{
return null;
}

var components = value.Split('-', StringSplitOptions.RemoveEmptyEntries);
if (components.Length < 4)
{
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 = ConvertTraceFlagsToSampled(components[3]);

return new W3CTraceHeader(new SentryTraceHeader(traceId, spanId, isSampled));
}

/// <inheritdoc/>
public override string ToString()
{
var traceFlags = ConvertSampledToTraceFlags(SentryTraceHeader.IsSampled);
return $"{SupportedVersion}-{SentryTraceHeader.TraceId}-{SentryTraceHeader.SpanId}-{traceFlags}";
}

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;
}
}
28 changes: 17 additions & 11 deletions test/Sentry.AspNetCore.Tests/SentryMiddlewareTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -698,7 +700,7 @@ public async Task InvokeAsync_RequestContainsSentryHeaders_ContinuesTrace()
var request = Substitute.For<HttpRequest>();
var fakeHeaders = new HeaderDictionary
{
{ "Sentry-Trace", "4b4d2878507b43d3af7dd8c4ab7a96d9-3cc6fd1337d243de"},
{ headerName, headerValue},
{ "Baggage", "sentry-trace_id=4b4d2878507b43d3af7dd8c4ab7a96d9, sentry-public_key=eb18e953812b41c3aeb042e666fd3b5c"},
};
_ = request.Headers.Returns(fakeHeaders);
Expand All @@ -710,19 +712,21 @@ public async Task InvokeAsync_RequestContainsSentryHeaders_ContinuesTrace()
_fixture.Hub.Received().ContinueTrace(Arg.Any<SentryTraceHeader>(), Arg.Any<BaggageHeader>());

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<HttpRequest>();
var fakeHeaders = new HeaderDictionary
{
{ "Sentry-Trace", "4b4d2878507b43d3af7dd8c4ab7a96d9-3cc6fd1337d243de"},
{ headerName, headerValue},
{ "Baggage", "sentry-trace_id=4b4d2878507b43d3af7dd8c4ab7a96d9, sentry-public_key=eb18e953812b41c3aeb042e666fd3b5c"},
};
var contextItems = new Dictionary<object, object>();
Expand All @@ -741,22 +745,24 @@ 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());
var transactionContext = contextItems[SentryMiddleware.BaggageHeaderItemKey] as BaggageHeader;
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<HttpRequest>();
var fakeHeaders = new HeaderDictionary
{
{ "Sentry-Trace", "4b4d2878507b43d3af7dd8c4ab7a96d9-3cc6fd1337d243de"},
{ headerName, headerValue},
{ "Baggage", "sentry-trace_id=4b4d2878507b43d3af7dd8c4ab7a96d9, sentry-public_key=eb18e953812b41c3aeb042e666fd3b5c"},
};
var contextItems = new Dictionary<object, object>();
Expand Down
Loading
Loading