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 @@
+
+
+
Nuxt on Cloudflare
+
+
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();