diff --git a/CHANGELOG.md b/CHANGELOG.md index 2af7c74fcaa7..a63d5b321595 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,14 @@ Work in this release was contributed by @faizansaiyed123, @itisrmk, @kunal649, @kyrylolvov and @seongwon030. Thank you for your contributions! +### Important Changes + +- **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.9.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..ce625f75cd49 --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/integrations/http-server-spans/index.ts @@ -0,0 +1,41 @@ +import * as Sentry from '@sentry/cloudflare'; + +interface Env { + SENTRY_DSN: string; +} + +// A plain worker. The `@sentry/cloudflare/vite` plugin wraps it with `withSentry` at build time, +// with the options from `instrument.server.ts`. +export default { + async fetch(request: Request): Promise { + 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 }); + }, +} satisfies ExportedHandler; diff --git a/dev-packages/cloudflare-integration-tests/suites/integrations/http-server-spans/instrument.server.ts b/dev-packages/cloudflare-integration-tests/suites/integrations/http-server-spans/instrument.server.ts new file mode 100644 index 000000000000..95122b1cba8a --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/integrations/http-server-spans/instrument.server.ts @@ -0,0 +1,18 @@ +import { defineCloudflareOptions, httpServerIntegration } from '@sentry/cloudflare'; + +export default defineCloudflareOptions((env: { SENTRY_DSN: string }) => ({ + dsn: env.SENTRY_DSN, + tracesSampleRate: 1.0, + traceLifecycle: 'static', + integrations: [ + 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); + }, + }), + ], +})); 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/vite.config.mts b/dev-packages/cloudflare-integration-tests/suites/integrations/http-server-spans/vite.config.mts new file mode 100644 index 000000000000..005f4448f6cb --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/integrations/http-server-spans/vite.config.mts @@ -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()], +}); 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..790abe9762d8 --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/integrations/http-server-spans/wrangler.jsonc @@ -0,0 +1,9 @@ +{ + "$schema": "../../../node_modules/wrangler/config-schema.json", + "name": "cloudflare-integrations-http-server-spans", + // `main` points at the source entry. The Sentry Vite plugin builds from it, and the runner serves + // the built output. + "main": "index.ts", + "compatibility_date": "2025-06-17", + "compatibility_flags": ["nodejs_compat"], +} diff --git a/dev-packages/cloudflare-integration-tests/suites/public-api/metrics/server-address/test.ts b/dev-packages/cloudflare-integration-tests/suites/public-api/metrics/server-address/test.ts index 013bf552d772..12cf754e08b6 100644 --- a/dev-packages/cloudflare-integration-tests/suites/public-api/metrics/server-address/test.ts +++ b/dev-packages/cloudflare-integration-tests/suites/public-api/metrics/server-address/test.ts @@ -12,7 +12,6 @@ it('should add server.address attribute to metrics when serverName is set', asyn name: 'test.counter', type: 'counter', value: 1, - span_id: expect.any(String), timestamp: expect.any(Number), trace_id: expect.any(String), attributes: { 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 {