diff --git a/TUnit.AspNetCore.Core/Http/ActivityPropagationHandler.cs b/TUnit.AspNetCore.Core/Http/ActivityPropagationHandler.cs index 56fc0029a4..6901488dd4 100644 --- a/TUnit.AspNetCore.Core/Http/ActivityPropagationHandler.cs +++ b/TUnit.AspNetCore.Core/Http/ActivityPropagationHandler.cs @@ -117,14 +117,14 @@ private static void InjectBaggage(Activity? activity, HttpRequestHeaders headers // If a propagator already emitted W3C baggage (e.g. OTel SDK's BaggagePropagator), // preserve it; otherwise emit our own so LegacyPropagator-based stacks still // propagate test correlation baggage. - if (activity is null || headers.Contains("baggage")) + if (activity is null || headers.Contains(TUnit.Core.TUnitActivitySource.BaggageHeader)) { return; } if (TUnit.Core.TUnitActivitySource.TryBuildBaggageHeader(activity) is { } baggage) { - headers.TryAddWithoutValidation("baggage", baggage); + headers.TryAddWithoutValidation(TUnit.Core.TUnitActivitySource.BaggageHeader, baggage); } } } diff --git a/TUnit.AspNetCore.Core/PropagatorAlignmentStartupFilter.cs b/TUnit.AspNetCore.Core/PropagatorAlignmentStartupFilter.cs new file mode 100644 index 0000000000..c442cf0650 --- /dev/null +++ b/TUnit.AspNetCore.Core/PropagatorAlignmentStartupFilter.cs @@ -0,0 +1,23 @@ +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using TUnit.Core; + +namespace TUnit.AspNetCore; + +/// +/// Runs after SUT startup code has +/// executed. Needed because user Program.cs/Startup.cs can call +/// Sdk.SetDefaultTextMapPropagator(...) (or otherwise reset +/// ) during host +/// build; is invoked when the pipeline is constructed, +/// which is after all service registration and startup assignments, so alignment wins. +/// +internal sealed class PropagatorAlignmentStartupFilter : IStartupFilter +{ + public Action Configure(Action next) + => app => + { + PropagatorAlignment.AlignIfDefault(); + next(app); + }; +} diff --git a/TUnit.AspNetCore.Core/TestWebApplicationFactory.cs b/TUnit.AspNetCore.Core/TestWebApplicationFactory.cs index 86850f6e77..4b4567ca49 100644 --- a/TUnit.AspNetCore.Core/TestWebApplicationFactory.cs +++ b/TUnit.AspNetCore.Core/TestWebApplicationFactory.cs @@ -69,6 +69,9 @@ protected virtual void ConfigureStartupConfiguration(IConfigurationBuilder confi /// Registers here /// (rather than in ) so that minimal API hosts — where /// returns null — also get correlated logging. + /// Also registers so the SUT's + /// ends up W3C-aligned + /// even when user startup code assigns a custom propagator of its own. /// Subclasses overriding this method must call base.ConfigureWebHost(builder). /// protected override void ConfigureWebHost(IWebHostBuilder builder) @@ -77,6 +80,7 @@ protected override void ConfigureWebHost(IWebHostBuilder builder) builder.ConfigureServices(services => { + services.AddSingleton(); services.AddCorrelatedTUnitLogging(); }); } diff --git a/TUnit.Aspire/Http/TUnitBaggagePropagationHandler.cs b/TUnit.Aspire/Http/TUnitBaggagePropagationHandler.cs index 7db4c59af2..1f6bc05290 100644 --- a/TUnit.Aspire/Http/TUnitBaggagePropagationHandler.cs +++ b/TUnit.Aspire/Http/TUnitBaggagePropagationHandler.cs @@ -38,12 +38,13 @@ protected override Task SendAsync( } }); - if (!request.Headers.Contains("baggage") + if (!request.Headers.Contains(TUnitActivitySource.BaggageHeader) && TUnitActivitySource.TryBuildBaggageHeader(activity) is { } baggage) { - // Older target frameworks still default to Correlation-Context for baggage. - // Emit W3C baggage explicitly so backend correlation is stable everywhere. - request.Headers.TryAddWithoutValidation("baggage", baggage); + // Belt-and-braces for users who opt out of TUnit's W3C propagator alignment + // via TUNIT_KEEP_LEGACY_PROPAGATOR=1: LegacyPropagator emits Correlation-Context + // only, so still emit W3C baggage explicitly for backend correlation. + request.Headers.TryAddWithoutValidation(TUnitActivitySource.BaggageHeader, baggage); } } diff --git a/TUnit.Core/PropagatorAlignment.cs b/TUnit.Core/PropagatorAlignment.cs new file mode 100644 index 0000000000..e19e75ca75 --- /dev/null +++ b/TUnit.Core/PropagatorAlignment.cs @@ -0,0 +1,176 @@ +#if NET + +using System.Diagnostics; +using System.Runtime.CompilerServices; + +namespace TUnit.Core; + +/// +/// Auto-aligns to a W3C-compatible +/// propagator (traceparent + W3C baggage) when the current propagator is .NET's default +/// LegacyPropagator. Without this, cross-process test correlation baggage +/// (tunit.test.id) is emitted as Correlation-Context, which the OpenTelemetry +/// SDK's BaggagePropagator does not read — the baggage silently drops between +/// the test process and the SUT. +/// +/// +/// On .NET 10+, delegates to the runtime's DistributedContextPropagator.CreateW3CPropagator(). +/// On .NET 8/9, uses a minimal built-in W3C baggage propagator. +/// Set TUNIT_KEEP_LEGACY_PROPAGATOR=1 to opt out. +/// +internal static class PropagatorAlignment +{ + private const string LegacyPropagatorTypeName = "System.Diagnostics.LegacyPropagator"; + + // Read once: env vars don't change within a process and GetEnvironmentVariable allocates. + private static readonly bool OptedOut = + Environment.GetEnvironmentVariable("TUNIT_KEEP_LEGACY_PROPAGATOR") == "1"; + +#pragma warning disable CA2255 // Module initializer is the intended entry point per issue #5592. + [ModuleInitializer] +#pragma warning restore CA2255 + internal static void AlignOnModuleLoad() => AlignIfDefault(); + + /// + /// Idempotent: re-align only if the current propagator is still the runtime default. + /// + internal static void AlignIfDefault() + { + if (OptedOut) + { + return; + } + + if (DistributedContextPropagator.Current.GetType().FullName == LegacyPropagatorTypeName) + { + DistributedContextPropagator.Current = CreateAlignedPropagator(); + } + } + + /// + /// Returns the propagator TUnit aligns to. On .NET 10+ this is the runtime's + /// built-in W3C propagator; on .NET 8/9 a minimal in-library equivalent. + /// + internal static DistributedContextPropagator CreateAlignedPropagator() + { +#if NET10_0_OR_GREATER + return DistributedContextPropagator.CreateW3CPropagator(); +#else + return new W3CBaggagePropagator(); +#endif + } + +#if !NET10_0_OR_GREATER + /// + /// Minimal W3C propagator for .NET 8/9: delegates traceparent/tracestate + /// to the default runtime propagator, and emits/parses baggage using the W3C + /// baggage header rather than the legacy Correlation-Context. + /// + private sealed class W3CBaggagePropagator : DistributedContextPropagator + { + private const string LegacyBaggageHeader = "Correlation-Context"; + + private static readonly DistributedContextPropagator DefaultPropagator = CreateDefaultPropagator(); + private static readonly IReadOnlyCollection FieldNames = new[] { "traceparent", "tracestate", TUnitActivitySource.BaggageHeader }; + + public override IReadOnlyCollection Fields => FieldNames; + + public override void Inject(Activity? activity, object? carrier, PropagatorSetterCallback? setter) + { + if (activity is null || setter is null) + { + return; + } + + // Filter the legacy baggage header from the default propagator's output + // so we don't emit both Correlation-Context and W3C baggage for the same values. + DefaultPropagator.Inject(activity, carrier, (c, key, value) => + { + if (!string.Equals(key, LegacyBaggageHeader, StringComparison.OrdinalIgnoreCase)) + { + setter(c, key, value); + } + }); + + if (TUnitActivitySource.TryBuildBaggageHeader(activity) is { } baggage) + { + setter(carrier, TUnitActivitySource.BaggageHeader, baggage); + } + } + + public override void ExtractTraceIdAndState(object? carrier, PropagatorGetterCallback? getter, out string? traceId, out string? traceState) + => DefaultPropagator.ExtractTraceIdAndState(carrier, getter, out traceId, out traceState); + + public override IEnumerable>? ExtractBaggage(object? carrier, PropagatorGetterCallback? getter) + { + if (getter is null) + { + return null; + } + + getter(carrier, TUnitActivitySource.BaggageHeader, out var header, out var headers); + if (string.IsNullOrEmpty(header) && headers is not null) + { + foreach (var h in headers) + { + if (!string.IsNullOrEmpty(h)) + { + header = h; + break; + } + } + } + + return string.IsNullOrEmpty(header) ? null : ParseBaggage(header!); + } + + private static List>? ParseBaggage(string header) + { + List>? result = null; + var remaining = header.AsSpan(); + + while (!remaining.IsEmpty) + { + ReadOnlySpan entry; + var comma = remaining.IndexOf(','); + if (comma < 0) + { + entry = remaining; + remaining = default; + } + else + { + entry = remaining[..comma]; + remaining = remaining[(comma + 1)..]; + } + + entry = entry.Trim(); + var semi = entry.IndexOf(';'); + if (semi >= 0) + { + entry = entry[..semi]; + } + + var eq = entry.IndexOf('='); + if (eq <= 0) + { + continue; + } + + var key = Uri.UnescapeDataString(entry[..eq].Trim().ToString()); + if (key.Length == 0) + { + continue; + } + + var value = Uri.UnescapeDataString(entry[(eq + 1)..].Trim().ToString()); + (result ??= new List>()).Add(new KeyValuePair(key, value)); + } + + return result; + } + } +#endif +} + +#endif diff --git a/TUnit.Core/TUnitActivitySource.cs b/TUnit.Core/TUnitActivitySource.cs index 64d7652802..9440d0009a 100644 --- a/TUnit.Core/TUnitActivitySource.cs +++ b/TUnit.Core/TUnitActivitySource.cs @@ -12,6 +12,9 @@ public static class TUnitActivitySource internal const string SourceName = "TUnit"; internal const string LifecycleSourceName = "TUnit.Lifecycle"; + /// W3C baggage HTTP header name. + internal const string BaggageHeader = "baggage"; + internal static readonly ActivitySource Source = new(SourceName, Version); internal static readonly ActivitySource LifecycleSource = new(LifecycleSourceName, Version); diff --git a/TUnit.UnitTests/PropagatorAlignmentTests.cs b/TUnit.UnitTests/PropagatorAlignmentTests.cs new file mode 100644 index 0000000000..93dee28822 --- /dev/null +++ b/TUnit.UnitTests/PropagatorAlignmentTests.cs @@ -0,0 +1,56 @@ +#if NET +using System.Diagnostics; +using TUnit.Core; + +namespace TUnit.UnitTests; + +// Tests mutate DistributedContextPropagator.Current (process-global) — must not run concurrently. +[NotInParallel(nameof(PropagatorAlignmentTests))] +public class PropagatorAlignmentTests +{ + [Test] + public async Task ModuleInitializer_Replaces_Default_Legacy_Propagator() + { + // Module init runs on first touch of any TUnit.Core type, so by now the default + // LegacyPropagator must already be gone; otherwise cross-process baggage breaks. + var current = DistributedContextPropagator.Current.GetType().FullName; + await Assert.That(current).IsNotEqualTo("System.Diagnostics.LegacyPropagator"); + } + + [Test] + public async Task AlignIfDefault_Leaves_Custom_Propagator_Untouched() + { + var original = DistributedContextPropagator.Current; + var custom = DistributedContextPropagator.CreatePassThroughPropagator(); + + try + { + DistributedContextPropagator.Current = custom; + PropagatorAlignment.AlignIfDefault(); + await Assert.That(DistributedContextPropagator.Current).IsSameReferenceAs(custom); + } + finally + { + DistributedContextPropagator.Current = original; + } + } + + [Test] + public async Task AlignIfDefault_Does_Not_Replace_Existing_W3C_Propagator() + { + var original = DistributedContextPropagator.Current; + var w3c = PropagatorAlignment.CreateAlignedPropagator(); + + try + { + DistributedContextPropagator.Current = w3c; + PropagatorAlignment.AlignIfDefault(); + await Assert.That(DistributedContextPropagator.Current).IsSameReferenceAs(w3c); + } + finally + { + DistributedContextPropagator.Current = original; + } + } +} +#endif diff --git a/docs/docs/examples/opentelemetry.md b/docs/docs/examples/opentelemetry.md index 480905c285..ee2bee431f 100644 --- a/docs/docs/examples/opentelemetry.md +++ b/docs/docs/examples/opentelemetry.md @@ -299,7 +299,9 @@ Two common causes. **1. The parent span isn't exported to the same backend.** The test-side `test case` span lives in the test process. If you only export from the SUT, the backend sees a child whose parent it has never seen. Either export the `"TUnit"` source from the test process too, or rely on the `tunit.test.id` tag (above) instead of trace hierarchy. -**2. The two processes use different baggage formats.** .NET defaults to `Correlation-Context`. The OpenTelemetry SDK reads W3C `baggage`. The two don't speak to each other. Use the same propagator on both sides: +**2. The two processes use different baggage formats.** .NET defaults to `Correlation-Context`. The OpenTelemetry SDK reads W3C `baggage`. TUnit auto-aligns `DistributedContextPropagator.Current` to W3C on module load, and `TestWebApplicationFactory` re-applies this for in-process SUTs via an `IStartupFilter` — no manual wiring needed. Set `TUNIT_KEEP_LEGACY_PROPAGATOR=1` to opt out. + +For an **out-of-process** SUT that doesn't reference `TUnit.Core`, you still need to align it yourself: ```csharp using OpenTelemetry; diff --git a/docs/docs/guides/distributed-tracing.md b/docs/docs/guides/distributed-tracing.md index 902b7a5f7d..a78365af42 100644 --- a/docs/docs/guides/distributed-tracing.md +++ b/docs/docs/guides/distributed-tracing.md @@ -99,7 +99,11 @@ For cross-process correlation (your test calling your SUT), use `tunit.test.id`. ## When tracing across processes -If your test process and your SUT are different processes (or you're using `WebApplicationFactory` heavily), make sure both sides agree on the propagator: +Cross-process baggage propagation (e.g. `tunit.test.id` reaching your SUT) depends on both sides using the W3C `baggage` header rather than .NET's default `Correlation-Context`. + +TUnit handles this automatically: a module initializer in `TUnit.Core` replaces the default `DistributedContextPropagator.LegacyPropagator` with `DistributedContextPropagator.CreateW3CPropagator()`. Any custom propagator you set yourself is left alone. If you want to retain the legacy behavior, set `TUNIT_KEEP_LEGACY_PROPAGATOR=1`. + +For the SUT side, if it shares the test process (e.g. `TestWebApplicationFactory`), alignment flows automatically. For out-of-process SUTs that don't reference `TUnit.Core`, align the propagator yourself on startup — either match `DistributedContextPropagator.Current` or, if you use the OpenTelemetry SDK: ```csharp using OpenTelemetry; @@ -112,8 +116,6 @@ Sdk.SetDefaultTextMapPropagator(new CompositeTextMapPropagator( ])); ``` -Without this, .NET's default propagator emits `Correlation-Context`, but the OpenTelemetry SDK only reads W3C `baggage`. The mismatch silently drops baggage and you lose `tunit.test.id` on the SUT side. - ## Limitations ### Static `ActivitySource` in third-party libraries