Skip to content

Commit 2ffcca3

Browse files
authored
fix(aspnetcore): fall back to Activity.Current (#5572)
Keep traceparent and baggage propagation working when the synthetic TUnit.AspNetCore.Http span is not created. Also align the ASP.NET Core and OpenTelemetry docs with the current root-per-test trace model and CreateClient() guidance.
1 parent 640bdd2 commit 2ffcca3

34 files changed

Lines changed: 1003 additions & 150 deletions
48 KB
Loading
42 KB
Loading
42.8 KB
Loading
32.7 KB
Loading
101 KB
Loading
146 KB
Loading

Directory.Packages.props

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,7 @@
7171
<PackageVersion Include="OpenTelemetry.Exporter.OpenTelemetryProtocol" Version="1.15.2" />
7272
<PackageVersion Include="OpenTelemetry.Extensions.Hosting" Version="1.15.2" />
7373
<PackageVersion Include="OpenTelemetry.Instrumentation.AspNetCore" Version="1.15.1" />
74+
<PackageVersion Include="OpenTelemetry.Instrumentation.Http" Version="1.15.0" />
7475
<PackageVersion Include="NUnit" Version="4.5.1" />
7576
<PackageVersion Include="NUnit.Analyzers" Version="4.12.0" />
7677
<PackageVersion Include="NUnit3TestAdapter" Version="6.2.0" />

TUnit.AspNetCore.Core/Http/ActivityPropagationHandler.cs

Lines changed: 86 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -16,33 +16,42 @@ internal sealed class ActivityPropagationHandler : DelegatingHandler
1616
// cleaned up on process exit. Not disposed explicitly because multiple handler
1717
// instances share this source across concurrent tests.
1818
private static readonly ActivitySource HttpActivitySource = new("TUnit.AspNetCore.Http");
19+
private readonly Func<HttpRequestMessage, Activity?> _startActivity;
20+
21+
public ActivityPropagationHandler()
22+
{
23+
_startActivity = StartHttpActivity;
24+
}
25+
26+
internal ActivityPropagationHandler(Func<HttpRequestMessage, Activity?> startActivity)
27+
{
28+
_startActivity = startActivity;
29+
}
1930

2031
protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
2132
{
22-
var path = request.RequestUri?.AbsolutePath ?? request.RequestUri?.ToString() ?? "unknown";
23-
using var activity = HttpActivitySource.StartActivity(
24-
$"HTTP {request.Method} {path}",
25-
ActivityKind.Client);
33+
var ambientActivity = Activity.Current;
34+
using var activity = _startActivity(request);
2635

2736
if (activity is not null)
2837
{
2938
activity.SetTag("http.request.method", request.Method.Method);
3039
activity.SetTag("url.full", request.RequestUri?.ToString());
3140
activity.SetTag("server.address", request.RequestUri?.Host);
3241

33-
// Inject trace context headers (traceparent + tracestate) so the server
34-
// creates child activities under the same trace
35-
DistributedContextPropagator.Current.Inject(activity, request.Headers,
36-
static (headers, key, value) =>
37-
{
38-
if (headers is HttpRequestHeaders h)
39-
{
40-
h.Remove(key);
41-
h.TryAddWithoutValidation(key, value);
42-
}
43-
});
42+
// WebApplicationFactory bypasses DiagnosticsHandler, so when we synthesize
43+
// a client span we also need to flow the ambient baggage onto it explicitly.
44+
// Child Activities do not reliably surface parent baggage across all target
45+
// frameworks, but correlation relies on the test's baggage being propagated.
46+
CopyBaggage(ambientActivity, activity);
4447
}
4548

49+
// Propagate the current distributed trace even when the helper span is not
50+
// created (for example, when no listener is attached to TUnit.AspNetCore.Http).
51+
var propagationActivity = activity ?? ambientActivity;
52+
InjectTraceContext(propagationActivity, request.Headers);
53+
InjectBaggage(propagationActivity, request.Headers);
54+
4655
var response = await base.SendAsync(request, cancellationToken);
4756

4857
if (activity is not null)
@@ -56,4 +65,66 @@ protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage
5665

5766
return response;
5867
}
68+
69+
private static Activity? StartHttpActivity(HttpRequestMessage request)
70+
{
71+
var path = request.RequestUri?.AbsolutePath ?? request.RequestUri?.ToString() ?? "unknown";
72+
return HttpActivitySource.StartActivity(
73+
$"HTTP {request.Method} {path}",
74+
ActivityKind.Client);
75+
}
76+
77+
private static void InjectTraceContext(Activity? activity, HttpRequestHeaders headers)
78+
{
79+
if (activity is null)
80+
{
81+
return;
82+
}
83+
84+
// Inject trace context headers (traceparent + tracestate) so the server
85+
// creates child activities under the same trace. Respect pre-existing headers
86+
// so callers who explicitly set their own context win.
87+
DistributedContextPropagator.Current.Inject(activity, headers,
88+
static (targetHeaders, key, value) =>
89+
{
90+
if (targetHeaders is HttpRequestHeaders h && key is not null && !h.Contains(key))
91+
{
92+
h.TryAddWithoutValidation(key, value);
93+
}
94+
});
95+
}
96+
97+
private static void CopyBaggage(Activity? source, Activity destination)
98+
{
99+
if (source is null || ReferenceEquals(source, destination))
100+
{
101+
return;
102+
}
103+
104+
foreach (var (key, value) in source.Baggage)
105+
{
106+
if (key is null || destination.GetBaggageItem(key) is not null)
107+
{
108+
continue;
109+
}
110+
111+
destination.SetBaggage(key, value);
112+
}
113+
}
114+
115+
private static void InjectBaggage(Activity? activity, HttpRequestHeaders headers)
116+
{
117+
// If a propagator already emitted W3C baggage (e.g. OTel SDK's BaggagePropagator),
118+
// preserve it; otherwise emit our own so LegacyPropagator-based stacks still
119+
// propagate test correlation baggage.
120+
if (activity is null || headers.Contains("baggage"))
121+
{
122+
return;
123+
}
124+
125+
if (TUnit.Core.TUnitActivitySource.TryBuildBaggageHeader(activity) is { } baggage)
126+
{
127+
headers.TryAddWithoutValidation("baggage", baggage);
128+
}
129+
}
59130
}

TUnit.AspNetCore.Core/TUnit.AspNetCore.Core.csproj

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010

1111
<ItemGroup>
1212
<InternalsVisibleTo Include="TUnit.AspNetCore" />
13+
<InternalsVisibleTo Include="TUnit.AspNetCore.Tests" />
1314
</ItemGroup>
1415

1516
<ItemGroup>
Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
using System.Diagnostics;
2+
using TUnit.Assertions;
3+
using TUnit.Assertions.Extensions;
4+
using TUnit.Core;
5+
6+
namespace TUnit.AspNetCore.Tests;
7+
8+
public class ActivityPropagationHandlerTests
9+
{
10+
[Test]
11+
public async Task SendAsync_InjectsTraceContext_WhenHelperSpanIsCreated()
12+
{
13+
Activity.Current = null;
14+
using var listenerScope = new ActivityListenerScope();
15+
using var activity = new Activity("test-root").Start();
16+
activity.SetBaggage(TUnitActivitySource.TagTestId, "my-test-context-id");
17+
18+
var captured = new CaptureHandler();
19+
var handler = CreateHandler();
20+
handler.InnerHandler = captured;
21+
using var client = new HttpClient(handler);
22+
23+
await client.GetAsync("http://localhost/test");
24+
25+
var traceparent = captured.LastRequest!.Headers.GetValues("traceparent").First();
26+
var parts = traceparent.Split('-');
27+
var baggageHeader = captured.LastRequest.Headers.GetValues("baggage").First();
28+
29+
await Assert.That(parts[1]).IsEqualTo(activity.TraceId.ToString());
30+
await Assert.That(parts[2]).IsNotEqualTo(activity.SpanId.ToString());
31+
await Assert.That(baggageHeader).Contains(TUnitActivitySource.TagTestId);
32+
await Assert.That(baggageHeader).Contains("my-test-context-id");
33+
}
34+
35+
[Test]
36+
public async Task SendAsync_FallsBackToActivityCurrent_WhenHelperSpanIsNotCreated()
37+
{
38+
Activity.Current = null;
39+
using var activity = new Activity("test-root").Start();
40+
activity.SetBaggage(TUnitActivitySource.TagTestId, "my-test-context-id");
41+
42+
var captured = new CaptureHandler();
43+
var handler = CreateHandler(static _ => null);
44+
handler.InnerHandler = captured;
45+
using var client = new HttpClient(handler);
46+
47+
await client.GetAsync("http://localhost/test");
48+
49+
var traceparent = captured.LastRequest!.Headers.GetValues("traceparent").First();
50+
var parts = traceparent.Split('-');
51+
var baggageHeader = captured.LastRequest.Headers.GetValues("baggage").First();
52+
53+
await Assert.That(parts[1]).IsEqualTo(activity.TraceId.ToString());
54+
await Assert.That(parts[2]).IsEqualTo(activity.SpanId.ToString());
55+
await Assert.That(baggageHeader).Contains(TUnitActivitySource.TagTestId);
56+
await Assert.That(baggageHeader).Contains("my-test-context-id");
57+
}
58+
59+
[Test]
60+
public async Task SendAsync_DoesNotInjectTraceContext_WhenNoAmbientActivityExists()
61+
{
62+
Activity.Current = null;
63+
64+
var captured = new CaptureHandler();
65+
var handler = CreateHandler(static _ => null);
66+
handler.InnerHandler = captured;
67+
using var client = new HttpClient(handler);
68+
69+
await client.GetAsync("http://localhost/test");
70+
71+
await Assert.That(captured.LastRequest!.Headers.Contains("traceparent")).IsFalse();
72+
await Assert.That(captured.LastRequest.Headers.Contains("baggage")).IsFalse();
73+
}
74+
75+
private static DelegatingHandler CreateHandler(Func<HttpRequestMessage, Activity?>? startActivity = null)
76+
{
77+
return startActivity is null
78+
? new ActivityPropagationHandler()
79+
: new ActivityPropagationHandler(startActivity);
80+
}
81+
82+
private sealed class ActivityListenerScope : IDisposable
83+
{
84+
private readonly ActivityListener _listener = new()
85+
{
86+
ShouldListenTo = static source => source.Name == "TUnit.AspNetCore.Http",
87+
Sample = static (ref ActivityCreationOptions<ActivityContext> _) =>
88+
ActivitySamplingResult.AllDataAndRecorded
89+
};
90+
91+
public ActivityListenerScope()
92+
{
93+
ActivitySource.AddActivityListener(_listener);
94+
}
95+
96+
public void Dispose()
97+
{
98+
_listener.Dispose();
99+
}
100+
}
101+
102+
private sealed class CaptureHandler : HttpMessageHandler
103+
{
104+
public HttpRequestMessage? LastRequest { get; private set; }
105+
106+
protected override Task<HttpResponseMessage> SendAsync(
107+
HttpRequestMessage request, CancellationToken cancellationToken)
108+
{
109+
LastRequest = request;
110+
return Task.FromResult(new HttpResponseMessage(System.Net.HttpStatusCode.OK));
111+
}
112+
}
113+
}

0 commit comments

Comments
 (0)