From de4643ff3b958a206d1cb74998c8d6a4ddb3da7d Mon Sep 17 00:00:00 2001 From: Andrei Borza Date: Fri, 31 Jul 2026 14:21:17 +0200 Subject: [PATCH 1/8] 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/8] 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/8] 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 & { From 417f5fcb21eb7304ad94584cbcad2332cbea93d0 Mon Sep 17 00:00:00 2001 From: Andrei Borza Date: Mon, 3 Aug 2026 15:27:28 +0200 Subject: [PATCH 4/8] Derive the cutoff from the parent instead of sealing it 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. --- packages/core/src/tracing/sentrySpan.ts | 8 --- packages/core/src/utils/spanUtils.ts | 50 +++++++++---------- .../core/test/lib/utils/spanUtils.test.ts | 25 +++++++--- 3 files changed, 44 insertions(+), 39 deletions(-) diff --git a/packages/core/src/tracing/sentrySpan.ts b/packages/core/src/tracing/sentrySpan.ts index 53351235c46a..b71b3dc2e486 100644 --- a/packages/core/src/tracing/sentrySpan.ts +++ b/packages/core/src/tracing/sentrySpan.ts @@ -35,7 +35,6 @@ import { getSpanDescendants, getStatusMessage, getStreamedSpanLinks, - sealChildSpansOnSpan, spanTimeInputToSeconds, spanToJSON, spanToTransactionTraceContext, @@ -414,8 +413,6 @@ 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; } @@ -473,11 +470,6 @@ 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 dc83641cd9ad..ac3ae915ab9d 100644 --- a/packages/core/src/utils/spanUtils.ts +++ b/packages/core/src/utils/spanUtils.ts @@ -4,6 +4,7 @@ import type { RawAttributes } from '../attributes'; import { serializeAttributes } from '../attributes'; import { getMainCarrier } from '../carrier'; import { getCurrentScope } from '../currentScopes'; +import { DEBUG_BUILD } from '../debug-build'; import { SEMANTIC_ATTRIBUTE_SENTRY_CUSTOM_SPAN_NAME, SEMANTIC_ATTRIBUTE_SENTRY_OP, @@ -30,7 +31,7 @@ import { addNonEnumerableProperty } from '../utils/object'; import { generateSpanId } from '../utils/propagationContext'; import { timestampInSeconds } from '../utils/time'; import { generateSentryTraceHeader, generateTraceparentHeader } from '../utils/tracing'; -import { consoleSandbox } from './debug-logger'; +import { consoleSandbox, debug } from './debug-logger'; import { _getSpanForScope } from './spanOnScope'; // These are aligned with OpenTelemetry trace flags @@ -360,19 +361,15 @@ export function addStatusMessageAttribute( } const CHILD_SPANS_FIELD = '_sentryChildSpans'; -const CHILD_SPANS_SEALED_FIELD = '_sentryChildSpansSealed'; const ROOT_SPAN_FIELD = '_sentryRootSpan'; -// 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; +// The limit a segment span is truncated to when it is serialized (`MAX_SPAN_COUNT` in `sentrySpan.ts`), +// past which further children can never be sent. Only used to warn, since a segment span this large is +// one that never ends. +const UNSENDABLE_CHILD_SPAN_COUNT = 1000; type SpanWithPotentialChildren = Span & { [CHILD_SPANS_FIELD]?: Set; - [CHILD_SPANS_SEALED_FIELD]?: boolean; [ROOT_SPAN_FIELD]?: Span; }; @@ -385,11 +382,17 @@ 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]) { + // `getSpanDescendants()` stops at an unsampled span, so a child of one could never show up in a + // transaction anyway. + if (!spanIsSampled(span)) { + return; + } + + // A segment span that stopped recording has had its tree read for the last time, and a child starting + // now belongs to whatever segment comes next: it is re-emitted on its own instead. Tracking it here + // would pin it for as long as the parent lives, which for a segment span left active in an async + // context (e.g. a framework boot span captured by a queue consumer) is the rest of the process. + if (rootSpan === span && !span.isRecording()) { return; } @@ -398,19 +401,16 @@ export function addChildSpanToSpan(span: SpanWithPotentialChildren, childSpan: S 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); + return; } -} -/** - * 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); + childSpans.add(childSpan); + + if (DEBUG_BUILD && rootSpan === span && childSpans.size === UNSENDABLE_CHILD_SPAN_COUNT) { + debug.warn( + `[Tracing] Span "${spanToJSON(span).description}" has ${UNSENDABLE_CHILD_SPAN_COUNT} child spans and has not ended. Further children cannot be sent, and all of them are kept in memory until it ends. This usually means the span is used as a long-lived parent, e.g. one started during startup that is still active in a background task.`, + ); + } } /** This is only used internally by Idle Spans. */ diff --git a/packages/core/test/lib/utils/spanUtils.test.ts b/packages/core/test/lib/utils/spanUtils.test.ts index 4aec34289814..9b4bd46f7ae8 100644 --- a/packages/core/test/lib/utils/spanUtils.test.ts +++ b/packages/core/test/lib/utils/spanUtils.test.ts @@ -793,16 +793,29 @@ describe('addChildSpanToSpan', () => { expect((parent as unknown as { _sentryChildSpans?: Set })._sentryChildSpans).toBeUndefined(); }); - it('stops tracking children once the cap is reached', () => { + it('does not track children on a segment span that stopped recording', () => { const parent = new SentrySpan({ name: 'parent', sampled: true }); + parent.end(); - const children = Array.from({ length: 1001 }, (_, i) => new SentrySpan({ name: `child-${i}`, sampled: true })); - children.forEach(child => addChildSpanToSpan(parent, child)); + const child = new SentrySpan({ name: 'child', sampled: true }); + 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); + expect(getRootSpan(child)).toBe(parent); + expect(getSpanDescendants(parent)).toEqual([parent]); + }); + + it('keeps tracking children on a span that stopped recording but is not the segment span', () => { + const segment = new SentrySpan({ name: 'segment', sampled: true }); + const parent = new SentrySpan({ name: 'parent', sampled: true }); + addChildSpanToSpan(segment, parent); + parent.end(); + + const child = new SentrySpan({ name: 'child', sampled: true }); + addChildSpanToSpan(parent, child); + + // the segment span is still open, so its transaction has not been assembled yet + expect(getSpanDescendants(segment)).toEqual([segment, parent, child]); }); }); From ab86a63ad41566421f4cbea2554725e0a8b785bc Mon Sep 17 00:00:00 2001 From: Andrei Borza Date: Mon, 3 Aug 2026 15:44:34 +0200 Subject: [PATCH 5/8] Tell users what to do about a segment span that never ends --- packages/core/src/utils/spanUtils.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/core/src/utils/spanUtils.ts b/packages/core/src/utils/spanUtils.ts index ac3ae915ab9d..789053a0b040 100644 --- a/packages/core/src/utils/spanUtils.ts +++ b/packages/core/src/utils/spanUtils.ts @@ -408,7 +408,7 @@ export function addChildSpanToSpan(span: SpanWithPotentialChildren, childSpan: S if (DEBUG_BUILD && rootSpan === span && childSpans.size === UNSENDABLE_CHILD_SPAN_COUNT) { debug.warn( - `[Tracing] Span "${spanToJSON(span).description}" has ${UNSENDABLE_CHILD_SPAN_COUNT} child spans and has not ended. Further children cannot be sent, and all of them are kept in memory until it ends. This usually means the span is used as a long-lived parent, e.g. one started during startup that is still active in a background task.`, + `[Tracing] Span "${spanToJSON(span).description}" has ${UNSENDABLE_CHILD_SPAN_COUNT} child spans and has not ended, so it holds on to all of them and cannot send any more. If it is meant to be long-lived, end it once its work is done, or start the code that creates these children in its own trace with \`Sentry.startNewTrace(() => { ... })\` so they are sent separately.`, ); } } From e3729213212aa95bad1bb836a82893d9fa1ebcf8 Mon Sep 17 00:00:00 2001 From: Andrei Borza Date: Mon, 3 Aug 2026 15:50:20 +0200 Subject: [PATCH 6/8] Name who can act on the unended segment span warning --- packages/core/src/utils/spanUtils.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/core/src/utils/spanUtils.ts b/packages/core/src/utils/spanUtils.ts index 789053a0b040..1cee175bf78e 100644 --- a/packages/core/src/utils/spanUtils.ts +++ b/packages/core/src/utils/spanUtils.ts @@ -408,7 +408,7 @@ export function addChildSpanToSpan(span: SpanWithPotentialChildren, childSpan: S if (DEBUG_BUILD && rootSpan === span && childSpans.size === UNSENDABLE_CHILD_SPAN_COUNT) { debug.warn( - `[Tracing] Span "${spanToJSON(span).description}" has ${UNSENDABLE_CHILD_SPAN_COUNT} child spans and has not ended, so it holds on to all of them and cannot send any more. If it is meant to be long-lived, end it once its work is done, or start the code that creates these children in its own trace with \`Sentry.startNewTrace(() => { ... })\` so they are sent separately.`, + `[Tracing] Span "${spanToJSON(span).description}" has ${UNSENDABLE_CHILD_SPAN_COUNT} child spans and has not ended, so it holds on to all of them and cannot send any more. If your code started this span, end it once its work is done. If your code started the child spans, wrap it in \`Sentry.startNewTrace(() => { ... })\` so they are sent separately. If neither is yours, this is an SDK or framework bug worth reporting.`, ); } } From c2f1e11104a3469e6045f092824e3747a65d9f4f Mon Sep 17 00:00:00 2001 From: Andrei Borza Date: Mon, 3 Aug 2026 15:55:51 +0200 Subject: [PATCH 7/8] Drop the unended segment span warning --- packages/core/src/utils/spanUtils.ts | 22 ++++------------------ 1 file changed, 4 insertions(+), 18 deletions(-) diff --git a/packages/core/src/utils/spanUtils.ts b/packages/core/src/utils/spanUtils.ts index 1cee175bf78e..07a0c9791319 100644 --- a/packages/core/src/utils/spanUtils.ts +++ b/packages/core/src/utils/spanUtils.ts @@ -4,7 +4,6 @@ import type { RawAttributes } from '../attributes'; import { serializeAttributes } from '../attributes'; import { getMainCarrier } from '../carrier'; import { getCurrentScope } from '../currentScopes'; -import { DEBUG_BUILD } from '../debug-build'; import { SEMANTIC_ATTRIBUTE_SENTRY_CUSTOM_SPAN_NAME, SEMANTIC_ATTRIBUTE_SENTRY_OP, @@ -31,7 +30,7 @@ import { addNonEnumerableProperty } from '../utils/object'; import { generateSpanId } from '../utils/propagationContext'; import { timestampInSeconds } from '../utils/time'; import { generateSentryTraceHeader, generateTraceparentHeader } from '../utils/tracing'; -import { consoleSandbox, debug } from './debug-logger'; +import { consoleSandbox } from './debug-logger'; import { _getSpanForScope } from './spanOnScope'; // These are aligned with OpenTelemetry trace flags @@ -363,11 +362,6 @@ export function addStatusMessageAttribute( const CHILD_SPANS_FIELD = '_sentryChildSpans'; const ROOT_SPAN_FIELD = '_sentryRootSpan'; -// The limit a segment span is truncated to when it is serialized (`MAX_SPAN_COUNT` in `sentrySpan.ts`), -// past which further children can never be sent. Only used to warn, since a segment span this large is -// one that never ends. -const UNSENDABLE_CHILD_SPAN_COUNT = 1000; - type SpanWithPotentialChildren = Span & { [CHILD_SPANS_FIELD]?: Set; [ROOT_SPAN_FIELD]?: Span; @@ -398,18 +392,10 @@ export function addChildSpanToSpan(span: SpanWithPotentialChildren, childSpan: S // We store a list of child spans on the parent span // We need this for `getSpanDescendants()` to work - const childSpans = span[CHILD_SPANS_FIELD]; - if (!childSpans) { + if (span[CHILD_SPANS_FIELD]) { + span[CHILD_SPANS_FIELD].add(childSpan); + } else { addNonEnumerableProperty(span, CHILD_SPANS_FIELD, new Set([childSpan])); - return; - } - - childSpans.add(childSpan); - - if (DEBUG_BUILD && rootSpan === span && childSpans.size === UNSENDABLE_CHILD_SPAN_COUNT) { - debug.warn( - `[Tracing] Span "${spanToJSON(span).description}" has ${UNSENDABLE_CHILD_SPAN_COUNT} child spans and has not ended, so it holds on to all of them and cannot send any more. If your code started this span, end it once its work is done. If your code started the child spans, wrap it in \`Sentry.startNewTrace(() => { ... })\` so they are sent separately. If neither is yours, this is an SDK or framework bug worth reporting.`, - ); } } From 4fb00f7f7a1cd266978035cd795f36ce4cf600b7 Mon Sep 17 00:00:00 2001 From: Andrei Borza Date: Mon, 3 Aug 2026 16:00:22 +0200 Subject: [PATCH 8/8] Explain why an unsampled parent skips child tracking --- packages/core/src/utils/spanUtils.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/core/src/utils/spanUtils.ts b/packages/core/src/utils/spanUtils.ts index 07a0c9791319..8e24736ba92d 100644 --- a/packages/core/src/utils/spanUtils.ts +++ b/packages/core/src/utils/spanUtils.ts @@ -376,8 +376,9 @@ 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, so a child of one could never show up in a - // transaction anyway. + // `_sentryChildSpans` exists only so `getSpanDescendants()` can walk the tree when the segment span + // is sent, and that walk stops at an unsampled span without ever visiting its children. So a child + // tracked here would be held for the parent's lifetime and never read. if (!spanIsSampled(span)) { return; }