From de4643ff3b958a206d1cb74998c8d6a4ddb3da7d Mon Sep 17 00:00:00 2001 From: Andrei Borza Date: Fri, 31 Jul 2026 14:21:17 +0200 Subject: [PATCH 1/3] fix(core): Bound child span tracking on long-lived spans 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 --- packages/core/src/tracing/sentrySpan.ts | 6 ++++ packages/core/src/utils/spanUtils.ts | 31 +++++++++++++++-- .../core/test/lib/tracing/sentrySpan.test.ts | 34 +++++++++++++++++-- .../core/test/lib/utils/spanUtils.test.ts | 26 ++++++++++++++ 4 files changed, 91 insertions(+), 6 deletions(-) diff --git a/packages/core/src/tracing/sentrySpan.ts b/packages/core/src/tracing/sentrySpan.ts index b71b3dc2e486..934316a9cae6 100644 --- a/packages/core/src/tracing/sentrySpan.ts +++ b/packages/core/src/tracing/sentrySpan.ts @@ -35,6 +35,7 @@ import { getSpanDescendants, getStatusMessage, getStreamedSpanLinks, + sealChildSpansOnSpan, spanTimeInputToSeconds, spanToJSON, spanToTransactionTraceContext, @@ -470,6 +471,11 @@ export class SentrySpan implements Span { spans.push(spanJSON); } + // This was the last read of the tree: the event below is assembled from `spans`, and a child that + // starts later is re-emitted on its own instead of from here. Tracking those children would retain + // them for as long as this span is, which for a segment span pinned in an async context is forever. + sealChildSpansOnSpan(this); + const source = this._attributes[SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]; // remove internal root span attributes we don't need to send. diff --git a/packages/core/src/utils/spanUtils.ts b/packages/core/src/utils/spanUtils.ts index 84dc4b57039a..e0d02079cac5 100644 --- a/packages/core/src/utils/spanUtils.ts +++ b/packages/core/src/utils/spanUtils.ts @@ -360,10 +360,16 @@ export function addStatusMessageAttribute( } const CHILD_SPANS_FIELD = '_sentryChildSpans'; +const CHILD_SPANS_SEALED_FIELD = '_sentryChildSpansSealed'; const ROOT_SPAN_FIELD = '_sentryRootSpan'; +// Matches the truncation applied when a segment span is serialized (`MAX_SPAN_COUNT` in +// `sentrySpan.ts`), so the children we refuse to track are ones that would be dropped at send time. +const MAX_CHILD_SPANS = 1000; + type SpanWithPotentialChildren = Span & { [CHILD_SPANS_FIELD]?: Set; + [CHILD_SPANS_SEALED_FIELD]?: boolean; [ROOT_SPAN_FIELD]?: Span; }; @@ -376,15 +382,34 @@ export function addChildSpanToSpan(span: SpanWithPotentialChildren, childSpan: S const rootSpan = span[ROOT_SPAN_FIELD] || span; addNonEnumerableProperty(childSpan, ROOT_SPAN_FIELD, rootSpan); + // `getSpanDescendants()` stops at an unsampled span, and a sealed span has already had its tree read + // for the last time, so a child added here could never show up in a transaction. Skipping it keeps a + // span that outlives its children (e.g. a framework boot span still active in a queue consumer's + // async context) from pinning every later child for the rest of the process. + if (!spanIsSampled(span) || span[CHILD_SPANS_SEALED_FIELD]) { + return; + } + // We store a list of child spans on the parent span // We need this for `getSpanDescendants()` to work - if (span[CHILD_SPANS_FIELD]) { - span[CHILD_SPANS_FIELD].add(childSpan); - } else { + const childSpans = span[CHILD_SPANS_FIELD]; + if (!childSpans) { addNonEnumerableProperty(span, CHILD_SPANS_FIELD, new Set([childSpan])); + } else if (childSpans.size < MAX_CHILD_SPANS) { + childSpans.add(childSpan); } } +/** + * Stops tracking further children on a span once its tree has been read for the last time. The children + * it already has are kept, so the tree stays what was sent. A child that starts afterwards is still + * reachable through its own root span reference, which is what re-emitting it as an orphan transaction + * relies on. + */ +export function sealChildSpansOnSpan(span: SpanWithPotentialChildren): void { + addNonEnumerableProperty(span, CHILD_SPANS_SEALED_FIELD, true); +} + /** This is only used internally by Idle Spans. */ export function removeChildSpanFromSpan(span: SpanWithPotentialChildren, childSpan: Span): void { if (span[CHILD_SPANS_FIELD]) { diff --git a/packages/core/test/lib/tracing/sentrySpan.test.ts b/packages/core/test/lib/tracing/sentrySpan.test.ts index 7522b7061234..4bf8645d6869 100644 --- a/packages/core/test/lib/tracing/sentrySpan.test.ts +++ b/packages/core/test/lib/tracing/sentrySpan.test.ts @@ -8,18 +8,22 @@ import { } from '../../../src/semanticAttributes'; import { SentrySpan } from '../../../src/tracing/sentrySpan'; import { SPAN_STATUS_ERROR } from '../../../src/tracing/spanstatus'; -import { startInactiveSpan, startSpan } from '../../../src/tracing/trace'; +import { startInactiveSpan, startSpan, withActiveSpan } from '../../../src/tracing/trace'; import { markSpanAsTracerProviderSpan, markSpanForOtelSourceInference, spanSourceWasExplicitlySet, } from '../../../src/tracing/utils'; import type { Envelope } from '../../../src/types/envelope'; -import type { SpanJSON } from '../../../src/types/span'; -import { spanToJSON, TRACE_FLAG_NONE, TRACE_FLAG_SAMPLED } from '../../../src/utils/spanUtils'; +import type { Span, SpanJSON } from '../../../src/types/span'; +import { getRootSpan, spanToJSON, TRACE_FLAG_NONE, TRACE_FLAG_SAMPLED } from '../../../src/utils/spanUtils'; import { timestampInSeconds } from '../../../src/utils/time'; import { getDefaultTestClientOptions, TestClient } from '../../mocks/client'; +function childSpansOf(span: Span): Set { + return (span as unknown as { _sentryChildSpans?: Set })._sentryChildSpans ?? new Set(); +} + describe('SentrySpan', () => { describe('name', () => { it('works with name', () => { @@ -214,6 +218,30 @@ describe('SentrySpan', () => { }); }); + describe('child span retention', () => { + it('stops tracking children on a segment span once it has been captured', () => { + const client = new TestClient(getDefaultTestClientOptions({ tracesSampleRate: 1 })); + setCurrentClient(client); + const captureEvent = vi.spyOn(client, 'captureEvent'); + + let rootSpan: Span | undefined; + startSpan({ name: 'root' }, span => { + rootSpan = span; + startSpan({ name: 'child' }, () => {}); + }); + + expect(captureEvent).toHaveBeenCalledTimes(1); + expect(captureEvent.mock.calls[0]![0].spans).toHaveLength(1); + expect(childSpansOf(rootSpan!).size).toBe(1); + + // A child that starts after the tree was read is not tracked, but can still find its root span, + // which is all that re-emitting it as its own transaction needs. + const lateChild = withActiveSpan(rootSpan!, () => startInactiveSpan({ name: 'late child' })); + expect(childSpansOf(rootSpan!).size).toBe(1); + expect(getRootSpan(lateChild)).toBe(rootSpan); + }); + }); + describe('end', () => { test('simple', () => { const span = new SentrySpan({}); diff --git a/packages/core/test/lib/utils/spanUtils.test.ts b/packages/core/test/lib/utils/spanUtils.test.ts index c6e1542716f2..4aec34289814 100644 --- a/packages/core/test/lib/utils/spanUtils.test.ts +++ b/packages/core/test/lib/utils/spanUtils.test.ts @@ -22,7 +22,9 @@ import type { Span, SpanAttributes, SpanTimeInput, StreamedSpanJSON } from '../. import type { SpanStatus } from '../../../src/types/spanStatus'; import type { OpenTelemetrySdkTraceBaseSpan } from '../../../src/utils/spanUtils'; import { + addChildSpanToSpan, getRootSpan, + getSpanDescendants, spanIsSampled, spanTimeInputToSeconds, spanToJSON, @@ -780,6 +782,30 @@ describe('getRootSpan', () => { }); }); +describe('addChildSpanToSpan', () => { + it('does not track children on an unsampled span', () => { + const parent = new SentrySpan({ name: 'parent', sampled: false }); + const child = new SentrySpan({ name: 'child', sampled: false }); + + addChildSpanToSpan(parent, child); + + expect(getRootSpan(child)).toBe(parent); + expect((parent as unknown as { _sentryChildSpans?: Set })._sentryChildSpans).toBeUndefined(); + }); + + it('stops tracking children once the cap is reached', () => { + const parent = new SentrySpan({ name: 'parent', sampled: true }); + + const children = Array.from({ length: 1001 }, (_, i) => new SentrySpan({ name: `child-${i}`, sampled: true })); + children.forEach(child => addChildSpanToSpan(parent, child)); + + // the parent plus the first 1000 children, which is what serialization would keep anyway + expect(getSpanDescendants(parent)).toHaveLength(1001); + // the child that was not tracked can still find its root span + expect(getRootSpan(children[1000]!)).toBe(parent); + }); +}); + describe('updateSpanName', () => { it('updates the span name and source', () => { const span = new SentrySpan({ name: 'old-name', attributes: { [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'url' } }); From c92f7f330f7fe2de9644c15a853517f6622b3167 Mon Sep 17 00:00:00 2001 From: Andrei Borza Date: Fri, 31 Jul 2026 15:33:37 +0200 Subject: [PATCH 2/3] Seal child span tracking on the span streaming path too --- packages/core/src/tracing/sentrySpan.ts | 2 ++ .../core/test/lib/tracing/sentrySpan.test.ts | 17 +++++++++++++++++ 2 files changed, 19 insertions(+) diff --git a/packages/core/src/tracing/sentrySpan.ts b/packages/core/src/tracing/sentrySpan.ts index 934316a9cae6..53351235c46a 100644 --- a/packages/core/src/tracing/sentrySpan.ts +++ b/packages/core/src/tracing/sentrySpan.ts @@ -414,6 +414,8 @@ export class SentrySpan implements Span { if (client && hasSpanStreamingEnabled(client)) { client.emit('afterSegmentSpanEnd', this); + // Every span streams on its own here, so the tree of a finished segment is never read again. + sealChildSpansOnSpan(this); return; } diff --git a/packages/core/test/lib/tracing/sentrySpan.test.ts b/packages/core/test/lib/tracing/sentrySpan.test.ts index 4bf8645d6869..cbfd2c2fffda 100644 --- a/packages/core/test/lib/tracing/sentrySpan.test.ts +++ b/packages/core/test/lib/tracing/sentrySpan.test.ts @@ -240,6 +240,23 @@ describe('SentrySpan', () => { expect(childSpansOf(rootSpan!).size).toBe(1); expect(getRootSpan(lateChild)).toBe(rootSpan); }); + + it('stops tracking children on a segment span that has streamed', () => { + const client = new TestClient(getDefaultTestClientOptions({ tracesSampleRate: 1, traceLifecycle: 'stream' })); + setCurrentClient(client); + + let rootSpan: Span | undefined; + startSpan({ name: 'root' }, span => { + rootSpan = span; + startSpan({ name: 'child' }, () => {}); + }); + + expect(childSpansOf(rootSpan!).size).toBe(1); + + const lateChild = withActiveSpan(rootSpan!, () => startInactiveSpan({ name: 'late child' })); + expect(childSpansOf(rootSpan!).size).toBe(1); + expect(getRootSpan(lateChild)).toBe(rootSpan); + }); }); describe('end', () => { From cc972e8a8eb4f4302103da68fba38c9ad00e73f5 Mon Sep 17 00:00:00 2001 From: Andrei Borza Date: Fri, 31 Jul 2026 17:17:10 +0200 Subject: [PATCH 3/3] Record the send-limit tradeoff on the child span cap --- packages/core/src/utils/spanUtils.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/packages/core/src/utils/spanUtils.ts b/packages/core/src/utils/spanUtils.ts index e0d02079cac5..dc83641cd9ad 100644 --- a/packages/core/src/utils/spanUtils.ts +++ b/packages/core/src/utils/spanUtils.ts @@ -363,8 +363,11 @@ const CHILD_SPANS_FIELD = '_sentryChildSpans'; const CHILD_SPANS_SEALED_FIELD = '_sentryChildSpansSealed'; const ROOT_SPAN_FIELD = '_sentryRootSpan'; -// Matches the truncation applied when a segment span is serialized (`MAX_SPAN_COUNT` in -// `sentrySpan.ts`), so the children we refuse to track are ones that would be dropped at send time. +// Mirrors the truncation applied when a segment span is serialized (`MAX_SPAN_COUNT` in +// `sentrySpan.ts`): a parent past this many children already has more than it can send, so we stop +// growing the tree instead of retaining spans for a parent that outlives them. Serialization drops +// unfinished and already-sent descendants before applying its own limit, so such a transaction can +// land slightly under that limit rather than exactly at it. const MAX_CHILD_SPANS = 1000; type SpanWithPotentialChildren = Span & {