Skip to content
15 changes: 15 additions & 0 deletions packages/core/src/utils/spanUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 4fb00f7. Configure here.


// We store a list of child spans on the parent span
// We need this for `getSpanDescendants()` to work
if (span[CHILD_SPANS_FIELD]) {
Expand Down
51 changes: 48 additions & 3 deletions packages/core/test/lib/tracing/sentrySpan.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Span> {
return (span as unknown as { _sentryChildSpans?: Set<Span> })._sentryChildSpans ?? new Set();
}

describe('SentrySpan', () => {
describe('name', () => {
it('works with name', () => {
Expand Down Expand Up @@ -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({});
Expand Down
39 changes: 39 additions & 0 deletions packages/core/test/lib/utils/spanUtils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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<Span> })._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' } });
Expand Down
Loading