Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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: 2 additions & 2 deletions TUnit.AspNetCore.Core/Http/ActivityPropagationHandler.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
}
}
23 changes: 23 additions & 0 deletions TUnit.AspNetCore.Core/PropagatorAlignmentStartupFilter.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using TUnit.Core;

namespace TUnit.AspNetCore;

/// <summary>
/// Runs <see cref="PropagatorAlignment.AlignIfDefault"/> after SUT startup code has
/// executed. Needed because user <c>Program.cs</c>/<c>Startup.cs</c> can call
/// <c>Sdk.SetDefaultTextMapPropagator(...)</c> (or otherwise reset
/// <see cref="System.Diagnostics.DistributedContextPropagator.Current"/>) during host
/// build; <see cref="IStartupFilter"/> is invoked when the pipeline is constructed,
/// which is after all service registration and startup assignments, so alignment wins.
/// </summary>
internal sealed class PropagatorAlignmentStartupFilter : IStartupFilter
{
public Action<IApplicationBuilder> Configure(Action<IApplicationBuilder> next)
=> app =>
{
PropagatorAlignment.AlignIfDefault();
next(app);
};
}
4 changes: 4 additions & 0 deletions TUnit.AspNetCore.Core/TestWebApplicationFactory.cs
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,9 @@ 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 registers <see cref="PropagatorAlignmentStartupFilter"/> so the SUT's
/// <see cref="System.Diagnostics.DistributedContextPropagator.Current"/> ends up W3C-aligned
/// even when user startup code assigns a custom propagator of its own.
/// Subclasses overriding this method must call <c>base.ConfigureWebHost(builder)</c>.
/// </summary>
protected override void ConfigureWebHost(IWebHostBuilder builder)
Expand All @@ -77,6 +80,7 @@ protected override void ConfigureWebHost(IWebHostBuilder builder)

builder.ConfigureServices(services =>
{
services.AddSingleton<IStartupFilter, PropagatorAlignmentStartupFilter>();
services.AddCorrelatedTUnitLogging();
});
}
Expand Down
9 changes: 5 additions & 4 deletions TUnit.Aspire/Http/TUnitBaggagePropagationHandler.cs
Original file line number Diff line number Diff line change
Expand Up @@ -38,12 +38,13 @@ protected override Task<HttpResponseMessage> 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);
}
}

Expand Down
176 changes: 176 additions & 0 deletions TUnit.Core/PropagatorAlignment.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,176 @@
#if NET

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

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 = CreateAlignedPropagator();
}
}

/// <summary>
/// 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.
/// </summary>
internal static DistributedContextPropagator CreateAlignedPropagator()
{
#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 LegacyBaggageHeader = "Correlation-Context";

private static readonly DistributedContextPropagator DefaultPropagator = CreateDefaultPropagator();
private static readonly IReadOnlyCollection<string> FieldNames = new[] { "traceparent", "tracestate", TUnitActivitySource.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;
}

// 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<KeyValuePair<string, string?>>? 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<KeyValuePair<string, string?>>? ParseBaggage(string header)
{
List<KeyValuePair<string, string?>>? result = null;
var remaining = header.AsSpan();

while (!remaining.IsEmpty)
{
ReadOnlySpan<char> 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<KeyValuePair<string, string?>>()).Add(new KeyValuePair<string, string?>(key, value));
}

return result;
}
}
#endif
}

#endif
3 changes: 3 additions & 0 deletions TUnit.Core/TUnitActivitySource.cs
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,9 @@ public static class TUnitActivitySource
internal const string SourceName = "TUnit";
internal const string LifecycleSourceName = "TUnit.Lifecycle";

/// <summary>W3C baggage HTTP header name.</summary>
internal const string BaggageHeader = "baggage";

internal static readonly ActivitySource Source = new(SourceName, Version);
internal static readonly ActivitySource LifecycleSource = new(LifecycleSourceName, Version);

Expand Down
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.CreateAlignedPropagator();

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`. TUnit auto-aligns `DistributedContextPropagator.Current` to W3C on module load, and `TestWebApplicationFactory<T>` 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;
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