diff --git a/TUnit.AspNetCore.Core/TUnit.AspNetCore.Core.csproj b/TUnit.AspNetCore.Core/TUnit.AspNetCore.Core.csproj index 9f9f7bc802..4daa3782ed 100644 --- a/TUnit.AspNetCore.Core/TUnit.AspNetCore.Core.csproj +++ b/TUnit.AspNetCore.Core/TUnit.AspNetCore.Core.csproj @@ -16,10 +16,17 @@ + + + + + + + diff --git a/TUnit.AspNetCore.Core/TestWebApplicationFactory.cs b/TUnit.AspNetCore.Core/TestWebApplicationFactory.cs index 3bc0e810c3..12af759107 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,24 @@ 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. + /// 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) + { + 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/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.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 : { } 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.