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.