Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions packages/core/src/tracing/sentrySpan.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ import {
getSpanDescendants,
getStatusMessage,
getStreamedSpanLinks,
sealChildSpansOnSpan,
spanTimeInputToSeconds,
spanToJSON,
spanToTransactionTraceContext,
Expand Down Expand Up @@ -413,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;
}

Expand Down Expand Up @@ -470,6 +473,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
Comment thread
cursor[bot] marked this conversation as resolved.
// 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.
Expand Down
34 changes: 31 additions & 3 deletions packages/core/src/utils/spanUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -360,10 +360,19 @@ 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;

type SpanWithPotentialChildren = Span & {
[CHILD_SPANS_FIELD]?: Set<Span>;
[CHILD_SPANS_SEALED_FIELD]?: boolean;
[ROOT_SPAN_FIELD]?: Span;
};

Expand All @@ -376,15 +385,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);
Comment thread
cursor[bot] marked this conversation as resolved.
}
}

/**
* 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]) {
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
26 changes: 26 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,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<Span> })._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' } });
Expand Down
Loading