Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 43 additions & 0 deletions TUnit.AspNetCore.Core/Http/TUnitHttpClientFilter.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
using Microsoft.Extensions.Http;

namespace TUnit.AspNetCore.Http;

/// <summary>
/// Prepends <see cref="ActivityPropagationHandler"/> and <see cref="TUnitTestIdHandler"/>
/// to every <see cref="System.Net.Http.IHttpClientFactory"/> handler pipeline built in the SUT.
/// Ensures outbound HTTP calls made via <c>AddHttpClient&lt;T&gt;()</c>, named, or typed clients
/// carry the current test's <c>traceparent</c>, <c>baggage</c>, and <c>X-TUnit-TestId</c> headers.
/// <para>
/// Both handler types must remain stateless and thread-safe: <see cref="System.Net.Http.IHttpClientFactory"/>
/// 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
/// <see cref="TUnit.Core.TestContext.Current"/> and <see cref="System.Diagnostics.Activity.Current"/>,
/// which are async-local — do not add instance fields capturing per-request state to either handler.
/// </para>
/// </summary>
internal sealed class TUnitHttpClientFilter : IHttpMessageHandlerBuilderFilter
{
public Action<HttpMessageHandlerBuilder> Configure(Action<HttpMessageHandlerBuilder> 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());
};

/// <summary>
/// Returns the TUnit propagation handlers followed by the caller-supplied handlers,
/// in the order they should be passed to <c>WebApplicationFactory.CreateDefaultClient</c>.
/// </summary>
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;
}
}
2 changes: 1 addition & 1 deletion TUnit.AspNetCore.Core/Http/TUnitTestIdHandler.cs
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ public TUnitTestIdHandler(HttpMessageHandler innerHandler) : base(innerHandler)
protected override Task<HttpResponseMessage> 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);
}
Expand Down
15 changes: 10 additions & 5 deletions TUnit.AspNetCore.Core/TestWebApplicationFactory.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -41,6 +44,12 @@ public WebApplicationFactory<TEntryPoint> GetIsolatedFactory(
configureIsolatedServices(services);
services.AddSingleton(testContext);
services.AddTUnitLogging(testContext);

if (options.AutoPropagateHttpClientFactory)
{
services.TryAddEnumerable(
ServiceDescriptor.Singleton<IHttpMessageHandlerBuilderFilter, TUnitHttpClientFilter>());
}
});

if (options.EnableHttpExchangeCapture)
Expand Down Expand Up @@ -173,11 +182,7 @@ private static ServiceDescriptor WrapHostedServiceDescriptor(ServiceDescriptor d
/// </summary>
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));
}

/// <inheritdoc cref="CreateDefaultClient(DelegatingHandler[])"/>
Expand Down
9 changes: 3 additions & 6 deletions TUnit.AspNetCore.Core/TracedWebApplicationFactory.cs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.AspNetCore.TestHost;
using TUnit.AspNetCore.Http;

namespace TUnit.AspNetCore;

Expand Down Expand Up @@ -41,19 +42,15 @@ public TracedWebApplicationFactory(WebApplicationFactory<TEntryPoint> inner)
/// Creates an <see cref="HttpClient"/> with activity tracing and test context propagation.
/// </summary>
public HttpClient CreateClient() =>
_inner.CreateDefaultClient(new ActivityPropagationHandler(), new TUnitTestIdHandler());
_inner.CreateDefaultClient(TUnitHttpClientFilter.PrependPropagationHandlers([]));

/// <summary>
/// Creates an <see cref="HttpClient"/> with the specified delegating handlers, plus
/// activity tracing and test context propagation (prepended before custom handlers).
/// </summary>
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));
}

/// <summary>
Expand Down
14 changes: 14 additions & 0 deletions TUnit.AspNetCore.Core/WebApplicationTestOptions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -8,4 +8,18 @@ public record WebApplicationTestOptions
/// Default is false.
/// </summary>
public bool EnableHttpExchangeCapture { get; set; } = false;

/// <summary>
/// Gets or sets a value indicating whether outbound HTTP calls made by the SUT through
/// <see cref="System.Net.Http.IHttpClientFactory"/> (including <c>AddHttpClient&lt;T&gt;()</c>,
/// named clients, and typed clients) should automatically carry the test's
/// <c>traceparent</c>, <c>baggage</c>, and <c>X-TUnit-TestId</c> headers.
/// Default is <c>true</c>.
/// <para>
/// Set to <c>false</c> 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.
/// </para>
/// </summary>
public bool AutoPropagateHttpClientFactory { get; set; } = true;
}
26 changes: 26 additions & 0 deletions TUnit.AspNetCore.Tests.WebApp/Program.cs
Original file line number Diff line number Diff line change
@@ -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<ILoggerFactory>().CreateLogger("Endpoints");
Expand All @@ -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<HttpResponseMessage> 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)
});
}
}
56 changes: 56 additions & 0 deletions TUnit.AspNetCore.Tests/IHttpClientFactoryPropagationTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
using System.Diagnostics;
using TUnit.AspNetCore;
using TUnit.Core;

namespace TUnit.AspNetCore.Tests;

/// <summary>
/// Coverage for thomhurst/TUnit#5590 — the SUT's <see cref="IHttpClientFactory"/> pipelines
/// must automatically carry the test's trace context when
/// <see cref="WebApplicationTestOptions.AutoPropagateHttpClientFactory"/> is <c>true</c>.
/// </summary>
public class IHttpClientFactoryPropagationTests : WebApplicationTest<TestWebAppFactory, Program>
{
[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<TestWebAppFactory, Program>
{
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:");
}
}
7 changes: 7 additions & 0 deletions docs/docs/examples/aspnet.md
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,13 @@ public class TodoApiTests : TestsBase
}
```

`TestWebApplicationFactory<T>` 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<T>()`, 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
Expand Down
2 changes: 1 addition & 1 deletion docs/docs/examples/opentelemetry.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<ActivityPropagationHandler>()`). Tracking automation: [#5590](https://github.com/thomhurst/TUnit/issues/5590).
Outbound HTTP calls the SUT itself makes through `IHttpClientFactory` (`AddHttpClient<T>()`, named clients, typed clients) are also auto-instrumented by `TestWebApplicationFactory<T>`. 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"

Expand Down
12 changes: 7 additions & 5 deletions docs/docs/guides/distributed-tracing.md
Original file line number Diff line number Diff line change
Expand Up @@ -135,15 +135,17 @@ Use [`TestWebApplicationFactory<T>`](/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<T>` 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<T>()`, 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<IDownstreamApi, DownstreamApi>()
.AddHttpMessageHandler<ActivityPropagationHandler>();
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.
Expand Down
Loading