diff --git a/dev-packages/e2e-tests/test-applications/nitro-3-cloudflare/package.json b/dev-packages/e2e-tests/test-applications/nitro-3-cloudflare/package.json new file mode 100644 index 000000000000..b654f92dd48c --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nitro-3-cloudflare/package.json @@ -0,0 +1,31 @@ +{ + "name": "nitro-3-cloudflare", + "version": "1.0.0", + "private": true, + "type": "module", + "scripts": { + "build": "vite build", + "preview": "wrangler dev --port 3030 --log-level=$(test $CI && echo 'none' || echo 'log')", + "clean": "npx rimraf node_modules pnpm-lock.yaml .output", + "test": "playwright test", + "test:build": "pnpm install && pnpm build", + "test:assert": "pnpm test" + }, + "dependencies": { + "@sentry/cloudflare": "latest || *", + "@sentry/nitro": "latest || *" + }, + "devDependencies": { + "@playwright/test": "~1.56.0", + "@sentry-internal/test-utils": "link:../../../test-utils", + "@sentry/core": "latest || *", + "nitro": "^3.0.260522-beta", + "rolldown": "latest", + "vite": "latest", + "wrangler": "^4.72.0" + }, + "volta": { + "node": "22.20.0", + "extends": "../../package.json" + } +} diff --git a/dev-packages/e2e-tests/test-applications/nitro-3-cloudflare/playwright.config.mjs b/dev-packages/e2e-tests/test-applications/nitro-3-cloudflare/playwright.config.mjs new file mode 100644 index 000000000000..395acfc282f9 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nitro-3-cloudflare/playwright.config.mjs @@ -0,0 +1,8 @@ +import { getPlaywrightConfig } from '@sentry-internal/test-utils'; + +const config = getPlaywrightConfig({ + startCommand: 'pnpm preview', + port: 3030, +}); + +export default config; diff --git a/dev-packages/e2e-tests/test-applications/nitro-3-cloudflare/server/api/index.ts b/dev-packages/e2e-tests/test-applications/nitro-3-cloudflare/server/api/index.ts new file mode 100644 index 000000000000..f242538db545 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nitro-3-cloudflare/server/api/index.ts @@ -0,0 +1,3 @@ +import { defineHandler } from 'nitro/h3'; + +export default defineHandler(() => ({ hello: 'world' })); diff --git a/dev-packages/e2e-tests/test-applications/nitro-3-cloudflare/server/api/test-error.ts b/dev-packages/e2e-tests/test-applications/nitro-3-cloudflare/server/api/test-error.ts new file mode 100644 index 000000000000..170efb1977ab --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nitro-3-cloudflare/server/api/test-error.ts @@ -0,0 +1,5 @@ +import { defineHandler } from 'nitro/h3'; + +export default defineHandler(() => { + throw new Error('This is a test error'); +}); diff --git a/dev-packages/e2e-tests/test-applications/nitro-3-cloudflare/server/plugins/sentry.ts b/dev-packages/e2e-tests/test-applications/nitro-3-cloudflare/server/plugins/sentry.ts new file mode 100644 index 000000000000..007c7868dafc --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nitro-3-cloudflare/server/plugins/sentry.ts @@ -0,0 +1,12 @@ +import { sentryCloudflareNitroPlugin } from '@sentry/nitro/cloudflare'; +import { definePlugin } from 'nitro'; + +export default definePlugin( + sentryCloudflareNitroPlugin(() => ({ + environment: 'qa', + traceLifecycle: 'static', + dsn: 'https://public@dsn.ingest.sentry.io/1337', + tunnel: 'http://localhost:3031/', + tracesSampleRate: 1.0, + })), +); diff --git a/dev-packages/e2e-tests/test-applications/nitro-3-cloudflare/start-event-proxy.mjs b/dev-packages/e2e-tests/test-applications/nitro-3-cloudflare/start-event-proxy.mjs new file mode 100644 index 000000000000..1a2d098be886 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nitro-3-cloudflare/start-event-proxy.mjs @@ -0,0 +1,6 @@ +import { startEventProxyServer } from '@sentry-internal/test-utils'; + +startEventProxyServer({ + port: 3031, + proxyServerName: 'nitro-3-cloudflare', +}); diff --git a/dev-packages/e2e-tests/test-applications/nitro-3-cloudflare/tests/errors.test.ts b/dev-packages/e2e-tests/test-applications/nitro-3-cloudflare/tests/errors.test.ts new file mode 100644 index 000000000000..ddf3f5759af5 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nitro-3-cloudflare/tests/errors.test.ts @@ -0,0 +1,45 @@ +import { expect, test } from '@playwright/test'; +import { waitForError } from '@sentry-internal/test-utils'; + +test('sends an error event from the Cloudflare Workers runtime', async ({ request }) => { + const errorEventPromise = waitForError('nitro-3-cloudflare', event => { + return !event.type && !!event.exception?.values?.some(v => v.value === 'This is a test error'); + }); + + const res = await request.get('/api/test-error'); + expect(res.status()).toBe(500); + + const errorEvent = await errorEventPromise; + const values = errorEvent.exception?.values ?? []; + + // h3 wraps the thrown error in an HTTPError, so both are reported and linked. + expect(values).toHaveLength(2); + expect(values.some(v => v.type === 'Error' && v.value === 'This is a test error')).toBe(true); + expect( + values.some(v => v.mechanism?.type === 'auto.function.nitro.captureErrorHook' && v.mechanism.handled === false), + ).toBe(true); + expect(errorEvent.sdk?.name).toBe('sentry.javascript.cloudflare'); +}); + +test('does not send 404 errors', async ({ request }) => { + const errorEvents: (string | undefined)[] = []; + const sentinelEventPromise = waitForError('nitro-3-cloudflare', event => { + if (event.type) { + return false; + } + errorEvents.push(event.exception?.values?.map(v => v.value).join(', ')); + return !!event.exception?.values?.some(v => v.value === 'This is a test error'); + }); + + const notFoundRes = await request.get('/api/non-existent-route'); + expect(notFoundRes.status()).toBe(404); + + // The sentinel error arrives after anything the 404 could have sent, so waiting for it + // makes the no-report assertion deterministic without a timeout. + const sentinelRes = await request.get('/api/test-error'); + expect(sentinelRes.status()).toBe(500); + + await sentinelEventPromise; + + expect(errorEvents).toHaveLength(1); +}); diff --git a/dev-packages/e2e-tests/test-applications/nitro-3-cloudflare/tests/transactions.test.ts b/dev-packages/e2e-tests/test-applications/nitro-3-cloudflare/tests/transactions.test.ts new file mode 100644 index 000000000000..48d238c441e5 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nitro-3-cloudflare/tests/transactions.test.ts @@ -0,0 +1,23 @@ +import { expect, test } from '@playwright/test'; +import { waitForTransaction } from '@sentry-internal/test-utils'; + +test('sends an http.server transaction from the Cloudflare Workers runtime', async ({ request }) => { + const transactionEventPromise = waitForTransaction('nitro-3-cloudflare', transactionEvent => { + return ( + transactionEvent.contexts?.trace?.op === 'http.server' && + (transactionEvent.request?.url?.endsWith('/api/') ?? false) + ); + }); + + const res = await request.get('/api/'); + expect(res.status()).toBe(200); + expect(await res.json()).toEqual({ hello: 'world' }); + + const transactionEvent = await transactionEventPromise; + + expect(transactionEvent.contexts?.trace).toMatchObject({ + op: 'http.server', + origin: 'auto.http.cloudflare', + }); + expect(transactionEvent.sdk?.name).toBe('sentry.javascript.cloudflare'); +}); diff --git a/dev-packages/e2e-tests/test-applications/nitro-3-cloudflare/tsconfig.json b/dev-packages/e2e-tests/test-applications/nitro-3-cloudflare/tsconfig.json new file mode 100644 index 000000000000..ee7ada91be42 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nitro-3-cloudflare/tsconfig.json @@ -0,0 +1,11 @@ +{ + "compilerOptions": { + "target": "ESNext", + "module": "ESNext", + "moduleResolution": "bundler", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true + }, + "include": ["server/**/*.ts", "vite.config.ts"] +} diff --git a/dev-packages/e2e-tests/test-applications/nitro-3-cloudflare/vite.config.ts b/dev-packages/e2e-tests/test-applications/nitro-3-cloudflare/vite.config.ts new file mode 100644 index 000000000000..614cf5c6e75d --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nitro-3-cloudflare/vite.config.ts @@ -0,0 +1,14 @@ +import { nitro } from 'nitro/vite'; +import { defineConfig } from 'vite'; + +export default defineConfig({ + plugins: [ + nitro({ + preset: 'cloudflare-module', + serverDir: './server', + cloudflare: { + deployConfig: false, + }, + }), + ], +}); diff --git a/dev-packages/e2e-tests/test-applications/nitro-3-cloudflare/wrangler.jsonc b/dev-packages/e2e-tests/test-applications/nitro-3-cloudflare/wrangler.jsonc new file mode 100644 index 000000000000..b05d2b9071c1 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nitro-3-cloudflare/wrangler.jsonc @@ -0,0 +1,11 @@ +{ + "$schema": "node_modules/wrangler/config-schema.json", + "name": "nitro-3-cloudflare", + "main": "./.output/server/index.mjs", + "compatibility_date": "2026-06-29", + "compatibility_flags": ["nodejs_compat"], + "assets": { + "directory": "./.output/public", + "binding": "ASSETS", + }, +} diff --git a/packages/nitro/README.md b/packages/nitro/README.md index 459d8087ce0a..0519c7f4b1c8 100644 --- a/packages/nitro/README.md +++ b/packages/nitro/README.md @@ -16,6 +16,7 @@ - [Official Nitro SDK Docs](https://docs.sentry.io/platforms/javascript/guides/nitro/) - [Example Nitro app](https://github.com/getsentry/sentry-javascript/tree/develop/dev-packages/e2e-tests/test-applications/nitro-3) +- [Example Nitro app on Cloudflare Workers](https://github.com/getsentry/sentry-javascript/tree/develop/dev-packages/e2e-tests/test-applications/nitro-3-cloudflare) ## Compatibility @@ -111,6 +112,91 @@ NODE_OPTIONS='--import ./instrument.mjs' npx nitro dev This works with any Nitro command (`nitro dev`, `nitro preview`, or a production start script). +## Cloudflare Workers + +On a Workers preset such as `cloudflare-module`, use the Cloudflare plugin from `@sentry/nitro/cloudflare`. It +replaces the `instrument.mjs` and `--import` steps above. + +Nitro registers every file in your server `plugins/` directory automatically, so creating the file is the only +registration step: + +```ts +// server/plugins/sentry.ts +import { definePlugin } from 'nitro'; +import { sentryCloudflareNitroPlugin } from '@sentry/nitro/cloudflare'; + +export default definePlugin( + sentryCloudflareNitroPlugin({ + dsn: '__YOUR_DSN__', + tracesSampleRate: 1.0, + }), +); +``` + +Directory scanning is disabled until `serverDir` is set, so set it next to the preset. In `nitro.config.ts`: + +```ts +import { defineNitroConfig } from 'nitro/config'; + +export default defineNitroConfig({ + preset: 'cloudflare-module', + serverDir: './server', +}); +``` + +Or in `vite.config.ts` when using Nitro as a Vite plugin: + +```ts +import { defineConfig } from 'vite'; +import { nitro } from 'nitro/vite'; + +export default defineConfig({ + plugins: [ + nitro({ + preset: 'cloudflare-module', + serverDir: './server', + }), + ], +}); +``` + +The plugin enables the full default integration set of `@sentry/cloudflare`, which requires the `nodejs_compat` +compatibility flag in your `wrangler.jsonc`: + +```jsonc +{ + "compatibility_flags": ["nodejs_compat"], +} +``` + +Secrets are not available at build time on Workers, so read the DSN from the runtime config when it comes from an +environment binding. The runtime config only picks up keys declared in your Nitro config, so declare the key there +and set it on the Worker as `NITRO_SENTRY_DSN`: + +```ts +// server/plugins/sentry.ts +import { definePlugin } from 'nitro'; +import { useRuntimeConfig } from 'nitro/runtime-config'; +import { sentryCloudflareNitroPlugin } from '@sentry/nitro/cloudflare'; + +export default definePlugin(sentryCloudflareNitroPlugin(() => ({ dsn: useRuntimeConfig().sentryDsn }))); +``` + +```ts +// nitro.config.ts +import { defineNitroConfig } from 'nitro/config'; + +export default defineNitroConfig({ + preset: 'cloudflare-module', + serverDir: './server', + runtimeConfig: { sentryDsn: '' }, +}); +``` + +The plugin gives each request its own isolation scope, flushes events through `waitUntil`, and reports unhandled +errors from Nitro's `error` hook. The build-time setup from `withSentryConfig` (source map upload and the h3 tracing +channels) is not wired up for the Workers path yet, so server spans come from the Cloudflare request wrapper only. + ## Uploading Source Maps The `withSentryConfig` function automatically configures source map uploading when the `authToken`, `org`, and `project` diff --git a/packages/nitro/package.json b/packages/nitro/package.json index bd46bd4e50f0..5e97da9eca2f 100644 --- a/packages/nitro/package.json +++ b/packages/nitro/package.json @@ -24,8 +24,23 @@ "exports": { "./package.json": "./package.json", ".": { + "workerd": { + "types": "./build/types/cloudflare/index.d.ts", + "import": "./build/esm/cloudflare/index.js", + "default": "./build/esm/cloudflare/index.js" + }, + "worker": { + "types": "./build/types/cloudflare/index.d.ts", + "import": "./build/esm/cloudflare/index.js", + "default": "./build/esm/cloudflare/index.js" + }, "types": "./build/types/index.d.ts", "default": "./build/esm/index.js" + }, + "./cloudflare": { + "types": "./build/types/cloudflare/index.d.ts", + "import": "./build/esm/cloudflare/index.js", + "default": "./build/esm/cloudflare/index.js" } }, "publishConfig": { @@ -36,6 +51,7 @@ }, "dependencies": { "@sentry/bundler-plugins": "^10.67.0", + "@sentry/cloudflare": "10.67.0", "@sentry/conventions": "^0.19.0", "@sentry/core": "10.67.0", "@sentry/node": "10.67.0", diff --git a/packages/nitro/rollup.npm.config.mjs b/packages/nitro/rollup.npm.config.mjs index 140655a7eca8..e6d993e5a2d2 100644 --- a/packages/nitro/rollup.npm.config.mjs +++ b/packages/nitro/rollup.npm.config.mjs @@ -3,7 +3,7 @@ import { makeBaseNPMConfig, makeNPMConfigVariants } from '@sentry-internal/rollu export default [ ...makeNPMConfigVariants( makeBaseNPMConfig({ - entrypoints: ['src/index.ts', 'src/runtime/plugins/server.ts'], + entrypoints: ['src/index.ts', 'src/cloudflare/index.ts', 'src/runtime/plugins/server.ts'], packageSpecificConfig: { external: [/^nitro/, /^h3/, /^srvx/, /^@sentry\/opentelemetry/, '@sentry/bundler-plugin-core'], }, diff --git a/packages/nitro/src/cloudflare/index.ts b/packages/nitro/src/cloudflare/index.ts new file mode 100644 index 000000000000..df9cb6f9b256 --- /dev/null +++ b/packages/nitro/src/cloudflare/index.ts @@ -0,0 +1,2 @@ +export * from '@sentry/cloudflare'; +export { sentryCloudflareNitroPlugin } from '../runtime/plugins/cloudflare'; diff --git a/packages/nitro/src/runtime/plugins/cloudflare.ts b/packages/nitro/src/runtime/plugins/cloudflare.ts new file mode 100644 index 000000000000..b6528fb523d1 --- /dev/null +++ b/packages/nitro/src/runtime/plugins/cloudflare.ts @@ -0,0 +1,92 @@ +import type { CloudflareOptions } from '@sentry/cloudflare'; +import { getDefaultIntegrations, setAsyncLocalStorageAsyncContextStrategy } from '@sentry/cloudflare'; +import { wrapRequestHandler } from '@sentry/cloudflare/request'; +import { consoleSandbox } from '@sentry/core'; +import type { NitroApp, NitroAppPlugin, ServerRequest } from 'nitro/types'; +import { DEBUG_BUILD } from '../../common/debug-build'; +import { captureErrorHook } from '../hooks/captureErrorHook'; + +type NitroAppWithHooks = NitroApp & { hooks: NonNullable }; + +let warnedAboutMissingExecutionContext = false; + +/** + * Sentry plugin for Nitro apps running on Cloudflare Workers. + * + * Default-export it from a file in your server `plugins/` directory, which Nitro registers + * automatically (`serverDir` has to be set for the directory to be scanned). It is the only + * registration a Workers build needs: it gives each request its own isolation scope, flushes + * through `waitUntil`, and reports unhandled errors from Nitro's `error` hook. + * + * Passing a function defers the options to each request, which is what reading a DSN from a + * request-scoped environment binding requires. + * + * @example Basic usage + * ```ts + * // server/plugins/sentry.ts + * import { definePlugin } from 'nitro'; + * import { sentryCloudflareNitroPlugin } from '@sentry/nitro/cloudflare'; + * + * export default definePlugin( + * sentryCloudflareNitroPlugin({ + * dsn: '__YOUR_DSN__', + * tracesSampleRate: 1.0, + * }), + * ); + * ``` + * + * @example Reading the DSN from the runtime config + * ```ts + * // server/plugins/sentry.ts + * import { definePlugin } from 'nitro'; + * import { useRuntimeConfig } from 'nitro/runtime-config'; + * import { sentryCloudflareNitroPlugin } from '@sentry/nitro/cloudflare'; + * + * export default definePlugin( + * sentryCloudflareNitroPlugin(() => ({ dsn: useRuntimeConfig().sentryDsn })), + * ); + * ``` + * + * The runtime config only picks up keys declared in your Nitro config, so pair the second + * example with `runtimeConfig: { sentryDsn: '' }` and set `NITRO_SENTRY_DSN` on the Worker. + */ +export const sentryCloudflareNitroPlugin = + (optionsOrFn: CloudflareOptions | ((nitroApp: NitroApp) => CloudflareOptions)): NitroAppPlugin => + (nitroApp: NitroAppWithHooks): void => { + const innerFetch = nitroApp.fetch.bind(nitroApp); + + nitroApp.fetch = (request: Request): Response | Promise => { + const context = (request as ServerRequest).runtime?.cloudflare?.context; + + if (!context) { + // `debug.log` stays silent until `init` enables it, and `init` is never reached on + // this path, so a swallowed message would make the no-op SDK undiagnosable. + if (DEBUG_BUILD && !warnedAboutMissingExecutionContext) { + warnedAboutMissingExecutionContext = true; + consoleSandbox(() => + // eslint-disable-next-line no-console + console.warn( + '[Sentry] No Cloudflare execution context found on the request. Requests will not be instrumented. This is expected in `nitro dev` and on non-Cloudflare presets.', + ), + ); + } + return innerFetch(request); + } + + // Only for instrumented requests, so a Node `--import` setup that registers this plugin + // by accident keeps its OTel-aware async context strategy. + setAsyncLocalStorageAsyncContextStrategy(); + + const userOptions = typeof optionsOrFn === 'function' ? optionsOrFn(nitroApp) : optionsOrFn; + + const options: CloudflareOptions = { + // Opts into the full integration set, which requires the Worker to enable `nodejs_compat`. + defaultIntegrations: getDefaultIntegrations(userOptions), + ...userOptions, + }; + + return wrapRequestHandler({ options, request, context }, () => innerFetch(request)); + }; + + nitroApp.hooks.hook('error', captureErrorHook); + }; diff --git a/packages/nitro/test/runtime/plugins/cloudflare.test.ts b/packages/nitro/test/runtime/plugins/cloudflare.test.ts new file mode 100644 index 000000000000..eb5a4228b2e8 --- /dev/null +++ b/packages/nitro/test/runtime/plugins/cloudflare.test.ts @@ -0,0 +1,203 @@ +import { existsSync, readFileSync } from 'node:fs'; +import { dirname, join, relative, resolve } from 'node:path'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { sentryCloudflareNitroPlugin } from '../../../src/runtime/plugins/cloudflare'; + +const mocks = vi.hoisted(() => ({ + wrapRequestHandler: vi.fn((_wrapperOptions: unknown, handler: () => unknown) => handler()), + setAsyncLocalStorageAsyncContextStrategy: vi.fn(), + getDefaultIntegrations: vi.fn(() => [{ name: 'CloudflareDefault' }]), +})); + +vi.mock('@sentry/cloudflare', () => ({ + getDefaultIntegrations: mocks.getDefaultIntegrations, + setAsyncLocalStorageAsyncContextStrategy: mocks.setAsyncLocalStorageAsyncContextStrategy, +})); + +vi.mock('@sentry/cloudflare/request', () => ({ + wrapRequestHandler: mocks.wrapRequestHandler, +})); + +const executionContext = { waitUntil: vi.fn(), passThroughOnException: vi.fn() }; + +function createNitroApp(): any { + return { + fetch: vi.fn(() => new Response('ok')), + hooks: { hook: vi.fn() }, + }; +} + +function createCloudflareRequest(url = 'https://example.com/'): Request { + return Object.assign(new Request(url), { + runtime: { name: 'cloudflare', cloudflare: { context: executionContext, env: {} } }, + }); +} + +describe('sentryCloudflareNitroPlugin', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('routes requests carrying a Cloudflare execution context through `wrapRequestHandler`', async () => { + const nitroApp = createNitroApp(); + const innerFetch = nitroApp.fetch; + + sentryCloudflareNitroPlugin({ dsn: 'https://public@example.ingest.sentry.io/1' })(nitroApp); + const request = createCloudflareRequest(); + await nitroApp.fetch(request); + + expect(mocks.wrapRequestHandler).toHaveBeenCalledTimes(1); + expect(mocks.wrapRequestHandler.mock.calls[0]![0]).toMatchObject({ request, context: executionContext }); + expect(innerFetch).toHaveBeenCalledWith(request); + }); + + // Without an execution context there is nothing to hang the flush on, which is the case in + // `nitro dev` and on every non-Workers preset. + it('passes the request through untouched and warns once when there is no execution context', async () => { + // A fresh module instance, so the one-shot warning flag does not depend on test order. + vi.resetModules(); + const { sentryCloudflareNitroPlugin: freshPlugin } = await import('../../../src/runtime/plugins/cloudflare'); + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const nitroApp = createNitroApp(); + const innerFetch = nitroApp.fetch; + + freshPlugin({ dsn: 'https://public@example.ingest.sentry.io/1' })(nitroApp); + const request = new Request('https://example.com/'); + await nitroApp.fetch(request); + await nitroApp.fetch(new Request('https://example.com/second')); + + expect(mocks.wrapRequestHandler).not.toHaveBeenCalled(); + expect(innerFetch).toHaveBeenCalledWith(request); + expect(warnSpy).toHaveBeenCalledTimes(1); + expect(warnSpy.mock.calls[0]![0]).toContain('No Cloudflare execution context'); + warnSpy.mockRestore(); + }); + + it('registers the error hook and defers the async context strategy to the first instrumented request', async () => { + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const nitroApp = createNitroApp(); + + sentryCloudflareNitroPlugin({ dsn: 'https://public@example.ingest.sentry.io/1' })(nitroApp); + + expect(nitroApp.hooks.hook).toHaveBeenCalledWith('error', expect.any(Function)); + expect(mocks.setAsyncLocalStorageAsyncContextStrategy).not.toHaveBeenCalled(); + + await nitroApp.fetch(new Request('https://example.com/')); + expect(mocks.setAsyncLocalStorageAsyncContextStrategy).not.toHaveBeenCalled(); + + await nitroApp.fetch(createCloudflareRequest()); + expect(mocks.setAsyncLocalStorageAsyncContextStrategy).toHaveBeenCalledTimes(1); + warnSpy.mockRestore(); + }); + + it('accepts a function so options can be read once the app exists', async () => { + const nitroApp = createNitroApp(); + const optionsFn = vi.fn(() => ({ dsn: 'https://public@example.ingest.sentry.io/2' })); + + sentryCloudflareNitroPlugin(optionsFn)(nitroApp); + await nitroApp.fetch(createCloudflareRequest()); + + expect(optionsFn).toHaveBeenCalledWith(nitroApp); + expect(mocks.wrapRequestHandler.mock.calls[0]![0]).toMatchObject({ + options: { dsn: 'https://public@example.ingest.sentry.io/2' }, + }); + }); + + it('re-reads the options on every request, so request-scoped bindings are picked up', async () => { + const nitroApp = createNitroApp(); + const dsns = [ + 'https://public@example.ingest.sentry.io/1', + 'https://public@example.ingest.sentry.io/2', + 'https://public@example.ingest.sentry.io/3', + ]; + const optionsFn = vi.fn(() => ({ dsn: dsns[optionsFn.mock.calls.length - 1] })); + + sentryCloudflareNitroPlugin(optionsFn)(nitroApp); + + expect(optionsFn).not.toHaveBeenCalled(); + + await nitroApp.fetch(createCloudflareRequest()); + await nitroApp.fetch(createCloudflareRequest()); + await nitroApp.fetch(createCloudflareRequest()); + + expect(optionsFn).toHaveBeenCalledTimes(3); + expect(mocks.wrapRequestHandler.mock.calls.map(call => (call[0] as any).options.dsn)).toEqual(dsns); + }); + + it('does not read the options for a request it does not instrument', async () => { + const nitroApp = createNitroApp(); + const optionsFn = vi.fn(() => ({ dsn: 'https://public@example.ingest.sentry.io/1' })); + + sentryCloudflareNitroPlugin(optionsFn)(nitroApp); + await nitroApp.fetch(new Request('https://example.com/')); + + expect(optionsFn).not.toHaveBeenCalled(); + }); + + it('defaults to the `nodejs_compat` integrations but lets explicit options win', async () => { + const nitroApp = createNitroApp(); + + sentryCloudflareNitroPlugin({ dsn: 'https://public@example.ingest.sentry.io/1' })(nitroApp); + await nitroApp.fetch(createCloudflareRequest()); + + expect((mocks.wrapRequestHandler.mock.calls[0]![0] as any).options.defaultIntegrations).toEqual([ + { name: 'CloudflareDefault' }, + ]); + + vi.clearAllMocks(); + const otherApp = createNitroApp(); + sentryCloudflareNitroPlugin({ dsn: 'https://public@example.ingest.sentry.io/1', defaultIntegrations: false })( + otherApp, + ); + await otherApp.fetch(createCloudflareRequest()); + + expect((mocks.wrapRequestHandler.mock.calls[0]![0] as any).options.defaultIntegrations).toBe(false); + }); +}); + +// See #22519 for the same class of bug reached through a barrel re-export. +describe('the `@sentry/nitro/cloudflare` module graph', () => { + const SRC_DIR = resolve(__dirname, '../../../src'); + const IMPORT_SPECIFIER_REGEX = /\bfrom\s*['"]([^'"]+)['"]|\bimport\s*['"]([^'"]+)['"]/g; + + function resolveRelativeImport(importer: string, specifier: string): string { + const withoutExtension = join(dirname(importer), specifier); + const candidates = [`${withoutExtension}.ts`, join(withoutExtension, 'index.ts')]; + const resolved = candidates.find(candidate => existsSync(candidate)); + + if (!resolved) { + throw new Error(`Could not resolve '${specifier}' imported from '${relative(SRC_DIR, importer)}'`); + } + + return resolved; + } + + it('never reaches `@sentry/node`', () => { + const seen = new Set(); + const offenders: string[] = []; + const queue = [join(SRC_DIR, 'cloudflare/index.ts')]; + + while (queue.length) { + const file = queue.pop() as string; + + if (seen.has(file)) { + continue; + } + seen.add(file); + + for (const match of readFileSync(file, 'utf8').matchAll(IMPORT_SPECIFIER_REGEX)) { + const specifier = match[1] ?? (match[2] as string); + + if (specifier === '@sentry/node' || specifier.startsWith('@sentry/node/')) { + offenders.push(relative(SRC_DIR, file)); + } + if (specifier.startsWith('.')) { + queue.push(resolveRelativeImport(file, specifier)); + } + } + } + + expect(seen.size).toBeGreaterThan(1); + expect(offenders).toEqual([]); + }); +});