Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
Show all changes
36 commits
Select commit Hold shift + click to select a range
f311ff2
feat: cross-process test log correlation via OTLP receiver (#4818)
thomhurst Apr 13, 2026
a32e2ad
fix: address code review findings on OTLP correlation PR
thomhurst Apr 13, 2026
13ee150
refactor: simplify after code review
thomhurst Apr 13, 2026
e3109a9
fix: gate CreateHttpClient override on EnableTelemetryCollection
thomhurst Apr 13, 2026
c3f804a
fix: address third-round review findings
thomhurst Apr 13, 2026
1f9ee36
docs: add telemetry correlation docs, enable by default
thomhurst Apr 13, 2026
8959f79
docs: note field ordering assumption in ParseResourceLogs
thomhurst Apr 13, 2026
b373da4
test: add thorough tests for OTLP log-to-test correlation
thomhurst Apr 13, 2026
ab164b9
fix: update public API snapshots for new InternalsVisibleTo entry
thomhurst Apr 14, 2026
4a9231d
feat: add OTLP integration tests and auto-inject telemetry env vars
thomhurst Apr 14, 2026
3aed4c2
refactor: simplify integration tests per code review
thomhurst Apr 14, 2026
dc20e0c
docs: address review feedback on shared handler and TraceId scope
thomhurst Apr 14, 2026
28d44bb
fix: generate unique TraceId per HTTP request for reliable correlation
thomhurst Apr 14, 2026
c40e230
feat: give each test its own TraceId via root activity with class link
thomhurst Apr 14, 2026
4359189
test: add engine activity tests, fix test body parent chain
thomhurst Apr 14, 2026
0587668
fix: restore parent-child activity hierarchy, move correlation to han…
thomhurst Apr 14, 2026
3e6575c
refactor: replace raw tag key strings with TUnitActivitySource constants
thomhurst Apr 14, 2026
3221c82
fix: replace fixed Task.Delay with polling in OtlpReceiverTests
thomhurst Apr 14, 2026
3ddbf74
fix: address review round 4 findings
thomhurst Apr 14, 2026
477a319
fix: remove mutually exclusive RunOnLinuxOnly/RunOnWindowsOnly attrs
thomhurst Apr 14, 2026
870a6c5
fix: add missing HangDump package to Aspire and ASP.NET test projects
thomhurst Apr 14, 2026
e911de2
fix: skip Aspire, ASP.NET, and RPC test modules on macOS
thomhurst Apr 14, 2026
3dd2b20
fix: enable Docker setup on macOS CI runners
thomhurst Apr 14, 2026
0874d0e
fix: skip Docker-dependent test modules on macOS
thomhurst Apr 14, 2026
87cfffa
fix: skip RPC tests globally until fixed (#5540)
thomhurst Apr 14, 2026
98fc40f
fix: restrict Docker-dependent CI modules to Linux, add missing ApiSe…
thomhurst Apr 14, 2026
08b62fb
fix: fix PublicAPI snapshot ordering and flaky severity test
thomhurst Apr 14, 2026
8c5e115
fix: move mock tests AOT publish into pipeline module
thomhurst Apr 14, 2026
068cdb0
Refactor code structure for improved readability and maintainability
thomhurst Apr 14, 2026
8034a5b
fix: share IntegrationTestFixture in WaitForHealthy tests
thomhurst Apr 14, 2026
f55d181
fix: restart Docker after setup to initialize iptables chains
thomhurst Apr 14, 2026
97178be
fix: remove docker/setup-docker-action
thomhurst Apr 14, 2026
4c3b872
fix: correct ASP.NET execution order assertions
thomhurst Apr 14, 2026
9145da5
fix: use CreateTablesAsync to avoid EnsureCreatedAsync race in parall…
thomhurst Apr 14, 2026
cad9f86
fix: use absolute path for AOT mock test publish output
thomhurst Apr 14, 2026
db8c9f9
merge: resolve conflict with main (NuGet.Protocol 7.3.1)
thomhurst Apr 14, 2026
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
Prev Previous commit
Next Next commit
fix: generate unique TraceId per HTTP request for reliable correlation
The handler now creates its own ActivityTraceId per request instead of
propagating Activity.Current's TraceId. This eliminates the shared-
TraceId-within-a-class problem without breaking the engine's activity
hierarchy (Session → Assembly → Class → Test).

- Handler generates unique TraceId + registers in TraceRegistry
- Integration tests simplified: removed StartCorrelatedActivity workaround
- Unit tests updated: verify unique TraceId generation, W3C format, registry
- Docs: removed shared-TraceId limitation section (no longer applies)
  • Loading branch information
thomhurst committed Apr 14, 2026
commit 28d44bb68634b1f54f87586e30c5a28e3518b21f
80 changes: 76 additions & 4 deletions TUnit.Aspire.Tests/BaggagePropagationHandlerTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,6 @@ public class BaggagePropagationHandlerTests
[Test]
public async Task SendAsync_InjectsTraceparentHeader()
{
using var activity = new Activity("test-inject-traceparent").Start();

var captured = new CaptureHandler();
var handler = new TUnitBaggagePropagationHandler { InnerHandler = captured };
using var client = new HttpClient(handler);
Expand All @@ -22,6 +20,79 @@ public async Task SendAsync_InjectsTraceparentHeader()
await Assert.That(captured.LastRequest!.Headers.Contains("traceparent")).IsTrue();
}

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

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 parts = traceparent.Split('-');
var requestTraceId = parts[1];

// The handler generates its own TraceId, distinct from Activity.Current's
await Assert.That(requestTraceId).IsNotEqualTo(activityTraceId);
}

[Test]
public async Task SendAsync_EachRequestGetsUniqueTraceId()
{
var captured = new CaptureHandler();
var handler = new TUnitBaggagePropagationHandler { InnerHandler = captured };
using var client = new HttpClient(handler);

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

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

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

await Assert.That(traceId1).IsNotEqualTo(traceId2);
}

[Test]
public async Task SendAsync_TraceparentFormat_IsValidW3C()
{
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 parts = traceparent.Split('-');

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)
}

[Test]
public async Task SendAsync_RegistersTraceIdInTraceRegistry()
{
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];

await Assert.That(TraceRegistry.IsRegistered(traceId)).IsTrue();
}

[Test]
public async Task SendAsync_InjectsBaggageHeader_WithActivityBaggage()
{
Expand Down Expand Up @@ -59,9 +130,8 @@ public async Task SendAsync_NoBaggage_DoesNotAddBaggageHeader()
}

[Test]
public async Task SendAsync_NoActivity_DoesNotThrow()
public async Task SendAsync_NoActivity_StillInjectsTraceparent()
{
// Ensure no ambient activity
Activity.Current = null;

var captured = new CaptureHandler();
Expand All @@ -71,6 +141,8 @@ public async Task SendAsync_NoActivity_DoesNotThrow()
await client.GetAsync("http://localhost/test");

await Assert.That(captured.LastRequest).IsNotNull();
await Assert.That(captured.LastRequest!.Headers.Contains("traceparent")).IsTrue();
await Assert.That(captured.LastRequest.Headers.Contains("baggage")).IsFalse();
}

[Test]
Expand Down
48 changes: 0 additions & 48 deletions TUnit.Aspire.Tests/OtlpCorrelationIntegrationTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -19,11 +19,6 @@ public class OtlpCorrelationIntegrationTests(IntegrationTestFixture fixture)
private const string ServiceName = "api-service";
private const int ConcurrentInstanceCount = 10;

// Each test needs its own TraceId for correlation. The engine creates test
// activities as children of the class activity (shared TraceId), so we create
// a dedicated root activity per test to get a unique TraceId for HTTP requests.
private static readonly ActivitySource TestActivitySource = new("TUnit.Aspire.IntegrationTests");

/// <summary>
/// Verifies the core end-to-end flow: a test makes an HTTP request to a real
/// Aspire-hosted API service, the service logs a message, and that log is
Expand All @@ -34,7 +29,6 @@ public async Task Logs_FromApiService_AppearInTestOutput()
{
var marker = $"integration-marker-{Guid.NewGuid():N}";

using var activity = StartCorrelatedActivity();
var client = fixture.CreateHttpClient(ServiceName);
using var response = await client.GetAsync($"/log?message={Uri.EscapeDataString(marker)}");

Expand All @@ -53,7 +47,6 @@ public async Task Logs_IncludeServiceNamePrefix()
{
var marker = $"svc-prefix-{Guid.NewGuid():N}";

using var activity = StartCorrelatedActivity();
var client = fixture.CreateHttpClient(ServiceName);
using var _ = await client.GetAsync($"/log?message={Uri.EscapeDataString(marker)}");

Expand All @@ -70,7 +63,6 @@ public async Task Logs_IncludeSeverityLevel()
{
var marker = $"severity-{Guid.NewGuid():N}";

using var activity = StartCorrelatedActivity();
var client = fixture.CreateHttpClient(ServiceName);
using var _ = await client.GetAsync($"/log?message={Uri.EscapeDataString(marker)}");

Expand All @@ -91,7 +83,6 @@ public async Task DifferentLogLevels_AreCorrectlyFormatted()
var warnMarker = $"warn-level-{Guid.NewGuid():N}";
var errorMarker = $"error-level-{Guid.NewGuid():N}";

using var activity = StartCorrelatedActivity();
var client = fixture.CreateHttpClient(ServiceName);
using var _ = await client.GetAsync($"/log-level?message={Uri.EscapeDataString(warnMarker)}&level=Warning");
using var __ = await client.GetAsync($"/log-level?message={Uri.EscapeDataString(errorMarker)}&level=Error");
Expand Down Expand Up @@ -125,7 +116,6 @@ public async Task ConcurrentTests_EachSeeOnlyOwnLogs(int instanceId)
.Select(i => $"concurrent-{instanceId}-req{i}-{Guid.NewGuid():N}")
.ToList();

using var activity = StartCorrelatedActivity();
var client = fixture.CreateHttpClient(ServiceName);

foreach (var marker in markers)
Expand Down Expand Up @@ -163,7 +153,6 @@ public async Task BurstConcurrentRequests_AllCorrelateCorrectly()
.Select(i => $"burst-{i}-{Guid.NewGuid():N}")
.ToList();

using var activity = StartCorrelatedActivity();
var client = fixture.CreateHttpClient(ServiceName);

// Fire all requests concurrently and dispose responses
Expand Down Expand Up @@ -195,7 +184,6 @@ public async Task MultipleRequests_AllCorrelateToSameTest()
.Select(i => $"multi-req-{i}-{Guid.NewGuid():N}")
.ToList();

using var activity = StartCorrelatedActivity();
var client = fixture.CreateHttpClient(ServiceName);

foreach (var marker in markers)
Expand Down Expand Up @@ -227,42 +215,6 @@ public async Task TraceRegistry_ContainsCurrentTestTraceId()
await Assert.That(isRegistered).IsTrue();
}

/// <summary>
/// Creates a new root <see cref="Activity"/> with a unique TraceId, registers it
/// in the <see cref="TraceRegistry"/>, and sets it as <see cref="Activity.Current"/>
/// so the <see cref="TUnitBaggagePropagationHandler"/> propagates it to the SUT.
/// </summary>
/// <remarks>
/// This is necessary because the TUnit engine creates test activities as children of
/// the class activity, meaning all tests in a class share the same TraceId. For OTLP
/// correlation to work correctly, each test needs a unique TraceId for its HTTP requests.
/// </remarks>
private Activity StartCorrelatedActivity()
{
var testContext = TestContext.Current!;

// Clear Activity.Current so the new activity does NOT inherit the class
// activity's TraceId. This is async-local so it only affects this test's context.
Activity.Current = null;

var activity = TestActivitySource.StartActivity(
"integration-test-request",
ActivityKind.Client)
?? throw new InvalidOperationException(
"StartActivity returned null — no ActivityListener is registered. " +
"Ensure the TUnit engine is creating test activities.");

activity.SetBaggage(TUnitActivitySource.TagTestId, testContext.Id);

// Register this new TraceId so the OTLP receiver can correlate logs back to this test
TraceRegistry.Register(
activity.TraceId.ToString(),
testContext.TestDetails.TestId,
testContext.Id);

return activity;
}

/// <summary>
/// Polls <see cref="TestContext.GetStandardOutput"/> until it contains the expected marker
/// or a timeout is reached. The OTLP SDK batches log exports, so there's inherent latency
Expand Down
84 changes: 40 additions & 44 deletions TUnit.Aspire/Http/TUnitBaggagePropagationHandler.cs
Original file line number Diff line number Diff line change
@@ -1,67 +1,63 @@
using System.Diagnostics;
using TUnit.Core;

namespace TUnit.Aspire.Http;

/// <summary>
/// DelegatingHandler that propagates <c>traceparent</c>, <c>tracestate</c>, and
/// <c>baggage</c> W3C headers from <see cref="Activity.Current"/> onto outgoing
/// HTTP requests. .NET's <c>DiagnosticsHandler</c> (which injects <c>traceparent</c>)
/// is only present in the <see cref="HttpClient"/> pipeline when using
/// <c>IHttpClientFactory</c> — it is NOT wired in when constructing
/// <c>new HttpClient(handler)</c> directly. This handler fills that gap so that both
/// trace context and <c>tunit.test.id</c> baggage reach the SUT process.
/// 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
/// when the engine's test activities share a class-level TraceId.
/// </summary>
internal sealed class TUnitBaggagePropagationHandler : DelegatingHandler
{
protected override Task<HttpResponseMessage> SendAsync(
HttpRequestMessage request, CancellationToken cancellationToken)
{
if (Activity.Current is { } activity)
// Generate a unique TraceId per request. The engine creates test activities
// as children of the class activity, so all tests in a class share the same
// TraceId. Using a fresh TraceId here ensures the OTLP receiver can map each
// request's logs back to the originating test, not just the class.
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)
{
// Inject traceparent and tracestate via the default propagator.
// This is necessary because DiagnosticsHandler is not in the pipeline
// when HttpClient is constructed with an explicit handler.
DistributedContextPropagator.Current.Inject(
activity,
request,
static (carrier, key, value) =>
{
if (carrier is HttpRequestMessage req)
{
req.Headers.TryAddWithoutValidation(key, value);
}
});
TraceRegistry.Register(
traceId.ToString(),
testContext.TestDetails.TestId,
testContext.Id);
}

// Inject baggage header if the propagator hasn't already done so.
// When OTel SDK is configured with BaggagePropagator, the Inject call
// above may have already added a baggage header — avoid duplicates.
if (!request.Headers.Contains("baggage"))
{
var first = true;
var sb = new System.Text.StringBuilder();
// Propagate baggage (including tunit.test.id) from the current activity
if (Activity.Current is { } activity && !request.Headers.Contains("baggage"))
{
var first = true;
var sb = new System.Text.StringBuilder();

foreach (var (key, value) in activity.Baggage)
foreach (var (key, value) in activity.Baggage)
{
if (key is null)
{
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;
continue;
}

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

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
24 changes: 0 additions & 24 deletions docs/docs/examples/aspire.md
Original file line number Diff line number Diff line change
Expand Up @@ -359,30 +359,6 @@ When disabled, `CreateHttpClient` delegates directly to Aspire's default impleme

### Limitations

- **Shared TraceId within a test class**: TUnit creates test activities as children of the class activity, so all tests in a class share the same TraceId by default. This means logs from one test's request may appear in another test's output within the same class. If you need per-test isolation (e.g., for concurrent tests), create a dedicated root activity per test:

```csharp
private static readonly ActivitySource TestSource = new("MyTests");

private Activity StartIsolatedActivity()
{
var ctx = TestContext.Current!;
Activity.Current = null; // Detach from class activity
var activity = TestSource.StartActivity("test-request", ActivityKind.Client)!;
activity.SetBaggage(TUnitActivitySource.TagTestId, ctx.Id);
TraceRegistry.Register(activity.TraceId.ToString(), ctx.TestDetails.TestId, ctx.Id);
return activity;
}

[Test]
public async Task My_Test()
{
using var activity = StartIsolatedActivity();
var client = fixture.CreateHttpClient("apiservice");
// Logs from this request will only appear in THIS test's output
}
```

- **Startup logs**: Logs emitted during app startup have no active trace context and cannot be correlated to a test. Use `WatchResourceLogs` for these.
- **Non-HTTP triggers**: Background jobs, timers, and message queue consumers that generate logs without an incoming HTTP request won't carry the test's TraceId.
- **Container resources**: Infrastructure resources like Redis and PostgreSQL don't have an OpenTelemetry SDK and can't export OTLP. Use `WatchResourceLogs` for their logs.
Expand Down
Loading