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
96 changes: 69 additions & 27 deletions TUnit.Aspire.Tests/BaggagePropagationHandlerTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,11 @@ namespace TUnit.Aspire.Tests;
public class BaggagePropagationHandlerTests
{
[Test]
public async Task SendAsync_InjectsTraceparentHeader()
public async Task SendAsync_InjectsTraceparentHeader_WhenActivityExists()
{
Activity.Current = null;
using var activity = new Activity("test-traceparent").Start();

var captured = new CaptureHandler();
var handler = new TUnitBaggagePropagationHandler { InnerHandler = captured };
using var client = new HttpClient(handler);
Expand All @@ -21,9 +24,10 @@ public async Task SendAsync_InjectsTraceparentHeader()
}

[Test]
public async Task SendAsync_TraceparentUsesUniqueTraceId_NotActivityCurrent()
public async Task SendAsync_TraceparentUsesActivityCurrentTraceId()
{
using var activity = new Activity("test-unique-traceid").Start();
Activity.Current = null;
using var activity = new Activity("test-uses-current").Start();
var activityTraceId = activity.TraceId.ToString();

var captured = new CaptureHandler();
Expand All @@ -33,16 +37,18 @@ public async Task SendAsync_TraceparentUsesUniqueTraceId_NotActivityCurrent()
await client.GetAsync("http://localhost/test");

var traceparent = captured.LastRequest!.Headers.GetValues("traceparent").First();
var parts = traceparent.Split('-');
var requestTraceId = parts[1];
var requestTraceId = traceparent.Split('-')[1];

// The handler generates its own TraceId, distinct from Activity.Current's
await Assert.That(requestTraceId).IsNotEqualTo(activityTraceId);
// Handler propagates Activity.Current's TraceId — natural OTEL propagation
await Assert.That(requestTraceId).IsEqualTo(activityTraceId);
}

[Test]
public async Task SendAsync_EachRequestGetsUniqueTraceId()
public async Task SendAsync_SameActivity_SharesTraceId()
{
Activity.Current = null;
using var activity = new Activity("test-same-traceid").Start();

var captured = new CaptureHandler();
var handler = new TUnitBaggagePropagationHandler { InnerHandler = captured };
using var client = new HttpClient(handler);
Expand All @@ -56,47 +62,85 @@ public async Task SendAsync_EachRequestGetsUniqueTraceId()
var traceId1 = traceparent1.Split('-')[1];
var traceId2 = traceparent2.Split('-')[1];

await Assert.That(traceId1).IsNotEqualTo(traceId2);
// Same Activity.Current → same TraceId (all requests belong to same trace)
await Assert.That(traceId1).IsEqualTo(traceId2);
}

[Test]
public async Task SendAsync_TraceparentFormat_IsValidW3C()
public async Task SendAsync_SameActivity_UsesDifferentSpanIds()
{
Activity.Current = null;
using var activity = new Activity("test-unique-spanids").Start();

var captured = new CaptureHandler();
var handler = new TUnitBaggagePropagationHandler { InnerHandler = captured };
using var client = new HttpClient(handler);

await client.GetAsync("http://localhost/test");
await client.GetAsync("http://localhost/test1");
var traceparent1 = captured.LastRequest!.Headers.GetValues("traceparent").First();

var traceparent = captured.LastRequest!.Headers.GetValues("traceparent").First();
var parts = traceparent.Split('-');
await client.GetAsync("http://localhost/test2");
var traceparent2 = captured.LastRequest!.Headers.GetValues("traceparent").First();

await Assert.That(parts.Length).IsEqualTo(4);
await Assert.That(parts[0]).IsEqualTo("00"); // version
await Assert.That(parts[1].Length).IsEqualTo(32); // trace-id (16 bytes hex)
await Assert.That(parts[2].Length).IsEqualTo(16); // parent-id (8 bytes hex)
await Assert.That(parts[3]).IsEqualTo("01"); // flags (sampled)
var spanId1 = traceparent1.Split('-')[2];
var spanId2 = traceparent2.Split('-')[2];

// Each request gets a unique SpanId within the same trace
await Assert.That(spanId1).IsNotEqualTo(spanId2);
}

[Test]
public async Task SendAsync_RegistersTraceIdInTraceRegistry()
public async Task SendAsync_DifferentActivities_UseDifferentTraceIds()
{
Activity.Current = null;

var captured = new CaptureHandler();
var handler = new TUnitBaggagePropagationHandler { InnerHandler = captured };
using var client = new HttpClient(handler);

using var activity1 = new Activity("test-trace-1").Start();
await client.GetAsync("http://localhost/test1");
var traceparent1 = captured.LastRequest!.Headers.GetValues("traceparent").First();
activity1.Stop();

using var activity2 = new Activity("test-trace-2").Start();
await client.GetAsync("http://localhost/test2");
var traceparent2 = captured.LastRequest!.Headers.GetValues("traceparent").First();
activity2.Stop();

var traceId1 = traceparent1.Split('-')[1];
var traceId2 = traceparent2.Split('-')[1];

// Different activities → different TraceIds (separate tests = separate traces)
await Assert.That(traceId1).IsNotEqualTo(traceId2);
}

[Test]
public async Task SendAsync_TraceparentFormat_IsValidW3C()
{
Activity.Current = null;
using var activity = new Activity("test-w3c-format").Start();

var captured = new CaptureHandler();
var handler = new TUnitBaggagePropagationHandler { InnerHandler = captured };
using var client = new HttpClient(handler);

await client.GetAsync("http://localhost/test");

var traceparent = captured.LastRequest!.Headers.GetValues("traceparent").First();
var traceId = traceparent.Split('-')[1];
var parts = traceparent.Split('-');

await Assert.That(TraceRegistry.IsRegistered(traceId)).IsTrue();
await Assert.That(parts.Length).IsEqualTo(4);
await Assert.That(parts[0]).IsEqualTo("00"); // version
await Assert.That(parts[1].Length).IsEqualTo(32); // trace-id (16 bytes hex)
await Assert.That(parts[2].Length).IsEqualTo(16); // parent-id (8 bytes hex)
// W3C trace-flags: "00" (not sampled) or "01" (sampled)
await Assert.That(parts[3]).IsEqualTo("00").Or.IsEqualTo("01");
}

[Test]
public async Task SendAsync_InjectsBaggageHeader_WithActivityBaggage()
{
// Detach from engine activity to control baggage precisely
Activity.Current = null;
using var activity = new Activity("test-inject-baggage").Start();
activity.SetBaggage(TUnitActivitySource.TagTestId, "my-test-context-id");
Expand All @@ -120,7 +164,6 @@ public async Task SendAsync_InjectsBaggageHeader_WithActivityBaggage()
[Test]
public async Task SendAsync_NoBaggage_DoesNotAddBaggageHeader()
{
// Detach from engine activity to prevent inheriting tunit.test.id baggage
Activity.Current = null;
using var activity = new Activity("test-no-baggage").Start();

Expand All @@ -134,7 +177,7 @@ public async Task SendAsync_NoBaggage_DoesNotAddBaggageHeader()
}

[Test]
public async Task SendAsync_NoActivity_StillInjectsTraceparent()
public async Task SendAsync_NoActivity_DoesNotInjectTraceparent()
{
Activity.Current = null;

Expand All @@ -145,9 +188,8 @@ public async Task SendAsync_NoActivity_StillInjectsTraceparent()
await client.GetAsync("http://localhost/test");

await Assert.That(captured.LastRequest).IsNotNull();
// traceparent is always injected (handler generates its own TraceId)
await Assert.That(captured.LastRequest!.Headers.Contains("traceparent")).IsTrue();
// No Activity means no baggage to propagate
// No Activity.Current → no trace context to propagate
await Assert.That(captured.LastRequest!.Headers.Contains("traceparent")).IsFalse();
await Assert.That(captured.LastRequest.Headers.Contains("baggage")).IsFalse();
}

Expand Down
31 changes: 24 additions & 7 deletions TUnit.Aspire.Tests/TestActivityTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,9 @@ namespace TUnit.Aspire.Tests;

/// <summary>
/// Tests that verify the TUnit engine creates test activities correctly:
/// parent-child hierarchy (test → class → assembly → session), proper tags,
/// baggage for cross-boundary propagation, and TraceId shared within a class.
/// parent-child hierarchy (test body → test case), proper tags,
/// baggage for cross-boundary propagation, Activity Links to the class activity,
/// and unique TraceIds per test case.
/// </summary>
public class TestActivityTests
{
Expand Down Expand Up @@ -72,14 +73,18 @@ public async Task TestCaseActivity_HasExpectedTags()
}

[Test]
public async Task TestCaseActivity_HasParentSpanId_FromClassActivity()
public async Task TestCaseActivity_HasActivityLink_ToClassActivity()
{
var testCase = Activity.Current!.Parent!;

// The test case has a ParentSpanId linking it to the class activity.
// Activity.Parent (object reference) may be null when explicit parentContext
// is used, but ParentSpanId is always set for child activities.
await Assert.That(testCase.ParentSpanId).IsNotEqualTo(default(ActivitySpanId));
// Each test case starts its own W3C trace (so ParentSpanId is default).
// Instead, an Activity Link references the class activity for correlation.
// This prevents all tests in a class from sharing one giant trace in backends
// like Seq/Jaeger while still preserving the logical hierarchy.
var link = await Assert.That(testCase.Links.ToList()).HasSingleItem();

await Assert.That(link.Context.TraceId).IsNotEqualTo(default(ActivityTraceId));
await Assert.That(link.Context.SpanId).IsNotEqualTo(default(ActivitySpanId));
}

[Test]
Expand All @@ -90,4 +95,16 @@ public async Task TraceId_IsNonEmpty()
await Assert.That(traceId.Length).IsEqualTo(32);
await Assert.That(traceId).IsNotEqualTo(new string('0', 32));
}

[Test]
public async Task TraceId_IsRegisteredInTraceRegistry()
{
var traceId = Activity.Current!.TraceId.ToString();

// The engine registers the test's TraceId in TraceRegistry at activity creation.
// This enables the OTLP receiver to correlate SUT logs back to this test
// without synthetic TraceId generation — pure natural OTEL propagation.
await Assert.That(TraceRegistry.IsRegistered(traceId)).IsTrue();
await Assert.That(TraceRegistry.GetContextId(traceId)).IsEqualTo(TestContext.Current!.Id);
}
}
84 changes: 39 additions & 45 deletions TUnit.Aspire/Http/TUnitBaggagePropagationHandler.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4,68 +4,62 @@
namespace TUnit.Aspire.Http;

/// <summary>
/// DelegatingHandler that injects W3C <c>traceparent</c> and <c>baggage</c> headers
/// into outgoing HTTP requests for cross-process OTLP correlation. Each request gets
/// a unique TraceId so the SUT's logs can be routed back to the specific test, even
/// though all tests in a class share the class activity's TraceId.
/// DelegatingHandler that propagates W3C <c>traceparent</c> and <c>baggage</c> headers
/// from <see cref="Activity.Current"/> into outgoing HTTP requests. This enables natural
/// OpenTelemetry distributed tracing: the SUT receives the test's TraceId and all its
/// spans and logs can be correlated back to the originating test.
/// </summary>
/// <remarks>
/// The engine keeps test activities as children of the class activity (parent-child,
/// shared TraceId) so trace backends display proper waterfalls:
/// Session → Assembly → Class → Test₁, Test₂, ...
/// This handler generates a fresh TraceId per outbound request and registers it in
/// <see cref="TraceRegistry"/> so the OTLP receiver can correlate SUT logs back to
/// the originating test.
/// Each test case starts its own W3C trace (unique TraceId) via a root activity in the
/// engine. HTTP requests made during a test inherit that TraceId through standard
/// <see cref="Activity.Current"/> propagation. The OTLP receiver maps the TraceId back
/// to the test via <see cref="TraceRegistry"/>, which is populated by the engine when
/// the test activity starts.
///
/// This handler is needed because Aspire's <c>CreateHttpClient</c> does not include
/// the standard <c>DiagnosticsHandler</c>, so W3C context propagation must be done
/// explicitly.
/// </remarks>
internal sealed class TUnitBaggagePropagationHandler : DelegatingHandler
{
protected override Task<HttpResponseMessage> SendAsync(
HttpRequestMessage request, CancellationToken cancellationToken)
{
// Generate a unique TraceId per request so the OTLP receiver can map each
// request's logs back to the originating test. The engine's test activities
// share the class-level TraceId (for proper trace-backend waterfall display),
// so we need a distinct TraceId here for per-test correlation.
var traceId = ActivityTraceId.CreateRandom();
var spanId = ActivitySpanId.CreateRandom();
request.Headers.TryAddWithoutValidation("traceparent", $"00-{traceId}-{spanId}-01");

// Register the unique TraceId so the OTLP receiver can correlate logs
if (TestContext.Current is { } testContext)
{
TraceRegistry.Register(
traceId.ToString(),
testContext.TestDetails.TestId,
testContext.Id);
}

// Propagate baggage (including tunit.test.id) from the current activity
if (Activity.Current is { } activity && !request.Headers.Contains("baggage"))
if (Activity.Current is { } activity)
{
var first = true;
var sb = new System.Text.StringBuilder();
// New SpanId per request so the SUT's spans parent correctly within this trace
var spanId = ActivitySpanId.CreateRandom();
var sampled = activity.Recorded ? "01" : "00";
request.Headers.TryAddWithoutValidation("traceparent",
$"00-{activity.TraceId}-{spanId}-{sampled}");

foreach (var (key, value) in activity.Baggage)
if (!request.Headers.Contains("baggage"))
{
if (key is null)
var first = true;
var sb = new System.Text.StringBuilder();

foreach (var (key, value) in activity.Baggage)
{
continue;
if (key is null)
{
continue;
}

if (!first)
{
sb.Append(',');
}

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

if (!first)
{
sb.Append(',');
request.Headers.TryAddWithoutValidation("baggage", sb.ToString());
}

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

if (!first)
{
request.Headers.TryAddWithoutValidation("baggage", sb.ToString());
}
}

Expand Down
7 changes: 7 additions & 0 deletions TUnit.Core/Context.cs
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,14 @@ public void RestoreExecutionContext()
#if NET
if (ExecutionContext is not null)
{
// ExecutionContext.Restore() restores ALL AsyncLocal values — including
// Activity.Current. The captured ExecutionContext may contain a stale
// (already-stopped) Activity from a previous hook/event receiver.
// Save the current Activity and restore it after the EC restore to
// prevent Activity chain corruption across parallel tests.
var currentActivity = System.Diagnostics.Activity.Current;
ExecutionContext.Restore(ExecutionContext);
System.Diagnostics.Activity.Current = currentActivity;
}

SetAsyncLocalContext();
Expand Down
5 changes: 3 additions & 2 deletions TUnit.Core/TUnitActivitySource.cs
Original file line number Diff line number Diff line change
Expand Up @@ -72,9 +72,10 @@ internal static string GetReadableTypeName(Type type)
string name,
ActivityKind kind = ActivityKind.Internal,
ActivityContext parentContext = default,
IEnumerable<KeyValuePair<string, object?>>? tags = null)
IEnumerable<KeyValuePair<string, object?>>? tags = null,
IEnumerable<ActivityLink>? links = null)
{
return Source.StartActivity(name, kind, parentContext, tags);
return Source.StartActivity(name, kind, parentContext, tags, links);
}

/// <summary>
Expand Down
Loading
Loading