Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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
4 changes: 4 additions & 0 deletions TUnit.AspNetCore.Core/TestWebApplicationFactory.cs
Original file line number Diff line number Diff line change
Expand Up @@ -69,12 +69,16 @@ protected virtual void ConfigureStartupConfiguration(IConfigurationBuilder confi
/// Registers <see cref="CorrelatedTUnitLoggingExtensions.AddCorrelatedTUnitLogging"/> here
/// (rather than in <see cref="CreateHostBuilder"/>) so that minimal API hosts — where
/// <see cref="CreateHostBuilder"/> returns <c>null</c> — also get correlated logging.
/// Also re-aligns <see cref="System.Diagnostics.DistributedContextPropagator.Current"/>
/// to the W3C propagator for the SUT, in case user startup code reset it to the default.
/// Subclasses overriding this method must call <c>base.ConfigureWebHost(builder)</c>.
/// </summary>
protected override void ConfigureWebHost(IWebHostBuilder builder)
{
base.ConfigureWebHost(builder);

PropagatorAlignment.AlignIfDefault();

builder.ConfigureServices(services =>
{
services.AddCorrelatedTUnitLogging();
Expand Down
185 changes: 185 additions & 0 deletions TUnit.Core/PropagatorAlignment.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,185 @@
#if NET

using System.Diagnostics;
using System.Runtime.CompilerServices;
using System.Text;

namespace TUnit.Core;

/// <summary>
/// Auto-aligns <see cref="DistributedContextPropagator.Current"/> to a W3C-compatible
/// propagator (traceparent + W3C baggage) when the current propagator is .NET's default
/// <c>LegacyPropagator</c>. Without this, cross-process test correlation baggage
/// (<c>tunit.test.id</c>) is emitted as <c>Correlation-Context</c>, which the OpenTelemetry
/// SDK's <c>BaggagePropagator</c> does not read — the baggage silently drops between
/// the test process and the SUT.
/// </summary>
/// <remarks>
/// On .NET 10+, delegates to the runtime's <c>DistributedContextPropagator.CreateW3CPropagator()</c>.
/// On .NET 8/9, uses a minimal built-in W3C baggage propagator.
/// Set <c>TUNIT_KEEP_LEGACY_PROPAGATOR=1</c> to opt out.
/// </remarks>
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();

/// <summary>
/// Idempotent: re-align only if the current propagator is still the runtime default.
/// </summary>
internal static void AlignIfDefault()
{
if (OptedOut)
{
return;
}

if (DistributedContextPropagator.Current.GetType().FullName == LegacyPropagatorTypeName)
{
DistributedContextPropagator.Current = CreateW3CPropagator();
}
}

internal static DistributedContextPropagator CreateW3CPropagator()
{
#if NET10_0_OR_GREATER
return DistributedContextPropagator.CreateW3CPropagator();
#else
return new W3CBaggagePropagator();
#endif
}

#if !NET10_0_OR_GREATER
/// <summary>
/// Minimal W3C propagator for .NET 8/9: delegates <c>traceparent</c>/<c>tracestate</c>
/// to the default runtime propagator, and emits/parses baggage using the W3C
/// <c>baggage</c> header rather than the legacy <c>Correlation-Context</c>.
/// </summary>
private sealed class W3CBaggagePropagator : DistributedContextPropagator
{
private const string BaggageHeader = "baggage";
private const string LegacyBaggageHeader = "Correlation-Context";

private static readonly DistributedContextPropagator DefaultPropagator = CreateDefaultPropagator();
private static readonly IReadOnlyCollection<string> FieldNames = new[] { "traceparent", "tracestate", BaggageHeader };

public override IReadOnlyCollection<string> Fields => FieldNames;

public override void Inject(Activity? activity, object? carrier, PropagatorSetterCallback? setter)
{
if (activity is null || setter is null)
{
return;
}

// Delegate traceparent/tracestate to the default (W3C-compatible) propagator,
// filtering out its legacy baggage header — we emit W3C baggage ourselves below.
DefaultPropagator.Inject(activity, carrier, (c, key, value) =>
{
if (!string.Equals(key, LegacyBaggageHeader, StringComparison.OrdinalIgnoreCase))
{
setter(c, key, value);
}
});

if (BuildBaggageHeader(activity) is { } baggage)
{
setter(carrier, 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<KeyValuePair<string, string?>>? ExtractBaggage(object? carrier, PropagatorGetterCallback? getter)
{
if (getter is null)
{
return null;
}

getter(carrier, 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 string? BuildBaggageHeader(Activity activity)
{
StringBuilder? sb = null;
foreach (var (key, value) in activity.Baggage)
{
if (string.IsNullOrEmpty(key))
{
continue;
}

if (sb is null)
{
sb = new StringBuilder();
}
else
{
sb.Append(',');
}

sb.Append(Uri.EscapeDataString(key));
sb.Append('=');
sb.Append(Uri.EscapeDataString(value ?? string.Empty));
}

return sb?.ToString();
}

private static List<KeyValuePair<string, string?>> ParseBaggage(string header)
{
var result = new List<KeyValuePair<string, string?>>();
foreach (var entry in header.Split(','))
{
var span = entry.AsSpan().Trim();
// Strip W3C baggage metadata (anything after ';')
var semi = span.IndexOf(';');
if (semi >= 0)
{
span = span[..semi];
}

var eq = span.IndexOf('=');
if (eq <= 0)
{
continue;
}

var key = Uri.UnescapeDataString(span[..eq].Trim().ToString());
var value = Uri.UnescapeDataString(span[(eq + 1)..].Trim().ToString());
if (key.Length > 0)
{
result.Add(new KeyValuePair<string, string?>(key, value));
}
}

return result;
}
}
#endif
}

#endif
56 changes: 56 additions & 0 deletions TUnit.UnitTests/PropagatorAlignmentTests.cs
Original file line number Diff line number Diff line change
@@ -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.CreateW3CPropagator();

try
{
DistributedContextPropagator.Current = w3c;
PropagatorAlignment.AlignIfDefault();
await Assert.That(DistributedContextPropagator.Current).IsSameReferenceAs(w3c);
}
finally
{
DistributedContextPropagator.Current = original;
}
}
}
#endif
4 changes: 3 additions & 1 deletion docs/docs/examples/opentelemetry.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`. Since TUnit 0.x (issue #5592), the test process auto-aligns `DistributedContextPropagator.Current` to W3C on module load, and `TestWebApplicationFactory<T>` re-applies this for in-process SUTs — 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;
Expand Down
8 changes: 5 additions & 3 deletions docs/docs/guides/distributed-tracing.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<T>`), 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;
Expand All @@ -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
Expand Down
Loading