From 89bcd6364fc326fdac4e23e9d0502ff9278c9ac5 Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Fri, 17 Apr 2026 17:00:33 +0100 Subject: [PATCH 1/2] feat: auto-propagate test trace context through IHttpClientFactory Registers an IHttpMessageHandlerBuilderFilter in TestWebApplicationFactory so that every IHttpClientFactory pipeline built inside the SUT (AddHttpClient(), named clients, typed clients) automatically carries the test's traceparent, baggage, and X-TUnit-TestId headers on outbound calls. Opt out per-test via WebApplicationTestOptions.AutoPropagateHttpClientFactory = false. Closes #5590. --- .../Http/TUnitHttpClientFilter.cs | 33 +++++++++++ .../Http/TUnitTestIdHandler.cs | 2 +- .../TestWebApplicationFactory.cs | 15 +++-- .../TracedWebApplicationFactory.cs | 9 +-- .../WebApplicationTestOptions.cs | 14 +++++ TUnit.AspNetCore.Tests.WebApp/Program.cs | 26 +++++++++ .../IHttpClientFactoryPropagationTests.cs | 55 +++++++++++++++++++ docs/docs/examples/aspnet.md | 7 +++ docs/docs/examples/opentelemetry.md | 2 +- docs/docs/guides/distributed-tracing.md | 12 ++-- 10 files changed, 157 insertions(+), 18 deletions(-) create mode 100644 TUnit.AspNetCore.Core/Http/TUnitHttpClientFilter.cs create mode 100644 TUnit.AspNetCore.Tests/IHttpClientFactoryPropagationTests.cs diff --git a/TUnit.AspNetCore.Core/Http/TUnitHttpClientFilter.cs b/TUnit.AspNetCore.Core/Http/TUnitHttpClientFilter.cs new file mode 100644 index 0000000000..6d3ca4d112 --- /dev/null +++ b/TUnit.AspNetCore.Core/Http/TUnitHttpClientFilter.cs @@ -0,0 +1,33 @@ +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. +/// +internal sealed class TUnitHttpClientFilter : IHttpMessageHandlerBuilderFilter +{ + public Action Configure(Action next) => + builder => + { + next(builder); + 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..46161fcb02 --- /dev/null +++ b/TUnit.AspNetCore.Tests/IHttpClientFactoryPropagationTests.cs @@ -0,0 +1,55 @@ +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:"); + } +} 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. From e4898e9dad6d256e3e56e10ad58501769e466cdb Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Fri, 17 Apr 2026 17:05:30 +0100 Subject: [PATCH 2/2] address review: document statelessness + tighten opt-out assertion - Document that both handler types inserted by TUnitHttpClientFilter must remain stateless/thread-safe because IHttpClientFactory caches pipelines and shares handler instances across concurrent parallel-test requests. - Explain the outermost-insert intent so a future refactor doesn't reverse the order. - Opt-out test now also asserts the downstream hop does not carry baggage, mirroring the positive test. --- TUnit.AspNetCore.Core/Http/TUnitHttpClientFilter.cs | 10 ++++++++++ .../IHttpClientFactoryPropagationTests.cs | 1 + 2 files changed, 11 insertions(+) diff --git a/TUnit.AspNetCore.Core/Http/TUnitHttpClientFilter.cs b/TUnit.AspNetCore.Core/Http/TUnitHttpClientFilter.cs index 6d3ca4d112..b98c6ee75f 100644 --- a/TUnit.AspNetCore.Core/Http/TUnitHttpClientFilter.cs +++ b/TUnit.AspNetCore.Core/Http/TUnitHttpClientFilter.cs @@ -7,6 +7,13 @@ namespace TUnit.AspNetCore.Http; /// 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 { @@ -14,6 +21,9 @@ public Action Configure(Action { 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()); }; diff --git a/TUnit.AspNetCore.Tests/IHttpClientFactoryPropagationTests.cs b/TUnit.AspNetCore.Tests/IHttpClientFactoryPropagationTests.cs index 46161fcb02..ff193e3bc3 100644 --- a/TUnit.AspNetCore.Tests/IHttpClientFactoryPropagationTests.cs +++ b/TUnit.AspNetCore.Tests/IHttpClientFactoryPropagationTests.cs @@ -51,5 +51,6 @@ public async Task SutHttpClientFactory_DoesNotPropagate_WhenAutoPropagationDisab await Assert.That(echoed).DoesNotContain(TUnitTestIdHandler.HeaderName); await Assert.That(echoed).DoesNotContain("traceparent:"); + await Assert.That(echoed).DoesNotContain("baggage:"); } }