Skip to content

feat(core): Add deferred segment-span transaction capture#21839

Draft
andreiborza wants to merge 3 commits into
ab/sentry-trace-provider-otelfrom
ab/sentry-trace-provider-core-capture
Draft

feat(core): Add deferred segment-span transaction capture#21839
andreiborza wants to merge 3 commits into
ab/sentry-trace-provider-otelfrom
ab/sentry-trace-provider-core-capture

Conversation

@andreiborza

@andreiborza andreiborza commented Jun 29, 2026

Copy link
Copy Markdown
Member

What

Adds the ability to defer the assembly of transactions to avoid dropping spans from transactions that end shortly after the segment span itself. Additionally, it also handles children that end after the debounce fired and transactions have already been sent. Spans that don't quite make it will end up as their own transaction in the same trace instead of being dropped .

This mimics what is already done today in the span exporter (a buffer + debounced flush).

Why

SentrySpan assembles a transaction synchronously from the span tree the instant the segment span ends. But some child spans are closed by their instrumentation after the root ends.

For example:

  • Same tick: diagnostics-channel instrumentations (HTTP, undici) end the child in an asyncEnd/response callback that runs after the root handler returns.
  • Later tick: some instrumentations replay spans asynchronously, notably @prisma/instrumentation emits its engine spans on a later tick once it receives the engine trace data.

Without deferral those children aren't in the tree yet at root-end, so they're silently dropped from the transaction. With the OTel SDK, this never happened because the SentrySpanExporter already buffers finished spans and flushes on a debounced timer. The SentryTracerProvider has no exporter, so defer reinstates that buffering window so late-ending children land before the snapshot.

  • Orphan emission handles the tail: a child that ends after the debounce fired and the transaction was already sent can't join it, so it is emitted as its own transaction in the same trace instead of being dropped (mirroring the exporter).

This is used and tested in #21680's integration/e2e tests.

Comment thread packages/core/src/tracing/sentrySpan.ts
@github-actions

github-actions Bot commented Jun 29, 2026

Copy link
Copy Markdown
Contributor

size-limit report 📦

Path Size % Change Change
@sentry/browser 27.62 kB - -
@sentry/browser - with treeshaking flags 26.05 kB - -
@sentry/browser (incl. Tracing) 46.27 kB +0.43% +195 B 🔺
@sentry/browser (incl. Tracing + Span Streaming) 48.01 kB +0.41% +196 B 🔺
@sentry/browser (incl. Tracing, Profiling) 51.04 kB +0.41% +204 B 🔺
@sentry/browser (incl. Tracing, Replay) 85.52 kB +0.25% +213 B 🔺
@sentry/browser (incl. Tracing, Replay) - with treeshaking flags 75.11 kB +0.26% +194 B 🔺
@sentry/browser (incl. Tracing, Replay with Canvas) 90.21 kB +0.25% +219 B 🔺
@sentry/browser (incl. Tracing, Replay, Feedback) 102.86 kB +0.2% +198 B 🔺
@sentry/browser (incl. Feedback) 44.8 kB - -
@sentry/browser (incl. sendFeedback) 32.42 kB - -
@sentry/browser (incl. FeedbackAsync) 37.55 kB - -
@sentry/browser (incl. Metrics) 28.68 kB - -
@sentry/browser (incl. Logs) 28.93 kB - -
@sentry/browser (incl. Metrics & Logs) 29.61 kB - -
@sentry/react 29.41 kB - -
@sentry/react (incl. Tracing) 48.55 kB +0.35% +169 B 🔺
@sentry/vue 33.05 kB +0.61% +198 B 🔺
@sentry/vue (incl. Tracing) 48.16 kB +0.49% +232 B 🔺
@sentry/svelte 27.64 kB - -
CDN Bundle 30.03 kB +0.03% +7 B 🔺
CDN Bundle (incl. Tracing) 48.24 kB +0.47% +224 B 🔺
CDN Bundle (incl. Logs, Metrics) 31.59 kB +0.01% +3 B 🔺
CDN Bundle (incl. Tracing, Logs, Metrics) 49.53 kB +0.37% +182 B 🔺
CDN Bundle (incl. Replay, Logs, Metrics) 70.79 kB +0.01% +5 B 🔺
CDN Bundle (incl. Tracing, Replay) 85.72 kB +0.25% +207 B 🔺
CDN Bundle (incl. Tracing, Replay, Logs, Metrics) 86.99 kB +0.24% +203 B 🔺
CDN Bundle (incl. Tracing, Replay, Feedback) 91.52 kB +0.23% +201 B 🔺
CDN Bundle (incl. Tracing, Replay, Feedback, Logs, Metrics) 92.75 kB +0.21% +193 B 🔺
CDN Bundle - uncompressed 89.42 kB - -
CDN Bundle (incl. Tracing) - uncompressed 145.91 kB +0.39% +566 B 🔺
CDN Bundle (incl. Logs, Metrics) - uncompressed 94.12 kB - -
CDN Bundle (incl. Tracing, Logs, Metrics) - uncompressed 149.89 kB +0.38% +566 B 🔺
CDN Bundle (incl. Replay, Logs, Metrics) - uncompressed 218.66 kB - -
CDN Bundle (incl. Tracing, Replay) - uncompressed 264.93 kB +0.22% +566 B 🔺
CDN Bundle (incl. Tracing, Replay, Logs, Metrics) - uncompressed 268.89 kB +0.22% +566 B 🔺
CDN Bundle (incl. Tracing, Replay, Feedback) - uncompressed 278.63 kB +0.21% +567 B 🔺
CDN Bundle (incl. Tracing, Replay, Feedback, Logs, Metrics) - uncompressed 282.58 kB +0.21% +567 B 🔺
@sentry/nextjs (client) 50.96 kB +0.39% +196 B 🔺
@sentry/sveltekit (client) 46.65 kB +0.42% +193 B 🔺
@sentry/core/server 78.1 kB +0.46% +351 B 🔺
@sentry/core/browser 64.42 kB +0.57% +365 B 🔺
@sentry/node-core 61.75 kB +0.46% +277 B 🔺
@sentry/node 123.09 kB +0.22% +270 B 🔺
@sentry/node/import (ESM hook with diagnostics-channel injection) 69.95 kB - -
@sentry/node/light 50.66 kB +0.42% +211 B 🔺
@sentry/node - without tracing 73.54 kB +0.47% +340 B 🔺
@sentry/aws-serverless 84.38 kB +0.35% +292 B 🔺
@sentry/cloudflare (withSentry) - minified 181.25 kB +0.35% +625 B 🔺
@sentry/cloudflare (withSentry) 448.67 kB +0.39% +1.74 kB 🔺

View base workflow run

Comment thread packages/core/src/tracing/sentrySpan.ts
Comment thread packages/core/src/tracing/sentrySpan.ts
@andreiborza andreiborza changed the title feat(core): Add deferred segment-span transaction capture, orphan emission, and provider-span sealing feat(core): Add deferred segment-span transaction capture Jun 29, 2026
Comment thread packages/core/src/tracing/sentrySpan.ts
@andreiborza andreiborza force-pushed the ab/sentry-trace-provider-otel branch from a1613c2 to 6bd79ac Compare June 29, 2026 15:46
@andreiborza andreiborza requested a review from a team as a code owner June 29, 2026 15:46
@andreiborza andreiborza requested review from JPeer264 and mydea and removed request for a team June 29, 2026 15:46
@andreiborza andreiborza force-pushed the ab/sentry-trace-provider-core-capture branch from 29ce501 to c741940 Compare June 29, 2026 15:46
@andreiborza andreiborza force-pushed the ab/sentry-trace-provider-otel branch 2 times, most recently from e005612 to 924ce11 Compare June 30, 2026 08:07
@andreiborza andreiborza force-pushed the ab/sentry-trace-provider-core-capture branch from c741940 to 4b3fc03 Compare June 30, 2026 08:13
Comment thread packages/opentelemetry/src/tracer.ts
@andreiborza andreiborza force-pushed the ab/sentry-trace-provider-otel branch from 924ce11 to 630bb67 Compare June 30, 2026 08:44
@andreiborza andreiborza force-pushed the ab/sentry-trace-provider-core-capture branch from 4b3fc03 to 14cb421 Compare June 30, 2026 08:48
@andreiborza andreiborza force-pushed the ab/sentry-trace-provider-otel branch from 630bb67 to 15154ed Compare June 30, 2026 09:56
@andreiborza andreiborza force-pushed the ab/sentry-trace-provider-core-capture branch from 14cb421 to 6b8db6e Compare June 30, 2026 09:56
Comment on lines +57 to +62
// Clients whose segment-span transaction capture should be deferred (rather than run synchronously on
// span end), mapped to the function that queues a deferred capture. Tracked per client rather than as
// a process-wide flag so pending captures and their timer cannot leak across `Sentry.init()` calls —
// a client that never opts in is simply absent. Enabled per client by SDKs that assemble transactions
// from the live span tree on root-span end (e.g. the Node SDK), which would otherwise drop children
// that close after it. Every other setup keeps its synchronous capture.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
// Clients whose segment-span transaction capture should be deferred (rather than run synchronously on
// span end), mapped to the function that queues a deferred capture. Tracked per client rather than as
// a process-wide flag so pending captures and their timer cannot leak across `Sentry.init()` calls —
// a client that never opts in is simply absent. Enabled per client by SDKs that assemble transactions
// from the live span tree on root-span end (e.g. the Node SDK), which would otherwise drop children
// that close after it. Every other setup keeps its synchronous capture.
// Clients that opted into deferred segment-span capture (see `_INTERNAL_se
tDeferSegmentSpanCapture`),
// mapped to the function that queues a deferred capture. Keyed by client r
ather than a process-wide
// flag so pending captures and their timer cannot leak across `Sentry.init
()` calls.

just a suggestion but I am wondering if we could cut this comment down a bit, reads quite lengthy 😅

Comment on lines +65 to +67
// Spans already included in a captured transaction. Used so a child that ends after its root segment
// was captured can be emitted as its own orphan transaction (see `_onSpanEnded`) without any span ever
// being sent in more than one transaction.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
// Spans already included in a captured transaction. Used so a child that ends after its root segment
// was captured can be emitted as its own orphan transaction (see `_onSpanEnded`) without any span ever
// being sent in more than one transaction.
// Track spans already sent in a transaction, so we can emit spans ending after the root in a separate transaction

const CAPTURED_SPANS = new WeakSet<Span>();

/**
* Defer a client's segment-span transaction capture. Set once by the SDK during setup (e.g. the Node

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

same here, maybe we can cut this down a bit

// fires on a later tick must reach the client active at span end and never whatever client
// is current when the timer fires (e.g. a different client after re-init), and the scope's
// client reference can be reassigned. Only the snapshot is deferred, so late children land.
client.captureEvent(transactionEvent, undefined, scope);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

q: the comment says do not resolve from the scope but then you pass the scope here, how does that work?

debouncedDrain.flush();
});

DEFERRED_SEGMENT_SPAN_CAPTURES.set(client, capture => {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

as discussed offline: maybe we can think about simplifying this by not doing this per client or using an explicit queue class or something like that. if not also fine just think this is quite hard to understand at first pass

Add per-client deferral of the segment-span transaction capture. The transaction is
otherwise assembled synchronously from the live span tree when the root span ends,
dropping child spans whose instrumentation closes them after it - in the same tick
(diagnostics-channel `asyncEnd`) or on a later tick (e.g. prisma engine spans). When a
client opts in via `_INTERNAL_setDeferSegmentSpanCapture`, a debounced timer (the one the
OpenTelemetry span exporter uses) delays the snapshot so those children land first, and
drains on the client `flush` hook so `Sentry.flush()` / `close()` stays safe. The browser
keeps its synchronous capture.

The opt-in call is wired separately (the Node SDK enables it on the SentryTracerProvider path).
@andreiborza andreiborza force-pushed the ab/sentry-trace-provider-otel branch from 15154ed to 7837eb8 Compare June 30, 2026 15:17
@andreiborza andreiborza force-pushed the ab/sentry-trace-provider-core-capture branch from 6b8db6e to 2df53ad Compare June 30, 2026 15:17

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit 2df53ad. Configure here.


expect(onSpy.mock.calls.filter(([hook]) => hook === 'flush')).toHaveLength(1);
});
});

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Missing defer behavior tests

Low Severity

This feat change adds deferred transaction capture and orphan emission, but the diff only adds a unit test that the flush hook registers once. There is no integration or E2E test here exercising deferred assembly, flush draining, or orphan transactions.

Fix in Cursor Fix in Web

Triggered by project rule: PR Review Guidelines for Cursor Bot

Reviewed by Cursor Bugbot for commit 2df53ad. Configure here.

!!DEFERRED_SEGMENT_SPAN_CAPTURES.get(client) &&
!isBrowser() &&
!CAPTURED_SPANS.has(this) &&
CAPTURED_SPANS.has(rootSpan);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: If a root span is a SentryNonRecordingSpan, the check CAPTURED_SPANS.has(rootSpan) will always be false, silently disabling orphan segment handling for its children.
Severity: MEDIUM

Suggested Fix

The check for orphan segments should not depend on the root span being a SentrySpan that has been converted to a transaction. Re-evaluate the CAPTURED_SPANS.has(rootSpan) condition. The logic could be adjusted to correctly identify orphans even when the root span is non-recording, perhaps by tracking sent transactions differently.

Prompt for AI Agent
Review the code at the location below. A potential bug has been identified by an AI
agent. Verify if this is a real issue. If it is, propose a fix; if not, explain why it's
not valid.

Location: packages/core/src/tracing/sentrySpan.ts#L417

Potential issue: The logic to detect orphan segments relies on checking if the root span
is present in the `CAPTURED_SPANS` set. However, a span is only added to this set when
`_convertSpanToTransaction` is called, a method exclusive to `SentrySpan`. If the root
span of a trace is a `SentryNonRecordingSpan`, it will never be added to
`CAPTURED_SPANS`. Consequently, any child `SentrySpan` that finishes after the root will
fail the `isOrphanSegment` check at `sentrySpan.ts:417`, and its data will be lost
instead of being sent as an orphan segment.

@andreiborza

Copy link
Copy Markdown
Member Author

I'm going to rework this slightly so it has no impact on browser SDKs.

@andreiborza andreiborza marked this pull request as draft July 1, 2026 07:22
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants