-
-
Notifications
You must be signed in to change notification settings - Fork 1.8k
feat(core): Add deferred segment-span transaction capture #21839
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
andreiborza
wants to merge
9
commits into
ab/sentry-trace-provider-otel
from
ab/sentry-trace-provider-core-capture
+424
−15
Open
Changes from all commits
Commits
Show all changes
9 commits
Select commit
Hold shift + click to select a range
cdb427c
Defer segment-span transaction capture with a debounced timer
andreiborza aff4711
Emit late-ending child spans as orphan transactions instead of droppi…
andreiborza f3cc97d
Make segment-span deferral enable-only
andreiborza 6889d84
Move deferred segment-span capture behind a tree-shakeable strategy seam
andreiborza cadd668
Unit-test deferred segment capture: late-child inclusion, orphan emis…
andreiborza 330e8f1
Clarify SegmentSpanCaptureConvertOptions doc comment
andreiborza 9b086cc
Route orphan transactions to the client that sent the segment
andreiborza 1d7f095
Simplify deferred segment capture to a per-client queue with bound ca…
andreiborza d93efba
Route deferred captures through the span's captured scope
andreiborza File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,116 @@ | ||
| import type { Client } from '../client'; | ||
| import type { Scope } from '../scope'; | ||
| import type { Span } from '../types/span'; | ||
| import { debounce } from '../utils/debounce'; | ||
| import { getSegmentSpanCaptureStrategy, setSegmentSpanCaptureStrategy } from './segmentSpanCaptureStrategy'; | ||
| import type { SegmentSpanConverter } from './segmentSpanCaptureStrategy'; | ||
|
|
||
| // Spans already sent in a transaction, so a child ending after its segment can be emitted as its own | ||
| // orphan transaction instead of being dropped or sent twice. | ||
| const CAPTURED_SPANS = new WeakSet<Span>(); | ||
| const isSpanAlreadyCaptured = (span: Span): boolean => CAPTURED_SPANS.has(span); | ||
| const markSpanCaptured = (span: Span): void => { | ||
| CAPTURED_SPANS.add(span); | ||
| }; | ||
|
|
||
| // One debounced queue per client, drained on the client's `flush`/`close`. Mirrors the OpenTelemetry | ||
| // span exporter, which holds one such buffer per instance, and the debounce window matches it. The | ||
| // capturing client is resolved from the span's captured scope and bound when the span ends, not | ||
| // re-resolved at drain time, so a deferred transaction lands on the client that created the span even if | ||
| // the current client (or the captured scope's own client) is reassigned before the debounce fires. | ||
| const CLIENT_QUEUES = new WeakMap<Client, (capture: () => void) => void>(); | ||
|
|
||
| /** | ||
| * @private Private API with no semver guarantees! | ||
| * | ||
| * Enable deferred segment-span transaction capture for a client: create its debounced queue and | ||
| * register the strategy (idempotent). | ||
| * | ||
| * `SentrySpan` otherwise assembles the transaction synchronously the instant a segment span ends, which | ||
| * drops children whose async instrumentation closes them later (a diagnostics-channel `asyncEnd` | ||
| * callback in the same tick, or engine spans replayed on a later tick). The debounced snapshot delays | ||
| * capture just enough for those later span ends to land first; a child that still ends after it is | ||
| * emitted as its own orphan transaction. Pending captures drain on the client's `flush` hook, so | ||
| * `Sentry.flush()` / `client.close()` cannot resolve before they run. | ||
| */ | ||
| export function _INTERNAL_setDeferSegmentSpanCapture(client: Client): void { | ||
| if (!getSegmentSpanCaptureStrategy()) { | ||
| setSegmentSpanCaptureStrategy(deferredSegmentSpanCaptureStrategy); | ||
| } | ||
| if (CLIENT_QUEUES.has(client)) { | ||
| return; | ||
| } | ||
|
|
||
| const pendingCaptures = new Set<() => void>(); | ||
| const debouncedDrain = debounce( | ||
| () => { | ||
| const captures = [...pendingCaptures]; | ||
| pendingCaptures.clear(); | ||
| for (const capture of captures) { | ||
| capture(); | ||
| } | ||
| }, | ||
| 1, | ||
| { maxWait: 100 }, | ||
| ); | ||
|
|
||
| client.on('flush', () => { | ||
| debouncedDrain.flush(); | ||
| }); | ||
|
|
||
| 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); | ||
| } | ||
|
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(); | ||
| } | ||
| }, | ||
| }; | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,43 @@ | ||
| import { getMainCarrier, getSentryCarrier } from '../carrier'; | ||
| import type { Scope } from '../scope'; | ||
| import type { TransactionEvent } from '../types/event'; | ||
| import type { Span } from '../types/span'; | ||
|
|
||
| /** | ||
| * Callbacks the deferred-capture strategy hands to `_convertSpanToTransaction` when assembling a | ||
| * transaction. The synchronous (browser) path calls the converter with no options, so neither runs. | ||
| */ | ||
| export interface SegmentSpanCaptureConvertOptions { | ||
| /** Skip a descendant already sent in an earlier transaction, so it isn't sent twice. */ | ||
| isSpanAlreadyCaptured?: (span: Span) => boolean; | ||
| /** Record each span included here, so a child that ends after the snapshot can be emitted as an orphan. */ | ||
| onSpanCaptured?: (span: Span) => void; | ||
| } | ||
|
|
||
| export type SegmentSpanConverter = (options?: SegmentSpanCaptureConvertOptions) => TransactionEvent | undefined; | ||
|
|
||
| /** | ||
| * Assembles segment spans into transactions. Registered by SDKs that defer capture (see | ||
| * `_INTERNAL_setDeferSegmentSpanCapture`); when unset, `SentrySpan` captures synchronously. Living | ||
| * behind this seam tree-shakes the deferral machinery out of SDKs that never register one (e.g. browser). | ||
| */ | ||
| export interface SegmentSpanCaptureStrategy { | ||
| /** Assemble and capture a segment (root or standalone-root) span's transaction through its captured scope. */ | ||
| onSegmentSpanEnded(convert: SegmentSpanConverter, scope: Scope): void; | ||
| /** Consider a child that ended after its segment for emission as its own orphan transaction. */ | ||
| onChildSpanEnded(span: Span, rootSpan: Span, convert: SegmentSpanConverter, scope: Scope): void; | ||
| } | ||
|
|
||
| /** | ||
| * @private Private API with no semver guarantees! | ||
| * | ||
| * Set the global segment-span capture strategy (or clear it with `undefined`). | ||
| */ | ||
| export function setSegmentSpanCaptureStrategy(strategy: SegmentSpanCaptureStrategy | undefined): void { | ||
| getSentryCarrier(getMainCarrier()).segmentSpanCaptureStrategy = strategy; | ||
| } | ||
|
|
||
| /** Get the global segment-span capture strategy, or `undefined` when none is registered. */ | ||
| export function getSegmentSpanCaptureStrategy(): SegmentSpanCaptureStrategy | undefined { | ||
| return getSentryCarrier(getMainCarrier()).segmentSpanCaptureStrategy; | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.