From 0163ddff0dcc6ac77d281719e803f2cc9345dc4a Mon Sep 17 00:00:00 2001 From: JPeer264 Date: Thu, 1 Oct 2026 11:56:26 +0200 Subject: [PATCH 1/5] fix(cloudflare): Keep the http.server span open for streamed HTML and RSC responses Streamed SSR (`text/html`) and RSC (`text/x-component`) responses were classified as non-streaming, so the root span ended when the handler returned and spans that ended while the body streamed were lost. Both content types are now treated as streaming when the response has no Content-Length, the same rule that `text/plain` already uses. The SDK now creates the streamed response with the original response as the init. This keeps `encodeBody: 'manual'`, so workerd no longer compresses an already compressed body a second time. Co-Authored-By: Claude Opus 5.5 --- .../precompressed-response/index.ts | 19 ++++++ .../instrument.server.ts | 6 ++ .../precompressed-response/test.ts | 18 +++++ .../precompressed-response/vite.config.mts | 7 ++ .../precompressed-response/wrangler.jsonc | 7 ++ .../streamed-response/index.ts | 43 ++++++++++++ .../streamed-response/instrument.server.ts | 6 ++ .../request-handler/streamed-response/test.ts | 35 ++++++++++ .../streamed-response/vite.config.mts | 7 ++ .../streamed-response/wrangler.jsonc | 7 ++ .../src/wrapRequestHandlerWithInit.ts | 9 ++- packages/cloudflare/test/request.test.ts | 68 +++++++++++++++++++ packages/core/src/utils/responseStreaming.ts | 9 ++- 13 files changed, 233 insertions(+), 8 deletions(-) create mode 100644 dev-packages/cloudflare-integration-tests/suites/request-handler/precompressed-response/index.ts create mode 100644 dev-packages/cloudflare-integration-tests/suites/request-handler/precompressed-response/instrument.server.ts create mode 100644 dev-packages/cloudflare-integration-tests/suites/request-handler/precompressed-response/test.ts create mode 100644 dev-packages/cloudflare-integration-tests/suites/request-handler/precompressed-response/vite.config.mts create mode 100644 dev-packages/cloudflare-integration-tests/suites/request-handler/precompressed-response/wrangler.jsonc create mode 100644 dev-packages/cloudflare-integration-tests/suites/request-handler/streamed-response/index.ts create mode 100644 dev-packages/cloudflare-integration-tests/suites/request-handler/streamed-response/instrument.server.ts create mode 100644 dev-packages/cloudflare-integration-tests/suites/request-handler/streamed-response/test.ts create mode 100644 dev-packages/cloudflare-integration-tests/suites/request-handler/streamed-response/vite.config.mts create mode 100644 dev-packages/cloudflare-integration-tests/suites/request-handler/streamed-response/wrangler.jsonc diff --git a/dev-packages/cloudflare-integration-tests/suites/request-handler/precompressed-response/index.ts b/dev-packages/cloudflare-integration-tests/suites/request-handler/precompressed-response/index.ts new file mode 100644 index 000000000000..774290a56061 --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/request-handler/precompressed-response/index.ts @@ -0,0 +1,19 @@ +interface Env { + SENTRY_DSN: string; +} + +const TEXT = 'precompressed'; + +export default { + async fetch(): Promise { + const gzipped = await new Response( + new Blob([TEXT]).stream().pipeThrough(new CompressionStream('gzip')), + ).arrayBuffer(); + + // `text/plain` without a Content-Length is classified as streaming, so the SDK replaces the response. + return new Response(gzipped, { + encodeBody: 'manual', + headers: { 'content-type': 'text/plain;charset=utf-8', 'content-encoding': 'gzip' }, + }); + }, +} satisfies ExportedHandler; diff --git a/dev-packages/cloudflare-integration-tests/suites/request-handler/precompressed-response/instrument.server.ts b/dev-packages/cloudflare-integration-tests/suites/request-handler/precompressed-response/instrument.server.ts new file mode 100644 index 000000000000..e577550ae0b8 --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/request-handler/precompressed-response/instrument.server.ts @@ -0,0 +1,6 @@ +import { defineCloudflareOptions } from '@sentry/cloudflare'; + +export default defineCloudflareOptions((env: { SENTRY_DSN: string }) => ({ + dsn: env.SENTRY_DSN, + tracesSampleRate: 1.0, +})); diff --git a/dev-packages/cloudflare-integration-tests/suites/request-handler/precompressed-response/test.ts b/dev-packages/cloudflare-integration-tests/suites/request-handler/precompressed-response/test.ts new file mode 100644 index 000000000000..dd1ba461f2d6 --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/request-handler/precompressed-response/test.ts @@ -0,0 +1,18 @@ +import { expect, it } from 'vitest'; +import { createRunner } from '../../../runner'; +import { getSpanOp, getSpansFromEnvelope } from '../../../spanUtils'; + +it('does not compress a response with `encodeBody: manual` a second time', async ({ signal }) => { + const runner = createRunner(__dirname) + .expect(envelope => { + const segmentSpan = getSpansFromEnvelope(envelope).find(span => span.is_segment); + expect(getSpanOp(segmentSpan!)).toBe('http.server'); + expect(segmentSpan?.attributes['url.path']).toEqual({ type: 'string', value: '/' }); + }) + .start(signal); + + const body = await runner.makeRequest('get', '/', { headers: { 'accept-encoding': 'gzip' } }); + expect(body).toBe('precompressed'); + + await runner.completed(); +}); diff --git a/dev-packages/cloudflare-integration-tests/suites/request-handler/precompressed-response/vite.config.mts b/dev-packages/cloudflare-integration-tests/suites/request-handler/precompressed-response/vite.config.mts new file mode 100644 index 000000000000..005f4448f6cb --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/request-handler/precompressed-response/vite.config.mts @@ -0,0 +1,7 @@ +import { cloudflare } from '@cloudflare/vite-plugin'; +import { sentryCloudflareVitePlugin } from '@sentry/cloudflare/vite'; +import { defineConfig } from 'vite'; + +export default defineConfig({ + plugins: [cloudflare(), sentryCloudflareVitePlugin()], +}); diff --git a/dev-packages/cloudflare-integration-tests/suites/request-handler/precompressed-response/wrangler.jsonc b/dev-packages/cloudflare-integration-tests/suites/request-handler/precompressed-response/wrangler.jsonc new file mode 100644 index 000000000000..ffc67decd4d2 --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/request-handler/precompressed-response/wrangler.jsonc @@ -0,0 +1,7 @@ +{ + "$schema": "../../../node_modules/wrangler/config-schema.json", + "name": "cloudflare-request-handler-precompressed-response", + "main": "index.ts", + "compatibility_date": "2025-06-17", + "compatibility_flags": ["nodejs_compat"], +} diff --git a/dev-packages/cloudflare-integration-tests/suites/request-handler/streamed-response/index.ts b/dev-packages/cloudflare-integration-tests/suites/request-handler/streamed-response/index.ts new file mode 100644 index 000000000000..f983d2cd0faf --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/request-handler/streamed-response/index.ts @@ -0,0 +1,43 @@ +import * as Sentry from '@sentry/cloudflare'; + +interface Env { + SENTRY_DSN: string; +} + +const SHELL = '
shell
'; +const SUSPENDED = '
suspended
'; + +export default { + async fetch(request: Request): Promise { + const url = new URL(request.url); + const contentType = url.searchParams.get('content-type'); + if (!contentType) { + return new Response('Missing content-type', { status: 400 }); + } + + if (url.searchParams.get('body') === 'stream') { + const encoder = new TextEncoder(); + const body = new ReadableStream({ + async start(controller) { + controller.enqueue(encoder.encode(SHELL)); + // Waits for a later task, so the suspended part renders after the handler has returned. + await new Promise(resolve => setTimeout(resolve, 0)); + Sentry.startSpan({ name: 'render' }, () => { + controller.enqueue(encoder.encode(SUSPENDED)); + }); + controller.close(); + }, + }); + + return new Response(body, { headers: { 'content-type': contentType } }); + } + + const html = Sentry.startSpan({ name: 'render' }, () => SHELL + SUSPENDED); + const headers = new Headers({ 'content-type': contentType }); + if (url.searchParams.get('body') === 'string-with-content-length') { + headers.set('content-length', String(new TextEncoder().encode(html).byteLength)); + } + + return new Response(html, { headers }); + }, +} satisfies ExportedHandler; diff --git a/dev-packages/cloudflare-integration-tests/suites/request-handler/streamed-response/instrument.server.ts b/dev-packages/cloudflare-integration-tests/suites/request-handler/streamed-response/instrument.server.ts new file mode 100644 index 000000000000..e577550ae0b8 --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/request-handler/streamed-response/instrument.server.ts @@ -0,0 +1,6 @@ +import { defineCloudflareOptions } from '@sentry/cloudflare'; + +export default defineCloudflareOptions((env: { SENTRY_DSN: string }) => ({ + dsn: env.SENTRY_DSN, + tracesSampleRate: 1.0, +})); diff --git a/dev-packages/cloudflare-integration-tests/suites/request-handler/streamed-response/test.ts b/dev-packages/cloudflare-integration-tests/suites/request-handler/streamed-response/test.ts new file mode 100644 index 000000000000..9bc187460cbe --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/request-handler/streamed-response/test.ts @@ -0,0 +1,35 @@ +import { expect, it } from 'vitest'; +import { createRunner } from '../../../runner'; +import { getSpanOp } from '../../../spanUtils'; + +it.for([ + { body: 'stream', contentType: 'text/html;charset=utf-8' }, + { body: 'stream', contentType: 'text/x-component;charset=utf-8' }, + { body: 'string', contentType: 'text/html;charset=utf-8' }, + { body: 'string', contentType: 'text/x-component;charset=utf-8' }, + { body: 'string-with-content-length', contentType: 'text/html;charset=utf-8' }, + { body: 'string-with-content-length', contentType: 'text/x-component;charset=utf-8' }, +])( + 'nests the render span in the http.server span for a $contentType response with a $body body', + async ({ body, contentType }, { signal }) => { + const runner = createRunner(__dirname).start(signal); + + const spansPromise = runner.collectStreamedSpans( + spansOfTrace => spansOfTrace.some(span => span.is_segment) && spansOfTrace.some(span => span.name === 'render'), + ); + + const responseBody = await runner.makeRequest( + 'get', + `/?body=${body}&content-type=${encodeURIComponent(contentType)}`, + ); + expect(responseBody).toBe('
shell
suspended
'); + + const spans = await spansPromise; + const segmentSpan = spans.find(span => span.is_segment); + const renderSpan = spans.find(span => span.name === 'render'); + + expect(getSpanOp(segmentSpan!)).toBe('http.server'); + expect(renderSpan?.parent_span_id).toBe(segmentSpan?.span_id); + expect(segmentSpan!.end_timestamp).toBeGreaterThanOrEqual(renderSpan!.end_timestamp); + }, +); diff --git a/dev-packages/cloudflare-integration-tests/suites/request-handler/streamed-response/vite.config.mts b/dev-packages/cloudflare-integration-tests/suites/request-handler/streamed-response/vite.config.mts new file mode 100644 index 000000000000..005f4448f6cb --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/request-handler/streamed-response/vite.config.mts @@ -0,0 +1,7 @@ +import { cloudflare } from '@cloudflare/vite-plugin'; +import { sentryCloudflareVitePlugin } from '@sentry/cloudflare/vite'; +import { defineConfig } from 'vite'; + +export default defineConfig({ + plugins: [cloudflare(), sentryCloudflareVitePlugin()], +}); diff --git a/dev-packages/cloudflare-integration-tests/suites/request-handler/streamed-response/wrangler.jsonc b/dev-packages/cloudflare-integration-tests/suites/request-handler/streamed-response/wrangler.jsonc new file mode 100644 index 000000000000..d41141882a2c --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/request-handler/streamed-response/wrangler.jsonc @@ -0,0 +1,7 @@ +{ + "$schema": "../../../node_modules/wrangler/config-schema.json", + "name": "cloudflare-request-handler-streamed-response", + "main": "index.ts", + "compatibility_date": "2025-06-17", + "compatibility_flags": ["nodejs_compat"], +} diff --git a/packages/cloudflare/src/wrapRequestHandlerWithInit.ts b/packages/cloudflare/src/wrapRequestHandlerWithInit.ts index 3599b88f4243..4231995b3039 100644 --- a/packages/cloudflare/src/wrapRequestHandlerWithInit.ts +++ b/packages/cloudflare/src/wrapRequestHandlerWithInit.ts @@ -208,11 +208,10 @@ export function wrapRequestHandlerWithInit( }, }); - return new Response(res.body.pipeThrough(transform), { - status: res.status, - statusText: res.statusText, - headers: res.headers, - }); + // Passing the original response as the init keeps `encodeBody: 'manual'`, which a + // Response does not expose as a property. Without it, workerd compresses a + // pre-compressed body a second time. + return new Response(res.body.pipeThrough(transform), res); } catch { span.end(); waitUntil?.(flushAndDispose(client)); diff --git a/packages/cloudflare/test/request.test.ts b/packages/cloudflare/test/request.test.ts index ae30155c3c6a..c0b6f5471651 100644 --- a/packages/cloudflare/test/request.test.ts +++ b/packages/cloudflare/test/request.test.ts @@ -961,6 +961,74 @@ describe('flushAndDispose', () => { }); }); + describe('streaming response classification', () => { + const options: CloudflareOptions = { dsn: MOCK_OPTIONS.dsn, cacheClient: false }; + + test.each(['text/x-component;charset=utf-8', 'text/html;charset=utf-8'])( + 'defers teardown until a %s body without Content-Length is consumed', + async contentType => { + const waits: Promise[] = []; + const waitUntil = vi.fn((promise: Promise) => waits.push(promise)); + const context = { waitUntil } as unknown as ExecutionContext; + + const flushSpy = vi.spyOn(SentryCore.Client.prototype, 'flush').mockResolvedValue(true); + + let releaseLastChunk!: () => void; + const lastChunkGate = new Promise(resolve => { + releaseLastChunk = resolve; + }); + + const stream = new ReadableStream({ + async start(controller) { + controller.enqueue(new TextEncoder().encode('
shell
')); + await lastChunkGate; + controller.enqueue(new TextEncoder().encode('
suspended
')); + controller.close(); + }, + }); + + const result = await wrapRequestHandler( + { options, request: new Request('https://example.com'), context }, + () => new Response(stream, { headers: { 'content-type': contentType } }), + ); + + expect(waitUntil).not.toHaveBeenCalled(); + + releaseLastChunk(); + expect(await result.text()).toBe('
shell
suspended
'); + + await Promise.all(waits); + expect(waitUntil).toHaveBeenCalledTimes(1); + + flushSpy.mockRestore(); + }, + ); + + test.each(['text/x-component;charset=utf-8', 'text/html;charset=utf-8'])( + 'tears down at handler return for a %s body with a Content-Length', + async contentType => { + const waitUntil = vi.fn(); + const context = { waitUntil } as unknown as ExecutionContext; + + const flushSpy = vi.spyOn(SentryCore.Client.prototype, 'flush').mockResolvedValue(true); + + const response = new Response('
prerendered
', { + headers: { 'content-type': contentType, 'content-length': '22' }, + }); + + const result = await wrapRequestHandler( + { options, request: new Request('https://example.com'), context }, + () => response, + ); + + expect(result).toBe(response); + expect(waitUntil).toHaveBeenCalledTimes(1); + + flushSpy.mockRestore(); + }, + ); + }); + test('dispose is NOT called for protocol upgrade responses (status 101)', async () => { const context = createMockExecutionContext(); const waits: Promise[] = []; diff --git a/packages/core/src/utils/responseStreaming.ts b/packages/core/src/utils/responseStreaming.ts index fee67cbb9f2a..28b016807f8a 100644 --- a/packages/core/src/utils/responseStreaming.ts +++ b/packages/core/src/utils/responseStreaming.ts @@ -8,8 +8,8 @@ export type StreamingGuess = { * Heuristics: * - No body → not streaming * - Known streaming Content-Types → streaming (SSE, NDJSON, JSON streaming) - * - text/plain without Content-Length → streaming (some AI APIs) - * - Otherwise → not streaming (conservative default, including HTML/SSR) + * - text/plain, text/html or text/x-component without Content-Length → streaming (some AI APIs, streamed SSR and RSC) + * - Otherwise → not streaming (conservative default) * * We avoid probing the stream to prevent blocking on transform streams (like injectTraceMetaTags) * or SSR streams that may not have data ready immediately. @@ -27,11 +27,14 @@ export function classifyResponseStreaming(res: Response): StreamingGuess { // - application/x-ndjson, application/ndjson: Newline-delimited JSON // - application/stream+json: JSON streaming // - text/plain (without Content-Length): Some AI APIs use this for streaming text + // - text/html (without Content-Length): Streamed SSR, which renders the body after the handler returns + // - text/x-component (without Content-Length): Streamed React Server Components payloads. + // Prerendered payloads are sent as one buffer with a Content-Length. if ( /^text\/event-stream\b/i.test(contentType) || /^application\/(x-)?ndjson\b/i.test(contentType) || /^application\/stream\+json\b/i.test(contentType) || - (/^text\/plain\b/i.test(contentType) && !contentLength) + (/^text\/(plain|html|x-component)\b/i.test(contentType) && !contentLength) ) { return { isStreaming: true }; } From 6e7c3d1bd0576a484d5e42bfe5601e04d93e51d6 Mon Sep 17 00:00:00 2001 From: JPeer264 Date: Thu, 1 Oct 2026 13:56:51 +0200 Subject: [PATCH 2/5] fixup! fix(cloudflare): Keep the http.server span open for streamed HTML and RSC responses Without the `transformstream_enable_standard_constructor` compatibility flag (compatibility dates before 2022-11-30, which Hydrogen's mini-oxygen uses), workerd ignores the transformer, so `flush` and `cancel` never ran and the span was never sent. Such Workers now end the span at handler return, as before. Co-Authored-By: Claude Opus 5.5 --- .../legacy-transform-stream/index.ts | 32 +++++++++++++++++++ .../legacy-transform-stream/test.ts | 20 ++++++++++++ .../legacy-transform-stream/wrangler.jsonc | 8 +++++ .../src/wrapRequestHandlerWithInit.ts | 21 ++++++++---- packages/cloudflare/test/request.test.ts | 32 +++++++++++++++++++ 5 files changed, 106 insertions(+), 7 deletions(-) create mode 100644 dev-packages/cloudflare-integration-tests/suites/request-handler/legacy-transform-stream/index.ts create mode 100644 dev-packages/cloudflare-integration-tests/suites/request-handler/legacy-transform-stream/test.ts create mode 100644 dev-packages/cloudflare-integration-tests/suites/request-handler/legacy-transform-stream/wrangler.jsonc diff --git a/dev-packages/cloudflare-integration-tests/suites/request-handler/legacy-transform-stream/index.ts b/dev-packages/cloudflare-integration-tests/suites/request-handler/legacy-transform-stream/index.ts new file mode 100644 index 000000000000..99f5bbf2d007 --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/request-handler/legacy-transform-stream/index.ts @@ -0,0 +1,32 @@ +import { wrapRequestHandler } from '@sentry/cloudflare/request'; + +interface Env { + SENTRY_DSN: string; +} + +export default { + async fetch(request, env, ctx) { + const contentType = new URL(request.url).searchParams.get('content-type') ?? 'text/html'; + + return wrapRequestHandler( + { + options: { + dsn: env.SENTRY_DSN, + tracesSampleRate: 1, + }, + request, + context: ctx, + }, + () => { + const body = new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode('
shell
')); + controller.close(); + }, + }); + + return new Response(body, { headers: { 'content-type': contentType } }); + }, + ); + }, +} satisfies ExportedHandler; diff --git a/dev-packages/cloudflare-integration-tests/suites/request-handler/legacy-transform-stream/test.ts b/dev-packages/cloudflare-integration-tests/suites/request-handler/legacy-transform-stream/test.ts new file mode 100644 index 000000000000..ce68cc50d89a --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/request-handler/legacy-transform-stream/test.ts @@ -0,0 +1,20 @@ +import { expect, it } from 'vitest'; +import { createRunner } from '../../../runner'; +import { getSpanOp, getSpansFromEnvelope } from '../../../spanUtils'; + +it.for(['text/html;charset=utf-8', 'text/event-stream'])( + 'sends the http.server span of a streamed %s response when TransformStream ignores the transformer', + async (contentType, { signal }) => { + const runner = createRunner(__dirname) + .expect(envelope => { + const segmentSpan = getSpansFromEnvelope(envelope).find(span => span.is_segment); + expect(getSpanOp(segmentSpan!)).toBe('http.server'); + }) + .start(signal); + + const body = await runner.makeRequest('get', `/?content-type=${encodeURIComponent(contentType)}`); + expect(body).toBe('
shell
'); + + await runner.completed(); + }, +); diff --git a/dev-packages/cloudflare-integration-tests/suites/request-handler/legacy-transform-stream/wrangler.jsonc b/dev-packages/cloudflare-integration-tests/suites/request-handler/legacy-transform-stream/wrangler.jsonc new file mode 100644 index 000000000000..c3d23453664e --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/request-handler/legacy-transform-stream/wrangler.jsonc @@ -0,0 +1,8 @@ +{ + "name": "worker-name", + // Hydrogen's mini-oxygen runs Workers with this date and only this flag, so `new TransformStream()` + // ignores the transformer. + "compatibility_date": "2022-10-31", + "compatibility_flags": ["streams_enable_constructors"], + "main": "index.ts", +} diff --git a/packages/cloudflare/src/wrapRequestHandlerWithInit.ts b/packages/cloudflare/src/wrapRequestHandlerWithInit.ts index 4231995b3039..750cbff089f6 100644 --- a/packages/cloudflare/src/wrapRequestHandlerWithInit.ts +++ b/packages/cloudflare/src/wrapRequestHandlerWithInit.ts @@ -186,6 +186,7 @@ export function wrapRequestHandlerWithInit( if (classification.isStreaming && res.body) { try { let ended = false; + let transformerUsed = false; const endSpanOnce = (): void => { if (ended) return; @@ -195,7 +196,13 @@ export function wrapRequestHandlerWithInit( waitUntil?.(flushAndDispose(client)); }; + // Without the `transformstream_enable_standard_constructor` compatibility flag (default from + // 2022-11-30, but Hydrogen's mini-oxygen uses 2022-10-31), workerd ignores the transformer, so + // `flush` and `cancel` would never end the span. Only a used transformer runs `start`. const transform = new TransformStream({ + start() { + transformerUsed = true; + }, flush() { // Source stream completed normally. endSpanOnce(); @@ -208,14 +215,14 @@ export function wrapRequestHandlerWithInit( }, }); - // Passing the original response as the init keeps `encodeBody: 'manual'`, which a - // Response does not expose as a property. Without it, workerd compresses a - // pre-compressed body a second time. - return new Response(res.body.pipeThrough(transform), res); + if (transformerUsed) { + // Passing the original response as the init keeps `encodeBody: 'manual'`, which a + // Response does not expose as a property. Without it, workerd compresses a + // pre-compressed body a second time. + return new Response(res.body.pipeThrough(transform), res); + } } catch { - span.end(); - waitUntil?.(flushAndDispose(client)); - return res; + // Falls back to ending the span at handler return. } } diff --git a/packages/cloudflare/test/request.test.ts b/packages/cloudflare/test/request.test.ts index c0b6f5471651..84f8c90920b7 100644 --- a/packages/cloudflare/test/request.test.ts +++ b/packages/cloudflare/test/request.test.ts @@ -1027,6 +1027,38 @@ describe('flushAndDispose', () => { flushSpy.mockRestore(); }, ); + + test('tears down at handler return when TransformStream ignores the transformer', async () => { + // workerd without `transformstream_enable_standard_constructor` returns an identity stream. + vi.stubGlobal( + 'TransformStream', + class extends TransformStream { + constructor() { + super(); + } + }, + ); + onTestFinished(() => { + vi.unstubAllGlobals(); + }); + + const waitUntil = vi.fn(); + const context = { waitUntil } as unknown as ExecutionContext; + + const flushSpy = vi.spyOn(SentryCore.Client.prototype, 'flush').mockResolvedValue(true); + + const response = new Response('
shell
', { headers: { 'content-type': 'text/html;charset=utf-8' } }); + + const result = await wrapRequestHandler( + { options, request: new Request('https://example.com'), context }, + () => response, + ); + + expect(result).toBe(response); + expect(waitUntil).toHaveBeenCalledTimes(1); + + flushSpy.mockRestore(); + }); }); test('dispose is NOT called for protocol upgrade responses (status 101)', async () => { From a9c3855c39f6a314667e27f653c03fdfee1bfda5 Mon Sep 17 00:00:00 2001 From: JPeer264 Date: Tue, 6 Oct 2026 17:35:44 +0200 Subject: [PATCH 3/5] fixup! fix(cloudflare): Keep the http.server span open for streamed HTML and RSC responses --- .../legacy-transform-stream/wrangler.jsonc | 4 +- packages/cloudflare/src/client.ts | 26 +++++ .../src/wrapRequestHandlerWithInit.ts | 19 ++-- packages/cloudflare/test/options.test-d.ts | 1 + packages/cloudflare/test/request.test.ts | 97 +++++++++++++++++++ 5 files changed, 139 insertions(+), 8 deletions(-) diff --git a/dev-packages/cloudflare-integration-tests/suites/request-handler/legacy-transform-stream/wrangler.jsonc b/dev-packages/cloudflare-integration-tests/suites/request-handler/legacy-transform-stream/wrangler.jsonc index c3d23453664e..10f17305c6f2 100644 --- a/dev-packages/cloudflare-integration-tests/suites/request-handler/legacy-transform-stream/wrangler.jsonc +++ b/dev-packages/cloudflare-integration-tests/suites/request-handler/legacy-transform-stream/wrangler.jsonc @@ -1,7 +1,7 @@ { "name": "worker-name", - // Hydrogen's mini-oxygen runs Workers with this date and only this flag, so `new TransformStream()` - // ignores the transformer. + // With this date and only this flag, `new TransformStream()` ignores the transformer. Mini-oxygen + // releases before 4.0.0 run Workers with this setup. "compatibility_date": "2022-10-31", "compatibility_flags": ["streams_enable_constructors"], "main": "index.ts", diff --git a/packages/cloudflare/src/client.ts b/packages/cloudflare/src/client.ts index 1a968cfe66b2..b650b2d8afad 100644 --- a/packages/cloudflare/src/client.ts +++ b/packages/cloudflare/src/client.ts @@ -460,6 +460,32 @@ interface BaseCloudflareOptions { * @default true */ cacheClient?: boolean; + + /** + * A function that receives the response of a `fetch` handler and returns whether its body is + * still being produced after the handler returns. + * + * For `true`, the SDK keeps the `http.server` span open until the body has been sent, so spans + * created while the body streams stay inside the request. It does this by passing the body through + * a `TransformStream`. For `false`, the SDK ends the span when the handler returns and sends the + * response unchanged. For `undefined`, the SDK decides: Server-Sent Events, NDJSON and + * `application/stream+json` responses, and `text/plain`, `text/html` and `text/x-component` + * responses without a `Content-Length` header count as streamed. + * + * @example + * ```ts + * export default Sentry.withSentry( + * env => ({ + * dsn: env.SENTRY_DSN, + * // React Router single fetch streams deferred data as `text/x-script`. + * isStreamingResponse: response => + * response.headers.get('content-type')?.startsWith('text/x-script') ? true : undefined, + * }), + * handler, + * ); + * ``` + */ + isStreamingResponse?: (response: Response) => boolean | undefined; } /** diff --git a/packages/cloudflare/src/wrapRequestHandlerWithInit.ts b/packages/cloudflare/src/wrapRequestHandlerWithInit.ts index 750cbff089f6..8d9a68ff00c3 100644 --- a/packages/cloudflare/src/wrapRequestHandlerWithInit.ts +++ b/packages/cloudflare/src/wrapRequestHandlerWithInit.ts @@ -14,11 +14,13 @@ import { httpHeadersToSpanAttributes, HTTP_SPAN_NAME_FALLBACK, parseStringToURLObject, + safeCallback, setHttpStatus, startSpanManual, winterCGHeadersToDict, } from '@sentry/core'; import { classifyResponseStreaming } from '@sentry/core/server'; +import { DEBUG_BUILD } from './debug-build'; import { captureIncomingRequestBody } from './integrations/httpServer'; import { flushDeferredChannelEvents } from './orchestrion-deferred-channels'; import type { CloudflareClient, CloudflareOptions } from './client'; @@ -180,10 +182,14 @@ export function wrapRequestHandlerWithInit( throw e; } - // Classify response to detect actual streaming - const classification = classifyResponseStreaming(res); + const isStreaming = + safeCallback( + DEBUG_BUILD ? 'Error in `isStreamingResponse`, using the default classification:' : '', + () => options.isStreamingResponse?.(res), + () => undefined, + ) ?? classifyResponseStreaming(res).isStreaming; - if (classification.isStreaming && res.body) { + if (isStreaming && res.body) { try { let ended = false; let transformerUsed = false; @@ -196,9 +202,10 @@ export function wrapRequestHandlerWithInit( waitUntil?.(flushAndDispose(client)); }; - // Without the `transformstream_enable_standard_constructor` compatibility flag (default from - // 2022-11-30, but Hydrogen's mini-oxygen uses 2022-10-31), workerd ignores the transformer, so - // `flush` and `cancel` would never end the span. Only a used transformer runs `start`. + // Workers with a compatibility date before 2022-11-30 and without the + // `transformstream_enable_standard_constructor` flag (for example under older mini-oxygen + // releases) ignore the transformer, so `flush` and `cancel` would never end the span. Only a + // used transformer runs `start`. const transform = new TransformStream({ start() { transformerUsed = true; diff --git a/packages/cloudflare/test/options.test-d.ts b/packages/cloudflare/test/options.test-d.ts index f93a24bb7497..f4e9c25e0b01 100644 --- a/packages/cloudflare/test/options.test-d.ts +++ b/packages/cloudflare/test/options.test-d.ts @@ -107,6 +107,7 @@ describe('valid options keep compiling', () => { serverName: 'my-worker', rpcTracePropagationBindings: ['ORDERS', /^SVC_/], durableObjectSqlSpanAllowlist: ['cf_my_table', /^cf_reports_/], + isStreamingResponse: response => (response.headers.has('content-length') ? false : undefined), beforeSend: event => event, integrations: [], }), diff --git a/packages/cloudflare/test/request.test.ts b/packages/cloudflare/test/request.test.ts index 84f8c90920b7..9dfa3f7f93b1 100644 --- a/packages/cloudflare/test/request.test.ts +++ b/packages/cloudflare/test/request.test.ts @@ -1059,6 +1059,103 @@ describe('flushAndDispose', () => { flushSpy.mockRestore(); }); + + test('tears down at handler return when isStreamingResponse returns false', async () => { + const waitUntil = vi.fn(); + const context = { waitUntil } as unknown as ExecutionContext; + + const flushSpy = vi.spyOn(SentryCore.Client.prototype, 'flush').mockResolvedValue(true); + + const response = new Response('
buffered
', { headers: { 'content-type': 'text/html;charset=utf-8' } }); + + const result = await wrapRequestHandler( + { + options: { ...options, isStreamingResponse: () => false }, + request: new Request('https://example.com'), + context, + }, + () => response, + ); + + expect(result).toBe(response); + expect(waitUntil).toHaveBeenCalledTimes(1); + + flushSpy.mockRestore(); + }); + + test('defers teardown when isStreamingResponse returns true for an unknown content type', async () => { + const waits: Promise[] = []; + const waitUntil = vi.fn((promise: Promise) => waits.push(promise)); + const context = { waitUntil } as unknown as ExecutionContext; + + const flushSpy = vi.spyOn(SentryCore.Client.prototype, 'flush').mockResolvedValue(true); + + let releaseLastChunk!: () => void; + const lastChunkGate = new Promise(resolve => { + releaseLastChunk = resolve; + }); + + const stream = new ReadableStream({ + async start(controller) { + controller.enqueue(new TextEncoder().encode('first')); + await lastChunkGate; + controller.enqueue(new TextEncoder().encode('deferred')); + controller.close(); + }, + }); + + const result = await wrapRequestHandler( + { + options: { + ...options, + isStreamingResponse: response => + response.headers.get('content-type') === 'text/x-script' ? true : undefined, + }, + request: new Request('https://example.com'), + context, + }, + () => new Response(stream, { headers: { 'content-type': 'text/x-script' } }), + ); + + expect(waitUntil).not.toHaveBeenCalled(); + + releaseLastChunk(); + expect(await result.text()).toBe('firstdeferred'); + + await Promise.all(waits); + expect(waitUntil).toHaveBeenCalledTimes(1); + + flushSpy.mockRestore(); + }); + + test('uses the default classification when isStreamingResponse throws', async () => { + const waitUntil = vi.fn(); + const context = { waitUntil } as unknown as ExecutionContext; + + const flushSpy = vi.spyOn(SentryCore.Client.prototype, 'flush').mockResolvedValue(true); + + const response = new Response('
shell
', { headers: { 'content-type': 'text/html;charset=utf-8' } }); + + const result = await wrapRequestHandler( + { + options: { + ...options, + isStreamingResponse: () => { + throw new Error('isStreamingResponse failed'); + }, + }, + request: new Request('https://example.com'), + context, + }, + () => response, + ); + + expect(result).not.toBe(response); + expect(waitUntil).not.toHaveBeenCalled(); + expect(await result.text()).toBe('
shell
'); + + flushSpy.mockRestore(); + }); }); test('dispose is NOT called for protocol upgrade responses (status 101)', async () => { From ee216da1b6b27141535fffef106dec2bbe39ff34 Mon Sep 17 00:00:00 2001 From: JPeer264 Date: Thu, 8 Oct 2026 13:42:06 +0200 Subject: [PATCH 4/5] fixup! fix(cloudflare): Keep the http.server span open for streamed HTML and RSC responses --- .../legacy-transform-stream/index.ts | 2 +- .../legacy-transform-stream/test.ts | 2 +- .../streamed-response/instrument.server.ts | 1 + packages/cloudflare/src/client.ts | 8 +- packages/cloudflare/test/request.test.ts | 90 ++++++++++++------- packages/core/src/utils/responseStreaming.ts | 8 +- .../test/lib/utils/responseStreaming.test.ts | 5 +- 7 files changed, 70 insertions(+), 46 deletions(-) diff --git a/dev-packages/cloudflare-integration-tests/suites/request-handler/legacy-transform-stream/index.ts b/dev-packages/cloudflare-integration-tests/suites/request-handler/legacy-transform-stream/index.ts index 99f5bbf2d007..45173ed825e8 100644 --- a/dev-packages/cloudflare-integration-tests/suites/request-handler/legacy-transform-stream/index.ts +++ b/dev-packages/cloudflare-integration-tests/suites/request-handler/legacy-transform-stream/index.ts @@ -6,7 +6,7 @@ interface Env { export default { async fetch(request, env, ctx) { - const contentType = new URL(request.url).searchParams.get('content-type') ?? 'text/html'; + const contentType = new URL(request.url).searchParams.get('content-type') ?? 'text/x-component'; return wrapRequestHandler( { diff --git a/dev-packages/cloudflare-integration-tests/suites/request-handler/legacy-transform-stream/test.ts b/dev-packages/cloudflare-integration-tests/suites/request-handler/legacy-transform-stream/test.ts index ce68cc50d89a..b675a9ce8714 100644 --- a/dev-packages/cloudflare-integration-tests/suites/request-handler/legacy-transform-stream/test.ts +++ b/dev-packages/cloudflare-integration-tests/suites/request-handler/legacy-transform-stream/test.ts @@ -2,7 +2,7 @@ import { expect, it } from 'vitest'; import { createRunner } from '../../../runner'; import { getSpanOp, getSpansFromEnvelope } from '../../../spanUtils'; -it.for(['text/html;charset=utf-8', 'text/event-stream'])( +it.for(['text/x-component;charset=utf-8', 'text/event-stream'])( 'sends the http.server span of a streamed %s response when TransformStream ignores the transformer', async (contentType, { signal }) => { const runner = createRunner(__dirname) diff --git a/dev-packages/cloudflare-integration-tests/suites/request-handler/streamed-response/instrument.server.ts b/dev-packages/cloudflare-integration-tests/suites/request-handler/streamed-response/instrument.server.ts index e577550ae0b8..f4c87d6d4b28 100644 --- a/dev-packages/cloudflare-integration-tests/suites/request-handler/streamed-response/instrument.server.ts +++ b/dev-packages/cloudflare-integration-tests/suites/request-handler/streamed-response/instrument.server.ts @@ -3,4 +3,5 @@ import { defineCloudflareOptions } from '@sentry/cloudflare'; export default defineCloudflareOptions((env: { SENTRY_DSN: string }) => ({ dsn: env.SENTRY_DSN, tracesSampleRate: 1.0, + isStreamingResponse: response => (response.headers.get('content-type')?.startsWith('text/html') ? true : undefined), })); diff --git a/packages/cloudflare/src/client.ts b/packages/cloudflare/src/client.ts index b650b2d8afad..258f3f0911b6 100644 --- a/packages/cloudflare/src/client.ts +++ b/packages/cloudflare/src/client.ts @@ -469,17 +469,17 @@ interface BaseCloudflareOptions { * created while the body streams stay inside the request. It does this by passing the body through * a `TransformStream`. For `false`, the SDK ends the span when the handler returns and sends the * response unchanged. For `undefined`, the SDK decides: Server-Sent Events, NDJSON and - * `application/stream+json` responses, and `text/plain`, `text/html` and `text/x-component` - * responses without a `Content-Length` header count as streamed. + * `application/stream+json` responses, and `text/plain` and `text/x-component` responses without a + * `Content-Length` header count as streamed. HTML does not, so return `true` for pages that are + * rendered with streamed SSR. * * @example * ```ts * export default Sentry.withSentry( * env => ({ * dsn: env.SENTRY_DSN, - * // React Router single fetch streams deferred data as `text/x-script`. * isStreamingResponse: response => - * response.headers.get('content-type')?.startsWith('text/x-script') ? true : undefined, + * response.headers.get('content-type')?.startsWith('text/html') ? true : undefined, * }), * handler, * ); diff --git a/packages/cloudflare/test/request.test.ts b/packages/cloudflare/test/request.test.ts index 9dfa3f7f93b1..a0cccbe157d5 100644 --- a/packages/cloudflare/test/request.test.ts +++ b/packages/cloudflare/test/request.test.ts @@ -964,45 +964,61 @@ describe('flushAndDispose', () => { describe('streaming response classification', () => { const options: CloudflareOptions = { dsn: MOCK_OPTIONS.dsn, cacheClient: false }; - test.each(['text/x-component;charset=utf-8', 'text/html;charset=utf-8'])( - 'defers teardown until a %s body without Content-Length is consumed', - async contentType => { - const waits: Promise[] = []; - const waitUntil = vi.fn((promise: Promise) => waits.push(promise)); - const context = { waitUntil } as unknown as ExecutionContext; + test('defers teardown until a text/x-component body without Content-Length is consumed', async () => { + const waits: Promise[] = []; + const waitUntil = vi.fn((promise: Promise) => waits.push(promise)); + const context = { waitUntil } as unknown as ExecutionContext; - const flushSpy = vi.spyOn(SentryCore.Client.prototype, 'flush').mockResolvedValue(true); + const flushSpy = vi.spyOn(SentryCore.Client.prototype, 'flush').mockResolvedValue(true); - let releaseLastChunk!: () => void; - const lastChunkGate = new Promise(resolve => { - releaseLastChunk = resolve; - }); + let releaseLastChunk!: () => void; + const lastChunkGate = new Promise(resolve => { + releaseLastChunk = resolve; + }); - const stream = new ReadableStream({ - async start(controller) { - controller.enqueue(new TextEncoder().encode('
shell
')); - await lastChunkGate; - controller.enqueue(new TextEncoder().encode('
suspended
')); - controller.close(); - }, - }); + const stream = new ReadableStream({ + async start(controller) { + controller.enqueue(new TextEncoder().encode('
shell
')); + await lastChunkGate; + controller.enqueue(new TextEncoder().encode('
suspended
')); + controller.close(); + }, + }); - const result = await wrapRequestHandler( - { options, request: new Request('https://example.com'), context }, - () => new Response(stream, { headers: { 'content-type': contentType } }), - ); + const result = await wrapRequestHandler( + { options, request: new Request('https://example.com'), context }, + () => new Response(stream, { headers: { 'content-type': 'text/x-component;charset=utf-8' } }), + ); - expect(waitUntil).not.toHaveBeenCalled(); + expect(waitUntil).not.toHaveBeenCalled(); - releaseLastChunk(); - expect(await result.text()).toBe('
shell
suspended
'); + releaseLastChunk(); + expect(await result.text()).toBe('
shell
suspended
'); - await Promise.all(waits); - expect(waitUntil).toHaveBeenCalledTimes(1); + await Promise.all(waits); + expect(waitUntil).toHaveBeenCalledTimes(1); - flushSpy.mockRestore(); - }, - ); + flushSpy.mockRestore(); + }); + + test('tears down at handler return for a text/html body without Content-Length', async () => { + const waitUntil = vi.fn(); + const context = { waitUntil } as unknown as ExecutionContext; + + const flushSpy = vi.spyOn(SentryCore.Client.prototype, 'flush').mockResolvedValue(true); + + const response = new Response('
page
', { headers: { 'content-type': 'text/html;charset=utf-8' } }); + + const result = await wrapRequestHandler( + { options, request: new Request('https://example.com'), context }, + () => response, + ); + + expect(result).toBe(response); + expect(waitUntil).toHaveBeenCalledTimes(1); + + flushSpy.mockRestore(); + }); test.each(['text/x-component;charset=utf-8', 'text/html;charset=utf-8'])( 'tears down at handler return for a %s body with a Content-Length', @@ -1047,7 +1063,9 @@ describe('flushAndDispose', () => { const flushSpy = vi.spyOn(SentryCore.Client.prototype, 'flush').mockResolvedValue(true); - const response = new Response('
shell
', { headers: { 'content-type': 'text/html;charset=utf-8' } }); + const response = new Response('
shell
', { + headers: { 'content-type': 'text/x-component;charset=utf-8' }, + }); const result = await wrapRequestHandler( { options, request: new Request('https://example.com'), context }, @@ -1066,7 +1084,9 @@ describe('flushAndDispose', () => { const flushSpy = vi.spyOn(SentryCore.Client.prototype, 'flush').mockResolvedValue(true); - const response = new Response('
buffered
', { headers: { 'content-type': 'text/html;charset=utf-8' } }); + const response = new Response('
buffered
', { + headers: { 'content-type': 'text/x-component;charset=utf-8' }, + }); const result = await wrapRequestHandler( { @@ -1134,7 +1154,9 @@ describe('flushAndDispose', () => { const flushSpy = vi.spyOn(SentryCore.Client.prototype, 'flush').mockResolvedValue(true); - const response = new Response('
shell
', { headers: { 'content-type': 'text/html;charset=utf-8' } }); + const response = new Response('
shell
', { + headers: { 'content-type': 'text/x-component;charset=utf-8' }, + }); const result = await wrapRequestHandler( { diff --git a/packages/core/src/utils/responseStreaming.ts b/packages/core/src/utils/responseStreaming.ts index 28b016807f8a..c87f9142cd68 100644 --- a/packages/core/src/utils/responseStreaming.ts +++ b/packages/core/src/utils/responseStreaming.ts @@ -8,8 +8,8 @@ export type StreamingGuess = { * Heuristics: * - No body → not streaming * - Known streaming Content-Types → streaming (SSE, NDJSON, JSON streaming) - * - text/plain, text/html or text/x-component without Content-Length → streaming (some AI APIs, streamed SSR and RSC) - * - Otherwise → not streaming (conservative default) + * - text/plain or text/x-component without Content-Length → streaming (some AI APIs, streamed RSC) + * - Otherwise → not streaming (conservative default, including HTML/SSR) * * We avoid probing the stream to prevent blocking on transform streams (like injectTraceMetaTags) * or SSR streams that may not have data ready immediately. @@ -27,14 +27,14 @@ export function classifyResponseStreaming(res: Response): StreamingGuess { // - application/x-ndjson, application/ndjson: Newline-delimited JSON // - application/stream+json: JSON streaming // - text/plain (without Content-Length): Some AI APIs use this for streaming text - // - text/html (without Content-Length): Streamed SSR, which renders the body after the handler returns // - text/x-component (without Content-Length): Streamed React Server Components payloads. // Prerendered payloads are sent as one buffer with a Content-Length. + // HTML is left out: a buffered `new Response(html)` has no Content-Length header either. if ( /^text\/event-stream\b/i.test(contentType) || /^application\/(x-)?ndjson\b/i.test(contentType) || /^application\/stream\+json\b/i.test(contentType) || - (/^text\/(plain|html|x-component)\b/i.test(contentType) && !contentLength) + (/^text\/(plain|x-component)\b/i.test(contentType) && !contentLength) ) { return { isStreaming: true }; } diff --git a/packages/core/test/lib/utils/responseStreaming.test.ts b/packages/core/test/lib/utils/responseStreaming.test.ts index 5ec7779bd7e8..40a23fbfe910 100644 --- a/packages/core/test/lib/utils/responseStreaming.test.ts +++ b/packages/core/test/lib/utils/responseStreaming.test.ts @@ -9,6 +9,7 @@ describe('classifyResponseStreaming', () => { ['application/stream+json', true], ['TEXT/EVENT-STREAM', true], ['text/plain', true], + ['text/x-component;charset=utf-8', true], ['text/html', false], ['application/json', false], ['application/octet-stream', false], @@ -20,8 +21,8 @@ describe('classifyResponseStreaming', () => { expect(response.body?.locked).toBe(false); }); - test('treats plain text with a known length as non-streaming', () => { - const response = new Response('ready', { headers: { 'content-type': 'text/plain', 'content-length': '5' } }); + test.each(['text/plain', 'text/x-component'])('treats %s with a known length as non-streaming', contentType => { + const response = new Response('ready', { headers: { 'content-type': contentType, 'content-length': '5' } }); expect(classifyResponseStreaming(response)).toEqual({ isStreaming: false }); }); From 463ef1bb03bcd3df474024909115d839476f4782 Mon Sep 17 00:00:00 2001 From: JPeer264 Date: Thu, 8 Oct 2026 13:59:34 +0200 Subject: [PATCH 5/5] fixup! fix(cloudflare): Keep the http.server span open for streamed HTML and RSC responses --- packages/cloudflare/src/client.ts | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/packages/cloudflare/src/client.ts b/packages/cloudflare/src/client.ts index 258f3f0911b6..095a6b6f374d 100644 --- a/packages/cloudflare/src/client.ts +++ b/packages/cloudflare/src/client.ts @@ -475,14 +475,14 @@ interface BaseCloudflareOptions { * * @example * ```ts - * export default Sentry.withSentry( - * env => ({ - * dsn: env.SENTRY_DSN, - * isStreamingResponse: response => - * response.headers.get('content-type')?.startsWith('text/html') ? true : undefined, - * }), - * handler, - * ); + * // src/instrument.server.ts + * import { defineCloudflareOptions } from '@sentry/cloudflare'; + * + * export default defineCloudflareOptions(env => ({ + * dsn: env.SENTRY_DSN, + * isStreamingResponse: response => + * response.headers.get('content-type')?.startsWith('text/html') ? true : undefined, + * })); * ``` */ isStreamingResponse?: (response: Response) => boolean | undefined;