diff --git a/TUnit.Engine/Reporters/Html/ActivityCollector.cs b/TUnit.Engine/Reporters/Html/ActivityCollector.cs
index cae6be94c1..838f5d7bd6 100644
--- a/TUnit.Engine/Reporters/Html/ActivityCollector.cs
+++ b/TUnit.Engine/Reporters/Html/ActivityCollector.cs
@@ -11,10 +11,19 @@ internal sealed class ActivityCollector : IDisposable
// TUnit's own spans are always captured regardless of caps.
// Soft cap — intentionally racy for performance; may be slightly exceeded under high concurrency.
private const int MaxExternalSpansPerTest = 100;
+ // Fallback cap applied per trace when the test case association cannot be determined
+ // (e.g. broken Activity.Parent chains from async connection pooling).
+ private const int MaxExternalSpansPerTrace = 100;
private readonly ConcurrentDictionary> _spansByTrace = new();
// Track external span count per test case (keyed by test case span ID)
private readonly ConcurrentDictionary _externalSpanCountsByTest = new();
+ // Fallback: per-trace cap for external spans whose parent chain is broken
+ // (e.g. Npgsql async pooling where Activity.Parent is null but traceId is correct)
+ private readonly ConcurrentDictionary _externalSpanCountsByTrace = new();
+ // Known test case span IDs, populated at activity start time so they're available
+ // before child spans stop (children stop before parents in Activity ordering).
+ private readonly ConcurrentDictionary _testCaseSpanIds = new();
// Fast-path cache of trace IDs that should be collected. Subsumes TraceRegistry lookups
// so that subsequent activities on the same trace avoid cross-class dictionary checks.
private readonly ConcurrentDictionary _knownTraceIds = new(StringComparer.OrdinalIgnoreCase);
@@ -31,6 +40,7 @@ public void Start()
ShouldListenTo = static _ => true,
Sample = SampleActivity,
SampleUsingParentId = SampleActivityUsingParentId,
+ ActivityStarted = OnActivityStarted,
ActivityStopped = OnActivityStopped
};
@@ -141,8 +151,20 @@ public SpanData[] GetAllSpans()
return lookup;
}
- private static string? FindTestCaseAncestor(Activity activity)
+ private void OnActivityStarted(Activity activity)
{
+ // Register test case span IDs early so they're available for child span lookups.
+ // Children stop before parents in Activity ordering, so we need this pre-registered.
+ if (IsTUnitSource(activity.Source.Name) &&
+ activity.GetTagItem("tunit.test.node_uid") is not null)
+ {
+ _testCaseSpanIds.TryAdd(activity.SpanId.ToString(), 0);
+ }
+ }
+
+ private string? FindTestCaseAncestor(Activity activity)
+ {
+ // First: walk in-memory parent chain (works when parent Activity is alive)
var current = activity.Parent;
while (current is not null)
{
@@ -155,6 +177,19 @@ public SpanData[] GetAllSpans()
current = current.Parent;
}
+ // Fallback: check if the direct ParentSpanId is a known test case span.
+ // Note: only one level — deeper broken chains fall through to the per-trace cap.
+ // This handles Npgsql async pooling where the direct parent reference is broken
+ // but W3C ParentSpanId is still correct.
+ if (activity.ParentSpanId != default)
+ {
+ var parentSpanId = activity.ParentSpanId.ToString();
+ if (_testCaseSpanIds.ContainsKey(parentSpanId))
+ {
+ return parentSpanId;
+ }
+ }
+
return null;
}
@@ -217,7 +252,16 @@ private void OnActivityStopped(Activity activity)
return;
}
}
- // External spans not under any test (e.g., fixture/infrastructure setup) are uncapped
+ else
+ {
+ // Fallback cap by trace ID to prevent unbounded growth for spans
+ // with broken parent chains (e.g., Npgsql async connection pooling).
+ var count = _externalSpanCountsByTrace.AddOrUpdate(traceId, 1, (_, c) => c + 1);
+ if (count > MaxExternalSpansPerTrace)
+ {
+ return;
+ }
+ }
}
var queue = _spansByTrace.GetOrAdd(traceId, _ => new ConcurrentQueue());
@@ -296,6 +340,16 @@ private void OnActivityStopped(Activity activity)
};
queue.Enqueue(spanData);
+
+ // Cleanup: remove test case span from tracking sets once it stops.
+ // All child spans will have already stopped by this point (children stop before parents).
+ if (isTUnit && activity.GetTagItem("tunit.test.node_uid") is not null)
+ {
+ var spanId = activity.SpanId.ToString();
+ _testCaseSpanIds.TryRemove(spanId, out _);
+ _externalSpanCountsByTest.TryRemove(spanId, out _);
+ _externalSpanCountsByTrace.TryRemove(traceId, out _);
+ }
}
public void Dispose()