Skip to content

Commit de4643f

Browse files
andreiborzaclaude
andcommitted
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 <noreply@anthropic.com>
1 parent d013fc4 commit de4643f

4 files changed

Lines changed: 91 additions & 6 deletions

File tree

packages/core/src/tracing/sentrySpan.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ import {
3535
getSpanDescendants,
3636
getStatusMessage,
3737
getStreamedSpanLinks,
38+
sealChildSpansOnSpan,
3839
spanTimeInputToSeconds,
3940
spanToJSON,
4041
spanToTransactionTraceContext,
@@ -470,6 +471,11 @@ export class SentrySpan implements Span {
470471
spans.push(spanJSON);
471472
}
472473

474+
// This was the last read of the tree: the event below is assembled from `spans`, and a child that
475+
// starts later is re-emitted on its own instead of from here. Tracking those children would retain
476+
// them for as long as this span is, which for a segment span pinned in an async context is forever.
477+
sealChildSpansOnSpan(this);
478+
473479
const source = this._attributes[SEMANTIC_ATTRIBUTE_SENTRY_SOURCE];
474480

475481
// remove internal root span attributes we don't need to send.

packages/core/src/utils/spanUtils.ts

Lines changed: 28 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -360,10 +360,16 @@ export function addStatusMessageAttribute(
360360
}
361361

362362
const CHILD_SPANS_FIELD = '_sentryChildSpans';
363+
const CHILD_SPANS_SEALED_FIELD = '_sentryChildSpansSealed';
363364
const ROOT_SPAN_FIELD = '_sentryRootSpan';
364365

366+
// Matches the truncation applied when a segment span is serialized (`MAX_SPAN_COUNT` in
367+
// `sentrySpan.ts`), so the children we refuse to track are ones that would be dropped at send time.
368+
const MAX_CHILD_SPANS = 1000;
369+
365370
type SpanWithPotentialChildren = Span & {
366371
[CHILD_SPANS_FIELD]?: Set<Span>;
372+
[CHILD_SPANS_SEALED_FIELD]?: boolean;
367373
[ROOT_SPAN_FIELD]?: Span;
368374
};
369375

@@ -376,15 +382,34 @@ export function addChildSpanToSpan(span: SpanWithPotentialChildren, childSpan: S
376382
const rootSpan = span[ROOT_SPAN_FIELD] || span;
377383
addNonEnumerableProperty(childSpan, ROOT_SPAN_FIELD, rootSpan);
378384

385+
// `getSpanDescendants()` stops at an unsampled span, and a sealed span has already had its tree read
386+
// for the last time, so a child added here could never show up in a transaction. Skipping it keeps a
387+
// span that outlives its children (e.g. a framework boot span still active in a queue consumer's
388+
// async context) from pinning every later child for the rest of the process.
389+
if (!spanIsSampled(span) || span[CHILD_SPANS_SEALED_FIELD]) {
390+
return;
391+
}
392+
379393
// We store a list of child spans on the parent span
380394
// We need this for `getSpanDescendants()` to work
381-
if (span[CHILD_SPANS_FIELD]) {
382-
span[CHILD_SPANS_FIELD].add(childSpan);
383-
} else {
395+
const childSpans = span[CHILD_SPANS_FIELD];
396+
if (!childSpans) {
384397
addNonEnumerableProperty(span, CHILD_SPANS_FIELD, new Set([childSpan]));
398+
} else if (childSpans.size < MAX_CHILD_SPANS) {
399+
childSpans.add(childSpan);
385400
}
386401
}
387402

403+
/**
404+
* Stops tracking further children on a span once its tree has been read for the last time. The children
405+
* it already has are kept, so the tree stays what was sent. A child that starts afterwards is still
406+
* reachable through its own root span reference, which is what re-emitting it as an orphan transaction
407+
* relies on.
408+
*/
409+
export function sealChildSpansOnSpan(span: SpanWithPotentialChildren): void {
410+
addNonEnumerableProperty(span, CHILD_SPANS_SEALED_FIELD, true);
411+
}
412+
388413
/** This is only used internally by Idle Spans. */
389414
export function removeChildSpanFromSpan(span: SpanWithPotentialChildren, childSpan: Span): void {
390415
if (span[CHILD_SPANS_FIELD]) {

packages/core/test/lib/tracing/sentrySpan.test.ts

Lines changed: 31 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,18 +8,22 @@ import {
88
} from '../../../src/semanticAttributes';
99
import { SentrySpan } from '../../../src/tracing/sentrySpan';
1010
import { SPAN_STATUS_ERROR } from '../../../src/tracing/spanstatus';
11-
import { startInactiveSpan, startSpan } from '../../../src/tracing/trace';
11+
import { startInactiveSpan, startSpan, withActiveSpan } from '../../../src/tracing/trace';
1212
import {
1313
markSpanAsTracerProviderSpan,
1414
markSpanForOtelSourceInference,
1515
spanSourceWasExplicitlySet,
1616
} from '../../../src/tracing/utils';
1717
import type { Envelope } from '../../../src/types/envelope';
18-
import type { SpanJSON } from '../../../src/types/span';
19-
import { spanToJSON, TRACE_FLAG_NONE, TRACE_FLAG_SAMPLED } from '../../../src/utils/spanUtils';
18+
import type { Span, SpanJSON } from '../../../src/types/span';
19+
import { getRootSpan, spanToJSON, TRACE_FLAG_NONE, TRACE_FLAG_SAMPLED } from '../../../src/utils/spanUtils';
2020
import { timestampInSeconds } from '../../../src/utils/time';
2121
import { getDefaultTestClientOptions, TestClient } from '../../mocks/client';
2222

23+
function childSpansOf(span: Span): Set<Span> {
24+
return (span as unknown as { _sentryChildSpans?: Set<Span> })._sentryChildSpans ?? new Set();
25+
}
26+
2327
describe('SentrySpan', () => {
2428
describe('name', () => {
2529
it('works with name', () => {
@@ -214,6 +218,30 @@ describe('SentrySpan', () => {
214218
});
215219
});
216220

221+
describe('child span retention', () => {
222+
it('stops tracking children on a segment span once it has been captured', () => {
223+
const client = new TestClient(getDefaultTestClientOptions({ tracesSampleRate: 1 }));
224+
setCurrentClient(client);
225+
const captureEvent = vi.spyOn(client, 'captureEvent');
226+
227+
let rootSpan: Span | undefined;
228+
startSpan({ name: 'root' }, span => {
229+
rootSpan = span;
230+
startSpan({ name: 'child' }, () => {});
231+
});
232+
233+
expect(captureEvent).toHaveBeenCalledTimes(1);
234+
expect(captureEvent.mock.calls[0]![0].spans).toHaveLength(1);
235+
expect(childSpansOf(rootSpan!).size).toBe(1);
236+
237+
// A child that starts after the tree was read is not tracked, but can still find its root span,
238+
// which is all that re-emitting it as its own transaction needs.
239+
const lateChild = withActiveSpan(rootSpan!, () => startInactiveSpan({ name: 'late child' }));
240+
expect(childSpansOf(rootSpan!).size).toBe(1);
241+
expect(getRootSpan(lateChild)).toBe(rootSpan);
242+
});
243+
});
244+
217245
describe('end', () => {
218246
test('simple', () => {
219247
const span = new SentrySpan({});

packages/core/test/lib/utils/spanUtils.test.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,9 @@ import type { Span, SpanAttributes, SpanTimeInput, StreamedSpanJSON } from '../.
2222
import type { SpanStatus } from '../../../src/types/spanStatus';
2323
import type { OpenTelemetrySdkTraceBaseSpan } from '../../../src/utils/spanUtils';
2424
import {
25+
addChildSpanToSpan,
2526
getRootSpan,
27+
getSpanDescendants,
2628
spanIsSampled,
2729
spanTimeInputToSeconds,
2830
spanToJSON,
@@ -780,6 +782,30 @@ describe('getRootSpan', () => {
780782
});
781783
});
782784

785+
describe('addChildSpanToSpan', () => {
786+
it('does not track children on an unsampled span', () => {
787+
const parent = new SentrySpan({ name: 'parent', sampled: false });
788+
const child = new SentrySpan({ name: 'child', sampled: false });
789+
790+
addChildSpanToSpan(parent, child);
791+
792+
expect(getRootSpan(child)).toBe(parent);
793+
expect((parent as unknown as { _sentryChildSpans?: Set<Span> })._sentryChildSpans).toBeUndefined();
794+
});
795+
796+
it('stops tracking children once the cap is reached', () => {
797+
const parent = new SentrySpan({ name: 'parent', sampled: true });
798+
799+
const children = Array.from({ length: 1001 }, (_, i) => new SentrySpan({ name: `child-${i}`, sampled: true }));
800+
children.forEach(child => addChildSpanToSpan(parent, child));
801+
802+
// the parent plus the first 1000 children, which is what serialization would keep anyway
803+
expect(getSpanDescendants(parent)).toHaveLength(1001);
804+
// the child that was not tracked can still find its root span
805+
expect(getRootSpan(children[1000]!)).toBe(parent);
806+
});
807+
});
808+
783809
describe('updateSpanName', () => {
784810
it('updates the span name and source', () => {
785811
const span = new SentrySpan({ name: 'old-name', attributes: { [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'url' } });

0 commit comments

Comments
 (0)