diff --git a/TUnit.Core/TraceRegistry.cs b/TUnit.Core/TraceRegistry.cs index 7259429f7b..b7fae450cd 100644 --- a/TUnit.Core/TraceRegistry.cs +++ b/TUnit.Core/TraceRegistry.cs @@ -63,6 +63,43 @@ internal static bool IsRegistered(string traceId) return TraceToContextId.GetValueOrDefault(traceId); } + /// + /// Associates with the same test(s) as + /// . Useful for messaging/queue consumers that start + /// a new trace but keep a causal link to the original test trace via OTEL span links. + /// + /// + /// true when the derived trace was associated with at least one test from the + /// source trace, or when both trace IDs are the same and the source trace is already + /// registered; otherwise, false. + /// + internal static bool TryRegisterDerivedTrace(string derivedTraceId, string sourceTraceId) + { + // Fast path: if both IDs are the same we only need to report whether the source + // trace is already registered — no dictionary updates required. + if (string.Equals(derivedTraceId, sourceTraceId, StringComparison.OrdinalIgnoreCase)) + { + return IsRegistered(sourceTraceId); + } + + if (!TraceToTests.TryGetValue(sourceTraceId, out var testNodeUids)) + { + return false; + } + + foreach (var testNodeUid in testNodeUids) + { + Register(derivedTraceId, testNodeUid.Key); + } + + if (TraceToContextId.TryGetValue(sourceTraceId, out var contextId)) + { + TraceToContextId.TryAdd(derivedTraceId, contextId); + } + + return true; + } + /// /// Gets all trace IDs registered for the given test node UID. /// Used by HtmlReporter to populate additional trace IDs on test results. diff --git a/TUnit.Engine.Tests/HtmlReporterTests.cs b/TUnit.Engine.Tests/HtmlReporterTests.cs index c94041b47a..3fb122e414 100644 --- a/TUnit.Engine.Tests/HtmlReporterTests.cs +++ b/TUnit.Engine.Tests/HtmlReporterTests.cs @@ -112,6 +112,38 @@ public void FilterAdditionalTraceIds_Returns_Empty_When_Only_Primary() result.ShouldBeEmpty(); } + [Test] + public void OrderTestsForDisplay_SortsByStartTime_ThenName() + { + var later = CreateTestResultWithStartTime("Later", "2026-05-07T09:26:25.0000000Z"); + var earlier = CreateTestResultWithStartTime("Earlier", "2026-05-07T09:26:24.0000000Z"); + var sameTimeButLaterName = CreateTestResultWithStartTime("Zeta", "2026-05-07T09:26:24.0000000Z"); + + var ordered = HtmlReporter.OrderTestsForDisplay([later, sameTimeButLaterName, earlier]); + + ordered.Select(static test => test.DisplayName).ShouldBe(["Earlier", "Zeta", "Later"]); + } + + [Test] + public void GenerateHtml_Includes_ClassTimeline_TestCaseRendering() + { + var html = HtmlReportGenerator.GenerateHtml(new ReportData + { + AssemblyName = "Tests", + MachineName = "machine", + Timestamp = "2026-05-07T09:26:24.0000000Z", + TUnitVersion = "1.0.0", + OperatingSystem = "Linux", + RuntimeVersion = ".NET 10.0", + TotalDurationMs = 0, + Summary = new ReportSummary(), + Groups = [], + }); + + html.ShouldContain("function collapseTestBodySpans(spans)"); + html.ShouldContain("const filtered = collapseTestBodySpans(getDescendants(allSpans, suite.spanId));"); + } + [Test] public async Task PublishArtifactAsync_Publishes_With_Correct_SessionUid() { @@ -133,4 +165,17 @@ public async Task PublishArtifactAsync_Publishes_With_Correct_SessionUid() File.Delete(tempFile); } } + + private static ReportTestResult CreateTestResultWithStartTime(string displayName, string? startTime) => new() + { + Id = displayName, + DisplayName = displayName, + MethodName = displayName, + ClassName = "SampleTests", + Status = "passed", + DurationMs = 1, + StartTime = startTime, + EndTime = startTime, + RetryAttempt = 0, + }; } diff --git a/TUnit.Engine/Reporters/Html/ActivityCollector.cs b/TUnit.Engine/Reporters/Html/ActivityCollector.cs index f57ee760a2..e233d07aa4 100644 --- a/TUnit.Engine/Reporters/Html/ActivityCollector.cs +++ b/TUnit.Engine/Reporters/Html/ActivityCollector.cs @@ -186,7 +186,12 @@ internal void IngestExternalSpan(SpanData span) { if (!_knownTraceIds.ContainsKey(span.TraceId)) { - return; + if (!TraceRegistry.IsRegistered(span.TraceId)) + { + return; + } + + _knownTraceIds.TryAdd(span.TraceId, 0); } // Prefer per-test cap when the span's direct parent is a known test case span. diff --git a/TUnit.Engine/Reporters/Html/HtmlReportGenerator.cs b/TUnit.Engine/Reporters/Html/HtmlReportGenerator.cs index 7eea57d877..cac409ea9a 100644 --- a/TUnit.Engine/Reporters/Html/HtmlReportGenerator.cs +++ b/TUnit.Engine/Reporters/Html/HtmlReportGenerator.cs @@ -1610,6 +1610,28 @@ function walk(sid) { return traceSpans.filter(s => included.has(s.spanId)); } +function collapseTestBodySpans(spans) { + if (!spans || !spans.length) return []; + const byId = {}; + spans.forEach(function(s) { byId[s.spanId] = s; }); + const testBodyIds = new Set( + spans + .filter(function(s) { return s.name === 'test body'; }) + .map(function(s) { return s.spanId; }) + ); + if (!testBodyIds.size) return spans; + return spans + .filter(function(s) { return !testBodyIds.has(s.spanId); }) + .map(function(s) { + if (!s.parentSpanId || !testBodyIds.has(s.parentSpanId)) return s; + const testBody = byId[s.parentSpanId]; + return { + ...s, + parentSpanId: testBody && testBody.parentSpanId ? testBody.parentSpanId : null + }; + }); +} + // Render a span waterfall from a filtered list of spans function renderSpanRows(sp, uid) { if (!sp || !sp.length) return ''; @@ -1653,14 +1675,8 @@ function gd(s) { function renderTrace(tid, rootSpanId) { const allSpans = spansByTrace[tid]; if (!allSpans || !allSpans.length) return ''; - let sp = getDescendants(allSpans, rootSpanId); + let sp = collapseTestBodySpans(getDescendants(allSpans, rootSpanId)); if (!sp.length) return ''; - // 'test body' must match TUnitActivitySource.SpanTestBody in C# - const directChildren = sp.filter(s => s.parentSpanId === rootSpanId); - if (directChildren.length === 1 && directChildren[0].name === 'test body') { - const tbId = directChildren[0].spanId; - sp = sp.filter(s => s.spanId !== tbId).map(s => s.parentSpanId === tbId ? {...s, parentSpanId: rootSpanId} : s); - } if (sp.length <= 1) return ''; return '
Trace Timeline
' + renderSpanRows(sp, 't-' + rootSpanId) + '
'; } @@ -1719,12 +1735,7 @@ function renderSuiteTrace(className) { if (!suite) return ''; const allSpans = spansByTrace[suite.traceId]; if (!allSpans) return ''; - const all = getDescendants(allSpans, suite.spanId); - const testCaseIds = new Set(); - all.forEach(s => { if (s.spanType === 'test case') testCaseIds.add(s.spanId); }); - const tcDescendants = new Set(); - testCaseIds.forEach(id => { getDescendants(all, id).forEach(s => { if (s.spanId !== id) tcDescendants.add(s.spanId); }); }); - const filtered = all.filter(s => !tcDescendants.has(s.spanId) && !testCaseIds.has(s.spanId)); + const filtered = collapseTestBodySpans(getDescendants(allSpans, suite.spanId)); // Include parent spans (assembly, session) for context let ancestor = suite.parentSpanId ? bySpanId[suite.parentSpanId] : null; while (ancestor) { diff --git a/TUnit.Engine/Reporters/Html/HtmlReporter.cs b/TUnit.Engine/Reporters/Html/HtmlReporter.cs index 5c3213503f..7efe9b4dca 100644 --- a/TUnit.Engine/Reporters/Html/HtmlReporter.cs +++ b/TUnit.Engine/Reporters/Html/HtmlReporter.cs @@ -336,7 +336,7 @@ private ReportData BuildReportData() ClassName = kvp.Key, Namespace = groupNamespaces.GetValueOrDefault(kvp.Key, ""), Summary = groupSummary, - Tests = kvp.Value.ToArray() + Tests = OrderTestsForDisplay(kvp.Value) }; } @@ -465,6 +465,15 @@ internal static string[] FilterAdditionalTraceIds(string[] allTraceIds, string? } #endif + internal static ReportTestResult[] OrderTestsForDisplay(IEnumerable tests) + { + return tests + .OrderBy(static test => test.StartTime is null ? 1 : 0) + .ThenBy(static test => test.StartTime, StringComparer.Ordinal) + .ThenBy(static test => test.DisplayName, StringComparer.Ordinal) + .ToArray(); + } + private static ReportTestResult ExtractTestResult(string testId, TestNode testNode, string? traceId, string? spanId, int retryAttempt, string[]? additionalTraceIds) { IProperty? stateProperty = null; diff --git a/TUnit.OpenTelemetry.Tests/OtlpReceiverIngestionTests.cs b/TUnit.OpenTelemetry.Tests/OtlpReceiverIngestionTests.cs index 22ea4c078c..da76581d55 100644 --- a/TUnit.OpenTelemetry.Tests/OtlpReceiverIngestionTests.cs +++ b/TUnit.OpenTelemetry.Tests/OtlpReceiverIngestionTests.cs @@ -11,6 +11,11 @@ namespace TUnit.OpenTelemetry.Tests; public class OtlpReceiverIngestionTests { + private const ulong SpanKindConsumer = 5; // OTLP SpanKind.CONSUMER + private const string TestSpanId = "0123456789abcdef"; + private const ulong TestStartTimeUnixNano = 1; + private const ulong TestEndTimeUnixNano = 2; + [Test] public async Task Receiver_ParsedTrace_ReachesActivityCollector() { @@ -47,4 +52,141 @@ public async Task Receiver_ParsedTrace_ReachesActivityCollector() await Assert.That(span).IsNotNull(); } + + [Test] + public async Task Receiver_ParsedLinkedTrace_RegistersAgainstOwningTest() + { + var collector = ActivityCollector.Current; + await Assert.That(collector).IsNotNull(); + + await using var receiver = new OtlpReceiver(); + receiver.Start(); + + var linkedContext = Activity.Current!.Context; + var derivedTraceId = Guid.NewGuid().ToString("N"); + var body = BuildLinkedTraceExportRequest( + derivedTraceId, + TestSpanId, + "sut-linked-op", + linkedContext.TraceId.ToString(), + linkedContext.SpanId.ToString()); + + using var client = new HttpClient(); + using var content = new ByteArrayContent(body); + content.Headers.ContentType = new("application/x-protobuf"); + var response = await client.PostAsync($"http://127.0.0.1:{receiver.Port}/v1/traces", content); + + await Assert.That(response.IsSuccessStatusCode).IsTrue(); + await receiver.WhenIdle(); + + var span = collector!.GetAllSpans().FirstOrDefault(s => + s.TraceId == derivedTraceId && s.Name == "sut-linked-op"); + + await Assert.That(span).IsNotNull(); + await Assert.That(TraceRegistry.IsRegistered(derivedTraceId)).IsTrue(); + await Assert.That(TraceRegistry.GetContextId(derivedTraceId)).IsEqualTo(TestContext.Current!.Id); + } + + private static byte[] BuildLinkedTraceExportRequest( + string derivedTraceId, + string derivedSpanId, + string spanName, + string sourceTraceId, + string sourceSpanId) + { + using var exportStream = new MemoryStream(); + var resourceSpans = BuildResourceSpans( + BuildScopeSpans( + BuildSpan(derivedTraceId, derivedSpanId, spanName, sourceTraceId, sourceSpanId))); + WriteField(exportStream, 1, resourceSpans); + return exportStream.ToArray(); + } + + private static byte[] BuildResourceSpans(byte[] scopeSpans) + { + using var stream = new MemoryStream(); + WriteField(stream, 2, scopeSpans); + return stream.ToArray(); + } + + private static byte[] BuildScopeSpans(byte[] span) + { + using var stream = new MemoryStream(); + WriteField(stream, 2, span); + return stream.ToArray(); + } + + private static byte[] BuildSpan( + string derivedTraceId, + string derivedSpanId, + string spanName, + string sourceTraceId, + string sourceSpanId) + { + using var stream = new MemoryStream(); + WriteField(stream, 1, Convert.FromHexString(derivedTraceId)); + WriteField(stream, 2, Convert.FromHexString(derivedSpanId)); + WriteStringField(stream, 5, spanName); + WriteVarintField(stream, 6, SpanKindConsumer); + WriteFixed64Field(stream, 7, TestStartTimeUnixNano); // start_time_unix_nano + WriteFixed64Field(stream, 8, TestEndTimeUnixNano); // end_time_unix_nano + WriteField(stream, 13, BuildSpanLink(sourceTraceId, sourceSpanId)); + return stream.ToArray(); + } + + private static byte[] BuildSpanLink(string traceId, string spanId) + { + using var stream = new MemoryStream(); + WriteField(stream, 1, Convert.FromHexString(traceId)); + WriteField(stream, 2, Convert.FromHexString(spanId)); + return stream.ToArray(); + } + + private static void WriteStringField(MemoryStream stream, int fieldNumber, string value) + { + WriteField(stream, fieldNumber, System.Text.Encoding.UTF8.GetBytes(value)); + } + + private static void WriteVarintField(MemoryStream stream, int fieldNumber, ulong value) + { + WriteTag(stream, fieldNumber, 0); + WriteVarint(stream, value); + } + + private static void WriteFixed64Field(MemoryStream stream, int fieldNumber, ulong value) + { + WriteTag(stream, fieldNumber, 1); + stream.Write(BitConverter.GetBytes(value)); + } + + private static void WriteField(MemoryStream stream, int fieldNumber, byte[] value) + { + WriteTag(stream, fieldNumber, 2); + WriteVarint(stream, (ulong)value.Length); + stream.Write(value); + } + + private static void WriteTag(MemoryStream stream, int fieldNumber, int wireType) + { + WriteVarint(stream, (ulong)((fieldNumber << 3) | wireType)); + } + + private static void WriteVarint(MemoryStream stream, ulong value) + { + // Manual protobuf encoding keeps this regression test self-contained and avoids + // introducing a protobuf dependency just to build one OTLP payload. + // Standard protobuf wire-format varint encoding: integers are emitted in + // 7-bit chunks and the high bit marks that another byte follows. + do + { + var current = (byte)(value & 0x7F); + value >>= 7; + if (value != 0) + { + current |= 0x80; + } + + stream.WriteByte(current); + } while (value != 0); + } } diff --git a/TUnit.OpenTelemetry/Receiver/OtlpReceiver.cs b/TUnit.OpenTelemetry/Receiver/OtlpReceiver.cs index 2f26ce7f4b..ab12d2763c 100644 --- a/TUnit.OpenTelemetry/Receiver/OtlpReceiver.cs +++ b/TUnit.OpenTelemetry/Receiver/OtlpReceiver.cs @@ -230,10 +230,27 @@ private static void ProcessTraces(byte[] body) foreach (var span in spans) { + RegisterDerivedTrace(span); sink(ToSpanData(span)); } } + private static void RegisterDerivedTrace(OtlpSpanRecord span) + { + if (TraceRegistry.IsRegistered(span.TraceId)) + { + return; + } + + foreach (var link in span.Links) + { + if (TraceRegistry.TryRegisterDerivedTrace(span.TraceId, link.TraceId)) + { + return; + } + } + } + private static SpanData ToSpanData(OtlpSpanRecord span) { ReportKeyValue[]? tags = null;