From 5f347d439a7ff48b652213bb37c7ec8020104fcb Mon Sep 17 00:00:00 2001 From: isaacs Date: Tue, 20 Oct 2026 14:53:24 -0700 Subject: [PATCH 1/3] 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) --- packages/cloudflare/src/baseSdk.ts | 38 ++++++++++--- packages/cloudflare/test/sdk.test.ts | 79 ++++++++++++++++++++++++++++ 2 files changed, 111 insertions(+), 6 deletions(-) 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 f8f98523b6ed952af1eba40c9716f7cd499a7a0e Mon Sep 17 00:00:00 2001 From: isaacs Date: Thu, 22 Oct 2026 20:00:34 -0700 Subject: [PATCH 2/3] 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 7f836ed6ea40a08cff3b991cd3cafa70628aa923 Mon Sep 17 00:00:00 2001 From: isaacs Date: Mon, 26 Oct 2026 08:57:58 -0700 Subject: [PATCH 3/3] fix: review comments --- packages/cloudflare/src/baseSdk.ts | 2 +- packages/cloudflare/src/client.ts | 2 +- packages/cloudflare/test/sdk.test.ts | 4 ++-- packages/nuxt/src/runtime/plugins/sentry-cloudflare.server.ts | 2 +- .../test/runtime/plugins/sentry-cloudflare.server.test.ts | 4 ++-- packages/sveltekit/src/worker/cloudflare.ts | 2 +- packages/sveltekit/test/worker/cloudflare.test.ts | 4 ++-- 7 files changed, 10 insertions(+), 10 deletions(-) diff --git a/packages/cloudflare/src/baseSdk.ts b/packages/cloudflare/src/baseSdk.ts index 0a9f6a1cf709..af1d2d681f66 100644 --- a/packages/cloudflare/src/baseSdk.ts +++ b/packages/cloudflare/src/baseSdk.ts @@ -109,7 +109,7 @@ export function initWithDefaultIntegrations( // `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; + 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 be45095e1481..ee2171cd987c 100644 --- a/packages/cloudflare/src/client.ts +++ b/packages/cloudflare/src/client.ts @@ -359,7 +359,7 @@ interface BaseCloudflareOptions { * the integrations of instrumented modules that load after `init()`. * @default false */ - extendsDefaultIntegrations?: boolean; + _extendsDefaultIntegrations?: boolean; /** * The Cloudflare SDK is not OpenTelemetry native. By default (`false`) it does not set up a tracer diff --git a/packages/cloudflare/test/sdk.test.ts b/packages/cloudflare/test/sdk.test.ts index 60254f504a66..b30630a92843 100644 --- a/packages/cloudflare/test/sdk.test.ts +++ b/packages/cloudflare/test/sdk.test.ts @@ -526,7 +526,7 @@ describe('getDefaultIntegrations', () => { test('adds it when a custom defaultIntegrations list extends the SDK defaults', async () => { resetSdk(); - const client = init({ dsn: DSN, defaultIntegrations: [], extendsDefaultIntegrations: true }); + const client = init({ dsn: DSN, defaultIntegrations: [], _extendsDefaultIntegrations: true }); await loadMysqlLate(client); @@ -538,7 +538,7 @@ describe('getDefaultIntegrations', () => { const client = init({ dsn: DSN, defaultIntegrations: [], - extendsDefaultIntegrations: true, + _extendsDefaultIntegrations: true, integrations: defaults => defaults.filter(integration => integration.name !== 'Mysql'), }); diff --git a/packages/nuxt/src/runtime/plugins/sentry-cloudflare.server.ts b/packages/nuxt/src/runtime/plugins/sentry-cloudflare.server.ts index 46c18d6f4aa8..1893de6a57b1 100644 --- a/packages/nuxt/src/runtime/plugins/sentry-cloudflare.server.ts +++ b/packages/nuxt/src/runtime/plugins/sentry-cloudflare.server.ts @@ -73,7 +73,7 @@ export const sentryCloudflareNitroPlugin = // Nitro's Cloudflare presets require the flag anyway, so opt into the full set. options: { defaultIntegrations: getDefaultIntegrations(cloudflareOptions), - extendsDefaultIntegrations: cloudflareOptions.defaultIntegrations === undefined, + _extendsDefaultIntegrations: cloudflareOptions.defaultIntegrations === undefined, ...cloudflareOptions, }, request, diff --git a/packages/nuxt/test/runtime/plugins/sentry-cloudflare.server.test.ts b/packages/nuxt/test/runtime/plugins/sentry-cloudflare.server.test.ts index b2e720545c8a..33de134f47cc 100644 --- a/packages/nuxt/test/runtime/plugins/sentry-cloudflare.server.test.ts +++ b/packages/nuxt/test/runtime/plugins/sentry-cloudflare.server.test.ts @@ -36,7 +36,7 @@ describe('sentryCloudflareNitroPlugin', () => { expect.objectContaining({ options: expect.objectContaining({ defaultIntegrations: expect.any(Array), - extendsDefaultIntegrations: true, + _extendsDefaultIntegrations: true, }), }), expect.any(Function), @@ -48,7 +48,7 @@ describe('sentryCloudflareNitroPlugin', () => { expect(wrapRequestHandler).toHaveBeenCalledWith( expect.objectContaining({ - options: expect.objectContaining({ defaultIntegrations: [], extendsDefaultIntegrations: false }), + 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 f7298f1539b5..f76450d64712 100644 --- a/packages/sveltekit/src/worker/cloudflare.ts +++ b/packages/sveltekit/src/worker/cloudflare.ts @@ -23,7 +23,7 @@ export function initCloudflareSentryHandle(options: CloudflareOptions): Handle { rewriteFramesIntegration(), svelteKitSpansIntegration(), ], - extendsDefaultIntegrations: options.defaultIntegrations === undefined, + _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 f4b24b641b4e..946854d0b102 100644 --- a/packages/sveltekit/test/worker/cloudflare.test.ts +++ b/packages/sveltekit/test/worker/cloudflare.test.ts @@ -66,7 +66,7 @@ describe('initCloudflareSentryHandle', () => { options: expect.objectContaining({ dsn: options.dsn, enableOpenTelemetrySetup: true, - extendsDefaultIntegrations: true, + _extendsDefaultIntegrations: true, }), request, context, @@ -91,7 +91,7 @@ describe('initCloudflareSentryHandle', () => { expect(wrapRequestHandler).toHaveBeenCalledWith( expect.objectContaining({ - options: expect.objectContaining({ defaultIntegrations: [], extendsDefaultIntegrations: false }), + options: expect.objectContaining({ defaultIntegrations: [], _extendsDefaultIntegrations: false }), }), expect.any(Function), );