Skip to content
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
132 changes: 132 additions & 0 deletions packages/core/src/tracing/deferSegmentSpanCapture.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
import type { Client } from '../client';
import { getCurrentScope } from '../currentScopes';
import type { Scope } from '../scope';
import type { Span } from '../types/span';
import { debounce } from '../utils/debounce';
import {
getSegmentSpanCaptureStrategy,
type SegmentSpanConverter,
setSegmentSpanCaptureStrategy,
} from './segmentSpanCaptureStrategy';
import { getCapturedScopesOnSpan } from './utils';

// Spans already sent in a transaction, mapped to the client that sent them. A child ending after its
// segment can then be emitted as its own orphan transaction (instead of dropped or sent twice), routed
// to the same client that sent the segment rather than whatever client is current when the child ends.
const CAPTURED_SPAN_CLIENTS = new WeakMap<Span, Client>();

const isSpanAlreadyCaptured = (span: Span): boolean => CAPTURED_SPAN_CLIENTS.has(span);

// Per-client so each client's flush/close drains only its own captures: one client's flush must not
// snapshot another's transaction early. Mirrors the per-client log/metric buffers.
const CLIENT_QUEUES = new WeakMap<Client, DeferredCaptureQueue>();

interface DeferredCaptureQueue {
enqueue: (capture: () => void) => void;
flush: () => void;
}

/**
* @private Private API with no semver guarantees!
*
* Enable deferred segment-span transaction capture for a client (idempotent per client). Deferring the
* snapshot lets children that close just after their segment still land in the transaction; pending
* captures drain on `flush`, so `Sentry.flush()` / `client.close()` cannot resolve before they run.
*/
export function _INTERNAL_setDeferSegmentSpanCapture(client: Client): void {
if (!getSegmentSpanCaptureStrategy()) {
setSegmentSpanCaptureStrategy(deferredSegmentSpanCaptureStrategy);
}
// A client that never opts in has no queue and falls back to synchronous capture below.
getClientQueue(client);
}

const deferredSegmentSpanCaptureStrategy = {
onSegmentSpanEnded(scope: Scope, client: Client, convert: SegmentSpanConverter): void {
const queue = CLIENT_QUEUES.get(client);
if (!queue) {
// Client never opted into deferral: capture synchronously, exactly as if no strategy existed.
const transactionEvent = convert();
if (transactionEvent) {
scope.captureEvent(transactionEvent);
}
return;
}

queue.enqueue(() => {
const transactionEvent = convert({
isSpanAlreadyCaptured,
onSpanCaptured: span => CAPTURED_SPAN_CLIENTS.set(span, client),
});
if (transactionEvent) {
// Capture via the client active at span end (passing its scope for context), so a later-tick
// capture reaches that client even if the current client changed since (e.g. after re-init).
client.captureEvent(transactionEvent, undefined, scope);
}
});
},

onChildSpanEnded(span: Span, rootSpan: Span, convert: SegmentSpanConverter): void {
// Route the orphan to the client that sent its segment, not the current one — which may have
// changed since (e.g. after re-init) — so it lands with its segment and survives a client swap.
const client = CAPTURED_SPAN_CLIENTS.get(rootSpan);
const queue = client && CLIENT_QUEUES.get(client);
// Only a late child of an already-captured segment is an orphan. Inert under span streaming, where
// no client is recorded.
if (!client || !queue || CAPTURED_SPAN_CLIENTS.has(span)) {
return;
}

const scope = getCapturedScopesOnSpan(span).scope || getCurrentScope();
queue.enqueue(() => {
const transactionEvent = convert({
isSpanAlreadyCaptured,
onSpanCaptured: capturedSpan => CAPTURED_SPAN_CLIENTS.set(capturedSpan, client),
});
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, undefined, scope);
}
});
Comment thread
cursor[bot] marked this conversation as resolved.
},
};

function getClientQueue(client: Client): DeferredCaptureQueue {
const existing = CLIENT_QUEUES.get(client);
if (existing) {
return existing;
}

const pendingCaptures = new Set<() => void>();
const debouncedDrain = debounce(
() => {
const captures = [...pendingCaptures];
pendingCaptures.clear();
for (const capture of captures) {
capture();
}
},
1,
{ maxWait: 100 },
);
Comment thread
andreiborza marked this conversation as resolved.

const queue: DeferredCaptureQueue = {
enqueue: capture => {
pendingCaptures.add(capture);
debouncedDrain();
},
flush: () => {
debouncedDrain.flush();
},
};

client.on('flush', () => {
queue.flush();
});

CLIENT_QUEUES.set(client, queue);
return queue;
}
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
44 changes: 44 additions & 0 deletions packages/core/src/tracing/segmentSpanCaptureStrategy.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import { getMainCarrier, getSentryCarrier } from '../carrier';
import type { Client } from '../client';
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. */
onSegmentSpanEnded(scope: Scope, client: Client, 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;
}
58 changes: 43 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,42 @@ 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);
const scope = getCapturedScopesOnSpan(this).scope || getCurrentScope();

// 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 = client && getSegmentSpanCaptureStrategy();
if (strategy) {
strategy.onSegmentSpanEnded(scope, client, options => this._convertSpanToTransaction(options));
} 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 +413,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