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/utils/streaming.ts b/packages/cloudflare/src/utils/streaming.ts index fee67cbb9f2a..28b016807f8a 100644 --- a/packages/cloudflare/src/utils/streaming.ts +++ b/packages/cloudflare/src/utils/streaming.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 }; } diff --git a/packages/cloudflare/src/wrapRequestHandlerWithInit.ts b/packages/cloudflare/src/wrapRequestHandlerWithInit.ts index 18f2ce63ddb2..d2d23725b4eb 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 249da91d8cd1..4494dc34c77b 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/deno/src/wrap-deno-request-handler.ts b/packages/deno/src/wrap-deno-request-handler.ts index 1d53cca0db27..25dc4cdf8016 100644 --- a/packages/deno/src/wrap-deno-request-handler.ts +++ b/packages/deno/src/wrap-deno-request-handler.ts @@ -129,6 +129,9 @@ export const wrapDenoRequestHandler = ( }, () => { return startSpanManual({ name, attributes }, async span => { + // Deno creates `completed` on first access and settles it on the next close of the request, which can + // happen inside the handler (e.g. on a WebSocket upgrade). It rejects when the response could not be sent. + const responseSent = info?.completed?.then(null, () => undefined); let res: Response; try { @@ -152,7 +155,13 @@ export const wrapDenoRequestHandler = ( throw e; } - return streamResponse(span, res); + // Runtimes without `completed` only expose the end of the response through the body stream. + if (!responseSent) { + return streamResponse(span, res); + } + + void responseSent.then(() => span.end()); + return res; }); }, ); diff --git a/packages/deno/test/deno-serve.test.ts b/packages/deno/test/deno-serve.test.ts index 933aded91ee8..9277fca7fc7a 100644 --- a/packages/deno/test/deno-serve.test.ts +++ b/packages/deno/test/deno-serve.test.ts @@ -1,10 +1,20 @@ // -import type { ErrorEvent, TransactionEvent } from '@sentry/core'; +import type { ErrorEvent, StreamedSpanJSON, TransactionEvent } from '@sentry/core'; import { getMainCarrier } from '@sentry/core'; -import { assertEquals, assertExists, assertNotEquals } from 'https://deno.land/std@0.212.0/assert/mod.ts'; +import { assert, assertEquals, assertExists, assertNotEquals } from 'https://deno.land/std@0.212.0/assert/mod.ts'; import type { DenoClient } from '../build/esm/index.js'; -import { captureException, captureMessage, init, denoServeIntegration, setTag, setUser } from '../build/esm/index.js'; +import { + captureException, + captureMessage, + init, + denoServeIntegration, + setTag, + setUser, + spanToJSON, + startSpan, +} from '../build/esm/index.js'; +import { makeTestTransport } from './transport.ts'; function resetGlobals(): void { getMainCarrier().__SENTRY__ = undefined; @@ -810,3 +820,194 @@ Deno.test('Deno.serve should work when manually capturing exceptions within hand // (unless explicitly set otherwise) assertEquals(errorEvent?.exception?.values?.[0]?.mechanism?.handled, true); }); + +Deno.test('Deno.serve should end the http.server span after a streamed response was sent', async () => { + resetGlobals(); + + const client = init({ + dsn: 'https://username@domain/123', + tracesSampleRate: 1, + transport: makeTestTransport(() => {}), + }) as DenoClient; + + const endedSpans: StreamedSpanJSON[] = []; + const serverSpanEnded = new Promise(resolve => { + client.on('spanEnd', span => { + const spanJson = spanToJSON(span); + endedSpans.push(spanJson); + if (spanJson.attributes['sentry.op'] === 'http.server') { + resolve(spanJson); + } + }); + }); + + const abortController = new AbortController(); + let onListen: ((_: unknown) => void) | undefined = undefined; + const p = new Promise(resolve => (onListen = resolve)); + const server = Deno.serve({ port: 0, signal: abortController.signal, onListen }, () => { + 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)); + startSpan({ name: 'render' }, () => { + controller.enqueue(encoder.encode('
suspended
')); + }); + controller.close(); + }, + }); + + return new Response(body, { headers: { 'content-type': 'text/html;charset=utf-8' } }); + }); + await p; + + const response = await fetch(`http://localhost:${server.addr.port}/`); + assertEquals(await response.text(), '
shell
suspended
'); + + const serverSpan = await serverSpanEnded; + const renderSpan = endedSpans.find(span => span.name === 'render'); + + abortController.abort(); + await server.finished; + await client.flush(2_000); + + assertEquals(renderSpan?.parent_span_id, serverSpan.span_id); + assert(serverSpan.end_timestamp! >= renderSpan!.end_timestamp!); +}); + +Deno.test('Deno.serve should send the response of the handler unchanged', async () => { + resetGlobals(); + + const client = init({ + dsn: 'https://username@domain/123', + tracesSampleRate: 1, + transport: makeTestTransport(() => {}), + }) as DenoClient; + + const serverSpanEnded = new Promise(resolve => { + client.on('spanEnd', span => { + if (spanToJSON(span).attributes['sentry.op'] === 'http.server') { + resolve(spanToJSON(span)); + } + }); + }); + + const abortController = new AbortController(); + let onListen: ((_: unknown) => void) | undefined = undefined; + const p = new Promise(resolve => (onListen = resolve)); + const server = Deno.serve({ port: 0, signal: abortController.signal, onListen }, () => { + return new Response('Hello World'); + }); + await p; + + const response = await fetch(`http://localhost:${server.addr.port}/`); + assertEquals(response.headers.get('content-length'), '11'); + assertEquals(await response.text(), 'Hello World'); + + const serverSpan = await serverSpanEnded; + + abortController.abort(); + await server.finished; + await client.flush(2_000); + + assertEquals(serverSpan.attributes['http.response.status_code'], 200); +}); + +Deno.test('Deno.serve should end the http.server span of a WebSocket upgrade before the socket closes', async () => { + resetGlobals(); + + const client = init({ + dsn: 'https://username@domain/123', + tracesSampleRate: 1, + transport: makeTestTransport(() => {}), + }) as DenoClient; + + const events: string[] = []; + client.on('spanEnd', span => { + if (spanToJSON(span).attributes['sentry.op'] === 'http.server') { + events.push('span ended'); + } + }); + + let resolveServerSocketClosed!: () => void; + const serverSocketClosed = new Promise(resolve => (resolveServerSocketClosed = resolve)); + + const abortController = new AbortController(); + let onListen: ((_: unknown) => void) | undefined = undefined; + const p = new Promise(resolve => (onListen = resolve)); + const server = Deno.serve({ port: 0, signal: abortController.signal, onListen }, request => { + const { socket, response } = Deno.upgradeWebSocket(request); + socket.onclose = () => { + events.push('socket closed'); + resolveServerSocketClosed(); + }; + return response; + }); + await p; + + const webSocket = new WebSocket(`ws://localhost:${server.addr.port}/`); + await new Promise(resolve => (webSocket.onopen = resolve)); + const clientSocketClosed = new Promise(resolve => (webSocket.onclose = resolve)); + webSocket.close(); + await Promise.all([serverSocketClosed, clientSocketClosed]); + + abortController.abort(); + await server.finished; + await client.flush(2_000); + + assertEquals(events, ['span ended', 'socket closed']); +}); + +Deno.test('Deno.serve should end the http.server span when the client disconnects during a streamed response', async () => { + resetGlobals(); + + const client = init({ + dsn: 'https://username@domain/123', + tracesSampleRate: 1, + transport: makeTestTransport(() => {}), + }) as DenoClient; + + const serverSpanEnded = new Promise(resolve => { + client.on('spanEnd', span => { + if (spanToJSON(span).attributes['sentry.op'] === 'http.server') { + resolve(spanToJSON(span)); + } + }); + }); + + let resolveSourceCancelled!: () => void; + const sourceCancelled = new Promise(resolve => (resolveSourceCancelled = resolve)); + + const abortController = new AbortController(); + let onListen: ((_: unknown) => void) | undefined = undefined; + const p = new Promise(resolve => (onListen = resolve)); + const server = Deno.serve({ port: 0, signal: abortController.signal, onListen }, () => { + const encoder = new TextEncoder(); + const body = new ReadableStream({ + pull(controller) { + controller.enqueue(encoder.encode('chunk\n')); + }, + cancel() { + resolveSourceCancelled(); + }, + }); + + return new Response(body, { headers: { 'content-type': 'text/html;charset=utf-8' } }); + }); + await p; + + const response = await fetch(`http://localhost:${server.addr.port}/`); + const reader = response.body!.getReader(); + await reader.read(); + await reader.cancel(); + + await sourceCancelled; + const serverSpan = await serverSpanEnded; + + abortController.abort(); + await server.finished; + await client.flush(2_000); + + assertEquals(serverSpan.attributes['http.response.status_code'], 200); +});