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