diff --git a/TUnit.AspNetCore.Core/Http/TUnitHttpClientFilter.cs b/TUnit.AspNetCore.Core/Http/TUnitHttpClientFilter.cs
new file mode 100644
index 0000000000..b98c6ee75f
--- /dev/null
+++ b/TUnit.AspNetCore.Core/Http/TUnitHttpClientFilter.cs
@@ -0,0 +1,43 @@
+using Microsoft.Extensions.Http;
+
+namespace TUnit.AspNetCore.Http;
+
+///
+/// Prepends and
+/// to every handler pipeline built in the SUT.
+/// Ensures outbound HTTP calls made via AddHttpClient<T>(), named, or typed clients
+/// carry the current test's traceparent, baggage, and X-TUnit-TestId headers.
+///
+/// Both handler types must remain stateless and thread-safe:
+/// caches the built pipeline and shares the same handler instances across every request on a given
+/// named client, including concurrent requests from parallel tests. Per-test correlation comes from
+/// and ,
+/// which are async-local — do not add instance fields capturing per-request state to either handler.
+///
+///
+internal sealed class TUnitHttpClientFilter : IHttpMessageHandlerBuilderFilter
+{
+ public Action Configure(Action next) =>
+ builder =>
+ {
+ next(builder);
+ // Insert at outermost positions so TUnit headers are emitted before any
+ // SUT-registered handler can run. Order must stay ActivityPropagationHandler
+ // first (writes traceparent/baggage) then TUnitTestIdHandler (writes X-TUnit-TestId).
+ builder.AdditionalHandlers.Insert(0, new ActivityPropagationHandler());
+ builder.AdditionalHandlers.Insert(1, new TUnitTestIdHandler());
+ };
+
+ ///
+ /// Returns the TUnit propagation handlers followed by the caller-supplied handlers,
+ /// in the order they should be passed to WebApplicationFactory.CreateDefaultClient.
+ ///
+ internal static DelegatingHandler[] PrependPropagationHandlers(DelegatingHandler[] handlers)
+ {
+ var all = new DelegatingHandler[handlers.Length + 2];
+ all[0] = new ActivityPropagationHandler();
+ all[1] = new TUnitTestIdHandler();
+ Array.Copy(handlers, 0, all, 2, handlers.Length);
+ return all;
+ }
+}
diff --git a/TUnit.AspNetCore.Core/Http/TUnitTestIdHandler.cs b/TUnit.AspNetCore.Core/Http/TUnitTestIdHandler.cs
index 7afd6ee513..d7f13b15b3 100644
--- a/TUnit.AspNetCore.Core/Http/TUnitTestIdHandler.cs
+++ b/TUnit.AspNetCore.Core/Http/TUnitTestIdHandler.cs
@@ -38,7 +38,7 @@ public TUnitTestIdHandler(HttpMessageHandler innerHandler) : base(innerHandler)
protected override Task SendAsync(
HttpRequestMessage request, CancellationToken cancellationToken)
{
- if ((_testContext ?? TestContext.Current) is { } ctx)
+ if ((_testContext ?? TestContext.Current) is { } ctx && !request.Headers.Contains(HeaderName))
{
request.Headers.TryAddWithoutValidation(HeaderName, ctx.Id);
}
diff --git a/TUnit.AspNetCore.Core/TestWebApplicationFactory.cs b/TUnit.AspNetCore.Core/TestWebApplicationFactory.cs
index 1a3c33542e..3bc0e810c3 100644
--- a/TUnit.AspNetCore.Core/TestWebApplicationFactory.cs
+++ b/TUnit.AspNetCore.Core/TestWebApplicationFactory.cs
@@ -3,8 +3,11 @@
using Microsoft.AspNetCore.TestHost;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.DependencyInjection.Extensions;
using Microsoft.Extensions.Hosting;
+using Microsoft.Extensions.Http;
using TUnit.AspNetCore.Extensions;
+using TUnit.AspNetCore.Http;
using TUnit.AspNetCore.Interception;
using TUnit.AspNetCore.Logging;
using TUnit.Core;
@@ -41,6 +44,12 @@ public WebApplicationFactory GetIsolatedFactory(
configureIsolatedServices(services);
services.AddSingleton(testContext);
services.AddTUnitLogging(testContext);
+
+ if (options.AutoPropagateHttpClientFactory)
+ {
+ services.TryAddEnumerable(
+ ServiceDescriptor.Singleton());
+ }
});
if (options.EnableHttpExchangeCapture)
@@ -173,11 +182,7 @@ private static ServiceDescriptor WrapHostedServiceDescriptor(ServiceDescriptor d
///
public new HttpClient CreateDefaultClient(params DelegatingHandler[] handlers)
{
- var all = new DelegatingHandler[handlers.Length + 2];
- all[0] = new ActivityPropagationHandler();
- all[1] = new TUnitTestIdHandler();
- Array.Copy(handlers, 0, all, 2, handlers.Length);
- return base.CreateDefaultClient(all);
+ return base.CreateDefaultClient(TUnitHttpClientFilter.PrependPropagationHandlers(handlers));
}
///
diff --git a/TUnit.AspNetCore.Core/TracedWebApplicationFactory.cs b/TUnit.AspNetCore.Core/TracedWebApplicationFactory.cs
index 55658086b8..6d549a3b39 100644
--- a/TUnit.AspNetCore.Core/TracedWebApplicationFactory.cs
+++ b/TUnit.AspNetCore.Core/TracedWebApplicationFactory.cs
@@ -1,5 +1,6 @@
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.AspNetCore.TestHost;
+using TUnit.AspNetCore.Http;
namespace TUnit.AspNetCore;
@@ -41,7 +42,7 @@ public TracedWebApplicationFactory(WebApplicationFactory inner)
/// Creates an with activity tracing and test context propagation.
///
public HttpClient CreateClient() =>
- _inner.CreateDefaultClient(new ActivityPropagationHandler(), new TUnitTestIdHandler());
+ _inner.CreateDefaultClient(TUnitHttpClientFilter.PrependPropagationHandlers([]));
///
/// Creates an with the specified delegating handlers, plus
@@ -49,11 +50,7 @@ public HttpClient CreateClient() =>
///
public HttpClient CreateDefaultClient(params DelegatingHandler[] handlers)
{
- var all = new DelegatingHandler[handlers.Length + 2];
- all[0] = new ActivityPropagationHandler();
- all[1] = new TUnitTestIdHandler();
- Array.Copy(handlers, 0, all, 2, handlers.Length);
- return _inner.CreateDefaultClient(all);
+ return _inner.CreateDefaultClient(TUnitHttpClientFilter.PrependPropagationHandlers(handlers));
}
///
diff --git a/TUnit.AspNetCore.Core/WebApplicationTestOptions.cs b/TUnit.AspNetCore.Core/WebApplicationTestOptions.cs
index bfeff23e64..91050dd0fc 100644
--- a/TUnit.AspNetCore.Core/WebApplicationTestOptions.cs
+++ b/TUnit.AspNetCore.Core/WebApplicationTestOptions.cs
@@ -8,4 +8,18 @@ public record WebApplicationTestOptions
/// Default is false.
///
public bool EnableHttpExchangeCapture { get; set; } = false;
+
+ ///
+ /// Gets or sets a value indicating whether outbound HTTP calls made by the SUT through
+ /// (including AddHttpClient<T>(),
+ /// named clients, and typed clients) should automatically carry the test's
+ /// traceparent, baggage, and X-TUnit-TestId headers.
+ /// Default is true.
+ ///
+ /// Set to false when the SUT already instruments its outbound HTTP calls
+ /// (for example via the OpenTelemetry HttpClient instrumentation) and you do not want
+ /// TUnit to prepend its handlers to every factory pipeline.
+ ///
+ ///
+ public bool AutoPropagateHttpClientFactory { get; set; } = true;
}
diff --git a/TUnit.AspNetCore.Tests.WebApp/Program.cs b/TUnit.AspNetCore.Tests.WebApp/Program.cs
index 03abeda9f9..3e2f4e2fa7 100644
--- a/TUnit.AspNetCore.Tests.WebApp/Program.cs
+++ b/TUnit.AspNetCore.Tests.WebApp/Program.cs
@@ -1,5 +1,8 @@
var builder = WebApplication.CreateBuilder(args);
+builder.Services.AddHttpClient("downstream")
+ .ConfigurePrimaryHttpMessageHandler(() => new HeaderEchoHandler());
+
var app = builder.Build();
var logger = app.Services.GetRequiredService().CreateLogger("Endpoints");
@@ -15,6 +18,29 @@
app.MapGet("/ping", () => "pong");
+// Outbound call through IHttpClientFactory. The downstream pipeline's primary
+// handler echoes request headers back in the response body so tests can assert
+// which headers the SUT-side HttpClient actually emitted.
+app.MapGet("/proxy", async (IHttpClientFactory factory) =>
+{
+ var client = factory.CreateClient("downstream");
+ var response = await client.GetAsync("http://downstream.test/");
+ var body = await response.Content.ReadAsStringAsync();
+ return Results.Content(body, "text/plain");
+});
+
app.Run();
public partial class Program;
+
+internal sealed class HeaderEchoHandler : HttpMessageHandler
+{
+ protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
+ {
+ var dump = string.Join("\n", request.Headers.SelectMany(h => h.Value.Select(v => $"{h.Key}: {v}")));
+ return Task.FromResult(new HttpResponseMessage(System.Net.HttpStatusCode.OK)
+ {
+ Content = new StringContent(dump)
+ });
+ }
+}
diff --git a/TUnit.AspNetCore.Tests/IHttpClientFactoryPropagationTests.cs b/TUnit.AspNetCore.Tests/IHttpClientFactoryPropagationTests.cs
new file mode 100644
index 0000000000..ff193e3bc3
--- /dev/null
+++ b/TUnit.AspNetCore.Tests/IHttpClientFactoryPropagationTests.cs
@@ -0,0 +1,56 @@
+using System.Diagnostics;
+using TUnit.AspNetCore;
+using TUnit.Core;
+
+namespace TUnit.AspNetCore.Tests;
+
+///
+/// Coverage for thomhurst/TUnit#5590 — the SUT's pipelines
+/// must automatically carry the test's trace context when
+/// is true.
+///
+public class IHttpClientFactoryPropagationTests : WebApplicationTest
+{
+ [Test]
+ public async Task SutHttpClientFactory_Propagates_TestContextHeaders()
+ {
+ using var activity = new Activity("outer-test-span").Start();
+
+ using var client = Factory.CreateClient();
+
+ var response = await client.GetAsync("/proxy");
+ response.EnsureSuccessStatusCode();
+
+ var echoed = await response.Content.ReadAsStringAsync();
+
+ await Assert.That(echoed).Contains("traceparent:");
+ await Assert.That(echoed).Contains(TUnitTestIdHandler.HeaderName + ": " + TestContext.Current!.Id);
+ await Assert.That(echoed).Contains("baggage:");
+ await Assert.That(echoed).Contains(activity.TraceId.ToString());
+ }
+}
+
+public class IHttpClientFactoryPropagationOptOutTests : WebApplicationTest
+{
+ protected override void ConfigureTestOptions(WebApplicationTestOptions options)
+ {
+ options.AutoPropagateHttpClientFactory = false;
+ }
+
+ [Test]
+ public async Task SutHttpClientFactory_DoesNotPropagate_WhenAutoPropagationDisabled()
+ {
+ using var activity = new Activity("outer-test-span").Start();
+
+ using var client = Factory.CreateClient();
+
+ var response = await client.GetAsync("/proxy");
+ response.EnsureSuccessStatusCode();
+
+ var echoed = await response.Content.ReadAsStringAsync();
+
+ await Assert.That(echoed).DoesNotContain(TUnitTestIdHandler.HeaderName);
+ await Assert.That(echoed).DoesNotContain("traceparent:");
+ await Assert.That(echoed).DoesNotContain("baggage:");
+ }
+}
diff --git a/docs/docs/examples/aspnet.md b/docs/docs/examples/aspnet.md
index 5e57287721..c58e88bb21 100644
--- a/docs/docs/examples/aspnet.md
+++ b/docs/docs/examples/aspnet.md
@@ -82,6 +82,13 @@ public class TodoApiTests : TestsBase
}
```
+`TestWebApplicationFactory` wires up these behaviors automatically:
+
+- **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`.
+- **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`.
+
## Core Concepts
### Why Test Isolation Matters
diff --git a/docs/docs/examples/opentelemetry.md b/docs/docs/examples/opentelemetry.md
index b4b20aec22..e8cde82e32 100644
--- a/docs/docs/examples/opentelemetry.md
+++ b/docs/docs/examples/opentelemetry.md
@@ -333,7 +333,7 @@ var client = traced.CreateClient();
Both attach the trace propagation handler automatically. See [ASP.NET Core integration](./aspnet.md) for full setup.
-For HTTP calls the SUT itself makes through `IHttpClientFactory`, today you have to add the handler manually (`.AddHttpMessageHandler()`). Tracking automation: [#5590](https://github.com/thomhurst/TUnit/issues/5590).
+Outbound HTTP calls the SUT itself makes through `IHttpClientFactory` (`AddHttpClient()`, named clients, typed clients) are also auto-instrumented by `TestWebApplicationFactory`. Opt out per-test via `WebApplicationTestOptions.AutoPropagateHttpClientFactory = false` when the SUT already owns its outbound tracing.
### "No spans show up in my exporter at all"
diff --git a/docs/docs/guides/distributed-tracing.md b/docs/docs/guides/distributed-tracing.md
index 019a8a84b3..87a8694aa3 100644
--- a/docs/docs/guides/distributed-tracing.md
+++ b/docs/docs/guides/distributed-tracing.md
@@ -135,15 +135,17 @@ Use [`TestWebApplicationFactory`](/docs/examples/aspnet) or wrap with `Traced
### `IHttpClientFactory` clients in the SUT
-Outbound HTTP calls the SUT itself makes (e.g. to a downstream service) are not auto-instrumented yet. Add the handler manually:
+`TestWebApplicationFactory` auto-registers an `IHttpMessageHandlerBuilderFilter` that prepends the TUnit tracing and test-id handlers to every `IHttpClientFactory` pipeline built in the SUT. Outbound calls from `AddHttpClient()`, named clients, and typed clients all carry `traceparent`, `baggage`, and `X-TUnit-TestId` automatically — no manual `.AddHttpMessageHandler<>()` wiring required.
+
+Opt out per-test when the SUT already instruments its own outbound HTTP (for example via the OpenTelemetry HttpClient instrumentation) by setting `WebApplicationTestOptions.AutoPropagateHttpClientFactory = false`:
```csharp
-services.AddHttpClient()
- .AddHttpMessageHandler();
+protected override void ConfigureTestOptions(WebApplicationTestOptions options)
+{
+ options.AutoPropagateHttpClientFactory = false;
+}
```
-Tracking automation: [#5590](https://github.com/thomhurst/TUnit/issues/5590).
-
### Raw `HttpClient`
`new HttpClient()` can't be intercepted. Either route through `IHttpClientFactory` or set the `traceparent` header manually.