diff --git a/packages/core/src/carrier.ts b/packages/core/src/carrier.ts index 70905e69ab94..94ca87dc1ef1 100644 --- a/packages/core/src/carrier.ts +++ b/packages/core/src/carrier.ts @@ -2,6 +2,7 @@ import type { AsyncContextStack } from './asyncContext/stackStrategy'; import type { AsyncContextStrategy } from './asyncContext/types'; import type { Client } from './client'; import type { Scope } from './scope'; +import type { SegmentSpanCaptureStrategy } from './tracing/segmentSpanCaptureStrategy'; import type { SerializedLog } from './types/log'; import type { SerializedMetric } from './types/metric'; import { SDK_VERSION } from './utils/version'; @@ -39,6 +40,9 @@ export interface SentryCarrier { */ clientToMetricBufferMap?: WeakMap>; + /** Strategy for assembling segment spans into transactions; set by SDKs that defer capture. */ + segmentSpanCaptureStrategy?: SegmentSpanCaptureStrategy; + /** Overwrites TextEncoder used in `@sentry/core`, need for `react-native@0.73` and older */ encodePolyfill?: (input: string) => Uint8Array; /** Overwrites TextDecoder used in `@sentry/core`, need for `react-native@0.73` and older */ diff --git a/packages/core/src/tracing/deferSegmentSpanCapture.ts b/packages/core/src/tracing/deferSegmentSpanCapture.ts new file mode 100644 index 000000000000..9633f7aacc27 --- /dev/null +++ b/packages/core/src/tracing/deferSegmentSpanCapture.ts @@ -0,0 +1,112 @@ +import type { Client } from '../client'; +import { getClient } from '../currentScopes'; +import type { Span } from '../types/span'; +import { debounce } from '../utils/debounce'; +import { getSegmentSpanCaptureStrategy, setSegmentSpanCaptureStrategy } from './segmentSpanCaptureStrategy'; +import type { SegmentSpanConverter } from './segmentSpanCaptureStrategy'; + +// Spans already sent in a transaction, so a child ending after its segment can be emitted as its own +// orphan transaction instead of being dropped or sent twice. +const CAPTURED_SPANS = new WeakSet(); +const isSpanAlreadyCaptured = (span: Span): boolean => CAPTURED_SPANS.has(span); +const markSpanCaptured = (span: Span): void => { + CAPTURED_SPANS.add(span); +}; + +// One debounced queue per client, drained on the client's `flush`/`close`. Mirrors the OpenTelemetry +// span exporter, which holds one such buffer per instance, and the debounce window matches it. The +// capturing client is bound when the span ends (not re-resolved at drain time), so a deferred capture +// lands on the client that created the span even if a different client became current in the meantime. +const CLIENT_QUEUES = new WeakMap void) => void>(); + +/** + * @private Private API with no semver guarantees! + * + * Enable deferred segment-span transaction capture for a client: create its debounced queue and + * register the strategy (idempotent). Called from the `NodeClient` constructor (see the reasoning + * there for why it lives on the client rather than in `initOtel`). + * + * `SentrySpan` otherwise assembles the transaction synchronously the instant a segment span ends, which + * drops children whose async instrumentation closes them later (a diagnostics-channel `asyncEnd` + * callback in the same tick, or engine spans replayed on a later tick). The debounced snapshot delays + * capture just enough for those later span ends to land first; a child that still ends after it is + * emitted as its own orphan transaction. Pending captures drain on the client's `flush` hook, so + * `Sentry.flush()` / `client.close()` cannot resolve before they run. + */ +export function _INTERNAL_setDeferSegmentSpanCapture(client: Client): void { + if (!getSegmentSpanCaptureStrategy()) { + setSegmentSpanCaptureStrategy(deferredSegmentSpanCaptureStrategy); + } + if (CLIENT_QUEUES.has(client)) { + return; + } + + const pendingCaptures = new Set<() => void>(); + const debouncedDrain = debounce( + () => { + const captures = [...pendingCaptures]; + pendingCaptures.clear(); + for (const capture of captures) { + capture(); + } + }, + 1, + { maxWait: 100 }, + ); + + client.on('flush', () => { + debouncedDrain.flush(); + }); + + CLIENT_QUEUES.set(client, capture => { + pendingCaptures.add(capture); + debouncedDrain(); + }); +} + +const deferredSegmentSpanCaptureStrategy = { + onSegmentSpanEnded(convert: SegmentSpanConverter): void { + const client = getClient(); + const enqueue = client && CLIENT_QUEUES.get(client); + if (!enqueue) { + // The current client didn't enable deferral: capture synchronously. + const transactionEvent = convert(); + if (transactionEvent) { + client?.captureEvent(transactionEvent); + } + return; + } + + enqueue(() => { + const transactionEvent = convert({ isSpanAlreadyCaptured, onSpanCaptured: markSpanCaptured }); + if (transactionEvent) { + client.captureEvent(transactionEvent); + } + }); + }, + + onChildSpanEnded(span: Span, rootSpan: Span, convert: SegmentSpanConverter): void { + // Only a late child of an already-captured segment is an orphan. Inert under span streaming, where + // `CAPTURED_SPANS` is never populated. + if (CAPTURED_SPANS.has(span) || !CAPTURED_SPANS.has(rootSpan)) { + return; + } + + const client = getClient(); + const enqueue = client && CLIENT_QUEUES.get(client); + if (!enqueue) { + return; + } + + enqueue(() => { + const transactionEvent = convert({ isSpanAlreadyCaptured, onSpanCaptured: markSpanCaptured }); + if (transactionEvent?.contexts?.trace?.data) { + // Tag orphans so they're distinguishable downstream (mirrors the OTel span exporter). + transactionEvent.contexts.trace.data['sentry.parent_span_already_sent'] = true; + } + if (transactionEvent) { + client.captureEvent(transactionEvent); + } + }); + }, +}; diff --git a/packages/core/src/tracing/index.ts b/packages/core/src/tracing/index.ts index abdb104e33d5..c06373d61f56 100644 --- a/packages/core/src/tracing/index.ts +++ b/packages/core/src/tracing/index.ts @@ -11,6 +11,7 @@ export { } from './utils'; export { startIdleSpan, TRACING_DEFAULTS } from './idleSpan'; export { SentrySpan } from './sentrySpan'; +export { _INTERNAL_setDeferSegmentSpanCapture } from './deferSegmentSpanCapture'; export { SentryNonRecordingSpan } from './sentryNonRecordingSpan'; export { setHttpStatus, getSpanStatusFromHttpCode } from './spanstatus'; export { SPAN_STATUS_ERROR, SPAN_STATUS_OK, SPAN_STATUS_UNSET } from './spanstatus'; diff --git a/packages/core/src/tracing/segmentSpanCaptureStrategy.ts b/packages/core/src/tracing/segmentSpanCaptureStrategy.ts new file mode 100644 index 000000000000..00fdece5784b --- /dev/null +++ b/packages/core/src/tracing/segmentSpanCaptureStrategy.ts @@ -0,0 +1,42 @@ +import { getMainCarrier, getSentryCarrier } from '../carrier'; +import type { TransactionEvent } from '../types/event'; +import type { Span } from '../types/span'; + +/** + * Callbacks the deferred-capture strategy hands to `_convertSpanToTransaction` when assembling a + * transaction. The synchronous (browser) path calls the converter with no options, so neither runs. + */ +export interface SegmentSpanCaptureConvertOptions { + /** Skip a descendant already sent in an earlier transaction, so it isn't sent twice. */ + isSpanAlreadyCaptured?: (span: Span) => boolean; + /** Record each span included here, so a child that ends after the snapshot can be emitted as an orphan. */ + onSpanCaptured?: (span: Span) => void; +} + +export type SegmentSpanConverter = (options?: SegmentSpanCaptureConvertOptions) => TransactionEvent | undefined; + +/** + * Assembles segment spans into transactions. Registered by SDKs that defer capture (see + * `_INTERNAL_setDeferSegmentSpanCapture`); when unset, `SentrySpan` captures synchronously. Living + * behind this seam tree-shakes the deferral machinery out of SDKs that never register one (e.g. browser). + */ +export interface SegmentSpanCaptureStrategy { + /** Assemble and capture a segment (root or standalone-root) span's transaction. */ + onSegmentSpanEnded(convert: SegmentSpanConverter): void; + /** Consider a child that ended after its segment for emission as its own orphan transaction. */ + onChildSpanEnded(span: Span, rootSpan: Span, convert: SegmentSpanConverter): void; +} + +/** + * @private Private API with no semver guarantees! + * + * Set the global segment-span capture strategy (or clear it with `undefined`). + */ +export function setSegmentSpanCaptureStrategy(strategy: SegmentSpanCaptureStrategy | undefined): void { + getSentryCarrier(getMainCarrier()).segmentSpanCaptureStrategy = strategy; +} + +/** Get the global segment-span capture strategy, or `undefined` when none is registered. */ +export function getSegmentSpanCaptureStrategy(): SegmentSpanCaptureStrategy | undefined { + return getSentryCarrier(getMainCarrier()).segmentSpanCaptureStrategy; +} diff --git a/packages/core/src/tracing/sentrySpan.ts b/packages/core/src/tracing/sentrySpan.ts index 8bf052ff5b64..527a19dafe3e 100644 --- a/packages/core/src/tracing/sentrySpan.ts +++ b/packages/core/src/tracing/sentrySpan.ts @@ -46,6 +46,7 @@ import { timestampInSeconds } from '../utils/time'; import { getDynamicSamplingContextFromSpan } from './dynamicSamplingContext'; import { logSpanEnd } from './logSpans'; import { timedEventsToMeasurements } from './measurement'; +import { getSegmentSpanCaptureStrategy, type SegmentSpanCaptureConvertOptions } from './segmentSpanCaptureStrategy'; import { hasSpanStreamingEnabled } from './spans/hasSpanStreamingEnabled'; import { getCapturedScopesOnSpan, markSpanSourceAsExplicit, spanShouldInferOtelSource } from './utils'; @@ -343,11 +344,8 @@ export class SentrySpan implements Span { // A segment span is basically the root span of a local span tree. // So for now, this is either what we previously refer to as the root span, // or a standalone span. - const isSegmentSpan = this._isStandaloneSpan || this === getRootSpan(this); - - if (!isSegmentSpan) { - return; - } + const rootSpan = getRootSpan(this); + const isSegmentSpan = this._isStandaloneSpan || this === rootSpan; // if this is a standalone span, we send it immediately if (this._isStandaloneSpan) { @@ -361,23 +359,41 @@ export class SentrySpan implements Span { } } return; - } else if (client && hasSpanStreamingEnabled(client)) { + } + + // Non-segment children aren't captured on their own. A registered strategy may re-emit a late child + // as its own orphan transaction; without one, it's dropped. + if (!isSegmentSpan) { + getSegmentSpanCaptureStrategy()?.onChildSpanEnded(this, rootSpan, options => + this._convertSpanToTransaction(options), + ); + return; + } + + if (client && hasSpanStreamingEnabled(client)) { // TODO (spans): Remove standalone span custom logic in favor of sending simple v2 web vital spans client.emit('afterSegmentSpanEnd', this); return; } - const transactionEvent = this._convertSpanToTransaction(); - if (transactionEvent) { - const scope = getCapturedScopesOnSpan(this).scope || getCurrentScope(); - scope.captureEvent(transactionEvent); + // A registered strategy defers the snapshot so children closing just after the segment still land + // (and late ones can orphan); without one, assemble synchronously from the live tree. + const strategy = getSegmentSpanCaptureStrategy(); + if (strategy) { + strategy.onSegmentSpanEnded(options => this._convertSpanToTransaction(options)); + } else { + const transactionEvent = this._convertSpanToTransaction(); + if (transactionEvent) { + const scope = getCapturedScopesOnSpan(this).scope || getCurrentScope(); + scope.captureEvent(transactionEvent); + } } } /** * Finish the transaction & prepare the event to send to Sentry. */ - private _convertSpanToTransaction(): TransactionEvent | undefined { + private _convertSpanToTransaction(options: SegmentSpanCaptureConvertOptions = {}): TransactionEvent | undefined { // We can only convert finished spans if (!isFullFinishedSpan(spanToJSON(this))) { return undefined; @@ -396,10 +412,21 @@ export class SentrySpan implements Span { return undefined; } - // The transaction span itself as well as any potential standalone spans should be filtered out - const finishedSpans = getSpanDescendants(this).filter(span => span !== this && !isStandaloneSpan(span)); - - const spans = finishedSpans.map(span => spanToJSON(span)).filter(isFullFinishedSpan); + // Skip the span itself, standalone spans, and (when a strategy tracks it) spans already sent. The + // synchronous default passes no hooks, so this bookkeeping stays out of SDKs that don't defer. + options.onSpanCaptured?.(this); + const spans: SpanJSON[] = []; + for (const descendant of getSpanDescendants(this)) { + if (descendant === this || isStandaloneSpan(descendant) || options.isSpanAlreadyCaptured?.(descendant)) { + continue; + } + const spanJSON = spanToJSON(descendant); + if (!isFullFinishedSpan(spanJSON)) { + continue; + } + options.onSpanCaptured?.(descendant); + spans.push(spanJSON); + } const source = this._attributes[SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]; diff --git a/packages/core/test/lib/tracing/deferSegmentSpanCapture.test.ts b/packages/core/test/lib/tracing/deferSegmentSpanCapture.test.ts new file mode 100644 index 000000000000..70fd49a8751f --- /dev/null +++ b/packages/core/test/lib/tracing/deferSegmentSpanCapture.test.ts @@ -0,0 +1,122 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { + getCurrentScope, + getGlobalScope, + getIsolationScope, + setCurrentClient, + startInactiveSpan, + withActiveSpan, +} from '../../../src'; +import { _INTERNAL_setDeferSegmentSpanCapture } from '../../../src/tracing/deferSegmentSpanCapture'; +import { + getSegmentSpanCaptureStrategy, + setSegmentSpanCaptureStrategy, +} from '../../../src/tracing/segmentSpanCaptureStrategy'; +import type { Event } from '../../../src/types/event'; +import { getDefaultTestClientOptions, TestClient } from '../../mocks/client'; + +const dsn = 'https://123@sentry.io/42'; + +describe('_INTERNAL_setDeferSegmentSpanCapture', () => { + afterEach(() => { + setSegmentSpanCaptureStrategy(undefined); + }); + + it('registers the global capture strategy', () => { + expect(getSegmentSpanCaptureStrategy()).toBeUndefined(); + + _INTERNAL_setDeferSegmentSpanCapture(new TestClient(getDefaultTestClientOptions())); + + expect(getSegmentSpanCaptureStrategy()).toBeDefined(); + }); + + it('registers the flush listener once and is idempotent on repeated enable', () => { + const client = new TestClient(getDefaultTestClientOptions()); + const onSpy = vi.spyOn(client, 'on'); + + _INTERNAL_setDeferSegmentSpanCapture(client); + _INTERNAL_setDeferSegmentSpanCapture(client); + + expect(onSpy.mock.calls.filter(([hook]) => hook === 'flush')).toHaveLength(1); + }); +}); + +describe('deferred segment-span capture', () => { + let transactions: Event[]; + let client: TestClient; + + beforeEach(() => { + vi.useFakeTimers(); + + getCurrentScope().clear(); + getIsolationScope().clear(); + getGlobalScope().clear(); + + transactions = []; + const options = getDefaultTestClientOptions({ + dsn, + tracesSampleRate: 1, + beforeSendTransaction: event => { + transactions.push(event); + return null; + }, + }); + client = new TestClient(options); + setCurrentClient(client); + client.init(); + _INTERNAL_setDeferSegmentSpanCapture(client); + }); + + afterEach(() => { + setSegmentSpanCaptureStrategy(undefined); + vi.clearAllMocks(); + vi.useRealTimers(); + }); + + it('includes a child that ends after the segment but before the debounce fires', () => { + const root = startInactiveSpan({ name: 'root' }); + const child = withActiveSpan(root, () => startInactiveSpan({ name: 'child' })); + + root.end(); + child.end(); + + // The snapshot is deferred, so nothing is captured until the debounce fires. + expect(transactions).toHaveLength(0); + + vi.advanceTimersByTime(100); + + expect(transactions).toHaveLength(1); + expect(transactions[0]!.spans).toEqual([expect.objectContaining({ description: 'child' })]); + }); + + it('emits a child that ends after the snapshot as its own orphan transaction', () => { + const root = startInactiveSpan({ name: 'root' }); + const child = withActiveSpan(root, () => startInactiveSpan({ name: 'child' })); + + root.end(); + vi.advanceTimersByTime(100); + + // Segment transaction assembled without the still-open child. + expect(transactions).toHaveLength(1); + expect(transactions[0]!.spans).toEqual([]); + + child.end(); + vi.advanceTimersByTime(100); + + expect(transactions).toHaveLength(2); + expect(transactions[1]!.transaction).toBe('child'); + expect(transactions[1]!.contexts?.trace?.data?.['sentry.parent_span_already_sent']).toBe(true); + }); + + it('drains pending captures synchronously on flush', () => { + const root = startInactiveSpan({ name: 'root' }); + root.end(); + + // Still queued behind the debounce timer. + expect(transactions).toHaveLength(0); + + client.emit('flush'); + + expect(transactions).toHaveLength(1); + }); +});