From 8e70a7862fc7ebe0ea07046639804543481ad4d8 Mon Sep 17 00:00:00 2001 From: isaacs Date: Tue, 20 Oct 2026 14:11:17 -0700 Subject: [PATCH] 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) --- CHANGELOG.md | 6 + .../integrations/http-server-spans/index.ts | 57 ++++ .../integrations/http-server-spans/test.ts | 71 +++++ .../http-server-spans/wrangler.jsonc | 6 + .../cloudflare/src/integrations/httpServer.ts | 133 ++++++++- .../src/wrapRequestHandlerWithInit.ts | 200 ++++++++------ packages/cloudflare/test/request.test.ts | 253 ++++++++++++++++++ 7 files changed, 628 insertions(+), 98 deletions(-) create mode 100644 dev-packages/cloudflare-integration-tests/suites/integrations/http-server-spans/index.ts create mode 100644 dev-packages/cloudflare-integration-tests/suites/integrations/http-server-spans/test.ts create mode 100644 dev-packages/cloudflare-integration-tests/suites/integrations/http-server-spans/wrangler.jsonc diff --git a/CHANGELOG.md b/CHANGELOG.md index ad51d92b462e..2978ea4dba0a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -63,6 +63,12 @@ Work in this release was contributed by @adityareddy-dev, @jpdefo, @Jxxunnn, @ph `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. +- **feat(cloudflare): Add incoming request span options to `httpServerIntegration`** + + 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`. + + `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. + ## 11.8.0 ### Important Changes diff --git a/dev-packages/cloudflare-integration-tests/suites/integrations/http-server-spans/index.ts b/dev-packages/cloudflare-integration-tests/suites/integrations/http-server-spans/index.ts new file mode 100644 index 000000000000..e0d6b4abab2e --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/integrations/http-server-spans/index.ts @@ -0,0 +1,57 @@ +import * as Sentry from '@sentry/cloudflare'; + +interface Env { + SENTRY_DSN: string; +} + +export default Sentry.withSentry( + (env: Env) => ({ + dsn: env.SENTRY_DSN, + tracesSampleRate: 1.0, + traceLifecycle: 'static', + integrations: [ + Sentry.httpServerIntegration({ + ignoreIncomingRequests: urlPath => urlPath.startsWith('/health'), + onSpanCreated: (span, request) => { + span.setAttribute('test.on_span_created', new URL(request.url).pathname); + }, + onSpanEnd: (span, _request, response) => { + span.setAttribute('test.on_span_end', response.status); + }, + }), + ], + }), + { + async fetch(request, _env, _ctx) { + const { pathname } = new URL(request.url); + + if (pathname === '/health') { + Sentry.captureMessage('Health check'); + return new Response('ok'); + } + + if (pathname === '/favicon.ico') { + Sentry.captureMessage('Static asset'); + return new Response('icon'); + } + + if (pathname === '/health/stream') { + // No span for this request. The message is captured after the handler returns, while the + // body streams, so it is only sent if the SDK waits for the stream to finish. + const encoder = new TextEncoder(); + const body = new ReadableStream({ + async start(controller) { + controller.enqueue(encoder.encode('a')); + await new Promise(resolve => setTimeout(resolve, 50)); + Sentry.captureMessage('Streamed after handler return'); + controller.enqueue(encoder.encode('b')); + controller.close(); + }, + }); + return new Response(body, { headers: { 'content-type': 'text/event-stream' } }); + } + + return new Response('ok', { status: 201 }); + }, + }, +); diff --git a/dev-packages/cloudflare-integration-tests/suites/integrations/http-server-spans/test.ts b/dev-packages/cloudflare-integration-tests/suites/integrations/http-server-spans/test.ts new file mode 100644 index 000000000000..35946ea9ac45 --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/integrations/http-server-spans/test.ts @@ -0,0 +1,71 @@ +import type { Envelope, Event } from '@sentry/core'; +import { expect, it } from 'vitest'; +import { createRunner } from '../../../runner'; + +function itemOf(envelope: Envelope): { type: string; event: Event } { + const [header, event] = envelope[1][0] as [{ type: string }, Event]; + return { type: header.type, event }; +} + +function messageEvent(message: string): (envelope: Envelope) => void { + return envelope => { + const { type, event } = itemOf(envelope); + expect(type).toBe('event'); + expect(event.message).toBe(message); + }; +} + +// The `onSpanCreated` and `onSpanEnd` hooks of the worker set these attributes. +function apiTransaction(envelope: Envelope): void { + const { type, event } = itemOf(envelope); + expect(type).toBe('transaction'); + expect(event.transaction).toBe('GET /api'); + expect(event.contexts?.trace?.data).toEqual( + expect.objectContaining({ + 'test.on_span_created': '/api', + 'test.on_span_end': 201, + }), + ); +} + +// Each test makes a request without a span, then a request with one. With `failOnUnexpected`, a +// transaction for the first request fails the test, as it arrives before the second one. + +it('does not create a span for a request that ignoreIncomingRequests matches', async ({ signal }) => { + const runner = createRunner(__dirname) + .unordered() + .failOnUnexpected() + .expect(messageEvent('Health check')) + .expect(apiTransaction) + .start(signal); + + await runner.makeRequest('get', '/health'); + await runner.makeRequest('get', '/api'); + await runner.completed(); +}); + +it('does not create a span for a static asset request by default', async ({ signal }) => { + const runner = createRunner(__dirname) + .unordered() + .failOnUnexpected() + .expect(messageEvent('Static asset')) + .expect(apiTransaction) + .start(signal); + + await runner.makeRequest('get', '/favicon.ico'); + await runner.makeRequest('get', '/api'); + await runner.completed(); +}); + +it('sends events from a streamed response without a span after the stream finishes', async ({ signal }) => { + const runner = createRunner(__dirname) + .unordered() + .failOnUnexpected() + .expect(messageEvent('Streamed after handler return')) + .expect(apiTransaction) + .start(signal); + + expect(await runner.makeRequest('get', '/health/stream')).toBe('ab'); + await runner.makeRequest('get', '/api'); + await runner.completed(); +}); diff --git a/dev-packages/cloudflare-integration-tests/suites/integrations/http-server-spans/wrangler.jsonc b/dev-packages/cloudflare-integration-tests/suites/integrations/http-server-spans/wrangler.jsonc new file mode 100644 index 000000000000..d6be01281f0c --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/integrations/http-server-spans/wrangler.jsonc @@ -0,0 +1,6 @@ +{ + "name": "worker-name", + "compatibility_date": "2025-06-17", + "main": "index.ts", + "compatibility_flags": ["nodejs_compat"], +} diff --git a/packages/cloudflare/src/integrations/httpServer.ts b/packages/cloudflare/src/integrations/httpServer.ts index 7fa3cd7ce30b..1975f168819e 100644 --- a/packages/cloudflare/src/integrations/httpServer.ts +++ b/packages/cloudflare/src/integrations/httpServer.ts @@ -1,6 +1,15 @@ -import type { Client, IntegrationFn, MaxRequestBodySize } from '@sentry/core'; -import { captureBodyFromWinterCGRequest, defineIntegration, getIsolationScope } from '@sentry/core'; +import type { Client, IntegrationFn, MaxRequestBodySize, Span } from '@sentry/core'; +import { + captureBodyFromWinterCGRequest, + defineIntegration, + getIsolationScope, + hasSpansEnabled, + parseStringToURLObject, + safeCallback, +} from '@sentry/core'; import type { AssertTrue, CheckServerRequestOptions } from '@sentry/core/server'; +import { isStaticAssetRequest } from '@sentry/core/server'; +import { DEBUG_BUILD } from '../debug-build'; const INTEGRATION_NAME = 'HttpServer' as const; @@ -39,6 +48,42 @@ export interface HttpServerIntegrationOptions { * ``` */ ignoreRequestBody?: (url: string, request: Request) => boolean; + + /** + * Do not create spans for incoming requests whose URL path makes the given callback return `true`. + * + * `urlPath` is the path of the request, including the query string. `request` is the incoming + * {@link Request}. Errors thrown by the handler are still captured. + */ + ignoreIncomingRequests?: (urlPath: string, request: Request) => boolean; + + /** + * Whether to automatically ignore common static asset requests (favicon.ico, robots.txt, etc.) + * when creating spans. + * + * @default `true` + */ + ignoreStaticAssets?: boolean; + + /** + * Whether to create spans for incoming requests. + * Errors are still captured and request data is still attached when this is `false`. + * Defaults to the client's tracing configuration (`hasSpansEnabled`). + */ + spans?: boolean; + + /** + * A hook that can be used to mutate the span for incoming requests. + * This is triggered after the span is created, before the request handler runs. + */ + onSpanCreated?: (span: Span, request: Request) => void; + + /** + * A hook that can be used to mutate the span one last time before it ends. + * For a streamed response, this runs when the stream finishes. + * It is not called when the request handler throws. + */ + onSpanEnd?: (span: Span, request: Request, response: Response) => void; } // Checks these options against the shared `ServerRequestOptions`. A new @@ -49,23 +94,15 @@ type _ServerRequestOptionsCheck = AssertTrue< HttpServerIntegrationOptions, Request, Response, - // Not supported yet. - | 'spans' - | 'ignoreIncomingRequests' - | 'ignoreStaticAssets' - | 'onSpanCreated' - | 'onSpanEnd' // Not supported. A worker has no long-lived process to run the timer // that flushes session aggregates. - | 'sessions' - | 'sessionFlushingDelayMS' + 'sessions' | 'sessionFlushingDelayMS' > >; -interface HttpServerIntegrationInstance { +interface HttpServerIntegrationInstance extends HttpServerIntegrationOptions { name: string; maxRequestBodySize: MaxRequestBodySize | undefined; - ignoreRequestBody?: (url: string, request: Request) => boolean; } const _httpServerIntegration = ((options: HttpServerIntegrationOptions = {}): HttpServerIntegrationInstance => { @@ -73,6 +110,11 @@ const _httpServerIntegration = ((options: HttpServerIntegrationOptions = {}): Ht name: INTEGRATION_NAME, maxRequestBodySize: options.maxRequestBodySize, ignoreRequestBody: options.ignoreRequestBody, + ignoreIncomingRequests: options.ignoreIncomingRequests, + ignoreStaticAssets: options.ignoreStaticAssets ?? true, + spans: options.spans, + onSpanCreated: options.onSpanCreated, + onSpanEnd: options.onSpanEnd, }; }) satisfies IntegrationFn; @@ -122,10 +164,75 @@ export async function captureIncomingRequestBody(client: Client, request: Reques return; } - if (integration.ignoreRequestBody?.(request.url, request)) { + const ignoreRequestBody = integration.ignoreRequestBody; + if ( + ignoreRequestBody && + // If the filter throws, skip the body so it cannot leak data the filter was meant to hide. + safeCallback( + DEBUG_BUILD ? 'Error in `ignoreRequestBody`, skipping the body:' : '', + () => ignoreRequestBody(request.url, request), + () => true, + ) + ) { return; } const isolationScope = getIsolationScope(); await captureBodyFromWinterCGRequest(request, isolationScope, effectiveBodySize); } + +/** + * Whether to create a span for the incoming request, based on the HttpServer integration config. + * Called internally by `wrapRequestHandler`. + */ +export function shouldCreateIncomingRequestSpan(client: Client | undefined, request: Request): boolean { + if (!client) { + return false; + } + + const integration = client.getIntegrationByName(INTEGRATION_NAME); + if (!(integration?.spans ?? hasSpansEnabled(client.getOptions()))) { + return false; + } + + const urlObject = parseStringToURLObject(request.url); + const urlPath = urlObject ? `${urlObject.pathname}${urlObject.search}` : '/'; + if (integration?.ignoreStaticAssets && request.method === 'GET' && isStaticAssetRequest(urlPath)) { + return false; + } + + const ignoreIncomingRequests = integration?.ignoreIncomingRequests; + return !( + ignoreIncomingRequests && + safeCallback( + DEBUG_BUILD ? 'Error in `ignoreIncomingRequests`, creating the span:' : '', + () => ignoreIncomingRequests(urlPath, request), + () => false, + ) + ); +} + +/** + * The `onSpanCreated` and `onSpanEnd` hooks of the HttpServer integration, guarded so that a hook + * that throws does not fail the request. Called internally by `wrapRequestHandler`. + */ +export function getIncomingRequestSpanHooks(client: Client | undefined): { + onSpanCreated: (span: Span, request: Request) => void; + onSpanEnd: (span: Span, request: Request, response: Response) => void; +} { + const integration = client?.getIntegrationByName(INTEGRATION_NAME); + return { + onSpanCreated: (span, request) => + safeCallback( + DEBUG_BUILD ? 'Error in `onSpanCreated`:' : '', + () => integration?.onSpanCreated?.(span, request), + () => undefined, + ), + onSpanEnd: (span, request, response) => + safeCallback( + DEBUG_BUILD ? 'Error in `onSpanEnd`:' : '', + () => integration?.onSpanEnd?.(span, request, response), + () => undefined, + ), + }; +} diff --git a/packages/cloudflare/src/wrapRequestHandlerWithInit.ts b/packages/cloudflare/src/wrapRequestHandlerWithInit.ts index 71444c9a87e3..e852ad1cf661 100644 --- a/packages/cloudflare/src/wrapRequestHandlerWithInit.ts +++ b/packages/cloudflare/src/wrapRequestHandlerWithInit.ts @@ -6,6 +6,7 @@ import { SENTRY_SEGMENT_NAME_SOURCE, } from '@sentry/conventions/attributes'; import { HTTP_SERVER } from '@sentry/conventions/op'; +import type { Span } from '@sentry/core'; import { captureException, continueTrace, @@ -21,7 +22,11 @@ import { } from '@sentry/core'; import { classifyResponseStreaming } from '@sentry/core/server'; import { DEBUG_BUILD } from './debug-build'; -import { captureIncomingRequestBody } from './integrations/httpServer'; +import { + captureIncomingRequestBody, + getIncomingRequestSpanHooks, + shouldCreateIncomingRequestSpan, +} from './integrations/httpServer'; import { flushDeferredChannelEvents } from './orchestrion-deferred-channels'; import type { CloudflareClient, CloudflareOptions } from './client'; import type { ExecutionContextCompat } from './executionContext'; @@ -155,98 +160,123 @@ export function wrapRequestHandlerWithInit( await captureIncomingRequestBody(client, request); } + const { onSpanCreated, onSpanEnd } = getIncomingRequestSpanHooks(client); + + // Runs the handler. Ends the span (if any) and flushes when the response is done. + const respond = async (span: Span | undefined): Promise => { + let res: Response; + + try { + res = await handler(); + if (span) { + setHttpStatus(span, res.status); + } + + // After the handler runs, the span name might have been updated by nested instrumentation + // (e.g., Remix parameterizing routes). The span should already have the correct name + // from that instrumentation, so we don't need to do anything here. + } catch (e) { + span?.end(); + if (captureErrors) { + captureException(e, { mechanism: { handled: false, type: errorMechanismType } }); + } + waitUntil?.(flushAndDispose(client)); + throw e; + } + + let ended = false; + const endSpan = (): void => { + if (ended) return; + + ended = true; + if (span) { + onSpanEnd(span, request, res); + span.end(); + } + }; + + const userClassification = safeCallback( + DEBUG_BUILD ? 'Error in `isStreamingResponse`, using the default classification:' : '', + () => options.isStreamingResponse?.(res, request), + () => undefined, + ); + // An async callback returns a promise, which would otherwise count as `true`. + const isStreaming = + typeof userClassification === 'boolean' ? userClassification : classifyResponseStreaming(res).isStreaming; + + if (isStreaming && res.body) { + try { + let transformerUsed = false; + + const endSpanOnce = (): void => { + if (ended) return; + + endSpan(); + 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(); + }, + cancel() { + // Client disconnected (or downstream cancelled). The `cancel` + // is being called while the response is still considered + // active, so this is a safe place to end the span. + endSpanOnce(); + }, + }); + + 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 { + // Falls back to ending the span at handler return. + } + } + + // Non-streaming response - end span immediately and return original + endSpan(); + + // Don't dispose for protocol upgrades (101 Switching Protocols) - the connection stays alive. + // This includes WebSocket upgrades where webSocketMessage/webSocketClose handlers + // will still be called and may need the client to capture errors. + if (res.status === 101) { + waitUntil?.(client?.flush(2000)); + } else { + waitUntil?.(flushAndDispose(client)); + } + return res; + }; + + const createSpan = shouldCreateIncomingRequestSpan(client, request); + return continueTrace( { sentryTrace: request.headers.get('sentry-trace') || '', baggage: request.headers.get('baggage') }, () => { + if (!createSpan) { + return respond(undefined); + } + // Note: This span will not have a duration unless I/O happens in the handler. This is // because of how the cloudflare workers runtime works. // See: https://developers.cloudflare.com/workers/runtime-apis/performance/ // Use startSpanManual to control when span ends (needed for streaming responses) - return startSpanManual({ name, attributes }, async span => { - let res: Response; - - try { - res = await handler(); - setHttpStatus(span, res.status); - - // After the handler runs, the span name might have been updated by nested instrumentation - // (e.g., Remix parameterizing routes). The span should already have the correct name - // from that instrumentation, so we don't need to do anything here. - } catch (e) { - span.end(); - if (captureErrors) { - captureException(e, { mechanism: { handled: false, type: errorMechanismType } }); - } - waitUntil?.(flushAndDispose(client)); - throw e; - } - - const userClassification = safeCallback( - DEBUG_BUILD ? 'Error in `isStreamingResponse`, using the default classification:' : '', - () => options.isStreamingResponse?.(res, request), - () => undefined, - ); - // An async callback returns a promise, which would otherwise count as `true`. - const isStreaming = - typeof userClassification === 'boolean' ? userClassification : classifyResponseStreaming(res).isStreaming; - - if (isStreaming && res.body) { - try { - let ended = false; - let transformerUsed = false; - - const endSpanOnce = (): void => { - if (ended) return; - - ended = true; - span.end(); - 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(); - }, - cancel() { - // Client disconnected (or downstream cancelled). The `cancel` - // is being called while the response is still considered - // active, so this is a safe place to end the span. - endSpanOnce(); - }, - }); - - 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 { - // Falls back to ending the span at handler return. - } - } - - // Non-streaming response - end span immediately and return original - span.end(); - - // Don't dispose for protocol upgrades (101 Switching Protocols) - the connection stays alive. - // This includes WebSocket upgrades where webSocketMessage/webSocketClose handlers - // will still be called and may need the client to capture errors. - if (res.status === 101) { - waitUntil?.(client?.flush(2000)); - } else { - waitUntil?.(flushAndDispose(client)); - } - return res; + return startSpanManual({ name, attributes }, span => { + onSpanCreated(span, request); + return respond(span); }); }, ); diff --git a/packages/cloudflare/test/request.test.ts b/packages/cloudflare/test/request.test.ts index a8c9a3f735f0..972180c62a8c 100644 --- a/packages/cloudflare/test/request.test.ts +++ b/packages/cloudflare/test/request.test.ts @@ -356,6 +356,45 @@ describe('withSentry', () => { expect(sentryEvent.sdkProcessingMetadata?.normalizedRequest?.data).toEqual(JSON.stringify({ key: 'value' })); }); + test.each([ + ['matches', () => true], + [ + 'throws', + () => { + throw new Error('filter failed'); + }, + ], + ])('does not capture the body when ignoreRequestBody %s', async (_label, ignoreRequestBody) => { + let sentryEvent: Event = {}; + + const response = await wrapRequestHandler( + { + options: { + ...MOCK_OPTIONS, + integrations: [httpServerIntegration({ ignoreRequestBody })], + beforeSend(event) { + sentryEvent = event; + return null; + }, + }, + request: new Request('https://example.com/login', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ password: 'secret' }), + }), + context: createMockExecutionContext(), + }, + () => { + SentryCore.captureMessage('request body'); + return new Response('test'); + }, + ); + + expect(await response.text()).toBe('test'); + expect(sentryEvent.message).toBe('request body'); + expect(sentryEvent.sdkProcessingMetadata?.normalizedRequest?.data).toBeUndefined(); + }); + test('does not capture cookies when dataCollection.cookies is disabled', async () => { let sentryEvent: Event = {}; @@ -649,6 +688,220 @@ describe('withSentry', () => { }); }); }); + + describe('httpServerIntegration span options', () => { + // Whether a span is active in the handler. + async function hasActiveSpan( + options: Partial, + url = 'https://example.com/users?id=1', + init?: RequestInit, + ): Promise { + let active = false; + const response = await wrapRequestHandler( + { + options: { ...MOCK_OPTIONS, tracesSampleRate: 1, ...options }, + request: new Request(url, init), + context: createMockExecutionContext(), + }, + () => { + active = !!SentryCore.getActiveSpan(); + return new Response('ok'); + }, + ); + expect(await response.text()).toBe('ok'); + return active; + } + + test('creates a span by default', async () => { + expect(await hasActiveSpan({})).toBe(true); + }); + + test('does not create spans for requests that ignoreIncomingRequests matches', async () => { + const calls: [string, string][] = []; + const integrations = [ + httpServerIntegration({ + ignoreIncomingRequests: (urlPath, request) => { + calls.push([urlPath, request.method]); + return urlPath.startsWith('/health'); + }, + }), + ]; + + expect(await hasActiveSpan({ integrations }, 'https://example.com/health?probe=1')).toBe(false); + expect(await hasActiveSpan({ integrations }, 'https://example.com/users')).toBe(true); + expect(calls).toEqual([ + ['/health?probe=1', 'GET'], + ['/users', 'GET'], + ]); + }); + + test('creates the span when ignoreIncomingRequests throws', async () => { + const integrations = [ + httpServerIntegration({ + ignoreIncomingRequests: () => { + throw new Error('filter failed'); + }, + }), + ]; + + expect(await hasActiveSpan({ integrations })).toBe(true); + }); + + test('does not create spans for GET requests to static assets by default', async () => { + expect(await hasActiveSpan({}, 'https://example.com/favicon.ico')).toBe(false); + expect(await hasActiveSpan({}, 'https://example.com/assets/app.js')).toBe(false); + expect(await hasActiveSpan({}, 'https://example.com/favicon.ico', { method: 'POST', body: 'x' })).toBe(true); + }); + + test('creates spans for static assets when ignoreStaticAssets is false', async () => { + const integrations = [httpServerIntegration({ ignoreStaticAssets: false })]; + + expect(await hasActiveSpan({ integrations }, 'https://example.com/favicon.ico')).toBe(true); + }); + + test('does not create spans when tracing is disabled', async () => { + expect(await hasActiveSpan({ tracesSampleRate: undefined })).toBe(false); + }); + + test('does not create spans when spans is false, but still captures errors and continues the trace', async () => { + const events: Event[] = []; + const request = new Request('https://example.com/users'); + request.headers.set('sentry-trace', '12312012123120121231201212312012-1121201211212012-1'); + const error = new Error('handler failed'); + + await expect( + wrapRequestHandler( + { + options: { + ...MOCK_OPTIONS, + tracesSampleRate: 1, + integrations: [httpServerIntegration({ spans: false })], + beforeSend(event) { + events.push(event); + return null; + }, + }, + request, + context: createMockExecutionContext(), + }, + () => { + expect(SentryCore.getActiveSpan()).toBeUndefined(); + throw error; + }, + ), + ).rejects.toBe(error); + + expect(events).toHaveLength(1); + expect(events[0]?.exception?.values?.[0]?.mechanism).toEqual({ handled: false, type: 'auto.http.cloudflare' }); + expect(events[0]?.contexts?.trace?.trace_id).toBe('12312012123120121231201212312012'); + }); + + test('calls onSpanCreated and onSpanEnd', async () => { + const calls: string[] = []; + let transaction: Event | undefined; + + await wrapRequestHandler( + { + options: { + ...MOCK_OPTIONS, + tracesSampleRate: 1, + integrations: [ + httpServerIntegration({ + onSpanCreated: (span, request) => { + calls.push(`created ${request.method} ${new URL(request.url).pathname}`); + span.setAttribute('onSpanCreated', 'yes'); + }, + onSpanEnd: (span, request, response) => { + calls.push(`end ${request.method} ${response.status}`); + span.setAttribute('onSpanEnd', response.status); + }, + }), + ], + beforeSendTransaction(event) { + transaction = event; + return null; + }, + }, + request: new Request('https://example.com/users'), + context: createMockExecutionContext(), + }, + () => { + calls.push('handler'); + return new Response('ok', { status: 201 }); + }, + ).then(response => response.text()); + + await vi.waitFor(() => expect(transaction).toBeDefined()); + expect(calls).toEqual(['created GET /users', 'handler', 'end GET 201']); + expect(transaction?.contexts?.trace?.data).toEqual( + expect.objectContaining({ onSpanCreated: 'yes', onSpanEnd: 201 }), + ); + }); + + test('calls onSpanEnd when a streamed response finishes', async () => { + const onSpanEnd = vi.fn(); + let controller: ReadableStreamDefaultController; + const source = new ReadableStream({ + start(value) { + controller = value; + }, + }); + + const response = await wrapRequestHandler( + { + options: { ...MOCK_OPTIONS, tracesSampleRate: 1, integrations: [httpServerIntegration({ onSpanEnd })] }, + request: new Request('https://example.com/events'), + context: createMockExecutionContext(), + }, + () => new Response(source, { headers: { 'content-type': 'text/event-stream' } }), + ); + + controller!.enqueue(new TextEncoder().encode('data: one\n\n')); + expect(onSpanEnd).not.toHaveBeenCalled(); + + controller!.close(); + expect(await response.text()).toBe('data: one\n\n'); + expect(onSpanEnd).toHaveBeenCalledTimes(1); + expect(onSpanEnd).toHaveBeenCalledWith(expect.anything(), expect.any(Request), expect.any(Response)); + }); + + test.each(['onSpanCreated', 'onSpanEnd'] as const)('still responds when %s throws', async hook => { + const integrations = [ + httpServerIntegration({ + [hook]: () => { + throw new Error(`${hook} failed`); + }, + }), + ]; + + expect(await hasActiveSpan({ integrations })).toBe(true); + }); + + test('flushes after a streamed response finishes when no span is created', async () => { + const context = createMockExecutionContext(); + const waitUntilSpy = vi.spyOn(context, 'waitUntil'); + let controller: ReadableStreamDefaultController; + const source = new ReadableStream({ + start(value) { + controller = value; + }, + }); + + const response = await wrapRequestHandler( + { + options: { ...MOCK_OPTIONS, tracesSampleRate: undefined }, + request: new Request('https://example.com/events'), + context, + }, + () => new Response(source, { headers: { 'content-type': 'text/event-stream' } }), + ); + expect(waitUntilSpy).not.toHaveBeenCalled(); + + controller!.close(); + await response.text(); + expect(waitUntilSpy).toHaveBeenCalledTimes(1); + }); + }); }); function createMockExecutionContext(): ExecutionContext {