From 62d167d507df83d6e003b950d1bee7cb337cc963 Mon Sep 17 00:00:00 2001
From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com>
Date: Mon, 2 Mar 2026 19:27:44 +0000
Subject: [PATCH 01/16] feat: capture distributed traces from instrumented
libraries in HTML report
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Expand ActivityCollector to listen to all ActivitySources (not just TUnit),
using smart sampling to capture child spans from HttpClient, ASP.NET Core,
EF Core, etc. that execute under a test's trace context. External traces
can also be linked via the new TestContext.RegisterTrace API.
- Add TraceRegistry for cross-project traceId↔testNodeUid correlation
- Add TestContext.Activity (public) and TestContext.RegisterTrace API
- Smart sampling: AllDataAndRecorded for known traces, PropagationData
for unknown (enables context propagation with near-zero overhead)
- Gate OnActivityStopped to only collect spans for known traces
- Add AdditionalTraceIds to ReportTestResult for linked external traces
- Add renderExternalTrace JS for "Linked Trace" sections in HTML report
---
TUnit.Core/TestContext.cs | 23 ++++++
TUnit.Core/TraceRegistry.cs | 53 +++++++++++++
.../Reporters/Html/ActivityCollector.cs | 79 +++++++++++++++++--
.../Reporters/Html/HtmlReportDataModel.cs | 3 +
.../Reporters/Html/HtmlReportGenerator.cs | 18 +++++
TUnit.Engine/Reporters/Html/HtmlReporter.cs | 15 +++-
6 files changed, 181 insertions(+), 10 deletions(-)
create mode 100644 TUnit.Core/TraceRegistry.cs
diff --git a/TUnit.Core/TestContext.cs b/TUnit.Core/TestContext.cs
index 50cd62cadb..25621a7463 100644
--- a/TUnit.Core/TestContext.cs
+++ b/TUnit.Core/TestContext.cs
@@ -257,6 +257,29 @@ internal override void SetAsyncLocalContext()
///
public object Lock { get; } = new();
+#if NET
+ ///
+ /// Gets the associated with this test's execution,
+ /// or null if no activity is active.
+ /// Use Activity.Context to parent external work (e.g., HttpClient calls) under this test's trace.
+ ///
+ public new System.Diagnostics.Activity? Activity
+ {
+ get => base.Activity;
+ internal set => base.Activity = value;
+ }
+
+ ///
+ /// Registers an external trace ID to be associated with this test.
+ /// Registered traces will be captured by the activity collector and displayed
+ /// in the HTML report as linked traces.
+ ///
+ /// The trace ID of the external trace to associate with this test.
+ public void RegisterTrace(System.Diagnostics.ActivityTraceId traceId)
+ {
+ TraceRegistry.Register(traceId.ToString(), TestDetails.TestId);
+ }
+#endif
internal IClassConstructor? ClassConstructor => _testBuilderContext.ClassConstructor;
diff --git a/TUnit.Core/TraceRegistry.cs b/TUnit.Core/TraceRegistry.cs
new file mode 100644
index 0000000000..ee197f3fd8
--- /dev/null
+++ b/TUnit.Core/TraceRegistry.cs
@@ -0,0 +1,53 @@
+#if NET
+using System.Collections.Concurrent;
+using System.ComponentModel;
+
+namespace TUnit.Core;
+
+///
+/// Provides cross-project communication between TUnit.Core (where tests run)
+/// and TUnit.Engine (where activities are collected) for distributed trace correlation.
+///
+public static class TraceRegistry
+{
+ // traceId → testNodeUids (uses ConcurrentDictionary as a set to prevent duplicates)
+ private static readonly ConcurrentDictionary> TraceToTests =
+ new(StringComparer.OrdinalIgnoreCase);
+
+ // testNodeUid → traceIds (uses ConcurrentDictionary as a set to prevent duplicates)
+ private static readonly ConcurrentDictionary> TestToTraces =
+ new(StringComparer.OrdinalIgnoreCase);
+
+ ///
+ /// Registers a trace ID as associated with a test node UID.
+ /// Called by .
+ ///
+ internal static void Register(string traceId, string testNodeUid)
+ {
+ TraceToTests.GetOrAdd(traceId, static _ => new(StringComparer.OrdinalIgnoreCase)).TryAdd(testNodeUid, 0);
+ TestToTraces.GetOrAdd(testNodeUid, static _ => new(StringComparer.OrdinalIgnoreCase)).TryAdd(traceId, 0);
+ }
+
+ ///
+ /// Returns true if the given trace ID has been registered by any test.
+ /// Used by ActivityCollector's sampling callback.
+ ///
+ [EditorBrowsable(EditorBrowsableState.Never)]
+ public static bool IsRegistered(string traceId)
+ {
+ return TraceToTests.ContainsKey(traceId);
+ }
+
+ ///
+ /// Gets all trace IDs registered for the given test node UID.
+ /// Used by HtmlReporter to populate additional trace IDs on test results.
+ ///
+ [EditorBrowsable(EditorBrowsableState.Never)]
+ public static string[] GetTraceIds(string testNodeUid)
+ {
+ return TestToTraces.TryGetValue(testNodeUid, out var set)
+ ? set.Keys.ToArray()
+ : [];
+ }
+}
+#endif
diff --git a/TUnit.Engine/Reporters/Html/ActivityCollector.cs b/TUnit.Engine/Reporters/Html/ActivityCollector.cs
index ce395525de..fb1a0171a4 100644
--- a/TUnit.Engine/Reporters/Html/ActivityCollector.cs
+++ b/TUnit.Engine/Reporters/Html/ActivityCollector.cs
@@ -1,6 +1,7 @@
#if NET
using System.Collections.Concurrent;
using System.Diagnostics;
+using TUnit.Core;
namespace TUnit.Engine.Reporters.Html;
@@ -12,6 +13,9 @@ internal sealed class ActivityCollector : IDisposable
private readonly ConcurrentDictionary> _spansByTrace = new();
private readonly ConcurrentDictionary _spanCountsByTrace = new();
+ // Fast-path cache of trace IDs that should be collected. Subsumes TraceRegistry lookups
+ // so that subsequent activities on the same trace avoid cross-class dictionary checks.
+ private readonly ConcurrentDictionary _knownTraceIds = new(StringComparer.OrdinalIgnoreCase);
private ActivityListener? _listener;
private int _totalSpanCount;
@@ -19,15 +23,69 @@ public void Start()
{
_listener = new ActivityListener
{
- ShouldListenTo = static source => IsTUnitSource(source),
- Sample = static (ref ActivityCreationOptions _) => ActivitySamplingResult.AllDataAndRecorded,
- SampleUsingParentId = static (ref ActivityCreationOptions _) => ActivitySamplingResult.AllDataAndRecorded,
+ ShouldListenTo = static _ => true,
+ Sample = SampleActivity,
+ SampleUsingParentId = SampleActivityUsingParentId,
ActivityStopped = OnActivityStopped
};
ActivitySource.AddActivityListener(_listener);
}
+ private ActivitySamplingResult SampleActivity(ref ActivityCreationOptions options)
+ {
+ var sourceName = options.Source.Name;
+
+ // TUnit/Microsoft.Testing sources: always record, register trace
+ if (IsTUnitSource(sourceName))
+ {
+ if (options.Parent.TraceId != default)
+ {
+ _knownTraceIds.TryAdd(options.Parent.TraceId.ToString(), 0);
+ }
+
+ return ActivitySamplingResult.AllDataAndRecorded;
+ }
+
+ // No parent trace → nothing to correlate with
+ if (options.Parent.TraceId == default)
+ {
+ return ActivitySamplingResult.PropagationData;
+ }
+
+ var parentTraceId = options.Parent.TraceId.ToString();
+
+ // Parent trace is known (child of a TUnit activity, e.g. HttpClient)
+ if (_knownTraceIds.ContainsKey(parentTraceId))
+ {
+ return ActivitySamplingResult.AllDataAndRecorded;
+ }
+
+ // Trace registered via TestContext.RegisterTrace
+ if (TraceRegistry.IsRegistered(parentTraceId))
+ {
+ _knownTraceIds.TryAdd(parentTraceId, 0);
+ return ActivitySamplingResult.AllDataAndRecorded;
+ }
+
+ // Everything else: create the Activity for context propagation but no timing/tags
+ return ActivitySamplingResult.PropagationData;
+ }
+
+ private ActivitySamplingResult SampleActivityUsingParentId(ref ActivityCreationOptions options)
+ {
+ var sourceName = options.Source.Name;
+
+ if (IsTUnitSource(sourceName))
+ {
+ return ActivitySamplingResult.AllDataAndRecorded;
+ }
+
+ // For string-based parent IDs we can't easily extract the trace ID,
+ // so use PropagationData to allow context flow
+ return ActivitySamplingResult.PropagationData;
+ }
+
public void Stop()
{
_listener?.Dispose();
@@ -70,9 +128,9 @@ public SpanData[] GetAllSpans()
return lookup;
}
- private static bool IsTUnitSource(ActivitySource source) =>
- source.Name.StartsWith("TUnit", StringComparison.Ordinal) ||
- source.Name.StartsWith("Microsoft.Testing", StringComparison.Ordinal);
+ private static bool IsTUnitSource(string sourceName) =>
+ sourceName.StartsWith("TUnit", StringComparison.Ordinal) ||
+ sourceName.StartsWith("Microsoft.Testing", StringComparison.Ordinal);
private static string EnrichSpanName(Activity activity)
{
@@ -101,6 +159,14 @@ private static string EnrichSpanName(Activity activity)
private void OnActivityStopped(Activity activity)
{
+ var traceId = activity.TraceId.ToString();
+
+ // Only collect spans for known traces (TUnit or registered external traces)
+ if (!_knownTraceIds.ContainsKey(traceId))
+ {
+ return;
+ }
+
var newTotal = Interlocked.Increment(ref _totalSpanCount);
if (newTotal > MaxTotalSpans)
{
@@ -108,7 +174,6 @@ private void OnActivityStopped(Activity activity)
return;
}
- var traceId = activity.TraceId.ToString();
var traceCount = _spanCountsByTrace.AddOrUpdate(traceId, 1, (_, c) => c + 1);
if (traceCount > MaxSpansPerTrace)
{
diff --git a/TUnit.Engine/Reporters/Html/HtmlReportDataModel.cs b/TUnit.Engine/Reporters/Html/HtmlReportDataModel.cs
index 22d48982a1..16fe6ace97 100644
--- a/TUnit.Engine/Reporters/Html/HtmlReportDataModel.cs
+++ b/TUnit.Engine/Reporters/Html/HtmlReportDataModel.cs
@@ -144,6 +144,9 @@ internal sealed class ReportTestResult
[JsonPropertyName("spanId")]
public string? SpanId { get; init; }
+
+ [JsonPropertyName("additionalTraceIds")]
+ public string[]? AdditionalTraceIds { get; init; }
}
internal sealed class ReportExceptionData
diff --git a/TUnit.Engine/Reporters/Html/HtmlReportGenerator.cs b/TUnit.Engine/Reporters/Html/HtmlReportGenerator.cs
index ccaa53cfc5..03dcc66ed1 100644
--- a/TUnit.Engine/Reporters/Html/HtmlReportGenerator.cs
+++ b/TUnit.Engine/Reporters/Html/HtmlReportGenerator.cs
@@ -1287,6 +1287,11 @@ function renderDetail(t) {
h += '';
}
if (t.traceId && t.spanId && spansByTrace[t.traceId]) h += renderTrace(t.traceId, t.spanId);
+ if (t.additionalTraceIds && t.additionalTraceIds.length) {
+ t.additionalTraceIds.forEach(function(tid) {
+ if (spansByTrace[tid]) h += renderExternalTrace(tid);
+ });
+ }
return h;
}
@@ -1364,6 +1369,19 @@ function renderTrace(tid, rootSpanId) {
return '
Trace Timeline
' + renderSpanRows(sp, 't-' + rootSpanId) + '
';
}
+// Render an external (linked) trace as a flat timeline
+function renderExternalTrace(tid) {
+ const sp = spansByTrace[tid];
+ if (!sp || !sp.length) return '';
+ // Determine a label from the most common source name
+ const srcCounts = {};
+ sp.forEach(function(s) { srcCounts[s.source] = (srcCounts[s.source] || 0) + 1; });
+ let topSrc = tid.substring(0, 8);
+ let topCount = 0;
+ for (var src in srcCounts) { if (srcCounts[src] > topCount) { topCount = srcCounts[src]; topSrc = src; } }
+ return '
Linked Trace: ' + esc(topSrc) + '
' + renderSpanRows(sp, 'ext-' + tid) + '
';
+}
+
const tlArrow = '';
// Suite-level trace: test suite span + non-test-case children (hooks, setup, teardown)
diff --git a/TUnit.Engine/Reporters/Html/HtmlReporter.cs b/TUnit.Engine/Reporters/Html/HtmlReporter.cs
index 93a0af9550..d032bbff63 100644
--- a/TUnit.Engine/Reporters/Html/HtmlReporter.cs
+++ b/TUnit.Engine/Reporters/Html/HtmlReporter.cs
@@ -7,6 +7,7 @@
using Microsoft.Testing.Platform.Extensions;
using Microsoft.Testing.Platform.Extensions.Messages;
using Microsoft.Testing.Platform.Extensions.TestHost;
+using TUnit.Core;
using TUnit.Engine.Configuration;
using TUnit.Engine.Constants;
using TUnit.Engine.Framework;
@@ -184,7 +185,14 @@ private ReportData BuildReportData()
}
}
- var testResult = ExtractTestResult(kvp.Key, testNode, traceId, spanId, retryAttempt);
+#if NET
+ var additionalTraceIds = TraceRegistry.GetTraceIds(kvp.Key);
+ string[]? additionalTraceIdsForResult = additionalTraceIds.Length > 0 ? additionalTraceIds : null;
+#else
+ string[]? additionalTraceIdsForResult = null;
+#endif
+
+ var testResult = ExtractTestResult(kvp.Key, testNode, traceId, spanId, retryAttempt, additionalTraceIdsForResult);
AccumulateStatus(summary, testResult.Status);
@@ -337,7 +345,7 @@ private static void AccumulateStatus(ReportSummary summary, string status)
}
}
- private static ReportTestResult ExtractTestResult(string testId, TestNode testNode, string? traceId, string? spanId, int retryAttempt)
+ private static ReportTestResult ExtractTestResult(string testId, TestNode testNode, string? traceId, string? spanId, int retryAttempt, string[]? additionalTraceIds)
{
IProperty? stateProperty = null;
TestMethodIdentifierProperty? testMethodIdentifier = null;
@@ -417,7 +425,8 @@ private static ReportTestResult ExtractTestResult(string testId, TestNode testNo
SkipReason = skipReason,
RetryAttempt = retryAttempt,
TraceId = traceId,
- SpanId = spanId
+ SpanId = spanId,
+ AdditionalTraceIds = additionalTraceIds
};
}
From 384ce04af51d246f297ddacc0dd882dccce5a887 Mon Sep 17 00:00:00 2001
From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com>
Date: Mon, 2 Mar 2026 19:31:18 +0000
Subject: [PATCH 02/16] docs: add distributed tracing guide to HTML report docs
Document how the HTML report automatically captures spans from
instrumented libraries (HttpClient, ASP.NET Core, EF Core), how to
link external traces via TestContext.RegisterTrace, and how to access
the test Activity for manual context propagation.
Also adds a cross-reference from the OpenTelemetry integration page.
---
docs/docs/examples/opentelemetry.md | 6 ++
docs/docs/guides/html-report.md | 91 +++++++++++++++++++++++++++++
2 files changed, 97 insertions(+)
diff --git a/docs/docs/examples/opentelemetry.md b/docs/docs/examples/opentelemetry.md
index 3b267992cc..852e649acc 100644
--- a/docs/docs/examples/opentelemetry.md
+++ b/docs/docs/examples/opentelemetry.md
@@ -117,3 +117,9 @@ dotnet add package OpenTelemetry.Exporter.Zipkin
.AddZipkinExporter(opts => opts.Endpoint = new Uri("http://localhost:9411/api/v2/spans"))
```
+
+## HTML Report Integration
+
+TUnit's built-in [HTML test report](/docs/guides/html-report) automatically captures activity spans and renders them as trace timelines — no OpenTelemetry SDK required. The report also captures spans from instrumented libraries like HttpClient, ASP.NET Core, and EF Core when they execute within a test's context.
+
+For details on distributed trace collection, linking external traces, and accessing the test's `Activity`, see the [Distributed Tracing](/docs/guides/html-report#distributed-tracing) section of the HTML report guide.
diff --git a/docs/docs/guides/html-report.md b/docs/docs/guides/html-report.md
index d9139d4b31..fd42a38f61 100644
--- a/docs/docs/guides/html-report.md
+++ b/docs/docs/guides/html-report.md
@@ -111,6 +111,89 @@ After the workflow run completes:
2. Look for the artifact link in the step summary (Options A/B), or
3. Find the report in the **Artifacts** section at the bottom of the page (all options)
+## Distributed Tracing
+
+:::note
+Distributed tracing requires .NET 8 or later.
+:::
+
+The HTML report automatically captures trace spans from **all** instrumented .NET libraries — not just TUnit's own spans. When your test code calls HttpClient, ASP.NET Core, EF Core, or any library that emits `System.Diagnostics.Activity` spans, those spans appear as children in the test's trace timeline with no extra configuration.
+
+### How It Works
+
+TUnit's test body runs under a `System.Diagnostics.Activity`. Because `Activity.Current` flows through async calls via `AsyncLocal`, any instrumented library automatically creates child spans under the **same trace**. The HTML report collects these and renders them in the test's timeline.
+
+For example, an integration test using `WebApplicationFactory`:
+
+```csharp
+[Test]
+public async Task GetUsers_ReturnsOk()
+{
+ var client = Factory.CreateClient();
+ var response = await client.GetAsync("/api/users"); // HttpClient + ASP.NET Core spans captured automatically
+
+ await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.OK);
+}
+```
+
+The trace timeline for this test will show the HttpClient request span, ASP.NET Core hosting span, and any middleware or database spans — all nested under the test's root span.
+
+### Linking External Traces
+
+If your test communicates with an external service that runs in a **separate process** (and therefore has a different trace context), you can manually link its trace to the test:
+
+```csharp
+[Test]
+public async Task ProcessOrder_SendsNotification()
+{
+ // Start some external work that creates its own trace
+ var externalActivity = MyExternalService.StartProcessing(orderId);
+
+ // Link that trace to this test so it appears in the HTML report
+ TestContext.Current!.RegisterTrace(externalActivity.Context.TraceId);
+
+ // ... wait for processing, assert results
+}
+```
+
+Linked traces appear as a separate **"Linked Trace"** section below the test's main trace timeline, labeled by the source name of the spans.
+
+### Accessing the Test Activity
+
+You can access the current test's `Activity` to parent external work explicitly:
+
+```csharp
+[Test]
+public async Task MyTest()
+{
+ // Get the test's activity for manual context propagation
+ var testActivity = TestContext.Current!.Activity;
+
+ // Use its context to parent work under this test's trace
+ using var childActivity = new ActivitySource("MyApp")
+ .StartActivity("custom-work", ActivityKind.Internal, testActivity!.Context);
+
+ // ... do work — this span will appear in the test's trace timeline
+}
+```
+
+This is useful when calling libraries that don't automatically propagate `Activity.Current` or when you need to create custom spans for visibility.
+
+### What Gets Captured
+
+| Source | Captured Automatically? | Notes |
+|--------|------------------------|-------|
+| TUnit spans (test lifecycle) | Yes | Always captured |
+| HttpClient (`System.Net.Http`) | Yes | When called from test context |
+| ASP.NET Core (`Microsoft.AspNetCore`) | Yes | Including via `WebApplicationFactory` |
+| EF Core (`Microsoft.EntityFrameworkCore`) | Yes | Database query spans |
+| Other instrumented libraries | Yes | Any library using `System.Diagnostics.Activity` |
+| External processes | No | Use `TestContext.RegisterTrace()` to link |
+
+### Overhead
+
+The collector uses **smart sampling**: spans from known test traces are fully recorded, while unrelated activities receive only `PropagationData` (near-zero cost — no timing or tags collected). The existing caps (1,000 spans per trace, 50,000 total) apply only to collected spans.
+
## Troubleshooting
### Report Not Generated
@@ -126,3 +209,11 @@ For test suites with 1,000+ tests, the report uses client-side rendering from em
### Trace Timeline Not Showing
The trace timeline requires `System.Diagnostics.Activity` spans to be active during the test run. If your tests don't create activities, the trace section won't appear. TUnit's internal activities are captured automatically on .NET 8.0+.
+
+### External Library Spans Not Appearing
+
+If spans from HttpClient, ASP.NET Core, or other libraries aren't showing in the trace timeline:
+
+- **Ensure .NET 8+** — distributed trace collection is not available on .NET Framework or .NET Standard targets
+- **Check the library is instrumented** — the library must use `System.Diagnostics.Activity` (most modern .NET libraries do)
+- **Verify async context flow** — the library call must happen within the test's async context (i.e., awaited from the test method or a hook). Fire-and-forget calls (`Task.Run` without await) may lose the `Activity.Current` context
From 380e04f2ade3cd7140a0804ba94c44a4cc766b5c Mon Sep 17 00:00:00 2001
From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com>
Date: Mon, 2 Mar 2026 19:37:24 +0000
Subject: [PATCH 03/16] fix: address code review feedback on distributed trace
collection
- Fix root TUnit activity trace ID not registered in _knownTraceIds:
register in OnActivityStopped for TUnit sources (root activities have
their TraceId assigned after sampling, not available in SampleActivity)
- Make TraceRegistry internal (InternalsVisibleTo already covers TUnit.Engine)
- Add TraceRegistry.Clear() called at end of test run to release memory
- Parse W3C trace ID from string-based parent IDs in SampleActivityUsingParentId
- Add comment documenting ShouldListenTo perf tradeoff
- Add comment clarifying TestDetails.TestId == TestNodeUid correlation
---
TUnit.Core/TestContext.cs | 2 ++
TUnit.Core/TraceRegistry.cs | 19 +++++++----
.../Reporters/Html/ActivityCollector.cs | 32 +++++++++++++++----
TUnit.Engine/Reporters/Html/HtmlReporter.cs | 1 +
4 files changed, 41 insertions(+), 13 deletions(-)
diff --git a/TUnit.Core/TestContext.cs b/TUnit.Core/TestContext.cs
index 25621a7463..770565e145 100644
--- a/TUnit.Core/TestContext.cs
+++ b/TUnit.Core/TestContext.cs
@@ -277,6 +277,8 @@ internal override void SetAsyncLocalContext()
/// The trace ID of the external trace to associate with this test.
public void RegisterTrace(System.Diagnostics.ActivityTraceId traceId)
{
+ // TestDetails.TestId is the stable test node UID (e.g. "MyNs.MyClass.MyTest:0")
+ // used as the key in GetTestSpanLookup and HtmlReporter's BuildReportData loop.
TraceRegistry.Register(traceId.ToString(), TestDetails.TestId);
}
#endif
diff --git a/TUnit.Core/TraceRegistry.cs b/TUnit.Core/TraceRegistry.cs
index ee197f3fd8..1da1c2036a 100644
--- a/TUnit.Core/TraceRegistry.cs
+++ b/TUnit.Core/TraceRegistry.cs
@@ -1,14 +1,14 @@
#if NET
using System.Collections.Concurrent;
-using System.ComponentModel;
namespace TUnit.Core;
///
/// Provides cross-project communication between TUnit.Core (where tests run)
/// and TUnit.Engine (where activities are collected) for distributed trace correlation.
+/// Accessible to TUnit.Engine via InternalsVisibleTo.
///
-public static class TraceRegistry
+internal static class TraceRegistry
{
// traceId → testNodeUids (uses ConcurrentDictionary as a set to prevent duplicates)
private static readonly ConcurrentDictionary> TraceToTests =
@@ -32,8 +32,7 @@ internal static void Register(string traceId, string testNodeUid)
/// Returns true if the given trace ID has been registered by any test.
/// Used by ActivityCollector's sampling callback.
///
- [EditorBrowsable(EditorBrowsableState.Never)]
- public static bool IsRegistered(string traceId)
+ internal static bool IsRegistered(string traceId)
{
return TraceToTests.ContainsKey(traceId);
}
@@ -42,12 +41,20 @@ public static bool IsRegistered(string traceId)
/// Gets all trace IDs registered for the given test node UID.
/// Used by HtmlReporter to populate additional trace IDs on test results.
///
- [EditorBrowsable(EditorBrowsableState.Never)]
- public static string[] GetTraceIds(string testNodeUid)
+ internal static string[] GetTraceIds(string testNodeUid)
{
return TestToTraces.TryGetValue(testNodeUid, out var set)
? set.Keys.ToArray()
: [];
}
+
+ ///
+ /// Clears all registered trace associations. Called at the end of a test run.
+ ///
+ internal static void Clear()
+ {
+ TraceToTests.Clear();
+ TestToTraces.Clear();
+ }
}
#endif
diff --git a/TUnit.Engine/Reporters/Html/ActivityCollector.cs b/TUnit.Engine/Reporters/Html/ActivityCollector.cs
index fb1a0171a4..ca90c2a0f3 100644
--- a/TUnit.Engine/Reporters/Html/ActivityCollector.cs
+++ b/TUnit.Engine/Reporters/Html/ActivityCollector.cs
@@ -21,6 +21,10 @@ internal sealed class ActivityCollector : IDisposable
public void Start()
{
+ // Listen to ALL sources so we can capture child spans from HttpClient, ASP.NET Core,
+ // EF Core, etc. The Sample callback uses smart filtering to avoid overhead: only spans
+ // belonging to known test traces are fully recorded; everything else gets PropagationData
+ // (near-zero cost — enables context flow without timing/tags).
_listener = new ActivityListener
{
ShouldListenTo = static _ => true,
@@ -74,15 +78,23 @@ private ActivitySamplingResult SampleActivity(ref ActivityCreationOptions options)
{
- var sourceName = options.Source.Name;
-
- if (IsTUnitSource(sourceName))
+ if (IsTUnitSource(options.Source.Name))
{
return ActivitySamplingResult.AllDataAndRecorded;
}
- // For string-based parent IDs we can't easily extract the trace ID,
- // so use PropagationData to allow context flow
+ // Try to extract the trace ID from W3C format: "00-{32-hex-traceId}-{16-hex-spanId}-{2-hex-flags}"
+ var parentId = options.Parent;
+ if (parentId is { Length: >= 35 } && parentId[2] == '-')
+ {
+ var traceIdStr = parentId.Substring(3, 32);
+ if (_knownTraceIds.ContainsKey(traceIdStr) || TraceRegistry.IsRegistered(traceIdStr))
+ {
+ _knownTraceIds.TryAdd(traceIdStr, 0);
+ return ActivitySamplingResult.AllDataAndRecorded;
+ }
+ }
+
return ActivitySamplingResult.PropagationData;
}
@@ -161,8 +173,14 @@ private void OnActivityStopped(Activity activity)
{
var traceId = activity.TraceId.ToString();
- // Only collect spans for known traces (TUnit or registered external traces)
- if (!_knownTraceIds.ContainsKey(traceId))
+ // TUnit activities always register their own trace ID. This catches root activities
+ // (e.g. "test session") whose TraceId is assigned by the runtime after sampling,
+ // so it couldn't be registered in SampleActivity where only the parent TraceId is known.
+ if (IsTUnitSource(activity.Source.Name))
+ {
+ _knownTraceIds.TryAdd(traceId, 0);
+ }
+ else if (!_knownTraceIds.ContainsKey(traceId))
{
return;
}
diff --git a/TUnit.Engine/Reporters/Html/HtmlReporter.cs b/TUnit.Engine/Reporters/Html/HtmlReporter.cs
index d032bbff63..3f5b495173 100644
--- a/TUnit.Engine/Reporters/Html/HtmlReporter.cs
+++ b/TUnit.Engine/Reporters/Html/HtmlReporter.cs
@@ -76,6 +76,7 @@ public async Task AfterRunAsync(int exitCode, CancellationToken cancellation)
{
#if NET
_activityCollector?.Stop();
+ TraceRegistry.Clear();
#endif
if (_updates.Count == 0)
From f34199135f9f0e43fba3648426337d50d1194797 Mon Sep 17 00:00:00 2001
From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com>
Date: Mon, 2 Mar 2026 20:10:48 +0000
Subject: [PATCH 04/16] fix: move TraceRegistry.Clear() after BuildReportData()
Clear() was called before BuildReportData(), so GetTraceIds() always
returned empty and RegisterTrace-linked traces never appeared in the
report. Now cleared after report data is captured.
---
TUnit.Engine/Reporters/Html/HtmlReporter.cs | 7 ++++++-
1 file changed, 6 insertions(+), 1 deletion(-)
diff --git a/TUnit.Engine/Reporters/Html/HtmlReporter.cs b/TUnit.Engine/Reporters/Html/HtmlReporter.cs
index 3f5b495173..3b58286c4a 100644
--- a/TUnit.Engine/Reporters/Html/HtmlReporter.cs
+++ b/TUnit.Engine/Reporters/Html/HtmlReporter.cs
@@ -76,15 +76,20 @@ public async Task AfterRunAsync(int exitCode, CancellationToken cancellation)
{
#if NET
_activityCollector?.Stop();
- TraceRegistry.Clear();
#endif
if (_updates.Count == 0)
{
+#if NET
+ TraceRegistry.Clear();
+#endif
return;
}
var reportData = BuildReportData();
+#if NET
+ TraceRegistry.Clear();
+#endif
var html = HtmlReportGenerator.GenerateHtml(reportData);
if (string.IsNullOrEmpty(html))
From e43a86af025113dd5681c2d20147bb97f5430ddc Mon Sep 17 00:00:00 2001
From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com>
Date: Mon, 2 Mar 2026 20:24:55 +0000
Subject: [PATCH 05/16] test: update public API snapshots for Activity and
RegisterTrace
---
...ests.Core_Library_Has_No_API_Changes.DotNet10_0.verified.txt | 2 ++
...Tests.Core_Library_Has_No_API_Changes.DotNet8_0.verified.txt | 2 ++
...Tests.Core_Library_Has_No_API_Changes.DotNet9_0.verified.txt | 2 ++
3 files changed, 6 insertions(+)
diff --git a/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.DotNet10_0.verified.txt b/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.DotNet10_0.verified.txt
index 8705a2ca67..fd608e5231 100644
--- a/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.DotNet10_0.verified.txt
+++ b/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.DotNet10_0.verified.txt
@@ -1357,6 +1357,7 @@ namespace
public class TestContext : .Context, ., ., ., ., ., ., ., .
{
public TestContext(string testName, serviceProvider, .ClassHookContext classContext, .TestBuilderContext testBuilderContext, .CancellationToken cancellationToken) { }
+ public new .Activity? Activity { get; }
public .ClassHookContext ClassContext { get; }
public . Dependencies { get; }
public . Events { get; }
@@ -1375,6 +1376,7 @@ namespace
public static string WorkingDirectory { get; set; }
public override string GetErrorOutput() { }
public override string GetStandardOutput() { }
+ public void RegisterTrace(.ActivityTraceId traceId) { }
public static .TestContext? GetById(string id) { }
}
public class TestContextEvents : .
diff --git a/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.DotNet8_0.verified.txt b/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.DotNet8_0.verified.txt
index 12ed6c34fb..b1c5ab08e1 100644
--- a/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.DotNet8_0.verified.txt
+++ b/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.DotNet8_0.verified.txt
@@ -1357,6 +1357,7 @@ namespace
public class TestContext : .Context, ., ., ., ., ., ., ., .
{
public TestContext(string testName, serviceProvider, .ClassHookContext classContext, .TestBuilderContext testBuilderContext, .CancellationToken cancellationToken) { }
+ public new .Activity? Activity { get; }
public .ClassHookContext ClassContext { get; }
public . Dependencies { get; }
public . Events { get; }
@@ -1375,6 +1376,7 @@ namespace
public static string WorkingDirectory { get; set; }
public override string GetErrorOutput() { }
public override string GetStandardOutput() { }
+ public void RegisterTrace(.ActivityTraceId traceId) { }
public static .TestContext? GetById(string id) { }
}
public class TestContextEvents : .
diff --git a/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.DotNet9_0.verified.txt b/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.DotNet9_0.verified.txt
index 278bb5f8cc..ac33389868 100644
--- a/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.DotNet9_0.verified.txt
+++ b/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.DotNet9_0.verified.txt
@@ -1357,6 +1357,7 @@ namespace
public class TestContext : .Context, ., ., ., ., ., ., ., .
{
public TestContext(string testName, serviceProvider, .ClassHookContext classContext, .TestBuilderContext testBuilderContext, .CancellationToken cancellationToken) { }
+ public new .Activity? Activity { get; }
public .ClassHookContext ClassContext { get; }
public . Dependencies { get; }
public . Events { get; }
@@ -1375,6 +1376,7 @@ namespace
public static string WorkingDirectory { get; set; }
public override string GetErrorOutput() { }
public override string GetStandardOutput() { }
+ public void RegisterTrace(.ActivityTraceId traceId) { }
public static .TestContext? GetById(string id) { }
}
public class TestContextEvents : .
From 649cc5b720dbc0d44f404ca7bd76a802231d9688 Mon Sep 17 00:00:00 2001
From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com>
Date: Mon, 2 Mar 2026 21:02:12 +0000
Subject: [PATCH 06/16] feat: add Client property with HTTP tracing to
WebApplicationTest
WebApplicationFactory.CreateClient() uses an in-memory TestServer
handler that bypasses .NET's built-in HTTP Activity instrumentation
(DiagnosticsHandler/SocketsHttpHandler). This means no HTTP spans
appear in the HTML report trace timeline.
Add ActivityPropagationHandler that creates Activity spans for HTTP
requests and injects W3C traceparent headers so server-side ASP.NET
Core spans are also correlated to the test's trace.
The new Client property on WebApplicationTest chains both
ActivityPropagationHandler and TUnitTestIdHandler, providing tracing
and test context propagation out of the box.
---
.../WebApplicationFactoryExtensions.cs | 2 +-
.../Http/ActivityPropagationHandler.cs | 48 +++++++++++++++++++
TUnit.AspNetCore/WebApplicationTest.cs | 21 ++++++++
3 files changed, 70 insertions(+), 1 deletion(-)
create mode 100644 TUnit.AspNetCore/Http/ActivityPropagationHandler.cs
diff --git a/TUnit.AspNetCore/Extensions/WebApplicationFactoryExtensions.cs b/TUnit.AspNetCore/Extensions/WebApplicationFactoryExtensions.cs
index d1f5738808..bf50eb065c 100644
--- a/TUnit.AspNetCore/Extensions/WebApplicationFactoryExtensions.cs
+++ b/TUnit.AspNetCore/Extensions/WebApplicationFactoryExtensions.cs
@@ -21,6 +21,6 @@ public static HttpClient CreateClientWithTestContext(
this WebApplicationFactory factory)
where TEntryPoint : class
{
- return factory.CreateDefaultClient(new TUnitTestIdHandler());
+ return factory.CreateDefaultClient(new ActivityPropagationHandler(), new TUnitTestIdHandler());
}
}
diff --git a/TUnit.AspNetCore/Http/ActivityPropagationHandler.cs b/TUnit.AspNetCore/Http/ActivityPropagationHandler.cs
new file mode 100644
index 0000000000..93fa564a25
--- /dev/null
+++ b/TUnit.AspNetCore/Http/ActivityPropagationHandler.cs
@@ -0,0 +1,48 @@
+using System.Diagnostics;
+
+namespace TUnit.AspNetCore;
+
+///
+/// DelegatingHandler that creates Activity spans for HTTP requests and propagates
+/// trace context via the W3C traceparent header. This bridges the gap where
+///
+/// creates an HttpClient with an in-memory handler, bypassing .NET's built-in
+/// DiagnosticsHandler that normally creates HTTP Activity spans.
+///
+internal sealed class ActivityPropagationHandler : DelegatingHandler
+{
+ private static readonly ActivitySource HttpActivitySource = new("TUnit.AspNetCore.Http");
+
+ protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
+ {
+ var path = request.RequestUri?.AbsolutePath ?? request.RequestUri?.ToString() ?? "unknown";
+ using var activity = HttpActivitySource.StartActivity(
+ $"HTTP {request.Method} {path}",
+ ActivityKind.Client);
+
+ if (activity is not null)
+ {
+ activity.SetTag("http.request.method", request.Method.Method);
+ activity.SetTag("url.full", request.RequestUri?.ToString());
+ activity.SetTag("server.address", request.RequestUri?.Host);
+
+ // Inject W3C traceparent header so the server creates child activities under the same trace
+ request.Headers.Remove("traceparent");
+ request.Headers.TryAddWithoutValidation("traceparent",
+ $"00-{activity.TraceId}-{activity.SpanId}-{(activity.Recorded ? "01" : "00")}");
+ }
+
+ var response = await base.SendAsync(request, cancellationToken);
+
+ if (activity is not null)
+ {
+ activity.SetTag("http.response.status_code", (int)response.StatusCode);
+ if (!response.IsSuccessStatusCode)
+ {
+ activity.SetStatus(ActivityStatusCode.Error);
+ }
+ }
+
+ return response;
+ }
+}
diff --git a/TUnit.AspNetCore/WebApplicationTest.cs b/TUnit.AspNetCore/WebApplicationTest.cs
index 15b43fa317..16c08f7a69 100644
--- a/TUnit.AspNetCore/WebApplicationTest.cs
+++ b/TUnit.AspNetCore/WebApplicationTest.cs
@@ -77,6 +77,7 @@ public abstract class WebApplicationTest : WebApplication
public TFactory GlobalFactory { get; set; } = null!;
private WebApplicationFactory? _factory;
+ private HttpClient? _tracedClient;
private readonly WebApplicationTestOptions _options = new();
@@ -88,6 +89,23 @@ public abstract class WebApplicationTest : WebApplication
"Factory is not initialized. Ensure the test has started and the BeforeTest hook has run. " +
"Do not access Factory during test discovery or in data source methods.");
+ ///
+ /// Gets an HttpClient configured with activity tracing and test context propagation.
+ /// HTTP requests made through this client appear as spans in the HTML report's trace timeline,
+ /// and the current test's context ID is propagated via HTTP header for server-side log correlation.
+ ///
+ ///
+ ///
+ ///
+ /// uses an in-memory handler that bypasses .NET's built-in HTTP Activity instrumentation,
+ /// so HTTP Activity spans are not emitted. This property adds handlers that fill that gap
+ /// and also propagate W3C traceparent headers so server-side ASP.NET Core spans
+ /// are correlated to the test's trace.
+ ///
+ ///
+ public HttpClient Client => _tracedClient ??= Factory.CreateDefaultClient(
+ new ActivityPropagationHandler(), new TUnitTestIdHandler());
+
///
/// Gets the service provider from the per-test factory.
/// Use this to resolve services for verification or setup.
@@ -125,6 +143,9 @@ public async Task InitializeFactoryAsync(TestContext testContext)
[EditorBrowsable(EditorBrowsableState.Never)]
public async Task DisposeFactoryAsync()
{
+ _tracedClient?.Dispose();
+ _tracedClient = null;
+
if (_factory != null)
{
await _factory.DisposeAsync();
From 2cadee6e42cc06e2f011dab5afd548d307786d9f Mon Sep 17 00:00:00 2001
From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com>
Date: Mon, 2 Mar 2026 21:17:56 +0000
Subject: [PATCH 07/16] refactor: use DistributedContextPropagator for trace
context injection
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Replace manual traceparent header construction with the idiomatic
DistributedContextPropagator.Current.Inject() API. This propagates
both traceparent and tracestate, and respects any custom propagator
configuration — consistent with what DiagnosticsHandler does.
---
.../Http/ActivityPropagationHandler.cs | 16 ++++++++++++----
1 file changed, 12 insertions(+), 4 deletions(-)
diff --git a/TUnit.AspNetCore/Http/ActivityPropagationHandler.cs b/TUnit.AspNetCore/Http/ActivityPropagationHandler.cs
index 93fa564a25..4666730a05 100644
--- a/TUnit.AspNetCore/Http/ActivityPropagationHandler.cs
+++ b/TUnit.AspNetCore/Http/ActivityPropagationHandler.cs
@@ -1,4 +1,5 @@
using System.Diagnostics;
+using System.Net.Http.Headers;
namespace TUnit.AspNetCore;
@@ -26,10 +27,17 @@ protected override async Task SendAsync(HttpRequestMessage
activity.SetTag("url.full", request.RequestUri?.ToString());
activity.SetTag("server.address", request.RequestUri?.Host);
- // Inject W3C traceparent header so the server creates child activities under the same trace
- request.Headers.Remove("traceparent");
- request.Headers.TryAddWithoutValidation("traceparent",
- $"00-{activity.TraceId}-{activity.SpanId}-{(activity.Recorded ? "01" : "00")}");
+ // Inject trace context headers (traceparent + tracestate) so the server
+ // creates child activities under the same trace
+ DistributedContextPropagator.Current.Inject(activity, request.Headers,
+ static (headers, key, value) =>
+ {
+ if (headers is HttpRequestHeaders h)
+ {
+ h.Remove(key);
+ h.TryAddWithoutValidation(key, value);
+ }
+ });
}
var response = await base.SendAsync(request, cancellationToken);
From bacce7d3d6e36007ef44a5801e2e6a943584b8a1 Mon Sep 17 00:00:00 2001
From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com>
Date: Mon, 2 Mar 2026 21:22:57 +0000
Subject: [PATCH 08/16] feat: expose GitHub Actions runtime variables for
integration tests
---
.github/workflows/cloudshop-example.yml | 7 +++++++
1 file changed, 7 insertions(+)
diff --git a/.github/workflows/cloudshop-example.yml b/.github/workflows/cloudshop-example.yml
index 7c17af6b86..d5537647d4 100644
--- a/.github/workflows/cloudshop-example.yml
+++ b/.github/workflows/cloudshop-example.yml
@@ -31,6 +31,13 @@ jobs:
restore-keys: |
nuget-cloudshop-
+ - name: Expose GitHub Actions Runtime
+ uses: actions/github-script@v7
+ with:
+ script: |
+ core.exportVariable('ACTIONS_RUNTIME_TOKEN', process.env['ACTIONS_RUNTIME_TOKEN']);
+ core.exportVariable('ACTIONS_RESULTS_URL', process.env['ACTIONS_RESULTS_URL']);
+
- name: Build
run: dotnet build examples/CloudShop/CloudShop.Tests/CloudShop.Tests.csproj -c Release
From ceec217d27bf0924773cfd92dc806994566d5ab8 Mon Sep 17 00:00:00 2001
From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com>
Date: Mon, 2 Mar 2026 21:44:35 +0000
Subject: [PATCH 09/16] fix: exempt TUnit spans from per-trace cap in
ActivityCollector
All TUnit tests share a single trace (rooted at the test session
activity). With Aspire/integration tests generating many HTTP and
infrastructure spans, the 1000-per-trace cap was being hit before
all test case spans could be captured. This caused later tests to
have no traceId/spanId in the HTML report, making their trace
timeline invisible.
TUnit's own spans (test case, hooks, body) are now exempt from both
per-trace and total caps. They're essential for the report and their
count is bounded by the number of tests. The caps continue to limit
external library spans (HttpClient, EF Core, etc.).
---
.../Reporters/Html/ActivityCollector.cs | 31 ++++++++++++-------
1 file changed, 19 insertions(+), 12 deletions(-)
diff --git a/TUnit.Engine/Reporters/Html/ActivityCollector.cs b/TUnit.Engine/Reporters/Html/ActivityCollector.cs
index ca90c2a0f3..8d153d5ed3 100644
--- a/TUnit.Engine/Reporters/Html/ActivityCollector.cs
+++ b/TUnit.Engine/Reporters/Html/ActivityCollector.cs
@@ -172,11 +172,12 @@ private static string EnrichSpanName(Activity activity)
private void OnActivityStopped(Activity activity)
{
var traceId = activity.TraceId.ToString();
+ var isTUnit = IsTUnitSource(activity.Source.Name);
// TUnit activities always register their own trace ID. This catches root activities
// (e.g. "test session") whose TraceId is assigned by the runtime after sampling,
// so it couldn't be registered in SampleActivity where only the parent TraceId is known.
- if (IsTUnitSource(activity.Source.Name))
+ if (isTUnit)
{
_knownTraceIds.TryAdd(traceId, 0);
}
@@ -185,19 +186,25 @@ private void OnActivityStopped(Activity activity)
return;
}
- var newTotal = Interlocked.Increment(ref _totalSpanCount);
- if (newTotal > MaxTotalSpans)
+ // TUnit's own spans (test case, hooks, body) are exempt from caps — they're essential
+ // for the report and bounded by test count. Caps only limit external library spans
+ // (HttpClient, EF Core, etc.) to prevent unbounded memory growth.
+ if (!isTUnit)
{
- Interlocked.Decrement(ref _totalSpanCount);
- return;
- }
+ var newTotal = Interlocked.Increment(ref _totalSpanCount);
+ if (newTotal > MaxTotalSpans)
+ {
+ Interlocked.Decrement(ref _totalSpanCount);
+ return;
+ }
- var traceCount = _spanCountsByTrace.AddOrUpdate(traceId, 1, (_, c) => c + 1);
- if (traceCount > MaxSpansPerTrace)
- {
- Interlocked.Decrement(ref _totalSpanCount);
- _spanCountsByTrace.AddOrUpdate(traceId, 0, (_, c) => Math.Max(0, c - 1));
- return;
+ var traceCount = _spanCountsByTrace.AddOrUpdate(traceId, 1, (_, c) => c + 1);
+ if (traceCount > MaxSpansPerTrace)
+ {
+ Interlocked.Decrement(ref _totalSpanCount);
+ _spanCountsByTrace.AddOrUpdate(traceId, 0, (_, c) => Math.Max(0, c - 1));
+ return;
+ }
}
var queue = _spansByTrace.GetOrAdd(traceId, _ => new ConcurrentQueue());
From 667feb1b00dfc47585521d4d3e683b95b35f5bfa Mon Sep 17 00:00:00 2001
From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com>
Date: Mon, 2 Mar 2026 21:46:16 +0000
Subject: [PATCH 10/16] refactor: replace per-trace span cap with per-test
external span cap
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The previous per-trace cap of 1000 was ineffective because all TUnit
tests share a single trace (rooted at the test session activity).
Infrastructure-heavy tests (Aspire, etc.) would exhaust the cap with
HTTP/connection spans before later tests could capture their spans.
New approach:
- TUnit spans are always captured (no cap) — essential for the report
- External spans (HttpClient, EF Core, etc.) are capped at 100 per
test case, found by walking the Activity.Parent chain
- Infrastructure spans not under any test are uncapped
---
.../Reporters/Html/ActivityCollector.cs | 54 ++++++++++++-------
1 file changed, 34 insertions(+), 20 deletions(-)
diff --git a/TUnit.Engine/Reporters/Html/ActivityCollector.cs b/TUnit.Engine/Reporters/Html/ActivityCollector.cs
index 8d153d5ed3..1c3ffd536b 100644
--- a/TUnit.Engine/Reporters/Html/ActivityCollector.cs
+++ b/TUnit.Engine/Reporters/Html/ActivityCollector.cs
@@ -7,17 +7,18 @@ namespace TUnit.Engine.Reporters.Html;
internal sealed class ActivityCollector : IDisposable
{
- // Soft caps — intentionally racy for performance; may be slightly exceeded under high concurrency.
- private const int MaxSpansPerTrace = 1000;
- private const int MaxTotalSpans = 50_000;
+ // Cap external (non-TUnit) spans per test to keep the report manageable.
+ // TUnit's own spans are always captured regardless of caps.
+ // Soft cap — intentionally racy for performance; may be slightly exceeded under high concurrency.
+ private const int MaxExternalSpansPerTest = 100;
private readonly ConcurrentDictionary> _spansByTrace = new();
- private readonly ConcurrentDictionary _spanCountsByTrace = new();
+ // Track external span count per test case (keyed by test case span ID)
+ private readonly ConcurrentDictionary _externalSpanCountsByTest = new();
// Fast-path cache of trace IDs that should be collected. Subsumes TraceRegistry lookups
// so that subsequent activities on the same trace avoid cross-class dictionary checks.
private readonly ConcurrentDictionary _knownTraceIds = new(StringComparer.OrdinalIgnoreCase);
private ActivityListener? _listener;
- private int _totalSpanCount;
public void Start()
{
@@ -140,6 +141,23 @@ public SpanData[] GetAllSpans()
return lookup;
}
+ private static string? FindTestCaseAncestor(Activity activity)
+ {
+ var current = activity.Parent;
+ while (current is not null)
+ {
+ if (IsTUnitSource(current.Source.Name) &&
+ current.DisplayName.StartsWith("test case", StringComparison.Ordinal))
+ {
+ return current.SpanId.ToString();
+ }
+
+ current = current.Parent;
+ }
+
+ return null;
+ }
+
private static bool IsTUnitSource(string sourceName) =>
sourceName.StartsWith("TUnit", StringComparison.Ordinal) ||
sourceName.StartsWith("Microsoft.Testing", StringComparison.Ordinal);
@@ -186,25 +204,21 @@ private void OnActivityStopped(Activity activity)
return;
}
- // TUnit's own spans (test case, hooks, body) are exempt from caps — they're essential
- // for the report and bounded by test count. Caps only limit external library spans
- // (HttpClient, EF Core, etc.) to prevent unbounded memory growth.
+ // Cap external spans per test to keep the report size manageable.
+ // TUnit's own spans are always captured — they're essential for the report.
if (!isTUnit)
{
- var newTotal = Interlocked.Increment(ref _totalSpanCount);
- if (newTotal > MaxTotalSpans)
+ var testSpanId = FindTestCaseAncestor(activity);
+ if (testSpanId is not null)
{
- Interlocked.Decrement(ref _totalSpanCount);
- return;
- }
-
- var traceCount = _spanCountsByTrace.AddOrUpdate(traceId, 1, (_, c) => c + 1);
- if (traceCount > MaxSpansPerTrace)
- {
- Interlocked.Decrement(ref _totalSpanCount);
- _spanCountsByTrace.AddOrUpdate(traceId, 0, (_, c) => Math.Max(0, c - 1));
- return;
+ var count = _externalSpanCountsByTest.AddOrUpdate(testSpanId, 1, (_, c) => c + 1);
+ if (count > MaxExternalSpansPerTest)
+ {
+ _externalSpanCountsByTest.AddOrUpdate(testSpanId, 0, (_, c) => Math.Max(0, c - 1));
+ return;
+ }
}
+ // External spans not under any test (e.g., fixture/infrastructure setup) are uncapped
}
var queue = _spansByTrace.GetOrAdd(traceId, _ => new ConcurrentQueue());
From 84d5722ac06e86a324be74ea364f8951932a4d6d Mon Sep 17 00:00:00 2001
From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com>
Date: Mon, 2 Mar 2026 21:52:53 +0000
Subject: [PATCH 11/16] fix: align trace timeline bars independent of depth
indentation
The span bars used margin-left on a flex item after a variable-width
indent element, causing deeper spans to be shifted further right than
their actual timeline position. Split the layout into a fixed label
area (with padding-left for indentation) and a separate track area
(with absolutely positioned bars), so bar positions are consistent
regardless of nesting depth.
---
TUnit.Engine/Reporters/Html/HtmlReportGenerator.cs | 14 ++++++++------
1 file changed, 8 insertions(+), 6 deletions(-)
diff --git a/TUnit.Engine/Reporters/Html/HtmlReportGenerator.cs b/TUnit.Engine/Reporters/Html/HtmlReportGenerator.cs
index 03dcc66ed1..f42d7b7508 100644
--- a/TUnit.Engine/Reporters/Html/HtmlReportGenerator.cs
+++ b/TUnit.Engine/Reporters/Html/HtmlReportGenerator.cs
@@ -757,13 +757,14 @@ .search input{
.trace{margin-top:6px}
.sp-row{display:flex;align-items:center;gap:6px;padding:2px 0;font-size:.78rem;cursor:pointer}
.sp-row:hover .sp-bar{filter:brightness(1.2)}
-.sp-indent{flex-shrink:0}
-.sp-bar{height:14px;border-radius:3px;min-width:3px;transition:filter .15s}
+.sp-lbl{flex:0 0 auto;display:flex;align-items:center;gap:4px;min-width:0;max-width:240px}
+.sp-track{flex:1;position:relative;height:14px;min-width:0}
+.sp-bar{position:absolute;top:0;height:100%;border-radius:3px;min-width:3px;transition:filter .15s}
.sp-bar.ok{background:linear-gradient(90deg,rgba(52,211,153,.6),var(--emerald))}
.sp-bar.err{background:linear-gradient(90deg,rgba(251,113,133,.6),var(--rose))}
.sp-bar.unk{background:linear-gradient(90deg,rgba(148,163,184,.4),var(--slate))}
-.sp-name{color:var(--text-2);max-width:180px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
-.sp-dur{font-family:var(--mono);color:var(--text-3);font-size:.72rem}
+.sp-name{color:var(--text-2);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
+.sp-dur{font-family:var(--mono);color:var(--text-3);font-size:.72rem;flex-shrink:0}
.sp-extra{
display:none;padding:6px 10px;margin:2px 0 4px;
background:var(--surface-0);border:1px solid var(--border);border-radius:var(--r);
@@ -1336,11 +1337,12 @@ function gd(s) {
const w = Math.max((s.durationMs / dur * 100), .5).toFixed(2);
const cls = s.status === 'Error' ? 'err' : s.status === 'Ok' ? 'ok' : 'unk';
h += '
';
- h += '';
- h += '';
+ h += '
';
h += '' + esc(s.name) + '';
h += '' + fmt(s.durationMs) + '';
h += '
';
+ h += '
';
+ h += '
';
let ex = '
';
ex += 'Source: ' + esc(s.source) + ' · Kind: ' + esc(s.kind);
if (s.tags && s.tags.length) { ex += ' Tags: '; s.tags.forEach(t => { ex += esc(t.key) + '=' + esc(t.value) + ' '; }); }
From 9d7c2176d6828c58d079a2e17680c77616934ab4 Mon Sep 17 00:00:00 2001
From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com>
Date: Mon, 2 Mar 2026 21:57:30 +0000
Subject: [PATCH 12/16] fix: use fixed-width label area in trace timeline
The label area used flex:0 0 auto, so longer span names pushed the
track start further right. Since bar left% is relative to the track,
identical timestamps rendered at different pixel positions across
rows. Fixed by giving the label a fixed 240px width.
---
TUnit.Engine/Reporters/Html/HtmlReportGenerator.cs | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/TUnit.Engine/Reporters/Html/HtmlReportGenerator.cs b/TUnit.Engine/Reporters/Html/HtmlReportGenerator.cs
index f42d7b7508..3742ecd113 100644
--- a/TUnit.Engine/Reporters/Html/HtmlReportGenerator.cs
+++ b/TUnit.Engine/Reporters/Html/HtmlReportGenerator.cs
@@ -757,7 +757,7 @@ .search input{
.trace{margin-top:6px}
.sp-row{display:flex;align-items:center;gap:6px;padding:2px 0;font-size:.78rem;cursor:pointer}
.sp-row:hover .sp-bar{filter:brightness(1.2)}
-.sp-lbl{flex:0 0 auto;display:flex;align-items:center;gap:4px;min-width:0;max-width:240px}
+.sp-lbl{flex:0 0 240px;display:flex;align-items:center;gap:4px;min-width:0}
.sp-track{flex:1;position:relative;height:14px;min-width:0}
.sp-bar{position:absolute;top:0;height:100%;border-radius:3px;min-width:3px;transition:filter .15s}
.sp-bar.ok{background:linear-gradient(90deg,rgba(52,211,153,.6),var(--emerald))}
From 58c488af2a7a6060ff3d3515e062c1ff72e435a3 Mon Sep 17 00:00:00 2001
From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com>
Date: Mon, 2 Mar 2026 22:23:58 +0000
Subject: [PATCH 13/16] feat: add TracedWebApplicationFactory to auto-inject
tracing handlers
Factory.CreateClient() now automatically includes ActivityPropagationHandler
and TUnitTestIdHandler, so HTTP spans appear in the trace timeline without
needing the separate Client property (removed).
---
.../TracedWebApplicationFactory.cs | 84 +++++++++++++++++++
TUnit.AspNetCore/WebApplicationTest.cs | 45 +++-------
2 files changed, 97 insertions(+), 32 deletions(-)
create mode 100644 TUnit.AspNetCore/TracedWebApplicationFactory.cs
diff --git a/TUnit.AspNetCore/TracedWebApplicationFactory.cs b/TUnit.AspNetCore/TracedWebApplicationFactory.cs
new file mode 100644
index 0000000000..7e209a137b
--- /dev/null
+++ b/TUnit.AspNetCore/TracedWebApplicationFactory.cs
@@ -0,0 +1,84 @@
+using Microsoft.AspNetCore.Mvc.Testing;
+using Microsoft.AspNetCore.TestHost;
+
+namespace TUnit.AspNetCore;
+
+///
+/// Wrapper around that automatically injects
+/// and into all
+/// created instances.
+///
+/// HTTP requests made through clients created by this factory will:
+///
+/// Appear as spans in the HTML report's trace timeline
+/// Propagate W3C traceparent headers for server-side span correlation
+/// Propagate the current test's context ID for log correlation
+///
+///
+///
+/// The entry point class of the web application.
+public sealed class TracedWebApplicationFactory : IAsyncDisposable, IDisposable
+ where TEntryPoint : class
+{
+ private readonly WebApplicationFactory _inner;
+
+ internal TracedWebApplicationFactory(WebApplicationFactory inner)
+ {
+ _inner = inner;
+ }
+
+ ///
+ /// Gets the instance.
+ ///
+ public TestServer Server => _inner.Server;
+
+ ///
+ /// Gets the application's .
+ ///
+ public IServiceProvider Services => _inner.Services;
+
+ ///
+ /// Creates an with activity tracing and test context propagation.
+ ///
+ public HttpClient CreateClient() =>
+ _inner.CreateDefaultClient(new ActivityPropagationHandler(), new TUnitTestIdHandler());
+
+ ///
+ /// Creates an with the specified delegating handlers, plus
+ /// activity tracing and test context propagation (prepended before custom handlers).
+ ///
+ public HttpClient CreateDefaultClient(params DelegatingHandler[] handlers)
+ {
+ var all = new DelegatingHandler[handlers.Length + 2];
+ all[0] = new ActivityPropagationHandler();
+ all[1] = new TUnitTestIdHandler();
+ Array.Copy(handlers, 0, all, 2, handlers.Length);
+ return _inner.CreateDefaultClient(all);
+ }
+
+ ///
+ /// Creates an with the specified base address and delegating handlers,
+ /// plus activity tracing and test context propagation (prepended before custom handlers).
+ ///
+ public HttpClient CreateDefaultClient(Uri baseAddress, params DelegatingHandler[] handlers)
+ {
+ var all = new DelegatingHandler[handlers.Length + 2];
+ all[0] = new ActivityPropagationHandler();
+ all[1] = new TUnitTestIdHandler();
+ Array.Copy(handlers, 0, all, 2, handlers.Length);
+ return _inner.CreateDefaultClient(baseAddress, all);
+ }
+
+ ///
+ /// Gets the underlying for advanced scenarios
+ /// that need direct access (e.g., calling WithWebHostBuilder).
+ /// Clients created from the inner factory will NOT have automatic tracing.
+ ///
+ public WebApplicationFactory Inner => _inner;
+
+ ///
+ public async ValueTask DisposeAsync() => await _inner.DisposeAsync();
+
+ ///
+ public void Dispose() => _inner.Dispose();
+}
diff --git a/TUnit.AspNetCore/WebApplicationTest.cs b/TUnit.AspNetCore/WebApplicationTest.cs
index 16c08f7a69..8512b05431 100644
--- a/TUnit.AspNetCore/WebApplicationTest.cs
+++ b/TUnit.AspNetCore/WebApplicationTest.cs
@@ -1,6 +1,5 @@
using System.ComponentModel;
using Microsoft.AspNetCore.Hosting;
-using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using TUnit.AspNetCore.Interception;
@@ -76,36 +75,20 @@ public abstract class WebApplicationTest : WebApplication
[ClassDataSource(Shared = [SharedType.PerTestSession])]
public TFactory GlobalFactory { get; set; } = null!;
- private WebApplicationFactory? _factory;
- private HttpClient? _tracedClient;
+ private TracedWebApplicationFactory? _factory;
private readonly WebApplicationTestOptions _options = new();
///
- /// Gets the per-test delegating factory. This factory is isolated to the current test.
+ /// Gets the per-test factory, isolated to the current test.
+ /// All calls on this factory
+ /// automatically inject activity tracing and test context propagation handlers.
///
/// Thrown if accessed before test setup.
- public WebApplicationFactory Factory => _factory ?? throw new InvalidOperationException(
+ public TracedWebApplicationFactory Factory => _factory ?? throw new InvalidOperationException(
"Factory is not initialized. Ensure the test has started and the BeforeTest hook has run. " +
"Do not access Factory during test discovery or in data source methods.");
- ///
- /// Gets an HttpClient configured with activity tracing and test context propagation.
- /// HTTP requests made through this client appear as spans in the HTML report's trace timeline,
- /// and the current test's context ID is propagated via HTTP header for server-side log correlation.
- ///
- ///
- ///
- ///
- /// uses an in-memory handler that bypasses .NET's built-in HTTP Activity instrumentation,
- /// so HTTP Activity spans are not emitted. This property adds handlers that fill that gap
- /// and also propagate W3C traceparent headers so server-side ASP.NET Core spans
- /// are correlated to the test's trace.
- ///
- ///
- public HttpClient Client => _tracedClient ??= Factory.CreateDefaultClient(
- new ActivityPropagationHandler(), new TUnitTestIdHandler());
-
///
/// Gets the service provider from the per-test factory.
/// Use this to resolve services for verification or setup.
@@ -127,13 +110,14 @@ public async Task InitializeFactoryAsync(TestContext testContext)
await SetupAsync();
// Then create factory with sync configuration (required by ASP.NET Core hosting)
- _factory = GlobalFactory.GetIsolatedFactory(
- testContext,
- _options,
- ConfigureTestServices,
- ConfigureTestConfiguration,
- (_, config) => ConfigureTestConfiguration(config),
- ConfigureWebHostBuilder);
+ _factory = new TracedWebApplicationFactory(
+ GlobalFactory.GetIsolatedFactory(
+ testContext,
+ _options,
+ ConfigureTestServices,
+ ConfigureTestConfiguration,
+ (_, config) => ConfigureTestConfiguration(config),
+ ConfigureWebHostBuilder));
// Eagerly start the test server to catch configuration errors early
_ = _factory.Server;
@@ -143,9 +127,6 @@ public async Task InitializeFactoryAsync(TestContext testContext)
[EditorBrowsable(EditorBrowsableState.Never)]
public async Task DisposeFactoryAsync()
{
- _tracedClient?.Dispose();
- _tracedClient = null;
-
if (_factory != null)
{
await _factory.DisposeAsync();
From 630748fbe564b49df11749be55789e3529829c0e Mon Sep 17 00:00:00 2001
From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com>
Date: Mon, 2 Mar 2026 22:57:35 +0000
Subject: [PATCH 14/16] fix: review fixes for activity trace collection
- Remove unnecessary span cap rollback in ActivityCollector (soft cap is
intentionally racy; rollback added complexity with no benefit)
- Add IDisposable to HtmlReporter for defensive ActivityCollector cleanup
- Make TracedWebApplicationFactory constructor public for extensibility
---
TUnit.AspNetCore/TracedWebApplicationFactory.cs | 2 +-
TUnit.Engine/Reporters/Html/ActivityCollector.cs | 1 -
TUnit.Engine/Reporters/Html/HtmlReporter.cs | 9 ++++++++-
3 files changed, 9 insertions(+), 3 deletions(-)
diff --git a/TUnit.AspNetCore/TracedWebApplicationFactory.cs b/TUnit.AspNetCore/TracedWebApplicationFactory.cs
index 7e209a137b..990ec1d837 100644
--- a/TUnit.AspNetCore/TracedWebApplicationFactory.cs
+++ b/TUnit.AspNetCore/TracedWebApplicationFactory.cs
@@ -22,7 +22,7 @@ public sealed class TracedWebApplicationFactory : IAsyncDisposable,
{
private readonly WebApplicationFactory _inner;
- internal TracedWebApplicationFactory(WebApplicationFactory inner)
+ public TracedWebApplicationFactory(WebApplicationFactory inner)
{
_inner = inner;
}
diff --git a/TUnit.Engine/Reporters/Html/ActivityCollector.cs b/TUnit.Engine/Reporters/Html/ActivityCollector.cs
index 1c3ffd536b..9f33de8a55 100644
--- a/TUnit.Engine/Reporters/Html/ActivityCollector.cs
+++ b/TUnit.Engine/Reporters/Html/ActivityCollector.cs
@@ -214,7 +214,6 @@ private void OnActivityStopped(Activity activity)
var count = _externalSpanCountsByTest.AddOrUpdate(testSpanId, 1, (_, c) => c + 1);
if (count > MaxExternalSpansPerTest)
{
- _externalSpanCountsByTest.AddOrUpdate(testSpanId, 0, (_, c) => Math.Max(0, c - 1));
return;
}
}
diff --git a/TUnit.Engine/Reporters/Html/HtmlReporter.cs b/TUnit.Engine/Reporters/Html/HtmlReporter.cs
index 3b58286c4a..ea800c983c 100644
--- a/TUnit.Engine/Reporters/Html/HtmlReporter.cs
+++ b/TUnit.Engine/Reporters/Html/HtmlReporter.cs
@@ -16,7 +16,7 @@
namespace TUnit.Engine.Reporters.Html;
-internal sealed class HtmlReporter(IExtension extension) : IDataConsumer, ITestHostApplicationLifetime, IFilterReceiver
+internal sealed class HtmlReporter(IExtension extension) : IDataConsumer, ITestHostApplicationLifetime, IFilterReceiver, IDisposable
{
private string? _outputPath;
private readonly ConcurrentDictionary> _updates = [];
@@ -108,6 +108,13 @@ public async Task AfterRunAsync(int exitCode, CancellationToken cancellation)
}
}
+ public void Dispose()
+ {
+#if NET
+ _activityCollector?.Dispose();
+#endif
+ }
+
public string? Filter { get; set; }
internal void SetOutputPath(string path)
From 6e39f88728bdbc370c01113591c80199bacc2d88 Mon Sep 17 00:00:00 2001
From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com>
Date: Mon, 2 Mar 2026 23:17:21 +0000
Subject: [PATCH 15/16] fix: use tag-based test case detection and update stale
docs
- FindTestCaseAncestor now checks for tunit.test.node_uid tag instead of
matching on DisplayName string, making it robust against activity renaming
- Update html-report.md to document current per-test external span cap (100)
instead of removed per-trace/total caps
---
TUnit.Engine/Reporters/Html/ActivityCollector.cs | 2 +-
docs/docs/guides/html-report.md | 2 +-
2 files changed, 2 insertions(+), 2 deletions(-)
diff --git a/TUnit.Engine/Reporters/Html/ActivityCollector.cs b/TUnit.Engine/Reporters/Html/ActivityCollector.cs
index 9f33de8a55..2d0888dd4b 100644
--- a/TUnit.Engine/Reporters/Html/ActivityCollector.cs
+++ b/TUnit.Engine/Reporters/Html/ActivityCollector.cs
@@ -147,7 +147,7 @@ public SpanData[] GetAllSpans()
while (current is not null)
{
if (IsTUnitSource(current.Source.Name) &&
- current.DisplayName.StartsWith("test case", StringComparison.Ordinal))
+ current.GetTagItem("tunit.test.node_uid") is not null)
{
return current.SpanId.ToString();
}
diff --git a/docs/docs/guides/html-report.md b/docs/docs/guides/html-report.md
index fd42a38f61..570c2da873 100644
--- a/docs/docs/guides/html-report.md
+++ b/docs/docs/guides/html-report.md
@@ -192,7 +192,7 @@ This is useful when calling libraries that don't automatically propagate `Activi
### Overhead
-The collector uses **smart sampling**: spans from known test traces are fully recorded, while unrelated activities receive only `PropagationData` (near-zero cost — no timing or tags collected). The existing caps (1,000 spans per trace, 50,000 total) apply only to collected spans.
+The collector uses **smart sampling**: spans from known test traces are fully recorded, while unrelated activities receive only `PropagationData` (near-zero cost — no timing or tags collected). TUnit's own spans are always captured. External spans (HttpClient, ASP.NET Core, etc.) are capped at 100 per test to keep the report size manageable.
## Troubleshooting
From 96b3e203940481de8dc7f63b86a4feda80f2ac7d Mon Sep 17 00:00:00 2001
From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com>
Date: Mon, 2 Mar 2026 23:37:01 +0000
Subject: [PATCH 16/16] fix: wrap TraceRegistry.Clear in finally block
Ensures stale trace registrations are cleaned up even if BuildReportData
throws. Also documents the static ActivitySource as intentionally
process-scoped.
---
TUnit.AspNetCore/Http/ActivityPropagationHandler.cs | 3 +++
TUnit.Engine/Reporters/Html/HtmlReporter.cs | 12 ++++++++++--
2 files changed, 13 insertions(+), 2 deletions(-)
diff --git a/TUnit.AspNetCore/Http/ActivityPropagationHandler.cs b/TUnit.AspNetCore/Http/ActivityPropagationHandler.cs
index 4666730a05..6537638dcc 100644
--- a/TUnit.AspNetCore/Http/ActivityPropagationHandler.cs
+++ b/TUnit.AspNetCore/Http/ActivityPropagationHandler.cs
@@ -12,6 +12,9 @@ namespace TUnit.AspNetCore;
///
internal sealed class ActivityPropagationHandler : DelegatingHandler
{
+ // Intentionally process-scoped: lives for the test process lifetime and is
+ // cleaned up on process exit. Not disposed explicitly because multiple handler
+ // instances share this source across concurrent tests.
private static readonly ActivitySource HttpActivitySource = new("TUnit.AspNetCore.Http");
protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
diff --git a/TUnit.Engine/Reporters/Html/HtmlReporter.cs b/TUnit.Engine/Reporters/Html/HtmlReporter.cs
index ea800c983c..8f07c6cb38 100644
--- a/TUnit.Engine/Reporters/Html/HtmlReporter.cs
+++ b/TUnit.Engine/Reporters/Html/HtmlReporter.cs
@@ -86,10 +86,18 @@ public async Task AfterRunAsync(int exitCode, CancellationToken cancellation)
return;
}
- var reportData = BuildReportData();
+ ReportData reportData;
+ try
+ {
+ reportData = BuildReportData();
+ }
+ finally
+ {
#if NET
- TraceRegistry.Clear();
+ TraceRegistry.Clear();
#endif
+ }
+
var html = HtmlReportGenerator.GenerateHtml(reportData);
if (string.IsNullOrEmpty(html))