Skip to content
37 changes: 37 additions & 0 deletions TUnit.Core/TraceRegistry.cs
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,43 @@ internal static bool IsRegistered(string traceId)
return TraceToContextId.GetValueOrDefault(traceId);
}

/// <summary>
/// Associates <paramref name="derivedTraceId"/> with the same test(s) as
/// <paramref name="sourceTraceId"/>. Useful for messaging/queue consumers that start
/// a new trace but keep a causal link to the original test trace via OTEL span links.
/// </summary>
/// <returns>
/// <c>true</c> 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, <c>false</c>.
/// </returns>
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;
}

/// <summary>
/// Gets all trace IDs registered for the given test node UID.
/// Used by HtmlReporter to populate additional trace IDs on test results.
Expand Down
45 changes: 45 additions & 0 deletions TUnit.Engine.Tests/HtmlReporterTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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()
{
Expand All @@ -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,
};
}
7 changes: 6 additions & 1 deletion TUnit.Engine/Reporters/Html/ActivityCollector.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
37 changes: 24 additions & 13 deletions TUnit.Engine/Reporters/Html/HtmlReportGenerator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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 '';
Expand Down Expand Up @@ -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 '<div class="d-sec"><div class="d-lbl">Trace Timeline</div>' + renderSpanRows(sp, 't-' + rootSpanId) + '</div>';
}
Expand Down Expand Up @@ -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) {
Expand Down
11 changes: 10 additions & 1 deletion TUnit.Engine/Reporters/Html/HtmlReporter.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
};
}

Expand Down Expand Up @@ -465,6 +465,15 @@ internal static string[] FilterAdditionalTraceIds(string[] allTraceIds, string?
}
#endif

internal static ReportTestResult[] OrderTestsForDisplay(IEnumerable<ReportTestResult> 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;
Expand Down
142 changes: 142 additions & 0 deletions TUnit.OpenTelemetry.Tests/OtlpReceiverIngestionTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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()
{
Expand Down Expand Up @@ -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);
}
}
17 changes: 17 additions & 0 deletions TUnit.OpenTelemetry/Receiver/OtlpReceiver.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Loading