Skip to content

fix(server-utils): Make trace meta tag injection chunk-safe and back-pressured - #23908

Draft
JPeer264 wants to merge 1 commit into
developfrom
jp/tanstack-quote-scan-chunks
Draft

fix(server-utils): Make trace meta tag injection chunk-safe and back-pressured#23908
JPeer264 wants to merge 1 commit into
developfrom
jp/tanstack-quote-scan-chunks

Conversation

@JPeer264

@JPeer264 JPeer264 commented Sep 2, 2026

Copy link
Copy Markdown
Member

closes #23468

Astro, SolidStart and TanStack Start each carried their own copy of the same head injection, and each copy had the same two defects. This moves one implementation into @sentry/server-utils, which all three already depend on, and points them at it. It lives in the shared export surface, so the entry without diagnostics channels carries it too and edge runtimes can use it.

The scan ran over every HTML chunk on its own, so a chunk boundary inside a quoted attribute value flipped the quote parity of the next chunk. The regex then paired the value's closing quote with a later one, swallowed <head> inside the resulting phantom string, and skipped the injection with no error. React's Fizz writer flushes when its 2048 byte view fills and writes any longer string on its own, so a long attribute on <html> puts a boundary right after its opening quote and the trace meta tags disappear. The duplicate guard had the same flaw: includes('"sentry-trace"') looked ahead within one chunk, so whether the app's own trace meta tags were seen depended on where the split fell.

The shared injector anchors on </head> instead, the way getMetaTagTransformer does in the react-router SDK, and carries the few characters that could still start either token between chunks. The closing tag cannot be confused with markup inside an attribute value, so the quote scanning goes away entirely, and everything the head contains has been seen by the time the tags are placed, which settles the duplicate guard too. The meta tags now sit at the end of <head> rather than at the start, which is where the react-router SDK already puts them.

The body was also pumped inside the start callback of a ReadableStream, with a for await loop that never consulted desiredSize, so it was drained as fast as it could be produced whether or not anyone was reading the wrapped response. TanStack's transformStreamWithRouter throttles itself against desiredSize on the stream we consume, so that pause never fired. The shared injector carries the consumer's backpressure through to the body being wrapped. Whether this is what produced the SSR stream tail exceeded maximum buffer prerender failure in the report is not established: on the request path React writes </body> last and TanStack releases its tail only once the app stream ends, so read pacing alone does not change how much that tail holds.

The body is pumped by hand rather than with pipeTo, because pipeTo rejects for either side failing and a consumer that navigates away would then be reported as an error on every aborted page load. Only a body that fails to read is reported; a consumer going away cancels the body and stays silent.

Four smaller fixes come with it: the text decoder was never flushed, so a body ending on an incomplete byte sequence lost its tail; holding characters back between chunks can split a surrogate pair, which encodes each half to U+FFFD; the upstream content-length was copied onto a body that had grown, which a client or proxy would truncate; and SolidStart wrapped the tags in newlines, which is the text node in <head> that React 19 whole-document hydration rejects.

Both TanStack Start E2E apps get a /split-head-chunk route whose document carries the long attribute ahead of the head. Verified against the published SDK on workerd: the route renders a <head> but no trace meta tags at all. Astro writes each template expression as its own chunk and Solid renders the shell to one string, so neither can split inside an attribute value; SolidStart gets a plain meta tag E2E test and Astro is already covered by its trace continuity test.

…pressured

Astro, SolidStart and TanStack Start each carried their own copy of the same
head injection, and each copy had the same two defects. This moves one
implementation into `@sentry/server-utils`, which all three already depend on,
and points them at it. It lives in the shared export surface, so the entry
without diagnostics channels carries it too and edge runtimes can use it.

The scan ran over every HTML chunk on its own, so a chunk boundary inside a
quoted attribute value flipped the quote parity of the next chunk. The regex
then paired the value's closing quote with a later one, swallowed `<head>`
inside the resulting phantom string, and skipped the injection with no error.
React's Fizz writer flushes when its 2048 byte view fills and writes any longer
string on its own, so a long attribute on `<html>` puts a boundary right after
its opening quote and the trace meta tags disappear. The duplicate guard had the
same flaw: `includes('"sentry-trace"')` looked ahead within one chunk, so whether
the app's own trace meta tags were seen depended on where the split fell.

The shared injector anchors on `</head>` instead, the way `getMetaTagTransformer`
does in the react-router SDK, and carries the few characters that could still
start either token between chunks. The closing tag cannot be confused with markup
inside an attribute value, so the quote scanning goes away entirely, and
everything the head contains has been seen by the time the tags are placed, which
settles the duplicate guard too. The meta tags now sit at the end of `<head>`
rather than at the start, which is where the react-router SDK already puts them.

The body was also pumped inside the `start` callback of a `ReadableStream`, with a
`for await` loop that never consulted `desiredSize`, so it was drained as fast as
it could be produced whether or not anyone was reading the wrapped response.
TanStack's `transformStreamWithRouter` throttles itself against `desiredSize` on
the stream we consume, so that pause never fired. The shared injector carries the
consumer's backpressure through to the body being wrapped. Whether this is what
produced the `SSR stream tail exceeded maximum buffer` prerender failure in the
report is not established: on the request path React writes `</body>` last and
TanStack releases its tail only once the app stream ends, so read pacing alone
does not change how much that tail holds.

The body is pumped by hand rather than with `pipeTo`, because `pipeTo` rejects
for either side failing and a consumer that navigates away would then be reported
as an error on every aborted page load. Only a body that fails to read is
reported; a consumer going away cancels the body and stays silent.

Four smaller fixes come with it: the text decoder was never flushed, so a body
ending on an incomplete byte sequence lost its tail; holding characters back
between chunks can split a surrogate pair, which encodes each half to U+FFFD;
the upstream `content-length` was copied onto a body that had grown, which a
client or proxy would truncate; and SolidStart wrapped the tags in newlines,
which is the text node in `<head>` that React 19 whole-document hydration
rejects.

Both TanStack Start E2E apps get a `/split-head-chunk` route whose document carries
the long attribute ahead of the head. Verified against the published SDK on workerd:
the route renders a `<head>` but no trace meta tags at all. Astro writes each template
expression as its own chunk and Solid renders the shell to one string, so neither
can split inside an attribute value; SolidStart gets a plain meta tag E2E test and
Astro is already covered by its trace continuity test.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@github-actions

github-actions Bot commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

size-limit report 📦

Path Size % Change Change
@sentry/browser 28.57 kB -4.23% -1.26 kB 🔽
@sentry/browser - with treeshaking flags 26.94 kB -3.9% -1.09 kB 🔽
@sentry/browser (incl. Tracing) 48.87 kB +3.44% +1.62 kB 🔺
@sentry/browser (incl. Tracing + Span Streaming) 48.88 kB +3.4% +1.6 kB 🔺
@sentry/browser (incl. Tracing, Profiling) 51.8 kB -0.38% -197 B 🔽
@sentry/browser (incl. Tracing, Replay) 88.36 kB +2.05% +1.77 kB 🔺
@sentry/browser (incl. Tracing, Replay) - with treeshaking flags 77.76 kB +2.32% +1.76 kB 🔺
@sentry/browser (incl. Tracing, Replay with Canvas) 93.05 kB +1.94% +1.77 kB 🔺
@sentry/browser (incl. Tracing, Replay, Feedback) 106.02 kB +1.99% +2.06 kB 🔺
@sentry/browser (incl. Feedback) 46.06 kB -2.28% -1.07 kB 🔽
@sentry/browser (incl. sendFeedback) 33.64 kB -2.98% -1.03 kB 🔽
@sentry/browser (incl. FeedbackAsync) 38.74 kB -2.61% -1.03 kB 🔽
@sentry/browser (incl. Metrics) 29.52 kB -4.48% -1.38 kB 🔽
@sentry/browser (incl. Logs) 29.81 kB -4.29% -1.33 kB 🔽
@sentry/browser (incl. Metrics & Logs) 30.45 kB -4.3% -1.37 kB 🔽
@sentry/react 30.32 kB -4.15% -1.31 kB 🔽
@sentry/react (incl. Tracing) 51.08 kB +3.17% +1.57 kB 🔺
@sentry/vue 35.74 kB +2.35% +819 B 🔺
@sentry/vue (incl. Tracing) 51.14 kB +3.87% +1.9 kB 🔺
@sentry/svelte 28.6 kB -4.21% -1.25 kB 🔽
CDN Bundle 30.36 kB -4.75% -1.51 kB 🔽
CDN Bundle (incl. Tracing) 49.52 kB +4.16% +1.98 kB 🔺
CDN Bundle (incl. Logs, Metrics) 32.59 kB -2.46% -819 B 🔽
CDN Bundle (incl. Tracing, Logs, Metrics) 51.43 kB +5.07% +2.48 kB 🔺
CDN Bundle (incl. Replay, Logs, Metrics) 73.19 kB +0.61% +439 B 🔺
CDN Bundle (incl. Tracing, Replay) 87.01 kB +2.15% +1.83 kB 🔺
CDN Bundle (incl. Tracing, Replay, Logs, Metrics) 88.88 kB +2.75% +2.38 kB 🔺
CDN Bundle (incl. Tracing, Replay, Feedback) 92.94 kB +2.13% +1.93 kB 🔺
CDN Bundle (incl. Tracing, Replay, Feedback, Logs, Metrics) 94.84 kB +2.74% +2.53 kB 🔺
CDN Bundle - uncompressed 89.97 kB -4.92% -4.65 kB 🔽
CDN Bundle (incl. Tracing) - uncompressed 147.58 kB +3.85% +5.46 kB 🔺
CDN Bundle (incl. Logs, Metrics) - uncompressed 96.27 kB -3% -2.98 kB 🔽
CDN Bundle (incl. Tracing, Logs, Metrics) - uncompressed 153.27 kB +4.91% +7.17 kB 🔺
CDN Bundle (incl. Replay, Logs, Metrics) - uncompressed 225.43 kB +0.67% +1.49 kB 🔺
CDN Bundle (incl. Tracing, Replay) - uncompressed 267.07 kB +2.18% +5.7 kB 🔺
CDN Bundle (incl. Tracing, Replay, Logs, Metrics) - uncompressed 272.75 kB +2.8% +7.41 kB 🔺
CDN Bundle (incl. Tracing, Replay, Feedback) - uncompressed 280.77 kB +2.07% +5.69 kB 🔺
CDN Bundle (incl. Tracing, Replay, Feedback, Logs, Metrics) - uncompressed 286.44 kB +2.66% +7.4 kB 🔺
@sentry/nextjs (client) 53.7 kB +3.11% +1.62 kB 🔺
@sentry/sveltekit (client) 49.3 kB +3.35% +1.59 kB 🔺
@sentry/core/server 40.74 kB -37.82% -24.78 kB 🔽
@sentry/core/browser 13.42 kB -74.13% -38.46 kB 🔽
@sentry/node 123.92 kB +3.07% +3.69 kB 🔺
@sentry/node/import (ESM hook with diagnostics-channel injection) 81.4 kB added added
@sentry/node - without tracing 88.2 kB +5.4% +4.51 kB 🔺
@sentry/aws-serverless 96.56 kB +4.56% +4.21 kB 🔺
@sentry/cloudflare (withSentry) - minified 201.23 kB -7.97% -17.42 kB 🔽
@sentry/cloudflare (withSentry) 500.94 kB -7.04% -37.9 kB 🔽
@sentry/browser - with treeshaking flags tracing without tracing 26.83 kB added added
@sentry/node - without channel injection 103.35 kB added added

View base workflow run

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

wrapFetchWithSentry quote scan breaks across chunks

1 participant