Skip to content

Commit d6c8d82

Browse files
JPeer264claude
andauthored
feat(cloudflare): Add isStreamingResponse option and detect streamed RSC responses (#24932)
closes #22876 closes [JS-3236](https://linear.app/getsentry/issue/JS-3236/provide-a-mechanism-to-override-classifyresponsestreaming-behaviour) ## Description This adds an `isStreamingResponse` option, so users can keep the `http.server` span open until a streamed response has been sent, for example for streamed SSR pages. Spans created while the body streams then stay inside the request. - `text/x-component` (RSC) responses without a `Content-Length` header now count as streamed by default. The classifier is shared, so this also applies to Bun and Deno. - Precompressed bodies with `encodeBody: 'manual'` are no longer compressed a second time. - Workers with a compatibility date before 2022-11-30 send the span again. They ignore the `TransformStream` transformer, so the SDK ends the span at handler return there. ```ts export default defineCloudflareOptions(env => ({ dsn: env.SENTRY_DSN, isStreamingResponse: response => response.headers.get('content-type')?.startsWith('text/html') ? true : undefined, })); ``` ## Detection decisions HTML is not streamed by default, because a buffered `new Response(html)` has no `Content-Length` header either and would also go through the `TransformStream`. The alternative is to treat HTML without a `Content-Length` as streamed, which fixes streamed SSR without configuration. --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
1 parent e1d4c42 commit d6c8d82

19 files changed

Lines changed: 525 additions & 15 deletions

File tree

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
import { wrapRequestHandler } from '@sentry/cloudflare/request';
2+
3+
interface Env {
4+
SENTRY_DSN: string;
5+
}
6+
7+
export default {
8+
async fetch(request, env, ctx) {
9+
const contentType = new URL(request.url).searchParams.get('content-type') ?? 'text/x-component';
10+
11+
return wrapRequestHandler(
12+
{
13+
options: {
14+
dsn: env.SENTRY_DSN,
15+
tracesSampleRate: 1,
16+
},
17+
request,
18+
context: ctx,
19+
},
20+
() => {
21+
const body = new ReadableStream<Uint8Array>({
22+
start(controller) {
23+
controller.enqueue(new TextEncoder().encode('<div>shell</div>'));
24+
controller.close();
25+
},
26+
});
27+
28+
return new Response(body, { headers: { 'content-type': contentType } });
29+
},
30+
);
31+
},
32+
} satisfies ExportedHandler<Env>;
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
import { expect, it } from 'vitest';
2+
import { createRunner } from '../../../runner';
3+
import { getSpanOp, getSpansFromEnvelope } from '../../../spanUtils';
4+
5+
it.for(['text/x-component;charset=utf-8', 'text/event-stream'])(
6+
'sends the http.server span of a streamed %s response when TransformStream ignores the transformer',
7+
async (contentType, { signal }) => {
8+
const runner = createRunner(__dirname)
9+
.expect(envelope => {
10+
const segmentSpan = getSpansFromEnvelope(envelope).find(span => span.is_segment);
11+
expect(getSpanOp(segmentSpan!)).toBe('http.server');
12+
})
13+
.start(signal);
14+
15+
const body = await runner.makeRequest<string>('get', `/?content-type=${encodeURIComponent(contentType)}`);
16+
expect(body).toBe('<div>shell</div>');
17+
18+
await runner.completed();
19+
},
20+
);
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
{
2+
"name": "worker-name",
3+
// With this date and only this flag, `new TransformStream()` ignores the transformer. Mini-oxygen
4+
// releases before 4.0.0 run Workers with this setup.
5+
"compatibility_date": "2022-10-31",
6+
"compatibility_flags": ["streams_enable_constructors"],
7+
"main": "index.ts",
8+
}
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
interface Env {
2+
SENTRY_DSN: string;
3+
}
4+
5+
const TEXT = 'precompressed';
6+
7+
export default {
8+
async fetch(): Promise<Response> {
9+
const gzipped = await new Response(
10+
new Blob([TEXT]).stream().pipeThrough(new CompressionStream('gzip')),
11+
).arrayBuffer();
12+
13+
// `text/plain` without a Content-Length is classified as streaming, so the SDK replaces the response.
14+
return new Response(gzipped, {
15+
encodeBody: 'manual',
16+
headers: { 'content-type': 'text/plain;charset=utf-8', 'content-encoding': 'gzip' },
17+
});
18+
},
19+
} satisfies ExportedHandler<Env>;
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
import { defineCloudflareOptions } from '@sentry/cloudflare';
2+
3+
export default defineCloudflareOptions((env: { SENTRY_DSN: string }) => ({
4+
dsn: env.SENTRY_DSN,
5+
tracesSampleRate: 1.0,
6+
}));
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
import { expect, it } from 'vitest';
2+
import { createRunner } from '../../../runner';
3+
import { getSpanOp, getSpansFromEnvelope } from '../../../spanUtils';
4+
5+
it('does not compress a response with `encodeBody: manual` a second time', async ({ signal }) => {
6+
const runner = createRunner(__dirname)
7+
.expect(envelope => {
8+
const segmentSpan = getSpansFromEnvelope(envelope).find(span => span.is_segment);
9+
expect(getSpanOp(segmentSpan!)).toBe('http.server');
10+
expect(segmentSpan?.attributes['url.path']).toEqual({ type: 'string', value: '/' });
11+
})
12+
.start(signal);
13+
14+
const body = await runner.makeRequest<string>('get', '/', { headers: { 'accept-encoding': 'gzip' } });
15+
expect(body).toBe('precompressed');
16+
17+
await runner.completed();
18+
});
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
import { cloudflare } from '@cloudflare/vite-plugin';
2+
import { sentryCloudflareVitePlugin } from '@sentry/cloudflare/vite';
3+
import { defineConfig } from 'vite';
4+
5+
export default defineConfig({
6+
plugins: [cloudflare(), sentryCloudflareVitePlugin()],
7+
});
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
{
2+
"$schema": "../../../node_modules/wrangler/config-schema.json",
3+
"name": "cloudflare-request-handler-precompressed-response",
4+
"main": "index.ts",
5+
"compatibility_date": "2025-06-17",
6+
"compatibility_flags": ["nodejs_compat"],
7+
}
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
import * as Sentry from '@sentry/cloudflare';
2+
3+
interface Env {
4+
SENTRY_DSN: string;
5+
}
6+
7+
const SHELL = '<div>shell</div>';
8+
const SUSPENDED = '<div>suspended</div>';
9+
10+
export default {
11+
async fetch(request: Request): Promise<Response> {
12+
const url = new URL(request.url);
13+
const contentType = url.searchParams.get('content-type');
14+
if (!contentType) {
15+
return new Response('Missing content-type', { status: 400 });
16+
}
17+
18+
if (url.searchParams.get('body') === 'stream') {
19+
const encoder = new TextEncoder();
20+
const body = new ReadableStream<Uint8Array>({
21+
async start(controller) {
22+
controller.enqueue(encoder.encode(SHELL));
23+
// Waits for a later task, so the suspended part renders after the handler has returned.
24+
await new Promise(resolve => setTimeout(resolve, 0));
25+
Sentry.startSpan({ name: 'render' }, () => {
26+
controller.enqueue(encoder.encode(SUSPENDED));
27+
});
28+
controller.close();
29+
},
30+
});
31+
32+
return new Response(body, { headers: { 'content-type': contentType } });
33+
}
34+
35+
const html = Sentry.startSpan({ name: 'render' }, () => SHELL + SUSPENDED);
36+
const headers = new Headers({ 'content-type': contentType });
37+
if (url.searchParams.get('body') === 'string-with-content-length') {
38+
headers.set('content-length', String(new TextEncoder().encode(html).byteLength));
39+
}
40+
41+
return new Response(html, { headers });
42+
},
43+
} satisfies ExportedHandler<Env>;
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
import { defineCloudflareOptions } from '@sentry/cloudflare';
2+
3+
export default defineCloudflareOptions((env: { SENTRY_DSN: string }) => ({
4+
dsn: env.SENTRY_DSN,
5+
tracesSampleRate: 1.0,
6+
isStreamingResponse: response => (response.headers.get('content-type')?.startsWith('text/html') ? true : undefined),
7+
}));

0 commit comments

Comments
 (0)