Skip to content
Open
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,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/x-component';

return wrapRequestHandler(
{
options: {
dsn: env.SENTRY_DSN,
tracesSampleRate: 1,
},
request,
context: ctx,
},
() => {
const body = new ReadableStream<Uint8Array>({
start(controller) {
controller.enqueue(new TextEncoder().encode('<div>shell</div>'));
controller.close();
},
});

return new Response(body, { headers: { 'content-type': contentType } });
},
);
},
} satisfies ExportedHandler<Env>;
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { expect, it } from 'vitest';
import { createRunner } from '../../../runner';
import { getSpanOp, getSpansFromEnvelope } from '../../../spanUtils';

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)
.expect(envelope => {
const segmentSpan = getSpansFromEnvelope(envelope).find(span => span.is_segment);
expect(getSpanOp(segmentSpan!)).toBe('http.server');
})
.start(signal);

const body = await runner.makeRequest<string>('get', `/?content-type=${encodeURIComponent(contentType)}`);
expect(body).toBe('<div>shell</div>');

await runner.completed();
},
);
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
{
"name": "worker-name",
// 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",
}
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,7 @@
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),
}));
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"],
}
26 changes: 26 additions & 0 deletions packages/cloudflare/src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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` 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
* // 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;
}

/**
Expand Down
35 changes: 24 additions & 11 deletions packages/cloudflare/src/wrapRequestHandlerWithInit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -180,12 +182,17 @@ 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;

const endSpanOnce = (): void => {
if (ended) return;
Expand All @@ -195,7 +202,14 @@ export function wrapRequestHandlerWithInit(
waitUntil?.(flushAndDispose(client));
};

// 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;
},
flush() {
// Source stream completed normally.
endSpanOnce();
Expand All @@ -208,15 +222,14 @@ export function wrapRequestHandlerWithInit(
},
});

return new Response(res.body.pipeThrough(transform), {
status: res.status,
statusText: res.statusText,
headers: res.headers,
});
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.
}
}

Expand Down
1 change: 1 addition & 0 deletions packages/cloudflare/test/options.test-d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: [],
}),
Expand Down
Loading
Loading