fix(cloudflare): Preserve late static child spans - #23428

Closed
betegon wants to merge 5 commits into
developfrom
bt/fix-cloudflare-late-static-child-spans
Closed

fix(cloudflare): Preserve late static child spans#23428
betegon wants to merge 5 commits into
developfrom
bt/fix-cloudflare-late-static-child-spans

Conversation

@betegon

@betegonbetegon commented Aug 13, 2026

Copy link
Copy Markdown
Member

Preserves child spans which end after a Cloudflare static request transaction has already been sent. These spans are emitted exactly once on the same trace with their original parent instead of disappearing.

Root cause

Cloudflare did not enable the deferred segment-capture strategy already used by the Node SDK. Static transactions were therefore assembled as soon as the request segment ended, with no path for a child ending later in waitUntil().

Enabling the existing strategy directly was not sufficient. It treated spans as captured before the client event pipeline had accepted their transaction. If processing, a before-send hook, or queue admission rejected that event, pending descendants could still be emitted as ghost orphan transactions.

Design

Core now exposes an internal, one-shot terminal capture decision. Deferred segment capture reserves spans while that decision is pending:

  • acceptance marks included spans as captured and releases pending late descendants;
  • rejection clears pending state and suppresses descendants which have not already been accepted.

Rejection is deliberately not retroactive: telemetry which has already passed its own pipeline remains accepted. The acceptance callback runs after the client processing-buffer slot has been released, so a follow-up capture does not incorrectly encounter a full buffer.

Cloudflare clients enable this strategy for static capture. A child ending after an accepted segment becomes a standalone transaction with the same trace ID, its exact original parent, and sentry.parent_span_already_sent=true. Span streaming remains unchanged. There is no public API or MCP protocol change.

Node already enables deferred segment capture, so the Core state-machine hardening also applies to its existing use of this machinery.

Relation to #23151

#23151 and its prerequisite #23136 solve a different part of the Cloudflare lifecycle: they reuse one client across invocations and eagerly drain envelopes and streamed-span trace buckets. This PR handles static transaction aggregation, where a late child cannot be added to a transaction which has already been sent. The two approaches are complementary.

A shared client can drain deferred static transactions from several invocations in one callback. Deferred capture therefore re-enters each span's captured current and isolation scopes before event processing, keeping processors, InvocationState, sendEnvelope, and waitUntil attached to the owning invocation. A combined checkout of both PRs reproduced both events under invocation B before this guard and A/B ownership after it. The committed Core regression protects current and isolation scope ownership without making this PR depend on #23151.

When #23151 rebases after this PR, its CloudflareClient constructor must retain _INTERNAL_setDeferSegmentSpanCapture(this). The strategy remains inert for its default streamed lifecycle.

Evidence

The bug was reproduced using baseline and patched SDK tarballs built from the same source SHA, with one pinned Worker fixture and a protocol-independent control:

ScenarioBaselinePatched
Standard MCP controlHTTP → MCP → child, exactly threeUnchanged, exactly three
Generic late childHTTP root only; child missingHTTP root plus one correctly parented orphan
MCP 2026-07-28 late childHTTP → MCP; child missingHTTP → MCP plus one orphan parented to MCP

The exact patched local run emitted seven expected transaction envelopes with no duplicates. A personal Cloudflare deployment with byte-identical Cloudflare/Core artifacts reproduced the same topology in Sentry; the final artifact set also passed the protected remote smoke. A modern MCP request is only the workload which exposed the generic lifecycle failure.

The repository regression uses a real Wrangler/workerd Worker and strict envelope cardinality. The final causal source state passes the complete Core (3361 passed, 14 skipped), Node (352 passed, 3 skipped), and Cloudflare (818 passed) package suites. The modern Cloudflare MCP and legacy McpAgent E2Es also pass in development and production modes; the Node MCP v2 control passed during the finalization cycle.

The Cloudflare bundle remains below both size limits: 217731 B minified and 541040 B unminified (limits 215 KiB and 529 KiB).

Compatibility and risk

Only static segment capture changes. Static traces can gain one transaction event where a late child was previously dropped. Accepted children are not emitted twice, children pending beneath a rejected segment remain suppressed, and the state is weakly keyed and released at terminal decisions.

The branch is arranged as five dependent review commits: terminal event decisions, the Core state machine, Cloudflare activation, the real Worker regression, and shared-client scope ownership.

Fixes#23427

betegonand others added 4 commits August 13, 2026 22:08
Deferred segment capture needs to distinguish an event accepted by the client pipeline from one rejected by sampling, processors, hooks, or queue admission.
Keep the decision internal, one-shot, and invisible to event hint consumers so existing callback contracts remain unchanged.
Co-Authored-By: OpenAI/ChatGPT <codex@openai.com>
Late descendants must wait for their segment event to be accepted, otherwise dropped or rejected roots can leak orphan telemetry and accepted descendants can be duplicated.
Track pending, accepted, and rejected subtrees with weak ownership and preserve exact local parentage across the deferred boundary.
Co-Authored-By: OpenAI/ChatGPT <codex@openai.com>
Static Cloudflare request transactions can finish before work registered with waitUntil ends, which previously dropped those late child spans.
Use the shared deferred-capture lifecycle so accepted late children remain traceable without changing streaming behavior.
Co-Authored-By: OpenAI/ChatGPT <codex@openai.com>
Exercise the static lifecycle through a real Wrangler worker and fail if the late child is lost, embedded twice, or emitted more than once.
The strict runner remains opt-in so existing integration suites keep their current envelope matching semantics.
Co-Authored-By: OpenAI/ChatGPT <codex@openai.com>
@github-actions

Copy link
Copy Markdown
Contributor

size-limit report 📦

PathSize% ChangeChange
@sentry/browser30.41 kB+0.38%+113 B 🔺
@sentry/browser - with treeshaking flags28.59 kB+0.42%+119 B 🔺
@sentry/browser - with treeshaking flags tracing without tracing26.93 kB+0.42%+111 B 🔺
@sentry/browser (incl. Tracing)48.69 kB+0.23%+108 B 🔺
@sentry/browser (incl. Tracing + Span Streaming)48.7 kB+0.23%+108 B 🔺
@sentry/browser (incl. Tracing, Profiling)51.59 kB+0.25%+128 B 🔺
@sentry/browser (incl. Tracing, Replay)88.1 kB+0.14%+121 B 🔺
@sentry/browser (incl. Tracing, Replay) - with treeshaking flags77.53 kB+0.22%+170 B 🔺
@sentry/browser (incl. Tracing, Replay with Canvas)92.83 kB+0.14%+125 B 🔺
@sentry/browser (incl. Tracing, Replay, Feedback)105.52 kB+0.12%+124 B 🔺
@sentry/browser (incl. Feedback)47.75 kB+0.22%+104 B 🔺
@sentry/browser (incl. sendFeedback)35.23 kB+0.32%+110 B 🔺
@sentry/browser (incl. FeedbackAsync)40.39 kB+0.28%+112 B 🔺
@sentry/browser (incl. Metrics)31.35 kB+0.34%+104 B 🔺
@sentry/browser (incl. Logs)31.63 kB+0.36%+112 B 🔺
@sentry/browser (incl. Metrics & Logs)32.26 kB+0.36%+115 B 🔺
@sentry/react32.21 kB+0.38%+119 B 🔺
@sentry/react (incl. Tracing)50.89 kB+0.24%+120 B 🔺
@sentry/vue35.48 kB+0.41%+142 B 🔺
@sentry/vue (incl. Tracing)50.65 kB+0.23%+114 B 🔺
@sentry/svelte30.44 kB+0.37%+111 B 🔺
CDN Bundle31.73 kB+0.38%+120 B 🔺
CDN Bundle (incl. Tracing)49.05 kB+0.32%+154 B 🔺
CDN Bundle (incl. Logs, Metrics)33.91 kB+0.32%+108 B 🔺
CDN Bundle (incl. Tracing, Logs, Metrics)50.95 kB+0.26%+128 B 🔺
CDN Bundle (incl. Replay, Logs, Metrics)74.43 kB+0.17%+125 B 🔺
CDN Bundle (incl. Tracing, Replay)86.61 kB+0.15%+127 B 🔺
CDN Bundle (incl. Tracing, Replay, Logs, Metrics)88.44 kB+0.16%+133 B 🔺
CDN Bundle (incl. Tracing, Replay, Feedback)92.32 kB+0.15%+137 B 🔺
CDN Bundle (incl. Tracing, Replay, Feedback, Logs, Metrics)94.24 kB+0.13%+115 B 🔺
CDN Bundle - uncompressed94.17 kB+0.35%+325 B 🔺
CDN Bundle (incl. Tracing) - uncompressed147.1 kB+0.25%+357 B 🔺
CDN Bundle (incl. Logs, Metrics) - uncompressed100.47 kB+0.33%+325 B 🔺
CDN Bundle (incl. Tracing, Logs, Metrics) - uncompressed152.79 kB+0.24%+357 B 🔺
CDN Bundle (incl. Replay, Logs, Metrics) - uncompressed229.4 kB+0.15%+325 B 🔺
CDN Bundle (incl. Tracing, Replay) - uncompressed266.37 kB+0.14%+357 B 🔺
CDN Bundle (incl. Tracing, Replay, Logs, Metrics) - uncompressed272.04 kB+0.14%+357 B 🔺
CDN Bundle (incl. Tracing, Replay, Feedback) - uncompressed280.06 kB+0.13%+357 B 🔺
CDN Bundle (incl. Tracing, Replay, Feedback, Logs, Metrics) - uncompressed285.73 kB+0.13%+357 B 🔺
@sentry/nextjs (client)53.43 kB+0.27%+143 B 🔺
@sentry/sveltekit (client)49.1 kB+0.22%+105 B 🔺
@sentry/core/server66.02 kB+0.99%+642 B 🔺
@sentry/core/browser52.36 kB+1.24%+641 B 🔺
@sentry/node117.65 kB+0.54%+622 B 🔺
@sentry/node/import (ESM hook with diagnostics-channel injection)0 Baddedadded
@sentry/node - without tracing82.11 kB+0.83%+675 B 🔺
@sentry/aws-serverless91.58 kB+0.72%+653 B 🔺
@sentry/cloudflare (withSentry) - minified217.73 kB+1.76%+3.75 kB 🔺
@sentry/cloudflare (withSentry)541.05 kB+2.3%+12.12 kB 🔺

View base workflow run

A shared client can drain deferred transactions from several invocations in one callback. Re-enter each span's captured current and isolation scopes so event processing and runtime flush state remain attributed to the owning invocation.
Co-Authored-By: OpenAI/ChatGPT <codex@openai.com>
@betegon

Copy link
Copy Markdown
MemberAuthor

Closing this implementation after validating the MCP workload on deployed Cloudflare Workers.

Using published @sentry/cloudflare@10.70.0 and @modelcontextprotocol/server@2.0.0 with MCP 2026-07-28, we deployed two Workers with byte-identical bundles. The static lifecycle lost a child span that ended later in waitUntil(). The stream lifecycle recorded the same child exactly once, on the same trace and with the MCP operation as its exact parent.

The underlying static behavior remains, so #23427 is being closed as not planned rather than fixed. For the MCP/Cloudflare path, traceLifecycle: "stream" is the supported solution and avoids adding a broad event-capture state machine to Core. We will add this recommendation to the Cloudflare and MCP documentation.

@betegonbetegon closed this Aug 14, 2026
Sign up for freeto 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.

Cloudflare drops static child spans that end in waitUntil after the response

1 participant

@betegon
, '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

fix(cloudflare): Preserve late static child spans - #23428

Closed
betegon wants to merge 5 commits into
developfrom
bt/fix-cloudflare-late-static-child-spans
Closed

fix(cloudflare): Preserve late static child spans#23428
betegon wants to merge 5 commits into
developfrom
bt/fix-cloudflare-late-static-child-spans

Conversation

@betegon

@betegonbetegon commented Aug 13, 2026

Copy link
Copy Markdown
Member

Preserves child spans which end after a Cloudflare static request transaction has already been sent. These spans are emitted exactly once on the same trace with their original parent instead of disappearing.

Root cause

Cloudflare did not enable the deferred segment-capture strategy already used by the Node SDK. Static transactions were therefore assembled as soon as the request segment ended, with no path for a child ending later in waitUntil().

Enabling the existing strategy directly was not sufficient. It treated spans as captured before the client event pipeline had accepted their transaction. If processing, a before-send hook, or queue admission rejected that event, pending descendants could still be emitted as ghost orphan transactions.

Design

Core now exposes an internal, one-shot terminal capture decision. Deferred segment capture reserves spans while that decision is pending:

  • acceptance marks included spans as captured and releases pending late descendants;
  • rejection clears pending state and suppresses descendants which have not already been accepted.

Rejection is deliberately not retroactive: telemetry which has already passed its own pipeline remains accepted. The acceptance callback runs after the client processing-buffer slot has been released, so a follow-up capture does not incorrectly encounter a full buffer.

Cloudflare clients enable this strategy for static capture. A child ending after an accepted segment becomes a standalone transaction with the same trace ID, its exact original parent, and sentry.parent_span_already_sent=true. Span streaming remains unchanged. There is no public API or MCP protocol change.

Node already enables deferred segment capture, so the Core state-machine hardening also applies to its existing use of this machinery.

Relation to #23151

#23151 and its prerequisite #23136 solve a different part of the Cloudflare lifecycle: they reuse one client across invocations and eagerly drain envelopes and streamed-span trace buckets. This PR handles static transaction aggregation, where a late child cannot be added to a transaction which has already been sent. The two approaches are complementary.

A shared client can drain deferred static transactions from several invocations in one callback. Deferred capture therefore re-enters each span's captured current and isolation scopes before event processing, keeping processors, InvocationState, sendEnvelope, and waitUntil attached to the owning invocation. A combined checkout of both PRs reproduced both events under invocation B before this guard and A/B ownership after it. The committed Core regression protects current and isolation scope ownership without making this PR depend on #23151.

When #23151 rebases after this PR, its CloudflareClient constructor must retain _INTERNAL_setDeferSegmentSpanCapture(this). The strategy remains inert for its default streamed lifecycle.

Evidence

The bug was reproduced using baseline and patched SDK tarballs built from the same source SHA, with one pinned Worker fixture and a protocol-independent control:

ScenarioBaselinePatched
Standard MCP controlHTTP → MCP → child, exactly threeUnchanged, exactly three
Generic late childHTTP root only; child missingHTTP root plus one correctly parented orphan
MCP 2026-07-28 late childHTTP → MCP; child missingHTTP → MCP plus one orphan parented to MCP

The exact patched local run emitted seven expected transaction envelopes with no duplicates. A personal Cloudflare deployment with byte-identical Cloudflare/Core artifacts reproduced the same topology in Sentry; the final artifact set also passed the protected remote smoke. A modern MCP request is only the workload which exposed the generic lifecycle failure.

The repository regression uses a real Wrangler/workerd Worker and strict envelope cardinality. The final causal source state passes the complete Core (3361 passed, 14 skipped), Node (352 passed, 3 skipped), and Cloudflare (818 passed) package suites. The modern Cloudflare MCP and legacy McpAgent E2Es also pass in development and production modes; the Node MCP v2 control passed during the finalization cycle.

The Cloudflare bundle remains below both size limits: 217731 B minified and 541040 B unminified (limits 215 KiB and 529 KiB).

Compatibility and risk

Only static segment capture changes. Static traces can gain one transaction event where a late child was previously dropped. Accepted children are not emitted twice, children pending beneath a rejected segment remain suppressed, and the state is weakly keyed and released at terminal decisions.

The branch is arranged as five dependent review commits: terminal event decisions, the Core state machine, Cloudflare activation, the real Worker regression, and shared-client scope ownership.

Fixes#23427

betegonand others added 4 commits August 13, 2026 22:08
Deferred segment capture needs to distinguish an event accepted by the client pipeline from one rejected by sampling, processors, hooks, or queue admission.
Keep the decision internal, one-shot, and invisible to event hint consumers so existing callback contracts remain unchanged.
Co-Authored-By: OpenAI/ChatGPT <codex@openai.com>
Late descendants must wait for their segment event to be accepted, otherwise dropped or rejected roots can leak orphan telemetry and accepted descendants can be duplicated.
Track pending, accepted, and rejected subtrees with weak ownership and preserve exact local parentage across the deferred boundary.
Co-Authored-By: OpenAI/ChatGPT <codex@openai.com>
Static Cloudflare request transactions can finish before work registered with waitUntil ends, which previously dropped those late child spans.
Use the shared deferred-capture lifecycle so accepted late children remain traceable without changing streaming behavior.
Co-Authored-By: OpenAI/ChatGPT <codex@openai.com>
Exercise the static lifecycle through a real Wrangler worker and fail if the late child is lost, embedded twice, or emitted more than once.
The strict runner remains opt-in so existing integration suites keep their current envelope matching semantics.
Co-Authored-By: OpenAI/ChatGPT <codex@openai.com>
@github-actions

Copy link
Copy Markdown
Contributor

size-limit report 📦

PathSize% ChangeChange
@sentry/browser30.41 kB+0.38%+113 B 🔺
@sentry/browser - with treeshaking flags28.59 kB+0.42%+119 B 🔺
@sentry/browser - with treeshaking flags tracing without tracing26.93 kB+0.42%+111 B 🔺
@sentry/browser (incl. Tracing)48.69 kB+0.23%+108 B 🔺
@sentry/browser (incl. Tracing + Span Streaming)48.7 kB+0.23%+108 B 🔺
@sentry/browser (incl. Tracing, Profiling)51.59 kB+0.25%+128 B 🔺
@sentry/browser (incl. Tracing, Replay)88.1 kB+0.14%+121 B 🔺
@sentry/browser (incl. Tracing, Replay) - with treeshaking flags77.53 kB+0.22%+170 B 🔺
@sentry/browser (incl. Tracing, Replay with Canvas)92.83 kB+0.14%+125 B 🔺
@sentry/browser (incl. Tracing, Replay, Feedback)105.52 kB+0.12%+124 B 🔺
@sentry/browser (incl. Feedback)47.75 kB+0.22%+104 B 🔺
@sentry/browser (incl. sendFeedback)35.23 kB+0.32%+110 B 🔺
@sentry/browser (incl. FeedbackAsync)40.39 kB+0.28%+112 B 🔺
@sentry/browser (incl. Metrics)31.35 kB+0.34%+104 B 🔺
@sentry/browser (incl. Logs)31.63 kB+0.36%+112 B 🔺
@sentry/browser (incl. Metrics & Logs)32.26 kB+0.36%+115 B 🔺
@sentry/react32.21 kB+0.38%+119 B 🔺
@sentry/react (incl. Tracing)50.89 kB+0.24%+120 B 🔺
@sentry/vue35.48 kB+0.41%+142 B 🔺
@sentry/vue (incl. Tracing)50.65 kB+0.23%+114 B 🔺
@sentry/svelte30.44 kB+0.37%+111 B 🔺
CDN Bundle31.73 kB+0.38%+120 B 🔺
CDN Bundle (incl. Tracing)49.05 kB+0.32%+154 B 🔺
CDN Bundle (incl. Logs, Metrics)33.91 kB+0.32%+108 B 🔺
CDN Bundle (incl. Tracing, Logs, Metrics)50.95 kB+0.26%+128 B 🔺
CDN Bundle (incl. Replay, Logs, Metrics)74.43 kB+0.17%+125 B 🔺
CDN Bundle (incl. Tracing, Replay)86.61 kB+0.15%+127 B 🔺
CDN Bundle (incl. Tracing, Replay, Logs, Metrics)88.44 kB+0.16%+133 B 🔺
CDN Bundle (incl. Tracing, Replay, Feedback)92.32 kB+0.15%+137 B 🔺
CDN Bundle (incl. Tracing, Replay, Feedback, Logs, Metrics)94.24 kB+0.13%+115 B 🔺
CDN Bundle - uncompressed94.17 kB+0.35%+325 B 🔺
CDN Bundle (incl. Tracing) - uncompressed147.1 kB+0.25%+357 B 🔺
CDN Bundle (incl. Logs, Metrics) - uncompressed100.47 kB+0.33%+325 B 🔺
CDN Bundle (incl. Tracing, Logs, Metrics) - uncompressed152.79 kB+0.24%+357 B 🔺
CDN Bundle (incl. Replay, Logs, Metrics) - uncompressed229.4 kB+0.15%+325 B 🔺
CDN Bundle (incl. Tracing, Replay) - uncompressed266.37 kB+0.14%+357 B 🔺
CDN Bundle (incl. Tracing, Replay, Logs, Metrics) - uncompressed272.04 kB+0.14%+357 B 🔺
CDN Bundle (incl. Tracing, Replay, Feedback) - uncompressed280.06 kB+0.13%+357 B 🔺
CDN Bundle (incl. Tracing, Replay, Feedback, Logs, Metrics) - uncompressed285.73 kB+0.13%+357 B 🔺
@sentry/nextjs (client)53.43 kB+0.27%+143 B 🔺
@sentry/sveltekit (client)49.1 kB+0.22%+105 B 🔺
@sentry/core/server66.02 kB+0.99%+642 B 🔺
@sentry/core/browser52.36 kB+1.24%+641 B 🔺
@sentry/node117.65 kB+0.54%+622 B 🔺
@sentry/node/import (ESM hook with diagnostics-channel injection)0 Baddedadded
@sentry/node - without tracing82.11 kB+0.83%+675 B 🔺
@sentry/aws-serverless91.58 kB+0.72%+653 B 🔺
@sentry/cloudflare (withSentry) - minified217.73 kB+1.76%+3.75 kB 🔺
@sentry/cloudflare (withSentry)541.05 kB+2.3%+12.12 kB 🔺

View base workflow run

A shared client can drain deferred transactions from several invocations in one callback. Re-enter each span's captured current and isolation scopes so event processing and runtime flush state remain attributed to the owning invocation.
Co-Authored-By: OpenAI/ChatGPT <codex@openai.com>
@betegon

Copy link
Copy Markdown
MemberAuthor

Closing this implementation after validating the MCP workload on deployed Cloudflare Workers.

Using published @sentry/cloudflare@10.70.0 and @modelcontextprotocol/server@2.0.0 with MCP 2026-07-28, we deployed two Workers with byte-identical bundles. The static lifecycle lost a child span that ended later in waitUntil(). The stream lifecycle recorded the same child exactly once, on the same trace and with the MCP operation as its exact parent.

The underlying static behavior remains, so #23427 is being closed as not planned rather than fixed. For the MCP/Cloudflare path, traceLifecycle: "stream" is the supported solution and avoids adding a broad event-capture state machine to Core. We will add this recommendation to the Cloudflare and MCP documentation.

@betegonbetegon closed this Aug 14, 2026
Sign up for freeto 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.

Cloudflare drops static child spans that end in waitUntil after the response

1 participant

@betegon
, '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

fix(cloudflare): Preserve late static child spans - #23428

Closed
betegon wants to merge 5 commits into
developfrom
bt/fix-cloudflare-late-static-child-spans
Closed

fix(cloudflare): Preserve late static child spans#23428
betegon wants to merge 5 commits into
developfrom
bt/fix-cloudflare-late-static-child-spans

Conversation

@betegon

@betegonbetegon commented Aug 13, 2026

Copy link
Copy Markdown
Member

Preserves child spans which end after a Cloudflare static request transaction has already been sent. These spans are emitted exactly once on the same trace with their original parent instead of disappearing.

Root cause

Cloudflare did not enable the deferred segment-capture strategy already used by the Node SDK. Static transactions were therefore assembled as soon as the request segment ended, with no path for a child ending later in waitUntil().

Enabling the existing strategy directly was not sufficient. It treated spans as captured before the client event pipeline had accepted their transaction. If processing, a before-send hook, or queue admission rejected that event, pending descendants could still be emitted as ghost orphan transactions.

Design

Core now exposes an internal, one-shot terminal capture decision. Deferred segment capture reserves spans while that decision is pending:

  • acceptance marks included spans as captured and releases pending late descendants;
  • rejection clears pending state and suppresses descendants which have not already been accepted.

Rejection is deliberately not retroactive: telemetry which has already passed its own pipeline remains accepted. The acceptance callback runs after the client processing-buffer slot has been released, so a follow-up capture does not incorrectly encounter a full buffer.

Cloudflare clients enable this strategy for static capture. A child ending after an accepted segment becomes a standalone transaction with the same trace ID, its exact original parent, and sentry.parent_span_already_sent=true. Span streaming remains unchanged. There is no public API or MCP protocol change.

Node already enables deferred segment capture, so the Core state-machine hardening also applies to its existing use of this machinery.

Relation to #23151

#23151 and its prerequisite #23136 solve a different part of the Cloudflare lifecycle: they reuse one client across invocations and eagerly drain envelopes and streamed-span trace buckets. This PR handles static transaction aggregation, where a late child cannot be added to a transaction which has already been sent. The two approaches are complementary.

A shared client can drain deferred static transactions from several invocations in one callback. Deferred capture therefore re-enters each span's captured current and isolation scopes before event processing, keeping processors, InvocationState, sendEnvelope, and waitUntil attached to the owning invocation. A combined checkout of both PRs reproduced both events under invocation B before this guard and A/B ownership after it. The committed Core regression protects current and isolation scope ownership without making this PR depend on #23151.

When #23151 rebases after this PR, its CloudflareClient constructor must retain _INTERNAL_setDeferSegmentSpanCapture(this). The strategy remains inert for its default streamed lifecycle.

Evidence

The bug was reproduced using baseline and patched SDK tarballs built from the same source SHA, with one pinned Worker fixture and a protocol-independent control:

ScenarioBaselinePatched
Standard MCP controlHTTP → MCP → child, exactly threeUnchanged, exactly three
Generic late childHTTP root only; child missingHTTP root plus one correctly parented orphan
MCP 2026-07-28 late childHTTP → MCP; child missingHTTP → MCP plus one orphan parented to MCP

The exact patched local run emitted seven expected transaction envelopes with no duplicates. A personal Cloudflare deployment with byte-identical Cloudflare/Core artifacts reproduced the same topology in Sentry; the final artifact set also passed the protected remote smoke. A modern MCP request is only the workload which exposed the generic lifecycle failure.

The repository regression uses a real Wrangler/workerd Worker and strict envelope cardinality. The final causal source state passes the complete Core (3361 passed, 14 skipped), Node (352 passed, 3 skipped), and Cloudflare (818 passed) package suites. The modern Cloudflare MCP and legacy McpAgent E2Es also pass in development and production modes; the Node MCP v2 control passed during the finalization cycle.

The Cloudflare bundle remains below both size limits: 217731 B minified and 541040 B unminified (limits 215 KiB and 529 KiB).

Compatibility and risk

Only static segment capture changes. Static traces can gain one transaction event where a late child was previously dropped. Accepted children are not emitted twice, children pending beneath a rejected segment remain suppressed, and the state is weakly keyed and released at terminal decisions.

The branch is arranged as five dependent review commits: terminal event decisions, the Core state machine, Cloudflare activation, the real Worker regression, and shared-client scope ownership.

Fixes#23427

betegonand others added 4 commits August 13, 2026 22:08
Deferred segment capture needs to distinguish an event accepted by the client pipeline from one rejected by sampling, processors, hooks, or queue admission.
Keep the decision internal, one-shot, and invisible to event hint consumers so existing callback contracts remain unchanged.
Co-Authored-By: OpenAI/ChatGPT <codex@openai.com>
Late descendants must wait for their segment event to be accepted, otherwise dropped or rejected roots can leak orphan telemetry and accepted descendants can be duplicated.
Track pending, accepted, and rejected subtrees with weak ownership and preserve exact local parentage across the deferred boundary.
Co-Authored-By: OpenAI/ChatGPT <codex@openai.com>
Static Cloudflare request transactions can finish before work registered with waitUntil ends, which previously dropped those late child spans.
Use the shared deferred-capture lifecycle so accepted late children remain traceable without changing streaming behavior.
Co-Authored-By: OpenAI/ChatGPT <codex@openai.com>
Exercise the static lifecycle through a real Wrangler worker and fail if the late child is lost, embedded twice, or emitted more than once.
The strict runner remains opt-in so existing integration suites keep their current envelope matching semantics.
Co-Authored-By: OpenAI/ChatGPT <codex@openai.com>
@github-actions

Copy link
Copy Markdown
Contributor

size-limit report 📦

PathSize% ChangeChange
@sentry/browser30.41 kB+0.38%+113 B 🔺
@sentry/browser - with treeshaking flags28.59 kB+0.42%+119 B 🔺
@sentry/browser - with treeshaking flags tracing without tracing26.93 kB+0.42%+111 B 🔺
@sentry/browser (incl. Tracing)48.69 kB+0.23%+108 B 🔺
@sentry/browser (incl. Tracing + Span Streaming)48.7 kB+0.23%+108 B 🔺
@sentry/browser (incl. Tracing, Profiling)51.59 kB+0.25%+128 B 🔺
@sentry/browser (incl. Tracing, Replay)88.1 kB+0.14%+121 B 🔺
@sentry/browser (incl. Tracing, Replay) - with treeshaking flags77.53 kB+0.22%+170 B 🔺
@sentry/browser (incl. Tracing, Replay with Canvas)92.83 kB+0.14%+125 B 🔺
@sentry/browser (incl. Tracing, Replay, Feedback)105.52 kB+0.12%+124 B 🔺
@sentry/browser (incl. Feedback)47.75 kB+0.22%+104 B 🔺
@sentry/browser (incl. sendFeedback)35.23 kB+0.32%+110 B 🔺
@sentry/browser (incl. FeedbackAsync)40.39 kB+0.28%+112 B 🔺
@sentry/browser (incl. Metrics)31.35 kB+0.34%+104 B 🔺
@sentry/browser (incl. Logs)31.63 kB+0.36%+112 B 🔺
@sentry/browser (incl. Metrics & Logs)32.26 kB+0.36%+115 B 🔺
@sentry/react32.21 kB+0.38%+119 B 🔺
@sentry/react (incl. Tracing)50.89 kB+0.24%+120 B 🔺
@sentry/vue35.48 kB+0.41%+142 B 🔺
@sentry/vue (incl. Tracing)50.65 kB+0.23%+114 B 🔺
@sentry/svelte30.44 kB+0.37%+111 B 🔺
CDN Bundle31.73 kB+0.38%+120 B 🔺
CDN Bundle (incl. Tracing)49.05 kB+0.32%+154 B 🔺
CDN Bundle (incl. Logs, Metrics)33.91 kB+0.32%+108 B 🔺
CDN Bundle (incl. Tracing, Logs, Metrics)50.95 kB+0.26%+128 B 🔺
CDN Bundle (incl. Replay, Logs, Metrics)74.43 kB+0.17%+125 B 🔺
CDN Bundle (incl. Tracing, Replay)86.61 kB+0.15%+127 B 🔺
CDN Bundle (incl. Tracing, Replay, Logs, Metrics)88.44 kB+0.16%+133 B 🔺
CDN Bundle (incl. Tracing, Replay, Feedback)92.32 kB+0.15%+137 B 🔺
CDN Bundle (incl. Tracing, Replay, Feedback, Logs, Metrics)94.24 kB+0.13%+115 B 🔺
CDN Bundle - uncompressed94.17 kB+0.35%+325 B 🔺
CDN Bundle (incl. Tracing) - uncompressed147.1 kB+0.25%+357 B 🔺
CDN Bundle (incl. Logs, Metrics) - uncompressed100.47 kB+0.33%+325 B 🔺
CDN Bundle (incl. Tracing, Logs, Metrics) - uncompressed152.79 kB+0.24%+357 B 🔺
CDN Bundle (incl. Replay, Logs, Metrics) - uncompressed229.4 kB+0.15%+325 B 🔺
CDN Bundle (incl. Tracing, Replay) - uncompressed266.37 kB+0.14%+357 B 🔺
CDN Bundle (incl. Tracing, Replay, Logs, Metrics) - uncompressed272.04 kB+0.14%+357 B 🔺
CDN Bundle (incl. Tracing, Replay, Feedback) - uncompressed280.06 kB+0.13%+357 B 🔺
CDN Bundle (incl. Tracing, Replay, Feedback, Logs, Metrics) - uncompressed285.73 kB+0.13%+357 B 🔺
@sentry/nextjs (client)53.43 kB+0.27%+143 B 🔺
@sentry/sveltekit (client)49.1 kB+0.22%+105 B 🔺
@sentry/core/server66.02 kB+0.99%+642 B 🔺
@sentry/core/browser52.36 kB+1.24%+641 B 🔺
@sentry/node117.65 kB+0.54%+622 B 🔺
@sentry/node/import (ESM hook with diagnostics-channel injection)0 Baddedadded
@sentry/node - without tracing82.11 kB+0.83%+675 B 🔺
@sentry/aws-serverless91.58 kB+0.72%+653 B 🔺
@sentry/cloudflare (withSentry) - minified217.73 kB+1.76%+3.75 kB 🔺
@sentry/cloudflare (withSentry)541.05 kB+2.3%+12.12 kB 🔺

View base workflow run

A shared client can drain deferred transactions from several invocations in one callback. Re-enter each span's captured current and isolation scopes so event processing and runtime flush state remain attributed to the owning invocation.
Co-Authored-By: OpenAI/ChatGPT <codex@openai.com>
@betegon

Copy link
Copy Markdown
MemberAuthor

Closing this implementation after validating the MCP workload on deployed Cloudflare Workers.

Using published @sentry/cloudflare@10.70.0 and @modelcontextprotocol/server@2.0.0 with MCP 2026-07-28, we deployed two Workers with byte-identical bundles. The static lifecycle lost a child span that ended later in waitUntil(). The stream lifecycle recorded the same child exactly once, on the same trace and with the MCP operation as its exact parent.

The underlying static behavior remains, so #23427 is being closed as not planned rather than fixed. For the MCP/Cloudflare path, traceLifecycle: "stream" is the supported solution and avoids adding a broad event-capture state machine to Core. We will add this recommendation to the Cloudflare and MCP documentation.

@betegonbetegon closed this Aug 14, 2026
Sign up for freeto 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.

Cloudflare drops static child spans that end in waitUntil after the response

1 participant

@betegon
, '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

fix(cloudflare): Preserve late static child spans - #23428

Closed
betegon wants to merge 5 commits into
developfrom
bt/fix-cloudflare-late-static-child-spans
Closed

fix(cloudflare): Preserve late static child spans#23428
betegon wants to merge 5 commits into
developfrom
bt/fix-cloudflare-late-static-child-spans

Conversation

@betegon

@betegonbetegon commented Aug 13, 2026

Copy link
Copy Markdown
Member

Preserves child spans which end after a Cloudflare static request transaction has already been sent. These spans are emitted exactly once on the same trace with their original parent instead of disappearing.

Root cause

Cloudflare did not enable the deferred segment-capture strategy already used by the Node SDK. Static transactions were therefore assembled as soon as the request segment ended, with no path for a child ending later in waitUntil().

Enabling the existing strategy directly was not sufficient. It treated spans as captured before the client event pipeline had accepted their transaction. If processing, a before-send hook, or queue admission rejected that event, pending descendants could still be emitted as ghost orphan transactions.

Design

Core now exposes an internal, one-shot terminal capture decision. Deferred segment capture reserves spans while that decision is pending:

  • acceptance marks included spans as captured and releases pending late descendants;
  • rejection clears pending state and suppresses descendants which have not already been accepted.

Rejection is deliberately not retroactive: telemetry which has already passed its own pipeline remains accepted. The acceptance callback runs after the client processing-buffer slot has been released, so a follow-up capture does not incorrectly encounter a full buffer.

Cloudflare clients enable this strategy for static capture. A child ending after an accepted segment becomes a standalone transaction with the same trace ID, its exact original parent, and sentry.parent_span_already_sent=true. Span streaming remains unchanged. There is no public API or MCP protocol change.

Node already enables deferred segment capture, so the Core state-machine hardening also applies to its existing use of this machinery.

Relation to #23151

#23151 and its prerequisite #23136 solve a different part of the Cloudflare lifecycle: they reuse one client across invocations and eagerly drain envelopes and streamed-span trace buckets. This PR handles static transaction aggregation, where a late child cannot be added to a transaction which has already been sent. The two approaches are complementary.

A shared client can drain deferred static transactions from several invocations in one callback. Deferred capture therefore re-enters each span's captured current and isolation scopes before event processing, keeping processors, InvocationState, sendEnvelope, and waitUntil attached to the owning invocation. A combined checkout of both PRs reproduced both events under invocation B before this guard and A/B ownership after it. The committed Core regression protects current and isolation scope ownership without making this PR depend on #23151.

When #23151 rebases after this PR, its CloudflareClient constructor must retain _INTERNAL_setDeferSegmentSpanCapture(this). The strategy remains inert for its default streamed lifecycle.

Evidence

The bug was reproduced using baseline and patched SDK tarballs built from the same source SHA, with one pinned Worker fixture and a protocol-independent control:

ScenarioBaselinePatched
Standard MCP controlHTTP → MCP → child, exactly threeUnchanged, exactly three
Generic late childHTTP root only; child missingHTTP root plus one correctly parented orphan
MCP 2026-07-28 late childHTTP → MCP; child missingHTTP → MCP plus one orphan parented to MCP

The exact patched local run emitted seven expected transaction envelopes with no duplicates. A personal Cloudflare deployment with byte-identical Cloudflare/Core artifacts reproduced the same topology in Sentry; the final artifact set also passed the protected remote smoke. A modern MCP request is only the workload which exposed the generic lifecycle failure.

The repository regression uses a real Wrangler/workerd Worker and strict envelope cardinality. The final causal source state passes the complete Core (3361 passed, 14 skipped), Node (352 passed, 3 skipped), and Cloudflare (818 passed) package suites. The modern Cloudflare MCP and legacy McpAgent E2Es also pass in development and production modes; the Node MCP v2 control passed during the finalization cycle.

The Cloudflare bundle remains below both size limits: 217731 B minified and 541040 B unminified (limits 215 KiB and 529 KiB).

Compatibility and risk

Only static segment capture changes. Static traces can gain one transaction event where a late child was previously dropped. Accepted children are not emitted twice, children pending beneath a rejected segment remain suppressed, and the state is weakly keyed and released at terminal decisions.

The branch is arranged as five dependent review commits: terminal event decisions, the Core state machine, Cloudflare activation, the real Worker regression, and shared-client scope ownership.

Fixes#23427

betegonand others added 4 commits August 13, 2026 22:08
Deferred segment capture needs to distinguish an event accepted by the client pipeline from one rejected by sampling, processors, hooks, or queue admission.
Keep the decision internal, one-shot, and invisible to event hint consumers so existing callback contracts remain unchanged.
Co-Authored-By: OpenAI/ChatGPT <codex@openai.com>
Late descendants must wait for their segment event to be accepted, otherwise dropped or rejected roots can leak orphan telemetry and accepted descendants can be duplicated.
Track pending, accepted, and rejected subtrees with weak ownership and preserve exact local parentage across the deferred boundary.
Co-Authored-By: OpenAI/ChatGPT <codex@openai.com>
Static Cloudflare request transactions can finish before work registered with waitUntil ends, which previously dropped those late child spans.
Use the shared deferred-capture lifecycle so accepted late children remain traceable without changing streaming behavior.
Co-Authored-By: OpenAI/ChatGPT <codex@openai.com>
Exercise the static lifecycle through a real Wrangler worker and fail if the late child is lost, embedded twice, or emitted more than once.
The strict runner remains opt-in so existing integration suites keep their current envelope matching semantics.
Co-Authored-By: OpenAI/ChatGPT <codex@openai.com>
@github-actions

Copy link
Copy Markdown
Contributor

size-limit report 📦

PathSize% ChangeChange
@sentry/browser30.41 kB+0.38%+113 B 🔺
@sentry/browser - with treeshaking flags28.59 kB+0.42%+119 B 🔺
@sentry/browser - with treeshaking flags tracing without tracing26.93 kB+0.42%+111 B 🔺
@sentry/browser (incl. Tracing)48.69 kB+0.23%+108 B 🔺
@sentry/browser (incl. Tracing + Span Streaming)48.7 kB+0.23%+108 B 🔺
@sentry/browser (incl. Tracing, Profiling)51.59 kB+0.25%+128 B 🔺
@sentry/browser (incl. Tracing, Replay)88.1 kB+0.14%+121 B 🔺
@sentry/browser (incl. Tracing, Replay) - with treeshaking flags77.53 kB+0.22%+170 B 🔺
@sentry/browser (incl. Tracing, Replay with Canvas)92.83 kB+0.14%+125 B 🔺
@sentry/browser (incl. Tracing, Replay, Feedback)105.52 kB+0.12%+124 B 🔺
@sentry/browser (incl. Feedback)47.75 kB+0.22%+104 B 🔺
@sentry/browser (incl. sendFeedback)35.23 kB+0.32%+110 B 🔺
@sentry/browser (incl. FeedbackAsync)40.39 kB+0.28%+112 B 🔺
@sentry/browser (incl. Metrics)31.35 kB+0.34%+104 B 🔺
@sentry/browser (incl. Logs)31.63 kB+0.36%+112 B 🔺
@sentry/browser (incl. Metrics & Logs)32.26 kB+0.36%+115 B 🔺
@sentry/react32.21 kB+0.38%+119 B 🔺
@sentry/react (incl. Tracing)50.89 kB+0.24%+120 B 🔺
@sentry/vue35.48 kB+0.41%+142 B 🔺
@sentry/vue (incl. Tracing)50.65 kB+0.23%+114 B 🔺
@sentry/svelte30.44 kB+0.37%+111 B 🔺
CDN Bundle31.73 kB+0.38%+120 B 🔺
CDN Bundle (incl. Tracing)49.05 kB+0.32%+154 B 🔺
CDN Bundle (incl. Logs, Metrics)33.91 kB+0.32%+108 B 🔺
CDN Bundle (incl. Tracing, Logs, Metrics)50.95 kB+0.26%+128 B 🔺
CDN Bundle (incl. Replay, Logs, Metrics)74.43 kB+0.17%+125 B 🔺
CDN Bundle (incl. Tracing, Replay)86.61 kB+0.15%+127 B 🔺
CDN Bundle (incl. Tracing, Replay, Logs, Metrics)88.44 kB+0.16%+133 B 🔺
CDN Bundle (incl. Tracing, Replay, Feedback)92.32 kB+0.15%+137 B 🔺
CDN Bundle (incl. Tracing, Replay, Feedback, Logs, Metrics)94.24 kB+0.13%+115 B 🔺
CDN Bundle - uncompressed94.17 kB+0.35%+325 B 🔺
CDN Bundle (incl. Tracing) - uncompressed147.1 kB+0.25%+357 B 🔺
CDN Bundle (incl. Logs, Metrics) - uncompressed100.47 kB+0.33%+325 B 🔺
CDN Bundle (incl. Tracing, Logs, Metrics) - uncompressed152.79 kB+0.24%+357 B 🔺
CDN Bundle (incl. Replay, Logs, Metrics) - uncompressed229.4 kB+0.15%+325 B 🔺
CDN Bundle (incl. Tracing, Replay) - uncompressed266.37 kB+0.14%+357 B 🔺
CDN Bundle (incl. Tracing, Replay, Logs, Metrics) - uncompressed272.04 kB+0.14%+357 B 🔺
CDN Bundle (incl. Tracing, Replay, Feedback) - uncompressed280.06 kB+0.13%+357 B 🔺
CDN Bundle (incl. Tracing, Replay, Feedback, Logs, Metrics) - uncompressed285.73 kB+0.13%+357 B 🔺
@sentry/nextjs (client)53.43 kB+0.27%+143 B 🔺
@sentry/sveltekit (client)49.1 kB+0.22%+105 B 🔺
@sentry/core/server66.02 kB+0.99%+642 B 🔺
@sentry/core/browser52.36 kB+1.24%+641 B 🔺
@sentry/node117.65 kB+0.54%+622 B 🔺
@sentry/node/import (ESM hook with diagnostics-channel injection)0 Baddedadded
@sentry/node - without tracing82.11 kB+0.83%+675 B 🔺
@sentry/aws-serverless91.58 kB+0.72%+653 B 🔺
@sentry/cloudflare (withSentry) - minified217.73 kB+1.76%+3.75 kB 🔺
@sentry/cloudflare (withSentry)541.05 kB+2.3%+12.12 kB 🔺

View base workflow run

A shared client can drain deferred transactions from several invocations in one callback. Re-enter each span's captured current and isolation scopes so event processing and runtime flush state remain attributed to the owning invocation.
Co-Authored-By: OpenAI/ChatGPT <codex@openai.com>
@betegon

Copy link
Copy Markdown
MemberAuthor

Closing this implementation after validating the MCP workload on deployed Cloudflare Workers.

Using published @sentry/cloudflare@10.70.0 and @modelcontextprotocol/server@2.0.0 with MCP 2026-07-28, we deployed two Workers with byte-identical bundles. The static lifecycle lost a child span that ended later in waitUntil(). The stream lifecycle recorded the same child exactly once, on the same trace and with the MCP operation as its exact parent.

The underlying static behavior remains, so #23427 is being closed as not planned rather than fixed. For the MCP/Cloudflare path, traceLifecycle: "stream" is the supported solution and avoids adding a broad event-capture state machine to Core. We will add this recommendation to the Cloudflare and MCP documentation.

@betegonbetegon closed this Aug 14, 2026
Sign up for freeto 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.

Cloudflare drops static child spans that end in waitUntil after the response

1 participant

@betegon
, '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

fix(cloudflare): Preserve late static child spans - #23428

Closed
betegon wants to merge 5 commits into
developfrom
bt/fix-cloudflare-late-static-child-spans
Closed

fix(cloudflare): Preserve late static child spans#23428
betegon wants to merge 5 commits into
developfrom
bt/fix-cloudflare-late-static-child-spans

Conversation

@betegon

@betegonbetegon commented Aug 13, 2026

Copy link
Copy Markdown
Member

Preserves child spans which end after a Cloudflare static request transaction has already been sent. These spans are emitted exactly once on the same trace with their original parent instead of disappearing.

Root cause

Cloudflare did not enable the deferred segment-capture strategy already used by the Node SDK. Static transactions were therefore assembled as soon as the request segment ended, with no path for a child ending later in waitUntil().

Enabling the existing strategy directly was not sufficient. It treated spans as captured before the client event pipeline had accepted their transaction. If processing, a before-send hook, or queue admission rejected that event, pending descendants could still be emitted as ghost orphan transactions.

Design

Core now exposes an internal, one-shot terminal capture decision. Deferred segment capture reserves spans while that decision is pending:

  • acceptance marks included spans as captured and releases pending late descendants;
  • rejection clears pending state and suppresses descendants which have not already been accepted.

Rejection is deliberately not retroactive: telemetry which has already passed its own pipeline remains accepted. The acceptance callback runs after the client processing-buffer slot has been released, so a follow-up capture does not incorrectly encounter a full buffer.

Cloudflare clients enable this strategy for static capture. A child ending after an accepted segment becomes a standalone transaction with the same trace ID, its exact original parent, and sentry.parent_span_already_sent=true. Span streaming remains unchanged. There is no public API or MCP protocol change.

Node already enables deferred segment capture, so the Core state-machine hardening also applies to its existing use of this machinery.

Relation to #23151

#23151 and its prerequisite #23136 solve a different part of the Cloudflare lifecycle: they reuse one client across invocations and eagerly drain envelopes and streamed-span trace buckets. This PR handles static transaction aggregation, where a late child cannot be added to a transaction which has already been sent. The two approaches are complementary.

A shared client can drain deferred static transactions from several invocations in one callback. Deferred capture therefore re-enters each span's captured current and isolation scopes before event processing, keeping processors, InvocationState, sendEnvelope, and waitUntil attached to the owning invocation. A combined checkout of both PRs reproduced both events under invocation B before this guard and A/B ownership after it. The committed Core regression protects current and isolation scope ownership without making this PR depend on #23151.

When #23151 rebases after this PR, its CloudflareClient constructor must retain _INTERNAL_setDeferSegmentSpanCapture(this). The strategy remains inert for its default streamed lifecycle.

Evidence

The bug was reproduced using baseline and patched SDK tarballs built from the same source SHA, with one pinned Worker fixture and a protocol-independent control:

ScenarioBaselinePatched
Standard MCP controlHTTP → MCP → child, exactly threeUnchanged, exactly three
Generic late childHTTP root only; child missingHTTP root plus one correctly parented orphan
MCP 2026-07-28 late childHTTP → MCP; child missingHTTP → MCP plus one orphan parented to MCP

The exact patched local run emitted seven expected transaction envelopes with no duplicates. A personal Cloudflare deployment with byte-identical Cloudflare/Core artifacts reproduced the same topology in Sentry; the final artifact set also passed the protected remote smoke. A modern MCP request is only the workload which exposed the generic lifecycle failure.

The repository regression uses a real Wrangler/workerd Worker and strict envelope cardinality. The final causal source state passes the complete Core (3361 passed, 14 skipped), Node (352 passed, 3 skipped), and Cloudflare (818 passed) package suites. The modern Cloudflare MCP and legacy McpAgent E2Es also pass in development and production modes; the Node MCP v2 control passed during the finalization cycle.

The Cloudflare bundle remains below both size limits: 217731 B minified and 541040 B unminified (limits 215 KiB and 529 KiB).

Compatibility and risk

Only static segment capture changes. Static traces can gain one transaction event where a late child was previously dropped. Accepted children are not emitted twice, children pending beneath a rejected segment remain suppressed, and the state is weakly keyed and released at terminal decisions.

The branch is arranged as five dependent review commits: terminal event decisions, the Core state machine, Cloudflare activation, the real Worker regression, and shared-client scope ownership.

Fixes#23427

betegonand others added 4 commits August 13, 2026 22:08
Deferred segment capture needs to distinguish an event accepted by the client pipeline from one rejected by sampling, processors, hooks, or queue admission.
Keep the decision internal, one-shot, and invisible to event hint consumers so existing callback contracts remain unchanged.
Co-Authored-By: OpenAI/ChatGPT <codex@openai.com>
Late descendants must wait for their segment event to be accepted, otherwise dropped or rejected roots can leak orphan telemetry and accepted descendants can be duplicated.
Track pending, accepted, and rejected subtrees with weak ownership and preserve exact local parentage across the deferred boundary.
Co-Authored-By: OpenAI/ChatGPT <codex@openai.com>
Static Cloudflare request transactions can finish before work registered with waitUntil ends, which previously dropped those late child spans.
Use the shared deferred-capture lifecycle so accepted late children remain traceable without changing streaming behavior.
Co-Authored-By: OpenAI/ChatGPT <codex@openai.com>
Exercise the static lifecycle through a real Wrangler worker and fail if the late child is lost, embedded twice, or emitted more than once.
The strict runner remains opt-in so existing integration suites keep their current envelope matching semantics.
Co-Authored-By: OpenAI/ChatGPT <codex@openai.com>
@github-actions

Copy link
Copy Markdown
Contributor

size-limit report 📦

PathSize% ChangeChange
@sentry/browser30.41 kB+0.38%+113 B 🔺
@sentry/browser - with treeshaking flags28.59 kB+0.42%+119 B 🔺
@sentry/browser - with treeshaking flags tracing without tracing26.93 kB+0.42%+111 B 🔺
@sentry/browser (incl. Tracing)48.69 kB+0.23%+108 B 🔺
@sentry/browser (incl. Tracing + Span Streaming)48.7 kB+0.23%+108 B 🔺
@sentry/browser (incl. Tracing, Profiling)51.59 kB+0.25%+128 B 🔺
@sentry/browser (incl. Tracing, Replay)88.1 kB+0.14%+121 B 🔺
@sentry/browser (incl. Tracing, Replay) - with treeshaking flags77.53 kB+0.22%+170 B 🔺
@sentry/browser (incl. Tracing, Replay with Canvas)92.83 kB+0.14%+125 B 🔺
@sentry/browser (incl. Tracing, Replay, Feedback)105.52 kB+0.12%+124 B 🔺
@sentry/browser (incl. Feedback)47.75 kB+0.22%+104 B 🔺
@sentry/browser (incl. sendFeedback)35.23 kB+0.32%+110 B 🔺
@sentry/browser (incl. FeedbackAsync)40.39 kB+0.28%+112 B 🔺
@sentry/browser (incl. Metrics)31.35 kB+0.34%+104 B 🔺
@sentry/browser (incl. Logs)31.63 kB+0.36%+112 B 🔺
@sentry/browser (incl. Metrics & Logs)32.26 kB+0.36%+115 B 🔺
@sentry/react32.21 kB+0.38%+119 B 🔺
@sentry/react (incl. Tracing)50.89 kB+0.24%+120 B 🔺
@sentry/vue35.48 kB+0.41%+142 B 🔺
@sentry/vue (incl. Tracing)50.65 kB+0.23%+114 B 🔺
@sentry/svelte30.44 kB+0.37%+111 B 🔺
CDN Bundle31.73 kB+0.38%+120 B 🔺
CDN Bundle (incl. Tracing)49.05 kB+0.32%+154 B 🔺
CDN Bundle (incl. Logs, Metrics)33.91 kB+0.32%+108 B 🔺
CDN Bundle (incl. Tracing, Logs, Metrics)50.95 kB+0.26%+128 B 🔺
CDN Bundle (incl. Replay, Logs, Metrics)74.43 kB+0.17%+125 B 🔺
CDN Bundle (incl. Tracing, Replay)86.61 kB+0.15%+127 B 🔺
CDN Bundle (incl. Tracing, Replay, Logs, Metrics)88.44 kB+0.16%+133 B 🔺
CDN Bundle (incl. Tracing, Replay, Feedback)92.32 kB+0.15%+137 B 🔺
CDN Bundle (incl. Tracing, Replay, Feedback, Logs, Metrics)94.24 kB+0.13%+115 B 🔺
CDN Bundle - uncompressed94.17 kB+0.35%+325 B 🔺
CDN Bundle (incl. Tracing) - uncompressed147.1 kB+0.25%+357 B 🔺
CDN Bundle (incl. Logs, Metrics) - uncompressed100.47 kB+0.33%+325 B 🔺
CDN Bundle (incl. Tracing, Logs, Metrics) - uncompressed152.79 kB+0.24%+357 B 🔺
CDN Bundle (incl. Replay, Logs, Metrics) - uncompressed229.4 kB+0.15%+325 B 🔺
CDN Bundle (incl. Tracing, Replay) - uncompressed266.37 kB+0.14%+357 B 🔺
CDN Bundle (incl. Tracing, Replay, Logs, Metrics) - uncompressed272.04 kB+0.14%+357 B 🔺
CDN Bundle (incl. Tracing, Replay, Feedback) - uncompressed280.06 kB+0.13%+357 B 🔺
CDN Bundle (incl. Tracing, Replay, Feedback, Logs, Metrics) - uncompressed285.73 kB+0.13%+357 B 🔺
@sentry/nextjs (client)53.43 kB+0.27%+143 B 🔺
@sentry/sveltekit (client)49.1 kB+0.22%+105 B 🔺
@sentry/core/server66.02 kB+0.99%+642 B 🔺
@sentry/core/browser52.36 kB+1.24%+641 B 🔺
@sentry/node117.65 kB+0.54%+622 B 🔺
@sentry/node/import (ESM hook with diagnostics-channel injection)0 Baddedadded
@sentry/node - without tracing82.11 kB+0.83%+675 B 🔺
@sentry/aws-serverless91.58 kB+0.72%+653 B 🔺
@sentry/cloudflare (withSentry) - minified217.73 kB+1.76%+3.75 kB 🔺
@sentry/cloudflare (withSentry)541.05 kB+2.3%+12.12 kB 🔺

View base workflow run

A shared client can drain deferred transactions from several invocations in one callback. Re-enter each span's captured current and isolation scopes so event processing and runtime flush state remain attributed to the owning invocation.
Co-Authored-By: OpenAI/ChatGPT <codex@openai.com>
@betegon

Copy link
Copy Markdown
MemberAuthor

Closing this implementation after validating the MCP workload on deployed Cloudflare Workers.

Using published @sentry/cloudflare@10.70.0 and @modelcontextprotocol/server@2.0.0 with MCP 2026-07-28, we deployed two Workers with byte-identical bundles. The static lifecycle lost a child span that ended later in waitUntil(). The stream lifecycle recorded the same child exactly once, on the same trace and with the MCP operation as its exact parent.

The underlying static behavior remains, so #23427 is being closed as not planned rather than fixed. For the MCP/Cloudflare path, traceLifecycle: "stream" is the supported solution and avoids adding a broad event-capture state machine to Core. We will add this recommendation to the Cloudflare and MCP documentation.

@betegonbetegon closed this Aug 14, 2026
Sign up for freeto 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.

Cloudflare drops static child spans that end in waitUntil after the response

1 participant

@betegon
, '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

fix(cloudflare): Preserve late static child spans - #23428

Closed
betegon wants to merge 5 commits into
developfrom
bt/fix-cloudflare-late-static-child-spans
Closed

fix(cloudflare): Preserve late static child spans#23428
betegon wants to merge 5 commits into
developfrom
bt/fix-cloudflare-late-static-child-spans

Conversation

@betegon

@betegonbetegon commented Aug 13, 2026

Copy link
Copy Markdown
Member

Preserves child spans which end after a Cloudflare static request transaction has already been sent. These spans are emitted exactly once on the same trace with their original parent instead of disappearing.

Root cause

Cloudflare did not enable the deferred segment-capture strategy already used by the Node SDK. Static transactions were therefore assembled as soon as the request segment ended, with no path for a child ending later in waitUntil().

Enabling the existing strategy directly was not sufficient. It treated spans as captured before the client event pipeline had accepted their transaction. If processing, a before-send hook, or queue admission rejected that event, pending descendants could still be emitted as ghost orphan transactions.

Design

Core now exposes an internal, one-shot terminal capture decision. Deferred segment capture reserves spans while that decision is pending:

  • acceptance marks included spans as captured and releases pending late descendants;
  • rejection clears pending state and suppresses descendants which have not already been accepted.

Rejection is deliberately not retroactive: telemetry which has already passed its own pipeline remains accepted. The acceptance callback runs after the client processing-buffer slot has been released, so a follow-up capture does not incorrectly encounter a full buffer.

Cloudflare clients enable this strategy for static capture. A child ending after an accepted segment becomes a standalone transaction with the same trace ID, its exact original parent, and sentry.parent_span_already_sent=true. Span streaming remains unchanged. There is no public API or MCP protocol change.

Node already enables deferred segment capture, so the Core state-machine hardening also applies to its existing use of this machinery.

Relation to #23151

#23151 and its prerequisite #23136 solve a different part of the Cloudflare lifecycle: they reuse one client across invocations and eagerly drain envelopes and streamed-span trace buckets. This PR handles static transaction aggregation, where a late child cannot be added to a transaction which has already been sent. The two approaches are complementary.

A shared client can drain deferred static transactions from several invocations in one callback. Deferred capture therefore re-enters each span's captured current and isolation scopes before event processing, keeping processors, InvocationState, sendEnvelope, and waitUntil attached to the owning invocation. A combined checkout of both PRs reproduced both events under invocation B before this guard and A/B ownership after it. The committed Core regression protects current and isolation scope ownership without making this PR depend on #23151.

When #23151 rebases after this PR, its CloudflareClient constructor must retain _INTERNAL_setDeferSegmentSpanCapture(this). The strategy remains inert for its default streamed lifecycle.

Evidence

The bug was reproduced using baseline and patched SDK tarballs built from the same source SHA, with one pinned Worker fixture and a protocol-independent control:

ScenarioBaselinePatched
Standard MCP controlHTTP → MCP → child, exactly threeUnchanged, exactly three
Generic late childHTTP root only; child missingHTTP root plus one correctly parented orphan
MCP 2026-07-28 late childHTTP → MCP; child missingHTTP → MCP plus one orphan parented to MCP

The exact patched local run emitted seven expected transaction envelopes with no duplicates. A personal Cloudflare deployment with byte-identical Cloudflare/Core artifacts reproduced the same topology in Sentry; the final artifact set also passed the protected remote smoke. A modern MCP request is only the workload which exposed the generic lifecycle failure.

The repository regression uses a real Wrangler/workerd Worker and strict envelope cardinality. The final causal source state passes the complete Core (3361 passed, 14 skipped), Node (352 passed, 3 skipped), and Cloudflare (818 passed) package suites. The modern Cloudflare MCP and legacy McpAgent E2Es also pass in development and production modes; the Node MCP v2 control passed during the finalization cycle.

The Cloudflare bundle remains below both size limits: 217731 B minified and 541040 B unminified (limits 215 KiB and 529 KiB).

Compatibility and risk

Only static segment capture changes. Static traces can gain one transaction event where a late child was previously dropped. Accepted children are not emitted twice, children pending beneath a rejected segment remain suppressed, and the state is weakly keyed and released at terminal decisions.

The branch is arranged as five dependent review commits: terminal event decisions, the Core state machine, Cloudflare activation, the real Worker regression, and shared-client scope ownership.

Fixes#23427

betegonand others added 4 commits August 13, 2026 22:08
Deferred segment capture needs to distinguish an event accepted by the client pipeline from one rejected by sampling, processors, hooks, or queue admission.
Keep the decision internal, one-shot, and invisible to event hint consumers so existing callback contracts remain unchanged.
Co-Authored-By: OpenAI/ChatGPT <codex@openai.com>
Late descendants must wait for their segment event to be accepted, otherwise dropped or rejected roots can leak orphan telemetry and accepted descendants can be duplicated.
Track pending, accepted, and rejected subtrees with weak ownership and preserve exact local parentage across the deferred boundary.
Co-Authored-By: OpenAI/ChatGPT <codex@openai.com>
Static Cloudflare request transactions can finish before work registered with waitUntil ends, which previously dropped those late child spans.
Use the shared deferred-capture lifecycle so accepted late children remain traceable without changing streaming behavior.
Co-Authored-By: OpenAI/ChatGPT <codex@openai.com>
Exercise the static lifecycle through a real Wrangler worker and fail if the late child is lost, embedded twice, or emitted more than once.
The strict runner remains opt-in so existing integration suites keep their current envelope matching semantics.
Co-Authored-By: OpenAI/ChatGPT <codex@openai.com>
@github-actions

Copy link
Copy Markdown
Contributor

size-limit report 📦

PathSize% ChangeChange
@sentry/browser30.41 kB+0.38%+113 B 🔺
@sentry/browser - with treeshaking flags28.59 kB+0.42%+119 B 🔺
@sentry/browser - with treeshaking flags tracing without tracing26.93 kB+0.42%+111 B 🔺
@sentry/browser (incl. Tracing)48.69 kB+0.23%+108 B 🔺
@sentry/browser (incl. Tracing + Span Streaming)48.7 kB+0.23%+108 B 🔺
@sentry/browser (incl. Tracing, Profiling)51.59 kB+0.25%+128 B 🔺
@sentry/browser (incl. Tracing, Replay)88.1 kB+0.14%+121 B 🔺
@sentry/browser (incl. Tracing, Replay) - with treeshaking flags77.53 kB+0.22%+170 B 🔺
@sentry/browser (incl. Tracing, Replay with Canvas)92.83 kB+0.14%+125 B 🔺
@sentry/browser (incl. Tracing, Replay, Feedback)105.52 kB+0.12%+124 B 🔺
@sentry/browser (incl. Feedback)47.75 kB+0.22%+104 B 🔺
@sentry/browser (incl. sendFeedback)35.23 kB+0.32%+110 B 🔺
@sentry/browser (incl. FeedbackAsync)40.39 kB+0.28%+112 B 🔺
@sentry/browser (incl. Metrics)31.35 kB+0.34%+104 B 🔺
@sentry/browser (incl. Logs)31.63 kB+0.36%+112 B 🔺
@sentry/browser (incl. Metrics & Logs)32.26 kB+0.36%+115 B 🔺
@sentry/react32.21 kB+0.38%+119 B 🔺
@sentry/react (incl. Tracing)50.89 kB+0.24%+120 B 🔺
@sentry/vue35.48 kB+0.41%+142 B 🔺
@sentry/vue (incl. Tracing)50.65 kB+0.23%+114 B 🔺
@sentry/svelte30.44 kB+0.37%+111 B 🔺
CDN Bundle31.73 kB+0.38%+120 B 🔺
CDN Bundle (incl. Tracing)49.05 kB+0.32%+154 B 🔺
CDN Bundle (incl. Logs, Metrics)33.91 kB+0.32%+108 B 🔺
CDN Bundle (incl. Tracing, Logs, Metrics)50.95 kB+0.26%+128 B 🔺
CDN Bundle (incl. Replay, Logs, Metrics)74.43 kB+0.17%+125 B 🔺
CDN Bundle (incl. Tracing, Replay)86.61 kB+0.15%+127 B 🔺
CDN Bundle (incl. Tracing, Replay, Logs, Metrics)88.44 kB+0.16%+133 B 🔺
CDN Bundle (incl. Tracing, Replay, Feedback)92.32 kB+0.15%+137 B 🔺
CDN Bundle (incl. Tracing, Replay, Feedback, Logs, Metrics)94.24 kB+0.13%+115 B 🔺
CDN Bundle - uncompressed94.17 kB+0.35%+325 B 🔺
CDN Bundle (incl. Tracing) - uncompressed147.1 kB+0.25%+357 B 🔺
CDN Bundle (incl. Logs, Metrics) - uncompressed100.47 kB+0.33%+325 B 🔺
CDN Bundle (incl. Tracing, Logs, Metrics) - uncompressed152.79 kB+0.24%+357 B 🔺
CDN Bundle (incl. Replay, Logs, Metrics) - uncompressed229.4 kB+0.15%+325 B 🔺
CDN Bundle (incl. Tracing, Replay) - uncompressed266.37 kB+0.14%+357 B 🔺
CDN Bundle (incl. Tracing, Replay, Logs, Metrics) - uncompressed272.04 kB+0.14%+357 B 🔺
CDN Bundle (incl. Tracing, Replay, Feedback) - uncompressed280.06 kB+0.13%+357 B 🔺
CDN Bundle (incl. Tracing, Replay, Feedback, Logs, Metrics) - uncompressed285.73 kB+0.13%+357 B 🔺
@sentry/nextjs (client)53.43 kB+0.27%+143 B 🔺
@sentry/sveltekit (client)49.1 kB+0.22%+105 B 🔺
@sentry/core/server66.02 kB+0.99%+642 B 🔺
@sentry/core/browser52.36 kB+1.24%+641 B 🔺
@sentry/node117.65 kB+0.54%+622 B 🔺
@sentry/node/import (ESM hook with diagnostics-channel injection)0 Baddedadded
@sentry/node - without tracing82.11 kB+0.83%+675 B 🔺
@sentry/aws-serverless91.58 kB+0.72%+653 B 🔺
@sentry/cloudflare (withSentry) - minified217.73 kB+1.76%+3.75 kB 🔺
@sentry/cloudflare (withSentry)541.05 kB+2.3%+12.12 kB 🔺

View base workflow run

A shared client can drain deferred transactions from several invocations in one callback. Re-enter each span's captured current and isolation scopes so event processing and runtime flush state remain attributed to the owning invocation.
Co-Authored-By: OpenAI/ChatGPT <codex@openai.com>
@betegon

Copy link
Copy Markdown
MemberAuthor

Closing this implementation after validating the MCP workload on deployed Cloudflare Workers.

Using published @sentry/cloudflare@10.70.0 and @modelcontextprotocol/server@2.0.0 with MCP 2026-07-28, we deployed two Workers with byte-identical bundles. The static lifecycle lost a child span that ended later in waitUntil(). The stream lifecycle recorded the same child exactly once, on the same trace and with the MCP operation as its exact parent.

The underlying static behavior remains, so #23427 is being closed as not planned rather than fixed. For the MCP/Cloudflare path, traceLifecycle: "stream" is the supported solution and avoids adding a broad event-capture state machine to Core. We will add this recommendation to the Cloudflare and MCP documentation.

@betegonbetegon closed this Aug 14, 2026
Sign up for freeto 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.

Cloudflare drops static child spans that end in waitUntil after the response

1 participant

@betegon
, '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

fix(cloudflare): Preserve late static child spans - #23428

Closed
betegon wants to merge 5 commits into
developfrom
bt/fix-cloudflare-late-static-child-spans
Closed

fix(cloudflare): Preserve late static child spans#23428
betegon wants to merge 5 commits into
developfrom
bt/fix-cloudflare-late-static-child-spans

Conversation

@betegon

@betegonbetegon commented Aug 13, 2026

Copy link
Copy Markdown
Member

Preserves child spans which end after a Cloudflare static request transaction has already been sent. These spans are emitted exactly once on the same trace with their original parent instead of disappearing.

Root cause

Cloudflare did not enable the deferred segment-capture strategy already used by the Node SDK. Static transactions were therefore assembled as soon as the request segment ended, with no path for a child ending later in waitUntil().

Enabling the existing strategy directly was not sufficient. It treated spans as captured before the client event pipeline had accepted their transaction. If processing, a before-send hook, or queue admission rejected that event, pending descendants could still be emitted as ghost orphan transactions.

Design

Core now exposes an internal, one-shot terminal capture decision. Deferred segment capture reserves spans while that decision is pending:

  • acceptance marks included spans as captured and releases pending late descendants;
  • rejection clears pending state and suppresses descendants which have not already been accepted.

Rejection is deliberately not retroactive: telemetry which has already passed its own pipeline remains accepted. The acceptance callback runs after the client processing-buffer slot has been released, so a follow-up capture does not incorrectly encounter a full buffer.

Cloudflare clients enable this strategy for static capture. A child ending after an accepted segment becomes a standalone transaction with the same trace ID, its exact original parent, and sentry.parent_span_already_sent=true. Span streaming remains unchanged. There is no public API or MCP protocol change.

Node already enables deferred segment capture, so the Core state-machine hardening also applies to its existing use of this machinery.

Relation to #23151

#23151 and its prerequisite #23136 solve a different part of the Cloudflare lifecycle: they reuse one client across invocations and eagerly drain envelopes and streamed-span trace buckets. This PR handles static transaction aggregation, where a late child cannot be added to a transaction which has already been sent. The two approaches are complementary.

A shared client can drain deferred static transactions from several invocations in one callback. Deferred capture therefore re-enters each span's captured current and isolation scopes before event processing, keeping processors, InvocationState, sendEnvelope, and waitUntil attached to the owning invocation. A combined checkout of both PRs reproduced both events under invocation B before this guard and A/B ownership after it. The committed Core regression protects current and isolation scope ownership without making this PR depend on #23151.

When #23151 rebases after this PR, its CloudflareClient constructor must retain _INTERNAL_setDeferSegmentSpanCapture(this). The strategy remains inert for its default streamed lifecycle.

Evidence

The bug was reproduced using baseline and patched SDK tarballs built from the same source SHA, with one pinned Worker fixture and a protocol-independent control:

ScenarioBaselinePatched
Standard MCP controlHTTP → MCP → child, exactly threeUnchanged, exactly three
Generic late childHTTP root only; child missingHTTP root plus one correctly parented orphan
MCP 2026-07-28 late childHTTP → MCP; child missingHTTP → MCP plus one orphan parented to MCP

The exact patched local run emitted seven expected transaction envelopes with no duplicates. A personal Cloudflare deployment with byte-identical Cloudflare/Core artifacts reproduced the same topology in Sentry; the final artifact set also passed the protected remote smoke. A modern MCP request is only the workload which exposed the generic lifecycle failure.

The repository regression uses a real Wrangler/workerd Worker and strict envelope cardinality. The final causal source state passes the complete Core (3361 passed, 14 skipped), Node (352 passed, 3 skipped), and Cloudflare (818 passed) package suites. The modern Cloudflare MCP and legacy McpAgent E2Es also pass in development and production modes; the Node MCP v2 control passed during the finalization cycle.

The Cloudflare bundle remains below both size limits: 217731 B minified and 541040 B unminified (limits 215 KiB and 529 KiB).

Compatibility and risk

Only static segment capture changes. Static traces can gain one transaction event where a late child was previously dropped. Accepted children are not emitted twice, children pending beneath a rejected segment remain suppressed, and the state is weakly keyed and released at terminal decisions.

The branch is arranged as five dependent review commits: terminal event decisions, the Core state machine, Cloudflare activation, the real Worker regression, and shared-client scope ownership.

Fixes#23427

betegonand others added 4 commits August 13, 2026 22:08
Deferred segment capture needs to distinguish an event accepted by the client pipeline from one rejected by sampling, processors, hooks, or queue admission.
Keep the decision internal, one-shot, and invisible to event hint consumers so existing callback contracts remain unchanged.
Co-Authored-By: OpenAI/ChatGPT <codex@openai.com>
Late descendants must wait for their segment event to be accepted, otherwise dropped or rejected roots can leak orphan telemetry and accepted descendants can be duplicated.
Track pending, accepted, and rejected subtrees with weak ownership and preserve exact local parentage across the deferred boundary.
Co-Authored-By: OpenAI/ChatGPT <codex@openai.com>
Static Cloudflare request transactions can finish before work registered with waitUntil ends, which previously dropped those late child spans.
Use the shared deferred-capture lifecycle so accepted late children remain traceable without changing streaming behavior.
Co-Authored-By: OpenAI/ChatGPT <codex@openai.com>
Exercise the static lifecycle through a real Wrangler worker and fail if the late child is lost, embedded twice, or emitted more than once.
The strict runner remains opt-in so existing integration suites keep their current envelope matching semantics.
Co-Authored-By: OpenAI/ChatGPT <codex@openai.com>
@github-actions

Copy link
Copy Markdown
Contributor

size-limit report 📦

PathSize% ChangeChange
@sentry/browser30.41 kB+0.38%+113 B 🔺
@sentry/browser - with treeshaking flags28.59 kB+0.42%+119 B 🔺
@sentry/browser - with treeshaking flags tracing without tracing26.93 kB+0.42%+111 B 🔺
@sentry/browser (incl. Tracing)48.69 kB+0.23%+108 B 🔺
@sentry/browser (incl. Tracing + Span Streaming)48.7 kB+0.23%+108 B 🔺
@sentry/browser (incl. Tracing, Profiling)51.59 kB+0.25%+128 B 🔺
@sentry/browser (incl. Tracing, Replay)88.1 kB+0.14%+121 B 🔺
@sentry/browser (incl. Tracing, Replay) - with treeshaking flags77.53 kB+0.22%+170 B 🔺
@sentry/browser (incl. Tracing, Replay with Canvas)92.83 kB+0.14%+125 B 🔺
@sentry/browser (incl. Tracing, Replay, Feedback)105.52 kB+0.12%+124 B 🔺
@sentry/browser (incl. Feedback)47.75 kB+0.22%+104 B 🔺
@sentry/browser (incl. sendFeedback)35.23 kB+0.32%+110 B 🔺
@sentry/browser (incl. FeedbackAsync)40.39 kB+0.28%+112 B 🔺
@sentry/browser (incl. Metrics)31.35 kB+0.34%+104 B 🔺
@sentry/browser (incl. Logs)31.63 kB+0.36%+112 B 🔺
@sentry/browser (incl. Metrics & Logs)32.26 kB+0.36%+115 B 🔺
@sentry/react32.21 kB+0.38%+119 B 🔺
@sentry/react (incl. Tracing)50.89 kB+0.24%+120 B 🔺
@sentry/vue35.48 kB+0.41%+142 B 🔺
@sentry/vue (incl. Tracing)50.65 kB+0.23%+114 B 🔺
@sentry/svelte30.44 kB+0.37%+111 B 🔺
CDN Bundle31.73 kB+0.38%+120 B 🔺
CDN Bundle (incl. Tracing)49.05 kB+0.32%+154 B 🔺
CDN Bundle (incl. Logs, Metrics)33.91 kB+0.32%+108 B 🔺
CDN Bundle (incl. Tracing, Logs, Metrics)50.95 kB+0.26%+128 B 🔺
CDN Bundle (incl. Replay, Logs, Metrics)74.43 kB+0.17%+125 B 🔺
CDN Bundle (incl. Tracing, Replay)86.61 kB+0.15%+127 B 🔺
CDN Bundle (incl. Tracing, Replay, Logs, Metrics)88.44 kB+0.16%+133 B 🔺
CDN Bundle (incl. Tracing, Replay, Feedback)92.32 kB+0.15%+137 B 🔺
CDN Bundle (incl. Tracing, Replay, Feedback, Logs, Metrics)94.24 kB+0.13%+115 B 🔺
CDN Bundle - uncompressed94.17 kB+0.35%+325 B 🔺
CDN Bundle (incl. Tracing) - uncompressed147.1 kB+0.25%+357 B 🔺
CDN Bundle (incl. Logs, Metrics) - uncompressed100.47 kB+0.33%+325 B 🔺
CDN Bundle (incl. Tracing, Logs, Metrics) - uncompressed152.79 kB+0.24%+357 B 🔺
CDN Bundle (incl. Replay, Logs, Metrics) - uncompressed229.4 kB+0.15%+325 B 🔺
CDN Bundle (incl. Tracing, Replay) - uncompressed266.37 kB+0.14%+357 B 🔺
CDN Bundle (incl. Tracing, Replay, Logs, Metrics) - uncompressed272.04 kB+0.14%+357 B 🔺
CDN Bundle (incl. Tracing, Replay, Feedback) - uncompressed280.06 kB+0.13%+357 B 🔺
CDN Bundle (incl. Tracing, Replay, Feedback, Logs, Metrics) - uncompressed285.73 kB+0.13%+357 B 🔺
@sentry/nextjs (client)53.43 kB+0.27%+143 B 🔺
@sentry/sveltekit (client)49.1 kB+0.22%+105 B 🔺
@sentry/core/server66.02 kB+0.99%+642 B 🔺
@sentry/core/browser52.36 kB+1.24%+641 B 🔺
@sentry/node117.65 kB+0.54%+622 B 🔺
@sentry/node/import (ESM hook with diagnostics-channel injection)0 Baddedadded
@sentry/node - without tracing82.11 kB+0.83%+675 B 🔺
@sentry/aws-serverless91.58 kB+0.72%+653 B 🔺
@sentry/cloudflare (withSentry) - minified217.73 kB+1.76%+3.75 kB 🔺
@sentry/cloudflare (withSentry)541.05 kB+2.3%+12.12 kB 🔺

View base workflow run

A shared client can drain deferred transactions from several invocations in one callback. Re-enter each span's captured current and isolation scopes so event processing and runtime flush state remain attributed to the owning invocation.
Co-Authored-By: OpenAI/ChatGPT <codex@openai.com>
@betegon

Copy link
Copy Markdown
MemberAuthor

Closing this implementation after validating the MCP workload on deployed Cloudflare Workers.

Using published @sentry/cloudflare@10.70.0 and @modelcontextprotocol/server@2.0.0 with MCP 2026-07-28, we deployed two Workers with byte-identical bundles. The static lifecycle lost a child span that ended later in waitUntil(). The stream lifecycle recorded the same child exactly once, on the same trace and with the MCP operation as its exact parent.

The underlying static behavior remains, so #23427 is being closed as not planned rather than fixed. For the MCP/Cloudflare path, traceLifecycle: "stream" is the supported solution and avoids adding a broad event-capture state machine to Core. We will add this recommendation to the Cloudflare and MCP documentation.

@betegonbetegon closed this Aug 14, 2026
Sign up for freeto 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.

Cloudflare drops static child spans that end in waitUntil after the response

1 participant

@betegon
, '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

fix(cloudflare): Preserve late static child spans - #23428

Closed
betegon wants to merge 5 commits into
developfrom
bt/fix-cloudflare-late-static-child-spans
Closed

fix(cloudflare): Preserve late static child spans#23428
betegon wants to merge 5 commits into
developfrom
bt/fix-cloudflare-late-static-child-spans

Conversation

@betegon

@betegonbetegon commented Aug 13, 2026

Copy link
Copy Markdown
Member

Preserves child spans which end after a Cloudflare static request transaction has already been sent. These spans are emitted exactly once on the same trace with their original parent instead of disappearing.

Root cause

Cloudflare did not enable the deferred segment-capture strategy already used by the Node SDK. Static transactions were therefore assembled as soon as the request segment ended, with no path for a child ending later in waitUntil().

Enabling the existing strategy directly was not sufficient. It treated spans as captured before the client event pipeline had accepted their transaction. If processing, a before-send hook, or queue admission rejected that event, pending descendants could still be emitted as ghost orphan transactions.

Design

Core now exposes an internal, one-shot terminal capture decision. Deferred segment capture reserves spans while that decision is pending:

  • acceptance marks included spans as captured and releases pending late descendants;
  • rejection clears pending state and suppresses descendants which have not already been accepted.

Rejection is deliberately not retroactive: telemetry which has already passed its own pipeline remains accepted. The acceptance callback runs after the client processing-buffer slot has been released, so a follow-up capture does not incorrectly encounter a full buffer.

Cloudflare clients enable this strategy for static capture. A child ending after an accepted segment becomes a standalone transaction with the same trace ID, its exact original parent, and sentry.parent_span_already_sent=true. Span streaming remains unchanged. There is no public API or MCP protocol change.

Node already enables deferred segment capture, so the Core state-machine hardening also applies to its existing use of this machinery.

Relation to #23151

#23151 and its prerequisite #23136 solve a different part of the Cloudflare lifecycle: they reuse one client across invocations and eagerly drain envelopes and streamed-span trace buckets. This PR handles static transaction aggregation, where a late child cannot be added to a transaction which has already been sent. The two approaches are complementary.

A shared client can drain deferred static transactions from several invocations in one callback. Deferred capture therefore re-enters each span's captured current and isolation scopes before event processing, keeping processors, InvocationState, sendEnvelope, and waitUntil attached to the owning invocation. A combined checkout of both PRs reproduced both events under invocation B before this guard and A/B ownership after it. The committed Core regression protects current and isolation scope ownership without making this PR depend on #23151.

When #23151 rebases after this PR, its CloudflareClient constructor must retain _INTERNAL_setDeferSegmentSpanCapture(this). The strategy remains inert for its default streamed lifecycle.

Evidence

The bug was reproduced using baseline and patched SDK tarballs built from the same source SHA, with one pinned Worker fixture and a protocol-independent control:

ScenarioBaselinePatched
Standard MCP controlHTTP → MCP → child, exactly threeUnchanged, exactly three
Generic late childHTTP root only; child missingHTTP root plus one correctly parented orphan
MCP 2026-07-28 late childHTTP → MCP; child missingHTTP → MCP plus one orphan parented to MCP

The exact patched local run emitted seven expected transaction envelopes with no duplicates. A personal Cloudflare deployment with byte-identical Cloudflare/Core artifacts reproduced the same topology in Sentry; the final artifact set also passed the protected remote smoke. A modern MCP request is only the workload which exposed the generic lifecycle failure.

The repository regression uses a real Wrangler/workerd Worker and strict envelope cardinality. The final causal source state passes the complete Core (3361 passed, 14 skipped), Node (352 passed, 3 skipped), and Cloudflare (818 passed) package suites. The modern Cloudflare MCP and legacy McpAgent E2Es also pass in development and production modes; the Node MCP v2 control passed during the finalization cycle.

The Cloudflare bundle remains below both size limits: 217731 B minified and 541040 B unminified (limits 215 KiB and 529 KiB).

Compatibility and risk

Only static segment capture changes. Static traces can gain one transaction event where a late child was previously dropped. Accepted children are not emitted twice, children pending beneath a rejected segment remain suppressed, and the state is weakly keyed and released at terminal decisions.

The branch is arranged as five dependent review commits: terminal event decisions, the Core state machine, Cloudflare activation, the real Worker regression, and shared-client scope ownership.

Fixes#23427

betegonand others added 4 commits August 13, 2026 22:08
Deferred segment capture needs to distinguish an event accepted by the client pipeline from one rejected by sampling, processors, hooks, or queue admission.
Keep the decision internal, one-shot, and invisible to event hint consumers so existing callback contracts remain unchanged.
Co-Authored-By: OpenAI/ChatGPT <codex@openai.com>
Late descendants must wait for their segment event to be accepted, otherwise dropped or rejected roots can leak orphan telemetry and accepted descendants can be duplicated.
Track pending, accepted, and rejected subtrees with weak ownership and preserve exact local parentage across the deferred boundary.
Co-Authored-By: OpenAI/ChatGPT <codex@openai.com>
Static Cloudflare request transactions can finish before work registered with waitUntil ends, which previously dropped those late child spans.
Use the shared deferred-capture lifecycle so accepted late children remain traceable without changing streaming behavior.
Co-Authored-By: OpenAI/ChatGPT <codex@openai.com>
Exercise the static lifecycle through a real Wrangler worker and fail if the late child is lost, embedded twice, or emitted more than once.
The strict runner remains opt-in so existing integration suites keep their current envelope matching semantics.
Co-Authored-By: OpenAI/ChatGPT <codex@openai.com>
@github-actions

Copy link
Copy Markdown
Contributor

size-limit report 📦

PathSize% ChangeChange
@sentry/browser30.41 kB+0.38%+113 B 🔺
@sentry/browser - with treeshaking flags28.59 kB+0.42%+119 B 🔺
@sentry/browser - with treeshaking flags tracing without tracing26.93 kB+0.42%+111 B 🔺
@sentry/browser (incl. Tracing)48.69 kB+0.23%+108 B 🔺
@sentry/browser (incl. Tracing + Span Streaming)48.7 kB+0.23%+108 B 🔺
@sentry/browser (incl. Tracing, Profiling)51.59 kB+0.25%+128 B 🔺
@sentry/browser (incl. Tracing, Replay)88.1 kB+0.14%+121 B 🔺
@sentry/browser (incl. Tracing, Replay) - with treeshaking flags77.53 kB+0.22%+170 B 🔺
@sentry/browser (incl. Tracing, Replay with Canvas)92.83 kB+0.14%+125 B 🔺
@sentry/browser (incl. Tracing, Replay, Feedback)105.52 kB+0.12%+124 B 🔺
@sentry/browser (incl. Feedback)47.75 kB+0.22%+104 B 🔺
@sentry/browser (incl. sendFeedback)35.23 kB+0.32%+110 B 🔺
@sentry/browser (incl. FeedbackAsync)40.39 kB+0.28%+112 B 🔺
@sentry/browser (incl. Metrics)31.35 kB+0.34%+104 B 🔺
@sentry/browser (incl. Logs)31.63 kB+0.36%+112 B 🔺
@sentry/browser (incl. Metrics & Logs)32.26 kB+0.36%+115 B 🔺
@sentry/react32.21 kB+0.38%+119 B 🔺
@sentry/react (incl. Tracing)50.89 kB+0.24%+120 B 🔺
@sentry/vue35.48 kB+0.41%+142 B 🔺
@sentry/vue (incl. Tracing)50.65 kB+0.23%+114 B 🔺
@sentry/svelte30.44 kB+0.37%+111 B 🔺
CDN Bundle31.73 kB+0.38%+120 B 🔺
CDN Bundle (incl. Tracing)49.05 kB+0.32%+154 B 🔺
CDN Bundle (incl. Logs, Metrics)33.91 kB+0.32%+108 B 🔺
CDN Bundle (incl. Tracing, Logs, Metrics)50.95 kB+0.26%+128 B 🔺
CDN Bundle (incl. Replay, Logs, Metrics)74.43 kB+0.17%+125 B 🔺
CDN Bundle (incl. Tracing, Replay)86.61 kB+0.15%+127 B 🔺
CDN Bundle (incl. Tracing, Replay, Logs, Metrics)88.44 kB+0.16%+133 B 🔺
CDN Bundle (incl. Tracing, Replay, Feedback)92.32 kB+0.15%+137 B 🔺
CDN Bundle (incl. Tracing, Replay, Feedback, Logs, Metrics)94.24 kB+0.13%+115 B 🔺
CDN Bundle - uncompressed94.17 kB+0.35%+325 B 🔺
CDN Bundle (incl. Tracing) - uncompressed147.1 kB+0.25%+357 B 🔺
CDN Bundle (incl. Logs, Metrics) - uncompressed100.47 kB+0.33%+325 B 🔺
CDN Bundle (incl. Tracing, Logs, Metrics) - uncompressed152.79 kB+0.24%+357 B 🔺
CDN Bundle (incl. Replay, Logs, Metrics) - uncompressed229.4 kB+0.15%+325 B 🔺
CDN Bundle (incl. Tracing, Replay) - uncompressed266.37 kB+0.14%+357 B 🔺
CDN Bundle (incl. Tracing, Replay, Logs, Metrics) - uncompressed272.04 kB+0.14%+357 B 🔺
CDN Bundle (incl. Tracing, Replay, Feedback) - uncompressed280.06 kB+0.13%+357 B 🔺
CDN Bundle (incl. Tracing, Replay, Feedback, Logs, Metrics) - uncompressed285.73 kB+0.13%+357 B 🔺
@sentry/nextjs (client)53.43 kB+0.27%+143 B 🔺
@sentry/sveltekit (client)49.1 kB+0.22%+105 B 🔺
@sentry/core/server66.02 kB+0.99%+642 B 🔺
@sentry/core/browser52.36 kB+1.24%+641 B 🔺
@sentry/node117.65 kB+0.54%+622 B 🔺
@sentry/node/import (ESM hook with diagnostics-channel injection)0 Baddedadded
@sentry/node - without tracing82.11 kB+0.83%+675 B 🔺
@sentry/aws-serverless91.58 kB+0.72%+653 B 🔺
@sentry/cloudflare (withSentry) - minified217.73 kB+1.76%+3.75 kB 🔺
@sentry/cloudflare (withSentry)541.05 kB+2.3%+12.12 kB 🔺

View base workflow run

A shared client can drain deferred transactions from several invocations in one callback. Re-enter each span's captured current and isolation scopes so event processing and runtime flush state remain attributed to the owning invocation.
Co-Authored-By: OpenAI/ChatGPT <codex@openai.com>
@betegon

Copy link
Copy Markdown
MemberAuthor

Closing this implementation after validating the MCP workload on deployed Cloudflare Workers.

Using published @sentry/cloudflare@10.70.0 and @modelcontextprotocol/server@2.0.0 with MCP 2026-07-28, we deployed two Workers with byte-identical bundles. The static lifecycle lost a child span that ended later in waitUntil(). The stream lifecycle recorded the same child exactly once, on the same trace and with the MCP operation as its exact parent.

The underlying static behavior remains, so #23427 is being closed as not planned rather than fixed. For the MCP/Cloudflare path, traceLifecycle: "stream" is the supported solution and avoids adding a broad event-capture state machine to Core. We will add this recommendation to the Cloudflare and MCP documentation.

@betegonbetegon closed this Aug 14, 2026
Sign up for freeto 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.

Cloudflare drops static child spans that end in waitUntil after the response

1 participant

@betegon