Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
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
25 changes: 25 additions & 0 deletions TUnit.Core/TestContext.cs
Original file line number Diff line number Diff line change
Expand Up @@ -257,6 +257,31 @@ internal override void SetAsyncLocalContext()
/// </summary>
public object Lock { get; } = new();

#if NET
/// <summary>
/// Gets the <see cref="System.Diagnostics.Activity"/> associated with this test's execution,
/// or <c>null</c> if no activity is active.
/// Use <c>Activity.Context</c> to parent external work (e.g., HttpClient calls) under this test's trace.
/// </summary>
public new System.Diagnostics.Activity? Activity
{
get => base.Activity;
internal set => base.Activity = value;
}

/// <summary>
/// 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.
/// </summary>
/// <param name="traceId">The trace ID of the external trace to associate with this test.</param>
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

internal IClassConstructor? ClassConstructor => _testBuilderContext.ClassConstructor;

Expand Down
60 changes: 60 additions & 0 deletions TUnit.Core/TraceRegistry.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
#if NET
using System.Collections.Concurrent;

namespace TUnit.Core;

/// <summary>
/// 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.
/// </summary>
internal static class TraceRegistry
{
// traceId → testNodeUids (uses ConcurrentDictionary as a set to prevent duplicates)
private static readonly ConcurrentDictionary<string, ConcurrentDictionary<string, byte>> TraceToTests =
new(StringComparer.OrdinalIgnoreCase);

// testNodeUid → traceIds (uses ConcurrentDictionary as a set to prevent duplicates)
private static readonly ConcurrentDictionary<string, ConcurrentDictionary<string, byte>> TestToTraces =
new(StringComparer.OrdinalIgnoreCase);

/// <summary>
/// Registers a trace ID as associated with a test node UID.
/// Called by <see cref="TestContext.RegisterTrace"/>.
/// </summary>
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);
}

/// <summary>
/// Returns <c>true</c> if the given trace ID has been registered by any test.
/// Used by ActivityCollector's sampling callback.
/// </summary>
internal static bool IsRegistered(string traceId)
{
return TraceToTests.ContainsKey(traceId);
}

/// <summary>
/// Gets all trace IDs registered for the given test node UID.
/// Used by HtmlReporter to populate additional trace IDs on test results.
/// </summary>
internal static string[] GetTraceIds(string testNodeUid)
{
return TestToTraces.TryGetValue(testNodeUid, out var set)
? set.Keys.ToArray()
: [];
}

/// <summary>
/// Clears all registered trace associations. Called at the end of a test run.
/// </summary>
internal static void Clear()
{
TraceToTests.Clear();
TestToTraces.Clear();
}
}
#endif
97 changes: 90 additions & 7 deletions TUnit.Engine/Reporters/Html/ActivityCollector.cs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
#if NET
using System.Collections.Concurrent;
using System.Diagnostics;
using TUnit.Core;

namespace TUnit.Engine.Reporters.Html;

Expand All @@ -12,22 +13,91 @@ internal sealed class ActivityCollector : IDisposable

private readonly ConcurrentDictionary<string, ConcurrentQueue<SpanData>> _spansByTrace = new();
private readonly ConcurrentDictionary<string, int> _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<string, byte> _knownTraceIds = new(StringComparer.OrdinalIgnoreCase);
private ActivityListener? _listener;
private int _totalSpanCount;

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 source => IsTUnitSource(source),
Sample = static (ref ActivityCreationOptions<ActivityContext> _) => ActivitySamplingResult.AllDataAndRecorded,
SampleUsingParentId = static (ref ActivityCreationOptions<string> _) => ActivitySamplingResult.AllDataAndRecorded,
ShouldListenTo = static _ => true,
Sample = SampleActivity,
SampleUsingParentId = SampleActivityUsingParentId,
ActivityStopped = OnActivityStopped
};

ActivitySource.AddActivityListener(_listener);
}

private ActivitySamplingResult SampleActivity(ref ActivityCreationOptions<ActivityContext> 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<string> options)
{
if (IsTUnitSource(options.Source.Name))
{
return ActivitySamplingResult.AllDataAndRecorded;
}

// 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;
}

public void Stop()
{
_listener?.Dispose();
Expand Down Expand Up @@ -70,9 +140,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)
{
Expand Down Expand Up @@ -101,14 +171,27 @@ private static string EnrichSpanName(Activity activity)

private void OnActivityStopped(Activity activity)
{
var traceId = activity.TraceId.ToString();

// 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;
}

var newTotal = Interlocked.Increment(ref _totalSpanCount);
if (newTotal > MaxTotalSpans)
{
Interlocked.Decrement(ref _totalSpanCount);
return;
}

var traceId = activity.TraceId.ToString();
var traceCount = _spanCountsByTrace.AddOrUpdate(traceId, 1, (_, c) => c + 1);
if (traceCount > MaxSpansPerTrace)
{
Expand Down
3 changes: 3 additions & 0 deletions TUnit.Engine/Reporters/Html/HtmlReportDataModel.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
18 changes: 18 additions & 0 deletions TUnit.Engine/Reporters/Html/HtmlReportGenerator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -1287,6 +1287,11 @@ function renderDetail(t) {
h += '</span></div>';
}
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;
}

Expand Down Expand Up @@ -1364,6 +1369,19 @@ function renderTrace(tid, rootSpanId) {
return '<div class="d-sec"><div class="d-lbl">Trace Timeline</div>' + renderSpanRows(sp, 't-' + rootSpanId) + '</div>';
}

// 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 '<div class="d-sec"><div class="d-lbl">Linked Trace: ' + esc(topSrc) + '</div>' + renderSpanRows(sp, 'ext-' + tid) + '</div>';
}

const tlArrow = '<svg class="tl-arrow" width="12" height="12" viewBox="0 0 16 16" fill="currentColor"><path d="M6.22 4.22a.75.75 0 0 1 1.06 0l3.25 3.25a.75.75 0 0 1 0 1.06l-3.25 3.25a.75.75 0 0 1-1.06-1.06L8.94 8 6.22 5.28a.75.75 0 0 1 0-1.06Z"/></svg>';

// Suite-level trace: test suite span + non-test-case children (hooks, setup, teardown)
Expand Down
21 changes: 18 additions & 3 deletions TUnit.Engine/Reporters/Html/HtmlReporter.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -79,10 +80,16 @@ public async Task AfterRunAsync(int exitCode, CancellationToken cancellation)

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))
Expand Down Expand Up @@ -184,7 +191,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);

Expand Down Expand Up @@ -337,7 +351,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;
Expand Down Expand Up @@ -417,7 +431,8 @@ private static ReportTestResult ExtractTestResult(string testId, TestNode testNo
SkipReason = skipReason,
RetryAttempt = retryAttempt,
TraceId = traceId,
SpanId = spanId
SpanId = spanId,
AdditionalTraceIds = additionalTraceIds
};
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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; }
Expand All @@ -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 : .
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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; }
Expand All @@ -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 : .
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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; }
Expand All @@ -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 : .
Expand Down
6 changes: 6 additions & 0 deletions docs/docs/examples/opentelemetry.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Loading
Loading