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`: