diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4-cloudflare/app/app.vue b/dev-packages/e2e-tests/test-applications/nuxt-4-cloudflare/app/app.vue new file mode 100644 index 000000000000..826fed9086a1 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-4-cloudflare/app/app.vue @@ -0,0 +1,5 @@ + diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4-cloudflare/docker-compose.yml b/dev-packages/e2e-tests/test-applications/nuxt-4-cloudflare/docker-compose.yml new file mode 100644 index 000000000000..f667d39fb2f0 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-4-cloudflare/docker-compose.yml @@ -0,0 +1,18 @@ +services: + db: + image: mysql:8.0 + restart: always + container_name: e2e-tests-nuxt-4-cloudflare-mysql + # The `mysql` 2.x driver doesn't speak MySQL 8's default + # `caching_sha2_password` auth, so force the legacy plugin. + command: ['--default-authentication-plugin=mysql_native_password'] + ports: + - '3306:3306' + environment: + MYSQL_ROOT_PASSWORD: docker + healthcheck: + test: ['CMD-SHELL', 'mysqladmin ping -h 127.0.0.1 -uroot -pdocker'] + interval: 2s + timeout: 3s + retries: 30 + start_period: 10s diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4-cloudflare/global-setup.mjs b/dev-packages/e2e-tests/test-applications/nuxt-4-cloudflare/global-setup.mjs new file mode 100644 index 000000000000..9ba25cd71638 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-4-cloudflare/global-setup.mjs @@ -0,0 +1,14 @@ +import { execSync } from 'child_process'; +import { dirname } from 'path'; +import { fileURLToPath } from 'url'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); + +export default async function globalSetup() { + // Start MySQL via Docker Compose. `--wait` blocks until the healthcheck in + // docker-compose.yml passes, so the worker can connect on the first request. + execSync('docker compose up -d --wait', { + cwd: __dirname, + stdio: 'inherit', + }); +} diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4-cloudflare/global-teardown.mjs b/dev-packages/e2e-tests/test-applications/nuxt-4-cloudflare/global-teardown.mjs new file mode 100644 index 000000000000..2742279431ad --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-4-cloudflare/global-teardown.mjs @@ -0,0 +1,12 @@ +import { execSync } from 'child_process'; +import { dirname } from 'path'; +import { fileURLToPath } from 'url'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); + +export default async function globalTeardown() { + execSync('docker compose down --volumes', { + cwd: __dirname, + stdio: 'inherit', + }); +} diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4-cloudflare/nuxt.config.ts b/dev-packages/e2e-tests/test-applications/nuxt-4-cloudflare/nuxt.config.ts new file mode 100644 index 000000000000..63f6e2e2633b --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-4-cloudflare/nuxt.config.ts @@ -0,0 +1,18 @@ +// https://nuxt.com/docs/api/configuration/nuxt-config +export default defineNuxtConfig({ + compatibilityDate: '2025-06-06', + modules: ['@sentry/nuxt/module'], + nitro: { + preset: 'cloudflare_module', + cloudflare: { + nodeCompat: true, + deployConfig: false, + }, + // The bundled `mysql` driver pulls in `readable-stream`, whose base `require('stream')` has no + // usable prototype under Nitro's unenv polyfill (throws `superCtor.prototype ... undefined`). + // Alias it to workerd's native `node:stream`, which has a real `Readable`. + alias: { + 'readable-stream': 'node:stream', + }, + }, +}); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4-cloudflare/package.json b/dev-packages/e2e-tests/test-applications/nuxt-4-cloudflare/package.json new file mode 100644 index 000000000000..15ec50540eba --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-4-cloudflare/package.json @@ -0,0 +1,28 @@ +{ + "name": "nuxt-4-cloudflare", + "type": "module", + "private": true, + "scripts": { + "build": "nuxt build", + "dev": "nuxt dev", + "preview": "wrangler dev --var \"E2E_TEST_DSN:$E2E_TEST_DSN\" --port 3030 --log-level=$(test $CI && echo 'none' || echo 'log')", + "test": "playwright test", + "test:build": "pnpm install && pnpm build", + "test:assert": "pnpm test" + }, + "dependencies": { + "@sentry/nuxt": "file:../../packed/sentry-nuxt-packed.tgz", + "@sentry/cloudflare": "file:../../packed/sentry-cloudflare-packed.tgz", + "mysql": "2.18.1", + "nuxt": "^4.1.2" + }, + "devDependencies": { + "@playwright/test": "~1.56.0", + "@sentry-internal/test-utils": "link:../../../test-utils", + "wrangler": "^4.72.0" + }, + "volta": { + "node": "22.20.0", + "extends": "../../package.json" + } +} diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4-cloudflare/playwright.config.mjs b/dev-packages/e2e-tests/test-applications/nuxt-4-cloudflare/playwright.config.mjs new file mode 100644 index 000000000000..a1e9207cdfa1 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-4-cloudflare/playwright.config.mjs @@ -0,0 +1,17 @@ +import { getPlaywrightConfig } from '@sentry-internal/test-utils'; + +// `nuxt build` (where the Sentry Nuxt module's orchestrion transform runs over Nitro's Cloudflare +// preset) produces the worker; `pnpm preview` (`wrangler dev`) serves it. `globalSetup` spins up the +// MySQL container the worker connects to. +const config = getPlaywrightConfig( + { + startCommand: 'pnpm preview', + port: 3030, + }, + { + globalSetup: './global-setup.mjs', + globalTeardown: './global-teardown.mjs', + }, +); + +export default config; diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4-cloudflare/server/api/db-mysql.ts b/dev-packages/e2e-tests/test-applications/nuxt-4-cloudflare/server/api/db-mysql.ts new file mode 100644 index 000000000000..0045cb7576d3 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-4-cloudflare/server/api/db-mysql.ts @@ -0,0 +1,35 @@ +import { defineEventHandler } from '#imports'; +import mysql from 'mysql'; + +export default defineEventHandler(() => { + const connection = mysql.createConnection({ + host: '127.0.0.1', + port: 3306, + user: 'root', + password: 'docker', + }); + + connection.on('error', () => { + // no-op + }); + + return new Promise((resolve, reject) => { + connection.query('SELECT 1 + 1 AS solution', error => { + if (error) { + connection.end(); + reject(error); + return; + } + + connection.query('SELECT NOW()', nestedError => { + connection.end(); + if (nestedError) { + reject(nestedError); + return; + } + + resolve({ status: 'ok' }); + }); + }); + }); +}); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4-cloudflare/server/plugins/sentry.ts b/dev-packages/e2e-tests/test-applications/nuxt-4-cloudflare/server/plugins/sentry.ts new file mode 100644 index 000000000000..f1725d6bf9e4 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-4-cloudflare/server/plugins/sentry.ts @@ -0,0 +1,11 @@ +import { defineNitroPlugin } from '#imports'; +import { sentryCloudflareNitroPlugin } from '@sentry/nuxt/module/plugins'; + +export default defineNitroPlugin( + sentryCloudflareNitroPlugin({ + traceLifecycle: 'static', + dsn: 'https://public@dsn.ingest.sentry.io/1337', + tracesSampleRate: 1.0, + tunnel: 'http://localhost:3031/', // proxy server + }), +); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4-cloudflare/start-event-proxy.mjs b/dev-packages/e2e-tests/test-applications/nuxt-4-cloudflare/start-event-proxy.mjs new file mode 100644 index 000000000000..5d537fb7c877 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-4-cloudflare/start-event-proxy.mjs @@ -0,0 +1,6 @@ +import { startEventProxyServer } from '@sentry-internal/test-utils'; + +startEventProxyServer({ + port: 3031, + proxyServerName: 'nuxt-4-cloudflare', +}); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4-cloudflare/tests/db.test.ts b/dev-packages/e2e-tests/test-applications/nuxt-4-cloudflare/tests/db.test.ts new file mode 100644 index 000000000000..7597a8baacbc --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-4-cloudflare/tests/db.test.ts @@ -0,0 +1,43 @@ +import { expect, test } from '@playwright/test'; +import { waitForTransaction } from '@sentry-internal/test-utils'; + +test('a real mysql query emits a db span with orchestrion-channel attributes', async ({ request }) => { + const transactionPromise = waitForTransaction('nuxt-4-cloudflare', transactionEvent => { + return ( + transactionEvent.contexts?.trace?.op === 'http.server' && + (transactionEvent.spans?.some(span => span.op === 'db') ?? false) + ); + }); + + const res = await request.get('/api/db-mysql'); + expect(res.status()).toBe(200); + + const transactionEvent = await transactionPromise; + const dbSpans = transactionEvent.spans!.filter(span => span.op === 'db'); + + const firstQuery = dbSpans.find(span => span.description === 'SELECT 1 + 1 AS solution'); + expect(firstQuery).toBeDefined(); + expect(firstQuery!.data?.['sentry.origin']).toBe('auto.db.mysql'); + expect(firstQuery!.data?.['db.system']).toBe('mysql'); + expect(firstQuery!.data?.['db.statement']).toBe('SELECT 1 + 1 AS solution'); + expect(firstQuery!.data?.['net.peer.name']).toBe('127.0.0.1'); + expect(firstQuery!.data?.['net.peer.port']).toBe(3306); + expect(firstQuery!.data?.['db.user']).toBe('root'); +}); + +test('a nested query lands on the same transaction (async context restored)', async ({ request }) => { + const transactionPromise = waitForTransaction('nuxt-4-cloudflare', transactionEvent => { + return ( + transactionEvent.contexts?.trace?.op === 'http.server' && + (transactionEvent.spans?.filter(span => span.op === 'db').length ?? 0) >= 2 + ); + }); + + const res = await request.get('/api/db-mysql'); + expect(res.status()).toBe(200); + + const transactionEvent = await transactionPromise; + const descriptions = transactionEvent.spans!.filter(span => span.op === 'db').map(span => span.description); + expect(descriptions).toContain('SELECT 1 + 1 AS solution'); + expect(descriptions).toContain('SELECT NOW()'); +}); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4-cloudflare/tsconfig.json b/dev-packages/e2e-tests/test-applications/nuxt-4-cloudflare/tsconfig.json new file mode 100644 index 000000000000..a746f2a70c28 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-4-cloudflare/tsconfig.json @@ -0,0 +1,4 @@ +{ + // https://nuxt.com/docs/guide/concepts/typescript + "extends": "./.nuxt/tsconfig.json" +} diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4-cloudflare/wrangler.jsonc b/dev-packages/e2e-tests/test-applications/nuxt-4-cloudflare/wrangler.jsonc new file mode 100644 index 000000000000..9f9e885fa782 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-4-cloudflare/wrangler.jsonc @@ -0,0 +1,11 @@ +{ + "$schema": "node_modules/wrangler/config-schema.json", + "name": "nuxt-4-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/nuxt/src/module.ts b/packages/nuxt/src/module.ts index fd3e874d214d..09c910bb825f 100644 --- a/packages/nuxt/src/module.ts +++ b/packages/nuxt/src/module.ts @@ -84,9 +84,12 @@ export default defineNuxtModule({ const nuxtMajor = parseInt((nuxt as unknown as { _version: string })._version?.split('.')[0] ?? '3', 10); const isMinNuxtV4 = nuxtMajor >= 4; - if (serverConfigFile) { - setupOrchestrion(nuxt, moduleOptions.buildTimeInstrumentation); + // Orchestrion runs on both the Node path (gated on a server config file) and the Cloudflare path + // (which has no server config file — the SDK is set up via `sentryCloudflareNitroPlugin`). The + // Cloudflare detection happens inside, keyed off the resolved Nitro preset. + setupOrchestrion(nuxt, !!serverConfigFile, moduleOptions.buildTimeInstrumentation); + if (serverConfigFile) { if (isNitroV3) { addServerPlugin(moduleDirResolver.resolve('./runtime/plugins/handler.server')); addServerPlugin(moduleDirResolver.resolve('./runtime/plugins/update-route-name.server')); diff --git a/packages/nuxt/src/vite/orchestrion.ts b/packages/nuxt/src/vite/orchestrion.ts index db367e295c59..6cbeeff68e0d 100644 --- a/packages/nuxt/src/vite/orchestrion.ts +++ b/packages/nuxt/src/vite/orchestrion.ts @@ -10,8 +10,13 @@ const IORedisDependencies = ['standard-as-callback']; /** * Configures Nitro to bundle and transform dependencies that publish tracing * events through diagnostics channels. + * + * `hasServerConfig` reflects whether a `sentry.server.config` file was found. On Node the SDK is + * initialized from that file, so orchestrion only makes sense when it exists. On Cloudflare the SDK + * is initialized through `sentryCloudflareNitroPlugin` instead (no server config file), so the + * transform must still run there — detected via the Nitro preset. */ -export function setupOrchestrion(nuxt: Nuxt, buildTimeInstrumentation?: boolean): void { +export function setupOrchestrion(nuxt: Nuxt, hasServerConfig: boolean, buildTimeInstrumentation?: boolean): void { if (buildTimeInstrumentation === false) { return; } @@ -21,6 +26,15 @@ export function setupOrchestrion(nuxt: Nuxt, buildTimeInstrumentation?: boolean) return; } + // On Cloudflare (workerd), subscribers are wired via a build-time marker that `@sentry/cloudflare` + // reads at runtime (through `sentryCloudflareNitroPlugin`); on Node they register at init. Nitro + // normalizes preset names, so match any `cloudflare*` spelling. + const isCloudflare = !!nitroConfig.preset?.replace(/-/g, '_').startsWith('cloudflare'); + + if (!hasServerConfig && !isCloudflare) { + return; + } + nitroConfig.rollupConfig ??= {}; if (nitroConfig.rollupConfig.plugins === null || nitroConfig.rollupConfig.plugins === undefined) { @@ -29,7 +43,9 @@ export function setupOrchestrion(nuxt: Nuxt, buildTimeInstrumentation?: boolean) nitroConfig.rollupConfig.plugins = [nitroConfig.rollupConfig.plugins]; } - nitroConfig.rollupConfig.plugins.push(sentryOrchestrionPlugin()); + nitroConfig.rollupConfig.plugins.push( + sentryOrchestrionPlugin(isCloudflare ? { injectChannelSubscribers: true } : {}), + ); const externals = (nitroConfig.externals ||= {}); const inline = externals.inline; diff --git a/packages/nuxt/test/vite/orchestrion.test.ts b/packages/nuxt/test/vite/orchestrion.test.ts index 4b1a79fce4b0..f2d314bcb720 100644 --- a/packages/nuxt/test/vite/orchestrion.test.ts +++ b/packages/nuxt/test/vite/orchestrion.test.ts @@ -48,7 +48,7 @@ describe('setupOrchestrion', () => { externals: { inline: ['ioredis', 'custom-dependency'] }, }; - setupOrchestrion(mockNuxt as unknown as Nuxt); + setupOrchestrion(mockNuxt as unknown as Nuxt, true); await mockNuxt.triggerHook('nitro:config', nitroConfig); expect(mockSentryOrchestrionPlugin).toHaveBeenCalledOnce(); @@ -56,12 +56,46 @@ describe('setupOrchestrion', () => { expect(nitroConfig.externals.inline).toEqual(['ioredis', 'custom-dependency', 'mysql', 'standard-as-callback']); }); + it('injects channel subscribers on a Cloudflare preset even without a server config file', async () => { + const { setupOrchestrion } = await import('../../src/vite/orchestrion'); + const mockNuxt = createMockNuxt(); + const nitroConfig = { preset: 'cloudflare_module' }; + + setupOrchestrion(mockNuxt as unknown as Nuxt, false); + await mockNuxt.triggerHook('nitro:config', nitroConfig); + + expect(mockSentryOrchestrionPlugin).toHaveBeenCalledWith({ injectChannelSubscribers: true }); + }); + + it('does not inject channel subscribers on a non-Cloudflare preset', async () => { + const { setupOrchestrion } = await import('../../src/vite/orchestrion'); + const mockNuxt = createMockNuxt(); + const nitroConfig = { preset: 'node-server' }; + + setupOrchestrion(mockNuxt as unknown as Nuxt, true); + await mockNuxt.triggerHook('nitro:config', nitroConfig); + + expect(mockSentryOrchestrionPlugin).toHaveBeenCalledWith({}); + }); + + it('does not run without a server config file on a non-Cloudflare preset', async () => { + const { setupOrchestrion } = await import('../../src/vite/orchestrion'); + const mockNuxt = createMockNuxt(); + const nitroConfig = { preset: 'node-server' }; + + setupOrchestrion(mockNuxt as unknown as Nuxt, false); + await mockNuxt.triggerHook('nitro:config', nitroConfig); + + expect(mockSentryOrchestrionPlugin).not.toHaveBeenCalled(); + expect(nitroConfig).toEqual({ preset: 'node-server' }); + }); + it('initializes absent Nitro configuration', async () => { const { setupOrchestrion } = await import('../../src/vite/orchestrion'); const mockNuxt = createMockNuxt(); const nitroConfig = {}; - setupOrchestrion(mockNuxt as unknown as Nuxt); + setupOrchestrion(mockNuxt as unknown as Nuxt, true); await mockNuxt.triggerHook('nitro:config', nitroConfig); expect(nitroConfig).toEqual({ @@ -75,7 +109,7 @@ describe('setupOrchestrion', () => { const mockNuxt = createMockNuxt(); const nitroConfig = {}; - setupOrchestrion(mockNuxt as unknown as Nuxt, false); + setupOrchestrion(mockNuxt as unknown as Nuxt, true, false); await mockNuxt.triggerHook('nitro:config', nitroConfig); expect(mockSentryOrchestrionPlugin).not.toHaveBeenCalled(); @@ -87,7 +121,7 @@ describe('setupOrchestrion', () => { const mockNuxt = createMockNuxt({ _prepare: true }); const nitroConfig = { rollupConfig: { plugins: [] } }; - setupOrchestrion(mockNuxt as unknown as Nuxt); + setupOrchestrion(mockNuxt as unknown as Nuxt, true); await mockNuxt.triggerHook('nitro:config', nitroConfig); expect(mockSentryOrchestrionPlugin).not.toHaveBeenCalled();