fix(core): Bound child span tracking on long-lived spans - #22916
fix(core): Bound child span tracking on long-lived spans#22916andreiborza wants to merge 8 commits into
Conversation
A span keeps a strong reference to every child started under it, so a span that outlives its children retains all of them. In NestJS the `Create Nest App` span stays the active parent of anything a `setInterval` from a provider constructor starts, which retains every span for the lifetime of the process. Children are no longer tracked on an unsampled span, on a segment span whose tree has already been serialized, or past the 1000 spans a transaction can carry. Every child still records its root span, so late children are re-emitted as their own transaction as before. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
size-limit report 📦
|
A segment span that is sampled but no longer recording has already had its tree read, so `addChildSpanToSpan` can decide on its own that a child arriving now will never be sent. That drops the `_sentryChildSpansSealed` field, the seal helper and its call sites, and the hard cap: there is no capture path left that can forget to seal.
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 1 potential issue.
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.
Reviewed by Cursor Bugbot for commit 4fb00f7. Configure here.
| // context (e.g. a framework boot span captured by a queue consumer) is the rest of the process. | ||
| if (rootSpan === span && !span.isRecording()) { | ||
| return; | ||
| } |
There was a problem hiding this comment.
Late children dropped under deferred capture
Medium Severity
The new segment-span guard treats !isRecording() as “tree already assembled,” but under deferred capture the snapshot runs later. A child that starts on that ended segment during the debounce window is not tracked, and onChildSpanEnded will not orphan it until the root is in CAPTURED_SPANS, so a child that also ends in that window is never sent. This hits Node’s static/transactions path, which always registers deferred capture.
Reviewed by Cursor Bugbot for commit 4fb00f7. Configure here.


What
Bounds the child spans a span keeps a reference to. A child is no longer tracked when it could never show up in what that span sends:
Late children still resolve their root span, so they keep being sent on their own. Both conditions are read off the parent, so this holds for every capture path, including span streaming and deferred capture.
Why
A span that outlives its children retains every one of them. In NestJS the
Create Nest Appspan stays the active parent of anything asetIntervalfrom a provider constructor starts, so those spans are never released and the process leaks around 80 MB/h.Reproduction: https://github.com/andreiborza/sentry-nestjs-span-retention-repro (60 s at 20 rps, 12000 spans, post-GC heap snapshots). Children retained on the boot span and heap node growth over the run, both measured on this branch with and without the fix:
Neither trace lifecycle avoids it, and published
@sentry/nestjs@10.69.0leaks the same way on both, so this is not new in v11.Closes: #22860