From 2edf64ed8da52a1511099cca3ccddf6331a5fddd Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Wed, 22 Apr 2026 21:05:04 +0100 Subject: [PATCH 1/9] fix(telemetry): remove duplicate HTTP client spans MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Strip span synthesis from both HTTP propagation handlers. .NET's System.Net.Http ActivitySource already emits a properly-shaped client span for real-socket traffic (Aspire), and the ASP.NET Core server span carries HTTP semconv tags for in-memory WAF traffic — synthesizing a second client span produced duplicate rows in trace timelines. Both handlers collapse to a single call to a shared HttpActivityPropagator that only injects traceparent + baggage so the SUT can correlate requests to the originating test. TUnitTestCorrelationProcessor now tags in OnEnd as well as OnStart: spans with a remote-context parent (ASP.NET Core server spans created from extracted traceparent) receive baggage via the propagator after StartActivity returns, so OnStart alone couldn't see it — the previous topology masked this by tagging the synthesized client span instead. Public-API impact: AspireHttpSourceName removed (shipped only in the unreleased #5666); AspNetCoreHttpSourceName marked [Obsolete] for binary compatibility. --- .../Http/ActivityPropagationHandler.cs | 150 +----------- .../TestWebApplicationFactory.cs | 13 +- .../ActivityPropagationHandlerTests.cs | 171 ++++--------- .../AutoConfigureOpenTelemetryTests.cs | 16 +- .../BaggagePropagationHandlerTests.cs | 228 +++--------------- .../Http/TUnitBaggagePropagationHandler.cs | 145 +---------- TUnit.Core/HttpActivityPropagator.cs | 47 ++++ TUnit.Core/TUnitActivitySource.cs | 16 +- .../CorrelationProcessorTests.cs | 27 +++ TUnit.OpenTelemetry/AutoStart.cs | 2 - .../TUnitTestCorrelationProcessor.cs | 30 ++- ...Has_No_API_Changes.DotNet10_0.verified.txt | 4 +- ..._Has_No_API_Changes.DotNet8_0.verified.txt | 4 +- ..._Has_No_API_Changes.DotNet9_0.verified.txt | 4 +- ...Has_No_API_Changes.DotNet10_0.verified.txt | 3 +- ..._Has_No_API_Changes.DotNet8_0.verified.txt | 3 +- ..._Has_No_API_Changes.DotNet9_0.verified.txt | 3 +- docs/docs/examples/aspnet.md | 2 +- docs/docs/examples/opentelemetry.md | 8 +- docs/docs/guides/distributed-tracing.md | 2 +- 20 files changed, 248 insertions(+), 630 deletions(-) create mode 100644 TUnit.Core/HttpActivityPropagator.cs diff --git a/TUnit.AspNetCore.Core/Http/ActivityPropagationHandler.cs b/TUnit.AspNetCore.Core/Http/ActivityPropagationHandler.cs index 5b64dde082..7c57590183 100644 --- a/TUnit.AspNetCore.Core/Http/ActivityPropagationHandler.cs +++ b/TUnit.AspNetCore.Core/Http/ActivityPropagationHandler.cs @@ -1,147 +1,23 @@ -using System.Diagnostics; -using System.Net.Http.Headers; +using TUnit.Core; namespace TUnit.AspNetCore; /// -/// DelegatingHandler that creates Activity spans for HTTP requests and propagates -/// trace context via the W3C traceparent header. This bridges the gap where -/// -/// creates an HttpClient with an in-memory handler, bypassing .NET's built-in -/// DiagnosticsHandler that normally creates HTTP Activity spans. +/// DelegatingHandler that injects W3C traceparent and baggage headers into +/// outgoing requests so the SUT can correlate them to the originating test. /// +/// +/// No client Activity is created here. For in-memory WebApplicationFactory traffic the +/// ASP.NET Core server span becomes a direct child of the ambient test Activity — no synthetic +/// client span is needed to stitch the trace. For SUT-initiated IHttpClientFactory +/// pipelines, the runtime's System.Net.Http ActivitySource already emits a properly-shaped +/// client span. +/// internal sealed class ActivityPropagationHandler : DelegatingHandler { - // Intentionally process-scoped: lives for the test process lifetime and is - // cleaned up on process exit. Not disposed explicitly because multiple handler - // instances share this source across concurrent tests. - private static readonly ActivitySource HttpActivitySource = new("TUnit.AspNetCore.Http"); - private readonly Func _startActivity; - - public ActivityPropagationHandler() - { - _startActivity = StartHttpActivity; - } - - internal ActivityPropagationHandler(Func startActivity) + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) { - _startActivity = startActivity; - } - - protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) - { - var ambientActivity = Activity.Current; - using var activity = _startActivity(request); - - if (activity is not null) - { - activity.SetTag("http.request.method", request.Method.Method); - activity.SetTag("url.full", request.RequestUri?.ToString()); - activity.SetTag("server.address", request.RequestUri?.Host); - - // WebApplicationFactory bypasses DiagnosticsHandler, so when we synthesize - // a client span we also need to flow the ambient baggage onto it explicitly. - // Child Activities do not reliably surface parent baggage across all target - // frameworks, but correlation relies on the test's baggage being propagated. - CopyBaggage(ambientActivity, activity); - } - - // Propagate the current distributed trace even when the helper span is not - // created (for example, when no listener is attached to TUnit.AspNetCore.Http). - var propagationActivity = activity ?? ambientActivity; - InjectTraceContext(propagationActivity, request.Headers); - InjectBaggage(propagationActivity, request.Headers); - - HttpResponseMessage response; - try - { - response = await base.SendAsync(request, cancellationToken).ConfigureAwait(false); - } - catch (Exception ex) - { - // Only the synthesized client span gets exception metadata. When no listener is - // attached, propagation falls back to the ambient activity and there is no extra - // HTTP client span to annotate. - TUnit.Core.TUnitActivitySource.RecordException(activity, ex); - throw; - } - - if (activity is not null) - { - var statusCode = (int)response.StatusCode; - activity.SetTag("http.response.status_code", statusCode); - if (statusCode >= 400) - { - activity.SetStatus(ActivityStatusCode.Error); - activity.SetTag("error.type", statusCode.ToString()); - } - } - - return response; - } - - private static Activity? StartHttpActivity(HttpRequestMessage request) - { - var method = string.IsNullOrWhiteSpace(request.Method.Method) - ? "HTTP" - : request.Method.Method; - return HttpActivitySource.StartActivity( - method, - ActivityKind.Client); - } - - private static void InjectTraceContext(Activity? activity, HttpRequestHeaders headers) - { - if (activity is null) - { - return; - } - - // Inject trace context headers (traceparent + tracestate) so the server - // creates child activities under the same trace. Respect pre-existing headers - // so callers who explicitly set their own context win. - DistributedContextPropagator.Current.Inject(activity, headers, - static (targetHeaders, key, value) => - { - if (targetHeaders is HttpRequestHeaders h && key is not null && !h.Contains(key)) - { - h.TryAddWithoutValidation(key, value); - } - }); - } - - private static void CopyBaggage(Activity? source, Activity destination) - { - if (source is null || ReferenceEquals(source, destination)) - { - return; - } - - foreach (var (key, value) in source.Baggage) - { - // Preserve baggage already attached to the synthesized client span itself. - if (key is null || destination.GetBaggageItem(key) is not null) - { - continue; - } - - destination.SetBaggage(key, value); - } - } - - private static void InjectBaggage(Activity? activity, HttpRequestHeaders headers) - { - // If a propagator already emitted W3C baggage (e.g. OTel SDK's BaggagePropagator), - // preserve it; otherwise emit our own so LegacyPropagator-based stacks still - // propagate test correlation baggage. - if (activity is null || headers.Contains(TUnit.Core.TUnitActivitySource.BaggageHeader)) - { - return; - } - - if (TUnit.Core.TUnitActivitySource.TryBuildBaggageHeader(activity) is { } baggage) - { - headers.TryAddWithoutValidation(TUnit.Core.TUnitActivitySource.BaggageHeader, baggage); - } + HttpActivityPropagator.Inject(System.Diagnostics.Activity.Current, request.Headers); + return base.SendAsync(request, cancellationToken); } } diff --git a/TUnit.AspNetCore.Core/TestWebApplicationFactory.cs b/TUnit.AspNetCore.Core/TestWebApplicationFactory.cs index 12af759107..a037af350d 100644 --- a/TUnit.AspNetCore.Core/TestWebApplicationFactory.cs +++ b/TUnit.AspNetCore.Core/TestWebApplicationFactory.cs @@ -103,17 +103,16 @@ protected override void ConfigureWebHost(IWebHostBuilder builder) /// /// Adds TUnit's default OpenTelemetry tracing configuration to : - /// the TUnit.AspNetCore.Http activity source, the - /// , and ASP.NET Core + HttpClient instrumentation. - /// Safe to call even if the SUT already registers these — OpenTelemetry de-duplicates them. - /// Also safe when combined with the TUnit.OpenTelemetry zero-config package: the - /// SUT and test-runner TracerProviders each carry their own processor, but the - /// processor's idempotent OnStart guard prevents duplicate tunit.test.id tags. + /// the and ASP.NET Core + HttpClient + /// instrumentation. Safe to call even if the SUT already registers these — OpenTelemetry + /// de-duplicates them. Also safe when combined with the TUnit.OpenTelemetry + /// zero-config package: the SUT and test-runner TracerProviders each carry their + /// own processor, and the processor's idempotent tagging guard prevents duplicate + /// tunit.test.id tags across its OnStart/OnEnd hooks. /// private static void AddTUnitOpenTelemetry(IServiceCollection services) { services.AddOpenTelemetry().WithTracing(tracing => tracing - .AddSource(TUnitActivitySource.AspNetCoreHttpSourceName) .AddProcessor(new TUnitTestCorrelationProcessor()) .AddAspNetCoreInstrumentation() .AddHttpClientInstrumentation()); diff --git a/TUnit.AspNetCore.Tests/ActivityPropagationHandlerTests.cs b/TUnit.AspNetCore.Tests/ActivityPropagationHandlerTests.cs index 0cc0ef25eb..f75906e16a 100644 --- a/TUnit.AspNetCore.Tests/ActivityPropagationHandlerTests.cs +++ b/TUnit.AspNetCore.Tests/ActivityPropagationHandlerTests.cs @@ -1,4 +1,3 @@ -using System.Collections.Concurrent; using System.Diagnostics; using System.Net; using TUnit.Assertions; @@ -11,70 +10,54 @@ namespace TUnit.AspNetCore.Tests; public class ActivityPropagationHandlerTests { [Test] - public async Task SendAsync_InjectsTraceContext_WhenHelperSpanIsCreated() + public async Task SendAsync_InjectsTraceparent_FromAmbientActivity() { Activity.Current = null; - using var listenerScope = new ActivityListenerScope(); using var activity = new Activity("test-root").Start(); - activity.SetBaggage(TUnitActivitySource.TagTestId, "my-test-context-id"); var captured = new CaptureHandler(); - var handler = CreateHandler(); - handler.InnerHandler = captured; + var handler = new ActivityPropagationHandler { InnerHandler = captured }; using var client = new HttpClient(handler); await client.GetAsync("http://localhost/test"); var traceparent = captured.LastRequest!.Headers.GetValues("traceparent").First(); var parts = traceparent.Split('-'); - var baggageHeader = captured.LastRequest.Headers.GetValues("baggage").First(); - var clientSpan = await Assert.That(listenerScope.StoppedActivities).HasSingleItem(); await AssertValidW3CTraceparent(traceparent); await Assert.That(parts[1]).IsEqualTo(activity.TraceId.ToString()); - await Assert.That(parts[2]).IsEqualTo(clientSpan.SpanId); - await Assert.That(parts[2]).IsNotEqualTo(activity.SpanId.ToString()); - await Assert.That(clientSpan.TraceId).IsEqualTo(activity.TraceId.ToString()); - await Assert.That(clientSpan.ParentSpanId).IsEqualTo(activity.SpanId.ToString()); - await Assert.That(clientSpan.Kind).IsEqualTo(ActivityKind.Client); - await Assert.That(clientSpan.DisplayName).IsEqualTo("GET"); - await Assert.That(baggageHeader).Contains(TUnitActivitySource.TagTestId); - await Assert.That(baggageHeader).Contains("my-test-context-id"); + // No synthesized client span — traceparent's parent-id is the ambient activity itself. + await Assert.That(parts[2]).IsEqualTo(activity.SpanId.ToString()); } [Test] - public async Task SendAsync_FallsBackToActivityCurrent_WhenHelperSpanIsNotCreated() + public async Task SendAsync_InjectsBaggage_FromAmbientActivity() { Activity.Current = null; using var activity = new Activity("test-root").Start(); activity.SetBaggage(TUnitActivitySource.TagTestId, "my-test-context-id"); + activity.SetBaggage("custom.key", "custom-value"); var captured = new CaptureHandler(); - var handler = CreateHandler(static _ => null); - handler.InnerHandler = captured; + var handler = new ActivityPropagationHandler { InnerHandler = captured }; using var client = new HttpClient(handler); await client.GetAsync("http://localhost/test"); - var traceparent = captured.LastRequest!.Headers.GetValues("traceparent").First(); - var parts = traceparent.Split('-'); - var baggageHeader = captured.LastRequest.Headers.GetValues("baggage").First(); - - await AssertValidW3CTraceparent(traceparent); - await Assert.That(parts[1]).IsEqualTo(activity.TraceId.ToString()); - await Assert.That(parts[2]).IsEqualTo(activity.SpanId.ToString()); + var baggageHeader = captured.LastRequest!.Headers.GetValues("baggage").First(); await Assert.That(baggageHeader).Contains(TUnitActivitySource.TagTestId); await Assert.That(baggageHeader).Contains("my-test-context-id"); + await Assert.That(baggageHeader).Contains("custom.key"); + await Assert.That(baggageHeader).Contains("custom-value"); } [Test] - public async Task SendAsync_DoesNotInjectTraceContext_WhenNoAmbientActivityExists() + public async Task SendAsync_NoAmbientActivity_InjectsNothing() { Activity.Current = null; var captured = new CaptureHandler(); - var handler = CreateHandler(static _ => null); - handler.InnerHandler = captured; + var handler = new ActivityPropagationHandler { InnerHandler = captured }; using var client = new HttpClient(handler); await client.GetAsync("http://localhost/test"); @@ -84,58 +67,30 @@ public async Task SendAsync_DoesNotInjectTraceContext_WhenNoAmbientActivityExist } [Test] - public async Task SendAsync_ClientSpan_3xxStatus_LeavesStatusUnset() + public async Task SendAsync_ForwardsInnerHandlerResponse() { Activity.Current = null; - using var listenerScope = new ActivityListenerScope(); - using var activity = new Activity("test-redirect").Start(); - - var captured = new CaptureHandler(HttpStatusCode.Redirect); - var handler = CreateHandler(); - handler.InnerHandler = captured; - using var client = new HttpClient(handler); - - using var response = await client.GetAsync("http://localhost/test"); - - await Assert.That((int)response.StatusCode).IsEqualTo(302); - - var clientSpan = await Assert.That(listenerScope.StoppedActivities).HasSingleItem(); - await Assert.That(clientSpan.Tags.GetValueOrDefault("http.response.status_code")).IsEqualTo("302"); - await Assert.That(clientSpan.Tags.ContainsKey("error.type")).IsFalse(); - await Assert.That(clientSpan.Status).IsEqualTo(ActivityStatusCode.Unset); - } - - [Test] - public async Task SendAsync_ClientSpan_4xxStatus_SetsErrorStatus() - { - Activity.Current = null; - using var listenerScope = new ActivityListenerScope(); - using var activity = new Activity("test-not-found").Start(); + using var activity = new Activity("test-status").Start(); var captured = new CaptureHandler(HttpStatusCode.NotFound); - var handler = CreateHandler(); - handler.InnerHandler = captured; + var handler = new ActivityPropagationHandler { InnerHandler = captured }; using var client = new HttpClient(handler); using var response = await client.GetAsync("http://localhost/test"); await Assert.That((int)response.StatusCode).IsEqualTo(404); - - var clientSpan = await Assert.That(listenerScope.StoppedActivities).HasSingleItem(); - await Assert.That(clientSpan.Tags.GetValueOrDefault("http.response.status_code")).IsEqualTo("404"); - await Assert.That(clientSpan.Tags.GetValueOrDefault("error.type")).IsEqualTo("404"); - await Assert.That(clientSpan.Status).IsEqualTo(ActivityStatusCode.Error); } [Test] - public async Task SendAsync_ClientSpan_RecordsException_WhenInnerHandlerThrows() + public async Task SendAsync_PropagatesInnerHandlerException() { Activity.Current = null; - using var listenerScope = new ActivityListenerScope(); using var activity = new Activity("test-transport-error").Start(); - var handler = CreateHandler(); - handler.InnerHandler = new ThrowingHandler(new HttpRequestException("boom")); + var handler = new ActivityPropagationHandler + { + InnerHandler = new ThrowingHandler(new HttpRequestException("boom")), + }; using var client = new HttpClient(handler); HttpRequestException? thrown = null; @@ -149,19 +104,29 @@ public async Task SendAsync_ClientSpan_RecordsException_WhenInnerHandlerThrows() } await Assert.That(thrown).IsNotNull(); - - var clientSpan = await Assert.That(listenerScope.StoppedActivities).HasSingleItem(); - await Assert.That(clientSpan.Tags.GetValueOrDefault("error.type")).Contains(nameof(HttpRequestException)); - await Assert.That(clientSpan.EventNames).Contains("exception"); - await Assert.That(clientSpan.Status).IsEqualTo(ActivityStatusCode.Error); + await Assert.That(thrown!.Message).IsEqualTo("boom"); } - // Pass static _ => null to simulate no helper span; null uses the real StartHttpActivity default. - private static DelegatingHandler CreateHandler(Func? startActivity = null) + [Test] + public async Task SendAsync_ExistingBaggageHeader_IsPreserved() { - return startActivity is null - ? new ActivityPropagationHandler() - : new ActivityPropagationHandler(startActivity); + Activity.Current = null; + using var activity = new Activity("test-existing-baggage").Start(); + activity.SetBaggage("should.not.appear", "true"); + + var captured = new CaptureHandler(); + var handler = new ActivityPropagationHandler { InnerHandler = captured }; + using var client = new HttpClient(handler); + + var request = new HttpRequestMessage(HttpMethod.Get, "http://localhost/test"); + request.Headers.TryAddWithoutValidation("baggage", "existing=value"); + + await client.SendAsync(request); + + var allBaggageValues = string.Join(",", + captured.LastRequest!.Headers.GetValues("baggage")); + await Assert.That(allBaggageValues).Contains("existing=value"); + await Assert.That(allBaggageValues).DoesNotContain("should.not.appear"); } private static async Task AssertValidW3CTraceparent(string traceparent) @@ -177,66 +142,16 @@ private static async Task AssertValidW3CTraceparent(string traceparent) await Assert.That(parts[3] is "00" or "01").IsTrue(); } - private sealed class ActivityListenerScope : IDisposable + private sealed class CaptureHandler( + HttpStatusCode statusCode = HttpStatusCode.OK) : HttpMessageHandler { - private readonly ConcurrentQueue _stoppedActivities = new(); - private readonly ActivityListener _listener; - - public ActivityListenerScope() - { - _listener = new ActivityListener - { - ShouldListenTo = static source => source.Name == TUnitActivitySource.AspNetCoreHttpSourceName, - Sample = static (ref ActivityCreationOptions _) => - ActivitySamplingResult.AllDataAndRecorded, - ActivityStopped = activity => _stoppedActivities.Enqueue(new RecordedActivity( - activity.TraceId.ToString(), - activity.SpanId.ToString(), - activity.ParentSpanId == default ? null : activity.ParentSpanId.ToString(), - activity.DisplayName, - activity.Kind, - activity.Status, - activity.TagObjects.ToDictionary(static t => t.Key, static t => t.Value?.ToString()), - activity.Events.Select(static e => e.Name).ToArray())) - }; - - ActivitySource.AddActivityListener(_listener); - } - - public RecordedActivity[] StoppedActivities => _stoppedActivities.ToArray(); - - public void Dispose() - { - _listener.Dispose(); - } - } - - private sealed record RecordedActivity( - string TraceId, - string SpanId, - string? ParentSpanId, - string DisplayName, - ActivityKind Kind, - ActivityStatusCode Status, - IReadOnlyDictionary Tags, - string[] EventNames); - - private sealed class CaptureHandler : HttpMessageHandler - { - private readonly HttpStatusCode _statusCode; - - public CaptureHandler(HttpStatusCode statusCode = HttpStatusCode.OK) - { - _statusCode = statusCode; - } - public HttpRequestMessage? LastRequest { get; private set; } protected override Task SendAsync( HttpRequestMessage request, CancellationToken cancellationToken) { LastRequest = request; - return Task.FromResult(new HttpResponseMessage(_statusCode)); + return Task.FromResult(new HttpResponseMessage(statusCode)); } } diff --git a/TUnit.AspNetCore.Tests/AutoConfigureOpenTelemetryTests.cs b/TUnit.AspNetCore.Tests/AutoConfigureOpenTelemetryTests.cs index bac62309a3..0ce827205e 100644 --- a/TUnit.AspNetCore.Tests/AutoConfigureOpenTelemetryTests.cs +++ b/TUnit.AspNetCore.Tests/AutoConfigureOpenTelemetryTests.cs @@ -35,7 +35,21 @@ public async Task AutoWires_TagsAspNetCoreSpans_WithTestId() response.EnsureSuccessStatusCode(); var testId = TestContext.Current!.Id; - var taggedSpan = _exported.FirstOrDefault(a => (a.GetTagItem(TUnitActivitySource.TagTestId) as string) == testId); + + // ASP.NET Core stops its server activity on a continuation that may outlive the + // client response, so poll briefly instead of reading _exported synchronously. + Activity? taggedSpan = null; + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(2)); + while (!cts.IsCancellationRequested) + { + taggedSpan = _exported.FirstOrDefault(a => (a.GetTagItem(TUnitActivitySource.TagTestId) as string) == testId); + if (taggedSpan is not null) + { + break; + } + await Task.Delay(20, cts.Token); + } + await Assert.That(taggedSpan).IsNotNull(); } } diff --git a/TUnit.Aspire.Tests/BaggagePropagationHandlerTests.cs b/TUnit.Aspire.Tests/BaggagePropagationHandlerTests.cs index 80a8efc6b9..73868ecdd8 100644 --- a/TUnit.Aspire.Tests/BaggagePropagationHandlerTests.cs +++ b/TUnit.Aspire.Tests/BaggagePropagationHandlerTests.cs @@ -1,4 +1,3 @@ -using System.Collections.Concurrent; using System.Diagnostics; using System.Net; using TUnit.Aspire.Http; @@ -12,164 +11,55 @@ namespace TUnit.Aspire.Tests; public class BaggagePropagationHandlerTests { [Test] - public async Task SendAsync_InjectsTraceparentHeader_WhenActivityExists() + public async Task SendAsync_InjectsTraceparentHeader_FromAmbientActivity() { Activity.Current = null; using var activity = new Activity("test-traceparent").Start(); var captured = new CaptureHandler(); - var handler = CreateHandler(); - handler.InnerHandler = captured; - using var client = new HttpClient(handler); - - await client.GetAsync("http://localhost/test"); - - await Assert.That(captured.LastRequest!.Headers.Contains("traceparent")).IsTrue(); - } - - [Test] - public async Task SendAsync_InjectsTraceContext_FromCreatedClientSpan_WhenHelperSpanIsCreated() - { - Activity.Current = null; - using var listenerScope = new ActivityListenerScope(); - using var activity = new Activity("test-root").Start(); - activity.SetBaggage(TUnitActivitySource.TagTestId, "my-test-context-id"); - - var captured = new CaptureHandler(); - var handler = CreateHandler(); - handler.InnerHandler = captured; + var handler = new TUnitBaggagePropagationHandler { InnerHandler = captured }; using var client = new HttpClient(handler); await client.GetAsync("http://localhost/test"); var traceparent = captured.LastRequest!.Headers.GetValues("traceparent").First(); var parts = traceparent.Split('-'); - var baggageHeader = captured.LastRequest.Headers.GetValues("baggage").First(); - var clientSpan = await Assert.That(listenerScope.StoppedActivities).HasSingleItem(); await AssertValidW3CTraceparent(traceparent); await Assert.That(parts[1]).IsEqualTo(activity.TraceId.ToString()); - await Assert.That(parts[2]).IsEqualTo(clientSpan.SpanId); - await Assert.That(parts[2]).IsNotEqualTo(activity.SpanId.ToString()); - await Assert.That(clientSpan.ParentSpanId).IsEqualTo(activity.SpanId.ToString()); - await Assert.That(clientSpan.Kind).IsEqualTo(ActivityKind.Client); - await Assert.That(clientSpan.DisplayName).IsEqualTo("GET"); - await Assert.That(baggageHeader).Contains(TUnitActivitySource.TagTestId); - await Assert.That(baggageHeader).Contains("my-test-context-id"); - } - - [Test] - public async Task SendAsync_CreatesNewClientSpan_PerRequest() - { - Activity.Current = null; - using var listenerScope = new ActivityListenerScope(); - using var activity = new Activity("test-multiple-requests").Start(); - - var captured = new CaptureHandler(); - var handler = CreateHandler(); - handler.InnerHandler = captured; - using var client = new HttpClient(handler); - - await client.GetAsync("http://localhost/test1"); - var traceparent1 = captured.LastRequest!.Headers.GetValues("traceparent").First(); - - await client.GetAsync("http://localhost/test2"); - var traceparent2 = captured.LastRequest!.Headers.GetValues("traceparent").First(); - - var spans = listenerScope.StoppedActivities; - - await Assert.That(spans.Length).IsEqualTo(2); - await Assert.That(traceparent1.Split('-')[1]).IsEqualTo(activity.TraceId.ToString()); - await Assert.That(traceparent2.Split('-')[1]).IsEqualTo(activity.TraceId.ToString()); - // Requests are awaited sequentially, so ActivityStopped fires in request order. - await Assert.That(traceparent1.Split('-')[2]).IsEqualTo(spans[0].SpanId); - await Assert.That(traceparent2.Split('-')[2]).IsEqualTo(spans[1].SpanId); - await Assert.That(traceparent1.Split('-')[2]).IsNotEqualTo(traceparent2.Split('-')[2]); + // No synthesized client span — traceparent's parent-id is the ambient activity itself. + // .NET's System.Net.Http ActivitySource emits the real outbound client span downstream. + await Assert.That(parts[2]).IsEqualTo(activity.SpanId.ToString()); } [Test] - public async Task SendAsync_FallsBackToActivityCurrent_WhenHelperSpanIsNotCreated() + public async Task SendAsync_InjectsBaggage_FromAmbientActivity() { Activity.Current = null; using var activity = new Activity("test-fallback").Start(); activity.SetBaggage(TUnitActivitySource.TagTestId, "my-test-context-id"); var captured = new CaptureHandler(); - var handler = CreateHandler(static _ => null); - handler.InnerHandler = captured; + var handler = new TUnitBaggagePropagationHandler { InnerHandler = captured }; using var client = new HttpClient(handler); await client.GetAsync("http://localhost/test"); - var traceparent = captured.LastRequest!.Headers.GetValues("traceparent").First(); - var parts = traceparent.Split('-'); - var baggageHeader = captured.LastRequest.Headers.GetValues("baggage").First(); - - await AssertValidW3CTraceparent(traceparent); - await Assert.That(parts[1]).IsEqualTo(activity.TraceId.ToString()); - await Assert.That(parts[2]).IsEqualTo(activity.SpanId.ToString()); + var baggageHeader = captured.LastRequest!.Headers.GetValues("baggage").First(); await Assert.That(baggageHeader).Contains(TUnitActivitySource.TagTestId); await Assert.That(baggageHeader).Contains("my-test-context-id"); } [Test] - public async Task SendAsync_ClientSpan_4xxStatus_SetsErrorStatus() - { - Activity.Current = null; - using var listenerScope = new ActivityListenerScope(); - using var activity = new Activity("test-response-tags").Start(); - - var captured = new CaptureHandler(HttpStatusCode.NotFound); - var handler = CreateHandler(); - handler.InnerHandler = captured; - using var client = new HttpClient(handler); - - using var response = await client.GetAsync("http://localhost/test"); - - await Assert.That((int)response.StatusCode).IsEqualTo(404); - - var clientSpan = await Assert.That(listenerScope.StoppedActivities).HasSingleItem(); - - await Assert.That(clientSpan.Tags.GetValueOrDefault("http.request.method")).IsEqualTo("GET"); - await Assert.That(clientSpan.Tags.GetValueOrDefault("url.full")).IsEqualTo("http://localhost/test"); - await Assert.That(clientSpan.Tags.GetValueOrDefault("server.address")).IsEqualTo("localhost"); - await Assert.That(clientSpan.Tags.GetValueOrDefault("http.response.status_code")).IsEqualTo("404"); - await Assert.That(clientSpan.Tags.GetValueOrDefault("error.type")).IsEqualTo("404"); - await Assert.That(clientSpan.Status).IsEqualTo(ActivityStatusCode.Error); - } - - [Test] - public async Task SendAsync_ClientSpan_3xxStatus_LeavesStatusUnset() - { - Activity.Current = null; - using var listenerScope = new ActivityListenerScope(); - using var activity = new Activity("test-redirect-tags").Start(); - - var captured = new CaptureHandler(HttpStatusCode.Redirect); - var handler = CreateHandler(); - handler.InnerHandler = captured; - using var client = new HttpClient(handler); - - using var response = await client.GetAsync("http://localhost/test"); - - await Assert.That((int)response.StatusCode).IsEqualTo(302); - - var clientSpan = await Assert.That(listenerScope.StoppedActivities).HasSingleItem(); - - await Assert.That(clientSpan.Tags.GetValueOrDefault("http.response.status_code")).IsEqualTo("302"); - await Assert.That(clientSpan.Tags.ContainsKey("error.type")).IsFalse(); - await Assert.That(clientSpan.Status).IsEqualTo(ActivityStatusCode.Unset); - } - - [Test] - public async Task SendAsync_ClientSpan_RecordsException_WhenInnerHandlerThrows() + public async Task SendAsync_PropagatesInnerHandlerException() { Activity.Current = null; - using var listenerScope = new ActivityListenerScope(); using var activity = new Activity("test-transport-error").Start(); - var handler = CreateHandler(); - handler.InnerHandler = new ThrowingHandler(new HttpRequestException("boom")); + var handler = new TUnitBaggagePropagationHandler + { + InnerHandler = new ThrowingHandler(new HttpRequestException("boom")), + }; using var client = new HttpClient(handler); HttpRequestException? thrown = null; @@ -183,11 +73,22 @@ public async Task SendAsync_ClientSpan_RecordsException_WhenInnerHandlerThrows() } await Assert.That(thrown).IsNotNull(); + await Assert.That(thrown!.Message).IsEqualTo("boom"); + } + + [Test] + public async Task SendAsync_ForwardsInnerHandlerResponseStatus() + { + Activity.Current = null; + using var activity = new Activity("test-status").Start(); + + var captured = new CaptureHandler(HttpStatusCode.NotFound); + var handler = new TUnitBaggagePropagationHandler { InnerHandler = captured }; + using var client = new HttpClient(handler); - var clientSpan = await Assert.That(listenerScope.StoppedActivities).HasSingleItem(); - await Assert.That(clientSpan.Tags.GetValueOrDefault("error.type")).Contains(nameof(HttpRequestException)); - await Assert.That(clientSpan.EventNames).Contains("exception"); - await Assert.That(clientSpan.Status).IsEqualTo(ActivityStatusCode.Error); + using var response = await client.GetAsync("http://localhost/test"); + + await Assert.That((int)response.StatusCode).IsEqualTo(404); } [Test] @@ -199,8 +100,7 @@ public async Task SendAsync_InjectsBaggageHeader_WithActivityBaggage() activity.SetBaggage("custom.key", "custom-value"); var captured = new CaptureHandler(); - var handler = CreateHandler(static _ => null); - handler.InnerHandler = captured; + var handler = new TUnitBaggagePropagationHandler { InnerHandler = captured }; using var client = new HttpClient(handler); await client.GetAsync("http://localhost/test"); @@ -218,20 +118,17 @@ public async Task SendAsync_InjectsBaggageHeader_WithActivityBaggage() public async Task SendAsync_MultipleBaggageItems_CommaSeparated() { Activity.Current = null; - using var listenerScope = new ActivityListenerScope(); using var activity = new Activity("test-multi-baggage").Start(); activity.SetBaggage("key1", "val1"); activity.SetBaggage("key2", "val2"); var captured = new CaptureHandler(); - var handler = CreateHandler(); - handler.InnerHandler = captured; + var handler = new TUnitBaggagePropagationHandler { InnerHandler = captured }; using var client = new HttpClient(handler); await client.GetAsync("http://localhost/test"); var baggageHeader = captured.LastRequest!.Headers.GetValues("baggage").First(); - await Assert.That(listenerScope.StoppedActivities).HasSingleItem(); await Assert.That(baggageHeader).Contains(","); } @@ -242,8 +139,7 @@ public async Task SendAsync_NoBaggage_DoesNotAddBaggageHeader() using var activity = new Activity("test-no-baggage").Start(); var captured = new CaptureHandler(); - var handler = CreateHandler(static _ => null); - handler.InnerHandler = captured; + var handler = new TUnitBaggagePropagationHandler { InnerHandler = captured }; using var client = new HttpClient(handler); await client.GetAsync("http://localhost/test"); @@ -257,8 +153,7 @@ public async Task SendAsync_NoActivity_DoesNotInjectTraceContext() Activity.Current = null; var captured = new CaptureHandler(); - var handler = CreateHandler(static _ => null); - handler.InnerHandler = captured; + var handler = new TUnitBaggagePropagationHandler { InnerHandler = captured }; using var client = new HttpClient(handler); await client.GetAsync("http://localhost/test"); @@ -275,8 +170,7 @@ public async Task SendAsync_BaggageValues_AreUriEncoded() activity.SetBaggage("key with spaces", "value=with&special"); var captured = new CaptureHandler(); - var handler = CreateHandler(static _ => null); - handler.InnerHandler = captured; + var handler = new TUnitBaggagePropagationHandler { InnerHandler = captured }; using var client = new HttpClient(handler); await client.GetAsync("http://localhost/test"); @@ -294,8 +188,7 @@ public async Task SendAsync_ExistingBaggageHeader_IsPreserved() activity.SetBaggage("should.not.appear", "true"); var captured = new CaptureHandler(); - var handler = CreateHandler(static _ => null); - handler.InnerHandler = captured; + var handler = new TUnitBaggagePropagationHandler { InnerHandler = captured }; using var client = new HttpClient(handler); var request = new HttpRequestMessage(HttpMethod.Get, "http://localhost/test"); @@ -309,15 +202,6 @@ public async Task SendAsync_ExistingBaggageHeader_IsPreserved() await Assert.That(allBaggageValues).DoesNotContain("should.not.appear"); } - // Pass static _ => null to simulate no helper span; null uses the real StartHttpActivity default. - private static TUnitBaggagePropagationHandler CreateHandler( - Func? startActivity = null) - { - return startActivity is null - ? new TUnitBaggagePropagationHandler() - : new TUnitBaggagePropagationHandler(startActivity); - } - private static async Task AssertValidW3CTraceparent(string traceparent) { var parts = traceparent.Split('-'); @@ -331,50 +215,6 @@ private static async Task AssertValidW3CTraceparent(string traceparent) await Assert.That(parts[3] is "00" or "01").IsTrue(); } - private sealed class ActivityListenerScope : IDisposable - { - private readonly ConcurrentQueue _stoppedActivities = new(); - private readonly ActivityListener _listener; - - public ActivityListenerScope() - { - _listener = new ActivityListener - { - ShouldListenTo = static source => source.Name == TUnitActivitySource.AspireHttpSourceName, - Sample = static (ref ActivityCreationOptions _) => - ActivitySamplingResult.AllDataAndRecorded, - ActivityStopped = activity => _stoppedActivities.Enqueue(new RecordedActivity( - activity.TraceId.ToString(), - activity.SpanId.ToString(), - activity.ParentSpanId == default ? null : activity.ParentSpanId.ToString(), - activity.DisplayName, - activity.Kind, - activity.Status, - activity.TagObjects.ToDictionary(static t => t.Key, static t => t.Value?.ToString()), - activity.Events.Select(static e => e.Name).ToArray())) - }; - - ActivitySource.AddActivityListener(_listener); - } - - public RecordedActivity[] StoppedActivities => _stoppedActivities.ToArray(); - - public void Dispose() - { - _listener.Dispose(); - } - } - - private sealed record RecordedActivity( - string TraceId, - string SpanId, - string? ParentSpanId, - string DisplayName, - ActivityKind Kind, - ActivityStatusCode Status, - IReadOnlyDictionary Tags, - string[] EventNames); - /// /// A handler that captures the outgoing request instead of sending it over the network. /// diff --git a/TUnit.Aspire/Http/TUnitBaggagePropagationHandler.cs b/TUnit.Aspire/Http/TUnitBaggagePropagationHandler.cs index 52f46f443a..3591e05409 100644 --- a/TUnit.Aspire/Http/TUnitBaggagePropagationHandler.cs +++ b/TUnit.Aspire/Http/TUnitBaggagePropagationHandler.cs @@ -4,147 +4,22 @@ namespace TUnit.Aspire.Http; /// -/// DelegatingHandler that creates Aspire HTTP client spans and propagates W3C -/// traceparent and baggage headers into outgoing HTTP requests. -/// This restores normal OpenTelemetry client/server span topology for -/// AspireFixture.CreateHttpClient requests. +/// DelegatingHandler that injects W3C traceparent and baggage headers into +/// outgoing requests made through AspireFixture.CreateHttpClient. /// /// -/// Each test case starts its own W3C trace (unique TraceId) via a root activity in the -/// engine. HTTP requests made during a test inherit that TraceId through standard -/// propagation. The OTLP receiver maps the TraceId back -/// to the test via , which is populated by the engine when -/// the test activity starts. -/// -/// This handler is needed because Aspire's CreateHttpClient does not include -/// the standard DiagnosticsHandler, so W3C context propagation must be done -/// explicitly. +/// Aspire's test HttpClient hits real sockets, so .NET's built-in +/// System.Net.Http ActivitySource already emits the outbound client span. This +/// handler only ensures trace context flows from the ambient test Activity onto the +/// outgoing request before that span starts, so the SUT can correlate requests to the +/// originating test. /// internal sealed class TUnitBaggagePropagationHandler : DelegatingHandler { - private static readonly ActivitySource HttpActivitySource = new(TUnitActivitySource.AspireHttpSourceName); - private readonly Func _startActivity; - - public TUnitBaggagePropagationHandler() - { - _startActivity = StartHttpActivity; - } - - internal TUnitBaggagePropagationHandler(Func startActivity) - { - _startActivity = startActivity; - } - - protected override async Task SendAsync( + protected override Task SendAsync( HttpRequestMessage request, CancellationToken cancellationToken) { - var ambientActivity = Activity.Current; - using var activity = _startActivity(request); - - if (activity is not null) - { - activity.SetTag("http.request.method", request.Method.Method); - activity.SetTag("url.full", request.RequestUri?.ToString()); - activity.SetTag("server.address", request.RequestUri?.Host); - - // Aspire's CreateHttpClient bypasses DiagnosticsHandler, so when we synthesize - // a client span we also need to flow the ambient baggage onto it explicitly. - // Child Activities do not reliably surface parent baggage across all target - // frameworks, but correlation relies on the test's baggage being propagated. - CopyBaggage(ambientActivity, activity); - } - - var propagationActivity = activity ?? ambientActivity; - InjectTraceContext(propagationActivity, request); - InjectBaggage(propagationActivity, request); - - HttpResponseMessage response; - try - { - response = await base.SendAsync(request, cancellationToken).ConfigureAwait(false); - } - catch (Exception ex) - { - // Only the synthesized client span gets exception metadata. When no listener is - // attached, propagation falls back to the ambient activity and there is no extra - // HTTP client span to annotate. - TUnitActivitySource.RecordException(activity, ex); - throw; - } - - if (activity is not null) - { - var statusCode = (int)response.StatusCode; - activity.SetTag("http.response.status_code", statusCode); - - if (statusCode >= 400) - { - activity.SetStatus(ActivityStatusCode.Error); - activity.SetTag("error.type", statusCode.ToString()); - } - } - - return response; - } - - private static Activity? StartHttpActivity(HttpRequestMessage request) - { - var method = string.IsNullOrWhiteSpace(request.Method.Method) - ? "HTTP" - : request.Method.Method; - return HttpActivitySource.StartActivity( - method, - ActivityKind.Client); - } - - private static void InjectTraceContext(Activity? activity, HttpRequestMessage request) - { - if (activity is null) - { - return; - } - - DistributedContextPropagator.Current.Inject(activity, request, static (carrier, key, value) => - { - if (carrier is HttpRequestMessage httpRequest && key is not null && !httpRequest.Headers.Contains(key)) - { - httpRequest.Headers.TryAddWithoutValidation(key, value); - } - }); - } - - private static void CopyBaggage(Activity? source, Activity destination) - { - if (source is null || ReferenceEquals(source, destination)) - { - return; - } - - foreach (var (key, value) in source.Baggage) - { - // Preserve baggage already attached to the synthesized client span itself. - if (key is null || destination.GetBaggageItem(key) is not null) - { - continue; - } - - destination.SetBaggage(key, value); - } - } - - private static void InjectBaggage(Activity? activity, HttpRequestMessage request) - { - if (activity is null || request.Headers.Contains(TUnitActivitySource.BaggageHeader)) - { - return; - } - - if (TUnitActivitySource.TryBuildBaggageHeader(activity) is { } baggage) - { - // Belt-and-braces for users who opt out of TUnit's W3C propagator alignment - // via TUNIT_KEEP_LEGACY_PROPAGATOR=1: LegacyPropagator emits Correlation-Context - // only, so still emit W3C baggage explicitly for backend correlation. - request.Headers.TryAddWithoutValidation(TUnitActivitySource.BaggageHeader, baggage); - } + HttpActivityPropagator.Inject(Activity.Current, request.Headers); + return base.SendAsync(request, cancellationToken); } } diff --git a/TUnit.Core/HttpActivityPropagator.cs b/TUnit.Core/HttpActivityPropagator.cs new file mode 100644 index 0000000000..238b5c9c54 --- /dev/null +++ b/TUnit.Core/HttpActivityPropagator.cs @@ -0,0 +1,47 @@ +#if NET + +using System.Diagnostics; +using System.Net.Http.Headers; + +namespace TUnit.Core; + +/// +/// Injects W3C traceparent and baggage headers onto outgoing HTTP requests +/// so a remote process (SUT) can correlate them to the originating test. +/// +/// +/// Pre-existing headers always win — callers who explicitly set their own trace context +/// or baggage are not overridden. Baggage is also emitted when the configured +/// doesn't emit it itself (e.g. the default +/// LegacyPropagator emits Correlation-Context instead). +/// +internal static class HttpActivityPropagator +{ + public static void Inject(Activity? activity, HttpRequestHeaders headers) + { + if (activity is null) + { + return; + } + + DistributedContextPropagator.Current.Inject(activity, headers, static (carrier, key, value) => + { + if (carrier is HttpRequestHeaders h && key is not null && !h.Contains(key)) + { + h.TryAddWithoutValidation(key, value); + } + }); + + if (headers.Contains(TUnitActivitySource.BaggageHeader)) + { + return; + } + + if (TUnitActivitySource.TryBuildBaggageHeader(activity) is { } baggage) + { + headers.TryAddWithoutValidation(TUnitActivitySource.BaggageHeader, baggage); + } + } +} + +#endif diff --git a/TUnit.Core/TUnitActivitySource.cs b/TUnit.Core/TUnitActivitySource.cs index 9ff0a88c5c..3883249b38 100644 --- a/TUnit.Core/TUnitActivitySource.cs +++ b/TUnit.Core/TUnitActivitySource.cs @@ -13,19 +13,15 @@ public static class TUnitActivitySource internal const string LifecycleSourceName = "TUnit.Lifecycle"; /// - /// Activity source emitted by TUnit's ASP.NET Core HTTP propagation handlers. - /// Registered automatically on the SUT's - /// listeners by TestWebApplicationFactory. + /// No longer emits spans. TUnit's ASP.NET Core HTTP propagation handler is now a pure + /// header propagator — the ASP.NET Core server span carries HTTP semantic-convention tags + /// for in-memory WebApplicationFactory traffic, and the runtime's + /// System.Net.Http ActivitySource emits the client span for SUT-initiated outbound + /// requests over real sockets. /// + [Obsolete("TUnit no longer emits spans under this source name. See the property remarks for the current trace topology. This constant is kept for binary compatibility and will be removed in a future major release.")] public const string AspNetCoreHttpSourceName = "TUnit.AspNetCore.Http"; - /// - /// Activity source emitted by TUnit's Aspire HTTP propagation handler. - /// Registered automatically by TUnit.OpenTelemetry.AutoStart so outbound - /// requests made through AspireFixture.CreateHttpClient appear as client spans. - /// - public const string AspireHttpSourceName = "TUnit.Aspire.Http"; - /// W3C baggage HTTP header name. internal const string BaggageHeader = "baggage"; diff --git a/TUnit.OpenTelemetry.Tests/CorrelationProcessorTests.cs b/TUnit.OpenTelemetry.Tests/CorrelationProcessorTests.cs index 6321b39a30..0ea2c4f291 100644 --- a/TUnit.OpenTelemetry.Tests/CorrelationProcessorTests.cs +++ b/TUnit.OpenTelemetry.Tests/CorrelationProcessorTests.cs @@ -38,6 +38,33 @@ public async Task Processor_SkipsWhenAlreadyTagged() await Assert.That(child.GetTagItem("tunit.test.id")).IsEqualTo("already-set"); } + [Test] + public async Task Processor_TagsOnEnd_WhenBaggageAddedAfterStart() + { + using var listener = AttachPermissiveListener("CorrelationProcessorTests.DeferredBaggage"); + var processor = new TUnitTestCorrelationProcessor(); + + // Simulates server activities whose baggage is populated by the propagator + // after ActivitySource.StartActivity returns (e.g. ASP.NET Core Hosting). + var previous = Activity.Current; + Activity.Current = null; + try + { + using var child = new ActivitySource("CorrelationProcessorTests.DeferredBaggage").StartActivity("child")!; + processor.OnStart(child); + await Assert.That(child.GetTagItem("tunit.test.id")).IsNull(); + + child.AddBaggage("tunit.test.id", "from-propagator"); + processor.OnEnd(child); + + await Assert.That(child.GetTagItem("tunit.test.id")).IsEqualTo("from-propagator"); + } + finally + { + Activity.Current = previous; + } + } + [Test] public async Task Processor_NoOp_WhenNoBaggage() { diff --git a/TUnit.OpenTelemetry/AutoStart.cs b/TUnit.OpenTelemetry/AutoStart.cs index f4ab24c809..a1ebf2c9b2 100644 --- a/TUnit.OpenTelemetry/AutoStart.cs +++ b/TUnit.OpenTelemetry/AutoStart.cs @@ -62,8 +62,6 @@ public static void Start() var builder = Sdk.CreateTracerProviderBuilder() .AddSource("TUnit") .AddSource("TUnit.Lifecycle") - .AddSource("TUnit.AspNetCore.Http") - .AddSource(TUnitActivitySource.AspireHttpSourceName) .AddProcessor(new TUnitTestCorrelationProcessor()); if (otlpEndpoint is not null) diff --git a/TUnit.OpenTelemetry/TUnitTestCorrelationProcessor.cs b/TUnit.OpenTelemetry/TUnitTestCorrelationProcessor.cs index de1713bd51..3c68dd8678 100644 --- a/TUnit.OpenTelemetry/TUnitTestCorrelationProcessor.cs +++ b/TUnit.OpenTelemetry/TUnitTestCorrelationProcessor.cs @@ -6,19 +6,45 @@ namespace TUnit.OpenTelemetry; /// /// Copies the tunit.test.id baggage item from the ambient Activity onto -/// every new span as a tag, so spans produced by libraries with broken parent +/// every span as a tag, so spans produced by libraries with broken parent /// chains can still be filtered by test in backends like Jaeger or Seq. /// +/// +/// Tagging runs at both and : +/// +/// Spans with an in-process parent (the common case) pick up the baggage at start via +/// 's parent-chain walk — OnStart is enough. +/// Spans with a remote-context parent (e.g. ASP.NET Core server spans created from an +/// extracted traceparent) receive baggage via the propagator after +/// returns, so only OnEnd +/// can see it. +/// +/// public sealed class TUnitTestCorrelationProcessor : BaseProcessor { public override void OnStart(Activity activity) + { + TryTag(activity); + } + + public override void OnEnd(Activity activity) + { + TryTag(activity); + } + + private static void TryTag(Activity activity) { if (activity.GetTagItem(TUnitActivitySource.TagTestId) is not null) { return; } - var testId = Activity.Current?.GetBaggageItem(TUnitActivitySource.TagTestId); + var testId = activity.GetBaggageItem(TUnitActivitySource.TagTestId); + if (testId is null && !ReferenceEquals(Activity.Current, activity)) + { + testId = Activity.Current?.GetBaggageItem(TUnitActivitySource.TagTestId); + } + if (testId is not null) { activity.SetTag(TUnitActivitySource.TagTestId, testId); diff --git a/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.DotNet10_0.verified.txt b/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.DotNet10_0.verified.txt index a01120ed25..e92385daa6 100644 --- a/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.DotNet10_0.verified.txt +++ b/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.DotNet10_0.verified.txt @@ -1333,8 +1333,10 @@ namespace } public static class TUnitActivitySource { + [("TUnit no longer emits spans under this source name. See the property remarks for " + + "the current trace topology. This constant is kept for binary compatibility and w" + + "ill be removed in a future major release.")] public const string AspNetCoreHttpSourceName = ".Http"; - public const string AspireHttpSourceName = ".Http"; public const string TagTestId = ".id"; } public class TUnitAttribute : { } diff --git a/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.DotNet8_0.verified.txt b/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.DotNet8_0.verified.txt index 1ff31f1746..d51c1804eb 100644 --- a/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.DotNet8_0.verified.txt +++ b/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.DotNet8_0.verified.txt @@ -1333,8 +1333,10 @@ namespace } public static class TUnitActivitySource { + [("TUnit no longer emits spans under this source name. See the property remarks for " + + "the current trace topology. This constant is kept for binary compatibility and w" + + "ill be removed in a future major release.")] public const string AspNetCoreHttpSourceName = ".Http"; - public const string AspireHttpSourceName = ".Http"; public const string TagTestId = ".id"; } public class TUnitAttribute : { } diff --git a/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.DotNet9_0.verified.txt b/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.DotNet9_0.verified.txt index 611860cbb3..ee987aa6d1 100644 --- a/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.DotNet9_0.verified.txt +++ b/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.DotNet9_0.verified.txt @@ -1333,8 +1333,10 @@ namespace } public static class TUnitActivitySource { + [("TUnit no longer emits spans under this source name. See the property remarks for " + + "the current trace topology. This constant is kept for binary compatibility and w" + + "ill be removed in a future major release.")] public const string AspNetCoreHttpSourceName = ".Http"; - public const string AspireHttpSourceName = ".Http"; public const string TagTestId = ".id"; } public class TUnitAttribute : { } diff --git a/TUnit.PublicAPI/Tests.OpenTelemetry_Library_Has_No_API_Changes.DotNet10_0.verified.txt b/TUnit.PublicAPI/Tests.OpenTelemetry_Library_Has_No_API_Changes.DotNet10_0.verified.txt index 285bf36ba7..6ad2a23a0c 100644 --- a/TUnit.PublicAPI/Tests.OpenTelemetry_Library_Has_No_API_Changes.DotNet10_0.verified.txt +++ b/TUnit.PublicAPI/Tests.OpenTelemetry_Library_Has_No_API_Changes.DotNet10_0.verified.txt @@ -18,7 +18,7 @@ namespace public const int AutoStartOrder = 2147483647; [.Before(., "PATH_SCRUBBED", 29, Order=2147483647)] public static void Start() { } - [.After(., "PATH_SCRUBBED", 95, Order=2147483647)] + [.After(., "PATH_SCRUBBED", 93, Order=2147483647)] public static void Stop() { } } public static class TUnitOpenTelemetry @@ -28,6 +28,7 @@ namespace public sealed class TUnitTestCorrelationProcessor : <.Activity> { public TUnitTestCorrelationProcessor() { } + public override void OnEnd(.Activity activity) { } public override void OnStart(.Activity activity) { } } } \ No newline at end of file diff --git a/TUnit.PublicAPI/Tests.OpenTelemetry_Library_Has_No_API_Changes.DotNet8_0.verified.txt b/TUnit.PublicAPI/Tests.OpenTelemetry_Library_Has_No_API_Changes.DotNet8_0.verified.txt index b4bfb083fd..a4c3f7c74e 100644 --- a/TUnit.PublicAPI/Tests.OpenTelemetry_Library_Has_No_API_Changes.DotNet8_0.verified.txt +++ b/TUnit.PublicAPI/Tests.OpenTelemetry_Library_Has_No_API_Changes.DotNet8_0.verified.txt @@ -18,7 +18,7 @@ namespace public const int AutoStartOrder = 2147483647; [.Before(., "PATH_SCRUBBED", 29, Order=2147483647)] public static void Start() { } - [.After(., "PATH_SCRUBBED", 95, Order=2147483647)] + [.After(., "PATH_SCRUBBED", 93, Order=2147483647)] public static void Stop() { } } public static class TUnitOpenTelemetry @@ -28,6 +28,7 @@ namespace public sealed class TUnitTestCorrelationProcessor : <.Activity> { public TUnitTestCorrelationProcessor() { } + public override void OnEnd(.Activity activity) { } public override void OnStart(.Activity activity) { } } } \ No newline at end of file diff --git a/TUnit.PublicAPI/Tests.OpenTelemetry_Library_Has_No_API_Changes.DotNet9_0.verified.txt b/TUnit.PublicAPI/Tests.OpenTelemetry_Library_Has_No_API_Changes.DotNet9_0.verified.txt index cf4bd327f8..57e91cd847 100644 --- a/TUnit.PublicAPI/Tests.OpenTelemetry_Library_Has_No_API_Changes.DotNet9_0.verified.txt +++ b/TUnit.PublicAPI/Tests.OpenTelemetry_Library_Has_No_API_Changes.DotNet9_0.verified.txt @@ -18,7 +18,7 @@ namespace public const int AutoStartOrder = 2147483647; [.Before(., "PATH_SCRUBBED", 29, Order=2147483647)] public static void Start() { } - [.After(., "PATH_SCRUBBED", 95, Order=2147483647)] + [.After(., "PATH_SCRUBBED", 93, Order=2147483647)] public static void Stop() { } } public static class TUnitOpenTelemetry @@ -28,6 +28,7 @@ namespace public sealed class TUnitTestCorrelationProcessor : <.Activity> { public TUnitTestCorrelationProcessor() { } + public override void OnEnd(.Activity activity) { } public override void OnStart(.Activity activity) { } } } \ No newline at end of file diff --git a/docs/docs/examples/aspnet.md b/docs/docs/examples/aspnet.md index 568daf2871..0d78a68a01 100644 --- a/docs/docs/examples/aspnet.md +++ b/docs/docs/examples/aspnet.md @@ -86,7 +86,7 @@ public class TodoApiTests : TestsBase - **Client-side tracing**: `CreateClient()` / `CreateDefaultClient()` return an `HttpClient` that propagates `traceparent`, `baggage`, and `X-TUnit-TestId` headers to the SUT. - **SUT `IHttpClientFactory` tracing**: Every pipeline built inside the SUT via `AddHttpClient()`, named clients, or typed clients also gets those headers prepended — outbound calls from your app to downstream services correlate with the originating test. Opt out per-test with `WebApplicationTestOptions.AutoPropagateHttpClientFactory = false`. -- **SUT-side OpenTelemetry**: The SUT's `TracerProvider` is augmented with the `TUnit.AspNetCore.Http` activity source, the `TUnitTestCorrelationProcessor` (stamps the `tunit.test.id` baggage item onto every span as a tag), and ASP.NET Core + HttpClient instrumentation. Spans emitted inside the SUT stay queryable per-test in backends like Jaeger or Seq, even when third-party libraries break the parent-chain. Opt out per-test with `WebApplicationTestOptions.AutoConfigureOpenTelemetry = false`. +- **SUT-side OpenTelemetry**: The SUT's `TracerProvider` is augmented with the `TUnitTestCorrelationProcessor` (stamps the `tunit.test.id` baggage item onto every span as a tag) and ASP.NET Core + HttpClient instrumentation. Spans emitted inside the SUT stay queryable per-test in backends like Jaeger or Seq, even when third-party libraries break the parent-chain. Opt out per-test with `WebApplicationTestOptions.AutoConfigureOpenTelemetry = false`. - **Correlated logging**: Server-side `ILogger` output is routed to the test that triggered the request. - **Hosted-service context hygiene**: `IHostedService.StartAsync` runs under `ExecutionContext.SuppressFlow()` so background work doesn't inherit the first test's `Activity.Current`. diff --git a/docs/docs/examples/opentelemetry.md b/docs/docs/examples/opentelemetry.md index f0f09d75e1..7b829ee8b4 100644 --- a/docs/docs/examples/opentelemetry.md +++ b/docs/docs/examples/opentelemetry.md @@ -22,7 +22,7 @@ Point it at a backend: export OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4317 ``` -That's it. The package auto-wires a `TracerProvider` at `[Before(TestDiscovery)]` (subscribed to `TUnit`, `TUnit.Lifecycle`, and `TUnit.AspNetCore.Http`), includes a pre-registered `TUnitTestCorrelationProcessor`, and disposes the provider at `[After(TestSession)]`. +That's it. The package auto-wires a `TracerProvider` at `[Before(TestDiscovery)]` (subscribed to `TUnit` and `TUnit.Lifecycle`), includes a pre-registered `TUnitTestCorrelationProcessor`, and disposes the provider at `[After(TestSession)]`. #### Customizing (add exporters, processors, resources) @@ -89,9 +89,6 @@ public class TraceSetup // Optional: export runner lifecycle traces (discovery, session, // assembly, suite, and shared setup/teardown) as a separate source. .AddSource("TUnit.Lifecycle") - // Optional: export the synthetic HTTP client spans created by - // TestWebApplicationFactory / TracedWebApplicationFactory too. - .AddSource("TUnit.AspNetCore.Http") .AddConsoleExporter() .Build(); } @@ -125,7 +122,7 @@ public class TraceSetup { _listener = new ActivityListener { - ShouldListenTo = source => source.Name is "TUnit" or "TUnit.Lifecycle" or "TUnit.AspNetCore.Http", + ShouldListenTo = source => source.Name is "TUnit" or "TUnit.Lifecycle", Sample = (ref ActivityCreationOptions _) => ActivitySamplingResult.AllDataAndRecorded, ActivityStarted = activity => Console.WriteLine($"▶ {activity.OperationName}"), ActivityStopped = activity => Console.WriteLine($"■ {activity.OperationName} ({activity.Duration.TotalMilliseconds:F1}ms)") @@ -260,7 +257,6 @@ automatically propagate the current test trace via W3C `traceparent` and `baggag The factory also augments the SUT's `TracerProvider` automatically — no manual `services.AddOpenTelemetry().WithTracing(...)` wiring is needed for the basics: -- Registers the `TUnit.AspNetCore.Http` activity source. - Adds the `TUnitTestCorrelationProcessor` so spans from libraries with broken parent chains are still tagged with `tunit.test.id`. - Adds ASP.NET Core and HttpClient instrumentation. diff --git a/docs/docs/guides/distributed-tracing.md b/docs/docs/guides/distributed-tracing.md index bf911919c9..346fbe1fc5 100644 --- a/docs/docs/guides/distributed-tracing.md +++ b/docs/docs/guides/distributed-tracing.md @@ -152,7 +152,7 @@ protected override void ConfigureTestOptions(WebApplicationTestOptions options) ### SUT-side OpenTelemetry wiring -`TestWebApplicationFactory` also augments the SUT's `TracerProvider` automatically — the `TUnit.AspNetCore.Http` activity source, the `TUnitTestCorrelationProcessor`, and ASP.NET Core + HttpClient instrumentation are layered on top of whatever `AddOpenTelemetry().WithTracing(...)` wiring the SUT already has. That means spans emitted inside the SUT stay queryable per-test (`tunit.test.id` tag) even when third-party libraries break the parent chain. +`TestWebApplicationFactory` also augments the SUT's `TracerProvider` automatically — the `TUnitTestCorrelationProcessor` and ASP.NET Core + HttpClient instrumentation are layered on top of whatever `AddOpenTelemetry().WithTracing(...)` wiring the SUT already has. That means spans emitted inside the SUT stay queryable per-test (`tunit.test.id` tag) even when third-party libraries break the parent chain. Opt out per-test with `WebApplicationTestOptions.AutoConfigureOpenTelemetry = false` when the SUT owns its own processors and you don't want TUnit's defaults layered on top. From a516d9e7857be7cc1af9472fd1ed19137a5b5dd6 Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Wed, 22 Apr 2026 21:13:43 +0100 Subject: [PATCH 2/9] fix(telemetry): address review feedback on TryTag + polling race - TUnitTestCorrelationProcessor: replace Activity.Current fallback with TraceRegistry.GetContextId keyed on the activity's own TraceId. The previous ambient-current fallback could cross-attribute a span to a different concurrent test if the span was stopped on a thread whose Activity.Current had swung to another test's context. Trace-id lookup is bound to the span itself and can't mis-attribute. - AutoConfigureOpenTelemetryTests: polling loop now uses a monotonic deadline instead of CancellationToken threaded into Task.Delay, so timeout surfaces as "no tagged span found" rather than a TaskCanceledException. - Added Processor_FallsBackToTraceRegistry_WhenActivityHasNoBaggage covering the new fallback path. --- .../AutoConfigureOpenTelemetryTests.cs | 6 ++-- .../CorrelationProcessorTests.cs | 29 +++++++++++++++++++ .../TUnitTestCorrelationProcessor.cs | 11 +++---- 3 files changed, 38 insertions(+), 8 deletions(-) diff --git a/TUnit.AspNetCore.Tests/AutoConfigureOpenTelemetryTests.cs b/TUnit.AspNetCore.Tests/AutoConfigureOpenTelemetryTests.cs index 0ce827205e..26629213f0 100644 --- a/TUnit.AspNetCore.Tests/AutoConfigureOpenTelemetryTests.cs +++ b/TUnit.AspNetCore.Tests/AutoConfigureOpenTelemetryTests.cs @@ -38,16 +38,16 @@ public async Task AutoWires_TagsAspNetCoreSpans_WithTestId() // ASP.NET Core stops its server activity on a continuation that may outlive the // client response, so poll briefly instead of reading _exported synchronously. + var deadline = Environment.TickCount64 + 2_000; Activity? taggedSpan = null; - using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(2)); - while (!cts.IsCancellationRequested) + while (Environment.TickCount64 < deadline) { taggedSpan = _exported.FirstOrDefault(a => (a.GetTagItem(TUnitActivitySource.TagTestId) as string) == testId); if (taggedSpan is not null) { break; } - await Task.Delay(20, cts.Token); + await Task.Delay(20); } await Assert.That(taggedSpan).IsNotNull(); diff --git a/TUnit.OpenTelemetry.Tests/CorrelationProcessorTests.cs b/TUnit.OpenTelemetry.Tests/CorrelationProcessorTests.cs index 0ea2c4f291..2bd1fe4712 100644 --- a/TUnit.OpenTelemetry.Tests/CorrelationProcessorTests.cs +++ b/TUnit.OpenTelemetry.Tests/CorrelationProcessorTests.cs @@ -2,6 +2,7 @@ using OpenTelemetry; using TUnit.Assertions; using TUnit.Assertions.Extensions; +using TUnit.Core; namespace TUnit.OpenTelemetry.Tests; @@ -65,6 +66,34 @@ public async Task Processor_TagsOnEnd_WhenBaggageAddedAfterStart() } } + [Test] + public async Task Processor_FallsBackToTraceRegistry_WhenActivityHasNoBaggage() + { + using var listener = AttachPermissiveListener("CorrelationProcessorTests.TraceRegistryFallback"); + var processor = new TUnitTestCorrelationProcessor(); + + // Simulates a span that outlives the test's async context: no baggage on the + // activity, Activity.Current belongs to another test, but the trace ID is + // still registered against the originating test. + var previous = Activity.Current; + Activity.Current = null; + try + { + using var child = new ActivitySource("CorrelationProcessorTests.TraceRegistryFallback").StartActivity("child")!; + var traceId = child.TraceId.ToString(); + // Additive registration — child.TraceId is random, so it can't collide with + // registrations from other concurrent tests, and entries live until session end. + TraceRegistry.Register(traceId, testNodeUid: "node-42", contextId: "ctx-42"); + processor.OnEnd(child); + + await Assert.That(child.GetTagItem("tunit.test.id")).IsEqualTo("ctx-42"); + } + finally + { + Activity.Current = previous; + } + } + [Test] public async Task Processor_NoOp_WhenNoBaggage() { diff --git a/TUnit.OpenTelemetry/TUnitTestCorrelationProcessor.cs b/TUnit.OpenTelemetry/TUnitTestCorrelationProcessor.cs index 3c68dd8678..b26b51559e 100644 --- a/TUnit.OpenTelemetry/TUnitTestCorrelationProcessor.cs +++ b/TUnit.OpenTelemetry/TUnitTestCorrelationProcessor.cs @@ -19,6 +19,10 @@ namespace TUnit.OpenTelemetry; /// returns, so only OnEnd /// can see it. /// +/// Fallback lookup uses keyed on the activity's own +/// , not — this avoids +/// cross-attribution when a span outlives its test's async context and is stopped on a +/// thread where belongs to a different concurrent test. /// public sealed class TUnitTestCorrelationProcessor : BaseProcessor { @@ -39,11 +43,8 @@ private static void TryTag(Activity activity) return; } - var testId = activity.GetBaggageItem(TUnitActivitySource.TagTestId); - if (testId is null && !ReferenceEquals(Activity.Current, activity)) - { - testId = Activity.Current?.GetBaggageItem(TUnitActivitySource.TagTestId); - } + var testId = activity.GetBaggageItem(TUnitActivitySource.TagTestId) + ?? TraceRegistry.GetContextId(activity.TraceId.ToString()); if (testId is not null) { From da533fb4dc280709885362b80ec3a32b1d066968 Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Wed, 22 Apr 2026 21:24:59 +0100 Subject: [PATCH 3/9] style: use Activity.Current via System.Diagnostics import MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Minor consistency nit surfaced in review — match the import style used by the Aspire handler. --- TUnit.AspNetCore.Core/Http/ActivityPropagationHandler.cs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/TUnit.AspNetCore.Core/Http/ActivityPropagationHandler.cs b/TUnit.AspNetCore.Core/Http/ActivityPropagationHandler.cs index 7c57590183..b3b53ff12d 100644 --- a/TUnit.AspNetCore.Core/Http/ActivityPropagationHandler.cs +++ b/TUnit.AspNetCore.Core/Http/ActivityPropagationHandler.cs @@ -1,3 +1,4 @@ +using System.Diagnostics; using TUnit.Core; namespace TUnit.AspNetCore; @@ -17,7 +18,7 @@ internal sealed class ActivityPropagationHandler : DelegatingHandler { protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) { - HttpActivityPropagator.Inject(System.Diagnostics.Activity.Current, request.Headers); + HttpActivityPropagator.Inject(Activity.Current, request.Headers); return base.SendAsync(request, cancellationToken); } } From 84b195ef246f1a0b06bbe20c0c3f0e2ff2520225 Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Wed, 22 Apr 2026 21:36:26 +0100 Subject: [PATCH 4/9] docs: tighten HttpActivityPropagator visibility + OnEnd note - HttpActivityPropagator.Inject: public -> internal. Class is internal, so the method visibility was redundant; explicit internal matches other internal helpers in TUnit. - TUnitTestCorrelationProcessor: add a remarks paragraph explaining that OnEnd tag writes are visible to deferred-serialization exporters (BatchExportProcessor, InMemoryExporter). Synchronous exporters need this processor to be registered before them to observe the tag. --- TUnit.Core/HttpActivityPropagator.cs | 2 +- TUnit.OpenTelemetry/TUnitTestCorrelationProcessor.cs | 7 +++++++ 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/TUnit.Core/HttpActivityPropagator.cs b/TUnit.Core/HttpActivityPropagator.cs index 238b5c9c54..d62088e9ab 100644 --- a/TUnit.Core/HttpActivityPropagator.cs +++ b/TUnit.Core/HttpActivityPropagator.cs @@ -17,7 +17,7 @@ namespace TUnit.Core; /// internal static class HttpActivityPropagator { - public static void Inject(Activity? activity, HttpRequestHeaders headers) + internal static void Inject(Activity? activity, HttpRequestHeaders headers) { if (activity is null) { diff --git a/TUnit.OpenTelemetry/TUnitTestCorrelationProcessor.cs b/TUnit.OpenTelemetry/TUnitTestCorrelationProcessor.cs index b26b51559e..d9912dab2d 100644 --- a/TUnit.OpenTelemetry/TUnitTestCorrelationProcessor.cs +++ b/TUnit.OpenTelemetry/TUnitTestCorrelationProcessor.cs @@ -23,6 +23,13 @@ namespace TUnit.OpenTelemetry; /// , not — this avoids /// cross-attribution when a span outlives its test's async context and is stopped on a /// thread where belongs to a different concurrent test. +/// +/// Tag writes during become visible to downstream processors and +/// exporters that defer serialization — the default BatchExportProcessor, and +/// reference-capturing exporters like InMemoryExporter. Synchronous pipelines +/// (e.g. SimpleExportProcessor) that serialize inside their own OnEnd +/// only observe the tag if this processor is registered before them. +/// /// public sealed class TUnitTestCorrelationProcessor : BaseProcessor { From 8d46f3e18e5ed8b08ad85c7c353d533419bbe93c Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Wed, 22 Apr 2026 22:00:12 +0100 Subject: [PATCH 5/9] refactor(aspire): drop TUnitBaggagePropagationHandler, use runtime DiagnosticsHandler MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Aspire handler injected traceparent before SocketsHttpHandler's internal DiagnosticsHandler got to do it, so outgoing requests carried the test body's span ID instead of the runtime-emitted client span ID. SUT server spans then parented to test body as siblings of the client span rather than as children, breaking the standard OTel client/server waterfall. The fix is to stop injecting. AspireFixture.CreateHttpClient now returns new HttpClient(new SocketsHttpHandler { SslOptions = ... }) and lets the runtime handle everything: - DiagnosticsHandler creates the client Activity - DistributedContextPropagator.Current.Inject emits traceparent+baggage against that client Activity's span ID (W3C) - baggage walks the parent chain so tunit.test.id flows to the SUT - SUT server span parents correctly under the client span Also subscribes the test-runner's TracerProvider to System.Net.Http so the runtime-emitted client span is actually exported — without this the span is created but not visible on dashboards, leaving server spans with orphan parents in cross-process traces. Users with TUNIT_KEEP_LEGACY_PROPAGATOR=1 no longer get the W3C baggage belt-and-braces emission that the handler did; the runtime uses whatever propagator is configured, which is the correct respect-the-opt-out behavior. Verified end-to-end against Jaeger: the Aspire trace-demo test now produces a clean 11-span waterfall with proper client/server pairing at every HTTP boundary. --- .../BaggagePropagationHandlerTests.cs | 242 ------------------ TUnit.Aspire/AspireFixture.cs | 20 +- .../Http/TUnitBaggagePropagationHandler.cs | 25 -- TUnit.OpenTelemetry/AutoStart.cs | 5 + 4 files changed, 14 insertions(+), 278 deletions(-) delete mode 100644 TUnit.Aspire.Tests/BaggagePropagationHandlerTests.cs delete mode 100644 TUnit.Aspire/Http/TUnitBaggagePropagationHandler.cs diff --git a/TUnit.Aspire.Tests/BaggagePropagationHandlerTests.cs b/TUnit.Aspire.Tests/BaggagePropagationHandlerTests.cs deleted file mode 100644 index 73868ecdd8..0000000000 --- a/TUnit.Aspire.Tests/BaggagePropagationHandlerTests.cs +++ /dev/null @@ -1,242 +0,0 @@ -using System.Diagnostics; -using System.Net; -using TUnit.Aspire.Http; -using TUnit.Assertions; -using TUnit.Assertions.Extensions; -using TUnit.Core; - -namespace TUnit.Aspire.Tests; - -[NotInParallel(nameof(BaggagePropagationHandlerTests))] -public class BaggagePropagationHandlerTests -{ - [Test] - public async Task SendAsync_InjectsTraceparentHeader_FromAmbientActivity() - { - Activity.Current = null; - using var activity = new Activity("test-traceparent").Start(); - - var captured = new CaptureHandler(); - var handler = new TUnitBaggagePropagationHandler { InnerHandler = captured }; - using var client = new HttpClient(handler); - - await client.GetAsync("http://localhost/test"); - - var traceparent = captured.LastRequest!.Headers.GetValues("traceparent").First(); - var parts = traceparent.Split('-'); - - await AssertValidW3CTraceparent(traceparent); - await Assert.That(parts[1]).IsEqualTo(activity.TraceId.ToString()); - // No synthesized client span — traceparent's parent-id is the ambient activity itself. - // .NET's System.Net.Http ActivitySource emits the real outbound client span downstream. - await Assert.That(parts[2]).IsEqualTo(activity.SpanId.ToString()); - } - - [Test] - public async Task SendAsync_InjectsBaggage_FromAmbientActivity() - { - Activity.Current = null; - using var activity = new Activity("test-fallback").Start(); - activity.SetBaggage(TUnitActivitySource.TagTestId, "my-test-context-id"); - - var captured = new CaptureHandler(); - var handler = new TUnitBaggagePropagationHandler { InnerHandler = captured }; - using var client = new HttpClient(handler); - - await client.GetAsync("http://localhost/test"); - - var baggageHeader = captured.LastRequest!.Headers.GetValues("baggage").First(); - await Assert.That(baggageHeader).Contains(TUnitActivitySource.TagTestId); - await Assert.That(baggageHeader).Contains("my-test-context-id"); - } - - [Test] - public async Task SendAsync_PropagatesInnerHandlerException() - { - Activity.Current = null; - using var activity = new Activity("test-transport-error").Start(); - - var handler = new TUnitBaggagePropagationHandler - { - InnerHandler = new ThrowingHandler(new HttpRequestException("boom")), - }; - using var client = new HttpClient(handler); - - HttpRequestException? thrown = null; - try - { - await client.GetAsync("http://localhost/test"); - } - catch (HttpRequestException ex) - { - thrown = ex; - } - - await Assert.That(thrown).IsNotNull(); - await Assert.That(thrown!.Message).IsEqualTo("boom"); - } - - [Test] - public async Task SendAsync_ForwardsInnerHandlerResponseStatus() - { - Activity.Current = null; - using var activity = new Activity("test-status").Start(); - - var captured = new CaptureHandler(HttpStatusCode.NotFound); - var handler = new TUnitBaggagePropagationHandler { InnerHandler = captured }; - using var client = new HttpClient(handler); - - using var response = await client.GetAsync("http://localhost/test"); - - await Assert.That((int)response.StatusCode).IsEqualTo(404); - } - - [Test] - public async Task SendAsync_InjectsBaggageHeader_WithActivityBaggage() - { - Activity.Current = null; - using var activity = new Activity("test-inject-baggage").Start(); - activity.SetBaggage(TUnitActivitySource.TagTestId, "my-test-context-id"); - activity.SetBaggage("custom.key", "custom-value"); - - var captured = new CaptureHandler(); - var handler = new TUnitBaggagePropagationHandler { InnerHandler = captured }; - using var client = new HttpClient(handler); - - await client.GetAsync("http://localhost/test"); - - await Assert.That(captured.LastRequest!.Headers.Contains("baggage")).IsTrue(); - - var baggageHeader = captured.LastRequest.Headers.GetValues("baggage").First(); - await Assert.That(baggageHeader).Contains(TUnitActivitySource.TagTestId); - await Assert.That(baggageHeader).Contains("my-test-context-id"); - await Assert.That(baggageHeader).Contains("custom.key"); - await Assert.That(baggageHeader).Contains("custom-value"); - } - - [Test] - public async Task SendAsync_MultipleBaggageItems_CommaSeparated() - { - Activity.Current = null; - using var activity = new Activity("test-multi-baggage").Start(); - activity.SetBaggage("key1", "val1"); - activity.SetBaggage("key2", "val2"); - - var captured = new CaptureHandler(); - var handler = new TUnitBaggagePropagationHandler { InnerHandler = captured }; - using var client = new HttpClient(handler); - - await client.GetAsync("http://localhost/test"); - - var baggageHeader = captured.LastRequest!.Headers.GetValues("baggage").First(); - await Assert.That(baggageHeader).Contains(","); - } - - [Test] - public async Task SendAsync_NoBaggage_DoesNotAddBaggageHeader() - { - Activity.Current = null; - using var activity = new Activity("test-no-baggage").Start(); - - var captured = new CaptureHandler(); - var handler = new TUnitBaggagePropagationHandler { InnerHandler = captured }; - using var client = new HttpClient(handler); - - await client.GetAsync("http://localhost/test"); - - await Assert.That(captured.LastRequest!.Headers.Contains("baggage")).IsFalse(); - } - - [Test] - public async Task SendAsync_NoActivity_DoesNotInjectTraceContext() - { - Activity.Current = null; - - var captured = new CaptureHandler(); - var handler = new TUnitBaggagePropagationHandler { InnerHandler = captured }; - using var client = new HttpClient(handler); - - await client.GetAsync("http://localhost/test"); - - await Assert.That(captured.LastRequest!.Headers.Contains("traceparent")).IsFalse(); - await Assert.That(captured.LastRequest.Headers.Contains("baggage")).IsFalse(); - } - - [Test] - public async Task SendAsync_BaggageValues_AreUriEncoded() - { - Activity.Current = null; - using var activity = new Activity("test-encoding").Start(); - activity.SetBaggage("key with spaces", "value=with&special"); - - var captured = new CaptureHandler(); - var handler = new TUnitBaggagePropagationHandler { InnerHandler = captured }; - using var client = new HttpClient(handler); - - await client.GetAsync("http://localhost/test"); - - var baggageHeader = captured.LastRequest!.Headers.GetValues("baggage").First(); - await Assert.That(baggageHeader).Contains("key%20with%20spaces"); - await Assert.That(baggageHeader).Contains("value%3Dwith%26special"); - } - - [Test] - public async Task SendAsync_ExistingBaggageHeader_IsPreserved() - { - Activity.Current = null; - using var activity = new Activity("test-existing-baggage").Start(); - activity.SetBaggage("should.not.appear", "true"); - - var captured = new CaptureHandler(); - var handler = new TUnitBaggagePropagationHandler { InnerHandler = captured }; - using var client = new HttpClient(handler); - - var request = new HttpRequestMessage(HttpMethod.Get, "http://localhost/test"); - request.Headers.TryAddWithoutValidation("baggage", "existing=value"); - - await client.SendAsync(request); - - var allBaggageValues = string.Join(",", - captured.LastRequest!.Headers.GetValues("baggage")); - await Assert.That(allBaggageValues).Contains("existing=value"); - await Assert.That(allBaggageValues).DoesNotContain("should.not.appear"); - } - - private static async Task AssertValidW3CTraceparent(string traceparent) - { - var parts = traceparent.Split('-'); - - await Assert.That(parts.Length).IsEqualTo(4); - await Assert.That(parts[0]).IsEqualTo("00"); - await Assert.That(parts[1].Length).IsEqualTo(32); - await Assert.That(parts[1].All(static c => Uri.IsHexDigit(c))).IsTrue(); - await Assert.That(parts[2].Length).IsEqualTo(16); - await Assert.That(parts[2].All(static c => Uri.IsHexDigit(c))).IsTrue(); - await Assert.That(parts[3] is "00" or "01").IsTrue(); - } - - /// - /// A handler that captures the outgoing request instead of sending it over the network. - /// - private sealed class CaptureHandler( - HttpStatusCode statusCode = HttpStatusCode.OK) : HttpMessageHandler - { - public HttpRequestMessage? LastRequest { get; private set; } - - protected override Task SendAsync( - HttpRequestMessage request, CancellationToken cancellationToken) - { - LastRequest = request; - return Task.FromResult(new HttpResponseMessage(statusCode)); - } - } - - private sealed class ThrowingHandler(Exception exception) : HttpMessageHandler - { - protected override Task SendAsync( - HttpRequestMessage request, CancellationToken cancellationToken) - { - return Task.FromException(exception); - } - } -} diff --git a/TUnit.Aspire/AspireFixture.cs b/TUnit.Aspire/AspireFixture.cs index b037d20161..e779c2247a 100644 --- a/TUnit.Aspire/AspireFixture.cs +++ b/TUnit.Aspire/AspireFixture.cs @@ -55,17 +55,15 @@ public HttpClient CreateHttpClient(string resourceName, string? endpointName = n return App.CreateHttpClient(resourceName, endpointName); } - // Share a single handler across all HttpClient instances. The handler is stateless: - // Activity.Current is async-local/per-test, and each SendAsync call creates and - // disposes its own client Activity span, so sharing stays safe while reusing the - // SocketsHttpHandler connection pool across tests. - _httpHandler ??= new Http.TUnitBaggagePropagationHandler - { - InnerHandler = new SocketsHttpHandler - { - // Match Aspire's CreateHttpClient behavior: trust dev certs for HTTPS resources - SslOptions = { RemoteCertificateValidationCallback = (_, _, _, _) => true }, - }, + // A shared SocketsHttpHandler reuses the connection pool across tests. + // The runtime's DiagnosticsHandler (auto-inserted by SocketsHttpHandler) creates + // the outbound client Activity and injects W3C traceparent + baggage via + // DistributedContextPropagator — the ambient test Activity's tunit.test.id baggage + // flows to the SUT automatically. No TUnit-side propagation handler is needed. + _httpHandler ??= new SocketsHttpHandler + { + // Match Aspire's CreateHttpClient behavior: trust dev certs for HTTPS resources + SslOptions = { RemoteCertificateValidationCallback = (_, _, _, _) => true }, }; return new HttpClient(_httpHandler, disposeHandler: false) diff --git a/TUnit.Aspire/Http/TUnitBaggagePropagationHandler.cs b/TUnit.Aspire/Http/TUnitBaggagePropagationHandler.cs deleted file mode 100644 index 3591e05409..0000000000 --- a/TUnit.Aspire/Http/TUnitBaggagePropagationHandler.cs +++ /dev/null @@ -1,25 +0,0 @@ -using System.Diagnostics; -using TUnit.Core; - -namespace TUnit.Aspire.Http; - -/// -/// DelegatingHandler that injects W3C traceparent and baggage headers into -/// outgoing requests made through AspireFixture.CreateHttpClient. -/// -/// -/// Aspire's test HttpClient hits real sockets, so .NET's built-in -/// System.Net.Http ActivitySource already emits the outbound client span. This -/// handler only ensures trace context flows from the ambient test Activity onto the -/// outgoing request before that span starts, so the SUT can correlate requests to the -/// originating test. -/// -internal sealed class TUnitBaggagePropagationHandler : DelegatingHandler -{ - protected override Task SendAsync( - HttpRequestMessage request, CancellationToken cancellationToken) - { - HttpActivityPropagator.Inject(Activity.Current, request.Headers); - return base.SendAsync(request, cancellationToken); - } -} diff --git a/TUnit.OpenTelemetry/AutoStart.cs b/TUnit.OpenTelemetry/AutoStart.cs index a1ebf2c9b2..e6e848f383 100644 --- a/TUnit.OpenTelemetry/AutoStart.cs +++ b/TUnit.OpenTelemetry/AutoStart.cs @@ -62,6 +62,11 @@ public static void Start() var builder = Sdk.CreateTracerProviderBuilder() .AddSource("TUnit") .AddSource("TUnit.Lifecycle") + // Runtime-emitted client spans from the test-runner's own HttpClient traffic + // (e.g. AspireFixture.CreateHttpClient). Emitted by .NET's SocketsHttpHandler + // pipeline; without this subscription the spans exist but aren't exported, and + // cross-process traces show orphan-parent server spans on the SUT side. + .AddSource("System.Net.Http") .AddProcessor(new TUnitTestCorrelationProcessor()); if (otlpEndpoint is not null) From 2f89741d897df7ec152e07048537ce238c014904 Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Wed, 22 Apr 2026 22:06:36 +0100 Subject: [PATCH 6/9] style: trim CreateHttpClient/AutoStart comments; narrow _httpHandler type MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Drop the WHAT explanation of what the runtime's DiagnosticsHandler does in AspireFixture.CreateHttpClient — the behavior is already documented on the method's XML summary and is self-evident from the code. Drop the WHAT sentence at AutoStart's AddSource("System.Net.Http"), keep only the WHY (orphan-parent server spans without it). Narrow `_httpHandler` field from `HttpMessageHandler?` to `SocketsHttpHandler?` — accurate to what's actually stored, makes SslOptions/PooledConnectionLifetime accessible without casts if we need them later. --- TUnit.Aspire/AspireFixture.cs | 7 +------ TUnit.OpenTelemetry/AutoStart.cs | 6 ++---- 2 files changed, 3 insertions(+), 10 deletions(-) diff --git a/TUnit.Aspire/AspireFixture.cs b/TUnit.Aspire/AspireFixture.cs index e779c2247a..50ee4d70b3 100644 --- a/TUnit.Aspire/AspireFixture.cs +++ b/TUnit.Aspire/AspireFixture.cs @@ -29,7 +29,7 @@ public class AspireFixture : IAsyncInitializer, IAsyncDisposable { private DistributedApplication? _app; private OtlpReceiver? _otlpReceiver; - private HttpMessageHandler? _httpHandler; + private SocketsHttpHandler? _httpHandler; /// /// The running Aspire distributed application. @@ -55,11 +55,6 @@ public HttpClient CreateHttpClient(string resourceName, string? endpointName = n return App.CreateHttpClient(resourceName, endpointName); } - // A shared SocketsHttpHandler reuses the connection pool across tests. - // The runtime's DiagnosticsHandler (auto-inserted by SocketsHttpHandler) creates - // the outbound client Activity and injects W3C traceparent + baggage via - // DistributedContextPropagator — the ambient test Activity's tunit.test.id baggage - // flows to the SUT automatically. No TUnit-side propagation handler is needed. _httpHandler ??= new SocketsHttpHandler { // Match Aspire's CreateHttpClient behavior: trust dev certs for HTTPS resources diff --git a/TUnit.OpenTelemetry/AutoStart.cs b/TUnit.OpenTelemetry/AutoStart.cs index e6e848f383..61691eaceb 100644 --- a/TUnit.OpenTelemetry/AutoStart.cs +++ b/TUnit.OpenTelemetry/AutoStart.cs @@ -62,10 +62,8 @@ public static void Start() var builder = Sdk.CreateTracerProviderBuilder() .AddSource("TUnit") .AddSource("TUnit.Lifecycle") - // Runtime-emitted client spans from the test-runner's own HttpClient traffic - // (e.g. AspireFixture.CreateHttpClient). Emitted by .NET's SocketsHttpHandler - // pipeline; without this subscription the spans exist but aren't exported, and - // cross-process traces show orphan-parent server spans on the SUT side. + // Without this subscription, runtime-emitted HttpClient spans are created but + // not exported, leaving orphan-parent server spans on the SUT side. .AddSource("System.Net.Http") .AddProcessor(new TUnitTestCorrelationProcessor()); From e39814e46ed34db9e4e1e7f77b2ab2bae703a789 Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Wed, 22 Apr 2026 22:12:33 +0100 Subject: [PATCH 7/9] docs: call out SimpleExportProcessor ordering in correlation example --- docs/docs/examples/opentelemetry.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/docs/examples/opentelemetry.md b/docs/docs/examples/opentelemetry.md index 7b829ee8b4..8c5d63a293 100644 --- a/docs/docs/examples/opentelemetry.md +++ b/docs/docs/examples/opentelemetry.md @@ -336,6 +336,8 @@ public sealed class TUnitTagProcessor : BaseProcessor .AddProcessor(new TUnitTagProcessor()) ``` +Register the correlation processor **before** any synchronous exporter (`SimpleExportProcessor`-based). The built-in `TUnitTestCorrelationProcessor` tags at both `OnStart` and `OnEnd`, and a `SimpleExport`-wrapped exporter that runs first would serialize the activity before the tag is applied. `BatchExportProcessor` (the default for OTLP/Jaeger/Zipkin) defers serialization, so order doesn't matter there. + Now you can filter by `tunit.test.id` in your backend even when the trace hierarchy is wrong. **Better fix** if you control the worker: stop it from capturing the test's context in the first place. From 69229a8571b6df648ac247ca01821fd1760913ca Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Wed, 22 Apr 2026 23:07:16 +0100 Subject: [PATCH 8/9] chore: update OpenTelemetry PublicAPI snapshots for line-number shift The AddSource("System.Net.Http") comment in AutoStart.cs shifted the Stop() method from line 93 to line 96; the line-number literal in the [After] attribute appears in the PublicAPI snapshot and needs to match. --- ...Telemetry_Library_Has_No_API_Changes.DotNet10_0.verified.txt | 2 +- ...nTelemetry_Library_Has_No_API_Changes.DotNet8_0.verified.txt | 2 +- ...nTelemetry_Library_Has_No_API_Changes.DotNet9_0.verified.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/TUnit.PublicAPI/Tests.OpenTelemetry_Library_Has_No_API_Changes.DotNet10_0.verified.txt b/TUnit.PublicAPI/Tests.OpenTelemetry_Library_Has_No_API_Changes.DotNet10_0.verified.txt index 6ad2a23a0c..868479063a 100644 --- a/TUnit.PublicAPI/Tests.OpenTelemetry_Library_Has_No_API_Changes.DotNet10_0.verified.txt +++ b/TUnit.PublicAPI/Tests.OpenTelemetry_Library_Has_No_API_Changes.DotNet10_0.verified.txt @@ -18,7 +18,7 @@ namespace public const int AutoStartOrder = 2147483647; [.Before(., "PATH_SCRUBBED", 29, Order=2147483647)] public static void Start() { } - [.After(., "PATH_SCRUBBED", 93, Order=2147483647)] + [.After(., "PATH_SCRUBBED", 96, Order=2147483647)] public static void Stop() { } } public static class TUnitOpenTelemetry diff --git a/TUnit.PublicAPI/Tests.OpenTelemetry_Library_Has_No_API_Changes.DotNet8_0.verified.txt b/TUnit.PublicAPI/Tests.OpenTelemetry_Library_Has_No_API_Changes.DotNet8_0.verified.txt index a4c3f7c74e..d96d5c79e8 100644 --- a/TUnit.PublicAPI/Tests.OpenTelemetry_Library_Has_No_API_Changes.DotNet8_0.verified.txt +++ b/TUnit.PublicAPI/Tests.OpenTelemetry_Library_Has_No_API_Changes.DotNet8_0.verified.txt @@ -18,7 +18,7 @@ namespace public const int AutoStartOrder = 2147483647; [.Before(., "PATH_SCRUBBED", 29, Order=2147483647)] public static void Start() { } - [.After(., "PATH_SCRUBBED", 93, Order=2147483647)] + [.After(., "PATH_SCRUBBED", 96, Order=2147483647)] public static void Stop() { } } public static class TUnitOpenTelemetry diff --git a/TUnit.PublicAPI/Tests.OpenTelemetry_Library_Has_No_API_Changes.DotNet9_0.verified.txt b/TUnit.PublicAPI/Tests.OpenTelemetry_Library_Has_No_API_Changes.DotNet9_0.verified.txt index 57e91cd847..a8fbb32c16 100644 --- a/TUnit.PublicAPI/Tests.OpenTelemetry_Library_Has_No_API_Changes.DotNet9_0.verified.txt +++ b/TUnit.PublicAPI/Tests.OpenTelemetry_Library_Has_No_API_Changes.DotNet9_0.verified.txt @@ -18,7 +18,7 @@ namespace public const int AutoStartOrder = 2147483647; [.Before(., "PATH_SCRUBBED", 29, Order=2147483647)] public static void Start() { } - [.After(., "PATH_SCRUBBED", 93, Order=2147483647)] + [.After(., "PATH_SCRUBBED", 96, Order=2147483647)] public static void Stop() { } } public static class TUnitOpenTelemetry From e7ca305be6ac3c2161ee6c2c61333867dae1cba2 Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Wed, 22 Apr 2026 23:19:00 +0100 Subject: [PATCH 9/9] docs: add ordering note near custom pipeline example; bump polling to 5s - opentelemetry.md: duplicate the SimpleExportProcessor ordering caveat adjacent to the custom pipeline (Option B) code sample, in addition to the correlation-processor section later on. - AutoConfigureOpenTelemetryTests: bump polling window from 2s to 5s for slower CI runners. Zero cost in the success path. --- TUnit.AspNetCore.Tests/AutoConfigureOpenTelemetryTests.cs | 2 +- docs/docs/examples/opentelemetry.md | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/TUnit.AspNetCore.Tests/AutoConfigureOpenTelemetryTests.cs b/TUnit.AspNetCore.Tests/AutoConfigureOpenTelemetryTests.cs index 26629213f0..7dbe558dae 100644 --- a/TUnit.AspNetCore.Tests/AutoConfigureOpenTelemetryTests.cs +++ b/TUnit.AspNetCore.Tests/AutoConfigureOpenTelemetryTests.cs @@ -38,7 +38,7 @@ public async Task AutoWires_TagsAspNetCoreSpans_WithTestId() // ASP.NET Core stops its server activity on a continuation that may outlive the // client response, so poll briefly instead of reading _exported synchronously. - var deadline = Environment.TickCount64 + 2_000; + var deadline = Environment.TickCount64 + 5_000; Activity? taggedSpan = null; while (Environment.TickCount64 < deadline) { diff --git a/docs/docs/examples/opentelemetry.md b/docs/docs/examples/opentelemetry.md index 8c5d63a293..2e46d18313 100644 --- a/docs/docs/examples/opentelemetry.md +++ b/docs/docs/examples/opentelemetry.md @@ -106,6 +106,8 @@ Use one stable service name for the test runner (for example, `MyTests`) rather `service.name` per test. Individual tests are already distinguished by their own trace IDs and TUnit tags such as `tunit.test.id`. +If you add `TUnitTestCorrelationProcessor` for cross-boundary tagging, register it **before** any synchronous exporter (`SimpleExportProcessor`-based). The built-in processor tags at both `OnStart` and `OnEnd`, so a `SimpleExport`-wrapped exporter that runs first would serialize the activity before the tag is applied. `BatchExportProcessor` (the default for OTLP/Jaeger/Zipkin) defers serialization, so order doesn't matter there. + ### Option C: Raw `ActivityListener` (no SDK dependency) If you don't want the OpenTelemetry SDK, you can subscribe directly with a `System.Diagnostics.ActivityListener`: