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 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..6537638dcc --- /dev/null +++ b/TUnit.AspNetCore/Http/ActivityPropagationHandler.cs @@ -0,0 +1,59 @@ +using System.Diagnostics; +using System.Net.Http.Headers; + +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 +{ + // 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) + { + 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 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); + + 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/TracedWebApplicationFactory.cs b/TUnit.AspNetCore/TracedWebApplicationFactory.cs new file mode 100644 index 0000000000..990ec1d837 --- /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; + + public 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 15b43fa317..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,15 +75,17 @@ public abstract class WebApplicationTest : WebApplication [ClassDataSource(Shared = [SharedType.PerTestSession])] public TFactory GlobalFactory { get; set; } = null!; - private WebApplicationFactory? _factory; + 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."); @@ -109,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; diff --git a/TUnit.Core/TestContext.cs b/TUnit.Core/TestContext.cs index 50cd62cadb..770565e145 100644 --- a/TUnit.Core/TestContext.cs +++ b/TUnit.Core/TestContext.cs @@ -257,6 +257,31 @@ 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) + { + // 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; diff --git a/TUnit.Core/TraceRegistry.cs b/TUnit.Core/TraceRegistry.cs new file mode 100644 index 0000000000..1da1c2036a --- /dev/null +++ b/TUnit.Core/TraceRegistry.cs @@ -0,0 +1,60 @@ +#if NET +using System.Collections.Concurrent; + +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. +/// +internal 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. + /// + internal 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. + /// + 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 ce395525de..2d0888dd4b 100644 --- a/TUnit.Engine/Reporters/Html/ActivityCollector.cs +++ b/TUnit.Engine/Reporters/Html/ActivityCollector.cs @@ -1,33 +1,104 @@ #if NET using System.Collections.Concurrent; using System.Diagnostics; +using TUnit.Core; 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() { + // 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 _) => 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) + { + 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(); @@ -70,9 +141,26 @@ 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 string? FindTestCaseAncestor(Activity activity) + { + var current = activity.Parent; + while (current is not null) + { + if (IsTUnitSource(current.Source.Name) && + current.GetTagItem("tunit.test.node_uid") is not null) + { + 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); private static string EnrichSpanName(Activity activity) { @@ -101,20 +189,35 @@ private static string EnrichSpanName(Activity activity) private void OnActivityStopped(Activity activity) { - var newTotal = Interlocked.Increment(ref _totalSpanCount); - if (newTotal > MaxTotalSpans) + 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 (isTUnit) + { + _knownTraceIds.TryAdd(traceId, 0); + } + else if (!_knownTraceIds.ContainsKey(traceId)) { - Interlocked.Decrement(ref _totalSpanCount); return; } - var traceId = activity.TraceId.ToString(); - var traceCount = _spanCountsByTrace.AddOrUpdate(traceId, 1, (_, c) => c + 1); - if (traceCount > MaxSpansPerTrace) + // 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) { - Interlocked.Decrement(ref _totalSpanCount); - _spanCountsByTrace.AddOrUpdate(traceId, 0, (_, c) => Math.Max(0, c - 1)); - return; + var testSpanId = FindTestCaseAncestor(activity); + if (testSpanId is not null) + { + var count = _externalSpanCountsByTest.AddOrUpdate(testSpanId, 1, (_, c) => c + 1); + if (count > MaxExternalSpansPerTest) + { + return; + } + } + // External spans not under any test (e.g., fixture/infrastructure setup) are uncapped } var queue = _spansByTrace.GetOrAdd(traceId, _ => new ConcurrentQueue()); 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..3742ecd113 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 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))} .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); @@ -1287,6 +1288,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; } @@ -1331,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) + ' '; }); } @@ -1364,6 +1371,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..8f07c6cb38 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; @@ -15,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 = []; @@ -79,10 +80,24 @@ public async Task AfterRunAsync(int exitCode, CancellationToken cancellation) if (_updates.Count == 0) { +#if NET + TraceRegistry.Clear(); +#endif return; } - var reportData = BuildReportData(); + ReportData reportData; + try + { + reportData = BuildReportData(); + } + finally + { +#if NET + TraceRegistry.Clear(); +#endif + } + var html = HtmlReportGenerator.GenerateHtml(reportData); if (string.IsNullOrEmpty(html)) @@ -101,6 +116,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) @@ -184,7 +206,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 +366,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 +446,8 @@ private static ReportTestResult ExtractTestResult(string testId, TestNode testNo SkipReason = skipReason, RetryAttempt = retryAttempt, TraceId = traceId, - SpanId = spanId + SpanId = spanId, + AdditionalTraceIds = additionalTraceIds }; } 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 : . 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..570c2da873 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). 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 ### 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