Skip to content
Open
4 changes: 4 additions & 0 deletions packages/core/src/carrier.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -39,6 +40,9 @@ export interface SentryCarrier {
*/
clientToMetricBufferMap?: WeakMap<Client, Array<SerializedMetric>>;

/** 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 */
Expand Down
116 changes: 116 additions & 0 deletions packages/core/src/tracing/deferSegmentSpanCapture.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
import type { Client } from '../client';
import type { Scope } from '../scope';
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<Span>();
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 resolved from the span's captured scope and bound when the span ends, not
// re-resolved at drain time, so a deferred transaction lands on the client that created the span even if
// the current client (or the captured scope's own client) is reassigned before the debounce fires.
const CLIENT_QUEUES = new WeakMap<Client, (capture: () => 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).
*
* `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();
});
Comment thread
andreiborza marked this conversation as resolved.

CLIENT_QUEUES.set(client, capture => {
pendingCaptures.add(capture);
debouncedDrain();
});
}

const deferredSegmentSpanCaptureStrategy = {
onSegmentSpanEnded(convert: SegmentSpanConverter, scope: Scope): void {
const client = scope.getClient();
const enqueue = client && CLIENT_QUEUES.get(client);
if (!enqueue) {
// The capturing 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);
}
Comment thread
cursor[bot] marked this conversation as resolved.
});
},

onChildSpanEnded(span: Span, rootSpan: Span, convert: SegmentSpanConverter, scope: Scope): 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 = scope.getClient();
const enqueue = client && CLIENT_QUEUES.get(client);

const captureOrphan = (): void => {
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);
}
};

// Defer when the capturing client batches; otherwise emit now so the orphan isn't dropped.
if (enqueue) {
enqueue(captureOrphan);
} else {
captureOrphan();
}
},
};
1 change: 1 addition & 0 deletions packages/core/src/tracing/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
43 changes: 43 additions & 0 deletions packages/core/src/tracing/segmentSpanCaptureStrategy.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import { getMainCarrier, getSentryCarrier } from '../carrier';
import type { Scope } from '../scope';
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 through its captured scope. */
onSegmentSpanEnded(convert: SegmentSpanConverter, scope: Scope): void;
/** Consider a child that ended after its segment for emission as its own orphan transaction. */
onChildSpanEnded(span: Span, rootSpan: Span, convert: SegmentSpanConverter, scope: Scope): 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;
}
59 changes: 44 additions & 15 deletions packages/core/src/tracing/sentrySpan.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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) {
Expand All @@ -361,23 +359,43 @@ 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) {
const strategy = getSegmentSpanCaptureStrategy();
if (strategy) {
const scope = getCapturedScopesOnSpan(this).scope || getCurrentScope();
strategy.onChildSpanEnded(this, rootSpan, options => this._convertSpanToTransaction(options), scope);
}
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 scope = getCapturedScopesOnSpan(this).scope || getCurrentScope();
const strategy = getSegmentSpanCaptureStrategy();
if (strategy) {
strategy.onSegmentSpanEnded(options => this._convertSpanToTransaction(options), scope);
} else {
const transactionEvent = this._convertSpanToTransaction();
if (transactionEvent) {
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;
Expand All @@ -396,10 +414,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];

Expand Down
Loading
Loading