diff --git a/packages/core/src/utils/spanUtils.ts b/packages/core/src/utils/spanUtils.ts index 84dc4b57039a..8e24736ba92d 100644 --- a/packages/core/src/utils/spanUtils.ts +++ b/packages/core/src/utils/spanUtils.ts @@ -376,6 +376,21 @@ export function addChildSpanToSpan(span: SpanWithPotentialChildren, childSpan: S const rootSpan = span[ROOT_SPAN_FIELD] || span; addNonEnumerableProperty(childSpan, ROOT_SPAN_FIELD, rootSpan); + // `_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; + } + + // 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; + } + // We store a list of child spans on the parent span // We need this for `getSpanDescendants()` to work 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..cbfd2c2fffda 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,47 @@ 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); + }); + + 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', () => { 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..9b4bd46f7ae8 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,43 @@ 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('does not track children on a segment span that stopped recording', () => { + const parent = new SentrySpan({ name: 'parent', sampled: true }); + parent.end(); + + const child = new SentrySpan({ name: 'child', sampled: true }); + addChildSpanToSpan(parent, child); + + // the child that was not tracked can still find its root span + 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]); + }); +}); + describe('updateSpanName', () => { it('updates the span name and source', () => { const span = new SentrySpan({ name: 'old-name', attributes: { [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'url' } });