Skip to content
Merged
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Prev Previous commit
Next Next commit
Fix MaxExternalSpansPerTest cap bypass when Activity.Parent chain is …
…broken

When libraries like Npgsql use async connection pooling, Activity.Parent
may be null/broken by the time OnActivityStopped fires, causing
FindTestCaseAncestor to return null and external spans to bypass the cap.

Three-layer fix:
1. Register test case span IDs on ActivityStarted (before children stop)
2. Fallback in FindTestCaseAncestor to check ParentSpanId against known
   test case span IDs when Activity.Parent walk fails
3. Per-trace cap as ultimate safety net for completely broken chains

Agent-Logs-Url: https://github.com/thomhurst/TUnit/sessions/1c7f9e0c-eca2-4452-a1b9-b94f99ac238c

Co-authored-by: thomhurst <30480171+thomhurst@users.noreply.github.com>
  • Loading branch information
Copilot and thomhurst authored Apr 3, 2026
commit ccbceccbb0dcb4fb7af919cfbe1e8c8b524f7fb4
44 changes: 42 additions & 2 deletions TUnit.Engine/Reporters/Html/ActivityCollector.cs
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,12 @@ internal sealed class ActivityCollector : IDisposable
private readonly ConcurrentDictionary<string, ConcurrentQueue<SpanData>> _spansByTrace = new();
// Track external span count per test case (keyed by test case span ID)
private readonly ConcurrentDictionary<string, int> _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<string, int> _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<string, byte> _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<string, byte> _knownTraceIds = new(StringComparer.OrdinalIgnoreCase);
Expand All @@ -31,6 +37,7 @@ public void Start()
ShouldListenTo = static _ => true,
Sample = SampleActivity,
SampleUsingParentId = SampleActivityUsingParentId,
ActivityStarted = OnActivityStarted,
ActivityStopped = OnActivityStopped
};

Expand Down Expand Up @@ -141,8 +148,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)
{
Expand All @@ -155,6 +174,18 @@ public SpanData[] GetAllSpans()
current = current.Parent;
}

// Fallback: check if ParentSpanId is a known test case span.
// This handles broken Activity.Parent chains (e.g. Npgsql async pooling)
// where the W3C ParentSpanId is still correct.
if (activity.ParentSpanId != default)
{
var parentSpanId = activity.ParentSpanId.ToString();
if (_testCaseSpanIds.ContainsKey(parentSpanId))
{
return parentSpanId;
}
}

return null;
}

Expand Down Expand Up @@ -217,7 +248,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 > MaxExternalSpansPerTest)
{
return;
}
}
}

var queue = _spansByTrace.GetOrAdd(traceId, _ => new ConcurrentQueue<SpanData>());
Expand Down
Loading