Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
<template>
<div>
<h1>Nuxt on Cloudflare</h1>
</div>
</template>
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
@@ -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',
});
}
Original file line number Diff line number Diff line change
@@ -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',
});
}
Original file line number Diff line number Diff line change
@@ -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',
},
},
});
Original file line number Diff line number Diff line change
@@ -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"
}
}
Original file line number Diff line number Diff line change
@@ -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;
Original file line number Diff line number Diff line change
@@ -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
});
Comment thread
chargome marked this conversation as resolved.

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' });
});
});
});
});
Original file line number Diff line number Diff line change
@@ -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
}),
);
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import { startEventProxyServer } from '@sentry-internal/test-utils';

startEventProxyServer({
port: 3031,
proxyServerName: 'nuxt-4-cloudflare',
});
Original file line number Diff line number Diff line change
@@ -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()');
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
{
// https://nuxt.com/docs/guide/concepts/typescript
"extends": "./.nuxt/tsconfig.json"
}
Original file line number Diff line number Diff line change
@@ -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",
},
}
7 changes: 5 additions & 2 deletions packages/nuxt/src/module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,9 +84,12 @@ export default defineNuxtModule<ModuleOptions>({
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'));
Expand Down
20 changes: 18 additions & 2 deletions packages/nuxt/src/vite/orchestrion.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand All @@ -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) {
Expand All @@ -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;
Expand Down
42 changes: 38 additions & 4 deletions packages/nuxt/test/vite/orchestrion.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,20 +48,54 @@ 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();
expect(nitroConfig.rollupConfig.plugins).toEqual([existingPlugin, { name: 'sentry-orchestrion-plugin' }]);
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({
Expand All @@ -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();
Expand All @@ -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();
Expand Down
Loading