Skip to content

Commit 0df3e5e

Browse files
isaacsclaude
andcommitted
feat(cloudflare): Add incoming request span options to httpServerIntegration
Cloudflare's `httpServerIntegration` took only the body capture options. A user could not skip spans for one route, such as a health check, or change the request span from the request or the response. Node, Deno and Bun all allow this. `httpServerIntegration` now accepts `ignoreIncomingRequests`, `ignoreStaticAssets`, `spans`, `onSpanCreated` and `onSpanEnd`. They use the same names and behavior as `denoServeIntegration` and `bunServerIntegration`, and the callbacks receive the web `Request`. The shared `ServerRequestOptions` check for Cloudflare now lists only the session options, which a worker cannot support because it has no long-lived process to run the flush timer. When the SDK skips the span, it still continues the incoming trace, attaches request data and the body, and captures errors. It also still waits for a streamed response to finish before it flushes. For a streamed response, `onSpanEnd` runs when the stream finishes. It does not run when the handler throws. A callback that throws does not fail the request. `ignoreRequestBody` also no longer fails the request when it throws. The SDK skips the body instead, so it cannot leak data that the filter was meant to hide. Deno and Bun already do this. Two defaults change behavior: - `ignoreStaticAssets` defaults to `true`, so `GET` requests for files such as `favicon.ico` or `*.js` no longer create spans. - `spans` defaults to the client's tracing configuration, so the SDK no longer starts request spans when tracing is disabled. Fixes #25573 Fixes JS-3971 Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
1 parent be699cb commit 0df3e5e

7 files changed

Lines changed: 628 additions & 98 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,12 @@ Work in this release was contributed by @adityareddy-dev, @jpdefo, @Jxxunnn, @ph
6363

6464
`httpIntegration` and `httpServerSpansIntegration` now accept `onSpanEnd`, a hook that runs when the response for an incoming request finishes, before its span ends. Use it to change the span based on the response. Deno and Bun already have this option.
6565

66+
- **feat(cloudflare): Add incoming request span options to `httpServerIntegration`**
67+
68+
Cloudflare's `httpServerIntegration` now accepts `ignoreIncomingRequests`, `ignoreStaticAssets`, `spans`, `onSpanCreated` and `onSpanEnd`, with the same meaning as in the Node, Deno and Bun integrations. The callbacks receive the web `Request`.
69+
70+
`ignoreStaticAssets` defaults to `true`, so workers no longer create spans for `GET` requests to common static assets such as `favicon.ico`, `robots.txt` or `*.js` files. To keep these spans, set `httpServerIntegration({ ignoreStaticAssets: false })`. Request spans also follow the client's tracing configuration by default, so the SDK does not start them when tracing is disabled. If `ignoreRequestBody` throws, the request no longer fails, and the SDK skips the body.
71+
6672
## 11.8.0
6773

6874
### Important Changes
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
import * as Sentry from '@sentry/cloudflare';
2+
3+
interface Env {
4+
SENTRY_DSN: string;
5+
}
6+
7+
export default Sentry.withSentry(
8+
(env: Env) => ({
9+
dsn: env.SENTRY_DSN,
10+
tracesSampleRate: 1.0,
11+
traceLifecycle: 'static',
12+
integrations: [
13+
Sentry.httpServerIntegration({
14+
ignoreIncomingRequests: urlPath => urlPath.startsWith('/health'),
15+
onSpanCreated: (span, request) => {
16+
span.setAttribute('test.on_span_created', new URL(request.url).pathname);
17+
},
18+
onSpanEnd: (span, _request, response) => {
19+
span.setAttribute('test.on_span_end', response.status);
20+
},
21+
}),
22+
],
23+
}),
24+
{
25+
async fetch(request, _env, _ctx) {
26+
const { pathname } = new URL(request.url);
27+
28+
if (pathname === '/health') {
29+
Sentry.captureMessage('Health check');
30+
return new Response('ok');
31+
}
32+
33+
if (pathname === '/favicon.ico') {
34+
Sentry.captureMessage('Static asset');
35+
return new Response('icon');
36+
}
37+
38+
if (pathname === '/health/stream') {
39+
// No span for this request. The message is captured after the handler returns, while the
40+
// body streams, so it is only sent if the SDK waits for the stream to finish.
41+
const encoder = new TextEncoder();
42+
const body = new ReadableStream<Uint8Array>({
43+
async start(controller) {
44+
controller.enqueue(encoder.encode('a'));
45+
await new Promise(resolve => setTimeout(resolve, 50));
46+
Sentry.captureMessage('Streamed after handler return');
47+
controller.enqueue(encoder.encode('b'));
48+
controller.close();
49+
},
50+
});
51+
return new Response(body, { headers: { 'content-type': 'text/event-stream' } });
52+
}
53+
54+
return new Response('ok', { status: 201 });
55+
},
56+
},
57+
);
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
import type { Envelope, Event } from '@sentry/core';
2+
import { expect, it } from 'vitest';
3+
import { createRunner } from '../../../runner';
4+
5+
function itemOf(envelope: Envelope): { type: string; event: Event } {
6+
const [header, event] = envelope[1][0] as [{ type: string }, Event];
7+
return { type: header.type, event };
8+
}
9+
10+
function messageEvent(message: string): (envelope: Envelope) => void {
11+
return envelope => {
12+
const { type, event } = itemOf(envelope);
13+
expect(type).toBe('event');
14+
expect(event.message).toBe(message);
15+
};
16+
}
17+
18+
// The `onSpanCreated` and `onSpanEnd` hooks of the worker set these attributes.
19+
function apiTransaction(envelope: Envelope): void {
20+
const { type, event } = itemOf(envelope);
21+
expect(type).toBe('transaction');
22+
expect(event.transaction).toBe('GET /api');
23+
expect(event.contexts?.trace?.data).toEqual(
24+
expect.objectContaining({
25+
'test.on_span_created': '/api',
26+
'test.on_span_end': 201,
27+
}),
28+
);
29+
}
30+
31+
// Each test makes a request without a span, then a request with one. With `failOnUnexpected`, a
32+
// transaction for the first request fails the test, as it arrives before the second one.
33+
34+
it('does not create a span for a request that ignoreIncomingRequests matches', async ({ signal }) => {
35+
const runner = createRunner(__dirname)
36+
.unordered()
37+
.failOnUnexpected()
38+
.expect(messageEvent('Health check'))
39+
.expect(apiTransaction)
40+
.start(signal);
41+
42+
await runner.makeRequest('get', '/health');
43+
await runner.makeRequest('get', '/api');
44+
await runner.completed();
45+
});
46+
47+
it('does not create a span for a static asset request by default', async ({ signal }) => {
48+
const runner = createRunner(__dirname)
49+
.unordered()
50+
.failOnUnexpected()
51+
.expect(messageEvent('Static asset'))
52+
.expect(apiTransaction)
53+
.start(signal);
54+
55+
await runner.makeRequest('get', '/favicon.ico');
56+
await runner.makeRequest('get', '/api');
57+
await runner.completed();
58+
});
59+
60+
it('sends events from a streamed response without a span after the stream finishes', async ({ signal }) => {
61+
const runner = createRunner(__dirname)
62+
.unordered()
63+
.failOnUnexpected()
64+
.expect(messageEvent('Streamed after handler return'))
65+
.expect(apiTransaction)
66+
.start(signal);
67+
68+
expect(await runner.makeRequest<string>('get', '/health/stream')).toBe('ab');
69+
await runner.makeRequest('get', '/api');
70+
await runner.completed();
71+
});
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
{
2+
"name": "worker-name",
3+
"compatibility_date": "2025-06-17",
4+
"main": "index.ts",
5+
"compatibility_flags": ["nodejs_compat"],
6+
}

‎packages/cloudflare/src/integrations/httpServer.ts‎

Lines changed: 120 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,15 @@
1-
import type { Client, IntegrationFn, MaxRequestBodySize } from '@sentry/core';
2-
import { captureBodyFromWinterCGRequest, defineIntegration, getIsolationScope } from '@sentry/core';
1+
import type { Client, IntegrationFn, MaxRequestBodySize, Span } from '@sentry/core';
2+
import {
3+
captureBodyFromWinterCGRequest,
4+
defineIntegration,
5+
getIsolationScope,
6+
hasSpansEnabled,
7+
parseStringToURLObject,
8+
safeCallback,
9+
} from '@sentry/core';
310
import type { AssertTrue, CheckServerRequestOptions } from '@sentry/core/server';
11+
import { isStaticAssetRequest } from '@sentry/core/server';
12+
import { DEBUG_BUILD } from '../debug-build';
413

514
const INTEGRATION_NAME = 'HttpServer' as const;
615

@@ -39,6 +48,42 @@ export interface HttpServerIntegrationOptions {
3948
* ```
4049
*/
4150
ignoreRequestBody?: (url: string, request: Request) => boolean;
51+
52+
/**
53+
* Do not create spans for incoming requests whose URL path makes the given callback return `true`.
54+
*
55+
* `urlPath` is the path of the request, including the query string. `request` is the incoming
56+
* {@link Request}. Errors thrown by the handler are still captured.
57+
*/
58+
ignoreIncomingRequests?: (urlPath: string, request: Request) => boolean;
59+
60+
/**
61+
* Whether to automatically ignore common static asset requests (favicon.ico, robots.txt, etc.)
62+
* when creating spans.
63+
*
64+
* @default `true`
65+
*/
66+
ignoreStaticAssets?: boolean;
67+
68+
/**
69+
* Whether to create spans for incoming requests.
70+
* Errors are still captured and request data is still attached when this is `false`.
71+
* Defaults to the client's tracing configuration (`hasSpansEnabled`).
72+
*/
73+
spans?: boolean;
74+
75+
/**
76+
* A hook that can be used to mutate the span for incoming requests.
77+
* This is triggered after the span is created, before the request handler runs.
78+
*/
79+
onSpanCreated?: (span: Span, request: Request) => void;
80+
81+
/**
82+
* A hook that can be used to mutate the span one last time before it ends.
83+
* For a streamed response, this runs when the stream finishes.
84+
* It is not called when the request handler throws.
85+
*/
86+
onSpanEnd?: (span: Span, request: Request, response: Response) => void;
4287
}
4388

4489
// Checks these options against the shared `ServerRequestOptions`. A new
@@ -49,30 +94,27 @@ type _ServerRequestOptionsCheck = AssertTrue<
4994
HttpServerIntegrationOptions,
5095
Request,
5196
Response,
52-
// Not supported yet.
53-
| 'spans'
54-
| 'ignoreIncomingRequests'
55-
| 'ignoreStaticAssets'
56-
| 'onSpanCreated'
57-
| 'onSpanEnd'
5897
// Not supported. A worker has no long-lived process to run the timer
5998
// that flushes session aggregates.
60-
| 'sessions'
61-
| 'sessionFlushingDelayMS'
99+
'sessions' | 'sessionFlushingDelayMS'
62100
>
63101
>;
64102

65-
interface HttpServerIntegrationInstance {
103+
interface HttpServerIntegrationInstance extends HttpServerIntegrationOptions {
66104
name: string;
67105
maxRequestBodySize: MaxRequestBodySize | undefined;
68-
ignoreRequestBody?: (url: string, request: Request) => boolean;
69106
}
70107

71108
const _httpServerIntegration = ((options: HttpServerIntegrationOptions = {}): HttpServerIntegrationInstance => {
72109
return {
73110
name: INTEGRATION_NAME,
74111
maxRequestBodySize: options.maxRequestBodySize,
75112
ignoreRequestBody: options.ignoreRequestBody,
113+
ignoreIncomingRequests: options.ignoreIncomingRequests,
114+
ignoreStaticAssets: options.ignoreStaticAssets ?? true,
115+
spans: options.spans,
116+
onSpanCreated: options.onSpanCreated,
117+
onSpanEnd: options.onSpanEnd,
76118
};
77119
}) satisfies IntegrationFn;
78120

@@ -122,10 +164,75 @@ export async function captureIncomingRequestBody(client: Client, request: Reques
122164
return;
123165
}
124166

125-
if (integration.ignoreRequestBody?.(request.url, request)) {
167+
const ignoreRequestBody = integration.ignoreRequestBody;
168+
if (
169+
ignoreRequestBody &&
170+
// If the filter throws, skip the body so it cannot leak data the filter was meant to hide.
171+
safeCallback(
172+
DEBUG_BUILD ? 'Error in `ignoreRequestBody`, skipping the body:' : '',
173+
() => ignoreRequestBody(request.url, request),
174+
() => true,
175+
)
176+
) {
126177
return;
127178
}
128179

129180
const isolationScope = getIsolationScope();
130181
await captureBodyFromWinterCGRequest(request, isolationScope, effectiveBodySize);
131182
}
183+
184+
/**
185+
* Whether to create a span for the incoming request, based on the HttpServer integration config.
186+
* Called internally by `wrapRequestHandler`.
187+
*/
188+
export function shouldCreateIncomingRequestSpan(client: Client | undefined, request: Request): boolean {
189+
if (!client) {
190+
return false;
191+
}
192+
193+
const integration = client.getIntegrationByName<HttpServerIntegrationInstance>(INTEGRATION_NAME);
194+
if (!(integration?.spans ?? hasSpansEnabled(client.getOptions()))) {
195+
return false;
196+
}
197+
198+
const urlObject = parseStringToURLObject(request.url);
199+
const urlPath = urlObject ? `${urlObject.pathname}${urlObject.search}` : '/';
200+
if (integration?.ignoreStaticAssets && request.method === 'GET' && isStaticAssetRequest(urlPath)) {
201+
return false;
202+
}
203+
204+
const ignoreIncomingRequests = integration?.ignoreIncomingRequests;
205+
return !(
206+
ignoreIncomingRequests &&
207+
safeCallback(
208+
DEBUG_BUILD ? 'Error in `ignoreIncomingRequests`, creating the span:' : '',
209+
() => ignoreIncomingRequests(urlPath, request),
210+
() => false,
211+
)
212+
);
213+
}
214+
215+
/**
216+
* The `onSpanCreated` and `onSpanEnd` hooks of the HttpServer integration, guarded so that a hook
217+
* that throws does not fail the request. Called internally by `wrapRequestHandler`.
218+
*/
219+
export function getIncomingRequestSpanHooks(client: Client | undefined): {
220+
onSpanCreated: (span: Span, request: Request) => void;
221+
onSpanEnd: (span: Span, request: Request, response: Response) => void;
222+
} {
223+
const integration = client?.getIntegrationByName<HttpServerIntegrationInstance>(INTEGRATION_NAME);
224+
return {
225+
onSpanCreated: (span, request) =>
226+
safeCallback(
227+
DEBUG_BUILD ? 'Error in `onSpanCreated`:' : '',
228+
() => integration?.onSpanCreated?.(span, request),
229+
() => undefined,
230+
),
231+
onSpanEnd: (span, request, response) =>
232+
safeCallback(
233+
DEBUG_BUILD ? 'Error in `onSpanEnd`:' : '',
234+
() => integration?.onSpanEnd?.(span, request, response),
235+
() => undefined,
236+
),
237+
};
238+
}

0 commit comments

Comments
 (0)