From 685d415137514deff8d50f9eca7b083e8011c6b4 Mon Sep 17 00:00:00 2001 From: isaacs Date: Tue, 20 Oct 2026 13:39:18 -0700 Subject: [PATCH 01/12] ref(core): Add shared `ServerRequestOptions` type across runtimes Node, Deno, Bun and Cloudflare each have their own options for incoming HTTP requests, and the option sets drift. Bun.serve takes only `maxRequestBodySize`, and Cloudflare has no span options. When we add an option to one runtime, nothing reminds us to add it to the others. `ServerRequestOptions` in `@sentry/core/server` lists the options that every incoming request integration should support. Each runtime options type is checked against it with `CheckServerRequestOptions`. The check lists the options that the runtime does not support yet, with a comment that says why. It fails to compile when a shared option is neither declared nor listed, when a listed option is declared, or when a callback written for the shared type does not fit the runtime option. The runtime types do not extend the shared type. With `extends`, a new shared option would appear in every runtime type, supported or not, and nothing would fail to compile. Each runtime keeps its own declarations and docs. Node and Bun now type the request in `ignoreRequestBody` as `RequestOptions & HttpIncomingMessage`. The value was always an `IncomingMessage`. Callbacks typed with `RequestOptions` still compile. The `RequestOptions` part is deprecated, for removal in v12. A new BUGBOT rule flags an incoming request option that applies to more than one runtime but is not in the shared type. There is no runtime behavior change. Fixes JS-3968 Fixes #25570 Co-Authored-By: Claude Opus 5.5 (1M context) --- .cursor/BUGBOT.md | 1 + .../bun/src/integrations/bunHttpServer.ts | 20 ++- packages/bun/src/integrations/bunserver.ts | 22 +++ .../cloudflare/src/integrations/httpServer.ts | 22 +++ .../http/server-request-options.ts | 128 ++++++++++++++++++ packages/core/src/server.ts | 5 + packages/deno/src/integrations/deno-serve.ts | 6 + packages/deno/src/integrations/http.ts | 15 +- .../http/httpServerIntegration.ts | 14 +- packages/node/src/integrations/http/index.ts | 19 +++ 10 files changed, 240 insertions(+), 12 deletions(-) create mode 100644 packages/core/src/integrations/http/server-request-options.ts diff --git a/.cursor/BUGBOT.md b/.cursor/BUGBOT.md index 8212b1b8489e..c0db9f483eb2 100644 --- a/.cursor/BUGBOT.md +++ b/.cursor/BUGBOT.md @@ -78,6 +78,7 @@ Keep reviews high-signal. Prefer actionable, high-confidence findings over specu - What to do instead: Use an existing reference to the scope or client. For example, this is possible in most `Integration` hooks. - Flag hard-coded attribute names and span ops (e.g. `'sentry.origin'`, `'sentry.op'`, `'http.request.method'`, `'db.system.name'`) when `@sentry/conventions` exports a constant for them. Use the constant from `@sentry/conventions/attributes` (e.g. `SENTRY_ORIGIN`, `SENTRY_OP`) or `@sentry/conventions/op` instead. Do not flag values that have no constant in `@sentry/conventions`. - Flag unnecessary `span.setAttribute(s)` calls: If data is already available at span start, it must be set via the `attributes` option of `startSpan`, `startSpanManual`, `startInactiveSpan` or `startIdleSpan` calls. This ensures that as much context as possible is available when `tracesSampler` or `ignoreSpans` SDK options are applied. If a `span.setAttribute(s)` call happens at a later time than right after span start and the attribute value can only be computed at that time, do not flag it. +- Flag a new option on an integration for incoming HTTP requests that is not in `ServerRequestOptions` (`packages/core/src/integrations/http/server-request-options.ts`), when it applies to more than one runtime. These integrations are Node `httpIntegration`, Deno `denoHttpIntegration` and `denoServeIntegration`, Bun `bunHttpServerIntegration` and `bunServerIntegration`, and Cloudflare `httpServerIntegration`. Add the option to `ServerRequestOptions`, then support it in each runtime or list it as unsupported in that runtime's `CheckServerRequestOptions` check. Also flag a runtime option that matches a shared option in purpose but has a different name or meaning. ### Code quality diff --git a/packages/bun/src/integrations/bunHttpServer.ts b/packages/bun/src/integrations/bunHttpServer.ts index f21d47e93f67..bb423dc5773d 100644 --- a/packages/bun/src/integrations/bunHttpServer.ts +++ b/packages/bun/src/integrations/bunHttpServer.ts @@ -3,7 +3,12 @@ import * as http from 'node:http'; import * as https from 'node:https'; import type { IntegrationFn, Span } from '@sentry/core'; import { defineIntegration } from '@sentry/core'; -import type { HttpIncomingMessage, HttpServerResponse } from '@sentry/core/server'; +import type { + AssertTrue, + CheckServerRequestOptions, + HttpIncomingMessage, + HttpServerResponse, +} from '@sentry/core/server'; import { getHttpServerSubscriptions, HTTP_ON_SERVER_REQUEST } from '@sentry/core/server'; const INTEGRATION_NAME = 'BunHttpServer' as const; @@ -37,8 +42,12 @@ interface BunHttpServerOptions { /** * Do not capture the request body for incoming HTTP requests to URLs where the given callback returns `true`. + * + * The `request` parameter is the incoming `node:http` `IncomingMessage`. The `RequestOptions` part of the type is + * deprecated and will be removed in v12. Its fields that `IncomingMessage` does not have (such as `path` or + * `hostname`) are always `undefined`. */ - ignoreRequestBody?: (url: string, request: http.RequestOptions) => boolean; + ignoreRequestBody?: (url: string, request: http.RequestOptions & HttpIncomingMessage) => boolean; /** * Controls the maximum size of incoming HTTP request bodies attached to events. @@ -73,6 +82,13 @@ interface BunHttpServerOptions { onSpanEnd?: (span: Span, request: HttpIncomingMessage, response: HttpServerResponse) => void; } +// Checks these options against the shared `ServerRequestOptions`. A new +// shared field fails to compile here, until this integration supports +// it or lists it as unsupported. +type _ServerRequestOptionsCheck = AssertTrue< + CheckServerRequestOptions +>; + let hasPatched = false; const _bunHttpServerIntegration = ((options: BunHttpServerOptions = {}) => { diff --git a/packages/bun/src/integrations/bunserver.ts b/packages/bun/src/integrations/bunserver.ts index 1bd340703aec..46ff0fc30e25 100644 --- a/packages/bun/src/integrations/bunserver.ts +++ b/packages/bun/src/integrations/bunserver.ts @@ -1,3 +1,4 @@ +/* eslint-disable max-lines */ import type { Integration, IntegrationFn, MaxRequestBodySize, SpanAttributes } from '@sentry/core'; import { captureBodyFromWinterCGRequest, @@ -20,6 +21,7 @@ import { filterCollectedUrl, filterCollectedUrlQuery, } from '@sentry/core'; +import type { AssertTrue, CheckServerRequestOptions } from '@sentry/core/server'; import { classifyResponseStreaming, getClientIPAddress } from '@sentry/core/server'; import type { Server, ServeOptions } from 'bun'; import { @@ -55,6 +57,26 @@ export type BunServerIntegrationOptions = { maxRequestBodySize?: MaxRequestBodySize; }; +// Checks these options against the shared `ServerRequestOptions`. A new +// shared field fails to compile here, until this integration supports +// it or lists it as unsupported. +type _ServerRequestOptionsCheck = AssertTrue< + CheckServerRequestOptions< + BunServerIntegrationOptions, + Request, + Response, + // Not supported yet. + | 'spans' + | 'sessions' + | 'sessionFlushingDelayMS' + | 'ignoreRequestBody' + | 'ignoreIncomingRequests' + | 'ignoreStaticAssets' + | 'onSpanCreated' + | 'onSpanEnd' + > +>; + const _bunServerIntegration = ((options: BunServerIntegrationOptions = {}) => { return { name: INTEGRATION_NAME, diff --git a/packages/cloudflare/src/integrations/httpServer.ts b/packages/cloudflare/src/integrations/httpServer.ts index 97e960f916d2..7fa3cd7ce30b 100644 --- a/packages/cloudflare/src/integrations/httpServer.ts +++ b/packages/cloudflare/src/integrations/httpServer.ts @@ -1,5 +1,6 @@ import type { Client, IntegrationFn, MaxRequestBodySize } from '@sentry/core'; import { captureBodyFromWinterCGRequest, defineIntegration, getIsolationScope } from '@sentry/core'; +import type { AssertTrue, CheckServerRequestOptions } from '@sentry/core/server'; const INTEGRATION_NAME = 'HttpServer' as const; @@ -40,6 +41,27 @@ export interface HttpServerIntegrationOptions { ignoreRequestBody?: (url: string, request: Request) => boolean; } +// Checks these options against the shared `ServerRequestOptions`. A new +// shared field fails to compile here, until this integration supports +// it or lists it as unsupported. +type _ServerRequestOptionsCheck = AssertTrue< + CheckServerRequestOptions< + 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' + > +>; + interface HttpServerIntegrationInstance { name: string; maxRequestBodySize: MaxRequestBodySize | undefined; diff --git a/packages/core/src/integrations/http/server-request-options.ts b/packages/core/src/integrations/http/server-request-options.ts new file mode 100644 index 000000000000..7125978ad603 --- /dev/null +++ b/packages/core/src/integrations/http/server-request-options.ts @@ -0,0 +1,128 @@ +import type { Span } from '../../types/span'; +import type { MaxRequestBodySize } from '../../utils/request'; + +/** + * Options that every integration for incoming HTTP requests should + * support, in each runtime. + * + * `TRequest` and `TResponse` are the request and response types that the + * runtime passes to callbacks: `HttpIncomingMessage` and + * `HttpServerResponse` for `node:http`, or the web `Request` and + * `Response` for `Deno.serve`, `Bun.serve` and Cloudflare. + * + * Runtime options types do not extend this type. Each one declares its + * own fields, with docs for that runtime, and uses + * {@link CheckServerRequestOptions} to check them against this type. + * Then a new field here fails to compile in each runtime, until the + * runtime supports it or lists it as unsupported. + */ +export interface ServerRequestOptions { + /** + * Whether to create spans for incoming requests. + * Defaults to the client's tracing configuration. + */ + spans?: boolean; + + /** + * Whether to create [Sessions](https://docs.sentry.io/product/releases/health/#sessions) for incoming + * requests, to track the health and crash-free rate of releases. + * + * @default `true` + */ + sessions?: boolean; + + /** + * Number of milliseconds until sessions are flushed as a session aggregate. + * + * @default `60000` (60s) + */ + sessionFlushingDelayMS?: number; + + /** + * Controls the maximum size of incoming request bodies attached to events. + * + * @default 'medium' + */ + maxRequestBodySize?: MaxRequestBodySize; + + /** + * Do not capture the request body for incoming requests where the given callback returns `true`. + * + * `url` is the full URL of the request, including the query string. + */ + ignoreRequestBody?: (url: string, request: TRequest) => boolean; + + /** + * Do not create spans for incoming requests where the given callback returns `true`. + * + * `urlPath` is the path of the request, including the query string. + */ + ignoreIncomingRequests?: (urlPath: string, request: TRequest) => boolean; + + /** + * Whether to ignore common static asset requests (favicon.ico, robots.txt, etc.) + * when creating spans. + * + * @default `true` + */ + ignoreStaticAssets?: boolean; + + /** + * A hook to change the span for an incoming request, after the span is created. + */ + onSpanCreated?: (span: Span, request: TRequest) => void; + + /** + * A hook to change the span for an incoming request one last time, before it ends. + */ + onSpanEnd?: (span: Span, request: TRequest, response: TResponse) => void; +} + +/** + * `true` when `TOptions` matches {@link ServerRequestOptions}, else a + * string that names the problem. Use it with a type that accepts only + * `true`, so a mismatch fails to compile: + * + * ```ts + * type _Check = AssertTrue>; + * ``` + * + * `TUnsupported` lists the fields that the runtime does not support. + * + * The check fails when: + * - `TOptions` does not declare a field, and `TUnsupported` does not + * list it. + * - `TUnsupported` lists a field that `TOptions` declares. + * - A callback written for the shared type does not fit the field in + * `TOptions`. + */ +export type CheckServerRequestOptions< + TOptions, + TRequest, + TResponse, + TUnsupported extends keyof ServerRequestOptions = never, +> = [MissingFields] extends [never] + ? [Extract] extends [never] + ? SupportedFields< + ServerRequestOptions, + TRequest, + TResponse, + TUnsupported + > extends SupportedFields + ? true + : 'A field type does not match ServerRequestOptions' + : `Listed as unsupported, but declared: ${Extract & string}` + : `Missing field: ${MissingFields & string}`; + +type MissingFields = Exclude< + keyof ServerRequestOptions, + TUnsupported | keyof TOptions +>; + +type SupportedFields = Pick< + T, + Extract, TUnsupported>> +>; + +/** Accepts only `true`. Use it with {@link CheckServerRequestOptions}. */ +export type AssertTrue = T; diff --git a/packages/core/src/server.ts b/packages/core/src/server.ts index c6d78b00f52d..626858543c77 100644 --- a/packages/core/src/server.ts +++ b/packages/core/src/server.ts @@ -43,6 +43,11 @@ export type { HttpServerResponse, HttpModuleExport, } from './integrations/http/types'; +export type { + AssertTrue, + CheckServerRequestOptions, + ServerRequestOptions, +} from './integrations/http/server-request-options'; export { createFetchIntegration } from './integrations/fetch'; export type { FetchIntegrationOptions } from './integrations/fetch'; export { classifyResponseStreaming } from './utils/responseStreaming'; diff --git a/packages/deno/src/integrations/deno-serve.ts b/packages/deno/src/integrations/deno-serve.ts index 9d20e9084848..8c8a4038e5b5 100644 --- a/packages/deno/src/integrations/deno-serve.ts +++ b/packages/deno/src/integrations/deno-serve.ts @@ -1,5 +1,6 @@ import type { Event, IntegrationFn, MaxRequestBodySize, Span } from '@sentry/core'; import { debug, defineIntegration } from '@sentry/core'; +import type { AssertTrue, CheckServerRequestOptions } from '@sentry/core/server'; import { DEFAULT_IGNORE_STATUS_CODES, processHttpServerTransactionEvent } from '@sentry/core/server'; import type { RequestHandlerWrapperOptions } from '../wrap-deno-request-handler'; import { wrapDenoRequestHandler } from '../wrap-deno-request-handler'; @@ -99,6 +100,11 @@ export type DenoServeIntegrationOptions = { ignoreStatusCodes?: (number | [number, number])[]; }; +// Checks these options against the shared `ServerRequestOptions`. A new +// shared field fails to compile here, until this integration supports +// it or lists it as unsupported. +type _ServerRequestOptionsCheck = AssertTrue>; + export type ServeParams = // [(Request) => Response] | [Deno.ServeHandler] diff --git a/packages/deno/src/integrations/http.ts b/packages/deno/src/integrations/http.ts index 34dc1ff4e5f4..ea6a4fbae6c0 100644 --- a/packages/deno/src/integrations/http.ts +++ b/packages/deno/src/integrations/http.ts @@ -3,7 +3,13 @@ import { errorMonitor } from 'node:events'; import type { RequestOptions } from 'node:http'; import type { Event, Integration, IntegrationFn, Span } from '@sentry/core'; import { defineIntegration } from '@sentry/core'; -import type { HttpClientRequest, HttpIncomingMessage, HttpServerResponse } from '@sentry/core/server'; +import type { + AssertTrue, + CheckServerRequestOptions, + HttpClientRequest, + HttpIncomingMessage, + HttpServerResponse, +} from '@sentry/core/server'; import { DEFAULT_IGNORE_STATUS_CODES, getHttpClientSubscriptions, @@ -151,6 +157,13 @@ export interface DenoHttpIntegrationOptions { onSpanEnd?: (span: Span, request: HttpIncomingMessage, response: HttpServerResponse) => void; } +// Checks these options against the shared `ServerRequestOptions`. A new +// shared field fails to compile here, until this integration supports +// it or lists it as unsupported. +type _ServerRequestOptionsCheck = AssertTrue< + CheckServerRequestOptions +>; + const _denoHttpIntegration = ((options: DenoHttpIntegrationOptions = {}) => { const breadcrumbs = options.breadcrumbs ?? true; const tracePropagation = options.tracePropagation ?? true; diff --git a/packages/node/src/integrations/http/httpServerIntegration.ts b/packages/node/src/integrations/http/httpServerIntegration.ts index 6322cd0169bd..b3c0c939d971 100644 --- a/packages/node/src/integrations/http/httpServerIntegration.ts +++ b/packages/node/src/integrations/http/httpServerIntegration.ts @@ -45,9 +45,11 @@ export interface HttpServerIntegrationOptions { * This can be useful for long running requests where the body is not needed and we want to avoid capturing it. * * @param url Contains the entire URL, including query string (if any), protocol, host, etc. of the incoming request. - * @param request Contains the {@type RequestOptions} object used to make the incoming request. + * @param request The incoming request, a `node:http` `IncomingMessage`. Use `request.url`, `request.method`, + * `request.headers`, etc. The `RequestOptions` part of the type is deprecated and will be removed in v12. Its + * fields that `IncomingMessage` does not have (such as `path` or `hostname`) are always `undefined`. */ - ignoreRequestBody?: (url: string, request: RequestOptions) => boolean; + ignoreRequestBody?: (url: string, request: RequestOptions & HttpIncomingMessage) => boolean; /** * Controls the maximum size of incoming HTTP request bodies attached to events. @@ -84,13 +86,7 @@ const _httpServerIntegration = ((options: HttpServerIntegrationOptions = {}) => // core subscription helper. Explicitly opt out so the helper does not // double-create spans when the client has tracing enabled. spans: false as const, - // Cast: core uses HttpIncomingMessage; node consumers pass - // RequestOptions-typed callbacks. - // The two are structurally compatible for the fields the callback reads - // (url, method, headers). - ignoreRequestBody: options.ignoreRequestBody as - | ((url: string, request: HttpIncomingMessage) => boolean) - | undefined, + ignoreRequestBody: options.ignoreRequestBody, /** * Hook called by core's `instrumentServer` to wrap the upstream diff --git a/packages/node/src/integrations/http/index.ts b/packages/node/src/integrations/http/index.ts index f0cfc28cdc87..7431a6a34537 100644 --- a/packages/node/src/integrations/http/index.ts +++ b/packages/node/src/integrations/http/index.ts @@ -8,6 +8,12 @@ import { hasSpanStreamingEnabled, stripDataUrlContent, } from '@sentry/core'; +import type { + AssertTrue, + CheckServerRequestOptions, + HttpIncomingMessage, + HttpServerResponse, +} from '@sentry/core/server'; import { getRequestUrlFromClientRequest } from '@sentry/core/server'; import type { NodeClient } from '../../sdk/client'; import type { HttpServerIntegrationOptions } from './httpServerIntegration'; @@ -83,6 +89,19 @@ interface HttpOptions extends HttpServerIntegrationOptions, HttpServerSpansInteg outgoingRequestApplyCustomAttributes?: OutgoingHttpRequestInstrumentationOptions['outgoingRequestApplyCustomAttributes']; } +// Checks these options against the shared `ServerRequestOptions`. A new +// shared field fails to compile here, until this integration supports +// it or lists it as unsupported. +type _ServerRequestOptionsCheck = AssertTrue< + CheckServerRequestOptions< + HttpOptions, + HttpIncomingMessage, + HttpServerResponse, + // Not supported yet. + 'onSpanEnd' + > +>; + /** * The http integration instruments Node's internal http and https modules. * It creates breadcrumbs and spans for outgoing HTTP requests which will be attached to the currently active span. From f75d0c1cc743f5ab5158b60414151527e93995b4 Mon Sep 17 00:00:00 2001 From: isaacs Date: Tue, 20 Oct 2026 13:50:57 -0700 Subject: [PATCH 02/12] feat(bun): Add incoming request options and sessions to `bunServerIntegration` `Bun.serve` took only `maxRequestBodySize`. A user could not skip body capture or spans for one route, and release health showed no request sessions or crash-free rate for `Bun.serve` apps. `denoServeIntegration` got these in #25493 and #25496. `bunServerIntegration` now accepts `ignoreRequestBody`, `ignoreIncomingRequests`, `ignoreStaticAssets`, `spans`, `onSpanCreated`, `onSpanEnd`, `sessions` and `sessionFlushingDelayMS`. They use the same names and behavior as `denoServeIntegration`, and the callbacks receive the web `Request`. The shared `ServerRequestOptions` check for `Bun.serve` now lists no unsupported options. When the SDK skips the span, it still continues the incoming trace, attaches request data and the body, records the session and captures errors. For a streamed response, `onSpanEnd` and the session end when the stream finishes. A user callback that throws does not fail the request. If `ignoreRequestBody` throws, the SDK skips the body, so it cannot leak data that the filter was meant to hide. Three changes in 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 integration no longer starts spans when tracing is disabled. - Errors thrown for `OPTIONS` and `HEAD` requests are now captured. These requests also record sessions, as on Node and Deno. Fixes #25571 Fixes JS-3969 Co-Authored-By: Claude Opus 5.5 (1M context) --- CHANGELOG.md | 8 + packages/bun/src/integrations/bunserver.ts | 285 ++++++++--- .../bun/test/integrations/bunserver.test.ts | 477 +++++++++++++++++- 3 files changed, 706 insertions(+), 64 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 18182d0b8e1d..b9239d53f789 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -51,6 +51,14 @@ Work in this release was contributed by @adityareddy-dev, @jpdefo, @Jxxunnn, @ph `denoServeIntegration` now records a request session for each incoming request, as `denoHttpIntegration` does for `node:http`. This gives release health and crash-free rates for `Deno.serve` apps. Sessions are sent only when a `release` is set. To turn them off, set `denoServeIntegration({ sessions: false })`. `sessionFlushingDelayMS` sets how long the SDK waits before it sends the session aggregate. +- **feat(bun): Add incoming request options and request sessions to `bunServerIntegration`** + + `bunServerIntegration` now accepts the same options as `denoServeIntegration`: `ignoreRequestBody`, `ignoreIncomingRequests`, `ignoreStaticAssets`, `spans`, `onSpanCreated`, `onSpanEnd`, `sessions` and `sessionFlushingDelayMS`. The callbacks receive the web `Request`. + + `Bun.serve` now records a request session for each incoming request. Sessions are sent only when a `release` is set. To turn them off, set `bunServerIntegration({ sessions: false })`. + + `ignoreStaticAssets` defaults to `true`, so `Bun.serve` no longer creates spans for `GET` requests to common static assets such as `favicon.ico`, `robots.txt` or `*.js` files. To keep these spans, set `bunServerIntegration({ ignoreStaticAssets: false })`. `Bun.serve` spans also follow the client's tracing configuration by default, so the integration does not start spans when tracing is disabled. Errors thrown for `OPTIONS` and `HEAD` requests are now captured. + ## 11.8.0 ### Important Changes diff --git a/packages/bun/src/integrations/bunserver.ts b/packages/bun/src/integrations/bunserver.ts index 46ff0fc30e25..35015447cce9 100644 --- a/packages/bun/src/integrations/bunserver.ts +++ b/packages/bun/src/integrations/bunserver.ts @@ -1,13 +1,15 @@ /* eslint-disable max-lines */ -import type { Integration, IntegrationFn, MaxRequestBodySize, SpanAttributes } from '@sentry/core'; +import type { Client, Integration, IntegrationFn, MaxRequestBodySize, Span, SpanAttributes } from '@sentry/core'; import { captureBodyFromWinterCGRequest, captureException, continueTrace, + debug, defineIntegration, getClient, getUrlFragment, getUrlQuery, + hasSpansEnabled, hasSpanStreamingEnabled, httpHeadersToSpanAttributes, HTTP_SPAN_NAME_FALLBACK, @@ -22,7 +24,12 @@ import { filterCollectedUrlQuery, } from '@sentry/core'; import type { AssertTrue, CheckServerRequestOptions } from '@sentry/core/server'; -import { classifyResponseStreaming, getClientIPAddress } from '@sentry/core/server'; +import { + classifyResponseStreaming, + getClientIPAddress, + isStaticAssetRequest, + startRequestSession, +} from '@sentry/core/server'; import type { Server, ServeOptions } from 'bun'; import { CLIENT_ADDRESS, @@ -55,32 +62,86 @@ export type BunServerIntegrationOptions = { * @default 'medium' */ maxRequestBodySize?: MaxRequestBodySize; + + /** + * Do not capture the request body for incoming requests where the given callback returns `true`. + * + * `url` is the full URL of the request, including the query string. `request` is the incoming + * {@link Request}. The callback runs before the SDK reads the body. + */ + ignoreRequestBody?: (url: string, request: Request) => boolean; + + /** + * Do not capture server 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 server 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; + + /** + * Whether the integration should create [Sessions](https://docs.sentry.io/product/releases/health/#sessions) for + * incoming requests to track the health and crash-free rate of your releases in Sentry. + * + * For a streamed response, the session ends when the stream finishes. + * + * @default `true` + */ + sessions?: boolean; + + /** + * Number of milliseconds until sessions are flushed as a session aggregate. + * + * @default `60000` (60s) + */ + sessionFlushingDelayMS?: number; }; // Checks these options against the shared `ServerRequestOptions`. A new // shared field fails to compile here, until this integration supports // it or lists it as unsupported. -type _ServerRequestOptionsCheck = AssertTrue< - CheckServerRequestOptions< - BunServerIntegrationOptions, - Request, - Response, - // Not supported yet. - | 'spans' - | 'sessions' - | 'sessionFlushingDelayMS' - | 'ignoreRequestBody' - | 'ignoreIncomingRequests' - | 'ignoreStaticAssets' - | 'onSpanCreated' - | 'onSpanEnd' - > ->; +type _ServerRequestOptionsCheck = AssertTrue>; const _bunServerIntegration = ((options: BunServerIntegrationOptions = {}) => { return { 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, + sessions: options.sessions ?? true, + sessionFlushingDelayMS: options.sessionFlushingDelayMS, setupOnce() { instrumentBunServe(); }, @@ -230,6 +291,59 @@ type RouteHandler = Extract< Function >; +function captureServeError(error: unknown): void { + captureException(error, { mechanism: { type: 'auto.http.bun.serve', handled: false } }); +} + +// A user callback that throws must not fail the request or leave the span open. +function callUserCallback(name: string, callback: () => T, fallback: T): T { + try { + return callback(); + } catch (e) { + debug.error(`Error in the Bun.serve ${name} callback.`, e); + return fallback; + } +} + +function shouldCreateSpan( + client: Client | undefined, + integration: BunServerIntegrationOptions | undefined, + request: Request, + urlPath: string, +): boolean { + if (!(integration?.spans ?? (client ? hasSpansEnabled(client.getOptions()) : false))) { + return false; + } + if (integration?.ignoreStaticAssets && request.method.toUpperCase() === 'GET' && isStaticAssetRequest(urlPath)) { + return false; + } + const ignoreIncomingRequests = integration?.ignoreIncomingRequests; + return !( + ignoreIncomingRequests && + callUserCallback('ignoreIncomingRequests', () => ignoreIncomingRequests(urlPath, request), false) + ); +} + +/** + * Calls `onDone` when the response is done: at once, or when a streamed body finishes. + * Returns the response to send. + */ +function onResponseDone(response: T, onDone: () => void, onStreamError: () => void): T { + if (response?.body && !response.body.locked && classifyResponseStreaming(response).isStreaming) { + const body = monitorStream(response.body, onDone, error => { + onStreamError(); + captureServeError(error); + }); + return new Response(body, { + status: response.status, + statusText: response.statusText, + headers: response.headers, + }) as T; + } + onDone(); + return response; +} + function wrapRequestHandler( target: T, thisArg: unknown, @@ -239,8 +353,26 @@ function wrapRequestHandler( return withIsolationScope(async isolationScope => { const request = args[0]; const upperCaseMethod = request.method.toUpperCase(); + + const client = getClient(); + const integration = client?.getIntegrationByName(INTEGRATION_NAME); + const endSession = + client && integration?.sessions + ? startRequestSession(client, { + requestIsolationScope: isolationScope, + sessionFlushingDelayMS: integration.sessionFlushingDelayMS, + }) + : undefined; + if (upperCaseMethod === 'OPTIONS' || upperCaseMethod === 'HEAD') { - return target.apply(thisArg, args); + try { + return await target.apply(thisArg, args); + } catch (e) { + captureServeError(e); + throw e; + } finally { + endSession?.(); + } } const parsedUrl = parseStringToURLObject(request.url); @@ -267,7 +399,6 @@ function wrapRequestHandler( routeName = route; } - const client = getClient(); const dataCollection = client?.getDataCollectionOptions(); let socketAddress: { address: string; port: number } | undefined; @@ -301,24 +432,52 @@ function wrapRequestHandler( ipAddress: socketAddress?.address, }); - if (client && dataCollection) { - const configuredBodySize = client.getIntegrationByName( - INTEGRATION_NAME, - )?.maxRequestBodySize; + if (dataCollection) { const effectiveBodySize = - configuredBodySize ?? (dataCollection.httpBodies.includes('incomingRequest') ? 'medium' : 'none'); - if (upperCaseMethod !== 'GET' && effectiveBodySize !== 'none') { + integration?.maxRequestBodySize ?? (dataCollection.httpBodies.includes('incomingRequest') ? 'medium' : 'none'); + const ignoreRequestBody = integration?.ignoreRequestBody; + if ( + upperCaseMethod !== 'GET' && + effectiveBodySize !== 'none' && + // If the filter throws, skip the body so it cannot leak data the filter was meant to hide. + !( + ignoreRequestBody && + callUserCallback('ignoreRequestBody', () => ignoreRequestBody(request.url, request), true) + ) + ) { await captureBodyFromWinterCGRequest(request, isolationScope, effectiveBodySize); } } + const urlPath = parsedUrl ? `${parsedUrl.pathname}${parsedUrl.search}` : '/'; + const createSpan = shouldCreateSpan(client, integration, request, urlPath); + return continueTrace( { sentryTrace: request.headers.get('sentry-trace') ?? '', baggage: request.headers.get('baggage'), }, - () => - startSpanManual( + async () => { + if (!createSpan) { + let response: Response | undefined; + try { + response = (await target.apply(thisArg, args)) as Response | undefined; + } catch (e) { + captureServeError(e); + endSession?.(); + throw e; + } + if (response?.status) { + isolationScope.setContext('response', { status_code: response.status }); + } + return onResponseDone( + response, + () => endSession?.(), + () => undefined, + ); + } + + return startSpanManual( { attributes: { ...attributes, [SENTRY_OP]: HTTP_SERVER }, // With span streaming, span names have to be low cardinality, so we can't fall back to the URL path. @@ -328,47 +487,49 @@ function wrapRequestHandler( : request.method?.toUpperCase() || HTTP_SPAN_NAME_FALLBACK, }, async (span, endSpan) => { + const { onSpanCreated, onSpanEnd } = integration ?? {}; + if (onSpanCreated) { + callUserCallback('onSpanCreated', () => onSpanCreated(span, request), undefined); + } + + let response: Response | undefined; try { - const response = (await target.apply(thisArg, args)) as Response | undefined; - if (response?.status) { - setHttpStatus(span, response.status); - - isolationScope.setContext('response', { - status_code: response.status, - }); - - if (dataCollection) { - span.setAttributes( - httpHeadersToSpanAttributes(response.headers.toJSON(), dataCollection, 'response'), - ); - } - } - if (response?.body && !response.body.locked && classifyResponseStreaming(response).isStreaming) { - const body = monitorStream(response.body, endSpan, error => { - span.setStatus({ code: SPAN_STATUS_ERROR, message: 'internal_error' }); - captureException(error, { mechanism: { type: 'auto.http.bun.serve', handled: false } }); - }); - return new Response(body, { - status: response.status, - statusText: response.statusText, - headers: response.headers, - }); - } - endSpan(); - return response; + response = (await target.apply(thisArg, args)) as Response | undefined; } catch (e) { span.setStatus({ code: SPAN_STATUS_ERROR, message: 'internal_error' }); - captureException(e, { - mechanism: { - type: 'auto.http.bun.serve', - handled: false, - }, - }); + captureServeError(e); endSpan(); + endSession?.(); throw e; } + + if (response?.status) { + setHttpStatus(span, response.status); + + isolationScope.setContext('response', { + status_code: response.status, + }); + + if (dataCollection) { + span.setAttributes(httpHeadersToSpanAttributes(response.headers.toJSON(), dataCollection, 'response')); + } + } + + const finalResponse = response; + return onResponseDone( + response, + () => { + if (onSpanEnd && finalResponse) { + callUserCallback('onSpanEnd', () => onSpanEnd(span, request, finalResponse), undefined); + } + endSpan(); + endSession?.(); + }, + () => span.setStatus({ code: SPAN_STATUS_ERROR, message: 'internal_error' }), + ); }, - ), + ); + }, ); }); } diff --git a/packages/bun/test/integrations/bunserver.test.ts b/packages/bun/test/integrations/bunserver.test.ts index bec2b892ea3a..5d846d3667d6 100644 --- a/packages/bun/test/integrations/bunserver.test.ts +++ b/packages/bun/test/integrations/bunserver.test.ts @@ -1,4 +1,4 @@ -import type { RequestEventData } from '@sentry/core'; +import type { AggregationCounts, Envelope, RequestEventData } from '@sentry/core'; import * as SentryCore from '@sentry/core'; import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, spyOn, test } from 'bun:test'; import type { BunOptions } from '../../src'; @@ -21,9 +21,11 @@ describe('Bun Serve Integration', () => { init({ dsn: 'https://username@domain/123', defaultIntegrations: false, - ...options, + // Spans follow the client's tracing configuration. + tracesSampleRate: 1, transport: () => SentryCore.createTransport({ recordDroppedEvent: () => undefined }, () => SentryCore.resolvedSyncPromise({})), + ...options, }); }; @@ -1195,4 +1197,475 @@ describe('Bun Serve Integration', () => { expect(normalizedRequest?.data).toBeUndefined(); }); }); + + describe('options', () => { + const captureBodySpy = spyOn(SentryCore, 'captureBodyFromWinterCGRequest'); + + beforeEach(() => { + captureBodySpy.mockClear(); + }); + + test('does not capture request bodies that ignoreRequestBody matches', async () => { + const ignoreRequestBody = (url: string, request: Request): boolean => + url.endsWith('/upload?id=1') && request.method === 'POST'; + setupClient({ integrations: [bunServerIntegration({ ignoreRequestBody })] }); + const server = Bun.serve({ port, fetch: async req => new Response(await req.text()) }); + try { + const ignored = await fetch(`http://localhost:${port}/upload?id=1`, { method: 'POST', body: 'secret' }); + expect(await ignored.text()).toBe('secret'); + expect(captureBodySpy).not.toHaveBeenCalled(); + + const captured = await fetch(`http://localhost:${port}/login`, { method: 'POST', body: 'hello' }); + expect(await captured.text()).toBe('hello'); + expect(captureBodySpy).toHaveBeenCalledTimes(1); + } finally { + await server.stop(true); + } + }); + + test('does not capture the body and still responds when ignoreRequestBody throws', async () => { + setupClient({ + integrations: [ + bunServerIntegration({ + ignoreRequestBody: () => { + throw new Error('filter failed'); + }, + }), + ], + }); + const server = Bun.serve({ port, fetch: async req => new Response(await req.text()) }); + try { + const response = await fetch(`http://localhost:${port}/login`, { method: 'POST', body: 'secret' }); + expect(await response.text()).toBe('secret'); + expect(captureBodySpy).not.toHaveBeenCalled(); + expect(captureExceptionSpy).not.toHaveBeenCalled(); + } finally { + await server.stop(true); + } + }); + + test('does not create spans for requests that ignoreIncomingRequests matches', async () => { + const urlPaths: string[] = []; + setupClient({ + integrations: [ + bunServerIntegration({ + ignoreIncomingRequests: (urlPath, request) => { + urlPaths.push(urlPath); + return urlPath.startsWith('/health') && request instanceof Request; + }, + }), + ], + }); + const server = Bun.serve({ port, fetch: () => new Response('ok') }); + try { + expect(await (await fetch(`http://localhost:${port}/health?probe=1`)).text()).toBe('ok'); + expect(startSpanSpy).not.toHaveBeenCalled(); + + expect(await (await fetch(`http://localhost:${port}/users`)).text()).toBe('ok'); + expect(startSpanSpy).toHaveBeenCalledTimes(1); + expect(urlPaths).toEqual(['/health?probe=1', '/users']); + } finally { + await server.stop(true); + } + }); + + test('creates the span and still responds when ignoreIncomingRequests throws', async () => { + setupClient({ + integrations: [ + bunServerIntegration({ + ignoreIncomingRequests: () => { + throw new Error('filter failed'); + }, + }), + ], + }); + const server = Bun.serve({ port, fetch: () => new Response('ok') }); + try { + expect(await (await fetch(`http://localhost:${port}/users`)).text()).toBe('ok'); + expect(startSpanSpy).toHaveBeenCalledTimes(1); + expect(captureExceptionSpy).not.toHaveBeenCalled(); + } finally { + await server.stop(true); + } + }); + + test('does not create spans for static assets by default', async () => { + setupClient({ integrations: [bunServerIntegration()] }); + const server = Bun.serve({ port, fetch: () => new Response('ok') }); + try { + expect(await (await fetch(`http://localhost:${port}/favicon.ico`)).text()).toBe('ok'); + expect(await (await fetch(`http://localhost:${port}/assets/app.js`)).text()).toBe('ok'); + expect(startSpanSpy).not.toHaveBeenCalled(); + + // The filter only applies to GET. + await (await fetch(`http://localhost:${port}/favicon.ico`, { method: 'POST', body: 'x' })).text(); + expect(startSpanSpy).toHaveBeenCalledTimes(1); + } finally { + await server.stop(true); + } + }); + + test('creates spans for static assets when ignoreStaticAssets is false', async () => { + setupClient({ integrations: [bunServerIntegration({ ignoreStaticAssets: false })] }); + const server = Bun.serve({ port, fetch: () => new Response('ok') }); + try { + expect(await (await fetch(`http://localhost:${port}/favicon.ico`)).text()).toBe('ok'); + expect(startSpanSpy).toHaveBeenCalledTimes(1); + } finally { + await server.stop(true); + } + }); + + test('does not create spans when spans is false, but still captures errors and request data', async () => { + setupClient({ integrations: [bunServerIntegration({ spans: false })] }); + let normalizedRequest: RequestEventData | undefined; + const error = new Error('handler failed'); + const server = Bun.serve({ + port, + fetch: () => { + normalizedRequest = SentryCore.getIsolationScope().getScopeData().sdkProcessingMetadata.normalizedRequest; + throw error; + }, + }); + try { + // `server.fetch` returns the handler error, which `bun:test` would report as unhandled from a real request. + await expect(server.fetch(new Request(`http://localhost:${port}/users`))).rejects.toBe(error); + expect(startSpanSpy).not.toHaveBeenCalled(); + expect(normalizedRequest?.url).toBe(`http://localhost:${port}/users`); + expect(captureExceptionSpy).toHaveBeenCalledWith(error, { + mechanism: { type: 'auto.http.bun.serve', handled: false }, + }); + } finally { + await server.stop(true); + } + }); + + test('does not create spans when tracing is disabled', async () => { + setupClient({ tracesSampleRate: undefined, integrations: [bunServerIntegration()] }); + const server = Bun.serve({ port, fetch: () => new Response('ok') }); + try { + expect(await (await fetch(`http://localhost:${port}/users`)).text()).toBe('ok'); + expect(startSpanSpy).not.toHaveBeenCalled(); + expect(continueTraceSpy).toHaveBeenCalledTimes(1); + } finally { + await server.stop(true); + } + }); + + test('calls onSpanCreated and onSpanEnd', async () => { + const calls: string[] = []; + setupClient({ + integrations: [ + bunServerIntegration({ + onSpanCreated: (span, request) => { + expect(span).toBe(mockSpan as unknown as SentryCore.Span); + calls.push(`created ${request.method} ${new URL(request.url).pathname}`); + }, + onSpanEnd: (span, request, response) => { + expect(span).toBe(mockSpan as unknown as SentryCore.Span); + calls.push(`end ${request.method} ${response.status}`); + }, + }), + ], + }); + const server = Bun.serve({ + port, + fetch: () => { + calls.push('handler'); + return new Response('ok', { status: 201 }); + }, + }); + try { + expect(await (await fetch(`http://localhost:${port}/users`)).text()).toBe('ok'); + expect(calls).toEqual(['created GET /users', 'handler', 'end GET 201']); + } finally { + await server.stop(true); + } + }); + + test('calls onSpanEnd when a streamed response finishes', async () => { + const onSpanEndCalls: number[] = []; + setupClient({ + integrations: [ + bunServerIntegration({ onSpanEnd: (_span, _request, response) => onSpanEndCalls.push(response.status) }), + ], + }); + let controller: ReadableStreamDefaultController; + const source = new ReadableStream({ + start(value) { + controller = value; + }, + }); + const server = Bun.serve({ + port, + fetch: () => new Response(source, { headers: { 'content-type': 'text/event-stream' } }), + }); + try { + const response = await server.fetch(new Request(`http://localhost:${port}/events`)); + controller!.enqueue(new TextEncoder().encode('data: one\n\n')); + expect(onSpanEndCalls).toEqual([]); + + controller!.close(); + expect(await response.text()).toBe('data: one\n\n'); + expect(onSpanEndCalls).toEqual([200]); + } finally { + await server.stop(true); + } + }); + + test.each(['onSpanCreated', 'onSpanEnd'] as const)( + 'still responds and ends the span when %s throws', + async hook => { + setupClient({ + integrations: [ + bunServerIntegration({ + [hook]: () => { + throw new Error(`${hook} failed`); + }, + }), + ], + }); + const server = Bun.serve({ port, fetch: () => new Response('ok') }); + try { + expect(await (await fetch(`http://localhost:${port}/users`)).text()).toBe('ok'); + expect(endSpanSpy).toHaveBeenCalledTimes(1); + expect(captureExceptionSpy).not.toHaveBeenCalled(); + } finally { + await server.stop(true); + } + }, + ); + + test.each(['OPTIONS', 'HEAD'])('captures errors thrown for %s requests', async method => { + const error = new Error(`${method} failed`); + const server = Bun.serve({ + port, + fetch: () => { + throw error; + }, + }); + try { + await expect(server.fetch(new Request(`http://localhost:${port}/`, { method }))).rejects.toBe(error); + expect(startSpanSpy).not.toHaveBeenCalled(); + expect(captureExceptionSpy).toHaveBeenCalledWith(error, { + mechanism: { type: 'auto.http.bun.serve', handled: false }, + }); + } finally { + await server.stop(true); + } + }); + }); + + describe('sessions', () => { + type SessionCounts = Omit; + + function setupSessionClient(options?: BunOptions): { + client: SentryCore.Client; + sessions: SessionCounts[]; + firstSession: Promise; + } { + const sessions: SessionCounts[] = []; + let resolveFirstSession: () => void; + const firstSession = new Promise(resolve => (resolveFirstSession = resolve)); + setupClient({ + release: '1.0.0', + integrations: [bunServerIntegration()], + ...options, + transport: () => + SentryCore.createTransport({ recordDroppedEvent: () => undefined }, request => { + const envelope = SentryCore.parseEnvelope(request.body) as Envelope; + SentryCore.forEachEnvelopeItem(envelope, (item, type) => { + if (type === 'sessions') { + for (const { started: _started, ...counts } of (item[1] as { aggregates: AggregationCounts[] }) + .aggregates) { + sessions.push(counts); + } + resolveFirstSession(); + } + }); + return SentryCore.resolvedSyncPromise({}); + }), + }); + return { client: SentryCore.getClient()!, sessions, firstSession }; + } + + test('records an exited session for a successful request', async () => { + const { client, sessions } = setupSessionClient(); + const server = Bun.serve({ port, fetch: () => new Response('ok') }); + try { + await (await fetch(`http://localhost:${port}/ok`)).text(); + } finally { + await server.stop(true); + } + await client.flush(); + + expect(sessions).toEqual([{ exited: 1, errored: 0, crashed: 0 }]); + }); + + test('records an errored session for a handled exception', async () => { + const { client, sessions } = setupSessionClient(); + const server = Bun.serve({ + port, + fetch: () => { + SentryCore.captureException(new Error('handled')); + return new Response('ok'); + }, + }); + try { + await (await fetch(`http://localhost:${port}/handled`)).text(); + } finally { + await server.stop(true); + } + await client.flush(); + + expect(sessions).toEqual([{ exited: 0, errored: 1, crashed: 0 }]); + }); + + test('records a crashed session when the handler throws', async () => { + const { client, sessions } = setupSessionClient(); + const error = new Error('unhandled'); + const server = Bun.serve({ + port, + fetch: () => { + throw error; + }, + }); + try { + await expect(server.fetch(new Request(`http://localhost:${port}/throws`))).rejects.toBe(error); + } finally { + await server.stop(true); + } + await client.flush(); + + expect(sessions).toEqual([{ exited: 0, errored: 0, crashed: 1 }]); + }); + + test.each([ + ['when tracing is enabled', {}], + ['when tracing is disabled', { tracesSampleRate: undefined }], + ] as const)('records a crashed session when a streamed response source fails %s', async (_label, options) => { + const { client, sessions } = setupSessionClient(options); + let controller: ReadableStreamDefaultController; + const source = new ReadableStream({ + start(value) { + controller = value; + }, + }); + const server = Bun.serve({ + port, + fetch: () => new Response(source, { headers: { 'content-type': 'text/event-stream' } }), + }); + try { + const response = await server.fetch(new Request(`http://localhost:${port}/events`)); + const error = new Error('stream interrupted'); + const text = response.text(); + controller!.error(error); + await expect(text).rejects.toBe(error); + } finally { + await server.stop(true); + } + await client.flush(); + + expect(sessions).toEqual([{ exited: 0, errored: 0, crashed: 1 }]); + }); + + test('records sessions for OPTIONS and HEAD requests', async () => { + const { client, sessions } = setupSessionClient(); + const server = Bun.serve({ port, fetch: () => new Response('ok') }); + try { + await (await fetch(`http://localhost:${port}/cors`, { method: 'OPTIONS' })).text(); + await (await fetch(`http://localhost:${port}/cors`, { method: 'HEAD' })).text(); + } finally { + await server.stop(true); + } + await client.flush(); + + expect(sessions).toEqual([{ exited: 2, errored: 0, crashed: 0 }]); + }); + + test('records sessions for requests without a span', async () => { + const { client, sessions } = setupSessionClient({ + integrations: [bunServerIntegration({ ignoreIncomingRequests: urlPath => urlPath === '/health' })], + }); + const server = Bun.serve({ port, fetch: () => new Response('ok') }); + try { + await (await fetch(`http://localhost:${port}/health`)).text(); + await (await fetch(`http://localhost:${port}/favicon.ico`)).text(); + } finally { + await server.stop(true); + } + await client.flush(); + + expect(startSpanSpy).not.toHaveBeenCalled(); + expect(sessions).toEqual([{ exited: 2, errored: 0, crashed: 0 }]); + }); + + test('does not record sessions when sessions is false', async () => { + const { client, sessions } = setupSessionClient({ integrations: [bunServerIntegration({ sessions: false })] }); + const server = Bun.serve({ port, fetch: () => new Response('ok') }); + try { + await (await fetch(`http://localhost:${port}/ok`)).text(); + } finally { + await server.stop(true); + } + await client.flush(); + + expect(sessions).toEqual([]); + }); + + test('sends the session aggregate after sessionFlushingDelayMS', async () => { + const { sessions, firstSession } = setupSessionClient({ + integrations: [bunServerIntegration({ sessionFlushingDelayMS: 50 })], + }); + const server = Bun.serve({ port, fetch: () => new Response('ok') }); + try { + await (await fetch(`http://localhost:${port}/ok`)).text(); + } finally { + await server.stop(true); + } + + // No flush here. The timeout only bounds a failure. With the default 60s delay, no session + // arrives within it. + let timeoutId: ReturnType | undefined; + const timedOut = new Promise<'timed out'>(resolve => { + timeoutId = setTimeout(() => resolve('timed out'), 2_000); + }); + const result = await Promise.race([firstSession.then(() => 'sent' as const), timedOut]); + clearTimeout(timeoutId); + + expect(result).toBe('sent'); + expect(sessions).toEqual([{ exited: 1, errored: 0, crashed: 0 }]); + }); + + test.each([ + ['when tracing is enabled', {}], + ['when tracing is disabled', { tracesSampleRate: undefined }], + ] as const)('records a streamed response session when the stream finishes %s', async (_label, options) => { + const { client, sessions } = setupSessionClient(options); + let controller: ReadableStreamDefaultController; + const source = new ReadableStream({ + start(value) { + controller = value; + }, + }); + const server = Bun.serve({ + port, + fetch: () => new Response(source, { headers: { 'content-type': 'text/event-stream' } }), + }); + try { + const response = await server.fetch(new Request(`http://localhost:${port}/events`)); + controller!.enqueue(new TextEncoder().encode('chunk\n')); + await client.flush(); + expect(sessions).toEqual([]); + + controller!.close(); + expect(await response.text()).toBe('chunk\n'); + } finally { + await server.stop(true); + } + await client.flush(); + + expect(sessions).toEqual([{ exited: 1, errored: 0, crashed: 0 }]); + }); + }); }); From 7834c0ce264ec038669f314dc081a38531f31e28 Mon Sep 17 00:00:00 2001 From: isaacs Date: Tue, 20 Oct 2026 13:54:27 -0700 Subject: [PATCH 03/12] feat(node): Add `onSpanEnd` to `httpIntegration` `httpIntegration` takes `onSpanCreated` for incoming requests, but not `onSpanEnd`. A user could not change the server span based on the response, for example to rename it from data that is only known when the response finishes. Core's `node:http` server code already supports `onSpanEnd`, and `denoHttpIntegration` and `bunHttpServerIntegration` pass it through. `httpServerSpansIntegration` now accepts `onSpanEnd` and calls it as core does: when the response finishes, after the SDK sets the response attributes and the span status, before the span ends. `httpIntegration` passes it through. The shared `ServerRequestOptions` check for Node now lists no unsupported options. Fixes #25572 Fixes JS-3970 Co-Authored-By: Claude Opus 5.5 (1M context) --- CHANGELOG.md | 4 + .../httpIntegration/instrument-options.mjs | 6 + .../instrument-throwing-callbacks.mjs | 3 + .../suites/tracing/httpIntegration/test.ts | 137 +++++++++++++++--- .../http/httpServerSpansIntegration.ts | 19 ++- packages/node/src/integrations/http/index.ts | 8 +- 6 files changed, 149 insertions(+), 28 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b9239d53f789..ad51d92b462e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -59,6 +59,10 @@ Work in this release was contributed by @adityareddy-dev, @jpdefo, @Jxxunnn, @ph `ignoreStaticAssets` defaults to `true`, so `Bun.serve` no longer creates spans for `GET` requests to common static assets such as `favicon.ico`, `robots.txt` or `*.js` files. To keep these spans, set `bunServerIntegration({ ignoreStaticAssets: false })`. `Bun.serve` spans also follow the client's tracing configuration by default, so the integration does not start spans when tracing is disabled. Errors thrown for `OPTIONS` and `HEAD` requests are now captured. +- **feat(node): Add `onSpanEnd` to `httpIntegration`** + + `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. + ## 11.8.0 ### Important Changes diff --git a/dev-packages/node-integration-tests/suites/tracing/httpIntegration/instrument-options.mjs b/dev-packages/node-integration-tests/suites/tracing/httpIntegration/instrument-options.mjs index d283ccff7951..6a02d7d4a26c 100644 --- a/dev-packages/node-integration-tests/suites/tracing/httpIntegration/instrument-options.mjs +++ b/dev-packages/node-integration-tests/suites/tracing/httpIntegration/instrument-options.mjs @@ -18,6 +18,12 @@ Sentry.init({ 'onSpanCreated.resMethod': res.req.method, }); }, + onSpanEnd: (span, req, res) => { + span.setAttributes({ + 'onSpanEnd.reqUrl': req.url, + 'onSpanEnd.resStatusCode': res.statusCode, + }); + }, }), ], }); diff --git a/dev-packages/node-integration-tests/suites/tracing/httpIntegration/instrument-throwing-callbacks.mjs b/dev-packages/node-integration-tests/suites/tracing/httpIntegration/instrument-throwing-callbacks.mjs index c2d7c4763781..6672b0dc99eb 100644 --- a/dev-packages/node-integration-tests/suites/tracing/httpIntegration/instrument-throwing-callbacks.mjs +++ b/dev-packages/node-integration-tests/suites/tracing/httpIntegration/instrument-throwing-callbacks.mjs @@ -15,6 +15,9 @@ Sentry.init({ onSpanCreated: () => { throw new Error('onSpanCreated error'); }, + onSpanEnd: () => { + throw new Error('onSpanEnd error'); + }, }), ], }); diff --git a/dev-packages/node-integration-tests/suites/tracing/httpIntegration/test.ts b/dev-packages/node-integration-tests/suites/tracing/httpIntegration/test.ts index 28ae77d23617..70618d73671d 100644 --- a/dev-packages/node-integration-tests/suites/tracing/httpIntegration/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/httpIntegration/test.ts @@ -1,9 +1,62 @@ import { HTTP_CLIENT, HTTP_SERVER } from '@sentry/conventions/op'; import { createTestServer } from '@sentry-internal/test-utils'; -import { HTTP_RESPONSE_STATUS_CODE, SENTRY_OP, URL_FULL, URL_PATH } from '@sentry/conventions/attributes'; +import { + CLIENT_ADDRESS, + CLIENT_PORT, + HTTP_REQUEST_METHOD, + HTTP_RESPONSE_STATUS_CODE, + HTTP_RESPONSE_STATUS_TEXT, + HTTP_ROUTE, + NETWORK_LOCAL_ADDRESS, + NETWORK_LOCAL_PORT, + NETWORK_PEER_ADDRESS, + NETWORK_PEER_PORT, + NETWORK_PROTOCOL_NAME, + NETWORK_PROTOCOL_VERSION, + NETWORK_TRANSPORT, + SENTRY_ENVIRONMENT, + SENTRY_IS_LOCALHOST, + SENTRY_KIND, + SENTRY_OP, + SENTRY_ORIGIN, + SENTRY_RELEASE, + SENTRY_SDK_INTEGRATIONS, + SENTRY_SDK_NAME, + SENTRY_SDK_VERSION, + SENTRY_SEGMENT_ID, + SENTRY_SEGMENT_NAME, + SENTRY_SEGMENT_NAME_SOURCE, + SENTRY_TRACE_LIFECYCLE, + SERVER_ADDRESS, + SERVER_PORT, + URL_FULL, + URL_PATH, + URL_SCHEME, + USER_AGENT_ORIGINAL, + USER_IP_ADDRESS, +} from '@sentry/conventions/attributes'; +import { SDK_VERSION, SEMANTIC_ATTRIBUTE_SENTRY_SAMPLE_RATE } from '@sentry/core'; import { afterAll, describe, expect, test } from 'vitest'; import { cleanupChildProcesses, createEsmAndCjsTests, createRunner } from '../../../utils/runner'; -import { supports } from '../../../utils'; +import { EXPECTED_SDK_NAME, supports } from '../../../utils'; + +const LOOPBACK_ADDRESS = /^(::1|127\.0\.0\.1|::ffff:127\.0\.0\.1)$/; + +// Attributes from the context of the host (app, device, OS, culture, runtime, cloud). Which of them +// the SDK sends depends on the platform, so tests only check that they are well formed. +const HOST_CONTEXT_ATTRIBUTE = /^(app|device|os|culture|process\.runtime|cloud)\./; + +function splitHostContextAttributes(attributes: Record): { + hostContext: Record; + rest: Record; +} { + const hostContext: Record = {}; + const rest: Record = {}; + for (const [key, value] of Object.entries(attributes)) { + (HOST_CONTEXT_ATTRIBUTE.test(key) ? hostContext : rest)[key] = value; + } + return { hostContext, rest }; +} function getCommonHttpRequestHeaders(): Record { return { @@ -22,30 +75,76 @@ describe('httpIntegration', () => { cleanupChildProcesses(); }); - describe('onSpanCreated option', () => { + describe('onSpanCreated and onSpanEnd options', () => { createEsmAndCjsTests(__dirname, 'server.mjs', 'instrument-options.mjs', (createRunner, test) => { - test('allows to configure onSpanCreated', async () => { + test('allows to configure onSpanCreated and onSpanEnd', async () => { const runner = createRunner() .expect({ - span: { - items: expect.arrayContaining([ + span: container => { + const segmentSpan = container.items.find(span => span.is_segment); + expect(segmentSpan).toEqual( expect.objectContaining({ - is_segment: true, span_id: expect.stringMatching(/[a-f\d]{16}/), trace_id: expect.stringMatching(/[a-f\d]{32}/), status: 'ok', - attributes: expect.objectContaining({ - [URL_FULL]: { type: 'string', value: expect.stringMatching(/\/test$/) }, - [HTTP_RESPONSE_STATUS_CODE]: { type: 'integer', value: 200 }, - [SENTRY_OP]: { type: 'string', value: HTTP_SERVER }, - onSpanCreated: { type: 'string', value: 'yes' }, - 'onSpanCreated.reqUrl': { type: 'string', value: expect.stringMatching(/\/test$/) }, - 'onSpanCreated.reqMethod': { type: 'string', value: 'GET' }, - 'onSpanCreated.resUrl': { type: 'string', value: expect.stringMatching(/\/test$/) }, - 'onSpanCreated.resMethod': { type: 'string', value: 'GET' }, - }), }), - ]), + ); + + // Every other attribute is listed, so the test fails when the span gets a new one. + const { hostContext, rest } = splitHostContextAttributes(segmentSpan!.attributes); + expect(rest).toEqual({ + onSpanCreated: { type: 'string', value: 'yes' }, + 'onSpanCreated.reqUrl': { type: 'string', value: '/test' }, + 'onSpanCreated.reqMethod': { type: 'string', value: 'GET' }, + 'onSpanCreated.resUrl': { type: 'string', value: '/test' }, + 'onSpanCreated.resMethod': { type: 'string', value: 'GET' }, + 'onSpanEnd.reqUrl': { type: 'string', value: '/test' }, + 'onSpanEnd.resStatusCode': { type: 'integer', value: 200 }, + [SENTRY_OP]: { type: 'string', value: HTTP_SERVER }, + [SENTRY_ORIGIN]: { type: 'string', value: 'auto.http.http_server' }, + [SENTRY_KIND]: { type: 'string', value: 'server' }, + [SENTRY_SEGMENT_ID]: { type: 'string', value: segmentSpan!.span_id }, + [SENTRY_SEGMENT_NAME]: { type: 'string', value: 'GET /test' }, + [SENTRY_SEGMENT_NAME_SOURCE]: { type: 'string', value: 'route' }, + [SENTRY_TRACE_LIFECYCLE]: { type: 'string', value: 'stream' }, + [SEMANTIC_ATTRIBUTE_SENTRY_SAMPLE_RATE]: { type: 'integer', value: 1 }, + [SENTRY_IS_LOCALHOST]: { type: 'boolean', value: true }, + [SENTRY_ENVIRONMENT]: { type: 'string', value: 'production' }, + [SENTRY_RELEASE]: { type: 'string', value: '1.0' }, + [SENTRY_SDK_NAME]: { type: 'string', value: EXPECTED_SDK_NAME }, + [SENTRY_SDK_VERSION]: { type: 'string', value: SDK_VERSION }, + [SENTRY_SDK_INTEGRATIONS]: { type: 'array', value: expect.arrayContaining(['Http']) }, + [HTTP_REQUEST_METHOD]: { type: 'string', value: 'GET' }, + [HTTP_ROUTE]: { type: 'string', value: '/test' }, + [HTTP_RESPONSE_STATUS_CODE]: { type: 'integer', value: 200 }, + [HTTP_RESPONSE_STATUS_TEXT]: { type: 'string', value: 'OK' }, + [URL_FULL]: { type: 'string', value: expect.stringMatching(/^http:\/\/localhost:\d+\/test$/) }, + [URL_PATH]: { type: 'string', value: '/test' }, + [URL_SCHEME]: { type: 'string', value: 'http' }, + [SERVER_ADDRESS]: { type: 'string', value: 'localhost' }, + [SERVER_PORT]: { type: 'integer', value: expect.any(Number) }, + [CLIENT_ADDRESS]: { type: 'string', value: expect.stringMatching(LOOPBACK_ADDRESS) }, + [CLIENT_PORT]: { type: 'integer', value: expect.any(Number) }, + [USER_IP_ADDRESS]: { type: 'string', value: expect.stringMatching(LOOPBACK_ADDRESS) }, + [NETWORK_LOCAL_ADDRESS]: { type: 'string', value: expect.stringMatching(LOOPBACK_ADDRESS) }, + [NETWORK_LOCAL_PORT]: { type: 'integer', value: expect.any(Number) }, + [NETWORK_PEER_ADDRESS]: { type: 'string', value: expect.stringMatching(LOOPBACK_ADDRESS) }, + [NETWORK_PEER_PORT]: { type: 'integer', value: expect.any(Number) }, + [NETWORK_PROTOCOL_NAME]: { type: 'string', value: 'http' }, + [NETWORK_PROTOCOL_VERSION]: { type: 'string', value: '1.1' }, + [NETWORK_TRANSPORT]: { type: 'string', value: 'tcp' }, + [USER_AGENT_ORIGINAL]: { type: 'string', value: 'node' }, + 'http.request.header.accept': { type: 'array', value: ['*/*'] }, + 'http.request.header.accept-encoding': { type: 'array', value: ['gzip, deflate'] }, + 'http.request.header.accept-language': { type: 'array', value: ['*'] }, + 'http.request.header.connection': { type: 'array', value: ['keep-alive'] }, + 'http.request.header.host': { type: 'array', value: [expect.stringMatching(/^localhost:\d+$/)] }, + 'http.request.header.sec-fetch-mode': { type: 'array', value: ['cors'] }, + 'http.request.header.user-agent': { type: 'array', value: ['node'] }, + }); + for (const attribute of Object.values(hostContext)) { + expect(attribute).toEqual({ type: expect.any(String), value: expect.anything() }); + } }, }) .start(); @@ -57,7 +156,7 @@ describe('httpIntegration', () => { describe('user callbacks that throw', () => { createEsmAndCjsTests(__dirname, 'server.mjs', 'instrument-throwing-callbacks.mjs', (createRunner, test) => { - test('still handles requests and sends the span when `onSpanCreated` and `ignoreIncomingRequests` throw', async () => { + test('still handles requests and sends the span when `onSpanCreated`, `onSpanEnd` and `ignoreIncomingRequests` throw', async () => { const runner = createRunner() .expect({ span: { diff --git a/packages/node/src/integrations/http/httpServerSpansIntegration.ts b/packages/node/src/integrations/http/httpServerSpansIntegration.ts index 5765629bdee9..db9c231a810f 100644 --- a/packages/node/src/integrations/http/httpServerSpansIntegration.ts +++ b/packages/node/src/integrations/http/httpServerSpansIntegration.ts @@ -36,6 +36,7 @@ import { } from '@sentry/core/server'; import { debug, + safeCallback, getSpanStatusFromHttpCode, httpHeadersToSpanAttributes, getContentLengthFromHeaders, @@ -52,7 +53,6 @@ import { filterCollectedUrlQuery, hasSpanStreamingEnabled, HTTP_SPAN_NAME_FALLBACK, - safeCallback, } from '@sentry/core'; import { DEBUG_BUILD } from '../../debug-build'; import type { NodeClient } from '../../sdk/client'; @@ -105,6 +105,12 @@ export interface HttpServerSpansIntegrationOptions { * This is triggered after the span is created, but before it is recorded. */ onSpanCreated?: (span: Span, request: HttpIncomingMessage, response: HttpServerResponse) => void; + + /** + * A hook that can be used to mutate the span one last time when the response is finished. + * This is triggered after the response status is set on the span, before the span ends. + */ + onSpanEnd?: (span: Span, request: HttpIncomingMessage, response: HttpServerResponse) => void; } const _httpServerSpansIntegration = ((options: HttpServerSpansIntegrationOptions = {}) => { @@ -113,7 +119,7 @@ const _httpServerSpansIntegration = ((options: HttpServerSpansIntegrationOptions // oxlint-disable-next-line typescript/no-deprecated const ignoreStatusCodes = options.ignoreStatusCodes ?? DEFAULT_IGNORE_STATUS_CODES; - const { onSpanCreated } = options; + const { onSpanCreated, onSpanEnd } = options; return { name: INTEGRATION_NAME, @@ -214,6 +220,15 @@ const _httpServerSpansIntegration = ((options: HttpServerSpansIntegrationOptions ); span.setAttributes(newAttributes); span.setStatus(status); + if (onSpanEnd) { + // A hook that throws must not skip `span.end()` or crash the process from the + // response's event listener. + safeCallback( + DEBUG_BUILD ? 'The `onSpanEnd` callback threw an error:' : '', + () => onSpanEnd(span, request, response), + () => undefined, + ); + } span.end(); } diff --git a/packages/node/src/integrations/http/index.ts b/packages/node/src/integrations/http/index.ts index 7431a6a34537..40c07a09ad40 100644 --- a/packages/node/src/integrations/http/index.ts +++ b/packages/node/src/integrations/http/index.ts @@ -93,13 +93,7 @@ interface HttpOptions extends HttpServerIntegrationOptions, HttpServerSpansInteg // shared field fails to compile here, until this integration supports // it or lists it as unsupported. type _ServerRequestOptionsCheck = AssertTrue< - CheckServerRequestOptions< - HttpOptions, - HttpIncomingMessage, - HttpServerResponse, - // Not supported yet. - 'onSpanEnd' - > + CheckServerRequestOptions >; /** From a99c2542857b9f24cf792ee0f02b28d79aa292a1 Mon Sep 17 00:00:00 2001 From: isaacs Date: Tue, 20 Oct 2026 14:11:17 -0700 Subject: [PATCH 04/12] 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 | 41 +++ .../http-server-spans/instrument.server.ts | 18 ++ .../integrations/http-server-spans/test.ts | 71 +++++ .../http-server-spans/vite.config.mts | 7 + .../http-server-spans/wrangler.jsonc | 9 + .../public-api/metrics/server-address/test.ts | 1 - .../cloudflare/src/integrations/httpServer.ts | 133 ++++++++- .../src/wrapRequestHandlerWithInit.ts | 200 ++++++++------ packages/cloudflare/test/request.test.ts | 253 ++++++++++++++++++ 10 files changed, 640 insertions(+), 99 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/instrument.server.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/vite.config.mts 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..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 { From 5f0942a31de5e2dfb156788e65c9684ba96da500 Mon Sep 17 00:00:00 2001 From: isaacs Date: Tue, 20 Oct 2026 14:42:13 -0700 Subject: [PATCH 05/12] feat(node, bun, deno, cloudflare): Align portable exports across server SDKs The server SDKs export different sets of helpers from `@sentry/core` and `@sentry/server-utils`, although the helpers work on each runtime. A user who moves between runtimes, or follows docs written for another one, finds a helper missing. Two of the gaps also hide default integrations: Node and Bun add `awsIntegration` to their defaults, and Deno adds `fastifyIntegration`, but they did not export them. A user who sets `defaultIntegrations` could not add them back. - Node and Bun now export `moduleMetadataIntegration`, `instrumentPostgresJsSql`, `instrumentCreateReactAgent`, `awsIntegration` and the `FlueOptions` type. `@sentry/aws-serverless` and `@sentry/google-cloud-serverless` re-export the new Node values, as the consistent exports check requires. - Deno and Cloudflare now export `parameterize` and `instrumentStateGraphCompile`, so all four runtimes export the same LangGraph helpers. - Bun and Cloudflare now export the `Log` and `LogSeverityLevel` types. - Deno now exports `fastifyIntegration`. Cloudflare's channel integrations (express, pg and so on) are separate work, because they need a warning when the vite plugin is missing. Fixes #25574 Fixes JS-3972 Co-Authored-By: Claude Opus 5.5 (1M context) --- CHANGELOG.md | 9 +++++++ .../suites/public-api/parameterize/index.ts | 15 +++++++++++ .../parameterize/instrument.server.ts | 5 ++++ .../suites/public-api/parameterize/test.ts | 15 +++++++++++ .../public-api/parameterize/vite.config.mts | 7 ++++++ .../public-api/parameterize/wrangler.jsonc | 9 +++++++ .../suites/parameterize/test.ts | 25 +++++++++++++++++++ packages/astro/src/index.server.ts | 4 +++ packages/aws-serverless/src/index.ts | 3 +++ packages/bun/src/index.ts | 6 +++++ packages/cloudflare/src/index.ts | 4 +++ packages/deno/src/index.ts | 3 +++ packages/deno/test/exports.test.ts | 4 +++ packages/google-cloud-serverless/src/index.ts | 4 +++ packages/node/src/index.ts | 4 +++ 15 files changed, 117 insertions(+) create mode 100644 dev-packages/cloudflare-integration-tests/suites/public-api/parameterize/index.ts create mode 100644 dev-packages/cloudflare-integration-tests/suites/public-api/parameterize/instrument.server.ts create mode 100644 dev-packages/cloudflare-integration-tests/suites/public-api/parameterize/test.ts create mode 100644 dev-packages/cloudflare-integration-tests/suites/public-api/parameterize/vite.config.mts create mode 100644 dev-packages/cloudflare-integration-tests/suites/public-api/parameterize/wrangler.jsonc create mode 100644 dev-packages/deno-integration-tests/suites/parameterize/test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 2978ea4dba0a..6ccb43b5b6c4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -69,6 +69,15 @@ Work in this release was contributed by @adityareddy-dev, @jpdefo, @Jxxunnn, @ph `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. +- **feat(node, bun, deno, cloudflare): Align portable exports across server SDKs** + + Node, Bun, Deno and Cloudflare now export the same helpers from `@sentry/core` and `@sentry/server-utils` where they work on that runtime: + + - Node and Bun now export `moduleMetadataIntegration`, `instrumentPostgresJsSql`, `instrumentCreateReactAgent`, `awsIntegration` and the `FlueOptions` type. `awsIntegration` was already in their default integrations. + - Deno and Cloudflare now export `parameterize` and `instrumentStateGraphCompile`. + - Bun and Cloudflare now export the `Log` and `LogSeverityLevel` types. + - Deno now exports `fastifyIntegration`, which was already in its default integrations. + ## 11.8.0 ### Important Changes diff --git a/dev-packages/cloudflare-integration-tests/suites/public-api/parameterize/index.ts b/dev-packages/cloudflare-integration-tests/suites/public-api/parameterize/index.ts new file mode 100644 index 000000000000..55fb7b4466b8 --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/public-api/parameterize/index.ts @@ -0,0 +1,15 @@ +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(): Promise { + const userId = 42; + Sentry.captureMessage(Sentry.parameterize`User ${userId} logged in`); + return new Response('ok'); + }, +} satisfies ExportedHandler; diff --git a/dev-packages/cloudflare-integration-tests/suites/public-api/parameterize/instrument.server.ts b/dev-packages/cloudflare-integration-tests/suites/public-api/parameterize/instrument.server.ts new file mode 100644 index 000000000000..9de8aec879bb --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/public-api/parameterize/instrument.server.ts @@ -0,0 +1,5 @@ +import { defineCloudflareOptions } from '@sentry/cloudflare'; + +export default defineCloudflareOptions((env: { SENTRY_DSN: string }) => ({ + dsn: env.SENTRY_DSN, +})); diff --git a/dev-packages/cloudflare-integration-tests/suites/public-api/parameterize/test.ts b/dev-packages/cloudflare-integration-tests/suites/public-api/parameterize/test.ts new file mode 100644 index 000000000000..b8dc67c3e462 --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/public-api/parameterize/test.ts @@ -0,0 +1,15 @@ +import type { Event } from '@sentry/core'; +import { expect, it } from 'vitest'; +import { createRunner } from '../../../runner'; + +it('parameterize from the public entry sends a message template and its values', async ({ signal }) => { + const runner = createRunner(__dirname) + .expect(envelope => { + const event = envelope[1]?.[0]?.[1] as Event; + expect(event.logentry).toEqual({ message: 'User %s logged in', params: [42] }); + }) + .start(signal); + + await runner.makeRequest('get', '/'); + await runner.completed(); +}); diff --git a/dev-packages/cloudflare-integration-tests/suites/public-api/parameterize/vite.config.mts b/dev-packages/cloudflare-integration-tests/suites/public-api/parameterize/vite.config.mts new file mode 100644 index 000000000000..005f4448f6cb --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/public-api/parameterize/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/public-api/parameterize/wrangler.jsonc b/dev-packages/cloudflare-integration-tests/suites/public-api/parameterize/wrangler.jsonc new file mode 100644 index 000000000000..cbfb247dd034 --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/public-api/parameterize/wrangler.jsonc @@ -0,0 +1,9 @@ +{ + "$schema": "../../../node_modules/wrangler/config-schema.json", + "name": "cloudflare-public-api-parameterize", + // `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/deno-integration-tests/suites/parameterize/test.ts b/dev-packages/deno-integration-tests/suites/parameterize/test.ts new file mode 100644 index 000000000000..16272b3db672 --- /dev/null +++ b/dev-packages/deno-integration-tests/suites/parameterize/test.ts @@ -0,0 +1,25 @@ +// + +import * as Sentry from '@sentry/deno'; +import { assertEquals } from 'https://deno.land/std@0.212.0/assert/assert_equals.ts'; +import { errorSink, resetGlobals, withTimeout } from '../../src/index.ts'; + +Deno.test('parameterize from the public entry sends a message template and its values', async () => { + resetGlobals(); + const sink = errorSink(); + + Sentry.init({ + dsn: 'https://username@domain/123', + beforeSend: sink.beforeSend, + }); + + const userId = 42; + Sentry.captureMessage(Sentry.parameterize`User ${userId} logged in`); + + const event = await withTimeout( + sink.waitFor(e => e.logentry?.message === 'User %s logged in'), + 5_000, + 'parameterized message event', + ); + assertEquals(event.logentry, { message: 'User %s logged in', params: [42] }); +}); diff --git a/packages/astro/src/index.server.ts b/packages/astro/src/index.server.ts index 80158e311e49..86fd2127c724 100644 --- a/packages/astro/src/index.server.ts +++ b/packages/astro/src/index.server.ts @@ -13,6 +13,7 @@ export { addIntegration, amqplibIntegration, anthropicAIIntegration, + awsIntegration, // eslint-disable-next-line typescript/no-deprecated anrIntegration, googleGenAIIntegration, @@ -102,6 +103,7 @@ export { langChainIntegration, langGraphIntegration, createFlueInstrumentation, + instrumentPostgresJsSql, mastraIntegration, piDurableIntegration, mcpServerIntegration, @@ -177,6 +179,8 @@ export { instrumentLangChainEmbeddings, instrumentStateGraph, instrumentStateGraphCompile, + instrumentCreateReactAgent, + moduleMetadataIntegration, zodErrorsIntegration, profiler, logger, diff --git a/packages/aws-serverless/src/index.ts b/packages/aws-serverless/src/index.ts index fd07e76a3fba..994aabb838ce 100644 --- a/packages/aws-serverless/src/index.ts +++ b/packages/aws-serverless/src/index.ts @@ -2,6 +2,7 @@ export { addEventProcessor, addBreadcrumb, addIntegration, + moduleMetadataIntegration, captureException, captureEvent, captureMessage, @@ -71,6 +72,7 @@ export { mcpServerIntegration, SentryMastraExporter, createFlueInstrumentation, + instrumentPostgresJsSql, modulesIntegration, nodeRuntimeMetricsIntegration, type NodeRuntimeMetricsOptions, @@ -159,6 +161,7 @@ export { instrumentGoogleGenAIClient, instrumentStateGraph, instrumentStateGraphCompile, + instrumentCreateReactAgent, zodErrorsIntegration, profiler, amqplibIntegration, diff --git a/packages/bun/src/index.ts b/packages/bun/src/index.ts index 4e91068d21f8..f7c8a286287b 100644 --- a/packages/bun/src/index.ts +++ b/packages/bun/src/index.ts @@ -10,6 +10,8 @@ export type { Exception, Session, SeverityLevel, + Log, + LogSeverityLevel, Span, StackFrame, Stacktrace, @@ -25,6 +27,7 @@ export { addEventProcessor, addBreadcrumb, addIntegration, + moduleMetadataIntegration, captureException, captureEvent, captureMessage, @@ -93,6 +96,7 @@ export { mcpServerIntegration, SentryMastraExporter, createFlueInstrumentation, + instrumentPostgresJsSql, modulesIntegration, contextLinesIntegration, nodeContextIntegration, @@ -176,6 +180,8 @@ export { instrumentGoogleGenAIClient, instrumentStateGraph, instrumentStateGraphCompile, + instrumentCreateReactAgent, + awsIntegration, zodErrorsIntegration, profiler, amqplibIntegration, diff --git a/packages/cloudflare/src/index.ts b/packages/cloudflare/src/index.ts index 88ce8deaac01..8be218013d43 100644 --- a/packages/cloudflare/src/index.ts +++ b/packages/cloudflare/src/index.ts @@ -11,6 +11,8 @@ export type { FeatureFlagsIntegration, Session, SeverityLevel, + Log, + LogSeverityLevel, Span, StackFrame, Stacktrace, @@ -30,6 +32,7 @@ export { captureException, captureEvent, captureMessage, + parameterize, captureFeedback, close, createTransport, @@ -139,6 +142,7 @@ export { createLangChainCallbackHandler, instrumentLangChainEmbeddings, instrumentStateGraph, + instrumentStateGraphCompile, instrumentCreateReactAgent, vercelAIIntegration, honoIntegration, diff --git a/packages/deno/src/index.ts b/packages/deno/src/index.ts index f55fe14de14f..59b68d31034c 100644 --- a/packages/deno/src/index.ts +++ b/packages/deno/src/index.ts @@ -30,6 +30,7 @@ export { captureException, captureEvent, captureMessage, + parameterize, captureFeedback, close, createTransport, @@ -122,6 +123,7 @@ export { createLangChainCallbackHandler, instrumentLangChainEmbeddings, instrumentStateGraph, + instrumentStateGraphCompile, instrumentCreateReactAgent, } from '@sentry/server-utils'; export type { FlueOptions } from '@sentry/server-utils'; @@ -156,6 +158,7 @@ export { genericPoolIntegration, googleGenAIIntegration, graphqlIntegration, + fastifyIntegration, hapiIntegration, honoIntegration, honoMiddleware, diff --git a/packages/deno/test/exports.test.ts b/packages/deno/test/exports.test.ts index c1468653c566..76b4cce1cd18 100644 --- a/packages/deno/test/exports.test.ts +++ b/packages/deno/test/exports.test.ts @@ -12,6 +12,7 @@ const EXPECTED_FUNCTION_EXPORTS = [ 'createConsolaReporter', 'growthbookIntegration', 'moduleMetadataIntegration', + 'parameterize', // @sentry/core/server 'trpcMiddleware', // @sentry/server-utils @@ -24,7 +25,10 @@ const EXPECTED_FUNCTION_EXPORTS = [ 'createLangChainCallbackHandler', 'instrumentLangChainEmbeddings', 'instrumentStateGraph', + 'instrumentStateGraphCompile', 'instrumentCreateReactAgent', + // In the default integrations, so users who set `defaultIntegrations` can add it back + 'fastifyIntegration', // ./sdk 'initWithoutDefaultIntegrations', 'getDefaultIntegrationsWithoutPerformance', diff --git a/packages/google-cloud-serverless/src/index.ts b/packages/google-cloud-serverless/src/index.ts index b90e609bbfc1..2c4cb5726769 100644 --- a/packages/google-cloud-serverless/src/index.ts +++ b/packages/google-cloud-serverless/src/index.ts @@ -2,6 +2,7 @@ export { addEventProcessor, addBreadcrumb, addIntegration, + moduleMetadataIntegration, captureException, captureEvent, captureMessage, @@ -71,6 +72,7 @@ export { mcpServerIntegration, SentryMastraExporter, createFlueInstrumentation, + instrumentPostgresJsSql, modulesIntegration, nodeRuntimeMetricsIntegration, type NodeRuntimeMetricsOptions, @@ -156,6 +158,8 @@ export { instrumentGoogleGenAIClient, instrumentStateGraph, instrumentStateGraphCompile, + instrumentCreateReactAgent, + awsIntegration, zodErrorsIntegration, profiler, amqplibIntegration, diff --git a/packages/node/src/index.ts b/packages/node/src/index.ts index 130194f72cf5..7cc711e110f0 100644 --- a/packages/node/src/index.ts +++ b/packages/node/src/index.ts @@ -6,6 +6,7 @@ export { expressErrorHandler, setupExpressErrorHandler } from '@sentry/server-ut export { amqplibIntegration, anthropicAIIntegration, + awsIntegration, dataloaderIntegration, expressIntegration, type ExpressIntegrationOptions, @@ -57,6 +58,8 @@ export { instrumentLangChainEmbeddings, instrumentStateGraph, instrumentStateGraphCompile, + instrumentCreateReactAgent, + instrumentPostgresJsSql, fastifyIntegration, // oxlint-disable-next-line typescript/no-deprecated setupFastifyErrorHandler, @@ -148,6 +151,7 @@ export { captureSession, endSession, addIntegration, + moduleMetadataIntegration, startSpan, startSpanManual, startInactiveSpan, From e410d628c5f3311c464b54aa2a34c3f03735e9a3 Mon Sep 17 00:00:00 2001 From: isaacs Date: Tue, 20 Oct 2026 14:48:45 -0700 Subject: [PATCH 06/12] feat(cloudflare): Export the channel integrations When the `@sentry/cloudflare/vite` plugin bundles an instrumented package, such as `pg` or `openai`, the SDK adds the matching channel integration. It calls each factory with no arguments, so a Cloudflare user always got the default options. They could not, for example, turn off prompt and response capture with `openAIIntegration({ recordInputs: false })`, as Node, Bun and Deno users can. `@sentry/cloudflare` now exports the channel integrations that Node exports. These are the integrations that the vite plugin can register, so their code already runs on workerd. A user instance passed in `integrations` replaces the registered default with the same name, at `init()` and also when the module loads after `init()`. Both packages set `"sideEffects": false`, so a worker bundles only the integrations it imports. The `withSentry` bundle does not change in size. These integrations get data only when the vite plugin injected the channels. Without the plugin, a configured integration records nothing. On Cloudflare, `invokeOrchestrionInstrumentation` now logs a debug warning, once for each integration, when the plugin did not run. Fixes #25575 Fixes JS-3973 Co-Authored-By: Claude Opus 5.5 (1M context) --- CHANGELOG.md | 16 +++++++ .../openai-options/index.ts | 46 +++++++++++++++++++ .../openai-options/test.ts | 25 ++++++++++ .../openai-options/vite.config.mts | 7 +++ .../openai-options/wrangler.jsonc | 6 +++ packages/cloudflare/src/index.ts | 42 +++++++++++++++++ packages/cloudflare/test/sdk.test.ts | 31 ++++++++++++- .../src/orchestrion/instrumentation.ts | 22 ++++++++- .../test/orchestrion/instrumentation.test.ts | 41 ++++++++++++++++- 9 files changed, 232 insertions(+), 4 deletions(-) create mode 100644 dev-packages/cloudflare-integration-tests/suites/vite/diagnostics-channel/openai-options/index.ts create mode 100644 dev-packages/cloudflare-integration-tests/suites/vite/diagnostics-channel/openai-options/test.ts create mode 100644 dev-packages/cloudflare-integration-tests/suites/vite/diagnostics-channel/openai-options/vite.config.mts create mode 100644 dev-packages/cloudflare-integration-tests/suites/vite/diagnostics-channel/openai-options/wrangler.jsonc diff --git a/CHANGELOG.md b/CHANGELOG.md index 6ccb43b5b6c4..3364374116bc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -78,6 +78,22 @@ Work in this release was contributed by @adityareddy-dev, @jpdefo, @Jxxunnn, @ph - Bun and Cloudflare now export the `Log` and `LogSeverityLevel` types. - Deno now exports `fastifyIntegration`, which was already in its default integrations. +- **feat(cloudflare): Export the channel integrations** + + When the `@sentry/cloudflare/vite` plugin bundles an instrumented package (for example `pg` or `openai`), the SDK adds the matching integration with its default options. `@sentry/cloudflare` now exports these integrations, so you can set their options, for example: + + ```ts + Sentry.withSentry( + env => ({ + dsn: env.SENTRY_DSN, + integrations: [Sentry.openAIIntegration({ recordInputs: false, recordOutputs: false })], + }), + handler, + ); + ``` + + These integrations need the vite plugin. Without it, they record nothing, and with `debug: true` the SDK logs a warning. + ## 11.8.0 ### Important Changes diff --git a/dev-packages/cloudflare-integration-tests/suites/vite/diagnostics-channel/openai-options/index.ts b/dev-packages/cloudflare-integration-tests/suites/vite/diagnostics-channel/openai-options/index.ts new file mode 100644 index 000000000000..1f3692d73837 --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/vite/diagnostics-channel/openai-options/index.ts @@ -0,0 +1,46 @@ +import * as Sentry from '@sentry/cloudflare'; +import OpenAI from 'openai'; + +interface Env { + SENTRY_DSN: string; +} + +// Return a canned response so the `openai` SDK runs on workerd without hitting the network. +const mockFetch: typeof fetch = async () => + new Response( + JSON.stringify({ + id: 'chatcmpl-mock123', + object: 'chat.completion', + created: 1677652288, + model: 'gpt-3.5-turbo', + choices: [ + { + index: 0, + message: { role: 'assistant', content: 'Hello from OpenAI!' }, + finish_reason: 'stop', + }, + ], + usage: { prompt_tokens: 10, completion_tokens: 15, total_tokens: 25 }, + }), + { status: 200, headers: { 'content-type': 'application/json' } }, + ); + +export default Sentry.withSentry( + (env: Env) => ({ + dsn: env.SENTRY_DSN, + tracesSampleRate: 1, + // `dataCollection` records inputs by default. The integration options must win over it. + integrations: [Sentry.openAIIntegration({ recordInputs: false })], + }), + { + async fetch(_request, _env, _ctx) { + // Not wrapped with `instrumentOpenAiClient`: the vite plugin instruments the `openai` package. + const client = new OpenAI({ apiKey: 'mock-api-key', fetch: mockFetch }); + await client.chat.completions.create({ + model: 'gpt-3.5-turbo', + messages: [{ role: 'user', content: 'What is the capital of France?' }], + }); + return new Response('ok'); + }, + }, +); diff --git a/dev-packages/cloudflare-integration-tests/suites/vite/diagnostics-channel/openai-options/test.ts b/dev-packages/cloudflare-integration-tests/suites/vite/diagnostics-channel/openai-options/test.ts new file mode 100644 index 000000000000..8aab877f22f8 --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/vite/diagnostics-channel/openai-options/test.ts @@ -0,0 +1,25 @@ +import { GEN_AI_INPUT_MESSAGES, GEN_AI_RESPONSE_TEXT, SENTRY_OP, SENTRY_ORIGIN } from '@sentry/conventions/attributes'; +import { GEN_AI_CHAT } from '@sentry/conventions/op'; +import { expect, it } from 'vitest'; +import { createRunner } from '../../../../runner'; + +it('applies the options of an openAIIntegration the user configured', async ({ signal }) => { + const runner = createRunner(__dirname).ignore('event').start(signal); + + // The segment span and the gen_ai child. The segment can arrive before the child's envelope. + const spansPromise = runner.collectStreamedSpans(spansOfTrace => spansOfTrace.length === 2); + + await runner.makeRequest('get', '/'); + + const genAiSpans = (await spansPromise).filter(span => !span.is_segment); + expect(genAiSpans).toHaveLength(1); + const attributes = genAiSpans[0]?.attributes; + + // The vite plugin instrumented `openai`, so the channel integration created the span. + expect(attributes?.[SENTRY_OP]).toEqual({ type: 'string', value: GEN_AI_CHAT }); + expect(attributes?.[SENTRY_ORIGIN]).toEqual({ type: 'string', value: 'auto.ai.openai' }); + // `recordInputs: false` from the configured integration wins over `dataCollection`, which records + // inputs by default. Outputs keep the default. + expect(attributes?.[GEN_AI_INPUT_MESSAGES]).toBeUndefined(); + expect(attributes?.[GEN_AI_RESPONSE_TEXT]).toEqual({ type: 'string', value: '["Hello from OpenAI!"]' }); +}); diff --git a/dev-packages/cloudflare-integration-tests/suites/vite/diagnostics-channel/openai-options/vite.config.mts b/dev-packages/cloudflare-integration-tests/suites/vite/diagnostics-channel/openai-options/vite.config.mts new file mode 100644 index 000000000000..cee0e8c0b4b8 --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/vite/diagnostics-channel/openai-options/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({ autoInstrumentation: false })], +}); diff --git a/dev-packages/cloudflare-integration-tests/suites/vite/diagnostics-channel/openai-options/wrangler.jsonc b/dev-packages/cloudflare-integration-tests/suites/vite/diagnostics-channel/openai-options/wrangler.jsonc new file mode 100644 index 000000000000..4c475b2fe276 --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/vite/diagnostics-channel/openai-options/wrangler.jsonc @@ -0,0 +1,6 @@ +{ + "name": "openai-options-worker", + "compatibility_date": "2026-04-26", + "main": "index.ts", + "compatibility_flags": ["nodejs_compat"], +} diff --git a/packages/cloudflare/src/index.ts b/packages/cloudflare/src/index.ts index 8be218013d43..e1fb6e60cee3 100644 --- a/packages/cloudflare/src/index.ts +++ b/packages/cloudflare/src/index.ts @@ -152,6 +152,48 @@ export { getInstrumentedModuleNames, } from '@sentry/server-utils'; +// The orchestrion channel integrations. When the `@sentry/cloudflare/vite` +// plugin bundles an instrumented package, the SDK adds the matching +// integration with its default options. Pass one in `integrations` to set +// options. Without the plugin, these integrations record nothing. +export { + amqplibIntegration, + anthropicAIIntegration, + awsIntegration, + dataloaderIntegration, + expressIntegration, + firebaseIntegration, + genericPoolIntegration, + googleGenAIIntegration, + graphqlIntegration, + groqIntegration, + hapiIntegration, + kafkaIntegration, + knexIntegration, + koaIntegration, + langChainIntegration, + langGraphIntegration, + lruMemoizerIntegration, + mastraIntegration, + mcpServerIntegration, + mistralAIIntegration, + mongoIntegration, + mongooseIntegration, + mysql2Integration, + mysqlIntegration, + neonIntegration, + nitroIntegration, + openAIIntegration, + piDurableIntegration, + postgresIntegration, + postgresJsIntegration, + redisIntegration, + SentryMastraExporter, + tediousIntegration, + togetherAIIntegration, + typesafeIntegration, +} from '@sentry/server-utils'; + export { instrumentWorkflowWithSentry } from './workflows'; export { setAsyncLocalStorageAsyncContextStrategy } from '@sentry/server-utils/no-diagnostic-channels'; diff --git a/packages/cloudflare/test/sdk.test.ts b/packages/cloudflare/test/sdk.test.ts index dcbbb7089da4..d1be4d2d4599 100644 --- a/packages/cloudflare/test/sdk.test.ts +++ b/packages/cloudflare/test/sdk.test.ts @@ -6,7 +6,7 @@ import type { CloudflareOptions } from '../src/client'; import { CloudflareClient } from '../src/client'; import { getDefaultIntegrations, init } from '../src/sdk'; import { resetSdk } from './testUtils'; -import { spanStreamingIntegration } from '../src/'; +import { mysqlIntegration as exportedMysqlIntegration, spanStreamingIntegration } from '../src/'; describe('init', () => { beforeEach(() => { @@ -443,4 +443,33 @@ describe('getDefaultIntegrations', () => { expect(client?.getIntegrationByName('Mysql')).toBeDefined(); }); + + test('keeps a channel integration the user configured instead of the registered default', async () => { + resetSdk(); + const { mysqlIntegration } = await import('@sentry/server-utils'); + globalThis.__SENTRY_ORCHESTRION__ = { + bundler: new Set(['mysql']), + integrations: new Map([['mysql', mysqlIntegration]]), + }; + const userInstance = exportedMysqlIntegration(); + + const client = init({ dsn: 'https://public@dsn.ingest.sentry.io/1337', integrations: [userInstance] }); + + expect(client?.getIntegrationByName('Mysql')).toBe(userInstance); + }); + + test('keeps a channel integration the user configured when its module loads after init', async () => { + resetSdk(); + const { mysqlIntegration } = await import('@sentry/server-utils'); + const userInstance = exportedMysqlIntegration(); + const client = init({ dsn: 'https://public@dsn.ingest.sentry.io/1337', integrations: [userInstance] }); + + globalThis.__SENTRY_ORCHESTRION__ = { + bundler: new Set(['mysql']), + integrations: new Map([['mysql', mysqlIntegration]]), + }; + client?.emit('orchestrion.module-injected', 'mysql'); + + expect(client?.getIntegrationByName('Mysql')).toBe(userInstance); + }); }); diff --git a/packages/server-utils/src/orchestrion/instrumentation.ts b/packages/server-utils/src/orchestrion/instrumentation.ts index ded8aa30ac68..0dbddefc4da7 100644 --- a/packages/server-utils/src/orchestrion/instrumentation.ts +++ b/packages/server-utils/src/orchestrion/instrumentation.ts @@ -1,6 +1,6 @@ import * as diagnosticsChannel from '../utils/diagnosticsChannel'; import type { Client } from '@sentry/core'; -import { addNonEnumerableProperty, debug, waitForTracingChannelBinding } from '@sentry/core'; +import { addNonEnumerableProperty, debug, GLOBAL_OBJ, waitForTracingChannelBinding } from '@sentry/core'; import { DEBUG_BUILD } from '../debug-build'; import { getOrchestrionInjectedModules } from './detect'; @@ -13,10 +13,13 @@ const INSTRUMENTED = Symbol.for('SentryOrchestrionInstrumented'); // oxlint-disable-next-line typescript/no-explicit-any type InstrumentationFn = ((...args: any[]) => void) & { [INSTRUMENTED]?: boolean }; -const globalAny = globalThis as { Bun?: unknown; Deno?: unknown }; +const globalAny = globalThis as { Bun?: unknown; Deno?: unknown; navigator?: { userAgent?: string } }; const isBun = typeof globalAny.Bun !== 'undefined'; const isDeno = typeof globalAny.Deno !== 'undefined'; +// Labels already warned about, so a client per request does not repeat the warning. +const warnedMissingCloudflarePlugin = new Set(); + /** * Run an integration's channel-subscription callback once one of its modules is * orchestrion-injected, and never more than once. @@ -113,6 +116,21 @@ export function invokeOrchestrionInstrumentation { diff --git a/packages/server-utils/test/orchestrion/instrumentation.test.ts b/packages/server-utils/test/orchestrion/instrumentation.test.ts index fce7ffc3bf26..07c61fd5bb7a 100644 --- a/packages/server-utils/test/orchestrion/instrumentation.test.ts +++ b/packages/server-utils/test/orchestrion/instrumentation.test.ts @@ -1,5 +1,5 @@ import type { AsyncContextStrategy } from '@sentry/core'; -import { GLOBAL_OBJ, setAsyncContextStrategy } from '@sentry/core'; +import { debug, GLOBAL_OBJ, setAsyncContextStrategy } from '@sentry/core'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { invokeOrchestrionInstrumentation } from '../../src/orchestrion/instrumentation'; @@ -190,4 +190,43 @@ describe('invokeOrchestrionInstrumentation', () => { expect(callback).not.toHaveBeenCalled(); expect(client.listenerCount()).toBe(1); }); + + describe('on Cloudflare', () => { + beforeEach(() => { + vi.stubGlobal('navigator', { userAgent: 'Cloudflare-Workers' }); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + }); + + it('warns once when the vite plugin did not run', () => { + const warn = vi.spyOn(debug, 'warn').mockImplementation(() => undefined); + + invokeOrchestrionInstrumentation(makeClient() as never, ['openai'], vi.fn(), []); + invokeOrchestrionInstrumentation(makeClient() as never, ['openai'], vi.fn(), []); + + expect(warn).toHaveBeenCalledTimes(1); + expect(warn).toHaveBeenCalledWith(expect.stringContaining('`@sentry/cloudflare/vite` plugin did not run')); + }); + + it('does not warn when the vite plugin ran, even before any module loaded', () => { + GLOBAL_OBJ.__SENTRY_ORCHESTRION__ = { bundler: new Set() }; + const warn = vi.spyOn(debug, 'warn').mockImplementation(() => undefined); + + invokeOrchestrionInstrumentation(makeClient() as never, ['pg'], vi.fn(), []); + + expect(warn).not.toHaveBeenCalled(); + }); + }); + + it('does not warn about the Cloudflare plugin on other runtimes', () => { + const warn = vi.spyOn(debug, 'warn').mockImplementation(() => undefined); + + invokeOrchestrionInstrumentation(makeClient() as never, ['redis'], vi.fn(), []); + + expect(warn).not.toHaveBeenCalled(); + warn.mockRestore(); + }); }); From 169d47797a5be1a0f61fab81ae1a52e638800e5f Mon Sep 17 00:00:00 2001 From: isaacs Date: Tue, 20 Oct 2026 14:53:24 -0700 Subject: [PATCH 07/12] fix(cloudflare): Do not add back channel integrations the user removed The `@sentry/cloudflare/vite` plugin stores an integration factory for each instrumented package when the package first runs. At `init()`, the SDK adds the stored factories to the defaults. A package that runs after `init()`, for example a driver loaded with a dynamic `import()`, emits `orchestrion.module-injected`, and the SDK then called `client.addIntegration(factory())`. That handler did not check the user's options, so it ignored each way to opt out: an `integrations` function that filtered the integration out, `defaultIntegrations: false`, and a custom `defaultIntegrations` list. The user got the integration back. For the AI integrations, that can turn prompt and response capture back on. Whether it happened depended on module load order. The handler now applies the rules that `init()` applies to the defaults: - If the user set `defaultIntegrations`, the SDK defaults are off, so the handler adds nothing. - If `integrations` is a function, the handler passes the late integration through it with `getIntegrationsToSetup` and installs the result with the same name, if there is one. This calls the function again, once for each late module. If the function throws, the handler skips the integration, so the module load that emitted the event still works. - If `integrations` is an array, nothing changes. An array cannot remove a default, and `addIntegration` already skips a name the user configured. Fixes #25576 Fixes JS-3974 Co-Authored-By: Claude Opus 5.5 (1M context) --- CHANGELOG.md | 4 ++ packages/cloudflare/src/baseSdk.ts | 38 ++++++++++--- packages/cloudflare/test/sdk.test.ts | 79 ++++++++++++++++++++++++++++ 3 files changed, 115 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3364374116bc..3803a5d99c30 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -94,6 +94,10 @@ Work in this release was contributed by @adityareddy-dev, @jpdefo, @Jxxunnn, @ph These integrations need the vite plugin. Without it, they record nothing, and with `debug: true` the SDK logs a warning. +- **fix(cloudflare): Do not add back channel integrations the user removed** + + When an instrumented package loaded after `init()` (for example through a dynamic `import()`), the SDK added its integration even if the user had removed it. This applied to an `integrations` function that filtered it out, `defaultIntegrations: false`, and a custom `defaultIntegrations` list. The SDK now applies the same rules to a late-loaded integration as to the defaults at `init()`. To do this, it calls an `integrations` function again, once for each package that loads after `init()`. + ## 11.8.0 ### Important Changes diff --git a/packages/cloudflare/src/baseSdk.ts b/packages/cloudflare/src/baseSdk.ts index 0375e7f94601..42814624ca07 100644 --- a/packages/cloudflare/src/baseSdk.ts +++ b/packages/cloudflare/src/baseSdk.ts @@ -1,5 +1,5 @@ import type { Integration } from '@sentry/core'; -import { _INTERNAL_isClientClosed, debug, getCurrentScope, setCurrentClient } from '@sentry/core'; +import { _INTERNAL_isClientClosed, debug, getCurrentScope, safeCallback, setCurrentClient } from '@sentry/core'; import { consoleIntegration, conversationIdIntegration, @@ -107,7 +107,9 @@ export function initWithDefaultIntegrations( return cached; } - if (options.defaultIntegrations === undefined) { + // `false` or a custom list turns off the SDK defaults, late-loaded ones included. + const usesSdkDefaults = options.defaultIntegrations === undefined; + if (usesSdkDefaults) { options.defaultIntegrations = getDefaultIntegrationsImpl(options); } @@ -153,12 +155,36 @@ export function initWithDefaultIntegrations( // lazily required on first use) stores its subscriber factory on the global // marker too late for the default-integrations snapshot above. Its injected // snippet emits this event right after storing the factory, so install the - // integration on the live client here. `addIntegration` dedupes by - // integration name, so already-installed integrations are no-ops. + // integration on the live client here, with the same rules as the defaults. + // `addIntegration` dedupes by integration name, so an integration the user + // configured stays in place. + const userIntegrations = options.integrations; client.on('orchestrion.module-injected', moduleName => { const factory = GLOBAL_OBJ.__SENTRY_ORCHESTRION__?.integrations?.get(moduleName); - if (factory) { - client.addIntegration(factory()); + if (!usesSdkDefaults || !factory) { + return; + } + + const integration = factory(); + if (typeof userIntegrations !== 'function') { + client.addIntegration(integration); + return; + } + + // Pass the late default through the user's `integrations` function, so a + // filter that removed it at init removes it here too. This calls the + // function again, once for each late module. If the function throws, skip + // the integration, so the module load that emitted the event still works. + const kept = safeCallback( + DEBUG_BUILD ? 'Error in the `integrations` function, skipping a late-loaded integration:' : '', + () => + getIntegrationsToSetup({ defaultIntegrations: [integration], integrations: userIntegrations }).find( + ({ name }) => name === integration.name, + ), + () => undefined, + ); + if (kept) { + client.addIntegration(kept); } }); diff --git a/packages/cloudflare/test/sdk.test.ts b/packages/cloudflare/test/sdk.test.ts index d1be4d2d4599..779af11162bb 100644 --- a/packages/cloudflare/test/sdk.test.ts +++ b/packages/cloudflare/test/sdk.test.ts @@ -458,6 +458,85 @@ describe('getDefaultIntegrations', () => { expect(client?.getIntegrationByName('Mysql')).toBe(userInstance); }); + describe('when a channel integration module loads after init', () => { + const DSN = 'https://public@dsn.ingest.sentry.io/1337'; + + async function loadMysqlLate(client: CloudflareClient | undefined): Promise { + const { mysqlIntegration } = await import('@sentry/server-utils'); + globalThis.__SENTRY_ORCHESTRION__ = { + bundler: new Set(['mysql']), + integrations: new Map([['mysql', mysqlIntegration]]), + }; + client?.emit('orchestrion.module-injected', 'mysql'); + } + + test('does not add it when the integrations function filtered it out', async () => { + resetSdk(); + const client = init({ + dsn: DSN, + integrations: defaults => defaults.filter(integration => integration.name !== 'Mysql'), + }); + + await loadMysqlLate(client); + + expect(client?.getIntegrationByName('Mysql')).toBeUndefined(); + }); + + test('adds it when the integrations function keeps it', async () => { + resetSdk(); + const client = init({ dsn: DSN, integrations: defaults => defaults }); + + await loadMysqlLate(client); + + expect(client?.getIntegrationByName('Mysql')).toBeDefined(); + }); + + test('keeps the instance the integrations function configured', async () => { + resetSdk(); + const client = init({ + dsn: DSN, + integrations: defaults => [...defaults, exportedMysqlIntegration()], + }); + const configured = client?.getIntegrationByName('Mysql'); + expect(configured).toBeDefined(); + + await loadMysqlLate(client); + + expect(client?.getIntegrationByName('Mysql')).toBe(configured); + }); + + test('does not add it and does not throw when the integrations function throws', async () => { + resetSdk(); + let calls = 0; + const client = init({ + dsn: DSN, + integrations: defaults => { + calls++; + if (calls > 1) { + throw new Error('integrations failed'); + } + return defaults; + }, + }); + + await expect(loadMysqlLate(client)).resolves.toBeUndefined(); + + expect(client?.getIntegrationByName('Mysql')).toBeUndefined(); + }); + + test.each([ + ['false', false as const], + ['a custom list', [] as Integration[]], + ])('does not add it when defaultIntegrations is %s', async (_label, defaultIntegrations) => { + resetSdk(); + const client = init({ dsn: DSN, defaultIntegrations }); + + await loadMysqlLate(client); + + expect(client?.getIntegrationByName('Mysql')).toBeUndefined(); + }); + }); + test('keeps a channel integration the user configured when its module loads after init', async () => { resetSdk(); const { mysqlIntegration } = await import('@sentry/server-utils'); From 388d968063f363b78b28e13dfd3efba98719fba5 Mon Sep 17 00:00:00 2001 From: isaacs Date: Thu, 22 Oct 2026 20:00:34 -0700 Subject: [PATCH 08/12] fix(cloudflare): Add late channel integrations for SDKs that extend the defaults The Nuxt and SvelteKit Cloudflare SDKs build `defaultIntegrations` from the Cloudflare defaults and pass the list to the SDK. Since the SDK treats any `defaultIntegrations` list as an opt-out, it did not add the channel integration of a module that loads after `init()`, such as the `mysql` driver in a route chunk. The `nuxt-4-cloudflare` and `sveltekit-cloudflare-pages` E2E tests then got no db spans and timed out. The new internal option `extendsDefaultIntegrations` tells the SDK that the list extends the defaults, so it adds late integrations again. The user's `integrations` function still filters them. Nuxt and SvelteKit set the option only when they build the list. A list that the user passes still turns off late integrations. Co-Authored-By: Claude Opus 5.5 (1M context) --- packages/cloudflare/src/baseSdk.ts | 7 ++- packages/cloudflare/src/client.ts | 10 ++++ packages/cloudflare/test/sdk.test.ts | 23 ++++++++ .../plugins/sentry-cloudflare.server.ts | 6 +- .../plugins/sentry-cloudflare.server.test.ts | 56 +++++++++++++++++++ packages/sveltekit/src/worker/cloudflare.ts | 1 + .../sveltekit/test/worker/cloudflare.test.ts | 25 ++++++++- 7 files changed, 123 insertions(+), 5 deletions(-) create mode 100644 packages/nuxt/test/runtime/plugins/sentry-cloudflare.server.test.ts diff --git a/packages/cloudflare/src/baseSdk.ts b/packages/cloudflare/src/baseSdk.ts index 42814624ca07..0a9f6a1cf709 100644 --- a/packages/cloudflare/src/baseSdk.ts +++ b/packages/cloudflare/src/baseSdk.ts @@ -107,9 +107,10 @@ export function initWithDefaultIntegrations( return cached; } - // `false` or a custom list turns off the SDK defaults, late-loaded ones included. - const usesSdkDefaults = options.defaultIntegrations === undefined; - if (usesSdkDefaults) { + // `false` or a custom list turns off the SDK defaults, late-loaded ones + // included, unless an SDK built the list from the defaults. + const usesSdkDefaults = options.defaultIntegrations === undefined || !!options.extendsDefaultIntegrations; + if (options.defaultIntegrations === undefined) { options.defaultIntegrations = getDefaultIntegrationsImpl(options); } diff --git a/packages/cloudflare/src/client.ts b/packages/cloudflare/src/client.ts index 051ffd8627d2..be45095e1481 100644 --- a/packages/cloudflare/src/client.ts +++ b/packages/cloudflare/src/client.ts @@ -351,6 +351,16 @@ interface BaseCloudflareOptions { */ enableDedupe?: boolean; + /** + * @ignore Used internally by SDKs that build `defaultIntegrations` from the Cloudflare defaults. + * @hidden Used internally by SDKs that build `defaultIntegrations` from the Cloudflare defaults. + * + * Marks the `defaultIntegrations` list as an extension of the SDK defaults, so the SDK also adds + * the integrations of instrumented modules that load after `init()`. + * @default false + */ + extendsDefaultIntegrations?: boolean; + /** * The Cloudflare SDK is not OpenTelemetry native. By default (`false`) it does not set up a tracer * provider; spans are emitted via the SDK's own instrumentation and scopes are isolated with diff --git a/packages/cloudflare/test/sdk.test.ts b/packages/cloudflare/test/sdk.test.ts index 779af11162bb..60254f504a66 100644 --- a/packages/cloudflare/test/sdk.test.ts +++ b/packages/cloudflare/test/sdk.test.ts @@ -524,6 +524,29 @@ describe('getDefaultIntegrations', () => { expect(client?.getIntegrationByName('Mysql')).toBeUndefined(); }); + test('adds it when a custom defaultIntegrations list extends the SDK defaults', async () => { + resetSdk(); + const client = init({ dsn: DSN, defaultIntegrations: [], extendsDefaultIntegrations: true }); + + await loadMysqlLate(client); + + expect(client?.getIntegrationByName('Mysql')).toBeDefined(); + }); + + test('does not add it when the integrations function filtered it out of a list that extends the SDK defaults', async () => { + resetSdk(); + const client = init({ + dsn: DSN, + defaultIntegrations: [], + extendsDefaultIntegrations: true, + integrations: defaults => defaults.filter(integration => integration.name !== 'Mysql'), + }); + + await loadMysqlLate(client); + + expect(client?.getIntegrationByName('Mysql')).toBeUndefined(); + }); + test.each([ ['false', false as const], ['a custom list', [] as Integration[]], diff --git a/packages/nuxt/src/runtime/plugins/sentry-cloudflare.server.ts b/packages/nuxt/src/runtime/plugins/sentry-cloudflare.server.ts index cc2fcb1c3315..46c18d6f4aa8 100644 --- a/packages/nuxt/src/runtime/plugins/sentry-cloudflare.server.ts +++ b/packages/nuxt/src/runtime/plugins/sentry-cloudflare.server.ts @@ -71,7 +71,11 @@ export const sentryCloudflareNitroPlugin = const requestHandlerOptions = { // `wrapRequestHandler` only sets up the integrations that work without `nodejs_compat`. // Nitro's Cloudflare presets require the flag anyway, so opt into the full set. - options: { defaultIntegrations: getDefaultIntegrations(cloudflareOptions), ...cloudflareOptions }, + options: { + defaultIntegrations: getDefaultIntegrations(cloudflareOptions), + extendsDefaultIntegrations: cloudflareOptions.defaultIntegrations === undefined, + ...cloudflareOptions, + }, request, context: getCloudflareProperties(event).context, }; diff --git a/packages/nuxt/test/runtime/plugins/sentry-cloudflare.server.test.ts b/packages/nuxt/test/runtime/plugins/sentry-cloudflare.server.test.ts new file mode 100644 index 000000000000..b2e720545c8a --- /dev/null +++ b/packages/nuxt/test/runtime/plugins/sentry-cloudflare.server.test.ts @@ -0,0 +1,56 @@ +import type { CloudflareOptions } from '@sentry/cloudflare'; +import { wrapRequestHandler } from '@sentry/cloudflare/request'; +import type { NitroApp } from 'nitropack'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { sentryCloudflareNitroPlugin } from '../../../src/runtime/plugins/sentry-cloudflare.server'; + +vi.mock('@sentry/cloudflare/request', () => ({ + wrapRequestHandler: vi.fn((_options: unknown, callback: () => unknown) => callback()), +})); + +const DSN = 'https://public@dsn.ingest.sentry.io/1337'; + +async function handleRequest(options: CloudflareOptions): Promise { + const nitroApp = { localFetch: vi.fn(), hooks: { hook: vi.fn() } } as unknown as NitroApp; + sentryCloudflareNitroPlugin(options)(nitroApp); + + const event = { + protocol: 'https:', + host: 'example.com', + method: 'GET', + headers: {}, + context: { cf: {}, cloudflare: { context: {} } }, + }; + await nitroApp.localFetch('/api/db-mysql', event as unknown as RequestInit); +} + +describe('sentryCloudflareNitroPlugin', () => { + beforeEach(() => { + vi.mocked(wrapRequestHandler).mockClear(); + }); + + it('passes a defaultIntegrations list that extends the SDK defaults', async () => { + await handleRequest({ dsn: DSN }); + + expect(wrapRequestHandler).toHaveBeenCalledWith( + expect.objectContaining({ + options: expect.objectContaining({ + defaultIntegrations: expect.any(Array), + extendsDefaultIntegrations: true, + }), + }), + expect.any(Function), + ); + }); + + it('does not mark a defaultIntegrations list the user passed as extending the SDK defaults', async () => { + await handleRequest({ dsn: DSN, defaultIntegrations: [] }); + + expect(wrapRequestHandler).toHaveBeenCalledWith( + expect.objectContaining({ + options: expect.objectContaining({ defaultIntegrations: [], extendsDefaultIntegrations: false }), + }), + expect.any(Function), + ); + }); +}); diff --git a/packages/sveltekit/src/worker/cloudflare.ts b/packages/sveltekit/src/worker/cloudflare.ts index 651cc6af6f37..f7298f1539b5 100644 --- a/packages/sveltekit/src/worker/cloudflare.ts +++ b/packages/sveltekit/src/worker/cloudflare.ts @@ -23,6 +23,7 @@ export function initCloudflareSentryHandle(options: CloudflareOptions): Handle { rewriteFramesIntegration(), svelteKitSpansIntegration(), ], + extendsDefaultIntegrations: options.defaultIntegrations === undefined, // SvelteKit emits its own OpenTelemetry spans (Kit tracing), so, like the Node SvelteKit SDK, it // defaults to registering the tracer provider instead of inheriting Cloudflare's no-provider default. // Only the `init`-backed wrapper from the main entry point honors this; `@sentry/cloudflare/request` diff --git a/packages/sveltekit/test/worker/cloudflare.test.ts b/packages/sveltekit/test/worker/cloudflare.test.ts index dea694b292ec..f4b24b641b4e 100644 --- a/packages/sveltekit/test/worker/cloudflare.test.ts +++ b/packages/sveltekit/test/worker/cloudflare.test.ts @@ -63,7 +63,11 @@ describe('initCloudflareSentryHandle', () => { { // SvelteKit emits its own OpenTelemetry spans, so it opts into the tracer provider rather than // inheriting Cloudflare's no-provider default. - options: expect.objectContaining({ dsn: options.dsn, enableOpenTelemetrySetup: true }), + options: expect.objectContaining({ + dsn: options.dsn, + enableOpenTelemetrySetup: true, + extendsDefaultIntegrations: true, + }), request, context, captureErrors: false, @@ -74,6 +78,25 @@ describe('initCloudflareSentryHandle', () => { expect(resolve).toHaveBeenCalledTimes(1); }); + it('does not mark a defaultIntegrations list the user passed as extending the SDK defaults', async () => { + const { options, event, resolve } = getHandlerInput(); + + // @ts-expect-error - resolving an empty object is enough for this test + vi.mocked(wrapRequestHandler).mockImplementationOnce((_, cb) => cb()); + + const handle = initCloudflareSentryHandle({ ...options, defaultIntegrations: [] }); + + // @ts-expect-error - only passing a partial event object + await handle({ event, resolve }); + + expect(wrapRequestHandler).toHaveBeenCalledWith( + expect.objectContaining({ + options: expect.objectContaining({ defaultIntegrations: [], extendsDefaultIntegrations: false }), + }), + expect.any(Function), + ); + }); + it('adds flag to skip request isolation in subsequent sentry handler', async () => { const { options, event, resolve } = getHandlerInput(); const locals = {}; From f02ec68017d5eef076c7982312d9d650654a1b58 Mon Sep 17 00:00:00 2001 From: isaacs Date: Thu, 22 Oct 2026 21:09:55 -0700 Subject: [PATCH 09/12] wip: testing why CI is failing by showing the real error --- dev-packages/e2e-tests/test-applications/nextjs-16/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16/package.json b/dev-packages/e2e-tests/test-applications/nextjs-16/package.json index 34b8d7f62dc9..d3d7771d65c4 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-16/package.json +++ b/dev-packages/e2e-tests/test-applications/nextjs-16/package.json @@ -7,7 +7,7 @@ "build": "next build > .tmp_build_stdout 2> .tmp_build_stderr || (cat .tmp_build_stdout && cat .tmp_build_stderr && exit 1)", "clean": "npx rimraf node_modules pnpm-lock.yaml .tmp_dev_server_logs", "dev:webpack": "next dev --webpack", - "build-webpack": "next build --webpack > .tmp_build_stdout 2> .tmp_build_stderr || (cat .tmp_build_stdout && cat .tmp_build_stderr && exit 1)", + "build-webpack": "next build --webpack --debug-prerender > .tmp_build_stdout 2> .tmp_build_stderr || (cat .tmp_build_stdout && cat .tmp_build_stderr && exit 1)", "start": "next start", "lint": "eslint", "test:prod": "TEST_ENV=production playwright test", From 9ff84ff4f84584cf6f69ea8ca9720f3bc3aaf18e Mon Sep 17 00:00:00 2001 From: isaacs Date: Thu, 22 Oct 2026 21:16:43 -0700 Subject: [PATCH 10/12] fixup! wip: testing why CI is failing by showing the real error --- dev-packages/e2e-tests/test-applications/nextjs-16/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16/package.json b/dev-packages/e2e-tests/test-applications/nextjs-16/package.json index d3d7771d65c4..460acfde84da 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-16/package.json +++ b/dev-packages/e2e-tests/test-applications/nextjs-16/package.json @@ -7,7 +7,7 @@ "build": "next build > .tmp_build_stdout 2> .tmp_build_stderr || (cat .tmp_build_stdout && cat .tmp_build_stderr && exit 1)", "clean": "npx rimraf node_modules pnpm-lock.yaml .tmp_dev_server_logs", "dev:webpack": "next dev --webpack", - "build-webpack": "next build --webpack --debug-prerender > .tmp_build_stdout 2> .tmp_build_stderr || (cat .tmp_build_stdout && cat .tmp_build_stderr && exit 1)", + "build-webpack": "next build --webpack > .tmp_build_stdout 2> .tmp_build_stderr || (cat .tmp_build_stdout && cat .tmp_build_stderr && echo 'Building again with --debug-prerender to show the real error' && next build --webpack --debug-prerender; exit 1)", "start": "next start", "lint": "eslint", "test:prod": "TEST_ENV=production playwright test", From 18d2100e2d938e0731eeee8c16eb8f36c46a45d2 Mon Sep 17 00:00:00 2001 From: isaacs Date: Thu, 22 Oct 2026 21:38:39 -0700 Subject: [PATCH 11/12] more ci debugging --- .../test-applications/nextjs-16/package.json | 2 +- .../nextjs-16/print-build-diagnostics.mjs | 84 +++++++++++++++++++ 2 files changed, 85 insertions(+), 1 deletion(-) create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-16/print-build-diagnostics.mjs diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16/package.json b/dev-packages/e2e-tests/test-applications/nextjs-16/package.json index 460acfde84da..17e3518402ce 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-16/package.json +++ b/dev-packages/e2e-tests/test-applications/nextjs-16/package.json @@ -7,7 +7,7 @@ "build": "next build > .tmp_build_stdout 2> .tmp_build_stderr || (cat .tmp_build_stdout && cat .tmp_build_stderr && exit 1)", "clean": "npx rimraf node_modules pnpm-lock.yaml .tmp_dev_server_logs", "dev:webpack": "next dev --webpack", - "build-webpack": "next build --webpack > .tmp_build_stdout 2> .tmp_build_stderr || (cat .tmp_build_stdout && cat .tmp_build_stderr && echo 'Building again with --debug-prerender to show the real error' && next build --webpack --debug-prerender; exit 1)", + "build-webpack": "next build --webpack > .tmp_build_stdout 2> .tmp_build_stderr || (cat .tmp_build_stdout && cat .tmp_build_stderr && node print-build-diagnostics.mjs; echo 'Building again with --debug-prerender to show the real error' && next build --webpack --debug-prerender; exit 1)", "start": "next start", "lint": "eslint", "test:prod": "TEST_ENV=production playwright test", diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16/print-build-diagnostics.mjs b/dev-packages/e2e-tests/test-applications/nextjs-16/print-build-diagnostics.mjs new file mode 100644 index 000000000000..ff2241585d99 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16/print-build-diagnostics.mjs @@ -0,0 +1,84 @@ +// Temporary: prints what CI needs to explain the "not in the React Client +// Manifest" build failure. Runs after a failed `next build`. +import fs from 'node:fs'; +import { createRequire } from 'node:module'; +import os from 'node:os'; +import path from 'node:path'; + +function section(title) { + console.log(`\n=== ${title}`); +} + +function walk(dir) { + if (!fs.existsSync(dir)) { + return []; + } + return fs.readdirSync(dir, { withFileTypes: true }).flatMap(entry => { + const full = path.join(dir, entry.name); + return entry.isDirectory() ? walk(full) : [full]; + }); +} + +section('Environment'); +console.log({ + platform: process.platform, + arch: process.arch, + machine: os.machine(), + cpus: os.cpus().length, + node: process.version, + cwd: process.cwd(), +}); + +section('oxc-parser'); +const pnpmDir = path.join('node_modules', '.pnpm'); +const oxcDirs = fs.existsSync(pnpmDir) ? fs.readdirSync(pnpmDir).filter(name => name.includes('oxc-parser')) : []; +console.log('installed:', oxcDirs.length ? oxcDirs : '(none found in node_modules/.pnpm)'); +try { + const oxcDir = oxcDirs.find(name => name.startsWith('oxc-parser@')); + const { parseSync } = createRequire(path.resolve(pnpmDir, oxcDir, 'node_modules/oxc-parser/package.json'))( + 'oxc-parser', + ); + const source = fs.readFileSync('app/global-error.tsx', 'utf8'); + const { program, errors } = parseSync('global-error.tsx', source); + console.log('loads: yes, parse errors:', errors.length); + console.log( + 'directives:', + program.body.filter(node => node.directive).map(node => node.directive), + ); +} catch (error) { + console.log('loads: no', error); +} + +section('Client reference manifest'); +const manifest = '.next/server/app/_global-error/page_client-reference-manifest.js'; +if (fs.existsSync(manifest)) { + const content = fs.readFileSync(manifest, 'utf8'); + console.log( + `${manifest}: ${content.length} bytes, "global-error" appears ${content.split('global-error').length - 1} times`, + ); +} else { + console.log(`${manifest}: missing`); +} + +section('Compiled global-error modules'); +const clientMarker = '__next_internal_client_entry_do_not_use__'; +let otherFiles = 0; +for (const file of [...walk('.next/server'), ...walk('.next/static/chunks')]) { + if (!file.endsWith('.js') || file.endsWith('_client-reference-manifest.js')) { + continue; + } + const content = fs.readFileSync(file, 'utf8'); + if (!content.includes('global-error')) { + continue; + } + if (!file.includes('global-error')) { + otherFiles++; + continue; + } + console.log(`${file}: client entry marker ${content.includes(clientMarker) ? 'present' : 'absent'}`); + const index = content.indexOf('global-error.tsx'); + console.log( + ` near "global-error.tsx": ${content.slice(Math.max(0, index - 150), index + 150).replace(/\s+/g, ' ')}`, + ); +} +console.log(`${otherFiles} other files mention "global-error"`); From 64d4723e804bc21b6d33b87e1e68465c2bf66edb Mon Sep 17 00:00:00 2001 From: isaacs Date: Thu, 22 Oct 2026 21:48:08 -0700 Subject: [PATCH 12/12] more ci experiments --- .../nextjs-16/next.config.ts | 2 +- .../nextjs-16/print-build-diagnostics.mjs | 19 +++++++++++++++++++ 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16/next.config.ts b/dev-packages/e2e-tests/test-applications/nextjs-16/next.config.ts index 6893bd77fdf7..791edf34afd2 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-16/next.config.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-16/next.config.ts @@ -20,7 +20,7 @@ export default withSentryConfig(nextConfig, { silent: true, applicationKey: 'nextjs-16-e2e', reactComponentAnnotation: { - enabled: true, + enabled: false, }, _experimental: { vercelCronsMonitoring: true, diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16/print-build-diagnostics.mjs b/dev-packages/e2e-tests/test-applications/nextjs-16/print-build-diagnostics.mjs index ff2241585d99..edf84fe170a4 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-16/print-build-diagnostics.mjs +++ b/dev-packages/e2e-tests/test-applications/nextjs-16/print-build-diagnostics.mjs @@ -49,6 +49,21 @@ try { console.log('loads: no', error); } +section('Component annotation transform'); +try { + const pluginsDir = fs.readdirSync(pnpmDir).find(name => name.startsWith('@sentry+bundler-plugins@')); + const { createComponentNameAnnotateHooks } = createRequire( + path.resolve(pnpmDir, pluginsDir, 'node_modules/@sentry/bundler-plugins/package.json'), + )('@sentry/bundler-plugins/core'); + const file = path.resolve('app/global-error.tsx'); + const source = fs.readFileSync(file, 'utf8'); + const result = await createComponentNameAnnotateHooks([], false).transform(source, file); + const code = typeof result === 'string' ? result : result?.code; + console.log(code === undefined ? 'no change' : `start: ${JSON.stringify(code.slice(0, 120))}`); +} catch (error) { + console.log('failed:', error); +} + section('Client reference manifest'); const manifest = '.next/server/app/_global-error/page_client-reference-manifest.js'; if (fs.existsSync(manifest)) { @@ -56,6 +71,10 @@ if (fs.existsSync(manifest)) { console.log( `${manifest}: ${content.length} bytes, "global-error" appears ${content.split('global-error').length - 1} times`, ); + let index = -1; + while ((index = content.indexOf('global-error', index + 1)) !== -1) { + console.log(` - ${content.slice(Math.max(0, index - 90), index + 90).replace(/\s+/g, ' ')}`); + } } else { console.log(`${manifest}: missing`); }