From 30813bb73b7a6a2d565ab8b6ec908e4014b3320e Mon Sep 17 00:00:00 2001 From: JPeer264 Date: Thu, 1 Oct 2026 11:56:26 +0200 Subject: [PATCH 1/2] 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 ++ packages/cloudflare/src/utils/streaming.ts | 9 ++- .../src/wrapRequestHandlerWithInit.ts | 9 ++- packages/cloudflare/test/request.test.ts | 68 +++++++++++++++++++ 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/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[] = []; From 31904c7e597ab2b1578bf227898322bebca832c6 Mon Sep 17 00:00:00 2001 From: JPeer264 Date: Thu, 1 Oct 2026 12:09:34 +0200 Subject: [PATCH 2/2] fix(deno): End the http.server span when Deno.serve has sent the response Deno.serve exposes `info.completed`, which settles when the response has been sent or could not be sent. The span now ends on it, so it stays open for streamed responses of every content type, and the SDK returns the handler's response unchanged. Before, the SDK replaced responses it classified as streaming, which moved string bodies off Deno's static fast path and replaced Content-Length with chunked encoding. `completed` is read before the handler runs, because Deno creates it on first access and settles it on the next close of the request, which a WebSocket upgrade does inside the handler. Runtimes without `completed` keep the stream wrapper. Co-Authored-By: Claude Opus 5.5 --- .../deno/src/wrap-deno-request-handler.ts | 11 +- packages/deno/test/deno-serve.test.ts | 207 +++++++++++++++++- 2 files changed, 214 insertions(+), 4 deletions(-) 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); +});