Merged
4 changes: 4 additions & 0 deletions packages/core/src/carrier.ts
Original file line numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content
Merged
4 changes: 4 additions & 0 deletions packages/core/src/carrier.ts
Original file line numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
4 changes: 4 additions & 0 deletions packages/core/src/carrier.ts
Original file line numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
4 changes: 4 additions & 0 deletions packages/core/src/carrier.ts
Original file line numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content
Merged
4 changes: 4 additions & 0 deletions packages/core/src/carrier.ts
Original file line numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
4 changes: 4 additions & 0 deletions packages/core/src/carrier.ts
Original file line numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
4 changes: 4 additions & 0 deletions packages/core/src/carrier.ts
Original file line numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content
Merged
4 changes: 4 additions & 0 deletions packages/core/src/carrier.ts
Original file line numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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