Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
interface Env {
SENTRY_DSN: string;
}

const TEXT = 'precompressed';

export default {
async fetch(): Promise<Response> {
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<Env>;
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import { defineCloudflareOptions } from '@sentry/cloudflare';

export default defineCloudflareOptions((env: { SENTRY_DSN: string }) => ({
dsn: env.SENTRY_DSN,
tracesSampleRate: 1.0,
}));
Original file line number Diff line number Diff line change
@@ -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<string>('get', '/', { headers: { 'accept-encoding': 'gzip' } });
expect(body).toBe('precompressed');

await runner.completed();
});
Original file line number Diff line number Diff line change
@@ -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()],
});
Original file line number Diff line number Diff line change
@@ -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"],
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import * as Sentry from '@sentry/cloudflare';

interface Env {
SENTRY_DSN: string;
}

const SHELL = '<div>shell</div>';
const SUSPENDED = '<div>suspended</div>';

export default {
async fetch(request: Request): Promise<Response> {
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<Uint8Array>({
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<Env>;
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import { defineCloudflareOptions } from '@sentry/cloudflare';

export default defineCloudflareOptions((env: { SENTRY_DSN: string }) => ({
dsn: env.SENTRY_DSN,
tracesSampleRate: 1.0,
}));
Original file line number Diff line number Diff line change
@@ -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<string>(
'get',
`/?body=${body}&content-type=${encodeURIComponent(contentType)}`,
);
expect(responseBody).toBe('<div>shell</div><div>suspended</div>');

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);
},
);
Original file line number Diff line number Diff line change
@@ -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()],
});
Original file line number Diff line number Diff line change
@@ -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"],
}
9 changes: 6 additions & 3 deletions packages/cloudflare/src/utils/streaming.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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 };
}
Expand Down
9 changes: 4 additions & 5 deletions packages/cloudflare/src/wrapRequestHandlerWithInit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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));
Expand Down
68 changes: 68 additions & 0 deletions packages/cloudflare/test/request.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<unknown>[] = [];
const waitUntil = vi.fn((promise: Promise<unknown>) => 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<void>(resolve => {
releaseLastChunk = resolve;
});

const stream = new ReadableStream({
async start(controller) {
controller.enqueue(new TextEncoder().encode('<div>shell</div>'));
await lastChunkGate;
controller.enqueue(new TextEncoder().encode('<div>suspended</div>'));
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('<div>shell</div><div>suspended</div>');

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('<div>prerendered</div>', {
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<unknown>[] = [];
Expand Down
11 changes: 10 additions & 1 deletion packages/deno/src/wrap-deno-request-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,9 @@ export const wrapDenoRequestHandler = <Addr extends Deno.Addr = Deno.Addr>(
},
() => {
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 {
Expand All @@ -152,7 +155,13 @@ export const wrapDenoRequestHandler = <Addr extends Deno.Addr = Deno.Addr>(
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;
});
},
);
Expand Down
Loading
Loading