From 5e28744a64d0526238538e279b60807e200bf1c2 Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Fri, 17 Apr 2026 20:29:18 +0100 Subject: [PATCH 1/3] feat: auto-configure OpenTelemetry in TestWebApplicationFactory SUT (#5594) TestWebApplicationFactory now augments the SUT's TracerProvider with TUnit's AspNetCore activity source, TUnitTestCorrelationProcessor, and ASP.NET Core + HttpClient instrumentation. Spans emitted inside the SUT stay queryable per-test (tunit.test.id tag) even when third-party libs break the parent chain. Opt out per-test with WebApplicationTestOptions.AutoConfigureOpenTelemetry = false. TUnitTestCorrelationProcessor moves from TUnit.OpenTelemetry to TUnit.Core (NET-only) so the AspNetCore wrapper can reference it without pulling in the zero-config package. Public namespace (TUnit.OpenTelemetry) is unchanged. --- .../TUnit.AspNetCore.Core.csproj | 6 ++ .../TestWebApplicationFactory.cs | 22 ++++++ .../WebApplicationTestOptions.cs | 18 +++++ .../AutoConfigureOpenTelemetryTests.cs | 72 +++++++++++++++++++ .../TUnit.AspNetCore.Tests.csproj | 4 ++ TUnit.Core/TUnit.Core.csproj | 3 + TUnit.Core/TUnitActivitySource.cs | 7 ++ .../TUnitTestCorrelationProcessor.cs | 4 ++ docs/docs/examples/aspnet.md | 1 + docs/docs/examples/opentelemetry.md | 11 ++- docs/docs/guides/distributed-tracing.md | 6 ++ 11 files changed, 152 insertions(+), 2 deletions(-) create mode 100644 TUnit.AspNetCore.Tests/AutoConfigureOpenTelemetryTests.cs rename {TUnit.OpenTelemetry => TUnit.Core}/TUnitTestCorrelationProcessor.cs (97%) diff --git a/TUnit.AspNetCore.Core/TUnit.AspNetCore.Core.csproj b/TUnit.AspNetCore.Core/TUnit.AspNetCore.Core.csproj index 9f9f7bc802..d80f80c7cb 100644 --- a/TUnit.AspNetCore.Core/TUnit.AspNetCore.Core.csproj +++ b/TUnit.AspNetCore.Core/TUnit.AspNetCore.Core.csproj @@ -20,6 +20,12 @@ OutputItemType="Analyzer" ReferenceOutputAssembly="false" /> + + + + + + diff --git a/TUnit.AspNetCore.Core/TestWebApplicationFactory.cs b/TUnit.AspNetCore.Core/TestWebApplicationFactory.cs index 3bc0e810c3..64336088d5 100644 --- a/TUnit.AspNetCore.Core/TestWebApplicationFactory.cs +++ b/TUnit.AspNetCore.Core/TestWebApplicationFactory.cs @@ -6,11 +6,13 @@ using Microsoft.Extensions.DependencyInjection.Extensions; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Http; +using OpenTelemetry.Trace; using TUnit.AspNetCore.Extensions; using TUnit.AspNetCore.Http; using TUnit.AspNetCore.Interception; using TUnit.AspNetCore.Logging; using TUnit.Core; +using TUnit.OpenTelemetry; namespace TUnit.AspNetCore; @@ -50,6 +52,11 @@ public WebApplicationFactory GetIsolatedFactory( services.TryAddEnumerable( ServiceDescriptor.Singleton()); } + + if (options.AutoConfigureOpenTelemetry) + { + AddTUnitOpenTelemetry(services); + } }); if (options.EnableHttpExchangeCapture) @@ -94,6 +101,21 @@ 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. + /// + private static void AddTUnitOpenTelemetry(IServiceCollection services) + { + services.AddOpenTelemetry().WithTracing(tracing => tracing + .AddSource(TUnitActivitySource.AspNetCoreHttpSourceName) + .AddProcessor(new TUnitTestCorrelationProcessor()) + .AddAspNetCoreInstrumentation() + .AddHttpClientInstrumentation()); + } + /// /// Controls whether every registered /// has its StartAsync dispatched onto a thread-pool worker with a clean diff --git a/TUnit.AspNetCore.Core/WebApplicationTestOptions.cs b/TUnit.AspNetCore.Core/WebApplicationTestOptions.cs index 91050dd0fc..2c924f79df 100644 --- a/TUnit.AspNetCore.Core/WebApplicationTestOptions.cs +++ b/TUnit.AspNetCore.Core/WebApplicationTestOptions.cs @@ -22,4 +22,22 @@ public record WebApplicationTestOptions /// /// public bool AutoPropagateHttpClientFactory { get; set; } = true; + + /// + /// Gets or sets a value indicating whether the SUT's + /// should be automatically augmented with the TUnit HTTP activity source, the + /// TUnitTestCorrelationProcessor, and ASP.NET Core + HttpClient instrumentation. + /// Default is true. + /// + /// When enabled, test spans emitted inside the SUT are tagged with the ambient + /// tunit.test.id baggage so they remain queryable per-test in backends like + /// Seq or Jaeger, even when third-party libraries break the parent-chain. + /// + /// + /// Set to false to leave the SUT's OpenTelemetry configuration untouched — + /// useful if the SUT configures its own processors and you do not want TUnit's + /// defaults layered on top. + /// + /// + public bool AutoConfigureOpenTelemetry { get; set; } = true; } diff --git a/TUnit.AspNetCore.Tests/AutoConfigureOpenTelemetryTests.cs b/TUnit.AspNetCore.Tests/AutoConfigureOpenTelemetryTests.cs new file mode 100644 index 0000000000..bac62309a3 --- /dev/null +++ b/TUnit.AspNetCore.Tests/AutoConfigureOpenTelemetryTests.cs @@ -0,0 +1,72 @@ +using System.Diagnostics; +using Microsoft.Extensions.DependencyInjection; +using OpenTelemetry.Trace; +using TUnit.AspNetCore; +using TUnit.Core; + +namespace TUnit.AspNetCore.Tests; + +/// +/// Coverage for thomhurst/TUnit#5594 — +/// automatically augments the SUT's with TUnit's +/// correlation processor + ASP.NET Core instrumentation. +/// +/// +/// Serialized against sibling auto-wire tests because +/// attaches a process-global per TracerProvider, +/// so a parallel factory's correlation processor can tag activities created by another +/// factory's SUT. Serializing keeps assertions observing only their own factory's wiring. +/// +[NotInParallel(nameof(AutoConfigureOpenTelemetryTests))] +public class AutoConfigureOpenTelemetryTests : WebApplicationTest +{ + private readonly List _exported = []; + + protected override void ConfigureTestServices(IServiceCollection services) + { + services.AddOpenTelemetry().WithTracing(t => t.AddInMemoryExporter(_exported)); + } + + [Test] + public async Task AutoWires_TagsAspNetCoreSpans_WithTestId() + { + using var client = Factory.CreateClient(); + var response = await client.GetAsync("/ping"); + response.EnsureSuccessStatusCode(); + + var testId = TestContext.Current!.Id; + var taggedSpan = _exported.FirstOrDefault(a => (a.GetTagItem(TUnitActivitySource.TagTestId) as string) == testId); + await Assert.That(taggedSpan).IsNotNull(); + } +} + +[NotInParallel(nameof(AutoConfigureOpenTelemetryTests))] +public class AutoConfigureOpenTelemetryOptOutTests : WebApplicationTest +{ + private readonly List _exported = []; + + protected override void ConfigureTestOptions(WebApplicationTestOptions options) + { + options.AutoConfigureOpenTelemetry = false; + } + + protected override void ConfigureTestServices(IServiceCollection services) + { + services.AddOpenTelemetry().WithTracing(t => t + .AddAspNetCoreInstrumentation() + .AddInMemoryExporter(_exported)); + } + + [Test] + public async Task OptOut_DoesNotTag_AspNetCoreSpans() + { + using var client = Factory.CreateClient(); + var response = await client.GetAsync("/ping"); + response.EnsureSuccessStatusCode(); + + foreach (var activity in _exported) + { + await Assert.That(activity.GetTagItem(TUnitActivitySource.TagTestId)).IsNull(); + } + } +} diff --git a/TUnit.AspNetCore.Tests/TUnit.AspNetCore.Tests.csproj b/TUnit.AspNetCore.Tests/TUnit.AspNetCore.Tests.csproj index ac3c24f9bc..8ae0077e6a 100644 --- a/TUnit.AspNetCore.Tests/TUnit.AspNetCore.Tests.csproj +++ b/TUnit.AspNetCore.Tests/TUnit.AspNetCore.Tests.csproj @@ -30,6 +30,10 @@ + + + + diff --git a/TUnit.Core/TUnit.Core.csproj b/TUnit.Core/TUnit.Core.csproj index 88433adfb8..6028ee14a4 100644 --- a/TUnit.Core/TUnit.Core.csproj +++ b/TUnit.Core/TUnit.Core.csproj @@ -66,6 +66,9 @@ + + + diff --git a/TUnit.Core/TUnitActivitySource.cs b/TUnit.Core/TUnitActivitySource.cs index 9440d0009a..5f547cb713 100644 --- a/TUnit.Core/TUnitActivitySource.cs +++ b/TUnit.Core/TUnitActivitySource.cs @@ -12,6 +12,13 @@ public static class TUnitActivitySource internal const string SourceName = "TUnit"; 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. + /// + public const string AspNetCoreHttpSourceName = "TUnit.AspNetCore.Http"; + /// W3C baggage HTTP header name. internal const string BaggageHeader = "baggage"; diff --git a/TUnit.OpenTelemetry/TUnitTestCorrelationProcessor.cs b/TUnit.Core/TUnitTestCorrelationProcessor.cs similarity index 97% rename from TUnit.OpenTelemetry/TUnitTestCorrelationProcessor.cs rename to TUnit.Core/TUnitTestCorrelationProcessor.cs index de1713bd51..b8b41458b9 100644 --- a/TUnit.OpenTelemetry/TUnitTestCorrelationProcessor.cs +++ b/TUnit.Core/TUnitTestCorrelationProcessor.cs @@ -1,3 +1,5 @@ +#if NET + using System.Diagnostics; using OpenTelemetry; using TUnit.Core; @@ -25,3 +27,5 @@ public override void OnStart(Activity activity) } } } + +#endif diff --git a/docs/docs/examples/aspnet.md b/docs/docs/examples/aspnet.md index c58e88bb21..568daf2871 100644 --- a/docs/docs/examples/aspnet.md +++ b/docs/docs/examples/aspnet.md @@ -86,6 +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`. - **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 099b6d6c8c..f0f09d75e1 100644 --- a/docs/docs/examples/opentelemetry.md +++ b/docs/docs/examples/opentelemetry.md @@ -258,8 +258,15 @@ dotnet add package OpenTelemetry.Exporter.Zipkin If you use `TestWebApplicationFactory` or `TracedWebApplicationFactory`, outgoing requests automatically propagate the current test trace via W3C `traceparent` and `baggage` headers. -Add `"TUnit.AspNetCore.Http"` as a source only if you also want TUnit's synthetic client spans -to appear in your exporter. Header propagation works either way. +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. + +Your own `WithTracing` callback on the SUT is preserved; TUnit's defaults are layered on top. If you configure your own exporter (OTLP, Jaeger, Zipkin, in-memory), test spans flow straight through it. + +Set `WebApplicationTestOptions.AutoConfigureOpenTelemetry = false` per-test to opt out — useful if the SUT owns its own processors and you don't want TUnit's defaults layered on top. ## Test Context Correlation via Activity Baggage diff --git a/docs/docs/guides/distributed-tracing.md b/docs/docs/guides/distributed-tracing.md index be75cb986b..8a68d346f1 100644 --- a/docs/docs/guides/distributed-tracing.md +++ b/docs/docs/guides/distributed-tracing.md @@ -150,6 +150,12 @@ 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. + +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. + ### Raw `HttpClient` `new HttpClient()` can't be intercepted. Either route through `IHttpClientFactory` or set the `traceparent` header manually. From a4142e609e6327e184c4ea7361c5765454c0038e Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Fri, 17 Apr 2026 20:36:48 +0100 Subject: [PATCH 2/3] refactor: keep TUnit.Core OTel-free; ref TUnit.OpenTelemetry from AspNetCore.Core Addresses PR review: TUnit.Core should not own an OpenTelemetry dependency (every TUnit user would get OTel transitively). Revert the processor move: TUnitTestCorrelationProcessor stays in TUnit.OpenTelemetry, and TUnit.AspNetCore.Core references that project directly. Also document zero-config + SUT double-processor safety on the helper. --- TUnit.AspNetCore.Core/TUnit.AspNetCore.Core.csproj | 1 + TUnit.AspNetCore.Core/TestWebApplicationFactory.cs | 3 +++ TUnit.Core/TUnit.Core.csproj | 3 --- .../TUnitTestCorrelationProcessor.cs | 4 ---- 4 files changed, 4 insertions(+), 7 deletions(-) rename {TUnit.Core => TUnit.OpenTelemetry}/TUnitTestCorrelationProcessor.cs (97%) diff --git a/TUnit.AspNetCore.Core/TUnit.AspNetCore.Core.csproj b/TUnit.AspNetCore.Core/TUnit.AspNetCore.Core.csproj index d80f80c7cb..4daa3782ed 100644 --- a/TUnit.AspNetCore.Core/TUnit.AspNetCore.Core.csproj +++ b/TUnit.AspNetCore.Core/TUnit.AspNetCore.Core.csproj @@ -16,6 +16,7 @@ + diff --git a/TUnit.AspNetCore.Core/TestWebApplicationFactory.cs b/TUnit.AspNetCore.Core/TestWebApplicationFactory.cs index 64336088d5..12af759107 100644 --- a/TUnit.AspNetCore.Core/TestWebApplicationFactory.cs +++ b/TUnit.AspNetCore.Core/TestWebApplicationFactory.cs @@ -106,6 +106,9 @@ protected override void ConfigureWebHost(IWebHostBuilder builder) /// 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. /// private static void AddTUnitOpenTelemetry(IServiceCollection services) { diff --git a/TUnit.Core/TUnit.Core.csproj b/TUnit.Core/TUnit.Core.csproj index 6028ee14a4..88433adfb8 100644 --- a/TUnit.Core/TUnit.Core.csproj +++ b/TUnit.Core/TUnit.Core.csproj @@ -66,9 +66,6 @@ - - - diff --git a/TUnit.Core/TUnitTestCorrelationProcessor.cs b/TUnit.OpenTelemetry/TUnitTestCorrelationProcessor.cs similarity index 97% rename from TUnit.Core/TUnitTestCorrelationProcessor.cs rename to TUnit.OpenTelemetry/TUnitTestCorrelationProcessor.cs index b8b41458b9..de1713bd51 100644 --- a/TUnit.Core/TUnitTestCorrelationProcessor.cs +++ b/TUnit.OpenTelemetry/TUnitTestCorrelationProcessor.cs @@ -1,5 +1,3 @@ -#if NET - using System.Diagnostics; using OpenTelemetry; using TUnit.Core; @@ -27,5 +25,3 @@ public override void OnStart(Activity activity) } } } - -#endif From aceb9e7284b58ee5fe556a81cc12d0b0b1374fb1 Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Fri, 17 Apr 2026 21:05:55 +0100 Subject: [PATCH 3/3] test: update Core public API snapshot for AspNetCoreHttpSourceName Adds the newly introduced public const on TUnitActivitySource to the verified PublicAPI snapshots for net8.0, net9.0, and net10.0. --- ...Tests.Core_Library_Has_No_API_Changes.DotNet10_0.verified.txt | 1 + .../Tests.Core_Library_Has_No_API_Changes.DotNet8_0.verified.txt | 1 + .../Tests.Core_Library_Has_No_API_Changes.DotNet9_0.verified.txt | 1 + 3 files changed, 3 insertions(+) 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 f06c61bcb3..0465a759f5 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 @@ -1330,6 +1330,7 @@ namespace } public static class TUnitActivitySource { + public const string AspNetCoreHttpSourceName = ".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 4fa9b816b6..2bc5a56bc9 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 @@ -1330,6 +1330,7 @@ namespace } public static class TUnitActivitySource { + public const string AspNetCoreHttpSourceName = ".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 f9f244f3d2..979d9a4793 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 @@ -1330,6 +1330,7 @@ namespace } public static class TUnitActivitySource { + public const string AspNetCoreHttpSourceName = ".Http"; public const string TagTestId = ".id"; } public class TUnitAttribute : { }