-
-
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
7
commits into
ab/sentry-trace-provider-otel
from
ab/sentry-trace-provider-core-capture
+380
−15
Open
Changes from all commits
Commits
Show all changes
7 commits
Select commit
Hold shift + click to select a range
571649c
Defer segment-span transaction capture with a debounced timer
andreiborza d2c27f4
Emit late-ending child spans as orphan transactions instead of droppi…
andreiborza 4b2c8f2
Make segment-span deferral enable-only
andreiborza 6f81792
Move deferred segment-span capture behind a tree-shakeable strategy seam
andreiborza a9a6c12
Unit-test deferred segment capture: late-child inclusion, orphan emis…
andreiborza b39882b
Clarify SegmentSpanCaptureConvertOptions doc comment
andreiborza e7f6447
Route orphan transactions to the client that sent the segment
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,132 @@ | ||
| import type { Client } from '../client'; | ||
| import { getCurrentScope } from '../currentScopes'; | ||
| import type { Scope } from '../scope'; | ||
| import type { Span } from '../types/span'; | ||
| import { debounce } from '../utils/debounce'; | ||
| import { | ||
| getSegmentSpanCaptureStrategy, | ||
| type SegmentSpanConverter, | ||
| setSegmentSpanCaptureStrategy, | ||
| } from './segmentSpanCaptureStrategy'; | ||
| import { getCapturedScopesOnSpan } from './utils'; | ||
|
|
||
| // Spans already sent in a transaction, mapped to the client that sent them. A child ending after its | ||
| // segment can then be emitted as its own orphan transaction (instead of dropped or sent twice), routed | ||
| // to the same client that sent the segment rather than whatever client is current when the child ends. | ||
| const CAPTURED_SPAN_CLIENTS = new WeakMap<Span, Client>(); | ||
|
|
||
| const isSpanAlreadyCaptured = (span: Span): boolean => CAPTURED_SPAN_CLIENTS.has(span); | ||
|
|
||
| // Per-client so each client's flush/close drains only its own captures: one client's flush must not | ||
| // snapshot another's transaction early. Mirrors the per-client log/metric buffers. | ||
| const CLIENT_QUEUES = new WeakMap<Client, DeferredCaptureQueue>(); | ||
|
|
||
| interface DeferredCaptureQueue { | ||
| enqueue: (capture: () => void) => void; | ||
| flush: () => void; | ||
| } | ||
|
|
||
| /** | ||
| * @private Private API with no semver guarantees! | ||
| * | ||
| * Enable deferred segment-span transaction capture for a client (idempotent per client). Deferring the | ||
| * snapshot lets children that close just after their segment still land in the transaction; pending | ||
| * captures drain on `flush`, so `Sentry.flush()` / `client.close()` cannot resolve before they run. | ||
| */ | ||
| export function _INTERNAL_setDeferSegmentSpanCapture(client: Client): void { | ||
| if (!getSegmentSpanCaptureStrategy()) { | ||
| setSegmentSpanCaptureStrategy(deferredSegmentSpanCaptureStrategy); | ||
| } | ||
| // A client that never opts in has no queue and falls back to synchronous capture below. | ||
| getClientQueue(client); | ||
| } | ||
|
|
||
| const deferredSegmentSpanCaptureStrategy = { | ||
| onSegmentSpanEnded(scope: Scope, client: Client, convert: SegmentSpanConverter): void { | ||
| const queue = CLIENT_QUEUES.get(client); | ||
| if (!queue) { | ||
| // Client never opted into deferral: capture synchronously, exactly as if no strategy existed. | ||
| const transactionEvent = convert(); | ||
| if (transactionEvent) { | ||
| scope.captureEvent(transactionEvent); | ||
| } | ||
| return; | ||
| } | ||
|
|
||
| queue.enqueue(() => { | ||
| const transactionEvent = convert({ | ||
| isSpanAlreadyCaptured, | ||
| onSpanCaptured: span => CAPTURED_SPAN_CLIENTS.set(span, client), | ||
| }); | ||
| if (transactionEvent) { | ||
| // Capture via the client active at span end (passing its scope for context), so a later-tick | ||
| // capture reaches that client even if the current client changed since (e.g. after re-init). | ||
| client.captureEvent(transactionEvent, undefined, scope); | ||
| } | ||
| }); | ||
| }, | ||
|
|
||
| onChildSpanEnded(span: Span, rootSpan: Span, convert: SegmentSpanConverter): void { | ||
| // Route the orphan to the client that sent its segment, not the current one — which may have | ||
| // changed since (e.g. after re-init) — so it lands with its segment and survives a client swap. | ||
| const client = CAPTURED_SPAN_CLIENTS.get(rootSpan); | ||
| const queue = client && CLIENT_QUEUES.get(client); | ||
| // Only a late child of an already-captured segment is an orphan. Inert under span streaming, where | ||
| // no client is recorded. | ||
| if (!client || !queue || CAPTURED_SPAN_CLIENTS.has(span)) { | ||
| return; | ||
| } | ||
|
|
||
| const scope = getCapturedScopesOnSpan(span).scope || getCurrentScope(); | ||
| queue.enqueue(() => { | ||
| const transactionEvent = convert({ | ||
| isSpanAlreadyCaptured, | ||
| onSpanCaptured: capturedSpan => CAPTURED_SPAN_CLIENTS.set(capturedSpan, client), | ||
| }); | ||
| if (transactionEvent?.contexts?.trace?.data) { | ||
| // Tag orphans so they're distinguishable downstream (mirrors the OTel span exporter). | ||
| transactionEvent.contexts.trace.data['sentry.parent_span_already_sent'] = true; | ||
| } | ||
| if (transactionEvent) { | ||
| client.captureEvent(transactionEvent, undefined, scope); | ||
| } | ||
| }); | ||
| }, | ||
| }; | ||
|
|
||
| function getClientQueue(client: Client): DeferredCaptureQueue { | ||
| const existing = CLIENT_QUEUES.get(client); | ||
| if (existing) { | ||
| return existing; | ||
| } | ||
|
|
||
| const pendingCaptures = new Set<() => void>(); | ||
| const debouncedDrain = debounce( | ||
| () => { | ||
| const captures = [...pendingCaptures]; | ||
| pendingCaptures.clear(); | ||
| for (const capture of captures) { | ||
| capture(); | ||
| } | ||
| }, | ||
| 1, | ||
| { maxWait: 100 }, | ||
| ); | ||
|
andreiborza marked this conversation as resolved.
|
||
|
|
||
| const queue: DeferredCaptureQueue = { | ||
| enqueue: capture => { | ||
| pendingCaptures.add(capture); | ||
| debouncedDrain(); | ||
| }, | ||
| flush: () => { | ||
| debouncedDrain.flush(); | ||
| }, | ||
| }; | ||
|
|
||
| client.on('flush', () => { | ||
| queue.flush(); | ||
| }); | ||
|
|
||
| CLIENT_QUEUES.set(client, queue); | ||
| return queue; | ||
| } | ||
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,44 @@ | ||
| import { getMainCarrier, getSentryCarrier } from '../carrier'; | ||
| import type { Client } from '../client'; | ||
| import type { Scope } from '../scope'; | ||
| import type { TransactionEvent } from '../types/event'; | ||
| import type { Span } from '../types/span'; | ||
|
|
||
| /** | ||
| * Callbacks the deferred-capture strategy hands to `_convertSpanToTransaction` when assembling a | ||
| * transaction. The synchronous (browser) path calls the converter with no options, so neither runs. | ||
| */ | ||
| export interface SegmentSpanCaptureConvertOptions { | ||
| /** Skip a descendant already sent in an earlier transaction, so it isn't sent twice. */ | ||
| isSpanAlreadyCaptured?: (span: Span) => boolean; | ||
| /** Record each span included here, so a child that ends after the snapshot can be emitted as an orphan. */ | ||
| onSpanCaptured?: (span: Span) => void; | ||
| } | ||
|
|
||
| export type SegmentSpanConverter = (options?: SegmentSpanCaptureConvertOptions) => TransactionEvent | undefined; | ||
|
|
||
| /** | ||
| * Assembles segment spans into transactions. Registered by SDKs that defer capture (see | ||
| * `_INTERNAL_setDeferSegmentSpanCapture`); when unset, `SentrySpan` captures synchronously. Living | ||
| * behind this seam tree-shakes the deferral machinery out of SDKs that never register one (e.g. browser). | ||
| */ | ||
| export interface SegmentSpanCaptureStrategy { | ||
| /** Assemble and capture a segment (root or standalone-root) span's transaction. */ | ||
| onSegmentSpanEnded(scope: Scope, client: Client, convert: SegmentSpanConverter): void; | ||
| /** Consider a child that ended after its segment for emission as its own orphan transaction. */ | ||
| onChildSpanEnded(span: Span, rootSpan: Span, convert: SegmentSpanConverter): void; | ||
| } | ||
|
|
||
| /** | ||
| * @private Private API with no semver guarantees! | ||
| * | ||
| * Set the global segment-span capture strategy (or clear it with `undefined`). | ||
| */ | ||
| export function setSegmentSpanCaptureStrategy(strategy: SegmentSpanCaptureStrategy | undefined): void { | ||
| getSentryCarrier(getMainCarrier()).segmentSpanCaptureStrategy = strategy; | ||
| } | ||
|
|
||
| /** Get the global segment-span capture strategy, or `undefined` when none is registered. */ | ||
| export function getSegmentSpanCaptureStrategy(): SegmentSpanCaptureStrategy | undefined { | ||
| return getSentryCarrier(getMainCarrier()).segmentSpanCaptureStrategy; | ||
| } |
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.