From c2e92788b4cb09289f95e28b63b9019db343ce09 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Thu, 7 May 2026 11:01:55 +0000
Subject: [PATCH 01/18] Initial plan
From 46b34f053621475e884c7da298fb47a408b4d59b Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Thu, 7 May 2026 11:20:04 +0000
Subject: [PATCH 02/18] Fix HTML trace ordering for multi-step tests
Agent-Logs-Url: https://github.com/thomhurst/TUnit/sessions/48d47cff-3125-44ff-b19b-e1dfe712be9f
Co-authored-by: thomhurst <30480171+thomhurst@users.noreply.github.com>
---
TUnit.Engine.Tests/HtmlReporterTests.cs | 45 +++++++++++++++++++
.../Reporters/Html/HtmlReportGenerator.cs | 37 +++++++++------
TUnit.Engine/Reporters/Html/HtmlReporter.cs | 11 ++++-
3 files changed, 79 insertions(+), 14 deletions(-)
diff --git a/TUnit.Engine.Tests/HtmlReporterTests.cs b/TUnit.Engine.Tests/HtmlReporterTests.cs
index c94041b47a..e7b02c16b1 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 = CreateReportTestResult("Later", "2026-05-07T09:26:25.0000000Z");
+ var earlier = CreateReportTestResult("Earlier", "2026-05-07T09:26:24.0000000Z");
+ var sameTimeButLaterName = CreateReportTestResult("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 CreateReportTestResult(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/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;
From 7a3a1ba2176b41aa3ff52d332153034883f3fd93 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Thu, 7 May 2026 11:22:16 +0000
Subject: [PATCH 03/18] Polish HtmlReporter regression tests
Agent-Logs-Url: https://github.com/thomhurst/TUnit/sessions/48d47cff-3125-44ff-b19b-e1dfe712be9f
Co-authored-by: thomhurst <30480171+thomhurst@users.noreply.github.com>
---
TUnit.Engine.Tests/HtmlReporterTests.cs | 8 ++++----
1 file changed, 4 insertions(+), 4 deletions(-)
diff --git a/TUnit.Engine.Tests/HtmlReporterTests.cs b/TUnit.Engine.Tests/HtmlReporterTests.cs
index e7b02c16b1..3fb122e414 100644
--- a/TUnit.Engine.Tests/HtmlReporterTests.cs
+++ b/TUnit.Engine.Tests/HtmlReporterTests.cs
@@ -115,9 +115,9 @@ public void FilterAdditionalTraceIds_Returns_Empty_When_Only_Primary()
[Test]
public void OrderTestsForDisplay_SortsByStartTime_ThenName()
{
- var later = CreateReportTestResult("Later", "2026-05-07T09:26:25.0000000Z");
- var earlier = CreateReportTestResult("Earlier", "2026-05-07T09:26:24.0000000Z");
- var sameTimeButLaterName = CreateReportTestResult("Zeta", "2026-05-07T09:26:24.0000000Z");
+ 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]);
@@ -166,7 +166,7 @@ public async Task PublishArtifactAsync_Publishes_With_Correct_SessionUid()
}
}
- private static ReportTestResult CreateReportTestResult(string displayName, string? startTime) => new()
+ private static ReportTestResult CreateTestResultWithStartTime(string displayName, string? startTime) => new()
{
Id = displayName,
DisplayName = displayName,
From ea5a720cac7a7d795cf60442823823050e8f46b6 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Thu, 7 May 2026 13:21:16 +0000
Subject: [PATCH 04/18] Correlate linked OTEL traces to tests
Agent-Logs-Url: https://github.com/thomhurst/TUnit/sessions/5339a2a6-32c0-4e8e-b65a-72c686b6873e
Co-authored-by: thomhurst <30480171+thomhurst@users.noreply.github.com>
---
TUnit.Core/TraceRegistry.cs | 30 ++++
.../Reporters/Html/ActivityCollector.cs | 7 +-
.../OtlpReceiverIngestionTests.cs | 134 ++++++++++++++++++
TUnit.OpenTelemetry/Receiver/OtlpReceiver.cs | 17 +++
4 files changed, 187 insertions(+), 1 deletion(-)
diff --git a/TUnit.Core/TraceRegistry.cs b/TUnit.Core/TraceRegistry.cs
index 7259429f7b..28594d1269 100644
--- a/TUnit.Core/TraceRegistry.cs
+++ b/TUnit.Core/TraceRegistry.cs
@@ -63,6 +63,36 @@ 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.
+ ///
+ internal static bool TryRegisterDerivedTrace(string derivedTraceId, string sourceTraceId)
+ {
+ if (string.Equals(derivedTraceId, sourceTraceId, StringComparison.OrdinalIgnoreCase))
+ {
+ return IsRegistered(sourceTraceId);
+ }
+
+ if (!TraceToTests.TryGetValue(sourceTraceId, out var testNodeUids))
+ {
+ return false;
+ }
+
+ foreach (var testNodeUid in testNodeUids.Keys)
+ {
+ Register(derivedTraceId, testNodeUid);
+ }
+
+ if (TraceToContextId.TryGetValue(sourceTraceId, out var contextId))
+ {
+ TraceToContextId[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/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.OpenTelemetry.Tests/OtlpReceiverIngestionTests.cs b/TUnit.OpenTelemetry.Tests/OtlpReceiverIngestionTests.cs
index 22ea4c078c..75f9d995a5 100644
--- a/TUnit.OpenTelemetry.Tests/OtlpReceiverIngestionTests.cs
+++ b/TUnit.OpenTelemetry.Tests/OtlpReceiverIngestionTests.cs
@@ -47,4 +47,138 @@ 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 traceId = Guid.NewGuid().ToString("N");
+ var spanId = "0123456789abcdef";
+ var body = BuildLinkedTraceExportRequest(
+ traceId,
+ spanId,
+ "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 == traceId && s.Name == "sut-linked-op");
+
+ await Assert.That(span).IsNotNull();
+ await Assert.That(TraceRegistry.IsRegistered(traceId)).IsTrue();
+ await Assert.That(TraceRegistry.GetContextId(traceId)).IsEqualTo(TestContext.Current!.Id);
+ }
+
+ private static byte[] BuildLinkedTraceExportRequest(
+ string traceId,
+ string spanId,
+ string spanName,
+ string linkedTraceId,
+ string linkedSpanId)
+ {
+ using var exportStream = new MemoryStream();
+ var resourceSpans = BuildResourceSpans(
+ BuildScopeSpans(
+ BuildSpan(traceId, spanId, spanName, linkedTraceId, linkedSpanId)));
+ 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 traceId,
+ string spanId,
+ string spanName,
+ string linkedTraceId,
+ string linkedSpanId)
+ {
+ using var stream = new MemoryStream();
+ WriteField(stream, 1, Convert.FromHexString(traceId));
+ WriteField(stream, 2, Convert.FromHexString(spanId));
+ WriteStringField(stream, 5, spanName);
+ WriteVarintField(stream, 6, 5); // CONSUMER
+ WriteFixed64Field(stream, 7, 1);
+ WriteFixed64Field(stream, 8, 2);
+ WriteField(stream, 13, BuildSpanLink(linkedTraceId, linkedSpanId));
+ 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)
+ {
+ 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;
From 9af29e7108bb051b075870ffb7994dd9f57d2039 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Thu, 7 May 2026 13:23:54 +0000
Subject: [PATCH 05/18] Polish OTEL linked trace regression coverage
Agent-Logs-Url: https://github.com/thomhurst/TUnit/sessions/5339a2a6-32c0-4e8e-b65a-72c686b6873e
Co-authored-by: thomhurst <30480171+thomhurst@users.noreply.github.com>
---
TUnit.Core/TraceRegistry.cs | 5 +++++
.../OtlpReceiverIngestionTests.cs | 18 ++++++++++--------
2 files changed, 15 insertions(+), 8 deletions(-)
diff --git a/TUnit.Core/TraceRegistry.cs b/TUnit.Core/TraceRegistry.cs
index 28594d1269..454a625ca6 100644
--- a/TUnit.Core/TraceRegistry.cs
+++ b/TUnit.Core/TraceRegistry.cs
@@ -68,6 +68,11 @@ internal static bool IsRegistered(string traceId)
/// . 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)
{
if (string.Equals(derivedTraceId, sourceTraceId, StringComparison.OrdinalIgnoreCase))
diff --git a/TUnit.OpenTelemetry.Tests/OtlpReceiverIngestionTests.cs b/TUnit.OpenTelemetry.Tests/OtlpReceiverIngestionTests.cs
index 75f9d995a5..786e3e86cc 100644
--- a/TUnit.OpenTelemetry.Tests/OtlpReceiverIngestionTests.cs
+++ b/TUnit.OpenTelemetry.Tests/OtlpReceiverIngestionTests.cs
@@ -11,6 +11,8 @@ namespace TUnit.OpenTelemetry.Tests;
public class OtlpReceiverIngestionTests
{
+ private const ulong SpanKindConsumer = 5;
+
[Test]
public async Task Receiver_ParsedTrace_ReachesActivityCollector()
{
@@ -58,10 +60,10 @@ public async Task Receiver_ParsedLinkedTrace_RegistersAgainstOwningTest()
receiver.Start();
var linkedContext = Activity.Current!.Context;
- var traceId = Guid.NewGuid().ToString("N");
+ var derivedTraceId = Guid.NewGuid().ToString("N");
var spanId = "0123456789abcdef";
var body = BuildLinkedTraceExportRequest(
- traceId,
+ derivedTraceId,
spanId,
"sut-linked-op",
linkedContext.TraceId.ToString(),
@@ -76,11 +78,11 @@ public async Task Receiver_ParsedLinkedTrace_RegistersAgainstOwningTest()
await receiver.WhenIdle();
var span = collector!.GetAllSpans().FirstOrDefault(s =>
- s.TraceId == traceId && s.Name == "sut-linked-op");
+ s.TraceId == derivedTraceId && s.Name == "sut-linked-op");
await Assert.That(span).IsNotNull();
- await Assert.That(TraceRegistry.IsRegistered(traceId)).IsTrue();
- await Assert.That(TraceRegistry.GetContextId(traceId)).IsEqualTo(TestContext.Current!.Id);
+ await Assert.That(TraceRegistry.IsRegistered(derivedTraceId)).IsTrue();
+ await Assert.That(TraceRegistry.GetContextId(derivedTraceId)).IsEqualTo(TestContext.Current!.Id);
}
private static byte[] BuildLinkedTraceExportRequest(
@@ -123,9 +125,9 @@ private static byte[] BuildSpan(
WriteField(stream, 1, Convert.FromHexString(traceId));
WriteField(stream, 2, Convert.FromHexString(spanId));
WriteStringField(stream, 5, spanName);
- WriteVarintField(stream, 6, 5); // CONSUMER
- WriteFixed64Field(stream, 7, 1);
- WriteFixed64Field(stream, 8, 2);
+ WriteVarintField(stream, 6, SpanKindConsumer);
+ WriteFixed64Field(stream, 7, 1); // start_time_unix_nano
+ WriteFixed64Field(stream, 8, 2); // end_time_unix_nano
WriteField(stream, 13, BuildSpanLink(linkedTraceId, linkedSpanId));
return stream.ToArray();
}
From a64c01d45e578d5a86a3b8ad0de6bdd07cd7ebc9 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Thu, 7 May 2026 13:25:51 +0000
Subject: [PATCH 06/18] Tighten linked trace correlation details
Agent-Logs-Url: https://github.com/thomhurst/TUnit/sessions/5339a2a6-32c0-4e8e-b65a-72c686b6873e
Co-authored-by: thomhurst <30480171+thomhurst@users.noreply.github.com>
---
TUnit.Core/TraceRegistry.cs | 2 +-
.../OtlpReceiverIngestionTests.cs | 10 ++++++----
2 files changed, 7 insertions(+), 5 deletions(-)
diff --git a/TUnit.Core/TraceRegistry.cs b/TUnit.Core/TraceRegistry.cs
index 454a625ca6..b4e602ff54 100644
--- a/TUnit.Core/TraceRegistry.cs
+++ b/TUnit.Core/TraceRegistry.cs
@@ -92,7 +92,7 @@ internal static bool TryRegisterDerivedTrace(string derivedTraceId, string sourc
if (TraceToContextId.TryGetValue(sourceTraceId, out var contextId))
{
- TraceToContextId[derivedTraceId] = contextId;
+ TraceToContextId.TryAdd(derivedTraceId, contextId);
}
return true;
diff --git a/TUnit.OpenTelemetry.Tests/OtlpReceiverIngestionTests.cs b/TUnit.OpenTelemetry.Tests/OtlpReceiverIngestionTests.cs
index 786e3e86cc..38fc707f99 100644
--- a/TUnit.OpenTelemetry.Tests/OtlpReceiverIngestionTests.cs
+++ b/TUnit.OpenTelemetry.Tests/OtlpReceiverIngestionTests.cs
@@ -12,6 +12,9 @@ namespace TUnit.OpenTelemetry.Tests;
public class OtlpReceiverIngestionTests
{
private const ulong SpanKindConsumer = 5;
+ private const string TestSpanId = "0123456789abcdef";
+ private const ulong TestStartTimeUnixNano = 1;
+ private const ulong TestEndTimeUnixNano = 2;
[Test]
public async Task Receiver_ParsedTrace_ReachesActivityCollector()
@@ -61,10 +64,9 @@ public async Task Receiver_ParsedLinkedTrace_RegistersAgainstOwningTest()
var linkedContext = Activity.Current!.Context;
var derivedTraceId = Guid.NewGuid().ToString("N");
- var spanId = "0123456789abcdef";
var body = BuildLinkedTraceExportRequest(
derivedTraceId,
- spanId,
+ TestSpanId,
"sut-linked-op",
linkedContext.TraceId.ToString(),
linkedContext.SpanId.ToString());
@@ -126,8 +128,8 @@ private static byte[] BuildSpan(
WriteField(stream, 2, Convert.FromHexString(spanId));
WriteStringField(stream, 5, spanName);
WriteVarintField(stream, 6, SpanKindConsumer);
- WriteFixed64Field(stream, 7, 1); // start_time_unix_nano
- WriteFixed64Field(stream, 8, 2); // end_time_unix_nano
+ WriteFixed64Field(stream, 7, TestStartTimeUnixNano); // start_time_unix_nano
+ WriteFixed64Field(stream, 8, TestEndTimeUnixNano); // end_time_unix_nano
WriteField(stream, 13, BuildSpanLink(linkedTraceId, linkedSpanId));
return stream.ToArray();
}
From 7b409969482fbc480e3fb384a2101791610fb3b7 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Thu, 7 May 2026 13:28:50 +0000
Subject: [PATCH 07/18] Polish OTLP receiver test readability
Agent-Logs-Url: https://github.com/thomhurst/TUnit/sessions/5339a2a6-32c0-4e8e-b65a-72c686b6873e
Co-authored-by: thomhurst <30480171+thomhurst@users.noreply.github.com>
---
.../OtlpReceiverIngestionTests.cs | 26 ++++++++++---------
1 file changed, 14 insertions(+), 12 deletions(-)
diff --git a/TUnit.OpenTelemetry.Tests/OtlpReceiverIngestionTests.cs b/TUnit.OpenTelemetry.Tests/OtlpReceiverIngestionTests.cs
index 38fc707f99..dde4822234 100644
--- a/TUnit.OpenTelemetry.Tests/OtlpReceiverIngestionTests.cs
+++ b/TUnit.OpenTelemetry.Tests/OtlpReceiverIngestionTests.cs
@@ -88,16 +88,16 @@ public async Task Receiver_ParsedLinkedTrace_RegistersAgainstOwningTest()
}
private static byte[] BuildLinkedTraceExportRequest(
- string traceId,
- string spanId,
+ string derivedTraceId,
+ string derivedSpanId,
string spanName,
- string linkedTraceId,
- string linkedSpanId)
+ string sourceTraceId,
+ string sourceSpanId)
{
using var exportStream = new MemoryStream();
var resourceSpans = BuildResourceSpans(
BuildScopeSpans(
- BuildSpan(traceId, spanId, spanName, linkedTraceId, linkedSpanId)));
+ BuildSpan(derivedTraceId, derivedSpanId, spanName, sourceTraceId, sourceSpanId)));
WriteField(exportStream, 1, resourceSpans);
return exportStream.ToArray();
}
@@ -117,20 +117,20 @@ private static byte[] BuildScopeSpans(byte[] span)
}
private static byte[] BuildSpan(
- string traceId,
- string spanId,
+ string derivedTraceId,
+ string derivedSpanId,
string spanName,
- string linkedTraceId,
- string linkedSpanId)
+ string sourceTraceId,
+ string sourceSpanId)
{
using var stream = new MemoryStream();
- WriteField(stream, 1, Convert.FromHexString(traceId));
- WriteField(stream, 2, Convert.FromHexString(spanId));
+ 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(linkedTraceId, linkedSpanId));
+ WriteField(stream, 13, BuildSpanLink(sourceTraceId, sourceSpanId));
return stream.ToArray();
}
@@ -173,6 +173,8 @@ private static void WriteTag(MemoryStream stream, int fieldNumber, int wireType)
private static void WriteVarint(MemoryStream stream, ulong value)
{
+ // Protobuf varints encode integers in 7-bit chunks; the high bit marks
+ // that another byte follows.
do
{
var current = (byte)(value & 0x7F);
From a1950acaaab49d6456452c7c73ed353dadd42b7b Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Thu, 7 May 2026 13:31:19 +0000
Subject: [PATCH 08/18] Polish derived trace correlation internals
Agent-Logs-Url: https://github.com/thomhurst/TUnit/sessions/5339a2a6-32c0-4e8e-b65a-72c686b6873e
Co-authored-by: thomhurst <30480171+thomhurst@users.noreply.github.com>
---
TUnit.Core/TraceRegistry.cs | 4 ++--
TUnit.OpenTelemetry.Tests/OtlpReceiverIngestionTests.cs | 4 ++--
2 files changed, 4 insertions(+), 4 deletions(-)
diff --git a/TUnit.Core/TraceRegistry.cs b/TUnit.Core/TraceRegistry.cs
index b4e602ff54..1078111aa2 100644
--- a/TUnit.Core/TraceRegistry.cs
+++ b/TUnit.Core/TraceRegistry.cs
@@ -85,9 +85,9 @@ internal static bool TryRegisterDerivedTrace(string derivedTraceId, string sourc
return false;
}
- foreach (var testNodeUid in testNodeUids.Keys)
+ foreach (var testNodeUid in testNodeUids)
{
- Register(derivedTraceId, testNodeUid);
+ Register(derivedTraceId, testNodeUid.Key);
}
if (TraceToContextId.TryGetValue(sourceTraceId, out var contextId))
diff --git a/TUnit.OpenTelemetry.Tests/OtlpReceiverIngestionTests.cs b/TUnit.OpenTelemetry.Tests/OtlpReceiverIngestionTests.cs
index dde4822234..36009f6039 100644
--- a/TUnit.OpenTelemetry.Tests/OtlpReceiverIngestionTests.cs
+++ b/TUnit.OpenTelemetry.Tests/OtlpReceiverIngestionTests.cs
@@ -173,8 +173,8 @@ private static void WriteTag(MemoryStream stream, int fieldNumber, int wireType)
private static void WriteVarint(MemoryStream stream, ulong value)
{
- // Protobuf varints encode integers in 7-bit chunks; the high bit marks
- // that another byte follows.
+ // 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);
From ec211aead44291b7c656c5c33f648a49dd3d1aa7 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Thu, 7 May 2026 13:32:54 +0000
Subject: [PATCH 09/18] Document linked trace correlation helpers
Agent-Logs-Url: https://github.com/thomhurst/TUnit/sessions/5339a2a6-32c0-4e8e-b65a-72c686b6873e
Co-authored-by: thomhurst <30480171+thomhurst@users.noreply.github.com>
---
TUnit.Core/TraceRegistry.cs | 2 ++
TUnit.OpenTelemetry.Tests/OtlpReceiverIngestionTests.cs | 4 +++-
2 files changed, 5 insertions(+), 1 deletion(-)
diff --git a/TUnit.Core/TraceRegistry.cs b/TUnit.Core/TraceRegistry.cs
index 1078111aa2..b7fae450cd 100644
--- a/TUnit.Core/TraceRegistry.cs
+++ b/TUnit.Core/TraceRegistry.cs
@@ -75,6 +75,8 @@ internal static bool IsRegistered(string traceId)
///
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);
diff --git a/TUnit.OpenTelemetry.Tests/OtlpReceiverIngestionTests.cs b/TUnit.OpenTelemetry.Tests/OtlpReceiverIngestionTests.cs
index 36009f6039..da76581d55 100644
--- a/TUnit.OpenTelemetry.Tests/OtlpReceiverIngestionTests.cs
+++ b/TUnit.OpenTelemetry.Tests/OtlpReceiverIngestionTests.cs
@@ -11,7 +11,7 @@ namespace TUnit.OpenTelemetry.Tests;
public class OtlpReceiverIngestionTests
{
- private const ulong SpanKindConsumer = 5;
+ private const ulong SpanKindConsumer = 5; // OTLP SpanKind.CONSUMER
private const string TestSpanId = "0123456789abcdef";
private const ulong TestStartTimeUnixNano = 1;
private const ulong TestEndTimeUnixNano = 2;
@@ -173,6 +173,8 @@ private static void WriteTag(MemoryStream stream, int fieldNumber, int 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
From dab8b4199b263667287cf5cf14bfc36f3f5fe0b5 Mon Sep 17 00:00:00 2001
From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com>
Date: Fri, 8 May 2026 22:15:35 +0100
Subject: [PATCH 10/18] feat(otel): receiver diagnostics + gRPC misroute
detection
Adds opt-in diagnostic counters to OtlpReceiver so we can tell
"SUT didn't export anything" from "SUT exported but TUnit dropped it
on the floor" when investigating reports of missing OTel activities
(e.g. issue #5845).
- Per-path request counts: /v1/logs, /v1/traces, /v1/metrics, other.
- Parse-failure counters for both signals.
- Span/log routing counters: parsed, no-trace-id, trace-not-registered,
link-registered, ingested.
- Distinct unknown-path tracking (capped) so misrouted endpoints surface.
- gRPC misroute detection: requests with Content-Type application/grpc
or paths under /opentelemetry.proto.collector.* are rejected with 415
and a clear "use OTEL_EXPORTER_OTLP_PROTOCOL=http/protobuf" message
instead of silently failing.
- Session-end summary written to Console.Error when TUNIT_OTLP_DEBUG=1.
No behaviour change for production runs (counters are passive, dump is
opt-in). Adds two tests covering path classification and gRPC reject.
---
.../OtlpReceiverIngestionTests.cs | 57 +++++
TUnit.OpenTelemetry/Receiver/OtlpReceiver.cs | 206 +++++++++++++++++-
2 files changed, 252 insertions(+), 11 deletions(-)
diff --git a/TUnit.OpenTelemetry.Tests/OtlpReceiverIngestionTests.cs b/TUnit.OpenTelemetry.Tests/OtlpReceiverIngestionTests.cs
index da76581d55..831f88420a 100644
--- a/TUnit.OpenTelemetry.Tests/OtlpReceiverIngestionTests.cs
+++ b/TUnit.OpenTelemetry.Tests/OtlpReceiverIngestionTests.cs
@@ -53,6 +53,63 @@ public async Task Receiver_ParsedTrace_ReachesActivityCollector()
await Assert.That(span).IsNotNull();
}
+ [Test]
+ public async Task Receiver_Diagnostics_ClassifiesEachRequestPath()
+ {
+ await using var receiver = new OtlpReceiver();
+ receiver.Start();
+
+ using var client = new HttpClient();
+ using var emptyTraces = new ByteArrayContent(Array.Empty());
+ using var emptyLogs = new ByteArrayContent(Array.Empty());
+ using var emptyMetrics = new ByteArrayContent(Array.Empty());
+ using var emptyOther = new ByteArrayContent(Array.Empty());
+
+ await client.PostAsync($"http://127.0.0.1:{receiver.Port}/v1/traces", emptyTraces);
+ await client.PostAsync($"http://127.0.0.1:{receiver.Port}/v1/logs", emptyLogs);
+ await client.PostAsync($"http://127.0.0.1:{receiver.Port}/v1/metrics", emptyMetrics);
+ await client.PostAsync($"http://127.0.0.1:{receiver.Port}/some/other/path", emptyOther);
+
+ await receiver.WhenIdle();
+
+ var diag = receiver.Diagnostics;
+ await Assert.That(diag.TracesRequests).IsEqualTo(1);
+ await Assert.That(diag.LogsRequests).IsEqualTo(1);
+ await Assert.That(diag.MetricsRequests).IsEqualTo(1);
+ await Assert.That(diag.OtherRequests).IsEqualTo(1);
+ await Assert.That(diag.TotalRequests).IsEqualTo(4);
+
+ var summary = diag.FormatSummary(receiver.Port);
+ await Assert.That(summary).Contains("requests.v1_traces = 1");
+ await Assert.That(summary).Contains("requests.v1_metrics = 1");
+ await Assert.That(summary).Contains("other_path[/some/other/path] = 1");
+ }
+
+ [Test]
+ public async Task Receiver_GrpcRequest_RejectedWith415AndCounted()
+ {
+ await using var receiver = new OtlpReceiver();
+ receiver.Start();
+
+ using var client = new HttpClient();
+
+ using var grpcByContentType = new ByteArrayContent(Array.Empty());
+ grpcByContentType.Headers.TryAddWithoutValidation("Content-Type", "application/grpc");
+ var contentTypeResponse = await client.PostAsync($"http://127.0.0.1:{receiver.Port}/v1/traces", grpcByContentType);
+
+ using var grpcByPath = new ByteArrayContent(Array.Empty());
+ var pathResponse = await client.PostAsync(
+ $"http://127.0.0.1:{receiver.Port}/opentelemetry.proto.collector.trace.v1.TraceService/Export",
+ grpcByPath);
+
+ await receiver.WhenIdle();
+
+ await Assert.That((int)contentTypeResponse.StatusCode).IsEqualTo(415);
+ await Assert.That((int)pathResponse.StatusCode).IsEqualTo(415);
+ await Assert.That(receiver.Diagnostics.GrpcRejected).IsEqualTo(2);
+ await Assert.That(receiver.Diagnostics.TracesRequests).IsEqualTo(0);
+ }
+
[Test]
public async Task Receiver_ParsedLinkedTrace_RegistersAgainstOwningTest()
{
diff --git a/TUnit.OpenTelemetry/Receiver/OtlpReceiver.cs b/TUnit.OpenTelemetry/Receiver/OtlpReceiver.cs
index ab12d2763c..d13ca3e3f9 100644
--- a/TUnit.OpenTelemetry/Receiver/OtlpReceiver.cs
+++ b/TUnit.OpenTelemetry/Receiver/OtlpReceiver.cs
@@ -32,10 +32,10 @@ internal sealed class OtlpReceiver : IAsyncDisposable
private readonly HttpListener _listener;
private readonly CancellationTokenSource _cts = new();
private readonly ConcurrentDictionary _inflightTasks = new();
+ private readonly OtlpReceiverDiagnostics _diagnostics = new();
private string? _upstreamEndpoint;
private Task? _listenTask;
private int _taskIdCounter;
- private int _requestCount;
///
/// The port the receiver is listening on.
@@ -45,7 +45,13 @@ internal sealed class OtlpReceiver : IAsyncDisposable
///
/// The number of POST requests successfully processed.
///
- internal int RequestCount => _requestCount;
+ internal int RequestCount => _diagnostics.TotalRequests;
+
+ ///
+ /// Per-counter snapshot of receiver activity. Useful in tests and when diagnosing
+ /// silent drops in user environments via .
+ ///
+ internal OtlpReceiverDiagnostics Diagnostics => _diagnostics;
///
/// Creates a new OTLP receiver.
@@ -141,6 +147,28 @@ private async Task ProcessRequestAsync(HttpListenerContext context)
return;
}
+ var path = request.Url?.AbsolutePath ?? "";
+
+ if (LooksLikeGrpc(request.ContentType, path))
+ {
+ // HttpListener is HTTP/1.1-only — most gRPC clients won't even reach us, but
+ // h2c-fallback or grpc-web requests can. Reject explicitly with 415 so the
+ // SUT exporter logs an error instead of silently retrying, and surface the
+ // mismatch in the diagnostic dump. Fix is to set
+ // OTEL_EXPORTER_OTLP_PROTOCOL=http/protobuf (already injected by
+ // TUnit.Aspire on every ProjectResource).
+ Interlocked.Increment(ref _diagnostics.GrpcRejected);
+ Interlocked.Increment(ref _diagnostics.TotalRequests);
+ _diagnostics.RecordUnknownPath(path);
+ response.StatusCode = 415;
+ response.ContentType = "text/plain";
+ ReadOnlySpan msg = "TUnit OTLP receiver does not support gRPC. Set OTEL_EXPORTER_OTLP_PROTOCOL=http/protobuf.\n"u8;
+ response.ContentLength64 = msg.Length;
+ response.OutputStream.Write(msg);
+ response.Close();
+ return;
+ }
+
// ContentLength64 is -1 for chunked; size-known path avoids MemoryStream growth copies.
byte[] body;
if (request.ContentLength64 >= 0)
@@ -170,15 +198,26 @@ private async Task ProcessRequestAsync(HttpListenerContext context)
body = ms.ToArray();
}
- var path = request.Url?.AbsolutePath ?? "";
-
if (path == "/v1/logs")
{
- ProcessLogs(body);
+ Interlocked.Increment(ref _diagnostics.LogsRequests);
+ ProcessLogs(body, _diagnostics);
}
else if (path == "/v1/traces")
{
- ProcessTraces(body);
+ Interlocked.Increment(ref _diagnostics.TracesRequests);
+ ProcessTraces(body, _diagnostics);
+ }
+ else if (path == "/v1/metrics")
+ {
+ // Standard OTLP/HTTP signal — accepted and forwarded upstream, but we don't
+ // render metrics in the report so there's nothing to parse.
+ Interlocked.Increment(ref _diagnostics.MetricsRequests);
+ }
+ else
+ {
+ Interlocked.Increment(ref _diagnostics.OtherRequests);
+ _diagnostics.RecordUnknownPath(path);
}
var upstream = Volatile.Read(ref _upstreamEndpoint);
@@ -187,7 +226,7 @@ private async Task ProcessRequestAsync(HttpListenerContext context)
TrackTask(ForwardAsync(upstream, path, body, request.ContentType));
}
- Interlocked.Increment(ref _requestCount);
+ Interlocked.Increment(ref _diagnostics.TotalRequests);
response.StatusCode = 200;
response.ContentType = "application/x-protobuf";
@@ -209,11 +248,12 @@ private async Task ProcessRequestAsync(HttpListenerContext context)
}
}
- private static void ProcessTraces(byte[] body)
+ private static void ProcessTraces(byte[] body, OtlpReceiverDiagnostics diag)
{
var sink = ExternalSpanSink.Current;
if (sink is null)
{
+ Interlocked.Increment(ref diag.TracesNoSink);
return;
}
@@ -224,21 +264,25 @@ private static void ProcessTraces(byte[] body)
}
catch (Exception ex)
{
+ Interlocked.Increment(ref diag.TracesParseFailures);
Trace.WriteLine($"[TUnit.OpenTelemetry] Failed to parse /v1/traces body: {ex.GetType().Name}: {ex.Message}");
return;
}
+ Interlocked.Add(ref diag.TracesSpansParsed, spans.Count);
+
foreach (var span in spans)
{
- RegisterDerivedTrace(span);
+ RegisterDerivedTrace(span, diag);
sink(ToSpanData(span));
}
}
- private static void RegisterDerivedTrace(OtlpSpanRecord span)
+ private static void RegisterDerivedTrace(OtlpSpanRecord span, OtlpReceiverDiagnostics diag)
{
if (TraceRegistry.IsRegistered(span.TraceId))
{
+ Interlocked.Increment(ref diag.TracesSpansTraceAlreadyRegistered);
return;
}
@@ -246,9 +290,12 @@ private static void RegisterDerivedTrace(OtlpSpanRecord span)
{
if (TraceRegistry.TryRegisterDerivedTrace(span.TraceId, link.TraceId))
{
+ Interlocked.Increment(ref diag.TracesSpansLinkRegistered);
return;
}
}
+
+ Interlocked.Increment(ref diag.TracesSpansNoMatchingTrace);
}
private static SpanData ToSpanData(OtlpSpanRecord span)
@@ -350,7 +397,7 @@ private static string MapStatusCode(int code) =>
? ((ActivityStatusCode)code).ToString()
: nameof(ActivityStatusCode.Unset);
- private static void ProcessLogs(byte[] body)
+ private static void ProcessLogs(byte[] body, OtlpReceiverDiagnostics diag)
{
List records;
try
@@ -359,14 +406,18 @@ private static void ProcessLogs(byte[] body)
}
catch (Exception ex)
{
+ Interlocked.Increment(ref diag.LogsParseFailures);
Trace.WriteLine($"[TUnit.OpenTelemetry] Failed to parse /v1/logs body: {ex.GetType().Name}: {ex.Message}");
return;
}
+ Interlocked.Add(ref diag.LogsRecordsParsed, records.Count);
+
foreach (var record in records)
{
if (string.IsNullOrEmpty(record.TraceId))
{
+ Interlocked.Increment(ref diag.LogsRecordsNoTraceId);
continue;
}
@@ -374,12 +425,14 @@ private static void ProcessLogs(byte[] body)
var contextId = TraceRegistry.GetContextId(record.TraceId);
if (contextId is null)
{
+ Interlocked.Increment(ref diag.LogsRecordsTraceNotRegistered);
continue;
}
var testContext = TestContext.GetById(contextId);
if (testContext is null)
{
+ Interlocked.Increment(ref diag.LogsRecordsTestContextMissing);
continue;
}
@@ -390,6 +443,7 @@ private static void ProcessLogs(byte[] body)
: $"[{record.ResourceName}] ";
testContext.Output.WriteLine($"{prefix}[{severity}] {record.Body}");
+ Interlocked.Increment(ref diag.LogsRecordsRouted);
}
}
@@ -450,9 +504,57 @@ public async ValueTask DisposeAsync()
// Best effort — individual failures already logged via Trace.WriteLine
}
+ // Distinguish "SUT didn't export anything" from "SUT exported but TUnit dropped it
+ // on the floor" — opt-in via TUNIT_OTLP_DEBUG=1 so production runs stay quiet.
+ if (IsDiagnosticsDumpEnabled())
+ {
+ try
+ {
+ Console.Error.WriteLine(_diagnostics.FormatSummary(Port));
+ }
+ catch
+ {
+ // Best effort — Console may be redirected/closed in some hosts
+ }
+ }
+
_cts.Dispose();
}
+ ///
+ /// Returns true if a request looks like gRPC — either by content-type
+ /// (application/grpc, application/grpc-web, application/grpc+proto)
+ /// or by an OTLP gRPC service path (/opentelemetry.proto.collector.{trace,logs,metrics}.v1.*Service/Export).
+ ///
+ private static bool LooksLikeGrpc(string? contentType, string path)
+ {
+ if (contentType is not null && contentType.StartsWith("application/grpc", StringComparison.OrdinalIgnoreCase))
+ {
+ return true;
+ }
+
+ // OTLP gRPC service paths all live under /opentelemetry.proto.collector. We don't
+ // need to match the full service+method to recognise the misroute.
+ return path.StartsWith("/opentelemetry.proto.collector.", StringComparison.Ordinal);
+ }
+
+ private static bool IsDiagnosticsDumpEnabled()
+ {
+ var value = Environment.GetEnvironmentVariable("TUNIT_OTLP_DEBUG");
+ return !string.IsNullOrEmpty(value)
+ && !string.Equals(value, "0", StringComparison.Ordinal)
+ && !string.Equals(value, "false", StringComparison.OrdinalIgnoreCase);
+ }
+
+ ///
+ /// Writes a one-line-per-counter summary of receiver activity. Intended for the
+ /// diagnostic dump path — call manually from a test to inspect counters.
+ ///
+ internal void WriteDiagnosticsSummary(TextWriter writer)
+ {
+ writer.Write(_diagnostics.FormatSummary(Port));
+ }
+
///
/// Creates an bound to a free port. Uses a retry loop to
/// handle the TOCTOU window between discovering a free port and binding to it.
@@ -460,6 +562,88 @@ public async ValueTask DisposeAsync()
private static (HttpListener Listener, int Port) CreateListener() => LoopbackHttpListenerFactory.Create();
}
+///
+/// Counter snapshot for an . Helps diagnose silent drops by
+/// distinguishing between requests that never arrived, parse failures, and spans/logs
+/// that arrived but didn't match any registered test trace.
+///
+internal sealed class OtlpReceiverDiagnostics
+{
+ private const int MaxTrackedUnknownPaths = 16;
+
+ private readonly ConcurrentDictionary _unknownPaths = new(StringComparer.Ordinal);
+
+ public int TotalRequests;
+ public int LogsRequests;
+ public int TracesRequests;
+ public int MetricsRequests;
+ public int OtherRequests;
+ public int GrpcRejected;
+
+ public int LogsParseFailures;
+ public int LogsRecordsParsed;
+ public int LogsRecordsNoTraceId;
+ public int LogsRecordsTraceNotRegistered;
+ public int LogsRecordsTestContextMissing;
+ public int LogsRecordsRouted;
+
+ public int TracesNoSink;
+ public int TracesParseFailures;
+ public int TracesSpansParsed;
+ public int TracesSpansTraceAlreadyRegistered;
+ public int TracesSpansLinkRegistered;
+ public int TracesSpansNoMatchingTrace;
+
+ ///
+ /// Records a request path that didn't match any known OTLP signal endpoint. Caps the
+ /// distinct-path set at to avoid unbounded growth
+ /// if a misbehaving client cycles through generated URLs.
+ ///
+ public void RecordUnknownPath(string path)
+ {
+ if (string.IsNullOrEmpty(path))
+ {
+ path = "(empty)";
+ }
+
+ if (_unknownPaths.ContainsKey(path) || _unknownPaths.Count < MaxTrackedUnknownPaths)
+ {
+ _unknownPaths.AddOrUpdate(path, 1, static (_, c) => c + 1);
+ }
+ }
+
+ public string FormatSummary(int port)
+ {
+ // Keep the layout grep-friendly so users can quickly paste a snippet into an issue.
+ var sb = new System.Text.StringBuilder();
+ sb.AppendLine($"[TUnit.OpenTelemetry] OtlpReceiver diagnostics summary (port {port}):");
+ sb.AppendLine($" requests.total = {TotalRequests}");
+ sb.AppendLine($" requests.v1_logs = {LogsRequests}");
+ sb.AppendLine($" requests.v1_traces = {TracesRequests}");
+ sb.AppendLine($" requests.v1_metrics = {MetricsRequests}");
+ sb.AppendLine($" requests.grpc_rejected = {GrpcRejected}");
+ sb.AppendLine($" requests.other_path = {OtherRequests}");
+ foreach (var entry in _unknownPaths)
+ {
+ sb.AppendLine($" other_path[{entry.Key}] = {entry.Value}");
+ }
+
+ sb.AppendLine($" logs.parse_failures = {LogsParseFailures}");
+ sb.AppendLine($" logs.records_parsed = {LogsRecordsParsed}");
+ sb.AppendLine($" logs.records_no_trace_id = {LogsRecordsNoTraceId}");
+ sb.AppendLine($" logs.records_trace_not_registered = {LogsRecordsTraceNotRegistered}");
+ sb.AppendLine($" logs.records_test_context_missing = {LogsRecordsTestContextMissing}");
+ sb.AppendLine($" logs.records_routed_to_test = {LogsRecordsRouted}");
+ sb.AppendLine($" traces.no_sink_registered = {TracesNoSink}");
+ sb.AppendLine($" traces.parse_failures = {TracesParseFailures}");
+ sb.AppendLine($" traces.spans_parsed = {TracesSpansParsed}");
+ sb.AppendLine($" traces.spans_trace_already_registered= {TracesSpansTraceAlreadyRegistered}");
+ sb.AppendLine($" traces.spans_registered_via_link = {TracesSpansLinkRegistered}");
+ sb.AppendLine($" traces.spans_no_matching_trace = {TracesSpansNoMatchingTrace}");
+ return sb.ToString();
+ }
+}
+
///
/// Binds an to a free loopback port, retrying if the
/// port is taken between probing and binding (TOCTOU window).
From 9d30e031b8969d132aa0d1a7ed76282f6ad54c4b Mon Sep 17 00:00:00 2001
From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com>
Date: Fri, 8 May 2026 22:30:41 +0100
Subject: [PATCH 11/18] =?UTF-8?q?fix(otel):=20address=20review=20on=20PR?=
=?UTF-8?q?=205847=20=E2=80=94=20diagnostic=20counter=20sharpening?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Three follow-ups from the automated review:
1. Per-batch dedupe of trace registration counters. A trace with 50
spans previously bumped TracesSpansTraceAlreadyRegistered to 50,
making "trace already known" indistinguishable from "50 separate
traces missed". Now counts unique traces per batch and renames the
counters to TracesAlreadyRegistered / TracesRegisteredViaLink /
TracesNoMatch to match the per-trace semantics.
2. Stop polluting unknown-paths map when gRPC is detected by
content-type on a known path (e.g. /v1/traces with
Content-Type application/grpc). Only path-triggered gRPC detection
records the path now.
3. Tighten OtlpReceiverDiagnostics fields from public to internal —
ref access works through assembly-internal visibility, and the
counters were never meant for cross-package consumers.
Adds two regression tests covering the dedupe and the unknown-paths
guard.
---
.../OtlpReceiverIngestionTests.cs | 56 +++++++++++++
TUnit.OpenTelemetry/Receiver/OtlpReceiver.cs | 81 ++++++++++++-------
2 files changed, 109 insertions(+), 28 deletions(-)
diff --git a/TUnit.OpenTelemetry.Tests/OtlpReceiverIngestionTests.cs b/TUnit.OpenTelemetry.Tests/OtlpReceiverIngestionTests.cs
index 831f88420a..a1ff893cb8 100644
--- a/TUnit.OpenTelemetry.Tests/OtlpReceiverIngestionTests.cs
+++ b/TUnit.OpenTelemetry.Tests/OtlpReceiverIngestionTests.cs
@@ -108,6 +108,36 @@ public async Task Receiver_GrpcRequest_RejectedWith415AndCounted()
await Assert.That((int)pathResponse.StatusCode).IsEqualTo(415);
await Assert.That(receiver.Diagnostics.GrpcRejected).IsEqualTo(2);
await Assert.That(receiver.Diagnostics.TracesRequests).IsEqualTo(0);
+
+ // Content-type-triggered rejection must NOT pollute the unknown-paths map with
+ // /v1/traces — only the path-triggered rejection should record its path.
+ var summary = receiver.Diagnostics.FormatSummary(receiver.Port);
+ await Assert.That(summary).DoesNotContain("other_path[/v1/traces]");
+ await Assert.That(summary).Contains("other_path[/opentelemetry.proto.collector.trace.v1.TraceService/Export]");
+ }
+
+ [Test]
+ public async Task Receiver_BatchOfSameTrace_CountsRegistrationOncePerTrace()
+ {
+ await using var receiver = new OtlpReceiver();
+ receiver.Start();
+
+ // Register a fake trace so all three spans hit the "already registered" path.
+ var traceId = Guid.NewGuid().ToString("N");
+ TraceRegistry.Register(traceId, "fake-test-context-id");
+
+ var body = BuildMultiSpanBatch(traceId, spanCount: 3);
+
+ using var client = new HttpClient();
+ using var content = new ByteArrayContent(body);
+ content.Headers.ContentType = new("application/x-protobuf");
+ await client.PostAsync($"http://127.0.0.1:{receiver.Port}/v1/traces", content);
+
+ await receiver.WhenIdle();
+
+ await Assert.That(receiver.Diagnostics.TracesSpansParsed).IsEqualTo(3);
+ await Assert.That(receiver.Diagnostics.TracesAlreadyRegistered).IsEqualTo(1);
+ await Assert.That(receiver.Diagnostics.TracesNoMatch).IsEqualTo(0);
}
[Test]
@@ -144,6 +174,32 @@ public async Task Receiver_ParsedLinkedTrace_RegistersAgainstOwningTest()
await Assert.That(TraceRegistry.GetContextId(derivedTraceId)).IsEqualTo(TestContext.Current!.Id);
}
+ private static byte[] BuildMultiSpanBatch(string traceId, int spanCount)
+ {
+ // Build N sibling spans that all share traceId but have distinct spanIds. This
+ // exercises the per-batch dedupe in ProcessTraces — each span should NOT bump
+ // the registration counter.
+ using var scopeStream = new MemoryStream();
+ for (var i = 0; i < spanCount; i++)
+ {
+ var spanId = i.ToString("X16");
+ using var spanStream = new MemoryStream();
+ WriteField(spanStream, 1, Convert.FromHexString(traceId));
+ WriteField(spanStream, 2, Convert.FromHexString(spanId));
+ WriteStringField(spanStream, 5, $"sut-batch-op-{i}");
+ WriteVarintField(spanStream, 6, SpanKindConsumer);
+ WriteFixed64Field(spanStream, 7, TestStartTimeUnixNano);
+ WriteFixed64Field(spanStream, 8, TestEndTimeUnixNano);
+ WriteField(scopeStream, 2, spanStream.ToArray());
+ }
+
+ using var resourceStream = new MemoryStream();
+ WriteField(resourceStream, 2, scopeStream.ToArray());
+ using var exportStream = new MemoryStream();
+ WriteField(exportStream, 1, resourceStream.ToArray());
+ return exportStream.ToArray();
+ }
+
private static byte[] BuildLinkedTraceExportRequest(
string derivedTraceId,
string derivedSpanId,
diff --git a/TUnit.OpenTelemetry/Receiver/OtlpReceiver.cs b/TUnit.OpenTelemetry/Receiver/OtlpReceiver.cs
index d13ca3e3f9..050e45329d 100644
--- a/TUnit.OpenTelemetry/Receiver/OtlpReceiver.cs
+++ b/TUnit.OpenTelemetry/Receiver/OtlpReceiver.cs
@@ -159,7 +159,14 @@ private async Task ProcessRequestAsync(HttpListenerContext context)
// TUnit.Aspire on every ProjectResource).
Interlocked.Increment(ref _diagnostics.GrpcRejected);
Interlocked.Increment(ref _diagnostics.TotalRequests);
- _diagnostics.RecordUnknownPath(path);
+ // Only record the path under "unknown" when path itself triggered detection;
+ // otherwise we'd add /v1/traces to the unknown-paths map on every gRPC-by-
+ // content-type rejection, which is actively misleading in the dump.
+ if (path.StartsWith("/opentelemetry.proto.collector.", StringComparison.Ordinal))
+ {
+ _diagnostics.RecordUnknownPath(path);
+ }
+
response.StatusCode = 415;
response.ContentType = "text/plain";
ReadOnlySpan msg = "TUnit OTLP receiver does not support gRPC. Set OTEL_EXPORTER_OTLP_PROTOCOL=http/protobuf.\n"u8;
@@ -271,9 +278,20 @@ private static void ProcessTraces(byte[] body, OtlpReceiverDiagnostics diag)
Interlocked.Add(ref diag.TracesSpansParsed, spans.Count);
+ // Dedupe registration attempts per batch — without this, every span in an
+ // already-known trace would bump the diagnostic counter, making
+ // "50 spans, all in a known trace" indistinguishable from "50 spans, 50 misses".
+ var registrationAttempted = spans.Count > 1
+ ? new HashSet(StringComparer.OrdinalIgnoreCase)
+ : null;
+
foreach (var span in spans)
{
- RegisterDerivedTrace(span, diag);
+ if (registrationAttempted is null || registrationAttempted.Add(span.TraceId))
+ {
+ RegisterDerivedTrace(span, diag);
+ }
+
sink(ToSpanData(span));
}
}
@@ -282,7 +300,7 @@ private static void RegisterDerivedTrace(OtlpSpanRecord span, OtlpReceiverDiagno
{
if (TraceRegistry.IsRegistered(span.TraceId))
{
- Interlocked.Increment(ref diag.TracesSpansTraceAlreadyRegistered);
+ Interlocked.Increment(ref diag.TracesAlreadyRegistered);
return;
}
@@ -290,12 +308,12 @@ private static void RegisterDerivedTrace(OtlpSpanRecord span, OtlpReceiverDiagno
{
if (TraceRegistry.TryRegisterDerivedTrace(span.TraceId, link.TraceId))
{
- Interlocked.Increment(ref diag.TracesSpansLinkRegistered);
+ Interlocked.Increment(ref diag.TracesRegisteredViaLink);
return;
}
}
- Interlocked.Increment(ref diag.TracesSpansNoMatchingTrace);
+ Interlocked.Increment(ref diag.TracesNoMatch);
}
private static SpanData ToSpanData(OtlpSpanRecord span)
@@ -573,26 +591,33 @@ internal sealed class OtlpReceiverDiagnostics
private readonly ConcurrentDictionary _unknownPaths = new(StringComparer.Ordinal);
- public int TotalRequests;
- public int LogsRequests;
- public int TracesRequests;
- public int MetricsRequests;
- public int OtherRequests;
- public int GrpcRejected;
-
- public int LogsParseFailures;
- public int LogsRecordsParsed;
- public int LogsRecordsNoTraceId;
- public int LogsRecordsTraceNotRegistered;
- public int LogsRecordsTestContextMissing;
- public int LogsRecordsRouted;
-
- public int TracesNoSink;
- public int TracesParseFailures;
- public int TracesSpansParsed;
- public int TracesSpansTraceAlreadyRegistered;
- public int TracesSpansLinkRegistered;
- public int TracesSpansNoMatchingTrace;
+ // Counters are mutated via Interlocked from the listener task and read at session end.
+ // No snapshot consistency across fields — readers may observe a partially-updated batch.
+ // Acceptable for a best-effort diagnostic dump; do not use for live decision-making.
+ // Fields are internal (rather than public) because ref access requires assembly-internal
+ // visibility at most, and OtlpReceiver lives in the same assembly.
+ internal int TotalRequests;
+ internal int LogsRequests;
+ internal int TracesRequests;
+ internal int MetricsRequests;
+ internal int OtherRequests;
+ internal int GrpcRejected;
+
+ internal int LogsParseFailures;
+ internal int LogsRecordsParsed;
+ internal int LogsRecordsNoTraceId;
+ internal int LogsRecordsTraceNotRegistered;
+ internal int LogsRecordsTestContextMissing;
+ internal int LogsRecordsRouted;
+
+ internal int TracesNoSink;
+ internal int TracesParseFailures;
+ internal int TracesSpansParsed;
+ // Per-trace (deduped per batch): how many distinct traces fell into each bucket
+ // when ProcessTraces attempted registration.
+ internal int TracesAlreadyRegistered;
+ internal int TracesRegisteredViaLink;
+ internal int TracesNoMatch;
///
/// Records a request path that didn't match any known OTLP signal endpoint. Caps the
@@ -637,9 +662,9 @@ public string FormatSummary(int port)
sb.AppendLine($" traces.no_sink_registered = {TracesNoSink}");
sb.AppendLine($" traces.parse_failures = {TracesParseFailures}");
sb.AppendLine($" traces.spans_parsed = {TracesSpansParsed}");
- sb.AppendLine($" traces.spans_trace_already_registered= {TracesSpansTraceAlreadyRegistered}");
- sb.AppendLine($" traces.spans_registered_via_link = {TracesSpansLinkRegistered}");
- sb.AppendLine($" traces.spans_no_matching_trace = {TracesSpansNoMatchingTrace}");
+ sb.AppendLine($" traces.unique_already_registered = {TracesAlreadyRegistered}");
+ sb.AppendLine($" traces.unique_registered_via_link = {TracesRegisteredViaLink}");
+ sb.AppendLine($" traces.unique_no_match = {TracesNoMatch}");
return sb.ToString();
}
}
From d12a0d3fc80ef926101c6ea02d73ad93c889503c Mon Sep 17 00:00:00 2001
From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com>
Date: Sun, 10 May 2026 15:23:06 +0100
Subject: [PATCH 12/18] fix(otel): drain receiver, propagate dashboard auth,
harden reporter tests
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Address remaining items on PR #5847:
* OtlpReceiver.DrainAsync — wait up to a stable window for late SUT exporter
flushes before AppHost teardown. Wired into AspireFixture.DisposeAsync so
fast tests no longer drop the trailing batch (workaround issue from #5845).
Window configurable via TUNIT_OTLP_DRAIN_MS (default 2000).
* Aspire dashboard forwarding — prefer DOTNET_DASHBOARD_OTLP_HTTP_ENDPOINT_URL
over the gRPC URL (the receiver only forwards http/protobuf). Parse
OTEL_EXPORTER_OTLP_HEADERS and pass to upstream so the dashboard's
x-otlp-api-key gate passes. Add upstream.forward_success / forward_failures
diagnostic counters surfaced under TUNIT_OTLP_DEBUG=1.
* Deferred review items:
- HtmlReporterTests: replace literal JS-symbol assertions with a behaviour
test that exercises span-data round-trip through the embedded report JSON.
- HtmlReporter.OrderTestsForDisplay: normalize StartTime/EndTime to UTC
before formatting so ordinal sort matches chronological order regardless
of local-clock offsets, plus a guard comment.
- OtlpReceiverDiagnostics.RecordUnknownPath: comment the soft cap behaviour
so a future contributor doesn't 'fix' a non-issue.
Tests: HtmlReporterTests (11/11) and OtlpReceiverIngestionTests (7/7) pass on
net10.0, including new drain + forwarding regression tests.
---
TUnit.Aspire/AspireFixture.cs | 60 +++++++-
TUnit.Core/TUnit.Core.csproj | 1 +
TUnit.Engine.Tests/HtmlReporterTests.cs | 46 +++++-
TUnit.Engine/Reporters/Html/HtmlReporter.cs | 6 +-
.../OtlpReceiverIngestionTests.cs | 83 +++++++++++
TUnit.OpenTelemetry/Receiver/OtlpReceiver.cs | 136 +++++++++++++++++-
6 files changed, 316 insertions(+), 16 deletions(-)
diff --git a/TUnit.Aspire/AspireFixture.cs b/TUnit.Aspire/AspireFixture.cs
index 8acabf65e4..4d5484a1f0 100644
--- a/TUnit.Aspire/AspireFixture.cs
+++ b/TUnit.Aspire/AspireFixture.cs
@@ -361,6 +361,16 @@ private void RemoveResources(IDistributedApplicationTestingBuilder builder)
///
public virtual async ValueTask DisposeAsync()
{
+ if (_otlpReceiver is not null && _app is not null)
+ {
+ // Give the SUT's BatchSpanProcessor a chance to flush trailing spans while the
+ // AppHost is still up. Without this, fast tests stop the app before the worker's
+ // exporter ticks (default 1s after our OTEL_BSP_SCHEDULE_DELAY override) and the
+ // last set of spans never reaches us.
+ LogProgress("Draining OTLP receiver...");
+ await _otlpReceiver.DrainAsync();
+ }
+
if (_app is not null)
{
LogProgress("Stopping application...");
@@ -386,6 +396,8 @@ public virtual async ValueTask DisposeAsync()
// --- OTLP Telemetry ---
private const string DashboardOtlpEndpointEnvVar = "DOTNET_DASHBOARD_OTLP_ENDPOINT_URL";
+ private const string DashboardOtlpHttpEndpointEnvVar = "DOTNET_DASHBOARD_OTLP_HTTP_ENDPOINT_URL";
+ private const string OtelExporterHeadersEnvVar = "OTEL_EXPORTER_OTLP_HEADERS";
private const string OtelExporterProtocolEnvVar = "OTEL_EXPORTER_OTLP_PROTOCOL";
private const string OtelServiceNameEnvVar = "OTEL_SERVICE_NAME";
private const string OtelBlrpScheduleDelayEnvVar = "OTEL_BLRP_SCHEDULE_DELAY";
@@ -393,13 +405,53 @@ public virtual async ValueTask DisposeAsync()
private void StartOtlpReceiver()
{
- // Check if there's an existing upstream OTLP endpoint (e.g., Aspire dashboard)
- // that we should forward to after processing.
- var upstreamEndpoint = Environment.GetEnvironmentVariable(DashboardOtlpEndpointEnvVar);
- _otlpReceiver = new OtlpReceiver(upstreamEndpoint);
+ // Prefer the dashboard's HTTP/protobuf endpoint — the receiver only forwards
+ // http/protobuf, not gRPC, so DOTNET_DASHBOARD_OTLP_ENDPOINT_URL (which is gRPC by
+ // default) would fail every forward. Fall back to the gRPC endpoint only as a
+ // last resort so users with a custom dashboard config still see *something*.
+ var upstreamEndpoint = Environment.GetEnvironmentVariable(DashboardOtlpHttpEndpointEnvVar)
+ ?? Environment.GetEnvironmentVariable(DashboardOtlpEndpointEnvVar);
+
+ _otlpReceiver = new OtlpReceiver(upstreamEndpoint)
+ {
+ UpstreamHeaders = ParseOtlpHeaders(Environment.GetEnvironmentVariable(OtelExporterHeadersEnvVar)),
+ };
_otlpReceiver.Start();
}
+ ///
+ /// Parses the OTEL_EXPORTER_OTLP_HEADERS format (key1=value1,key2=value2)
+ /// into a header list. The Aspire dashboard requires x-otlp-api-key on its OTLP
+ /// endpoints when auth is enabled (the default since Aspire 9), so without forwarding
+ /// these the dashboard rejects every proxied span.
+ ///
+ private static IReadOnlyList>? ParseOtlpHeaders(string? raw)
+ {
+ if (string.IsNullOrWhiteSpace(raw))
+ {
+ return null;
+ }
+
+ var result = new List>();
+ foreach (var pair in raw.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
+ {
+ var eq = pair.IndexOf('=');
+ if (eq <= 0 || eq == pair.Length - 1)
+ {
+ continue;
+ }
+
+ var key = pair[..eq].Trim();
+ var value = pair[(eq + 1)..].Trim();
+ if (key.Length > 0)
+ {
+ result.Add(new KeyValuePair(key, value));
+ }
+ }
+
+ return result.Count == 0 ? null : result;
+ }
+
private void ConfigureOtlpEndpoints(IDistributedApplicationTestingBuilder builder)
{
var otlpEndpoint = $"http://127.0.0.1:{_otlpReceiver!.Port}";
diff --git a/TUnit.Core/TUnit.Core.csproj b/TUnit.Core/TUnit.Core.csproj
index 81a5b5be0e..a6eec1aeec 100644
--- a/TUnit.Core/TUnit.Core.csproj
+++ b/TUnit.Core/TUnit.Core.csproj
@@ -11,6 +11,7 @@
+
diff --git a/TUnit.Engine.Tests/HtmlReporterTests.cs b/TUnit.Engine.Tests/HtmlReporterTests.cs
index 3fb122e414..6399916344 100644
--- a/TUnit.Engine.Tests/HtmlReporterTests.cs
+++ b/TUnit.Engine.Tests/HtmlReporterTests.cs
@@ -1,8 +1,12 @@
#pragma warning disable TPEXP
+using System.IO.Compression;
+using System.Text;
+using System.Text.RegularExpressions;
using Microsoft.Testing.Platform.Extensions.Messages;
using Microsoft.Testing.Platform.TestHost;
using Shouldly;
+using TUnit.Core;
using TUnit.Engine.Reporters.Html;
namespace TUnit.Engine.Tests;
@@ -125,8 +129,26 @@ public void OrderTestsForDisplay_SortsByStartTime_ThenName()
}
[Test]
- public void GenerateHtml_Includes_ClassTimeline_TestCaseRendering()
+ public void GenerateHtml_RoundTrips_TestBodySpans_AndChildren_Through_EmbeddedData()
{
+ // Exercise the data path the class timeline depends on: a 'test body' span with
+ // children must reach the report's embedded JSON so the client-side collapse logic
+ // has the data it needs to re-parent children to the test-case span.
+ const string traceId = "0123456789abcdef0123456789abcdef";
+ var spans = new[]
+ {
+ new SpanData
+ {
+ TraceId = traceId, SpanId = "aaaaaaaaaaaaaaaa", Name = "test body",
+ Source = "TUnit", Kind = "Internal", Status = "Ok",
+ },
+ new SpanData
+ {
+ TraceId = traceId, SpanId = "bbbbbbbbbbbbbbbb", ParentSpanId = "aaaaaaaaaaaaaaaa",
+ Name = "wiremock-call", Source = "TUnit", Kind = "Client", Status = "Ok",
+ },
+ };
+
var html = HtmlReportGenerator.GenerateHtml(new ReportData
{
AssemblyName = "Tests",
@@ -138,10 +160,28 @@ public void GenerateHtml_Includes_ClassTimeline_TestCaseRendering()
TotalDurationMs = 0,
Summary = new ReportSummary(),
Groups = [],
+ Spans = spans,
});
- html.ShouldContain("function collapseTestBodySpans(spans)");
- html.ShouldContain("const filtered = collapseTestBodySpans(getDescendants(allSpans, suite.spanId));");
+ var embedded = ExtractEmbeddedReportJson(html);
+ embedded.ShouldContain("\"name\":\"test body\"");
+ embedded.ShouldContain("\"name\":\"wiremock-call\"");
+ embedded.ShouldContain("\"parentSpanId\":\"aaaaaaaaaaaaaaaa\"");
+ }
+
+ private static string ExtractEmbeddedReportJson(string html)
+ {
+ // The renderer embeds ReportData as gzip+base64 inside ",
+ RegexOptions.Singleline);
+ match.Success.ShouldBeTrue("Expected embedded test-data script in rendered HTML.");
+ var compressed = Convert.FromBase64String(match.Groups["payload"].Value);
+ using var ms = new MemoryStream(compressed);
+ using var gz = new GZipStream(ms, CompressionMode.Decompress);
+ using var reader = new StreamReader(gz, Encoding.UTF8);
+ return reader.ReadToEnd();
}
[Test]
diff --git a/TUnit.Engine/Reporters/Html/HtmlReporter.cs b/TUnit.Engine/Reporters/Html/HtmlReporter.cs
index 7efe9b4dca..58c6f81c04 100644
--- a/TUnit.Engine/Reporters/Html/HtmlReporter.cs
+++ b/TUnit.Engine/Reporters/Html/HtmlReporter.cs
@@ -467,6 +467,8 @@ internal static string[] FilterAdditionalTraceIds(string[] allTraceIds, string?
internal static ReportTestResult[] OrderTestsForDisplay(IEnumerable tests)
{
+ // StartTime is normalized to UTC and round-trip-formatted ("o" → ...+00:00) before reaching
+ // this method, so ordinal string comparison sorts chronologically across all rows.
return tests
.OrderBy(static test => test.StartTime is null ? 1 : 0)
.ThenBy(static test => test.StartTime, StringComparer.Ordinal)
@@ -542,8 +544,8 @@ private static ReportTestResult ExtractTestResult(string testId, TestNode testNo
ClassName = className,
Status = status,
DurationMs = durationMs,
- StartTime = startTime?.ToString("o"),
- EndTime = endTime?.ToString("o"),
+ StartTime = startTime?.ToUniversalTime().ToString("o"),
+ EndTime = endTime?.ToUniversalTime().ToString("o"),
Exception = exception,
Output = stdOut,
ErrorOutput = stdErr,
diff --git a/TUnit.OpenTelemetry.Tests/OtlpReceiverIngestionTests.cs b/TUnit.OpenTelemetry.Tests/OtlpReceiverIngestionTests.cs
index a1ff893cb8..f24968b87a 100644
--- a/TUnit.OpenTelemetry.Tests/OtlpReceiverIngestionTests.cs
+++ b/TUnit.OpenTelemetry.Tests/OtlpReceiverIngestionTests.cs
@@ -1,4 +1,5 @@
using System.Diagnostics;
+using System.Net;
using OpenTelemetry;
using OpenTelemetry.Exporter;
using OpenTelemetry.Trace;
@@ -140,6 +141,88 @@ public async Task Receiver_BatchOfSameTrace_CountsRegistrationOncePerTrace()
await Assert.That(receiver.Diagnostics.TracesNoMatch).IsEqualTo(0);
}
+ [Test]
+ public async Task Receiver_Forwarding_PropagatesHeadersAndCountsSuccess()
+ {
+ // Mock upstream stands in for the Aspire dashboard — gates on the api-key header
+ // so we can prove the receiver actually propagated it instead of just dropping body
+ // bytes onto a permissive endpoint.
+ using var upstreamListener = new HttpListener();
+ var upstreamPort = FindFreeLoopbackPort();
+ upstreamListener.Prefixes.Add($"http://127.0.0.1:{upstreamPort}/");
+ upstreamListener.Start();
+
+ var receivedAuth = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+ var listenerTask = Task.Run(async () =>
+ {
+ var ctx = await upstreamListener.GetContextAsync();
+ receivedAuth.TrySetResult(ctx.Request.Headers["x-otlp-api-key"]);
+ ctx.Response.StatusCode = 200;
+ ctx.Response.Close();
+ });
+
+ await using var receiver = new OtlpReceiver($"http://127.0.0.1:{upstreamPort}")
+ {
+ UpstreamHeaders = [new KeyValuePair("x-otlp-api-key", "test-token-abc")],
+ };
+ receiver.Start();
+
+ using var client = new HttpClient();
+ using var content = new ByteArrayContent(Array.Empty());
+ content.Headers.ContentType = new("application/x-protobuf");
+ await client.PostAsync($"http://127.0.0.1:{receiver.Port}/v1/traces", content);
+
+ await receiver.DrainAsync(TimeSpan.FromSeconds(3));
+
+ var auth = await receivedAuth.Task.WaitAsync(TimeSpan.FromSeconds(2));
+ upstreamListener.Stop();
+ await listenerTask;
+
+ await Assert.That(auth).IsEqualTo("test-token-abc");
+ await Assert.That(receiver.Diagnostics.UpstreamForwardSuccess).IsEqualTo(1);
+ await Assert.That(receiver.Diagnostics.UpstreamForwardFailures).IsEqualTo(0);
+ }
+
+ private static int FindFreeLoopbackPort()
+ {
+ using var probe = new System.Net.Sockets.TcpListener(System.Net.IPAddress.Loopback, 0);
+ probe.Start();
+ var port = ((System.Net.IPEndPoint)probe.LocalEndpoint).Port;
+ probe.Stop();
+ return port;
+ }
+
+ [Test]
+ public async Task Receiver_DrainAsync_WaitsForLatePostBeforeReturning()
+ {
+ await using var receiver = new OtlpReceiver();
+ receiver.Start();
+
+ // Simulate a SUT exporter that flushes a couple hundred ms after the test logic
+ // would finish — without DrainAsync, AspireFixture would tear down the AppHost
+ // and the late POST would fail / be dropped.
+ var latePost = Task.Run(async () =>
+ {
+ await Task.Delay(TimeSpan.FromMilliseconds(200));
+ using var client = new HttpClient();
+ using var content = new ByteArrayContent(Array.Empty());
+ await client.PostAsync($"http://127.0.0.1:{receiver.Port}/v1/traces", content);
+ });
+
+ var drainStart = DateTime.UtcNow;
+ await receiver.DrainAsync(TimeSpan.FromSeconds(3));
+ var drainElapsed = DateTime.UtcNow - drainStart;
+
+ await latePost;
+
+ await Assert.That(receiver.Diagnostics.TracesRequests).IsEqualTo(1);
+ // The drain must have observed the late request — i.e., it didn't return at the
+ // 250ms stable window before the 200ms-delayed POST landed.
+ await Assert.That(drainElapsed).IsGreaterThanOrEqualTo(TimeSpan.FromMilliseconds(400));
+ // And it must respect the cap — no point waiting indefinitely once quiet.
+ await Assert.That(drainElapsed).IsLessThan(TimeSpan.FromSeconds(3));
+ }
+
[Test]
public async Task Receiver_ParsedLinkedTrace_RegistersAgainstOwningTest()
{
diff --git a/TUnit.OpenTelemetry/Receiver/OtlpReceiver.cs b/TUnit.OpenTelemetry/Receiver/OtlpReceiver.cs
index 050e45329d..c1f6bddd6e 100644
--- a/TUnit.OpenTelemetry/Receiver/OtlpReceiver.cs
+++ b/TUnit.OpenTelemetry/Receiver/OtlpReceiver.cs
@@ -34,6 +34,7 @@ internal sealed class OtlpReceiver : IAsyncDisposable
private readonly ConcurrentDictionary _inflightTasks = new();
private readonly OtlpReceiverDiagnostics _diagnostics = new();
private string? _upstreamEndpoint;
+ private IReadOnlyList>? _upstreamHeaders;
private Task? _listenTask;
private int _taskIdCounter;
@@ -77,6 +78,17 @@ public string? UpstreamEndpoint
set => Volatile.Write(ref _upstreamEndpoint, value?.TrimEnd('/'));
}
+ ///
+ /// Optional headers attached to upstream forwarded requests. The Aspire dashboard
+ /// gates its OTLP endpoints on an x-otlp-api-key header; without it, forwarding
+ /// returns 401 and the dashboard never sees the SUT's spans.
+ ///
+ public IReadOnlyList>? UpstreamHeaders
+ {
+ get => Volatile.Read(ref _upstreamHeaders);
+ set => Volatile.Write(ref _upstreamHeaders, value);
+ }
+
///
/// Starts accepting OTLP requests.
///
@@ -112,6 +124,79 @@ private async Task ListenLoop()
///
internal Task WhenIdle() => Task.WhenAll(_inflightTasks.Values);
+ ///
+ /// Waits for the receiver to go quiet — no in-flight tasks and no new POSTs for a short
+ /// stable window — up to . Intended for the session-end boundary
+ /// where the SUT's BatchSpanProcessor may still have unflushed spans queued; without
+ /// this, fast tests can finish and tear down their AppHost before exporters drain, dropping
+ /// the trailing telemetry the report is meant to show.
+ ///
+ /// Maximum total time to wait. Defaults to .
+ /// Stops the wait early.
+ public async Task DrainAsync(TimeSpan? window = null, CancellationToken cancellationToken = default)
+ {
+ var stableFor = TimeSpan.FromMilliseconds(250);
+ var deadline = DateTime.UtcNow + (window ?? DefaultDrainWindow);
+
+ while (!cancellationToken.IsCancellationRequested)
+ {
+ var beforeCount = Volatile.Read(ref _diagnostics.TotalRequests);
+
+ try
+ {
+ await WhenIdle().ConfigureAwait(false);
+ }
+ catch
+ {
+ // Individual request failures already logged via Trace.WriteLine; the drain
+ // is best-effort and shouldn't surface them.
+ }
+
+ var remaining = deadline - DateTime.UtcNow;
+ if (remaining <= TimeSpan.Zero)
+ {
+ return;
+ }
+
+ try
+ {
+ await Task.Delay(stableFor < remaining ? stableFor : remaining, cancellationToken)
+ .ConfigureAwait(false);
+ }
+ catch (OperationCanceledException)
+ {
+ return;
+ }
+
+ var afterCount = Volatile.Read(ref _diagnostics.TotalRequests);
+ if (afterCount == beforeCount && _inflightTasks.IsEmpty)
+ {
+ return;
+ }
+ }
+ }
+
+ ///
+ /// Default drain window applied by when no value is supplied.
+ /// Honours the TUNIT_OTLP_DRAIN_MS environment variable so users with slow exporters
+ /// can extend the wait without recompiling.
+ ///
+ public static TimeSpan DefaultDrainWindow
+ {
+ get
+ {
+ var raw = Environment.GetEnvironmentVariable("TUNIT_OTLP_DRAIN_MS");
+ if (!string.IsNullOrEmpty(raw)
+ && int.TryParse(raw, System.Globalization.NumberStyles.Integer, System.Globalization.CultureInfo.InvariantCulture, out var ms)
+ && ms >= 0)
+ {
+ return TimeSpan.FromMilliseconds(ms);
+ }
+
+ return TimeSpan.FromSeconds(2);
+ }
+ }
+
private void TrackTask(Task task)
{
var id = Interlocked.Increment(ref _taskIdCounter);
@@ -230,7 +315,7 @@ private async Task ProcessRequestAsync(HttpListenerContext context)
var upstream = Volatile.Read(ref _upstreamEndpoint);
if (upstream is not null)
{
- TrackTask(ForwardAsync(upstream, path, body, request.ContentType));
+ TrackTask(ForwardAsync(upstream, Volatile.Read(ref _upstreamHeaders), path, body, request.ContentType, _diagnostics));
}
Interlocked.Increment(ref _diagnostics.TotalRequests);
@@ -465,22 +550,48 @@ private static void ProcessLogs(byte[] body, OtlpReceiverDiagnostics diag)
}
}
- private static async Task ForwardAsync(string upstream, string path, byte[] body, string? contentType)
+ private static async Task ForwardAsync(
+ string upstream,
+ IReadOnlyList>? headers,
+ string path,
+ byte[] body,
+ string? contentType,
+ OtlpReceiverDiagnostics diag)
{
try
{
- using var content = new ByteArrayContent(body);
+ using var request = new HttpRequestMessage(HttpMethod.Post, $"{upstream}{path}");
+ request.Content = new ByteArrayContent(body);
if (contentType is not null)
{
- content.Headers.TryAddWithoutValidation("Content-Type", contentType);
+ request.Content.Headers.TryAddWithoutValidation("Content-Type", contentType);
+ }
+
+ if (headers is not null)
+ {
+ foreach (var header in headers)
+ {
+ // Auth-style headers like x-otlp-api-key live on the request, not the
+ // content. TryAddWithoutValidation skips strict header-name checks so
+ // user-supplied values flow through.
+ request.Headers.TryAddWithoutValidation(header.Key, header.Value);
+ }
}
- await s_forwardingClient.PostAsync(
- $"{upstream}{path}",
- content).ConfigureAwait(false);
+ using var response = await s_forwardingClient.SendAsync(request).ConfigureAwait(false);
+ if (response.IsSuccessStatusCode)
+ {
+ Interlocked.Increment(ref diag.UpstreamForwardSuccess);
+ }
+ else
+ {
+ Interlocked.Increment(ref diag.UpstreamForwardFailures);
+ Trace.WriteLine($"[TUnit.OpenTelemetry] Upstream {path} returned {(int)response.StatusCode} {response.ReasonPhrase}.");
+ }
}
catch (Exception ex)
{
+ Interlocked.Increment(ref diag.UpstreamForwardFailures);
Trace.WriteLine($"[TUnit.OpenTelemetry] OTLP forwarding to upstream failed: {ex.Message}");
}
}
@@ -619,6 +730,12 @@ internal sealed class OtlpReceiverDiagnostics
internal int TracesRegisteredViaLink;
internal int TracesNoMatch;
+ // Upstream forwarding (Aspire dashboard etc.) — distinguish "didn't try" from
+ // "tried and the dashboard rejected" so users can tell whether the proxy step
+ // is actually reaching the dashboard.
+ internal int UpstreamForwardSuccess;
+ internal int UpstreamForwardFailures;
+
///
/// Records a request path that didn't match any known OTLP signal endpoint. Caps the
/// distinct-path set at to avoid unbounded growth
@@ -631,6 +748,9 @@ public void RecordUnknownPath(string path)
path = "(empty)";
}
+ // Soft cap: ContainsKey/Count are checked outside the dictionary lock, so concurrent
+ // callers may push a few entries past MaxTrackedUnknownPaths under a burst. Acceptable
+ // for a diagnostic-only path — exact enforcement isn't worth the contention.
if (_unknownPaths.ContainsKey(path) || _unknownPaths.Count < MaxTrackedUnknownPaths)
{
_unknownPaths.AddOrUpdate(path, 1, static (_, c) => c + 1);
@@ -665,6 +785,8 @@ public string FormatSummary(int port)
sb.AppendLine($" traces.unique_already_registered = {TracesAlreadyRegistered}");
sb.AppendLine($" traces.unique_registered_via_link = {TracesRegisteredViaLink}");
sb.AppendLine($" traces.unique_no_match = {TracesNoMatch}");
+ sb.AppendLine($" upstream.forward_success = {UpstreamForwardSuccess}");
+ sb.AppendLine($" upstream.forward_failures = {UpstreamForwardFailures}");
return sb.ToString();
}
}
From cf4e7e138d985c60c935ffe6388bbf8054a41175 Mon Sep 17 00:00:00 2001
From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com>
Date: Sun, 10 May 2026 15:29:44 +0100
Subject: [PATCH 13/18] refactor(otel): tighten receiver after review
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
* Reuse LoopbackHttpListenerFactory.FindFreePort in tests instead of a
duplicate TcpListener probe.
* ForwardAsync becomes an instance method — drops 2 redundant params and
reads _upstreamHeaders/_diagnostics from instance state directly.
* Use HttpCompletionOption.ResponseHeadersRead on forwarding so we don't
buffer the dashboard's response body for a fire-and-forget proxy.
* DrainAsync uses Stopwatch instead of DateTime.UtcNow — monotonic and
immune to system-clock jumps mid-drain.
* Hoist TUNIT_OTLP_DRAIN_MS / TUNIT_OTLP_DEBUG to private const fields.
* UpstreamHeaders surface is IReadOnlyDictionary instead of
IReadOnlyList — matches OTLP_HEADERS semantics.
* Forwarding test wraps upstream-listener teardown in try/finally so a
failed assertion doesn't leak the HttpListener.
---
TUnit.Aspire/AspireFixture.cs | 6 +--
.../OtlpReceiverIngestionTests.cs | 51 ++++++++++---------
TUnit.OpenTelemetry/Receiver/OtlpReceiver.cs | 41 +++++++--------
3 files changed, 48 insertions(+), 50 deletions(-)
diff --git a/TUnit.Aspire/AspireFixture.cs b/TUnit.Aspire/AspireFixture.cs
index 4d5484a1f0..9c416d057a 100644
--- a/TUnit.Aspire/AspireFixture.cs
+++ b/TUnit.Aspire/AspireFixture.cs
@@ -425,14 +425,14 @@ private void StartOtlpReceiver()
/// endpoints when auth is enabled (the default since Aspire 9), so without forwarding
/// these the dashboard rejects every proxied span.
///
- private static IReadOnlyList>? ParseOtlpHeaders(string? raw)
+ private static IReadOnlyDictionary? ParseOtlpHeaders(string? raw)
{
if (string.IsNullOrWhiteSpace(raw))
{
return null;
}
- var result = new List>();
+ var result = new Dictionary(StringComparer.OrdinalIgnoreCase);
foreach (var pair in raw.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
{
var eq = pair.IndexOf('=');
@@ -445,7 +445,7 @@ private void StartOtlpReceiver()
var value = pair[(eq + 1)..].Trim();
if (key.Length > 0)
{
- result.Add(new KeyValuePair(key, value));
+ result[key] = value;
}
}
diff --git a/TUnit.OpenTelemetry.Tests/OtlpReceiverIngestionTests.cs b/TUnit.OpenTelemetry.Tests/OtlpReceiverIngestionTests.cs
index f24968b87a..c654ea0b9a 100644
--- a/TUnit.OpenTelemetry.Tests/OtlpReceiverIngestionTests.cs
+++ b/TUnit.OpenTelemetry.Tests/OtlpReceiverIngestionTests.cs
@@ -148,7 +148,7 @@ public async Task Receiver_Forwarding_PropagatesHeadersAndCountsSuccess()
// so we can prove the receiver actually propagated it instead of just dropping body
// bytes onto a permissive endpoint.
using var upstreamListener = new HttpListener();
- var upstreamPort = FindFreeLoopbackPort();
+ var upstreamPort = LoopbackHttpListenerFactory.FindFreePort();
upstreamListener.Prefixes.Add($"http://127.0.0.1:{upstreamPort}/");
upstreamListener.Start();
@@ -161,35 +161,36 @@ public async Task Receiver_Forwarding_PropagatesHeadersAndCountsSuccess()
ctx.Response.Close();
});
- await using var receiver = new OtlpReceiver($"http://127.0.0.1:{upstreamPort}")
+ try
{
- UpstreamHeaders = [new KeyValuePair("x-otlp-api-key", "test-token-abc")],
- };
- receiver.Start();
-
- using var client = new HttpClient();
- using var content = new ByteArrayContent(Array.Empty());
- content.Headers.ContentType = new("application/x-protobuf");
- await client.PostAsync($"http://127.0.0.1:{receiver.Port}/v1/traces", content);
+ await using var receiver = new OtlpReceiver($"http://127.0.0.1:{upstreamPort}")
+ {
+ UpstreamHeaders = new Dictionary(StringComparer.OrdinalIgnoreCase)
+ {
+ ["x-otlp-api-key"] = "test-token-abc",
+ },
+ };
+ receiver.Start();
- await receiver.DrainAsync(TimeSpan.FromSeconds(3));
+ using var client = new HttpClient();
+ using var content = new ByteArrayContent(Array.Empty());
+ content.Headers.ContentType = new("application/x-protobuf");
+ await client.PostAsync($"http://127.0.0.1:{receiver.Port}/v1/traces", content);
- var auth = await receivedAuth.Task.WaitAsync(TimeSpan.FromSeconds(2));
- upstreamListener.Stop();
- await listenerTask;
+ await receiver.DrainAsync(TimeSpan.FromSeconds(3));
- await Assert.That(auth).IsEqualTo("test-token-abc");
- await Assert.That(receiver.Diagnostics.UpstreamForwardSuccess).IsEqualTo(1);
- await Assert.That(receiver.Diagnostics.UpstreamForwardFailures).IsEqualTo(0);
- }
+ var auth = await receivedAuth.Task.WaitAsync(TimeSpan.FromSeconds(2));
- private static int FindFreeLoopbackPort()
- {
- using var probe = new System.Net.Sockets.TcpListener(System.Net.IPAddress.Loopback, 0);
- probe.Start();
- var port = ((System.Net.IPEndPoint)probe.LocalEndpoint).Port;
- probe.Stop();
- return port;
+ await Assert.That(auth).IsEqualTo("test-token-abc");
+ await Assert.That(receiver.Diagnostics.UpstreamForwardSuccess).IsEqualTo(1);
+ await Assert.That(receiver.Diagnostics.UpstreamForwardFailures).IsEqualTo(0);
+ }
+ finally
+ {
+ upstreamListener.Stop();
+ // Ignore — listener context may already be torn down on assertion failure.
+ try { await listenerTask; } catch { }
+ }
}
[Test]
diff --git a/TUnit.OpenTelemetry/Receiver/OtlpReceiver.cs b/TUnit.OpenTelemetry/Receiver/OtlpReceiver.cs
index c1f6bddd6e..385de01c43 100644
--- a/TUnit.OpenTelemetry/Receiver/OtlpReceiver.cs
+++ b/TUnit.OpenTelemetry/Receiver/OtlpReceiver.cs
@@ -26,6 +26,8 @@ namespace TUnit.OpenTelemetry.Receiver;
internal sealed class OtlpReceiver : IAsyncDisposable
{
private const long MaxBodyBytes = 16 * 1024 * 1024; // 16 MB
+ private const string DrainWindowEnvVar = "TUNIT_OTLP_DRAIN_MS";
+ private const string DiagnosticsDumpEnvVar = "TUNIT_OTLP_DEBUG";
private static readonly HttpClient s_forwardingClient = new();
@@ -34,7 +36,7 @@ internal sealed class OtlpReceiver : IAsyncDisposable
private readonly ConcurrentDictionary _inflightTasks = new();
private readonly OtlpReceiverDiagnostics _diagnostics = new();
private string? _upstreamEndpoint;
- private IReadOnlyList>? _upstreamHeaders;
+ private IReadOnlyDictionary? _upstreamHeaders;
private Task? _listenTask;
private int _taskIdCounter;
@@ -83,7 +85,7 @@ public string? UpstreamEndpoint
/// gates its OTLP endpoints on an x-otlp-api-key header; without it, forwarding
/// returns 401 and the dashboard never sees the SUT's spans.
///
- public IReadOnlyList>? UpstreamHeaders
+ public IReadOnlyDictionary? UpstreamHeaders
{
get => Volatile.Read(ref _upstreamHeaders);
set => Volatile.Write(ref _upstreamHeaders, value);
@@ -136,7 +138,8 @@ private async Task ListenLoop()
public async Task DrainAsync(TimeSpan? window = null, CancellationToken cancellationToken = default)
{
var stableFor = TimeSpan.FromMilliseconds(250);
- var deadline = DateTime.UtcNow + (window ?? DefaultDrainWindow);
+ var totalWindow = window ?? DefaultDrainWindow;
+ var clock = Stopwatch.StartNew();
while (!cancellationToken.IsCancellationRequested)
{
@@ -152,7 +155,7 @@ public async Task DrainAsync(TimeSpan? window = null, CancellationToken cancella
// is best-effort and shouldn't surface them.
}
- var remaining = deadline - DateTime.UtcNow;
+ var remaining = totalWindow - clock.Elapsed;
if (remaining <= TimeSpan.Zero)
{
return;
@@ -185,7 +188,7 @@ public static TimeSpan DefaultDrainWindow
{
get
{
- var raw = Environment.GetEnvironmentVariable("TUNIT_OTLP_DRAIN_MS");
+ var raw = Environment.GetEnvironmentVariable(DrainWindowEnvVar);
if (!string.IsNullOrEmpty(raw)
&& int.TryParse(raw, System.Globalization.NumberStyles.Integer, System.Globalization.CultureInfo.InvariantCulture, out var ms)
&& ms >= 0)
@@ -315,7 +318,7 @@ private async Task ProcessRequestAsync(HttpListenerContext context)
var upstream = Volatile.Read(ref _upstreamEndpoint);
if (upstream is not null)
{
- TrackTask(ForwardAsync(upstream, Volatile.Read(ref _upstreamHeaders), path, body, request.ContentType, _diagnostics));
+ TrackTask(ForwardAsync(upstream, path, body, request.ContentType));
}
Interlocked.Increment(ref _diagnostics.TotalRequests);
@@ -550,13 +553,7 @@ private static void ProcessLogs(byte[] body, OtlpReceiverDiagnostics diag)
}
}
- private static async Task ForwardAsync(
- string upstream,
- IReadOnlyList>? headers,
- string path,
- byte[] body,
- string? contentType,
- OtlpReceiverDiagnostics diag)
+ private async Task ForwardAsync(string upstream, string path, byte[] body, string? contentType)
{
try
{
@@ -567,31 +564,31 @@ private static async Task ForwardAsync(
request.Content.Headers.TryAddWithoutValidation("Content-Type", contentType);
}
+ var headers = Volatile.Read(ref _upstreamHeaders);
if (headers is not null)
{
foreach (var header in headers)
{
- // Auth-style headers like x-otlp-api-key live on the request, not the
- // content. TryAddWithoutValidation skips strict header-name checks so
- // user-supplied values flow through.
request.Headers.TryAddWithoutValidation(header.Key, header.Value);
}
}
- using var response = await s_forwardingClient.SendAsync(request).ConfigureAwait(false);
+ using var response = await s_forwardingClient
+ .SendAsync(request, HttpCompletionOption.ResponseHeadersRead)
+ .ConfigureAwait(false);
if (response.IsSuccessStatusCode)
{
- Interlocked.Increment(ref diag.UpstreamForwardSuccess);
+ Interlocked.Increment(ref _diagnostics.UpstreamForwardSuccess);
}
else
{
- Interlocked.Increment(ref diag.UpstreamForwardFailures);
+ Interlocked.Increment(ref _diagnostics.UpstreamForwardFailures);
Trace.WriteLine($"[TUnit.OpenTelemetry] Upstream {path} returned {(int)response.StatusCode} {response.ReasonPhrase}.");
}
}
catch (Exception ex)
{
- Interlocked.Increment(ref diag.UpstreamForwardFailures);
+ Interlocked.Increment(ref _diagnostics.UpstreamForwardFailures);
Trace.WriteLine($"[TUnit.OpenTelemetry] OTLP forwarding to upstream failed: {ex.Message}");
}
}
@@ -669,7 +666,7 @@ private static bool LooksLikeGrpc(string? contentType, string path)
private static bool IsDiagnosticsDumpEnabled()
{
- var value = Environment.GetEnvironmentVariable("TUNIT_OTLP_DEBUG");
+ var value = Environment.GetEnvironmentVariable(DiagnosticsDumpEnvVar);
return !string.IsNullOrEmpty(value)
&& !string.Equals(value, "0", StringComparison.Ordinal)
&& !string.Equals(value, "false", StringComparison.OrdinalIgnoreCase);
@@ -821,7 +818,7 @@ internal static (HttpListener Listener, int Port) Create()
throw new InvalidOperationException($"Could not bind loopback HttpListener after {MaxPortBindingAttempts} attempts.");
}
- private static int FindFreePort()
+ internal static int FindFreePort()
{
using var listener = new TcpListener(IPAddress.Loopback, 0);
listener.Start();
From a45bfab618ed0e43aaa85bf71b153acf21077eea Mon Sep 17 00:00:00 2001
From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com>
Date: Sun, 10 May 2026 15:46:43 +0100
Subject: [PATCH 14/18] =?UTF-8?q?fix(otel):=20address=20review=20on=20PR?=
=?UTF-8?q?=205847=20=E2=80=94=20drain=20doc,=20header=20parsing,=20log=20?=
=?UTF-8?q?routing?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
* DrainAsync XML doc now states the quiet-period heuristic explicitly so
TUNIT_OTLP_DRAIN_MS users don't expect "longer = guaranteed complete".
* DefaultDrainWindow: parse env var once at type init via a static readonly
rather than re-reading on every property access.
* AspireFixture.ParseOtlpHeaders: accept "key=" (empty value) — valid per
OTEL header spec — by dropping the trailing-empty rejection. Comment why
IndexOf('=') is correct for base64-padded API keys.
* TraceRegistry.TryRegisterDerivedTrace: emit a Trace.WriteLine when the
source trace has tests but no context-id mapping, so a missing log line
in the report points at a concrete cause instead of silent drop.
* HtmlReportGenerator: comment the JS 'test body' literal as bound to
TUnitActivitySource.SpanTestBody so a future C# rename doesn't break the
client-side collapse silently.
* HtmlReporterTests: clarify the round-trip test covers the server-side
data path only; client-side collapse runs in the browser.
---
TUnit.Aspire/AspireFixture.cs | 5 ++-
TUnit.Core/TraceRegistry.cs | 10 ++++++
TUnit.Engine.Tests/HtmlReporterTests.cs | 7 ++--
.../Reporters/Html/HtmlReportGenerator.cs | 1 +
TUnit.OpenTelemetry/Receiver/OtlpReceiver.cs | 32 +++++++++++--------
5 files changed, 38 insertions(+), 17 deletions(-)
diff --git a/TUnit.Aspire/AspireFixture.cs b/TUnit.Aspire/AspireFixture.cs
index 9c416d057a..f4744ec315 100644
--- a/TUnit.Aspire/AspireFixture.cs
+++ b/TUnit.Aspire/AspireFixture.cs
@@ -435,8 +435,11 @@ private void StartOtlpReceiver()
var result = new Dictionary(StringComparer.OrdinalIgnoreCase);
foreach (var pair in raw.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
{
+ // Split on the first '=' only — base64 tokens (api keys) commonly carry trailing
+ // '=' padding that must stay in the value, and the OTEL spec permits empty values
+ // (key=) so we don't reject them.
var eq = pair.IndexOf('=');
- if (eq <= 0 || eq == pair.Length - 1)
+ if (eq <= 0)
{
continue;
}
diff --git a/TUnit.Core/TraceRegistry.cs b/TUnit.Core/TraceRegistry.cs
index b7fae450cd..f26e504291 100644
--- a/TUnit.Core/TraceRegistry.cs
+++ b/TUnit.Core/TraceRegistry.cs
@@ -1,5 +1,6 @@
#if NET
using System.Collections.Concurrent;
+using System.Diagnostics;
namespace TUnit.Core;
@@ -96,6 +97,15 @@ internal static bool TryRegisterDerivedTrace(string derivedTraceId, string sourc
{
TraceToContextId.TryAdd(derivedTraceId, contextId);
}
+ else
+ {
+ // Source trace had test associations but no context-id mapping. The derived
+ // trace's TraceToTests entry will work for span correlation, but log routing
+ // through GetContextId will return null and ProcessLogs will silently drop
+ // records. Surface that here so a missing log line in the report points at a
+ // concrete cause instead of "nothing happened".
+ Trace.WriteLine($"[TUnit.Core] TraceRegistry.TryRegisterDerivedTrace: source trace {sourceTraceId} has no context-id mapping; logs for derived trace {derivedTraceId} will not be routed to a test.");
+ }
return true;
}
diff --git a/TUnit.Engine.Tests/HtmlReporterTests.cs b/TUnit.Engine.Tests/HtmlReporterTests.cs
index 6399916344..d068562aee 100644
--- a/TUnit.Engine.Tests/HtmlReporterTests.cs
+++ b/TUnit.Engine.Tests/HtmlReporterTests.cs
@@ -131,9 +131,10 @@ public void OrderTestsForDisplay_SortsByStartTime_ThenName()
[Test]
public void GenerateHtml_RoundTrips_TestBodySpans_AndChildren_Through_EmbeddedData()
{
- // Exercise the data path the class timeline depends on: a 'test body' span with
- // children must reach the report's embedded JSON so the client-side collapse logic
- // has the data it needs to re-parent children to the test-case span.
+ // Server-side data path only — the client-side collapseTestBodySpans JS runs in the
+ // browser and is not exercised here. This test pins down the contract the JS relies
+ // on: a 'test body' span with children survives serialisation into the embedded
+ // JSON so the JS can re-parent children to the test-case span at render time.
const string traceId = "0123456789abcdef0123456789abcdef";
var spans = new[]
{
diff --git a/TUnit.Engine/Reporters/Html/HtmlReportGenerator.cs b/TUnit.Engine/Reporters/Html/HtmlReportGenerator.cs
index cac409ea9a..aed0c810a3 100644
--- a/TUnit.Engine/Reporters/Html/HtmlReportGenerator.cs
+++ b/TUnit.Engine/Reporters/Html/HtmlReportGenerator.cs
@@ -1610,6 +1610,7 @@ function walk(sid) {
return traceSpans.filter(s => included.has(s.spanId));
}
+// 'test body' must match TUnitActivitySource.SpanTestBody in C#.
function collapseTestBodySpans(spans) {
if (!spans || !spans.length) return [];
const byId = {};
diff --git a/TUnit.OpenTelemetry/Receiver/OtlpReceiver.cs b/TUnit.OpenTelemetry/Receiver/OtlpReceiver.cs
index 385de01c43..50b5d240d6 100644
--- a/TUnit.OpenTelemetry/Receiver/OtlpReceiver.cs
+++ b/TUnit.OpenTelemetry/Receiver/OtlpReceiver.cs
@@ -133,6 +133,13 @@ private async Task ListenLoop()
/// this, fast tests can finish and tear down their AppHost before exporters drain, dropping
/// the trailing telemetry the report is meant to show.
///
+ ///
+ /// Best-effort heuristic: the drain returns as soon as no new requests arrive for the
+ /// stable window. Spans the SUT exports after the drain returns can still be missed —
+ /// this isn't an explicit OTel flush, since exporters in another process can't be
+ /// signalled directly. Increase TUNIT_OTLP_DRAIN_MS if your exporter's batch
+ /// schedule is longer than the default 2s.
+ ///
/// Maximum total time to wait. Defaults to .
/// Stops the wait early.
public async Task DrainAsync(TimeSpan? window = null, CancellationToken cancellationToken = default)
@@ -181,23 +188,22 @@ await Task.Delay(stableFor < remaining ? stableFor : remaining, cancellationToke
///
/// Default drain window applied by when no value is supplied.
- /// Honours the TUNIT_OTLP_DRAIN_MS environment variable so users with slow exporters
- /// can extend the wait without recompiling.
+ /// Honours the TUNIT_OTLP_DRAIN_MS environment variable, captured once at type
+ /// init so repeated reads don't pay env-var lookup cost.
///
- public static TimeSpan DefaultDrainWindow
+ public static TimeSpan DefaultDrainWindow { get; } = ResolveDefaultDrainWindow();
+
+ private static TimeSpan ResolveDefaultDrainWindow()
{
- get
+ var raw = Environment.GetEnvironmentVariable(DrainWindowEnvVar);
+ if (!string.IsNullOrEmpty(raw)
+ && int.TryParse(raw, System.Globalization.NumberStyles.Integer, System.Globalization.CultureInfo.InvariantCulture, out var ms)
+ && ms >= 0)
{
- var raw = Environment.GetEnvironmentVariable(DrainWindowEnvVar);
- if (!string.IsNullOrEmpty(raw)
- && int.TryParse(raw, System.Globalization.NumberStyles.Integer, System.Globalization.CultureInfo.InvariantCulture, out var ms)
- && ms >= 0)
- {
- return TimeSpan.FromMilliseconds(ms);
- }
-
- return TimeSpan.FromSeconds(2);
+ return TimeSpan.FromMilliseconds(ms);
}
+
+ return TimeSpan.FromSeconds(2);
}
private void TrackTask(Task task)
From cf1f977ce62195ebd489c9a89fa3677a5169d8fd Mon Sep 17 00:00:00 2001
From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com>
Date: Sun, 10 May 2026 16:07:01 +0100
Subject: [PATCH 15/18] feat(report): add Report.ExpandClassTimeline opt-in
setting
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The class-timeline rewrite from earlier in this PR (#5847) made test-case
spans + their non-'test body' children visible in the class timeline. Great
for BDD-style [DependsOn] flows but noisy for classes of independent tests.
* New TUnitSettings.Report bucket — separate from Display (console) so
future HTML report knobs have a home.
* Report.ExpandClassTimeline (default false) restores the pre-PR class
timeline filter (test cases + descendants excluded) for non-BDD classes;
set to true to opt into the multi-step view.
* JS branches in renderSuiteTrace honour the flag at render time.
* Refresh PublicAPI snapshots for the new ReportSettings type.
* Side fixes from the same review:
- Sanitise HTTP ReasonPhrase before Trace.WriteLine to silence the
CodeQL log-injection finding (response header is attacker-controlled
in principle).
- Assert Activity.Current is non-null in the linked-trace ingestion test
so a missing parent span fails with a useful message instead of NRE.
---
TUnit.Core/Settings/ReportSettings.cs | 19 +++++++++++++++++++
TUnit.Core/Settings/TUnitSettings.cs | 5 +++++
.../Reporters/Html/HtmlReportDataModel.cs | 3 +++
.../Reporters/Html/HtmlReportGenerator.cs | 16 +++++++++++++++-
TUnit.Engine/Reporters/Html/HtmlReporter.cs | 3 ++-
.../OtlpReceiverIngestionTests.cs | 1 +
TUnit.OpenTelemetry/Receiver/OtlpReceiver.cs | 6 +++++-
...Has_No_API_Changes.DotNet10_0.verified.txt | 6 ++++++
..._Has_No_API_Changes.DotNet8_0.verified.txt | 6 ++++++
..._Has_No_API_Changes.DotNet9_0.verified.txt | 6 ++++++
...ary_Has_No_API_Changes.Net4_7.verified.txt | 6 ++++++
TUnit.UnitTests/TUnitSettingsTests.cs | 4 ++++
12 files changed, 78 insertions(+), 3 deletions(-)
create mode 100644 TUnit.Core/Settings/ReportSettings.cs
diff --git a/TUnit.Core/Settings/ReportSettings.cs b/TUnit.Core/Settings/ReportSettings.cs
new file mode 100644
index 0000000000..f8b31aa42f
--- /dev/null
+++ b/TUnit.Core/Settings/ReportSettings.cs
@@ -0,0 +1,19 @@
+namespace TUnit.Core.Settings;
+
+///
+/// Controls HTML report rendering. Independent from , which
+/// governs console output.
+///
+public sealed class ReportSettings
+{
+ internal ReportSettings() { }
+
+ ///
+ /// When true, the HTML report's class timeline includes each test-case span and
+ /// its non-test body children, making BDD-style [DependsOn] chains visible
+ /// at the class level. When false (default), the class timeline shows only
+ /// class-level infrastructure spans (suite, init/dispose) — quieter for classes of
+ /// independent tests.
+ ///
+ public bool ExpandClassTimeline { get; set; }
+}
diff --git a/TUnit.Core/Settings/TUnitSettings.cs b/TUnit.Core/Settings/TUnitSettings.cs
index cc15574a89..bb864d535a 100644
--- a/TUnit.Core/Settings/TUnitSettings.cs
+++ b/TUnit.Core/Settings/TUnitSettings.cs
@@ -38,4 +38,9 @@ internal TUnitSettings() { }
/// Controls test run behavior.
///
public ExecutionSettings Execution { get; } = new();
+
+ ///
+ /// Controls HTML report rendering.
+ ///
+ public ReportSettings Report { get; } = new();
}
diff --git a/TUnit.Engine/Reporters/Html/HtmlReportDataModel.cs b/TUnit.Engine/Reporters/Html/HtmlReportDataModel.cs
index 5589f26169..0a284c4e5c 100644
--- a/TUnit.Engine/Reporters/Html/HtmlReportDataModel.cs
+++ b/TUnit.Engine/Reporters/Html/HtmlReportDataModel.cs
@@ -49,6 +49,9 @@ internal sealed class ReportData
[JsonPropertyName("repositorySlug")]
public string? RepositorySlug { get; init; }
+
+ [JsonPropertyName("expandClassTimeline")]
+ public bool ExpandClassTimeline { get; init; }
}
internal sealed class ReportSummary
diff --git a/TUnit.Engine/Reporters/Html/HtmlReportGenerator.cs b/TUnit.Engine/Reporters/Html/HtmlReportGenerator.cs
index aed0c810a3..3fcf9ead65 100644
--- a/TUnit.Engine/Reporters/Html/HtmlReportGenerator.cs
+++ b/TUnit.Engine/Reporters/Html/HtmlReportGenerator.cs
@@ -1736,7 +1736,21 @@ function renderSuiteTrace(className) {
if (!suite) return '';
const allSpans = spansByTrace[suite.traceId];
if (!allSpans) return '';
- const filtered = collapseTestBodySpans(getDescendants(allSpans, suite.spanId));
+ const all = getDescendants(allSpans, suite.spanId);
+ let filtered;
+ if (data.expandClassTimeline) {
+ // BDD/DependsOn mode: include test-case spans and their non-'test body' children
+ // so multi-step flows are visible at the class level.
+ filtered = collapseTestBodySpans(all);
+ } else {
+ // Default: drop test-case spans and their full subtrees so the class timeline
+ // shows only class-level infrastructure (suite, init/dispose, parallel coordination).
+ 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); }); });
+ filtered = all.filter(s => !tcDescendants.has(s.spanId) && !testCaseIds.has(s.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 58c6f81c04..80b36880cc 100644
--- a/TUnit.Engine/Reporters/Html/HtmlReporter.cs
+++ b/TUnit.Engine/Reporters/Html/HtmlReporter.cs
@@ -376,7 +376,8 @@ private ReportData BuildReportData()
CommitSha = commitSha,
Branch = branch,
PullRequestNumber = prNumber,
- RepositorySlug = repoSlug
+ RepositorySlug = repoSlug,
+ ExpandClassTimeline = TUnit.Core.Settings.TUnitSettings.Default.Report.ExpandClassTimeline,
};
}
diff --git a/TUnit.OpenTelemetry.Tests/OtlpReceiverIngestionTests.cs b/TUnit.OpenTelemetry.Tests/OtlpReceiverIngestionTests.cs
index c654ea0b9a..5842ae9b9a 100644
--- a/TUnit.OpenTelemetry.Tests/OtlpReceiverIngestionTests.cs
+++ b/TUnit.OpenTelemetry.Tests/OtlpReceiverIngestionTests.cs
@@ -229,6 +229,7 @@ public async Task Receiver_ParsedLinkedTrace_RegistersAgainstOwningTest()
{
var collector = ActivityCollector.Current;
await Assert.That(collector).IsNotNull();
+ await Assert.That(Activity.Current).IsNotNull();
await using var receiver = new OtlpReceiver();
receiver.Start();
diff --git a/TUnit.OpenTelemetry/Receiver/OtlpReceiver.cs b/TUnit.OpenTelemetry/Receiver/OtlpReceiver.cs
index 50b5d240d6..7dfbeea679 100644
--- a/TUnit.OpenTelemetry/Receiver/OtlpReceiver.cs
+++ b/TUnit.OpenTelemetry/Receiver/OtlpReceiver.cs
@@ -589,7 +589,11 @@ private async Task ForwardAsync(string upstream, string path, byte[] body, strin
else
{
Interlocked.Increment(ref _diagnostics.UpstreamForwardFailures);
- Trace.WriteLine($"[TUnit.OpenTelemetry] Upstream {path} returned {(int)response.StatusCode} {response.ReasonPhrase}.");
+ // Strip CR/LF from ReasonPhrase before logging — it's an HTTP response header
+ // value and could otherwise inject newlines into the trace output, breaking
+ // log parsing tools (CodeQL log-injection rule).
+ var safePhrase = response.ReasonPhrase?.Replace('\r', ' ').Replace('\n', ' ');
+ Trace.WriteLine($"[TUnit.OpenTelemetry] Upstream {path} returned {(int)response.StatusCode} {safePhrase}.");
}
}
catch (Exception ex)
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 7023b1601f..6d96c41c58 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
@@ -2,6 +2,7 @@
[assembly: .(@", PublicKey=0024000004800000940000000602000000240000525341310004000001000100698a70398fa0b2230c5a72e3bd9d56b48f809f6173e49a19fbb942d621be93ad48c5566b47b28faabc359b9ad3ff4e00bbdea88f5bdfa250f391fedd28182b2e37b55d429c0151a42a98ea7a5821818cd15a79fef9903e8607a88304cf3e0317bf86ec96e32e1381535a6582251e5a6eed40b5a3ed82bc444598b1269cce57a7")]
[assembly: .(@".Tests, PublicKey=0024000004800000940000000602000000240000525341310004000001000100698a70398fa0b2230c5a72e3bd9d56b48f809f6173e49a19fbb942d621be93ad48c5566b47b28faabc359b9ad3ff4e00bbdea88f5bdfa250f391fedd28182b2e37b55d429c0151a42a98ea7a5821818cd15a79fef9903e8607a88304cf3e0317bf86ec96e32e1381535a6582251e5a6eed40b5a3ed82bc444598b1269cce57a7")]
[assembly: .(@", PublicKey=0024000004800000940000000602000000240000525341310004000001000100698a70398fa0b2230c5a72e3bd9d56b48f809f6173e49a19fbb942d621be93ad48c5566b47b28faabc359b9ad3ff4e00bbdea88f5bdfa250f391fedd28182b2e37b55d429c0151a42a98ea7a5821818cd15a79fef9903e8607a88304cf3e0317bf86ec96e32e1381535a6582251e5a6eed40b5a3ed82bc444598b1269cce57a7")]
+[assembly: .(@".Tests, PublicKey=0024000004800000940000000602000000240000525341310004000001000100698a70398fa0b2230c5a72e3bd9d56b48f809f6173e49a19fbb942d621be93ad48c5566b47b28faabc359b9ad3ff4e00bbdea88f5bdfa250f391fedd28182b2e37b55d429c0151a42a98ea7a5821818cd15a79fef9903e8607a88304cf3e0317bf86ec96e32e1381535a6582251e5a6eed40b5a3ed82bc444598b1269cce57a7")]
[assembly: .(@".Microsoft, PublicKey=0024000004800000940000000602000000240000525341310004000001000100698a70398fa0b2230c5a72e3bd9d56b48f809f6173e49a19fbb942d621be93ad48c5566b47b28faabc359b9ad3ff4e00bbdea88f5bdfa250f391fedd28182b2e37b55d429c0151a42a98ea7a5821818cd15a79fef9903e8607a88304cf3e0317bf86ec96e32e1381535a6582251e5a6eed40b5a3ed82bc444598b1269cce57a7")]
[assembly: .(@", PublicKey=0024000004800000940000000602000000240000525341310004000001000100698a70398fa0b2230c5a72e3bd9d56b48f809f6173e49a19fbb942d621be93ad48c5566b47b28faabc359b9ad3ff4e00bbdea88f5bdfa250f391fedd28182b2e37b55d429c0151a42a98ea7a5821818cd15a79fef9903e8607a88304cf3e0317bf86ec96e32e1381535a6582251e5a6eed40b5a3ed82bc444598b1269cce57a7")]
[assembly: .(@".Tests, PublicKey=0024000004800000940000000602000000240000525341310004000001000100698a70398fa0b2230c5a72e3bd9d56b48f809f6173e49a19fbb942d621be93ad48c5566b47b28faabc359b9ad3ff4e00bbdea88f5bdfa250f391fedd28182b2e37b55d429c0151a42a98ea7a5821818cd15a79fef9903e8607a88304cf3e0317bf86ec96e32e1381535a6582251e5a6eed40b5a3ed82bc444598b1269cce57a7")]
@@ -2974,11 +2975,16 @@ namespace .Settings
{
public int? MaximumParallelTests { get; set; }
}
+ public sealed class ReportSettings
+ {
+ public bool ExpandClassTimeline { get; set; }
+ }
public sealed class TUnitSettings
{
public . Display { get; }
public . Execution { get; }
public . Parallelism { get; }
+ public . Report { get; }
public . Timeouts { get; }
}
public sealed class TimeoutSettings
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 727930ed31..87db8453d8 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
@@ -2,6 +2,7 @@
[assembly: .(@", PublicKey=0024000004800000940000000602000000240000525341310004000001000100698a70398fa0b2230c5a72e3bd9d56b48f809f6173e49a19fbb942d621be93ad48c5566b47b28faabc359b9ad3ff4e00bbdea88f5bdfa250f391fedd28182b2e37b55d429c0151a42a98ea7a5821818cd15a79fef9903e8607a88304cf3e0317bf86ec96e32e1381535a6582251e5a6eed40b5a3ed82bc444598b1269cce57a7")]
[assembly: .(@".Tests, PublicKey=0024000004800000940000000602000000240000525341310004000001000100698a70398fa0b2230c5a72e3bd9d56b48f809f6173e49a19fbb942d621be93ad48c5566b47b28faabc359b9ad3ff4e00bbdea88f5bdfa250f391fedd28182b2e37b55d429c0151a42a98ea7a5821818cd15a79fef9903e8607a88304cf3e0317bf86ec96e32e1381535a6582251e5a6eed40b5a3ed82bc444598b1269cce57a7")]
[assembly: .(@", PublicKey=0024000004800000940000000602000000240000525341310004000001000100698a70398fa0b2230c5a72e3bd9d56b48f809f6173e49a19fbb942d621be93ad48c5566b47b28faabc359b9ad3ff4e00bbdea88f5bdfa250f391fedd28182b2e37b55d429c0151a42a98ea7a5821818cd15a79fef9903e8607a88304cf3e0317bf86ec96e32e1381535a6582251e5a6eed40b5a3ed82bc444598b1269cce57a7")]
+[assembly: .(@".Tests, PublicKey=0024000004800000940000000602000000240000525341310004000001000100698a70398fa0b2230c5a72e3bd9d56b48f809f6173e49a19fbb942d621be93ad48c5566b47b28faabc359b9ad3ff4e00bbdea88f5bdfa250f391fedd28182b2e37b55d429c0151a42a98ea7a5821818cd15a79fef9903e8607a88304cf3e0317bf86ec96e32e1381535a6582251e5a6eed40b5a3ed82bc444598b1269cce57a7")]
[assembly: .(@".Microsoft, PublicKey=0024000004800000940000000602000000240000525341310004000001000100698a70398fa0b2230c5a72e3bd9d56b48f809f6173e49a19fbb942d621be93ad48c5566b47b28faabc359b9ad3ff4e00bbdea88f5bdfa250f391fedd28182b2e37b55d429c0151a42a98ea7a5821818cd15a79fef9903e8607a88304cf3e0317bf86ec96e32e1381535a6582251e5a6eed40b5a3ed82bc444598b1269cce57a7")]
[assembly: .(@", PublicKey=0024000004800000940000000602000000240000525341310004000001000100698a70398fa0b2230c5a72e3bd9d56b48f809f6173e49a19fbb942d621be93ad48c5566b47b28faabc359b9ad3ff4e00bbdea88f5bdfa250f391fedd28182b2e37b55d429c0151a42a98ea7a5821818cd15a79fef9903e8607a88304cf3e0317bf86ec96e32e1381535a6582251e5a6eed40b5a3ed82bc444598b1269cce57a7")]
[assembly: .(@".Tests, PublicKey=0024000004800000940000000602000000240000525341310004000001000100698a70398fa0b2230c5a72e3bd9d56b48f809f6173e49a19fbb942d621be93ad48c5566b47b28faabc359b9ad3ff4e00bbdea88f5bdfa250f391fedd28182b2e37b55d429c0151a42a98ea7a5821818cd15a79fef9903e8607a88304cf3e0317bf86ec96e32e1381535a6582251e5a6eed40b5a3ed82bc444598b1269cce57a7")]
@@ -2974,11 +2975,16 @@ namespace .Settings
{
public int? MaximumParallelTests { get; set; }
}
+ public sealed class ReportSettings
+ {
+ public bool ExpandClassTimeline { get; set; }
+ }
public sealed class TUnitSettings
{
public . Display { get; }
public . Execution { get; }
public . Parallelism { get; }
+ public . Report { get; }
public . Timeouts { get; }
}
public sealed class TimeoutSettings
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 7a7fe98dc6..966ca22248 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
@@ -2,6 +2,7 @@
[assembly: .(@", PublicKey=0024000004800000940000000602000000240000525341310004000001000100698a70398fa0b2230c5a72e3bd9d56b48f809f6173e49a19fbb942d621be93ad48c5566b47b28faabc359b9ad3ff4e00bbdea88f5bdfa250f391fedd28182b2e37b55d429c0151a42a98ea7a5821818cd15a79fef9903e8607a88304cf3e0317bf86ec96e32e1381535a6582251e5a6eed40b5a3ed82bc444598b1269cce57a7")]
[assembly: .(@".Tests, PublicKey=0024000004800000940000000602000000240000525341310004000001000100698a70398fa0b2230c5a72e3bd9d56b48f809f6173e49a19fbb942d621be93ad48c5566b47b28faabc359b9ad3ff4e00bbdea88f5bdfa250f391fedd28182b2e37b55d429c0151a42a98ea7a5821818cd15a79fef9903e8607a88304cf3e0317bf86ec96e32e1381535a6582251e5a6eed40b5a3ed82bc444598b1269cce57a7")]
[assembly: .(@", PublicKey=0024000004800000940000000602000000240000525341310004000001000100698a70398fa0b2230c5a72e3bd9d56b48f809f6173e49a19fbb942d621be93ad48c5566b47b28faabc359b9ad3ff4e00bbdea88f5bdfa250f391fedd28182b2e37b55d429c0151a42a98ea7a5821818cd15a79fef9903e8607a88304cf3e0317bf86ec96e32e1381535a6582251e5a6eed40b5a3ed82bc444598b1269cce57a7")]
+[assembly: .(@".Tests, PublicKey=0024000004800000940000000602000000240000525341310004000001000100698a70398fa0b2230c5a72e3bd9d56b48f809f6173e49a19fbb942d621be93ad48c5566b47b28faabc359b9ad3ff4e00bbdea88f5bdfa250f391fedd28182b2e37b55d429c0151a42a98ea7a5821818cd15a79fef9903e8607a88304cf3e0317bf86ec96e32e1381535a6582251e5a6eed40b5a3ed82bc444598b1269cce57a7")]
[assembly: .(@".Microsoft, PublicKey=0024000004800000940000000602000000240000525341310004000001000100698a70398fa0b2230c5a72e3bd9d56b48f809f6173e49a19fbb942d621be93ad48c5566b47b28faabc359b9ad3ff4e00bbdea88f5bdfa250f391fedd28182b2e37b55d429c0151a42a98ea7a5821818cd15a79fef9903e8607a88304cf3e0317bf86ec96e32e1381535a6582251e5a6eed40b5a3ed82bc444598b1269cce57a7")]
[assembly: .(@", PublicKey=0024000004800000940000000602000000240000525341310004000001000100698a70398fa0b2230c5a72e3bd9d56b48f809f6173e49a19fbb942d621be93ad48c5566b47b28faabc359b9ad3ff4e00bbdea88f5bdfa250f391fedd28182b2e37b55d429c0151a42a98ea7a5821818cd15a79fef9903e8607a88304cf3e0317bf86ec96e32e1381535a6582251e5a6eed40b5a3ed82bc444598b1269cce57a7")]
[assembly: .(@".Tests, PublicKey=0024000004800000940000000602000000240000525341310004000001000100698a70398fa0b2230c5a72e3bd9d56b48f809f6173e49a19fbb942d621be93ad48c5566b47b28faabc359b9ad3ff4e00bbdea88f5bdfa250f391fedd28182b2e37b55d429c0151a42a98ea7a5821818cd15a79fef9903e8607a88304cf3e0317bf86ec96e32e1381535a6582251e5a6eed40b5a3ed82bc444598b1269cce57a7")]
@@ -2974,11 +2975,16 @@ namespace .Settings
{
public int? MaximumParallelTests { get; set; }
}
+ public sealed class ReportSettings
+ {
+ public bool ExpandClassTimeline { get; set; }
+ }
public sealed class TUnitSettings
{
public . Display { get; }
public . Execution { get; }
public . Parallelism { get; }
+ public . Report { get; }
public . Timeouts { get; }
}
public sealed class TimeoutSettings
diff --git a/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.Net4_7.verified.txt b/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.Net4_7.verified.txt
index 5a2f24547d..1bee3ea610 100644
--- a/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.Net4_7.verified.txt
+++ b/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.Net4_7.verified.txt
@@ -2,6 +2,7 @@
[assembly: .(@", PublicKey=0024000004800000940000000602000000240000525341310004000001000100698a70398fa0b2230c5a72e3bd9d56b48f809f6173e49a19fbb942d621be93ad48c5566b47b28faabc359b9ad3ff4e00bbdea88f5bdfa250f391fedd28182b2e37b55d429c0151a42a98ea7a5821818cd15a79fef9903e8607a88304cf3e0317bf86ec96e32e1381535a6582251e5a6eed40b5a3ed82bc444598b1269cce57a7")]
[assembly: .(@".Tests, PublicKey=0024000004800000940000000602000000240000525341310004000001000100698a70398fa0b2230c5a72e3bd9d56b48f809f6173e49a19fbb942d621be93ad48c5566b47b28faabc359b9ad3ff4e00bbdea88f5bdfa250f391fedd28182b2e37b55d429c0151a42a98ea7a5821818cd15a79fef9903e8607a88304cf3e0317bf86ec96e32e1381535a6582251e5a6eed40b5a3ed82bc444598b1269cce57a7")]
[assembly: .(@", PublicKey=0024000004800000940000000602000000240000525341310004000001000100698a70398fa0b2230c5a72e3bd9d56b48f809f6173e49a19fbb942d621be93ad48c5566b47b28faabc359b9ad3ff4e00bbdea88f5bdfa250f391fedd28182b2e37b55d429c0151a42a98ea7a5821818cd15a79fef9903e8607a88304cf3e0317bf86ec96e32e1381535a6582251e5a6eed40b5a3ed82bc444598b1269cce57a7")]
+[assembly: .(@".Tests, PublicKey=0024000004800000940000000602000000240000525341310004000001000100698a70398fa0b2230c5a72e3bd9d56b48f809f6173e49a19fbb942d621be93ad48c5566b47b28faabc359b9ad3ff4e00bbdea88f5bdfa250f391fedd28182b2e37b55d429c0151a42a98ea7a5821818cd15a79fef9903e8607a88304cf3e0317bf86ec96e32e1381535a6582251e5a6eed40b5a3ed82bc444598b1269cce57a7")]
[assembly: .(@".Microsoft, PublicKey=0024000004800000940000000602000000240000525341310004000001000100698a70398fa0b2230c5a72e3bd9d56b48f809f6173e49a19fbb942d621be93ad48c5566b47b28faabc359b9ad3ff4e00bbdea88f5bdfa250f391fedd28182b2e37b55d429c0151a42a98ea7a5821818cd15a79fef9903e8607a88304cf3e0317bf86ec96e32e1381535a6582251e5a6eed40b5a3ed82bc444598b1269cce57a7")]
[assembly: .(@", PublicKey=0024000004800000940000000602000000240000525341310004000001000100698a70398fa0b2230c5a72e3bd9d56b48f809f6173e49a19fbb942d621be93ad48c5566b47b28faabc359b9ad3ff4e00bbdea88f5bdfa250f391fedd28182b2e37b55d429c0151a42a98ea7a5821818cd15a79fef9903e8607a88304cf3e0317bf86ec96e32e1381535a6582251e5a6eed40b5a3ed82bc444598b1269cce57a7")]
[assembly: .(@".Tests, PublicKey=0024000004800000940000000602000000240000525341310004000001000100698a70398fa0b2230c5a72e3bd9d56b48f809f6173e49a19fbb942d621be93ad48c5566b47b28faabc359b9ad3ff4e00bbdea88f5bdfa250f391fedd28182b2e37b55d429c0151a42a98ea7a5821818cd15a79fef9903e8607a88304cf3e0317bf86ec96e32e1381535a6582251e5a6eed40b5a3ed82bc444598b1269cce57a7")]
@@ -2895,11 +2896,16 @@ namespace .Settings
{
public int? MaximumParallelTests { get; set; }
}
+ public sealed class ReportSettings
+ {
+ public bool ExpandClassTimeline { get; set; }
+ }
public sealed class TUnitSettings
{
public . Display { get; }
public . Execution { get; }
public . Parallelism { get; }
+ public . Report { get; }
public . Timeouts { get; }
}
public sealed class TimeoutSettings
diff --git a/TUnit.UnitTests/TUnitSettingsTests.cs b/TUnit.UnitTests/TUnitSettingsTests.cs
index 287fdbd63b..df4ced08be 100644
--- a/TUnit.UnitTests/TUnitSettingsTests.cs
+++ b/TUnit.UnitTests/TUnitSettingsTests.cs
@@ -18,6 +18,7 @@ public class TUnitSettingsTests
private int? _savedMaximumParallelTests;
private bool _savedDetailedStackTrace;
private bool _savedFailFast;
+ private bool _savedExpandClassTimeline;
[Before(HookType.Test)]
public void SnapshotSettings()
@@ -29,6 +30,7 @@ public void SnapshotSettings()
_savedMaximumParallelTests = TUnitSettings.Default.Parallelism.MaximumParallelTests;
_savedDetailedStackTrace = TUnitSettings.Default.Display.DetailedStackTrace;
_savedFailFast = TUnitSettings.Default.Execution.FailFast;
+ _savedExpandClassTimeline = TUnitSettings.Default.Report.ExpandClassTimeline;
}
[After(HookType.Test)]
@@ -41,6 +43,7 @@ public void RestoreSettings()
TUnitSettings.Default.Parallelism.MaximumParallelTests = _savedMaximumParallelTests;
TUnitSettings.Default.Display.DetailedStackTrace = _savedDetailedStackTrace;
TUnitSettings.Default.Execution.FailFast = _savedFailFast;
+ TUnitSettings.Default.Report.ExpandClassTimeline = _savedExpandClassTimeline;
}
[Test]
@@ -53,6 +56,7 @@ public async Task Defaults_Are_Correct()
await Assert.That(TUnitSettings.Default.Parallelism.MaximumParallelTests).IsNull();
await Assert.That(TUnitSettings.Default.Display.DetailedStackTrace).IsFalse();
await Assert.That(TUnitSettings.Default.Execution.FailFast).IsFalse();
+ await Assert.That(TUnitSettings.Default.Report.ExpandClassTimeline).IsFalse();
}
[Test]
From 723094f78f7a9dde0066e5f213cd650a43d84bef Mon Sep 17 00:00:00 2001
From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com>
Date: Sun, 10 May 2026 16:10:38 +0100
Subject: [PATCH 16/18] refactor(report): use bare TUnitSettings via using
directive
Match the convention used by every other engine call site (TestRunner, TestScheduler, HookTimeoutHelper, TestCoordinator, TUnitMessageBus).
---
TUnit.Engine/Reporters/Html/HtmlReporter.cs | 3 ++-
1 file changed, 2 insertions(+), 1 deletion(-)
diff --git a/TUnit.Engine/Reporters/Html/HtmlReporter.cs b/TUnit.Engine/Reporters/Html/HtmlReporter.cs
index 80b36880cc..f360f4b8b2 100644
--- a/TUnit.Engine/Reporters/Html/HtmlReporter.cs
+++ b/TUnit.Engine/Reporters/Html/HtmlReporter.cs
@@ -11,6 +11,7 @@
using Microsoft.Testing.Platform.Services;
using Microsoft.Testing.Platform.TestHost;
using TUnit.Core;
+using TUnit.Core.Settings;
using TUnit.Engine.Configuration;
using TUnit.Engine.Constants;
using TUnit.Engine.Exceptions;
@@ -377,7 +378,7 @@ private ReportData BuildReportData()
Branch = branch,
PullRequestNumber = prNumber,
RepositorySlug = repoSlug,
- ExpandClassTimeline = TUnit.Core.Settings.TUnitSettings.Default.Report.ExpandClassTimeline,
+ ExpandClassTimeline = TUnitSettings.Default.Report.ExpandClassTimeline,
};
}
From 13c605ee8a390639c813b33426fd8d0681c4cb11 Mon Sep 17 00:00:00 2001
From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com>
Date: Sun, 10 May 2026 16:19:59 +0100
Subject: [PATCH 17/18] docs+fix(report): address review on PR 5847 round 6/7
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
* TraceRegistry.TryRegisterDerivedTrace XML doc now states span correlation
succeeding does not guarantee log routing — mirrors the existing
Trace.WriteLine diagnostic so callers know what the bool actually means.
* OtlpReceiver.DrainAsync remarks now mention the fixed 250ms stable window
alongside the configurable TUNIT_OTLP_DRAIN_MS total cap.
* collapseTestBodySpans JS comment notes the asymmetric usage (always in
renderTrace, only in renderSuiteTrace when ExpandClassTimeline is on)
is intentional — the default class-timeline branch drops test cases +
subtrees so no test-body span survives to collapse.
* OrderTestsForDisplay parses StartTime to DateTimeOffset for ordering
instead of leaning on caller UTC normalization. ReportTestResult is
reachable from test projects via InternalsVisibleTo, so external
construction with non-UTC offsets is realistic; parsing makes the sort
correct regardless.
---
TUnit.Core/TraceRegistry.cs | 11 +++++++++--
.../Reporters/Html/HtmlReportGenerator.cs | 3 +++
TUnit.Engine/Reporters/Html/HtmlReporter.cs | 16 ++++++++++++----
TUnit.OpenTelemetry/Receiver/OtlpReceiver.cs | 4 ++++
4 files changed, 28 insertions(+), 6 deletions(-)
diff --git a/TUnit.Core/TraceRegistry.cs b/TUnit.Core/TraceRegistry.cs
index f26e504291..a294bf6d7c 100644
--- a/TUnit.Core/TraceRegistry.cs
+++ b/TUnit.Core/TraceRegistry.cs
@@ -71,8 +71,15 @@ internal static bool IsRegistered(string traceId)
///
///
/// 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.
+ /// source trace (span correlation), or when both trace IDs are the same and the source
+ /// trace is already registered; otherwise, false.
+ ///
+ /// A true result does NOT guarantee log routing — if the source trace has no
+ /// context-id mapping (only added by the 3-arg ),
+ /// span correlation succeeds but log records for the derived trace fall through
+ /// and are dropped. The case is logged via
+ /// .
+ ///
///
internal static bool TryRegisterDerivedTrace(string derivedTraceId, string sourceTraceId)
{
diff --git a/TUnit.Engine/Reporters/Html/HtmlReportGenerator.cs b/TUnit.Engine/Reporters/Html/HtmlReportGenerator.cs
index 3fcf9ead65..4832a0492b 100644
--- a/TUnit.Engine/Reporters/Html/HtmlReportGenerator.cs
+++ b/TUnit.Engine/Reporters/Html/HtmlReportGenerator.cs
@@ -1611,6 +1611,9 @@ function walk(sid) {
}
// 'test body' must match TUnitActivitySource.SpanTestBody in C#.
+// Used by renderTrace (per-test view, always) and by renderSuiteTrace only when
+// data.expandClassTimeline is true. The default class-timeline branch excludes
+// test-case spans + their entire subtrees, so no test-body span survives to collapse.
function collapseTestBodySpans(spans) {
if (!spans || !spans.length) return [];
const byId = {};
diff --git a/TUnit.Engine/Reporters/Html/HtmlReporter.cs b/TUnit.Engine/Reporters/Html/HtmlReporter.cs
index f360f4b8b2..a8cf5898f0 100644
--- a/TUnit.Engine/Reporters/Html/HtmlReporter.cs
+++ b/TUnit.Engine/Reporters/Html/HtmlReporter.cs
@@ -1,4 +1,5 @@
using System.Collections.Concurrent;
+using System.Globalization;
using System.Net;
using System.Reflection;
using System.Runtime.InteropServices;
@@ -469,15 +470,22 @@ internal static string[] FilterAdditionalTraceIds(string[] allTraceIds, string?
internal static ReportTestResult[] OrderTestsForDisplay(IEnumerable tests)
{
- // StartTime is normalized to UTC and round-trip-formatted ("o" → ...+00:00) before reaching
- // this method, so ordinal string comparison sorts chronologically across all rows.
+ // Parse to DateTimeOffset so the sort works regardless of how the caller formatted
+ // StartTime — production writes UTC ISO-8601, but tests construct ReportTestResult
+ // directly via InternalsVisibleTo and could pass non-UTC offsets.
return tests
- .OrderBy(static test => test.StartTime is null ? 1 : 0)
- .ThenBy(static test => test.StartTime, StringComparer.Ordinal)
+ .OrderBy(static test => ParseStartTimeForSort(test.StartTime))
.ThenBy(static test => test.DisplayName, StringComparer.Ordinal)
.ToArray();
}
+ private static DateTimeOffset ParseStartTimeForSort(string? raw)
+ {
+ return DateTimeOffset.TryParse(raw, CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal, out var parsed)
+ ? parsed
+ : DateTimeOffset.MaxValue;
+ }
+
private static ReportTestResult ExtractTestResult(string testId, TestNode testNode, string? traceId, string? spanId, int retryAttempt, string[]? additionalTraceIds)
{
IProperty? stateProperty = null;
diff --git a/TUnit.OpenTelemetry/Receiver/OtlpReceiver.cs b/TUnit.OpenTelemetry/Receiver/OtlpReceiver.cs
index 7dfbeea679..a0a03ca0b5 100644
--- a/TUnit.OpenTelemetry/Receiver/OtlpReceiver.cs
+++ b/TUnit.OpenTelemetry/Receiver/OtlpReceiver.cs
@@ -139,6 +139,10 @@ private async Task ListenLoop()
/// this isn't an explicit OTel flush, since exporters in another process can't be
/// signalled directly. Increase TUNIT_OTLP_DRAIN_MS if your exporter's batch
/// schedule is longer than the default 2s.
+ ///
+ /// The internal stable window (250 ms of inactivity) is fixed; only the total cap
+ /// is configurable via TUNIT_OTLP_DRAIN_MS.
+ ///
///
/// Maximum total time to wait. Defaults to .
/// Stops the wait early.
From df4f203a34dbcde1d0053f598e2626c86b16d75e Mon Sep 17 00:00:00 2001
From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com>
Date: Sun, 10 May 2026 16:28:44 +0100
Subject: [PATCH 18/18] docs+test(otel): clear up review round 7 polish
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
* RequestCount XML doc: spell out that it counts all POSTs including
rejected gRPC, and point at the per-signal counters for breakdowns.
* collapseTestBodySpans: comment the parentless-test-body fallback as
defensive only — TUnit's span model never produces it.
* DrainAsync test: lower-bound 350ms with a comment that the invariant
is "drain didn't return at the 250ms stable window", not the exact
wall-clock floor. Buys headroom for CI jitter.
---
TUnit.Engine/Reporters/Html/HtmlReportGenerator.cs | 2 ++
TUnit.OpenTelemetry.Tests/OtlpReceiverIngestionTests.cs | 8 +++++---
TUnit.OpenTelemetry/Receiver/OtlpReceiver.cs | 5 ++++-
3 files changed, 11 insertions(+), 4 deletions(-)
diff --git a/TUnit.Engine/Reporters/Html/HtmlReportGenerator.cs b/TUnit.Engine/Reporters/Html/HtmlReportGenerator.cs
index 4832a0492b..0ca56b4950 100644
--- a/TUnit.Engine/Reporters/Html/HtmlReportGenerator.cs
+++ b/TUnit.Engine/Reporters/Html/HtmlReportGenerator.cs
@@ -1629,6 +1629,8 @@ function collapseTestBodySpans(spans) {
.map(function(s) {
if (!s.parentSpanId || !testBodyIds.has(s.parentSpanId)) return s;
const testBody = byId[s.parentSpanId];
+ // testBody.parentSpanId is the test-case span in TUnit's model — null fallback
+ // is defensive only; a parentless test-body span shouldn't occur in practice.
return {
...s,
parentSpanId: testBody && testBody.parentSpanId ? testBody.parentSpanId : null
diff --git a/TUnit.OpenTelemetry.Tests/OtlpReceiverIngestionTests.cs b/TUnit.OpenTelemetry.Tests/OtlpReceiverIngestionTests.cs
index 5842ae9b9a..7973e7634d 100644
--- a/TUnit.OpenTelemetry.Tests/OtlpReceiverIngestionTests.cs
+++ b/TUnit.OpenTelemetry.Tests/OtlpReceiverIngestionTests.cs
@@ -217,9 +217,11 @@ public async Task Receiver_DrainAsync_WaitsForLatePostBeforeReturning()
await latePost;
await Assert.That(receiver.Diagnostics.TracesRequests).IsEqualTo(1);
- // The drain must have observed the late request — i.e., it didn't return at the
- // 250ms stable window before the 200ms-delayed POST landed.
- await Assert.That(drainElapsed).IsGreaterThanOrEqualTo(TimeSpan.FromMilliseconds(400));
+ // The drain must have waited past the first 250ms stable window — otherwise the
+ // 200ms-delayed POST would have landed after the drain returned. Lower bound is
+ // 350ms to leave headroom for CI scheduling jitter; the real invariant is "drain
+ // didn't return at ~250ms".
+ await Assert.That(drainElapsed).IsGreaterThanOrEqualTo(TimeSpan.FromMilliseconds(350));
// And it must respect the cap — no point waiting indefinitely once quiet.
await Assert.That(drainElapsed).IsLessThan(TimeSpan.FromSeconds(3));
}
diff --git a/TUnit.OpenTelemetry/Receiver/OtlpReceiver.cs b/TUnit.OpenTelemetry/Receiver/OtlpReceiver.cs
index a0a03ca0b5..5403c904a8 100644
--- a/TUnit.OpenTelemetry/Receiver/OtlpReceiver.cs
+++ b/TUnit.OpenTelemetry/Receiver/OtlpReceiver.cs
@@ -46,7 +46,10 @@ internal sealed class OtlpReceiver : IAsyncDisposable
public int Port { get; }
///
- /// The number of POST requests successfully processed.
+ /// Total POST requests received, including those rejected (e.g. gRPC content-type)
+ /// before signal-specific processing. Use the per-signal counters
+ /// (LogsRequests, TracesRequests, MetricsRequests, GrpcRejected)
+ /// on for finer-grained breakdown.
///
internal int RequestCount => _diagnostics.TotalRequests;