From 87f700b840ffef54e12acdfad9c938e6b03d2f49 Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Wed, 11 Nov 2026 12:20:05 +0000 Subject: [PATCH 1/6] test(e2e): Add Bun, Deno and Cloudflare variants to nuxt-4 Runs the nuxt-4 suite on Node, Bun, Deno and Cloudflare from one app, following react-router-8-framework, and folds nuxt-4-cloudflare into it. Fixes the SDK defects the variants surfaced: db0 spans and prepare errors on bun-sqlite, Bun CommonJS namespaces in the orchestrion interop, and on Cloudflare the missing transaction name of request events and the tracing meta tags on cached HTML. Ref #24838 Co-Authored-By: Isaac Schlueter --- dev-packages/e2e-tests/README.md | 13 +++- .../nuxt-4-cloudflare/app/app.vue | 5 -- .../nuxt-4-cloudflare/docker-compose.yml | 18 ------ .../nuxt-4-cloudflare/global-setup.mjs | 14 ----- .../nuxt-4-cloudflare/global-teardown.mjs | 12 ---- .../nuxt-4-cloudflare/nuxt.config.ts | 24 ------- .../nuxt-4-cloudflare/package.json | 35 ----------- .../nuxt-4-cloudflare/playwright.config.mjs | 17 ----- .../nuxt-4-cloudflare/pnpm-workspace.yaml | 4 -- .../nuxt-4-cloudflare/server/api/anchor.ts | 4 -- .../nuxt-4-cloudflare/server/api/db-mysql.ts | 35 ----------- .../nuxt-4-cloudflare/start-event-proxy.mjs | 6 -- .../nuxt-4-cloudflare/tests/db.test.ts | 57 ----------------- .../nuxt-4-cloudflare/tests/tunnel.test.ts | 21 ------- .../nuxt-4-cloudflare/tsconfig.json | 4 -- .../nuxt-4-cloudflare/wrangler.jsonc | 11 ---- .../plugins => nuxt-4/cloudflare}/sentry.ts | 8 ++- .../nuxt-4/instrument-preload.bun.mjs | 11 ++++ .../test-applications/nuxt-4/nuxt.config.ts | 41 ++++++++---- .../test-applications/nuxt-4/package.json | 33 +++++++++- .../nuxt-4/playwright.config.ts | 12 +++- .../nuxt-4/pnpm-workspace.yaml | 1 + .../build-with-prerender-event-sink.mjs | 5 +- .../nuxt-4/sentry.server.config.bun.ts | 18 ++++++ .../nuxt-4/sentry.server.config.deno.ts | 16 +++++ .../nuxt-4/sentry.server.config.ts | 1 + .../server/api/db-mongoose.cloudflare.ts | 6 ++ .../nuxt-4/server/api/db-test.ts | 15 +++-- .../nuxt-4/server/api/echo-headers.ts | 6 ++ .../nuxt-4/server/api/isolation.ts | 13 ++++ .../nuxt-4/server/api/log.ts | 7 +++ .../nuxt-4/server/api/outgoing-fetch.ts | 7 +++ .../server/api/post-error.ts | 0 .../nuxt-4/server/api/trace-continuation.ts | 7 +++ .../nuxt-4/server/middleware/03.auth.ts | 4 +- .../nuxt-4/tests/build-injection.test.ts | 21 ++++++- .../nuxt-4/tests/database-multi.test.ts | 17 ++--- .../nuxt-4/tests/database.test.ts | 5 +- .../nuxt-4/tests/db-drivers.test.ts | 3 + .../nuxt-4/tests/import-compat.test.ts | 13 ++-- .../nuxt-4/tests/logs.server.test.ts | 28 +++++++++ .../test-applications/nuxt-4/tests/runtime.ts | 31 ++++++++++ .../nuxt-4/tests/scopes.server.test.ts | 16 +++++ .../nuxt-4/tests/startup-error.test.ts | 14 ++++- .../tests/trace-propagation.server.test.ts | 55 ++++++++++++++++ .../nuxt-4/tests/tracing.cached-html.test.ts | 14 +++-- .../nuxt-4/tests/tracing.server.test.ts | 3 +- .../nuxt-4/tests/tracing.test.ts | 17 +++-- .../tests/tunnel-request-body.test.ts | 8 ++- .../test-applications/nuxt-4/wrangler.jsonc | 23 +++++++ .../plugins/sentry-cloudflare.server.ts | 29 ++++++++- .../nuxt/src/runtime/plugins/sentry.server.ts | 25 +------- packages/nuxt/src/runtime/utils.ts | 32 ++++++++++ .../src/runtime/utils/database-span-data.ts | 6 +- .../src/runtime/utils/instrumentDatabase.ts | 33 +++++++--- packages/nuxt/test/runtime/utils.test.ts | 27 +++++++- .../runtime/utils/database-span-data.test.ts | 6 +- .../runtime/utils/instrumentDatabase.test.ts | 62 +++++++++++++++++++ .../src/orchestrion/bundler/rollup.ts | 16 +++-- .../test/orchestrion/rollup-plugin.test.ts | 42 +++++++++++-- 60 files changed, 658 insertions(+), 379 deletions(-) delete mode 100644 dev-packages/e2e-tests/test-applications/nuxt-4-cloudflare/app/app.vue delete mode 100644 dev-packages/e2e-tests/test-applications/nuxt-4-cloudflare/docker-compose.yml delete mode 100644 dev-packages/e2e-tests/test-applications/nuxt-4-cloudflare/global-setup.mjs delete mode 100644 dev-packages/e2e-tests/test-applications/nuxt-4-cloudflare/global-teardown.mjs delete mode 100644 dev-packages/e2e-tests/test-applications/nuxt-4-cloudflare/nuxt.config.ts delete mode 100644 dev-packages/e2e-tests/test-applications/nuxt-4-cloudflare/package.json delete mode 100644 dev-packages/e2e-tests/test-applications/nuxt-4-cloudflare/playwright.config.mjs delete mode 100644 dev-packages/e2e-tests/test-applications/nuxt-4-cloudflare/pnpm-workspace.yaml delete mode 100644 dev-packages/e2e-tests/test-applications/nuxt-4-cloudflare/server/api/anchor.ts delete mode 100644 dev-packages/e2e-tests/test-applications/nuxt-4-cloudflare/server/api/db-mysql.ts delete mode 100644 dev-packages/e2e-tests/test-applications/nuxt-4-cloudflare/start-event-proxy.mjs delete mode 100644 dev-packages/e2e-tests/test-applications/nuxt-4-cloudflare/tests/db.test.ts delete mode 100644 dev-packages/e2e-tests/test-applications/nuxt-4-cloudflare/tests/tunnel.test.ts delete mode 100644 dev-packages/e2e-tests/test-applications/nuxt-4-cloudflare/tsconfig.json delete mode 100644 dev-packages/e2e-tests/test-applications/nuxt-4-cloudflare/wrangler.jsonc rename dev-packages/e2e-tests/test-applications/{nuxt-4-cloudflare/server/plugins => nuxt-4/cloudflare}/sentry.ts (62%) create mode 100644 dev-packages/e2e-tests/test-applications/nuxt-4/instrument-preload.bun.mjs create mode 100644 dev-packages/e2e-tests/test-applications/nuxt-4/sentry.server.config.bun.ts create mode 100644 dev-packages/e2e-tests/test-applications/nuxt-4/sentry.server.config.deno.ts create mode 100644 dev-packages/e2e-tests/test-applications/nuxt-4/server/api/db-mongoose.cloudflare.ts create mode 100644 dev-packages/e2e-tests/test-applications/nuxt-4/server/api/echo-headers.ts create mode 100644 dev-packages/e2e-tests/test-applications/nuxt-4/server/api/isolation.ts create mode 100644 dev-packages/e2e-tests/test-applications/nuxt-4/server/api/log.ts create mode 100644 dev-packages/e2e-tests/test-applications/nuxt-4/server/api/outgoing-fetch.ts rename dev-packages/e2e-tests/test-applications/{nuxt-4-cloudflare => nuxt-4}/server/api/post-error.ts (100%) create mode 100644 dev-packages/e2e-tests/test-applications/nuxt-4/server/api/trace-continuation.ts create mode 100644 dev-packages/e2e-tests/test-applications/nuxt-4/tests/logs.server.test.ts create mode 100644 dev-packages/e2e-tests/test-applications/nuxt-4/tests/runtime.ts create mode 100644 dev-packages/e2e-tests/test-applications/nuxt-4/tests/scopes.server.test.ts create mode 100644 dev-packages/e2e-tests/test-applications/nuxt-4/tests/trace-propagation.server.test.ts rename dev-packages/e2e-tests/test-applications/{nuxt-4-cloudflare => nuxt-4}/tests/tunnel-request-body.test.ts (56%) create mode 100644 dev-packages/e2e-tests/test-applications/nuxt-4/wrangler.jsonc create mode 100644 packages/nuxt/test/runtime/utils/instrumentDatabase.test.ts diff --git a/dev-packages/e2e-tests/README.md b/dev-packages/e2e-tests/README.md index be2b94586ece..6cd6efc034bf 100644 --- a/dev-packages/e2e-tests/README.md +++ b/dev-packages/e2e-tests/README.md @@ -175,13 +175,19 @@ is the reference setup. values that come from its `init()` (`sdk.name`, default integrations) are Node-only. Next.js is different: it loads `instrumentation.ts` on every runtime, so `nextjs-16` calls `init()` of `@sentry/nextjs` on Bun and Deno too, and adds the integrations of `@sentry/bun` or `@sentry/deno` that replace the Node instrumentation those runtimes do not - trigger. + trigger. Nuxt is similar: `@sentry/nuxt` bundles `sentry.server.config.ts` into the Nitro output, so `nuxt-4` calls + `init()` of `@sentry/nuxt` on Bun and Deno too. `sentry.server.config.bun.ts` and `sentry.server.config.deno.ts` add + the integrations of `@sentry/bun` or `@sentry/deno`. On Cloudflare, `nuxt-4` builds with the `cloudflare_module` + preset and sets up the SDK with `sentryCloudflareNitroPlugin`, which `nuxt.config.ts` adds only for that runtime. + Nitro turns every file under `server/api` into a route, so `nuxt.config.ts` lists the runtime-specific files there + (such as `db-mongoose.cloudflare.ts`) in Nitro's `ignore`. - **Start commands**: `playwright.config.mjs` selects the start command with `getRuntime()`. Bun and Deno use the same build as Node and only change the start command, for example `bun --bun --preload ./instrument.bun.mjs ./node_modules/@react-router/serve/bin.cjs ./build/server/index.js` and `deno run -A --preload=@sentry/deno/import --preload=./instrument.deno.mjs ./node_modules/@react-router/serve/bin.cjs ./build/server/index.js`. The app's `deno.json` maps `@sentry/deno/import` to `node_modules`, because the e2e dependencies are `file:` tarballs - that `npm:@sentry/deno/import` does not resolve. + that `npm:@sentry/deno/import` does not resolve. `nuxt-4` starts the Nitro output with no preload: + `bun .output/server/index.mjs` and `deno run -A .output/server/index.mjs`. - **Cloudflare**: the app has the Cloudflare dependencies installed all the time. The Cloudflare build has its own `vite.cloudflare.config.ts` with `@cloudflare/vite-plugin` and `sentryCloudflareVitePlugin` from `@sentry/cloudflare/vite`, which the runner uses in place of `vite.config.ts` (see runtime-specific files). List the @@ -210,7 +216,8 @@ is the reference setup. - **Deno version**: a variant can pin the Deno version, for example `"deno-version": "v2.9.0"`. - **Bun**: under `bun run` the SDK cannot inject diagnostics channels into packages that stay outside the build (for example Express behind `react-router-serve`), so those produce no spans on Bun. Where a test depends on them, branch - on `RUNTIME` and say why in a comment. + on `RUNTIME` and say why in a comment. `nuxt-4` builds with `bun --bun nuxt build` on Bun, because the Nitro + prerenderer loads its `bun-sqlite` connector in the build process. ```json "sentryTest": { 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 deleted file mode 100644 index 826fed9086a1..000000000000 --- a/dev-packages/e2e-tests/test-applications/nuxt-4-cloudflare/app/app.vue +++ /dev/null @@ -1,5 +0,0 @@ - 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 deleted file mode 100644 index f667d39fb2f0..000000000000 --- a/dev-packages/e2e-tests/test-applications/nuxt-4-cloudflare/docker-compose.yml +++ /dev/null @@ -1,18 +0,0 @@ -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 deleted file mode 100644 index 9ba25cd71638..000000000000 --- a/dev-packages/e2e-tests/test-applications/nuxt-4-cloudflare/global-setup.mjs +++ /dev/null @@ -1,14 +0,0 @@ -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 deleted file mode 100644 index 2742279431ad..000000000000 --- a/dev-packages/e2e-tests/test-applications/nuxt-4-cloudflare/global-teardown.mjs +++ /dev/null @@ -1,12 +0,0 @@ -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 deleted file mode 100644 index b83483ac0363..000000000000 --- a/dev-packages/e2e-tests/test-applications/nuxt-4-cloudflare/nuxt.config.ts +++ /dev/null @@ -1,24 +0,0 @@ -// https://nuxt.com/docs/api/configuration/nuxt-config -export default defineNuxtConfig({ - compatibilityDate: '2025-06-06', - modules: ['@sentry/nuxt/module'], - // The tunnel variant serves the app, and so the tunnel, under a base URL. - ...(process.env.E2E_TEST_TUNNEL && { app: { baseURL: '/app/' } }), - 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', - }, - // The tunnel variant serves the managed tunnel. - replace: { - __E2E_TEST_TUNNEL__: JSON.stringify(!!process.env.E2E_TEST_TUNNEL), - }, - }, -}); 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 deleted file mode 100644 index 7fb1258723d6..000000000000 --- a/dev-packages/e2e-tests/test-applications/nuxt-4-cloudflare/package.json +++ /dev/null @@ -1,35 +0,0 @@ -{ - "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", - "test:build:tunnel": "pnpm install && E2E_TEST_TUNNEL=1 pnpm build", - "test:assert:tunnel": "E2E_TEST_TUNNEL=1 pnpm test tunnel" - }, - "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.64.1", - "@sentry-internal/test-utils": "file:../../packed/sentry-internal-test-utils-packed.tgz", - "wrangler": "^4.72.0" - }, - "sentryTest": { - "variants": [ - { - "build-command": "pnpm test:build:tunnel", - "assert-command": "pnpm test:assert:tunnel", - "label": "nuxt-4-cloudflare (tunnel)" - } - ] - } -} 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 deleted file mode 100644 index a1e9207cdfa1..000000000000 --- a/dev-packages/e2e-tests/test-applications/nuxt-4-cloudflare/playwright.config.mjs +++ /dev/null @@ -1,17 +0,0 @@ -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/pnpm-workspace.yaml b/dev-packages/e2e-tests/test-applications/nuxt-4-cloudflare/pnpm-workspace.yaml deleted file mode 100644 index fd29a82085e0..000000000000 --- a/dev-packages/e2e-tests/test-applications/nuxt-4-cloudflare/pnpm-workspace.yaml +++ /dev/null @@ -1,4 +0,0 @@ -allowBuilds: - esbuild: true - workerd: true -minimumReleaseAge: 0 diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4-cloudflare/server/api/anchor.ts b/dev-packages/e2e-tests/test-applications/nuxt-4-cloudflare/server/api/anchor.ts deleted file mode 100644 index cee7601e363b..000000000000 --- a/dev-packages/e2e-tests/test-applications/nuxt-4-cloudflare/server/api/anchor.ts +++ /dev/null @@ -1,4 +0,0 @@ -import { defineEventHandler } from '#imports'; - -// A plain route for tests to request after the tunnel traffic. -export default defineEventHandler(() => 'ok'); 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 deleted file mode 100644 index 0045cb7576d3..000000000000 --- a/dev-packages/e2e-tests/test-applications/nuxt-4-cloudflare/server/api/db-mysql.ts +++ /dev/null @@ -1,35 +0,0 @@ -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/start-event-proxy.mjs b/dev-packages/e2e-tests/test-applications/nuxt-4-cloudflare/start-event-proxy.mjs deleted file mode 100644 index 5d537fb7c877..000000000000 --- a/dev-packages/e2e-tests/test-applications/nuxt-4-cloudflare/start-event-proxy.mjs +++ /dev/null @@ -1,6 +0,0 @@ -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 deleted file mode 100644 index 62bfdb837e53..000000000000 --- a/dev-packages/e2e-tests/test-applications/nuxt-4-cloudflare/tests/db.test.ts +++ /dev/null @@ -1,57 +0,0 @@ -import { expect, test } from '@playwright/test'; -import { collectStreamedSpans, getSpanOp } from '@sentry-internal/test-utils'; - -// Streamed child spans arrive across several envelopes, so collect until the request's segment -// span and the expected db spans have all been flushed. The streamed segment name is method-only -// (`GET`), so the segment is selected via `url.path`. -function collectDbSpans(minDbSpans: number) { - return collectStreamedSpans( - 'nuxt-4-cloudflare', - spans => - spans.some(span => span.is_segment && span.attributes['url.path']?.value === '/api/db-mysql') && - spans.filter(span => getSpanOp(span) === 'db').length >= minDbSpans, - ); -} - -test('a real mysql query emits a db span with orchestrion-channel attributes', async ({ request }) => { - const spansPromise = collectDbSpans(1); - - const res = await request.get('/api/db-mysql'); - expect(res.status()).toBe(200); - - const spans = await spansPromise; - const rootSpan = spans.find(span => span.is_segment && span.attributes['url.path']?.value === '/api/db-mysql'); - expect(rootSpan).toBeDefined(); - expect(getSpanOp(rootSpan!)).toBe('http.server'); - - const dbSpans = spans.filter(span => getSpanOp(span) === 'db' && span.trace_id === rootSpan!.trace_id); - - const firstQuery = dbSpans.find(span => span.attributes['db.query.text']?.value === 'SELECT ? + ? AS solution'); - expect(firstQuery).toBeDefined(); - expect(firstQuery!.name).toBe('SELECT'); - expect(firstQuery!.attributes).toMatchObject({ - 'sentry.origin': { type: 'string', value: 'auto.db.mysql' }, - 'db.system.name': { type: 'string', value: 'mysql' }, - 'db.query.text': { type: 'string', value: 'SELECT ? + ? AS solution' }, - 'server.address': { type: 'string', value: '127.0.0.1' }, - 'server.port': { type: 'integer', value: 3306 }, - 'db.user': { type: 'string', value: 'root' }, - }); -}); - -test('a nested query lands on the same trace (async context restored)', async ({ request }) => { - const spansPromise = collectDbSpans(2); - - const res = await request.get('/api/db-mysql'); - expect(res.status()).toBe(200); - - const spans = await spansPromise; - const rootSpan = spans.find(span => span.is_segment && span.attributes['url.path']?.value === '/api/db-mysql'); - expect(rootSpan).toBeDefined(); - - const queryTexts = spans - .filter(span => getSpanOp(span) === 'db' && span.trace_id === rootSpan!.trace_id) - .map(span => span.attributes['db.query.text']?.value); - expect(queryTexts).toContain('SELECT ? + ? AS solution'); - expect(queryTexts).toContain('SELECT NOW()'); -}); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4-cloudflare/tests/tunnel.test.ts b/dev-packages/e2e-tests/test-applications/nuxt-4-cloudflare/tests/tunnel.test.ts deleted file mode 100644 index 33c00290d888..000000000000 --- a/dev-packages/e2e-tests/test-applications/nuxt-4-cloudflare/tests/tunnel.test.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { expect, test } from '@playwright/test'; -import { isServerSpanAt, makeClientReportEnvelope, watchForTunnelSpan } from '@sentry-internal/test-utils'; - -test.skip(!process.env.E2E_TEST_TUNNEL, 'Only runs in the tunnel variant'); - -// The isolate's first tunnel request is the one a late span filter would let through, so this stays the only test. -test('answers the managed tunnel set up by `sentryCloudflareNitroPlugin`, without a span of it', async ({ - request, -}) => { - const dsn = 'http://public@localhost:3031/1337'; - const raceTunnelSpan = watchForTunnelSpan('nuxt-4-cloudflare', isServerSpanAt(/^\/app\/qpo967/)); - - // The path the SDK derives from `dsn`. - const response = await request.post('/app/qpo967', { - data: makeClientReportEnvelope(dsn), - headers: { 'Content-Type': 'application/x-sentry-envelope' }, - }); - - expect(response.status()).toBe(200); - expect(await raceTunnelSpan(isServerSpanAt('/app/api/anchor'), () => request.get('/app/api/anchor'))).toBe('anchor'); -}); 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 deleted file mode 100644 index a746f2a70c28..000000000000 --- a/dev-packages/e2e-tests/test-applications/nuxt-4-cloudflare/tsconfig.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - // 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 deleted file mode 100644 index 9f9e885fa782..000000000000 --- a/dev-packages/e2e-tests/test-applications/nuxt-4-cloudflare/wrangler.jsonc +++ /dev/null @@ -1,11 +0,0 @@ -{ - "$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/dev-packages/e2e-tests/test-applications/nuxt-4-cloudflare/server/plugins/sentry.ts b/dev-packages/e2e-tests/test-applications/nuxt-4/cloudflare/sentry.ts similarity index 62% rename from dev-packages/e2e-tests/test-applications/nuxt-4-cloudflare/server/plugins/sentry.ts rename to dev-packages/e2e-tests/test-applications/nuxt-4/cloudflare/sentry.ts index e4cc28eaf3ae..3d620c3a875a 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-4-cloudflare/server/plugins/sentry.ts +++ b/dev-packages/e2e-tests/test-applications/nuxt-4/cloudflare/sentry.ts @@ -6,10 +6,14 @@ declare const __E2E_TEST_TUNNEL__: boolean; export default defineNitroPlugin( sentryCloudflareNitroPlugin({ - // The tunnel forwards to the DSN host, which is the event proxy in the tunnel variant. dsn: __E2E_TEST_TUNNEL__ ? 'http://public@localhost:3031/1337' : 'https://public@dsn.ingest.sentry.io/1337', tracesSampleRate: 1.0, + enableLogs: true, tunnel: 'http://localhost:3031/', // proxy server - integrations: __E2E_TEST_TUNNEL__ ? [serveTunnelIntegration()] : [], + // Several tests trigger the same server error one after another, which Dedupe would drop. + integrations: integrations => [ + ...integrations.filter(integration => integration.name !== 'Dedupe'), + ...(__E2E_TEST_TUNNEL__ ? [serveTunnelIntegration()] : []), + ], }), ); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4/instrument-preload.bun.mjs b/dev-packages/e2e-tests/test-applications/nuxt-4/instrument-preload.bun.mjs new file mode 100644 index 000000000000..fd33b1df4164 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-4/instrument-preload.bun.mjs @@ -0,0 +1,11 @@ +// Simulates a v10-style `bun --preload` that fully initializes the SDK +// before the config bundled into the server build runs its own `Sentry.init`. +import { bunHttpServerIntegration } from '@sentry/bun'; +import * as Sentry from '@sentry/nuxt'; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + tracesSampleRate: 1.0, + tunnel: 'http://localhost:3031/', + integrations: [bunHttpServerIntegration()], +}); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4/nuxt.config.ts b/dev-packages/e2e-tests/test-applications/nuxt-4/nuxt.config.ts index 272aaf5061c7..1ede33ec99e4 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-4/nuxt.config.ts +++ b/dev-packages/e2e-tests/test-applications/nuxt-4/nuxt.config.ts @@ -1,5 +1,16 @@ // https://nuxt.com/docs/api/configuration/nuxt-config const tunnelVariant = !!process.env.E2E_TEST_TUNNEL; +// The runtime variants set `RUNTIME` for the build, see dev-packages/e2e-tests/README.md. +const runtime = process.env.RUNTIME || 'node'; +const cloudflare = runtime === 'cloudflare'; + +// `node:sqlite` is not available on Bun, which has its own SQLite module, and on Cloudflare, which has D1. +function database(name: string, binding: string) { + if (cloudflare) { + return { connector: 'cloudflare-d1', options: { bindingName: binding } }; + } + return { connector: runtime === 'bun' ? 'bun-sqlite' : 'sqlite', options: { name } }; +} export default defineNuxtConfig({ compatibilityDate: '2025-06-06', @@ -31,22 +42,28 @@ export default defineNuxtConfig({ }, }, nitro: { + ...(cloudflare && { + preset: 'cloudflare_module', + cloudflare: { nodeCompat: true, deployConfig: false }, + // Sets up the SDK on Cloudflare, where the Nitro preset does not use `sentry.server.config.ts`. + plugins: ['~~/cloudflare/sentry.ts'], + // 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' }, + }), + // Files for one runtime replace their counterpart in the copy of the app for that runtime, see + // dev-packages/e2e-tests/README.md, and are no routes of their own. + ignore: ['**/*.cloudflare.ts'], + // A Worker has no access to the env vars of the test run, so the tunnel variant is set at build time. + replace: { __E2E_TEST_TUNNEL__: JSON.stringify(tunnelVariant) }, experimental: { database: true, }, database: { - default: { - connector: 'sqlite', - options: { name: 'db' }, - }, - users: { - connector: 'sqlite', - options: { name: 'users_db' }, - }, - analytics: { - connector: 'sqlite', - options: { name: 'analytics_db' }, - }, + default: database('db', 'DB'), + users: database('users_db', 'USERS_DB'), + analytics: database('analytics_db', 'ANALYTICS_DB'), }, storage: { 'test-storage': { diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4/package.json b/dev-packages/e2e-tests/test-applications/nuxt-4/package.json index 35f5a25b7a42..81070e7c24f7 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-4/package.json +++ b/dev-packages/e2e-tests/test-applications/nuxt-4/package.json @@ -8,6 +8,9 @@ "generate": "nuxt generate", "preview": "nuxt preview", "start": "node .output/server/index.mjs", + "start:bun": "bun .output/server/index.mjs", + "start:deno": "deno run -A .output/server/index.mjs", + "start:cloudflare": "wrangler dev --port 3030", "start:import": "node --import ./.output/server/sentry.server.config.mjs .output/server/index.mjs", "clean": "npx nuxi cleanup", "test": "playwright test", @@ -24,6 +27,9 @@ "//": "Pin ioredis to 5.10.1 because that's the last version before it publishes its own diagnostics channels", "dependencies": { "@pinia/nuxt": "^0.5.5", + "@sentry/bun": "file:../../packed/sentry-bun-packed.tgz", + "@sentry/cloudflare": "file:../../packed/sentry-cloudflare-packed.tgz", + "@sentry/deno": "file:../../packed/sentry-deno-packed.tgz", "@sentry/nuxt": "file:../../packed/sentry-nuxt-packed.tgz", "ioredis": "5.10.1", "mongoose": "^9.10.2", @@ -32,7 +38,8 @@ }, "devDependencies": { "@playwright/test": "1.64.1", - "@sentry-internal/test-utils": "file:../../packed/sentry-internal-test-utils-packed.tgz" + "@sentry-internal/test-utils": "file:../../packed/sentry-internal-test-utils-packed.tgz", + "wrangler": "^4.72.0" }, "sentryTest": { "variants": [ @@ -62,6 +69,30 @@ "label": "nuxt-4 (tunnel-basepath-dev)" } ], + "optionalVariants": [ + { + "runtime": "bun", + "assert-command": "pnpm test:prod", + "label": "nuxt-4 (bun)" + }, + { + "runtime": "deno", + "deno-version": "v2.9.0", + "assert-command": "pnpm test:prod", + "label": "nuxt-4 (deno)" + }, + { + "runtime": "cloudflare", + "assert-command": "pnpm test:prod", + "label": "nuxt-4 (cloudflare)" + }, + { + "runtime": "cloudflare", + "build-command": "pnpm test:build:tunnel-basepath", + "assert-command": "pnpm test:assert:tunnel-basepath", + "label": "nuxt-4 (tunnel-basepath-cloudflare)" + } + ], "canaryVariants": [ { "build-command": "pnpm test:build-canary", diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4/playwright.config.ts b/dev-packages/e2e-tests/test-applications/nuxt-4/playwright.config.ts index bc80fed9ac7d..bebffa04b20c 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-4/playwright.config.ts +++ b/dev-packages/e2e-tests/test-applications/nuxt-4/playwright.config.ts @@ -1,4 +1,4 @@ -import { getPlaywrightConfig } from '@sentry-internal/test-utils'; +import { getPlaywrightConfig, getRuntime } from '@sentry-internal/test-utils'; const testEnv = process.env.TEST_ENV; @@ -6,6 +6,14 @@ if (!testEnv) { throw new Error('No test env defined'); } +// The server config is bundled into the Nitro output, so every runtime starts the server entry without a preload. +const productionStartCommands = { + node: 'pnpm start', + bun: 'pnpm start:bun', + deno: 'pnpm start:deno', + cloudflare: 'pnpm start:cloudflare', +}; + const getStartCommand = () => { if (testEnv === 'development') { // The Sentry server config is bundled into the dev server via a nitro plugin, so no preload is needed. @@ -13,7 +21,7 @@ const getStartCommand = () => { } if (testEnv === 'production') { - return 'pnpm start'; + return productionStartCommands[getRuntime()]; } // Runs the suite with the compat shim preloaded, like existing `--import` deploy commands do. diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4/pnpm-workspace.yaml b/dev-packages/e2e-tests/test-applications/nuxt-4/pnpm-workspace.yaml index 8525919fe522..524e15b28e94 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-4/pnpm-workspace.yaml +++ b/dev-packages/e2e-tests/test-applications/nuxt-4/pnpm-workspace.yaml @@ -1,4 +1,5 @@ allowBuilds: esbuild: true vue-demi: true + workerd: true minimumReleaseAge: 0 diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4/scripts/build-with-prerender-event-sink.mjs b/dev-packages/e2e-tests/test-applications/nuxt-4/scripts/build-with-prerender-event-sink.mjs index 421bfe312c74..ad554b2bb70f 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-4/scripts/build-with-prerender-event-sink.mjs +++ b/dev-packages/e2e-tests/test-applications/nuxt-4/scripts/build-with-prerender-event-sink.mjs @@ -18,7 +18,10 @@ const sinkAvailable = await new Promise(resolve => { sink.listen(3031, () => resolve(true)); }); -const build = spawn('nuxt', ['build'], { stdio: 'inherit', shell: true }); +// The prerenderer runs the server bundle in the build process, and the Bun build has to run on Bun to load the +// `bun-sqlite` connector there. +const nuxt = process.env.RUNTIME === 'bun' ? 'bun --bun nuxt' : 'nuxt'; +const build = spawn(nuxt, ['build'], { stdio: 'inherit', shell: true }); const exitCode = await new Promise(resolve => build.on('exit', resolve)); sink.close(); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4/sentry.server.config.bun.ts b/dev-packages/e2e-tests/test-applications/nuxt-4/sentry.server.config.bun.ts new file mode 100644 index 000000000000..b18678256211 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-4/sentry.server.config.bun.ts @@ -0,0 +1,18 @@ +import { bunHttpServerIntegration, fetchIntegration } from '@sentry/bun'; +import * as Sentry from '@sentry/nuxt'; + +Sentry.init({ + dsn: process.env.E2E_TEST_TUNNEL ? 'http://public@localhost:3031/1337' : 'https://public@dsn.ingest.sentry.io/1337', + tracesSampleRate: 1.0, // Capture 100% of the transactions + enableLogs: true, + tunnel: 'http://localhost:3031/', // proxy server + // Several tests trigger the same server error one after another, which Dedupe would drop. + integrations: integrations => [ + ...integrations.filter(integration => integration.name !== 'Dedupe'), + // Bun emits neither the `node:http` diagnostics channel nor the undici channels the Node SDK listens to, so these + // replace its incoming request and outgoing `fetch` instrumentation. + bunHttpServerIntegration(), + fetchIntegration(), + ...(process.env.E2E_TEST_TUNNEL ? [Sentry.serveTunnelIntegration()] : []), + ], +}); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4/sentry.server.config.deno.ts b/dev-packages/e2e-tests/test-applications/nuxt-4/sentry.server.config.deno.ts new file mode 100644 index 000000000000..2e32b70cb59d --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-4/sentry.server.config.deno.ts @@ -0,0 +1,16 @@ +import { fetchIntegration } from '@sentry/deno'; +import * as Sentry from '@sentry/nuxt'; + +Sentry.init({ + dsn: process.env.E2E_TEST_TUNNEL ? 'http://public@localhost:3031/1337' : 'https://public@dsn.ingest.sentry.io/1337', + tracesSampleRate: 1.0, // Capture 100% of the transactions + enableLogs: true, + tunnel: 'http://localhost:3031/', // proxy server + // Several tests trigger the same server error one after another, which Dedupe would drop. + integrations: integrations => [ + ...integrations.filter(integration => integration.name !== 'Dedupe'), + // The `fetch` of Deno is not undici, so the Node SDK does not see outgoing requests. + fetchIntegration(), + ...(process.env.E2E_TEST_TUNNEL ? [Sentry.serveTunnelIntegration()] : []), + ], +}); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4/sentry.server.config.ts b/dev-packages/e2e-tests/test-applications/nuxt-4/sentry.server.config.ts index c956224de1fc..95c65c41549f 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-4/sentry.server.config.ts +++ b/dev-packages/e2e-tests/test-applications/nuxt-4/sentry.server.config.ts @@ -3,6 +3,7 @@ import * as Sentry from '@sentry/nuxt'; Sentry.init({ dsn: process.env.E2E_TEST_TUNNEL ? 'http://public@localhost:3031/1337' : 'https://public@dsn.ingest.sentry.io/1337', tracesSampleRate: 1.0, // Capture 100% of the transactions + enableLogs: true, tunnel: 'http://localhost:3031/', // proxy server // Opt into the Sentry OpenTelemetry tracer provider in the "(tracer provider)" e2e variant. // Leaving it `undefined` otherwise keeps the SDK's default (no provider). diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4/server/api/db-mongoose.cloudflare.ts b/dev-packages/e2e-tests/test-applications/nuxt-4/server/api/db-mongoose.cloudflare.ts new file mode 100644 index 000000000000..163da48c85d9 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-4/server/api/db-mongoose.cloudflare.ts @@ -0,0 +1,6 @@ +import { createError, defineEventHandler } from '#imports'; + +// Replaces `db-mongoose.ts` on Cloudflare, see `MONGOOSE_UNSUPPORTED` in `tests/runtime.ts`. +export default defineEventHandler(() => { + throw createError({ statusCode: 501 }); +}); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4/server/api/db-test.ts b/dev-packages/e2e-tests/test-applications/nuxt-4/server/api/db-test.ts index 4fa90c6e005d..e9487afa9800 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-4/server/api/db-test.ts +++ b/dev-packages/e2e-tests/test-applications/nuxt-4/server/api/db-test.ts @@ -16,10 +16,10 @@ export default defineEventHandler(async event => { case 'prepare-all': { await db.exec('CREATE TABLE IF NOT EXISTS products (id INTEGER PRIMARY KEY, name TEXT, price REAL)'); - await db.exec(`INSERT OR REPLACE INTO products (id, name, price) VALUES - (1, 'Product A', 10.99), - (2, 'Product B', 20.50), - (3, 'Product C', 15.25)`); + // One line, because D1 runs every line of `exec` as a statement of its own. + await db.exec( + `INSERT OR REPLACE INTO products (id, name, price) VALUES (1, 'Product A', 10.99), (2, 'Product B', 20.50), (3, 'Product C', 15.25)`, + ); const stmt = db.prepare('SELECT * FROM products WHERE price > ?'); const results = await stmt.all(10); return { success: true, count: results.length, results }; @@ -34,10 +34,9 @@ export default defineEventHandler(async event => { case 'prepare-bind': { await db.exec('CREATE TABLE IF NOT EXISTS items (id INTEGER PRIMARY KEY, category TEXT, value INTEGER)'); - await db.exec(`INSERT OR REPLACE INTO items (id, category, value) VALUES - (1, 'electronics', 100), - (2, 'books', 50), - (3, 'electronics', 200)`); + await db.exec( + `INSERT OR REPLACE INTO items (id, category, value) VALUES (1, 'electronics', 100), (2, 'books', 50), (3, 'electronics', 200)`, + ); const stmt = db.prepare('SELECT * FROM items WHERE category = ?'); const boundStmt = stmt.bind('electronics'); const results = await boundStmt.all(); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4/server/api/echo-headers.ts b/dev-packages/e2e-tests/test-applications/nuxt-4/server/api/echo-headers.ts new file mode 100644 index 000000000000..6474ada63d5f --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-4/server/api/echo-headers.ts @@ -0,0 +1,6 @@ +import { defineEventHandler, getHeader } from '#imports'; + +export default defineEventHandler(event => ({ + 'sentry-trace': getHeader(event, 'sentry-trace'), + baggage: getHeader(event, 'baggage'), +})); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4/server/api/isolation.ts b/dev-packages/e2e-tests/test-applications/nuxt-4/server/api/isolation.ts new file mode 100644 index 000000000000..3748637cc023 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-4/server/api/isolation.ts @@ -0,0 +1,13 @@ +import * as Sentry from '@sentry/nuxt'; +import { defineEventHandler, getQuery } from '#imports'; + +export default defineEventHandler(async event => { + const tag = String(getQuery(event).tag); + Sentry.setTag('isolation-tag', tag); + + // Lets the concurrent requests of the test set their tags before any of them captures its event. + await new Promise(resolve => setTimeout(resolve, 200)); + + Sentry.captureMessage(`isolation ${tag}`); + return { tag }; +}); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4/server/api/log.ts b/dev-packages/e2e-tests/test-applications/nuxt-4/server/api/log.ts new file mode 100644 index 000000000000..10951fde0abe --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-4/server/api/log.ts @@ -0,0 +1,7 @@ +import * as Sentry from '@sentry/nuxt'; +import { defineEventHandler } from '#imports'; + +export default defineEventHandler(() => { + Sentry.logger.info('nuxt-4 server log', { 'e2e.attr': 'value' }); + return { ok: true }; +}); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4/server/api/outgoing-fetch.ts b/dev-packages/e2e-tests/test-applications/nuxt-4/server/api/outgoing-fetch.ts new file mode 100644 index 000000000000..c947d95e5017 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-4/server/api/outgoing-fetch.ts @@ -0,0 +1,7 @@ +import { defineEventHandler, getRequestURL } from '#imports'; + +export default defineEventHandler(async event => { + // The runtime's own `fetch`, not Nitro's `$fetch`, which calls a local route without a request. + const response = await fetch(new URL('/api/echo-headers', getRequestURL(event).origin)); + return response.json(); +}); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4-cloudflare/server/api/post-error.ts b/dev-packages/e2e-tests/test-applications/nuxt-4/server/api/post-error.ts similarity index 100% rename from dev-packages/e2e-tests/test-applications/nuxt-4-cloudflare/server/api/post-error.ts rename to dev-packages/e2e-tests/test-applications/nuxt-4/server/api/post-error.ts diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4/server/api/trace-continuation.ts b/dev-packages/e2e-tests/test-applications/nuxt-4/server/api/trace-continuation.ts new file mode 100644 index 000000000000..077c572679c6 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-4/server/api/trace-continuation.ts @@ -0,0 +1,7 @@ +import * as Sentry from '@sentry/nuxt'; +import { defineEventHandler } from '#imports'; + +export default defineEventHandler(() => { + Sentry.captureMessage('trace continuation'); + return { ok: true }; +}); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4/server/middleware/03.auth.ts b/dev-packages/e2e-tests/test-applications/nuxt-4/server/middleware/03.auth.ts index 471d1d32d7a4..a2ddef1c4c34 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-4/server/middleware/03.auth.ts +++ b/dev-packages/e2e-tests/test-applications/nuxt-4/server/middleware/03.auth.ts @@ -1,8 +1,10 @@ import { createError, defineEventHandler, getHeader, setHeader, getQuery } from '#imports'; +declare const __E2E_TEST_TUNNEL__: boolean; + export default defineEventHandler(async event => { // The tunnel variant checks that the managed tunnel answers before an auth middleware like this one. - if (process.env.E2E_TEST_TUNNEL && event.method === 'POST' && getHeader(event, 'x-auth') !== 'ok') { + if (__E2E_TEST_TUNNEL__ && event.method === 'POST' && getHeader(event, 'x-auth') !== 'ok') { throw createError({ statusCode: 401, statusMessage: 'blocked by user auth' }); } diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4/tests/build-injection.test.ts b/dev-packages/e2e-tests/test-applications/nuxt-4/tests/build-injection.test.ts index e00258723c71..6aeef1e31e88 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-4/tests/build-injection.test.ts +++ b/dev-packages/e2e-tests/test-applications/nuxt-4/tests/build-injection.test.ts @@ -2,6 +2,7 @@ import { existsSync, readFileSync, readdirSync } from 'node:fs'; import path from 'node:path'; import { expect, test } from '@playwright/test'; import { isViteServer, NITRO_ONLY, NO_IMPORT_CONFIG } from './isViteServer'; +import { RUNTIME } from './runtime'; function readServerBundle(): string { const serverDir = path.join(process.cwd(), '.output/server'); @@ -11,6 +12,11 @@ function readServerBundle(): string { .join('\n'); } +// The Cloudflare preset minifies the Worker, which renames the `tracingChannel` import. +function tracingChannelCall(name: string): RegExp { + return new RegExp(`${RUNTIME === 'cloudflare' ? '[\\w$]+' : 'tracingChannel'}\\(["']${name}["']\\)`); +} + function readClientBundle(): string { const serverDir = path.join(process.cwd(), '.output/public'); return readdirSync(serverDir, { recursive: true }) @@ -34,15 +40,15 @@ test.describe('Orchestrion build-time injection', () => { // Each transformed module carries the module-injected snippet that records it // on the global marker (and registers its subscriber factory) when evaluated. expect(serverBundle).toContain('orchestrionModuleInjected'); - expect(serverBundle).toMatch(/tracingChannel\(["']orchestrion:mysql:query["']\)/); + expect(serverBundle).toMatch(tracingChannelCall('orchestrion:mysql:query')); }); test('injects diagnostics-channel publishers into ioredis', () => { // Only a server route imports ioredis. test.skip(isViteServer, NITRO_ONLY); - expect(serverBundle).toMatch(/tracingChannel\(["']orchestrion:ioredis:command["']\)/); - expect(serverBundle).toMatch(/tracingChannel\(["']orchestrion:ioredis:connect["']\)/); + expect(serverBundle).toMatch(tracingChannelCall('orchestrion:ioredis:command')); + expect(serverBundle).toMatch(tracingChannelCall('orchestrion:ioredis:connect')); }); test('does not inject diagnostics-channel publishers into the client build', () => { @@ -51,9 +57,12 @@ test.describe('Orchestrion build-time injection', () => { }); }); +const NO_SERVER_CONFIG = 'Cloudflare: `sentryCloudflareNitroPlugin` sets up the SDK, not `sentry.server.config.ts`'; + test.describe('Sentry server config injection', () => { test('evaluates Sentry.init before nitro runs its plugins', () => { test.skip(isViteServer, NITRO_ONLY); + test.skip(RUNTIME === 'cloudflare', NO_SERVER_CONFIG); const nitroChunk = readFileSync(path.join(process.cwd(), '.output/server/chunks/nitro/nitro.mjs'), 'utf8'); @@ -72,6 +81,12 @@ test.describe('Sentry server config injection', () => { const shimPath = path.join(process.cwd(), '.output/server/sentry.server.config.mjs'); + if (RUNTIME === 'cloudflare') { + // A Worker is never started with `--import`, so there is nothing to keep working. + expect(existsSync(shimPath)).toBe(false); + return; + } + expect(existsSync(shimPath)).toBe(true); expect(readFileSync(shimPath, 'utf8')).toContain('no longer needed'); }); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4/tests/database-multi.test.ts b/dev-packages/e2e-tests/test-applications/nuxt-4/tests/database-multi.test.ts index 50a0f6ae09d6..b90ced0f444e 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-4/tests/database-multi.test.ts +++ b/dev-packages/e2e-tests/test-applications/nuxt-4/tests/database-multi.test.ts @@ -1,6 +1,7 @@ import { expect, test } from '@playwright/test'; import { collectStreamedSpansUntilSegment, getSpanOp } from '@sentry-internal/test-utils'; import { isViteServer, NITRO_ONLY } from './isViteServer'; +import { dbNamespaceAttribute } from './runtime'; test.skip(isViteServer, NITRO_ONLY); @@ -29,7 +30,7 @@ test.describe('multiple database instances', () => { 'db.query.summary': { type: 'string', value: 'SELECT default_table' }, 'db.query.text': { type: 'string', value: 'SELECT * FROM default_table WHERE id = ?' }, 'db.system.name': { type: 'string', value: 'sqlite' }, - 'db.namespace': { type: 'string', value: 'db.sqlite' }, + ...dbNamespaceAttribute('db'), 'sentry.origin': { type: 'string', value: 'auto.db.nuxt' }, }); }); @@ -48,7 +49,7 @@ test.describe('multiple database instances', () => { 'db.query.summary': { type: 'string', value: 'SELECT user_profiles' }, 'db.query.text': { type: 'string', value: 'SELECT * FROM user_profiles WHERE id = ?' }, 'db.system.name': { type: 'string', value: 'sqlite' }, - 'db.namespace': { type: 'string', value: 'users_db.sqlite' }, + ...dbNamespaceAttribute('users_db'), 'sentry.origin': { type: 'string', value: 'auto.db.nuxt' }, }); }); @@ -67,7 +68,7 @@ test.describe('multiple database instances', () => { 'db.query.summary': { type: 'string', value: 'SELECT events' }, 'db.query.text': { type: 'string', value: 'SELECT * FROM events WHERE id = ?' }, 'db.system.name': { type: 'string', value: 'sqlite' }, - 'db.namespace': { type: 'string', value: 'analytics_db.sqlite' }, + ...dbNamespaceAttribute('analytics_db'), 'sentry.origin': { type: 'string', value: 'auto.db.nuxt' }, }); }); @@ -88,21 +89,21 @@ test.describe('multiple database instances', () => { expect(sessionSpan?.attributes).toMatchObject({ 'db.query.summary': { type: 'string', value: 'SELECT sessions' }, 'db.query.text': { type: 'string', value: 'SELECT * FROM sessions WHERE id = ?' }, - 'db.namespace': { type: 'string', value: 'db.sqlite' }, + ...dbNamespaceAttribute('db'), 'db.system.name': { type: 'string', value: 'sqlite' }, 'sentry.origin': { type: 'string', value: 'auto.db.nuxt' }, }); expect(accountSpan?.attributes).toMatchObject({ 'db.query.summary': { type: 'string', value: 'SELECT accounts' }, 'db.query.text': { type: 'string', value: 'SELECT * FROM accounts WHERE id = ?' }, - 'db.namespace': { type: 'string', value: 'users_db.sqlite' }, + ...dbNamespaceAttribute('users_db'), 'db.system.name': { type: 'string', value: 'sqlite' }, 'sentry.origin': { type: 'string', value: 'auto.db.nuxt' }, }); expect(metricSpan?.attributes).toMatchObject({ 'db.query.summary': { type: 'string', value: 'SELECT metrics' }, 'db.query.text': { type: 'string', value: 'SELECT * FROM metrics WHERE id = ?' }, - 'db.namespace': { type: 'string', value: 'analytics_db.sqlite' }, + ...dbNamespaceAttribute('analytics_db'), 'db.system.name': { type: 'string', value: 'sqlite' }, 'sentry.origin': { type: 'string', value: 'auto.db.nuxt' }, }); @@ -128,7 +129,7 @@ test.describe('multiple database instances', () => { 'db.query.summary': { type: 'string', value: 'INSERT logs' }, 'db.query.text': { type: 'string', value: 'INSERT INTO logs (message) VALUES (?)' }, 'db.system.name': { type: 'string', value: 'sqlite' }, - 'db.namespace': { type: 'string', value: 'db.sqlite' }, + ...dbNamespaceAttribute('db'), 'sentry.origin': { type: 'string', value: 'auto.db.nuxt' }, }); @@ -137,7 +138,7 @@ test.describe('multiple database instances', () => { 'db.query.summary': { type: 'string', value: 'INSERT audit_logs' }, 'db.query.text': { type: 'string', value: 'INSERT INTO audit_logs (action) VALUES (?)' }, 'db.system.name': { type: 'string', value: 'sqlite' }, - 'db.namespace': { type: 'string', value: 'users_db.sqlite' }, + ...dbNamespaceAttribute('users_db'), 'sentry.origin': { type: 'string', value: 'auto.db.nuxt' }, }); }); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4/tests/database.test.ts b/dev-packages/e2e-tests/test-applications/nuxt-4/tests/database.test.ts index ab6071727d6f..0da3cecc7419 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-4/tests/database.test.ts +++ b/dev-packages/e2e-tests/test-applications/nuxt-4/tests/database.test.ts @@ -1,6 +1,7 @@ import { expect, test } from '@playwright/test'; import { collectStreamedSpansUntilSegment, getSpanOp, waitForError } from '@sentry-internal/test-utils'; import { isViteServer, NITRO_ONLY } from './isViteServer'; +import { dbNamespaceAttribute, RUNTIME } from './runtime'; test.skip(isViteServer, NITRO_ONLY); @@ -28,7 +29,7 @@ test.describe('database integration', () => { 'db.query.summary': { type: 'string', value: 'SELECT users' }, 'db.query.text': { type: 'string', value: 'SELECT * FROM users WHERE id = ?' }, 'db.system.name': { type: 'string', value: 'sqlite' }, - 'db.namespace': { type: 'string', value: 'db.sqlite' }, + ...dbNamespaceAttribute('db'), 'sentry.op': { type: 'string', value: 'db.query' }, 'sentry.origin': { type: 'string', value: 'auto.db.nuxt' }, }); @@ -151,6 +152,8 @@ test.describe('database integration', () => { expect(dbException?.mechanism).toEqual({ handled: false, type: 'auto.db.nuxt', + // D1 errors carry a `cause`, so the exception is linked and gets an id. + ...(RUNTIME === 'cloudflare' && { exception_id: 0 }), }); const dbSpan = dbSpans.find(span => span.name === 'SELECT nonexistent_table'); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4/tests/db-drivers.test.ts b/dev-packages/e2e-tests/test-applications/nuxt-4/tests/db-drivers.test.ts index 4702225dda1b..912fb27f4a91 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-4/tests/db-drivers.test.ts +++ b/dev-packages/e2e-tests/test-applications/nuxt-4/tests/db-drivers.test.ts @@ -1,6 +1,7 @@ import { expect, test } from '@playwright/test'; import { collectStreamedSpansUntilSegment, getSpanOp } from '@sentry-internal/test-utils'; import { isViteServer, NITRO_ONLY } from './isViteServer'; +import { MONGOOSE_UNSUPPORTED, RUNTIME } from './runtime'; test.skip(isViteServer, NITRO_ONLY); @@ -73,6 +74,8 @@ test('Instruments ioredis automatically', async ({ baseURL }) => { // The Nitro bundle force-inlines the instrumented drivers while their CommonJS dependencies and // Node builtins stay external, and every `require()` across that boundary needs working interop. test('Instruments mongoose automatically', async ({ baseURL }) => { + test.skip(RUNTIME === 'cloudflare', MONGOOSE_UNSUPPORTED); + const spansPromise = collectRequestSpans('/api/db-mongoose'); const response = await fetch(`${baseURL}/api/db-mongoose`); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4/tests/import-compat.test.ts b/dev-packages/e2e-tests/test-applications/nuxt-4/tests/import-compat.test.ts index be1316fb61a2..0b65f1c36d15 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-4/tests/import-compat.test.ts +++ b/dev-packages/e2e-tests/test-applications/nuxt-4/tests/import-compat.test.ts @@ -3,10 +3,14 @@ import { spawn } from 'node:child_process'; import { expect, test } from '@playwright/test'; import { getSpanOp, waitForStreamedSpan } from '@sentry-internal/test-utils'; import { isViteServer, NO_IMPORT_CONFIG } from './isViteServer'; +import { getServerCommand, NO_PROCESS, RUNTIME } from './runtime'; -// `node --import` start commands must keep working now that the config is bundled: the emitted -// config file is a shim that only prints a removal hint, and a preload that really initializes -// the SDK must not cause a second init. Each test spawns its own server on a dedicated port. +test.skip(RUNTIME === 'cloudflare', NO_PROCESS); + +// `node --import` start commands (and their `--preload` counterparts on Bun and Deno) must keep +// working now that the config is bundled: the emitted config file is a shim that only prints a +// removal hint, and a preload that really initializes the SDK must not cause a second init. Each +// test spawns its own server on a dedicated port. interface PreloadedServer { child: ChildProcess; @@ -14,7 +18,8 @@ interface PreloadedServer { } async function startServerWithPreload(preloadPath: string, port: string): Promise { - const child = spawn('node', ['--import', preloadPath, '.output/server/index.mjs'], { + const [command, args] = getServerCommand(preloadPath); + const child = spawn(command, args, { env: { ...process.env, PORT: port }, }); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4/tests/logs.server.test.ts b/dev-packages/e2e-tests/test-applications/nuxt-4/tests/logs.server.test.ts new file mode 100644 index 000000000000..349c98e524af --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-4/tests/logs.server.test.ts @@ -0,0 +1,28 @@ +import { expect, test } from '@playwright/test'; +import { waitForEnvelopeItem, waitForStreamedSpan } from '@sentry-internal/test-utils'; + +interface LogContainer { + items: { trace_id?: string; body: string; level: string; attributes?: Record }[]; +} + +test('sends a log of a server route in the trace of the request', async ({ request }) => { + const segmentPromise = waitForStreamedSpan( + 'nuxt-4', + span => span.is_segment && span.attributes['url.path']?.value === '/api/log', + ); + + await request.get('/api/log'); + + const segment = await segmentPromise; + // The log can arrive before the segment, so the search starts at the first envelope of the proxy. + const logItem = await waitForEnvelopeItem( + 'nuxt-4', + item => item[0].type === 'log' && (item[1] as LogContainer).items.some(log => log.trace_id === segment.trace_id), + 0, + ); + const log = (logItem[1] as LogContainer).items.find(log => log.trace_id === segment.trace_id)!; + + expect(log.body).toBe('nuxt-4 server log'); + expect(log.level).toBe('info'); + expect(log.attributes?.['e2e.attr']).toEqual({ value: 'value', type: 'string' }); +}); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4/tests/runtime.ts b/dev-packages/e2e-tests/test-applications/nuxt-4/tests/runtime.ts new file mode 100644 index 000000000000..3870bcf63c4b --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-4/tests/runtime.ts @@ -0,0 +1,31 @@ +import { getRuntime } from '@sentry-internal/test-utils'; + +export const RUNTIME = getRuntime(); + +export const NO_PROCESS = 'Cloudflare: the Worker is no process the tests can start with a preload or watch exit'; + +// Without Sentry too, the Cloudflare preset of Nitro allows no externals and fails the build on the optional +// `require()`s of the `mongodb` driver (for example `@aws-sdk/credential-providers`). +export const MONGOOSE_UNSUPPORTED = 'Cloudflare: Nitro cannot bundle the `mongodb` driver for Workers'; + +export const SERVER_ORIGIN = RUNTIME === 'cloudflare' ? 'auto.http.cloudflare' : 'auto.http.http_server'; + +/** `db.namespace` of a db0 span: Nitro names the SQLite file after the database, while a D1 binding names none. */ +export function dbNamespaceAttribute(name: string): { 'db.namespace'?: { type: string; value: string } } { + return RUNTIME === 'cloudflare' ? {} : { 'db.namespace': { type: 'string', value: `${name}.sqlite` } }; +} + +/** The command and arguments that start the built server on the runtime of the variant. */ +export function getServerCommand(preload?: string): [string, string[]] { + const entry = '.output/server/index.mjs'; + + if (RUNTIME === 'bun') { + return ['bun', preload ? ['--preload', preload, entry] : [entry]]; + } + + if (RUNTIME === 'deno') { + return ['deno', ['run', '-A', ...(preload ? [`--preload=${preload}`] : []), entry]]; + } + + return ['node', preload ? ['--import', preload, entry] : [entry]]; +} diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4/tests/scopes.server.test.ts b/dev-packages/e2e-tests/test-applications/nuxt-4/tests/scopes.server.test.ts new file mode 100644 index 000000000000..5d0bcf032b3c --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-4/tests/scopes.server.test.ts @@ -0,0 +1,16 @@ +import { expect, test } from '@playwright/test'; +import { waitForError } from '@sentry-internal/test-utils'; + +test('isolates the scope data and trace of concurrent requests', async ({ request }) => { + const tags = ['alpha', 'beta', 'gamma']; + const eventPromises = tags.map(tag => waitForError('nuxt-4', event => event.message === `isolation ${tag}`)); + + await Promise.all(tags.map(tag => request.get(`/api/isolation?tag=${tag}`))); + const events = await Promise.all(eventPromises); + + events.forEach((event, index) => { + expect(event.tags?.['isolation-tag']).toBe(tags[index]); + expect(event.transaction).toBe('GET /api/isolation'); + }); + expect(new Set(events.map(event => event.contexts?.trace?.trace_id)).size).toBe(tags.length); +}); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4/tests/startup-error.test.ts b/dev-packages/e2e-tests/test-applications/nuxt-4/tests/startup-error.test.ts index b51a0719c206..e7f1ccae231f 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-4/tests/startup-error.test.ts +++ b/dev-packages/e2e-tests/test-applications/nuxt-4/tests/startup-error.test.ts @@ -2,17 +2,25 @@ import { spawn } from 'node:child_process'; import { expect, test } from '@playwright/test'; import { waitForError, waitForSession } from '@sentry-internal/test-utils'; import { isViteServer, NITRO_ONLY } from './isViteServer'; +import { getServerCommand, NO_PROCESS, RUNTIME } from './runtime'; test.skip(isViteServer, NITRO_ONLY); +test.skip(RUNTIME === 'cloudflare', NO_PROCESS); // Errors thrown between server start and `listen` (module evaluation, nitro plugin runs) must be // captured and flushed before the process exits. Each test spawns its own server process because // the crash kills it; events still reach the shared event proxy via the tunnel. +// Deno hands an error thrown while the modules evaluate to the `unhandledRejection` listeners, where Node and Bun +// treat it as an uncaught exception. The listener of the SDK (mode `warn`) keeps the process alive, so on Deno it +// exits with 0 once it has nothing left to do. +const CRASH_EXIT_CODE = RUNTIME === 'deno' ? 0 : 1; + /** Starts the built server and resolves with its exit code. */ function spawnCrashingServer(env: Record): Promise { return new Promise((resolve, reject) => { - const child = spawn('node', ['.output/server/index.mjs'], { + const [command, args] = getServerCommand(); + const child = spawn(command, args, { // Session tracking needs a release env: { ...process.env, SENTRY_RELEASE: 'startup-error-test', ...env }, }); @@ -29,7 +37,7 @@ test('captures error and crashed session when a nitro plugin throws during start const [errorEvent, session] = await Promise.all([errorPromise, sessionPromise]); - expect(exitCode).toBe(1); + expect(exitCode).toBe(CRASH_EXIT_CODE); expect(errorEvent.exception?.values?.[0]?.value).toBe('startup-crash-test'); expect(errorEvent.exception?.values?.[0]?.mechanism?.handled).toBe(false); expect(session.status).toBe('crashed'); @@ -43,7 +51,7 @@ test('captures error when a server module throws during bundle evaluation', asyn const errorEvent = await errorPromise; - expect(exitCode).toBe(1); + expect(exitCode).toBe(CRASH_EXIT_CODE); expect(errorEvent.exception?.values?.[0]?.value).toBe('eval-crash-test'); expect(errorEvent.exception?.values?.[0]?.mechanism?.handled).toBe(false); }); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4/tests/trace-propagation.server.test.ts b/dev-packages/e2e-tests/test-applications/nuxt-4/tests/trace-propagation.server.test.ts new file mode 100644 index 000000000000..c0179c19585d --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-4/tests/trace-propagation.server.test.ts @@ -0,0 +1,55 @@ +import { expect, test } from '@playwright/test'; +import type { SerializedStreamedSpan } from '@sentry-internal/test-utils'; +import { collectStreamedSpans, getSpanOp, waitForError, waitForStreamedSpan } from '@sentry-internal/test-utils'; + +test('continues an incoming trace in the server segment and its events', async ({ request }) => { + const traceId = crypto.randomUUID().replace(/-/g, ''); + const parentSpanId = traceId.slice(0, 16); + + const segmentPromise = waitForStreamedSpan('nuxt-4', span => span.is_segment && span.trace_id === traceId); + const eventPromise = waitForError('nuxt-4', event => event.message === 'trace continuation'); + + await request.get('/api/trace-continuation', { + headers: { + 'sentry-trace': `${traceId}-${parentSpanId}-1`, + baggage: `sentry-trace_id=${traceId},sentry-sample_rate=1,sentry-sampled=true`, + }, + }); + + const [segment, event] = await Promise.all([segmentPromise, eventPromise]); + + expect(getSpanOp(segment)).toBe('http.server'); + expect(segment.parent_span_id).toBe(parentSpanId); + expect(segment.attributes['url.path']?.value).toBe('/api/trace-continuation'); + expect(event.contexts?.trace?.trace_id).toBe(traceId); + expect(event.contexts?.trace?.span_id).toBe(segment.span_id); +}); + +test('propagates the trace to an outgoing `fetch` request', async ({ request }) => { + const isOutgoingSegment = (span: SerializedStreamedSpan) => + span.is_segment && span.attributes['url.path']?.value === '/api/outgoing-fetch'; + const isIncomingSegment = (span: SerializedStreamedSpan) => + span.is_segment && span.attributes['url.path']?.value === '/api/echo-headers'; + const isHttpClientSpan = (span: SerializedStreamedSpan) => + getSpanOp(span) === 'http.client' && String(span.attributes['url.full']?.value).endsWith('/api/echo-headers'); + + // Both segments and the http.client span in between share one trace, and `collectStreamedSpans` + // evaluates one trace at a time, so requiring all three keeps them paired. + const spansPromise = collectStreamedSpans( + 'nuxt-4', + spans => spans.some(isOutgoingSegment) && spans.some(isIncomingSegment) && spans.some(isHttpClientSpan), + ); + + const headers = await (await request.get('/api/outgoing-fetch')).json(); + + const spans = await spansPromise; + const outgoingSegment = spans.find(isOutgoingSegment)!; + const incomingSegment = spans.find(isIncomingSegment)!; + const httpClientSpan = spans.find(isHttpClientSpan)!; + + expect(httpClientSpan.parent_span_id).toBe(outgoingSegment.span_id); + expect(incomingSegment.parent_span_id).toBe(httpClientSpan.span_id); + expect(headers['sentry-trace']).toBe(`${outgoingSegment.trace_id}-${httpClientSpan.span_id}-1`); + expect(headers.baggage).toContain(`sentry-trace_id=${outgoingSegment.trace_id}`); + expect(headers.baggage).toContain('sentry-sampled=true'); +}); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4/tests/tracing.cached-html.test.ts b/dev-packages/e2e-tests/test-applications/nuxt-4/tests/tracing.cached-html.test.ts index 42ed5115020c..db4b67c60132 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-4/tests/tracing.cached-html.test.ts +++ b/dev-packages/e2e-tests/test-applications/nuxt-4/tests/tracing.cached-html.test.ts @@ -1,6 +1,7 @@ import { expect, test, type Page } from '@playwright/test'; import { getSpanOp, waitForStreamedSpan } from '@sentry-internal/test-utils'; import { isViteServer, NO_META_TAGS } from './isViteServer'; +import { RUNTIME } from './runtime'; test.describe('Rendering Modes with Cached HTML', () => { test('changes tracing meta tags with multiple requests on Client-Side only page', async ({ page }) => { @@ -27,7 +28,10 @@ test.describe('Rendering Modes with Cached HTML', () => { }); test('exclude tracing meta tags on pre-rendered page', async ({ page }) => { - await testExcludeTracingMetaTagsOnCachedPage(page, '/rendering-modes/pre-rendered-page', 'Pre-Rendered Page'); + await testExcludeTracingMetaTagsOnCachedPage(page, '/rendering-modes/pre-rendered-page', 'Pre-Rendered Page', { + // On Cloudflare, the pre-rendered page is a static asset, served without the Nitro app, where the SDK runs. + hasServerSegment: RUNTIME !== 'cloudflare', + }); }); }); @@ -132,17 +136,19 @@ export async function testChangingTracingMetaTagsOnISRPage( * @param page - Playwright page object * @param routePath - The route path to test (e.g., '/rendering-modes/swr-cached-page') * @param expectedPageText - The text to verify is visible on the page (e.g., 'SWR Cached Page') + * @param options.hasServerSegment - Whether the first request reaches the server, which then sends a segment */ export async function testExcludeTracingMetaTagsOnCachedPage( page: Page, routePath: string, expectedPageText: string, + { hasServerSegment = true }: { hasServerSegment?: boolean } = {}, ): Promise { // === 1. Request === const clientSpanPromise1 = waitForPageloadSegment(routePath); // Only the 1. request creates a server root span - const serverSpanPromise1 = waitForServerSegment(routePath); + const serverSpanPromise1 = hasServerSegment ? waitForServerSegment(routePath) : undefined; const [_1, clientSpan1, serverSpan1] = await Promise.all([ page.goto(routePath), @@ -168,7 +174,7 @@ export async function testExcludeTracingMetaTagsOnCachedPage( const clientSpan1TraceId = clientSpan1.trace_id; const clientSpan2TraceId = clientSpan2.trace_id; - const serverSpan1TraceId = serverSpan1.trace_id; + const serverSpan1TraceId = serverSpan1?.trace_id; await test.step('No baggage and sentry-trace meta-tags are present on second request', async () => { expect(await page.locator('meta[name="baggage"]').count()).toBe(0); @@ -176,7 +182,7 @@ export async function testExcludeTracingMetaTagsOnCachedPage( }); await test.step('1. server root span and all client root spans are defined', () => { - expect(serverSpan1TraceId).toBeDefined(); + expect(serverSpan1TraceId).toEqual(hasServerSegment ? expect.any(String) : undefined); expect(clientSpan1TraceId).toBeDefined(); expect(clientSpan2TraceId).toBeDefined(); }); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4/tests/tracing.server.test.ts b/dev-packages/e2e-tests/test-applications/nuxt-4/tests/tracing.server.test.ts index a0c8630bd730..25d54633ebf7 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-4/tests/tracing.server.test.ts +++ b/dev-packages/e2e-tests/test-applications/nuxt-4/tests/tracing.server.test.ts @@ -2,6 +2,7 @@ import { expect, test } from '@playwright/test'; import { collectStreamedSpans, getSpanOp, waitForStreamedSpan } from '@sentry-internal/test-utils'; import { IMPORT_SURFACES } from './importSurfaces'; import { isViteServer, NITRO_ONLY } from './isViteServer'; +import { SERVER_ORIGIN } from './runtime'; test('sends a server root span on pageload', async ({ page }) => { const serverSpanPromise = waitForStreamedSpan('nuxt-4', span => { @@ -13,7 +14,7 @@ test('sends a server root span on pageload', async ({ page }) => { const serverSpan = await serverSpanPromise; expect(getSpanOp(serverSpan)).toBe('http.server'); - expect(serverSpan.attributes['sentry.origin']?.value).toBe('auto.http.http_server'); + expect(serverSpan.attributes['sentry.origin']?.value).toBe(SERVER_ORIGIN); }); test('does not send spans for build asset folder "_nuxt"', async ({ page }) => { diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4/tests/tracing.test.ts b/dev-packages/e2e-tests/test-applications/nuxt-4/tests/tracing.test.ts index b67402a6ae23..304ba32ca537 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-4/tests/tracing.test.ts +++ b/dev-packages/e2e-tests/test-applications/nuxt-4/tests/tracing.test.ts @@ -2,6 +2,7 @@ import { expect, test } from '@playwright/test'; import { collectStreamedSpans, getSpanOp, waitForStreamedSpan } from '@sentry-internal/test-utils'; import { IMPORT_SURFACES } from './importSurfaces'; import { isViteServer, NITRO_ONLY, NO_META_TAGS } from './isViteServer'; +import { RUNTIME, SERVER_ORIGIN } from './runtime'; test.describe('distributed tracing', () => { const PARAM = 's0me-param'; @@ -27,8 +28,14 @@ test.describe('distributed tracing', () => { await expect(page.locator('meta[name="baggage"]')).toHaveCount(1); const baggageMetaTagContent = await page.locator('meta[name="baggage"]').getAttribute('content'); - // URL-encoded for parametrized 'GET /test-param/s0me-param' -> `GET /test-param/:param` - expect(baggageMetaTagContent).toContain(`sentry-transaction=GET%20%2Ftest-param%2F%3Aparam`); + if (RUNTIME === 'cloudflare') { + // `sentryCloudflareNitroPlugin` takes the trace data of the meta tags when the request starts, before the + // route names the segment. + expect(baggageMetaTagContent).not.toContain('sentry-transaction='); + } else { + // URL-encoded for parametrized 'GET /test-param/s0me-param' -> `GET /test-param/:param` + expect(baggageMetaTagContent).toContain(`sentry-transaction=GET%20%2Ftest-param%2F%3Aparam`); + } expect(baggageMetaTagContent).toContain(`sentry-trace_id=${serverSpan.trace_id}`); expect(baggageMetaTagContent).toContain('sentry-sampled=true'); expect(baggageMetaTagContent).toContain('sentry-sample_rate=1'); @@ -55,7 +62,7 @@ test.describe('distributed tracing', () => { is_segment: true, attributes: expect.objectContaining({ 'sentry.op': { type: 'string', value: 'http.server' }, - 'sentry.origin': { type: 'string', value: 'auto.http.http_server' }, + 'sentry.origin': { type: 'string', value: SERVER_ORIGIN }, 'sentry.segment.name.source': { type: 'string', value: 'route' }, }), }); @@ -135,7 +142,7 @@ test.describe('distributed tracing', () => { is_segment: true, attributes: expect.objectContaining({ 'sentry.op': { type: 'string', value: 'http.server' }, - 'sentry.origin': { type: 'string', value: 'auto.http.http_server' }, + 'sentry.origin': { type: 'string', value: SERVER_ORIGIN }, 'sentry.segment.name.source': { type: 'string', value: 'route' }, }), }); @@ -146,7 +153,7 @@ test.describe('distributed tracing', () => { parent_span_id: httpClientSpan?.span_id, // http.client span is parent attributes: expect.objectContaining({ 'sentry.op': { type: 'string', value: 'http.server' }, - 'sentry.origin': { type: 'string', value: 'auto.http.http_server' }, + 'sentry.origin': { type: 'string', value: SERVER_ORIGIN }, }), }); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4-cloudflare/tests/tunnel-request-body.test.ts b/dev-packages/e2e-tests/test-applications/nuxt-4/tests/tunnel-request-body.test.ts similarity index 56% rename from dev-packages/e2e-tests/test-applications/nuxt-4-cloudflare/tests/tunnel-request-body.test.ts rename to dev-packages/e2e-tests/test-applications/nuxt-4/tests/tunnel-request-body.test.ts index aaca20e2a3eb..24173103eb56 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-4-cloudflare/tests/tunnel-request-body.test.ts +++ b/dev-packages/e2e-tests/test-applications/nuxt-4/tests/tunnel-request-body.test.ts @@ -4,9 +4,13 @@ import { waitForError } from '@sentry-internal/test-utils'; test.skip(!process.env.E2E_TEST_TUNNEL, 'Only runs in the tunnel variant'); test('keeps the body of a request that is not a tunnel request off its error event', async ({ request }) => { - const errorPromise = waitForError('nuxt-4-cloudflare', event => event.exception?.values?.[0]?.value === 'POST error'); + const errorPromise = waitForError('nuxt-4', event => event.exception?.values?.[0]?.value === 'POST error'); - await request.post('/app/api/post-error', { data: { note: 'stays off the event' } }); + await request.post(`${process.env.E2E_TEST_BASE_PATH ?? ''}/api/post-error`, { + data: { note: 'stays off the event' }, + // Passes the auth middleware of the tunnel variant. + headers: { 'x-auth': 'ok' }, + }); const error = await errorPromise; expect(error.request?.method).toBe('POST'); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4/wrangler.jsonc b/dev-packages/e2e-tests/test-applications/nuxt-4/wrangler.jsonc new file mode 100644 index 000000000000..35ba0deb951f --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-4/wrangler.jsonc @@ -0,0 +1,23 @@ +{ + "$schema": "node_modules/wrangler/config-schema.json", + "name": "nuxt-4", + "main": "./.output/server/index.mjs", + "compatibility_date": "2026-06-29", + "compatibility_flags": ["nodejs_compat"], + "assets": { + "directory": "./.output/public", + "binding": "ASSETS", + // Like Nitro on Node, serve pre-rendered pages at their path instead of redirecting to a trailing slash. + "html_handling": "drop-trailing-slash", + }, + // `wrangler dev` keeps these in a local SQLite file, so they need no real database. + "d1_databases": [ + { "binding": "DB", "database_name": "db", "database_id": "00000000-0000-0000-0000-000000000001" }, + { "binding": "USERS_DB", "database_name": "users_db", "database_id": "00000000-0000-0000-0000-000000000002" }, + { + "binding": "ANALYTICS_DB", + "database_name": "analytics_db", + "database_id": "00000000-0000-0000-0000-000000000003", + }, + ], +} diff --git a/packages/nuxt/src/runtime/plugins/sentry-cloudflare.server.ts b/packages/nuxt/src/runtime/plugins/sentry-cloudflare.server.ts index 45dfa9a87a55..f92af8fb367a 100644 --- a/packages/nuxt/src/runtime/plugins/sentry-cloudflare.server.ts +++ b/packages/nuxt/src/runtime/plugins/sentry-cloudflare.server.ts @@ -2,14 +2,20 @@ import type { ExecutionContext } from '@cloudflare/workers-types'; import type { CloudflareOptions } from '@sentry/cloudflare'; import { getDefaultIntegrations, setAsyncLocalStorageAsyncContextStrategy } from '@sentry/cloudflare'; import { wrapRequestHandler } from '@sentry/cloudflare/request'; -import { debug, getDefaultIsolationScope, getIsolationScope, getTraceData } from '@sentry/core'; +import { + debug, + getDefaultIsolationScope, + getIsolationScope, + getTraceData, + stripUrlQueryAndFragment, +} from '@sentry/core'; import { _INTERNAL_isServedTunnelRequest as isServedTunnelRequest } from '@sentry/core/server'; import type { H3Event } from 'h3'; import type { NitroApp, NitroAppPlugin } from 'nitropack'; import type { NuxtRenderHTMLContext } from 'nuxt/app'; import { sentryCaptureErrorHook } from '../hooks/captureErrorHook'; import { updateRouteBeforeResponse } from '../hooks/updateRouteBeforeResponse'; -import { addSentryTracingMetaTags } from '../utils'; +import { addSentryTracingMetaTags, isCachedHtml } from '../utils'; import { getCfProperties, getCloudflareProperties, hasCfProperty, isEventType } from '../utils/event-type-check'; /** @@ -75,7 +81,10 @@ export const sentryCloudflareNitroPlugin = request, context: runtime.cloudflare.context, }, - () => appFetch(request), + () => { + setRequestTransactionName(request.method, new URL(request.url).pathname); + return appFetch(request); + }, ); }; nitroApp.hooks.hook('error', sentryCaptureErrorHook); @@ -120,6 +129,8 @@ export const sentryCloudflareNitroPlugin = }; return wrapRequestHandler(requestHandlerOptions, () => { + setRequestTransactionName(event.method, pathname); + const isolationScope = getIsolationScope(); const newIsolationScope = isolationScope === getDefaultIsolationScope() ? isolationScope.clone() : isolationScope; @@ -146,6 +157,10 @@ export const sentryCloudflareNitroPlugin = nitroApp.hooks.hook('beforeResponse', updateRouteBeforeResponse); nitroApp.hooks.hook('render:html', (html: NuxtRenderHTMLContext, { event }: { event: H3Event }) => { + if (isCachedHtml(event)) { + return; + } + let storedTraceData: ReturnType | undefined = undefined; if ( @@ -170,3 +185,11 @@ export const sentryCloudflareNitroPlugin = nitroApp.hooks.hook('error', sentryCaptureErrorHook); }; + +/** + * Names the events of the request after its method and path until a route names them, like the `node:http` + * instrumentation does on Node. Without it, events captured outside Nitro's `error` hook have no transaction. + */ +function setRequestTransactionName(method: string, path: string): void { + getIsolationScope().setTransactionName(`${method.toUpperCase()} ${stripUrlQueryAndFragment(path)}`); +} diff --git a/packages/nuxt/src/runtime/plugins/sentry.server.ts b/packages/nuxt/src/runtime/plugins/sentry.server.ts index fd35d035c077..79503f69ca72 100644 --- a/packages/nuxt/src/runtime/plugins/sentry.server.ts +++ b/packages/nuxt/src/runtime/plugins/sentry.server.ts @@ -1,36 +1,15 @@ -import { debug } from '@sentry/core'; import type { H3Event } from 'h3'; import type { NitroAppPlugin } from 'nitropack'; import type { NuxtRenderHTMLContext } from 'nuxt/app'; import { sentryCaptureErrorHook } from '../hooks/captureErrorHook'; -import { addSentryTracingMetaTags } from '../utils'; +import { addSentryTracingMetaTags, isCachedHtml } from '../utils'; export default (nitroApp => { nitroApp.hooks.hook('error', sentryCaptureErrorHook); nitroApp.hooks.hook('render:html', (html: NuxtRenderHTMLContext, { event }: { event: H3Event }) => { - // h3 v1 (Nuxt 4): event.node.res.getHeaders(); h3 v2 (Nuxt 5): event.node is undefined - const nodeResHeadersH3v1 = event.node?.res?.getHeaders() || {}; - - // h3 v2 (Nuxt 5): response headers are on event.res.headers - const isPreRenderedPage = - Object.keys(nodeResHeadersH3v1).includes('x-nitro-prerender') || - // fix × typescript-eslint(no-unsafe-member-access): Unsafe member access .res on an `any` value. - // oxlint-disable-next-line typescript/no-explicit-any,typescript-oxlint/no-unsafe-member-access - !!(event as any).res?.headers?.has?.('x-nitro-prerender'); - - // oxlint-disable-next-line typescript-oxlint/no-unsafe-member-access - const isSWRCachedPage = /* Nitro v2 */ (event?.context?.cache?.options?.swr || - // oxlint-disable-next-line typescript-oxlint/no-unsafe-member-access - /* Nitro v3 */ event?.context?.routeRules?.swr) as boolean; - - if (!isPreRenderedPage && !isSWRCachedPage) { + if (!isCachedHtml(event)) { addSentryTracingMetaTags(html.head); - } else { - const reason = isPreRenderedPage ? 'the page was pre-rendered' : 'SWR caching is enabled for the route'; - debug.log( - `Not adding Sentry tracing meta tags to HTML for ${event.path} because ${reason}. This will disable distributed tracing and prevent connecting multiple client page loads to the same server request.`, - ); } }); }) satisfies NitroAppPlugin; diff --git a/packages/nuxt/src/runtime/utils.ts b/packages/nuxt/src/runtime/utils.ts index becb6ef55d19..0bafb214d7bf 100644 --- a/packages/nuxt/src/runtime/utils.ts +++ b/packages/nuxt/src/runtime/utils.ts @@ -1,5 +1,6 @@ import type { ClientOptions, Context, SerializedTraceData } from '@sentry/core'; import { captureException, debug, getClient, getTraceMetaTags, isObjectLike } from '@sentry/core'; +import type { H3Event } from 'h3'; import type { CapturedErrorContext } from 'nitropack/types'; import type { NuxtRenderHTMLContext } from 'nuxt/app'; import type { ComponentPublicInstance } from 'vue'; @@ -49,6 +50,37 @@ export function extractErrorContext(errorContext: CapturedErrorContext | undefin return ctx; } +/** + * Whether the HTML of the request is pre-rendered or SWR-cached. Cached HTML gets no tracing meta tags, because every + * page load that is served it would continue the trace of the request that rendered it. + */ +export function isCachedHtml(event: H3Event): boolean { + // h3 v1 (Nuxt 4): event.node.res.getHeaders(); h3 v2 (Nuxt 5): event.node is undefined + const nodeResHeadersH3v1 = event.node?.res?.getHeaders() || {}; + + // h3 v2 (Nuxt 5): response headers are on event.res.headers + const isPreRenderedPage = + Object.keys(nodeResHeadersH3v1).includes('x-nitro-prerender') || + // fix × typescript-eslint(no-unsafe-member-access): Unsafe member access .res on an `any` value. + // oxlint-disable-next-line typescript/no-explicit-any,typescript-oxlint/no-unsafe-member-access + !!(event as any).res?.headers?.has?.('x-nitro-prerender'); + + // oxlint-disable-next-line typescript-oxlint/no-unsafe-member-access + const isSWRCachedPage = /* Nitro v2 */ (event?.context?.cache?.options?.swr || + // oxlint-disable-next-line typescript-oxlint/no-unsafe-member-access + /* Nitro v3 */ event?.context?.routeRules?.swr) as boolean; + + if (!isPreRenderedPage && !isSWRCachedPage) { + return false; + } + + const reason = isPreRenderedPage ? 'the page was pre-rendered' : 'SWR caching is enabled for the route'; + debug.log( + `Not adding Sentry tracing meta tags to HTML for ${event.path} because ${reason}. This will disable distributed tracing and prevent connecting multiple client page loads to the same server request.`, + ); + return true; +} + /** * Adds Sentry tracing tags to the returned html page. * diff --git a/packages/nuxt/src/runtime/utils/database-span-data.ts b/packages/nuxt/src/runtime/utils/database-span-data.ts index ed3a4804a656..aa819fe62b74 100644 --- a/packages/nuxt/src/runtime/utils/database-span-data.ts +++ b/packages/nuxt/src/runtime/utils/database-span-data.ts @@ -45,7 +45,11 @@ export function getDatabaseSpanData(config?: DatabaseConnectionConfig): Partial< }; } - if ((['better-sqlite3', 'bun', 'sqlite', 'sqlite3'] as ConnectorName[]).includes(config.connector)) { + if ( + (['better-sqlite3', 'bun', 'bun-sqlite', 'node-sqlite', 'sqlite', 'sqlite3'] as ConnectorName[]).includes( + config.connector, + ) + ) { return { // DB is the default file name in nitro for sqlite-like connectors [DB_NAMESPACE]: `${config.options?.name ?? 'db'}.sqlite`, diff --git a/packages/nuxt/src/runtime/utils/instrumentDatabase.ts b/packages/nuxt/src/runtime/utils/instrumentDatabase.ts index 6dc9a4d84818..7834f4c2d630 100644 --- a/packages/nuxt/src/runtime/utils/instrumentDatabase.ts +++ b/packages/nuxt/src/runtime/utils/instrumentDatabase.ts @@ -73,7 +73,8 @@ function instrumentDatabase(db: MaybeInstrumentedDatabase, config?: DatabaseConn } const metadata: DatabaseSpanData = { - [DB_SYSTEM_NAME]: config?.connector ?? db.dialect, + // A connector name like `bun-sqlite` or `mysql2` names the driver, the dialect names the database system. + [DB_SYSTEM_NAME]: db.dialect ?? config?.connector, ...getDatabaseSpanData(config), }; @@ -85,7 +86,17 @@ function instrumentDatabase(db: MaybeInstrumentedDatabase, config?: DatabaseConn apply(target, thisArg, args: Parameters) { const [query] = args; - return instrumentPreparedStatement(target.apply(thisArg, args), query, metadata, dialect); + let statement: PreparedStatement; + try { + statement = target.apply(thisArg, args); + } catch (error) { + // Connectors like `bun-sqlite` and `better-sqlite3` compile the statement in `prepare`, so an invalid query + // throws here and not when the statement runs. + startSpan(createStartSpanOptions(query, metadata, dialect), span => captureDatabaseError(span, error)); + throw error; + } + + return instrumentPreparedStatement(statement, query, metadata, dialect); }, }); @@ -224,13 +235,7 @@ function handleSpanStart(fn: () => unknown, breadcrumbOpts?: { query: string; di return result; } catch (error) { - span.setStatus({ code: SPAN_STATUS_ERROR, message: 'internal_error' }); - captureException(error, { - mechanism: { - handled: false, - type: NUXT_DATABASE_ORIGIN, - }, - }); + captureDatabaseError(span, error); // Re-throw the error to be handled by the caller throw error; @@ -240,6 +245,16 @@ function handleSpanStart(fn: () => unknown, breadcrumbOpts?: { query: string; di }; } +function captureDatabaseError(span: Span, error: unknown): void { + span.setStatus({ code: SPAN_STATUS_ERROR, message: 'internal_error' }); + captureException(error, { + mechanism: { + handled: false, + type: NUXT_DATABASE_ORIGIN, + }, + }); +} + function createBreadcrumb(query: string, dialect: SqlDialect | undefined): void { // The breadcrumb carries the same query text as the span, so it is sanitized and guarded the same way. const queryText = query ? sanitizeSqlQuery(query, dialect) : undefined; diff --git a/packages/nuxt/test/runtime/utils.test.ts b/packages/nuxt/test/runtime/utils.test.ts index e930f63afde0..c55639805eb5 100644 --- a/packages/nuxt/test/runtime/utils.test.ts +++ b/packages/nuxt/test/runtime/utils.test.ts @@ -1,7 +1,8 @@ import { captureException, getClient } from '@sentry/core'; import { afterEach, beforeEach, describe, expect, it, type Mock, test, vi } from 'vitest'; import type { ComponentPublicInstance } from 'vue'; -import { extractErrorContext, reportNuxtError } from '../../src/runtime/utils'; +import type { H3Event } from 'h3'; +import { extractErrorContext, isCachedHtml, reportNuxtError } from '../../src/runtime/utils'; describe('extractErrorContext', () => { it('returns empty object for undefined or empty context', () => { @@ -167,3 +168,27 @@ describe('reportNuxtError', () => { }); }); }); + +describe('isCachedHtml', () => { + function createEvent({ + headers = {}, + context = {}, + }: { + headers?: Record; + context?: object; + }): H3Event { + return { path: '/page', node: { res: { getHeaders: () => headers } }, context } as unknown as H3Event; + } + + it('is true for a pre-rendered page', () => { + expect(isCachedHtml(createEvent({ headers: { 'x-nitro-prerender': '/page' } }))).toBe(true); + }); + + it('is true for an SWR-cached page', () => { + expect(isCachedHtml(createEvent({ context: { cache: { options: { swr: true } } } }))).toBe(true); + }); + + it('is false for a page rendered per request', () => { + expect(isCachedHtml(createEvent({}))).toBe(false); + }); +}); diff --git a/packages/nuxt/test/runtime/utils/database-span-data.test.ts b/packages/nuxt/test/runtime/utils/database-span-data.test.ts index fc4f4b376af8..8d4e19bc82e2 100644 --- a/packages/nuxt/test/runtime/utils/database-span-data.test.ts +++ b/packages/nuxt/test/runtime/utils/database-span-data.test.ts @@ -124,7 +124,7 @@ describe('getDatabaseSpanData', () => { }); describe('SQLite-like connectors', () => { - it.each(['better-sqlite3', 'bun', 'sqlite', 'sqlite3'] as ConnectorName[])( + it.each(['better-sqlite3', 'bun', 'bun-sqlite', 'node-sqlite', 'sqlite', 'sqlite3'] as ConnectorName[])( 'should extract database name for %s', connector => { const config: DatabaseConfig = { @@ -141,7 +141,7 @@ describe('getDatabaseSpanData', () => { }, ); - it.each(['better-sqlite3', 'bun', 'sqlite', 'sqlite3'] as ConnectorName[])( + it.each(['better-sqlite3', 'bun', 'bun-sqlite', 'node-sqlite', 'sqlite', 'sqlite3'] as ConnectorName[])( 'should use default name for %s when name is not provided', connector => { const config: DatabaseConfig = { @@ -156,7 +156,7 @@ describe('getDatabaseSpanData', () => { }, ); - it.each(['better-sqlite3', 'bun', 'sqlite', 'sqlite3'] as ConnectorName[])( + it.each(['better-sqlite3', 'bun', 'bun-sqlite', 'node-sqlite', 'sqlite', 'sqlite3'] as ConnectorName[])( 'should handle missing options for %s', connector => { const config: DatabaseConfig = { diff --git a/packages/nuxt/test/runtime/utils/instrumentDatabase.test.ts b/packages/nuxt/test/runtime/utils/instrumentDatabase.test.ts new file mode 100644 index 000000000000..89230cbd080e --- /dev/null +++ b/packages/nuxt/test/runtime/utils/instrumentDatabase.test.ts @@ -0,0 +1,62 @@ +import * as SentryCore from '@sentry/core'; +import type { Database } from 'db0'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { createDatabasePlugin } from '../../../src/runtime/utils/instrumentDatabase'; + +const span = { setStatus: vi.fn() }; + +vi.mock('@sentry/core', async importOriginal => ({ + ...(await importOriginal()), + captureException: vi.fn(), + startSpan: vi.fn((_options, callback) => callback(span)), +})); + +function createDatabase(prepare: Database['prepare']): Database { + return { dialect: 'sqlite', prepare, sql: vi.fn(), exec: vi.fn() } as unknown as Database; +} + +describe('createDatabasePlugin', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('captures an error that `prepare` throws and rethrows it', () => { + const error = new Error('no such table: missing'); + const db = createDatabase(() => { + throw error; + }); + + createDatabasePlugin(() => db, { default: { connector: 'bun-sqlite' } }); + + expect(() => db.prepare('SELECT * FROM missing')).toThrow(error); + expect(SentryCore.startSpan).toHaveBeenCalledWith( + expect.objectContaining({ + attributes: expect.objectContaining({ + 'db.query.text': 'SELECT * FROM missing', + 'db.system.name': 'sqlite', + 'sentry.origin': 'auto.db.nuxt', + }), + }), + expect.any(Function), + ); + expect(span.setStatus).toHaveBeenCalledWith({ code: SentryCore.SPAN_STATUS_ERROR, message: 'internal_error' }); + expect(SentryCore.captureException).toHaveBeenCalledWith(error, { + mechanism: { handled: false, type: 'auto.db.nuxt' }, + }); + }); + + it('names the database system after the dialect, not the connector', async () => { + const statement = { all: vi.fn(), get: vi.fn(), run: vi.fn(), bind: vi.fn() }; + const db = createDatabase(() => statement as unknown as ReturnType); + + createDatabasePlugin(() => db, { default: { connector: 'bun-sqlite' } }); + await db.prepare('SELECT 1').all(); + + expect(SentryCore.startSpan).toHaveBeenCalledWith( + expect.objectContaining({ + attributes: expect.objectContaining({ 'db.system.name': 'sqlite', 'db.namespace': 'db.sqlite' }), + }), + expect.any(Function), + ); + }); +}); diff --git a/packages/server-utils/src/orchestrion/bundler/rollup.ts b/packages/server-utils/src/orchestrion/bundler/rollup.ts index fc0403927a11..92dec8788533 100644 --- a/packages/server-utils/src/orchestrion/bundler/rollup.ts +++ b/packages/server-utils/src/orchestrion/bundler/rollup.ts @@ -45,7 +45,7 @@ export interface CommonJSInteropOptions { * `@rollup/plugin-commonjs` options for builds that bundle CommonJS packages while their * dependencies stay external. Builtin `require()`s unwrap to the module (a namespace breaks * direct calls) and convert inside `try` blocks (a bare `require` throws in ESM output). - * Externals keep `'auto'`, which {@link sentryCommonJSInteropPlugin} fixes for Node >= 23. + * Externals keep `'auto'`, which {@link sentryCommonJSInteropPlugin} fixes for Node >= 23, Deno and Bun. */ export function commonJSInteropOptions(): CommonJSInteropOptions { return { @@ -57,14 +57,18 @@ export function commonJSInteropOptions(): CommonJSInteropOptions { const COMMONJS_HELPERS_ID = '\0commonjsHelpers.js'; // The plugin's `'auto'` helper unwraps a required external module only when `default` is the -// sole key on its namespace. Node >= 23 adds a `'module.exports'` key to every CommonJS -// namespace (nodejs/node#53848), so the check never passes. Ignoring that key restores it. +// sole key on its namespace. Node >= 23 and Deno add a `'module.exports'` key to every CommonJS +// namespace (nodejs/node#53848), so ignoring that key restores the check there. Bun adds a key +// for each own property of `module.exports`, which for a function includes the non-enumerable +// `length` and `name`. No ES module namespace mirrors a non-enumerable property of its default +// export, so such a key marks a CommonJS function export on Bun. const BROKEN_NAMESPACE_CHECK = 'Object.keys(n).length === 1'; -const FIXED_NAMESPACE_CHECK = "Object.keys(n).filter(k => k !== 'module.exports').length === 1"; +const FIXED_NAMESPACE_CHECK = + "(Object.keys(n).filter(k => k !== 'module.exports').length === 1 || (typeof n['default'] === 'function' && Object.keys(n).some(k => Object.getOwnPropertyDescriptor(n['default'], k)?.enumerable === false)))"; /** - * Fixes `@rollup/plugin-commonjs`' `'auto'` interop for Node >= 23 by patching the broken - * namespace check in its helpers module. The patch applies only while the exact broken + * Fixes `@rollup/plugin-commonjs`' `'auto'` interop for Node >= 23, Deno and Bun by patching the + * broken namespace check in its helpers module. The patch applies only while the exact broken * expression exists, so it retires itself once the plugin is fixed upstream. */ export function sentryCommonJSInteropPlugin(): Plugin { diff --git a/packages/server-utils/test/orchestrion/rollup-plugin.test.ts b/packages/server-utils/test/orchestrion/rollup-plugin.test.ts index 6f7ef28041bb..c45bf8942444 100644 --- a/packages/server-utils/test/orchestrion/rollup-plugin.test.ts +++ b/packages/server-utils/test/orchestrion/rollup-plugin.test.ts @@ -86,19 +86,51 @@ describe('sentryCommonJSInteropPlugin', () => { it('patches the namespace check in the commonjs helpers module', () => { const result = transform(brokenHelper, '\0commonjsHelpers.js'); - expect(result?.code).toContain("Object.keys(n).filter(k => k !== 'module.exports').length === 1"); expect(result?.code).not.toContain('Object.keys(n).length === 1'); }); + describe('patched helper', () => { + const patched = transform(brokenHelper, '\0commonjsHelpers.js')!.code.replace('export function', 'function'); + // oxlint-disable-next-line typescript/no-implied-eval -- evaluates the patched helper source + const getDefault = new Function(`${patched}; return getDefaultExportFromNamespaceIfNotNamed;`)() as ( + n: Record, + ) => unknown; + + const cjsExports = Object.assign(function defaults() {}, { helper: 1 }); + + it('unwraps a CommonJS namespace on Node < 23', () => { + expect(getDefault({ default: cjsExports })).toBe(cjsExports); + }); + + it('unwraps a CommonJS namespace on Node >= 23 and Deno', () => { + expect(getDefault({ default: cjsExports, 'module.exports': cjsExports })).toBe(cjsExports); + }); + + it('unwraps the namespace of a CommonJS function export on Bun', () => { + const namespace = { + default: cjsExports, + helper: cjsExports.helper, + length: cjsExports.length, + name: cjsExports.name, + prototype: cjsExports.prototype, + }; + + expect(getDefault(namespace)).toBe(cjsExports); + }); + + it('keeps the namespace of an ES module with named exports', () => { + const namespace = { default: cjsExports, helper: cjsExports.helper }; + + expect(getDefault(namespace)).toBe(namespace); + }); + }); + it('ignores other modules', () => { expect(transform(brokenHelper, '/app/node_modules/mongoose/lib/index.js')).toBeNull(); }); it('leaves an already-fixed helpers module unchanged', () => { - const fixedHelper = brokenHelper.replace( - 'Object.keys(n).length === 1', - "Object.keys(n).filter(k => k !== 'module.exports').length === 1", - ); + const fixedHelper = transform(brokenHelper, '\0commonjsHelpers.js')!.code; expect(transform(fixedHelper, '\0commonjsHelpers.js')).toBeNull(); }); From 1f0d4d012b929f8e4a5f5d5a71f27d11d8e7a230 Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Wed, 11 Nov 2026 12:39:09 +0000 Subject: [PATCH 2/6] test(e2e): Skip the new nuxt-4 server route tests on the Vite server and require the runtime variants The Vite server variant builds no server/ directory, like the other server route tests note. Bun, Deno and Cloudflare passed in CI, so they move to the required variants. The Cloudflare tunnel variant stays optional. Co-Authored-By: Isaac Schlueter --- .../e2e-tests/test-applications/nuxt-4/package.json | 8 ++++---- .../test-applications/nuxt-4/tests/logs.server.test.ts | 3 +++ .../test-applications/nuxt-4/tests/scopes.server.test.ts | 3 +++ .../nuxt-4/tests/trace-propagation.server.test.ts | 3 +++ 4 files changed, 13 insertions(+), 4 deletions(-) diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4/package.json b/dev-packages/e2e-tests/test-applications/nuxt-4/package.json index 81070e7c24f7..e81af0ae0fc3 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-4/package.json +++ b/dev-packages/e2e-tests/test-applications/nuxt-4/package.json @@ -67,9 +67,7 @@ "build-command": "pnpm install", "assert-command": "pnpm test:assert:tunnel-basepath-dev", "label": "nuxt-4 (tunnel-basepath-dev)" - } - ], - "optionalVariants": [ + }, { "runtime": "bun", "assert-command": "pnpm test:prod", @@ -85,7 +83,9 @@ "runtime": "cloudflare", "assert-command": "pnpm test:prod", "label": "nuxt-4 (cloudflare)" - }, + } + ], + "optionalVariants": [ { "runtime": "cloudflare", "build-command": "pnpm test:build:tunnel-basepath", diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4/tests/logs.server.test.ts b/dev-packages/e2e-tests/test-applications/nuxt-4/tests/logs.server.test.ts index 349c98e524af..f74ae4258bb0 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-4/tests/logs.server.test.ts +++ b/dev-packages/e2e-tests/test-applications/nuxt-4/tests/logs.server.test.ts @@ -1,5 +1,8 @@ import { expect, test } from '@playwright/test'; import { waitForEnvelopeItem, waitForStreamedSpan } from '@sentry-internal/test-utils'; +import { isViteServer, NITRO_ONLY } from './isViteServer'; + +test.skip(isViteServer, NITRO_ONLY); interface LogContainer { items: { trace_id?: string; body: string; level: string; attributes?: Record }[]; diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4/tests/scopes.server.test.ts b/dev-packages/e2e-tests/test-applications/nuxt-4/tests/scopes.server.test.ts index 5d0bcf032b3c..c00cadd3eab5 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-4/tests/scopes.server.test.ts +++ b/dev-packages/e2e-tests/test-applications/nuxt-4/tests/scopes.server.test.ts @@ -1,5 +1,8 @@ import { expect, test } from '@playwright/test'; import { waitForError } from '@sentry-internal/test-utils'; +import { isViteServer, NITRO_ONLY } from './isViteServer'; + +test.skip(isViteServer, NITRO_ONLY); test('isolates the scope data and trace of concurrent requests', async ({ request }) => { const tags = ['alpha', 'beta', 'gamma']; diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4/tests/trace-propagation.server.test.ts b/dev-packages/e2e-tests/test-applications/nuxt-4/tests/trace-propagation.server.test.ts index c0179c19585d..3422452185e8 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-4/tests/trace-propagation.server.test.ts +++ b/dev-packages/e2e-tests/test-applications/nuxt-4/tests/trace-propagation.server.test.ts @@ -1,6 +1,9 @@ import { expect, test } from '@playwright/test'; import type { SerializedStreamedSpan } from '@sentry-internal/test-utils'; import { collectStreamedSpans, getSpanOp, waitForError, waitForStreamedSpan } from '@sentry-internal/test-utils'; +import { isViteServer, NITRO_ONLY } from './isViteServer'; + +test.skip(isViteServer, NITRO_ONLY); test('continues an incoming trace in the server segment and its events', async ({ request }) => { const traceId = crypto.randomUUID().replace(/-/g, ''); From cf6542cfb158e0c3e7be22c55fa708af5c5e2e7c Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Fri, 13 Nov 2026 11:00:29 +0000 Subject: [PATCH 3/6] fix(nuxt): Skip the Node sentry.server plugin on Cloudflare With a server config, the module adds the sentry.server plugin on every preset. On Cloudflare, sentryCloudflareNitroPlugin already adds the page meta tags, so cached pages got two sentry-cache-origin tags. --- packages/nuxt/src/vite/nitroServer.ts | 11 +++++++++-- packages/nuxt/test/vite/nitroServer.test.ts | 16 ++++++++++++++++ 2 files changed, 25 insertions(+), 2 deletions(-) diff --git a/packages/nuxt/src/vite/nitroServer.ts b/packages/nuxt/src/vite/nitroServer.ts index 91c164ad75f9..0729a9d02b81 100644 --- a/packages/nuxt/src/vite/nitroServer.ts +++ b/packages/nuxt/src/vite/nitroServer.ts @@ -13,7 +13,7 @@ import { import { addDatabaseInstrumentation } from './databaseConfig'; import { addMiddlewareImports, addMiddlewareInstrumentation } from './middlewareConfig'; import { addCacheStorageInstrumentation, addStorageInstrumentation } from './storageConfig'; -import { addRuntimeInjectionTraceDep, type ServerApi } from './utils'; +import { addRuntimeInjectionTraceDep, isCloudflarePreset, type ServerApi } from './utils'; type NitroModuleOptions = Omit & Required>; @@ -50,7 +50,8 @@ export function setupNitroServer( addTunnelPlugin(serverApi); - addNitroPlugin(moduleDirResolver.resolve('./runtime/plugins/sentry.server')); + const sentryServerPlugin = moduleDirResolver.resolve('./runtime/plugins/sentry.server'); + addNitroPlugin(sentryServerPlugin); // Preps the middleware instrumentation module. addMiddlewareImports(); @@ -63,6 +64,12 @@ export function setupNitroServer( return; } + // On Cloudflare, `sentryCloudflareNitroPlugin` adds the meta tags and captures the errors, so this plugin would add + // them a second time. + if (isCloudflarePreset(nitro.options.preset)) { + nitro.options.plugins = (nitro.options.plugins ?? []).filter(plugin => plugin !== sentryServerPlugin); + } + addMiddlewareInstrumentation(nitro, isNitroV3); if (isNitroV3) { diff --git a/packages/nuxt/test/vite/nitroServer.test.ts b/packages/nuxt/test/vite/nitroServer.test.ts index 3543a469575b..1a3309c5f425 100644 --- a/packages/nuxt/test/vite/nitroServer.test.ts +++ b/packages/nuxt/test/vite/nitroServer.test.ts @@ -96,4 +96,20 @@ describe('setupNitroServer', () => { expect(nitro.hooks.hook).toHaveBeenCalledWith('rollup:before', expect.any(Function)); expect(nitro.hooks.hook).toHaveBeenCalledWith('close', expect.any(Function)); }); + + it.each([ + ['cloudflare_module', ['./runtime/plugins/handler-legacy.server']], + ['node-server', ['./runtime/plugins/handler-legacy.server', './runtime/plugins/sentry.server']], + ])('drops the sentry.server plugin only on Cloudflare (%s preset)', (preset, expectedPlugins) => { + const { nuxt, callHook } = createFakeNuxt(); + const nitro = { + options: { preset, plugins: ['./runtime/plugins/handler-legacy.server', './runtime/plugins/sentry.server'] }, + hooks: { hook: vi.fn() }, + }; + setupNitroServer(nuxt, 'nitro2', SERVER_CONFIG, MODULE_OPTIONS); + + callHook('nitro:init', nitro); + + expect(nitro.options.plugins).toEqual(expectedPlugins); + }); }); From ff63868da0eb6c098ea293812781e6fb223c13ae Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Fri, 13 Nov 2026 11:11:37 +0000 Subject: [PATCH 4/6] fix(nuxt): Skip the sentry.server plugin in the Cloudflare prerenderer too The prerenderer is built from the original config with the nitro-prerender preset, so pre-rendered pages still got both plugins' meta tags. --- packages/nuxt/src/vite/nitroServer.ts | 8 +++++- packages/nuxt/test/vite/nitroServer.test.ts | 29 ++++++++++++++------- 2 files changed, 27 insertions(+), 10 deletions(-) diff --git a/packages/nuxt/src/vite/nitroServer.ts b/packages/nuxt/src/vite/nitroServer.ts index 0729a9d02b81..95477bb9a8e6 100644 --- a/packages/nuxt/src/vite/nitroServer.ts +++ b/packages/nuxt/src/vite/nitroServer.ts @@ -67,7 +67,13 @@ export function setupNitroServer( // On Cloudflare, `sentryCloudflareNitroPlugin` adds the meta tags and captures the errors, so this plugin would add // them a second time. if (isCloudflarePreset(nitro.options.preset)) { - nitro.options.plugins = (nitro.options.plugins ?? []).filter(plugin => plugin !== sentryServerPlugin); + const withoutSentryServerPlugin = (plugins: T[] | undefined): T[] => + (plugins ?? []).filter(plugin => plugin !== sentryServerPlugin); + nitro.options.plugins = withoutSentryServerPlugin(nitro.options.plugins); + // The prerenderer is built from the original config, with its own preset. + nitro.hooks.hook('prerender:config', config => { + config.plugins = withoutSentryServerPlugin(config.plugins); + }); } addMiddlewareInstrumentation(nitro, isNitroV3); diff --git a/packages/nuxt/test/vite/nitroServer.test.ts b/packages/nuxt/test/vite/nitroServer.test.ts index 1a3309c5f425..03260bc25c15 100644 --- a/packages/nuxt/test/vite/nitroServer.test.ts +++ b/packages/nuxt/test/vite/nitroServer.test.ts @@ -97,19 +97,30 @@ describe('setupNitroServer', () => { expect(nitro.hooks.hook).toHaveBeenCalledWith('close', expect.any(Function)); }); - it.each([ - ['cloudflare_module', ['./runtime/plugins/handler-legacy.server']], - ['node-server', ['./runtime/plugins/handler-legacy.server', './runtime/plugins/sentry.server']], - ])('drops the sentry.server plugin only on Cloudflare (%s preset)', (preset, expectedPlugins) => { + it('drops the sentry.server plugin from the Cloudflare build and its prerenderer', () => { + const { nuxt, callHook } = createFakeNuxt(); + const plugins = ['./runtime/plugins/handler-legacy.server', './runtime/plugins/sentry.server']; + const nitro = { options: { preset: 'cloudflare_module', plugins }, hooks: { hook: vi.fn() } }; + setupNitroServer(nuxt, 'nitro2', SERVER_CONFIG, MODULE_OPTIONS); + + callHook('nitro:init', nitro); + const prerenderConfig = { plugins }; + const prerenderConfigHook = nitro.hooks.hook.mock.calls.find(([name]) => name === 'prerender:config')?.[1]; + prerenderConfigHook?.(prerenderConfig); + + expect(nitro.options.plugins).toEqual(['./runtime/plugins/handler-legacy.server']); + expect(prerenderConfig.plugins).toEqual(['./runtime/plugins/handler-legacy.server']); + }); + + it('keeps the sentry.server plugin on other presets', () => { const { nuxt, callHook } = createFakeNuxt(); - const nitro = { - options: { preset, plugins: ['./runtime/plugins/handler-legacy.server', './runtime/plugins/sentry.server'] }, - hooks: { hook: vi.fn() }, - }; + const plugins = ['./runtime/plugins/handler-legacy.server', './runtime/plugins/sentry.server']; + const nitro = { options: { preset: 'node-server', plugins }, hooks: { hook: vi.fn() } }; setupNitroServer(nuxt, 'nitro2', SERVER_CONFIG, MODULE_OPTIONS); callHook('nitro:init', nitro); - expect(nitro.options.plugins).toEqual(expectedPlugins); + expect(nitro.options.plugins).toEqual(plugins); + expect(nitro.hooks.hook).not.toHaveBeenCalledWith('prerender:config', expect.any(Function)); }); }); From 0fa1a2fe55de08f244c4f27f5c17ab5085f36ffe Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Fri, 13 Nov 2026 14:44:18 +0000 Subject: [PATCH 5/6] fix(server-utils): Unwrap a Bun namespace only when its keys mirror the function An ES module with a default function and a named `name` export matched the Bun check and lost its named exports. Co-Authored-By: Isaac Schlueter --- packages/server-utils/src/orchestrion/bundler/rollup.ts | 7 ++++--- .../server-utils/test/orchestrion/rollup-plugin.test.ts | 6 ++++++ 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/packages/server-utils/src/orchestrion/bundler/rollup.ts b/packages/server-utils/src/orchestrion/bundler/rollup.ts index 92dec8788533..651b5b89708d 100644 --- a/packages/server-utils/src/orchestrion/bundler/rollup.ts +++ b/packages/server-utils/src/orchestrion/bundler/rollup.ts @@ -60,11 +60,12 @@ const COMMONJS_HELPERS_ID = '\0commonjsHelpers.js'; // sole key on its namespace. Node >= 23 and Deno add a `'module.exports'` key to every CommonJS // namespace (nodejs/node#53848), so ignoring that key restores the check there. Bun adds a key // for each own property of `module.exports`, which for a function includes the non-enumerable -// `length` and `name`. No ES module namespace mirrors a non-enumerable property of its default -// export, so such a key marks a CommonJS function export on Bun. +// `length` and `name`. So on Bun, a namespace whose keys all mirror its function default, at least +// one of them a non-enumerable property, is unwrapped. An ES module namespace can match only when +// its named exports equal the same properties of its default export, which loses nothing. const BROKEN_NAMESPACE_CHECK = 'Object.keys(n).length === 1'; const FIXED_NAMESPACE_CHECK = - "(Object.keys(n).filter(k => k !== 'module.exports').length === 1 || (typeof n['default'] === 'function' && Object.keys(n).some(k => Object.getOwnPropertyDescriptor(n['default'], k)?.enumerable === false)))"; + "(Object.keys(n).filter(k => k !== 'module.exports').length === 1 || (typeof n['default'] === 'function' && Object.keys(n).every(k => k === 'default' || k === 'module.exports' || n[k] === n['default'][k]) && Object.keys(n).some(k => Object.getOwnPropertyDescriptor(n['default'], k)?.enumerable === false)))"; /** * Fixes `@rollup/plugin-commonjs`' `'auto'` interop for Node >= 23, Deno and Bun by patching the diff --git a/packages/server-utils/test/orchestrion/rollup-plugin.test.ts b/packages/server-utils/test/orchestrion/rollup-plugin.test.ts index c45bf8942444..e328e5f06f81 100644 --- a/packages/server-utils/test/orchestrion/rollup-plugin.test.ts +++ b/packages/server-utils/test/orchestrion/rollup-plugin.test.ts @@ -123,6 +123,12 @@ describe('sentryCommonJSInteropPlugin', () => { expect(getDefault(namespace)).toBe(namespace); }); + + it('keeps the namespace of an ES module that exports a `name` next to its default function', () => { + const namespace = { default: function plugin() {}, name: 'my-plugin' }; + + expect(getDefault(namespace)).toBe(namespace); + }); }); it('ignores other modules', () => { From 2aaa9619e10e36b10a31c9f2dcc0732d6c001052 Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Fri, 13 Nov 2026 14:44:18 +0000 Subject: [PATCH 6/6] fix(nuxt): Name page server spans on Cloudflare, test the documented setup The Cloudflare variant now builds without sentry.server.config.ts, as the Cloudflare setup documents. Without it the module added no route detector, so page server spans were named after the method only. The module now adds it on both setups. This makes the Cloudflare-only removal of the sentry.server plugin unneeded, so it is reverted. Co-Authored-By: Isaac Schlueter --- CHANGELOG.md | 4 +++ dev-packages/e2e-tests/README.md | 4 ++- .../test-applications/nuxt-4/package.json | 5 +++- .../nuxt-4/tests/cache.test.ts | 2 ++ .../nuxt-4/tests/database-multi.test.ts | 19 ++++++------- .../nuxt-4/tests/database.test.ts | 7 +++-- .../nuxt-4/tests/middleware.test.ts | 2 ++ .../test-applications/nuxt-4/tests/runtime.ts | 8 +++--- .../nuxt-4/tests/storage-aliases.test.ts | 2 ++ .../nuxt-4/tests/storage.test.ts | 2 ++ packages/nuxt/src/module.ts | 14 +++++----- packages/nuxt/src/vite/nitroServer.ts | 17 ++---------- packages/nuxt/test/vite/nitroServer.test.ts | 27 ------------------- 13 files changed, 46 insertions(+), 67 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c18ccce7fe07..bf8899da2003 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,10 @@ Work in this release was contributed by @kunal649 and @bardrop. Thank you for yo HTTP spans that the SDK builds from a `URL` now send `url.scheme` without the trailing colon (`https` instead of `https:`) and `url.port` as a number instead of a string, as the Sentry conventions define them and as Node `http.server` spans already do. This changes `http.server` spans from `@sentry/cloudflare`, `Bun.serve`, Deno and Nitro, and outgoing `node:http` client spans. Update saved queries, alerts and dashboards that filter on `url.scheme:"https:"` or on `url.port` as a string. +- **fix(nuxt): Name the database system in `db.system.name` of db0 spans ([#26285](https://github.com/getsentry/sentry-javascript/pull/26285))** + + Nitro database (db0) spans now set `db.system.name` to the database system of the connector's dialect (`mysql`, `postgresql` or `sqlite`), as the OpenTelemetry conventions define it, instead of the connector name. For example, spans of the `mysql2` and `planetscale` connectors now report `mysql`, and spans of `better-sqlite3` and `cloudflare-d1` now report `sqlite`. Update saved queries, alerts and dashboards that filter on the connector names. + ## 11.12.0 ### Important Changes diff --git a/dev-packages/e2e-tests/README.md b/dev-packages/e2e-tests/README.md index cb5d2b9f1c08..7cb8c3e1a750 100644 --- a/dev-packages/e2e-tests/README.md +++ b/dev-packages/e2e-tests/README.md @@ -179,7 +179,9 @@ is the reference setup. into the Nitro output, so `nuxt-4` calls `init()` of `@sentry/nuxt` on Bun and Deno too. `sentry.server.config.bun.ts` and `sentry.server.config.deno.ts` add the integrations of `@sentry/bun` or `@sentry/deno`. On Cloudflare, `nuxt-4` builds with the `cloudflare_module` preset and sets up the SDK with `sentryCloudflareNitroPlugin`, which - `nuxt.config.ts` adds only for that runtime. Nitro turns every file under `server/api` into a route, so + `nuxt.config.ts` adds only for that runtime. Like the documented Cloudflare setup, that build has no + `sentry.server.config.ts`: a runtime-specific file can replace a file but not remove it, so the build command deletes + it. Nitro turns every file under `server/api` into a route, so `nuxt.config.ts` lists the runtime-specific files there (such as `db-mongoose.cloudflare.ts`) in Nitro's `ignore`. - **Start commands**: `playwright.config.mjs` selects the start command with `getRuntime()`. Bun and Deno use the same build as Node and only change the start command, for example diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4/package.json b/dev-packages/e2e-tests/test-applications/nuxt-4/package.json index ff09aaa697dd..f1c6d3f6ac46 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-4/package.json +++ b/dev-packages/e2e-tests/test-applications/nuxt-4/package.json @@ -21,6 +21,8 @@ "test:assert": "pnpm test:prod && pnpm test:dev", "test:prod:import": "TEST_ENV=production-import playwright test", "test:build:tunnel-basepath": "pnpm install && E2E_TEST_TUNNEL=1 E2E_TEST_BASE_PATH=/app node ./scripts/build-with-prerender-event-sink.mjs", + "test:build:cloudflare": "rm sentry.server.config.ts && pnpm test:build", + "test:build:tunnel-basepath-cloudflare": "rm sentry.server.config.ts && pnpm test:build:tunnel-basepath", "test:assert:tunnel-basepath": "E2E_TEST_TUNNEL=1 E2E_TEST_BASE_PATH=/app TEST_ENV=production playwright test tunnel", "test:assert:tunnel-basepath-dev": "E2E_TEST_TUNNEL=1 E2E_TEST_BASE_PATH=/app TEST_ENV=development playwright test tunnel" }, @@ -82,6 +84,7 @@ }, { "runtime": "cloudflare", + "build-command": "pnpm test:build:cloudflare", "assert-command": "pnpm test:prod", "label": "nuxt-4 (cloudflare)" } @@ -89,7 +92,7 @@ "optionalVariants": [ { "runtime": "cloudflare", - "build-command": "pnpm test:build:tunnel-basepath", + "build-command": "pnpm test:build:tunnel-basepath-cloudflare", "assert-command": "pnpm test:assert:tunnel-basepath", "label": "nuxt-4 (tunnel-basepath-cloudflare)" } diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4/tests/cache.test.ts b/dev-packages/e2e-tests/test-applications/nuxt-4/tests/cache.test.ts index 812ae2641ca0..644a371447c0 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-4/tests/cache.test.ts +++ b/dev-packages/e2e-tests/test-applications/nuxt-4/tests/cache.test.ts @@ -1,8 +1,10 @@ import { expect, test } from '@playwright/test'; import { collectStreamedSpansUntilSegment } from '@sentry-internal/test-utils'; import { isViteServer, NITRO_ONLY } from './isViteServer'; +import { NO_SERVER_CONFIG_INSTRUMENTATION, RUNTIME } from './runtime'; test.skip(isViteServer, NITRO_ONLY); +test.skip(RUNTIME === 'cloudflare', NO_SERVER_CONFIG_INSTRUMENTATION); test.describe('Cache Instrumentation', () => { const SEMANTIC_ATTRIBUTE_CACHE_KEY = 'cache.key'; diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4/tests/database-multi.test.ts b/dev-packages/e2e-tests/test-applications/nuxt-4/tests/database-multi.test.ts index b90ced0f444e..70fe76fb4c5c 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-4/tests/database-multi.test.ts +++ b/dev-packages/e2e-tests/test-applications/nuxt-4/tests/database-multi.test.ts @@ -1,9 +1,10 @@ import { expect, test } from '@playwright/test'; import { collectStreamedSpansUntilSegment, getSpanOp } from '@sentry-internal/test-utils'; import { isViteServer, NITRO_ONLY } from './isViteServer'; -import { dbNamespaceAttribute } from './runtime'; +import { NO_SERVER_CONFIG_INSTRUMENTATION, RUNTIME } from './runtime'; test.skip(isViteServer, NITRO_ONLY); +test.skip(RUNTIME === 'cloudflare', NO_SERVER_CONFIG_INSTRUMENTATION); async function collectDbSpans() { const spans = await collectStreamedSpansUntilSegment( @@ -30,7 +31,7 @@ test.describe('multiple database instances', () => { 'db.query.summary': { type: 'string', value: 'SELECT default_table' }, 'db.query.text': { type: 'string', value: 'SELECT * FROM default_table WHERE id = ?' }, 'db.system.name': { type: 'string', value: 'sqlite' }, - ...dbNamespaceAttribute('db'), + 'db.namespace': { type: 'string', value: 'db.sqlite' }, 'sentry.origin': { type: 'string', value: 'auto.db.nuxt' }, }); }); @@ -49,7 +50,7 @@ test.describe('multiple database instances', () => { 'db.query.summary': { type: 'string', value: 'SELECT user_profiles' }, 'db.query.text': { type: 'string', value: 'SELECT * FROM user_profiles WHERE id = ?' }, 'db.system.name': { type: 'string', value: 'sqlite' }, - ...dbNamespaceAttribute('users_db'), + 'db.namespace': { type: 'string', value: 'users_db.sqlite' }, 'sentry.origin': { type: 'string', value: 'auto.db.nuxt' }, }); }); @@ -68,7 +69,7 @@ test.describe('multiple database instances', () => { 'db.query.summary': { type: 'string', value: 'SELECT events' }, 'db.query.text': { type: 'string', value: 'SELECT * FROM events WHERE id = ?' }, 'db.system.name': { type: 'string', value: 'sqlite' }, - ...dbNamespaceAttribute('analytics_db'), + 'db.namespace': { type: 'string', value: 'analytics_db.sqlite' }, 'sentry.origin': { type: 'string', value: 'auto.db.nuxt' }, }); }); @@ -89,21 +90,21 @@ test.describe('multiple database instances', () => { expect(sessionSpan?.attributes).toMatchObject({ 'db.query.summary': { type: 'string', value: 'SELECT sessions' }, 'db.query.text': { type: 'string', value: 'SELECT * FROM sessions WHERE id = ?' }, - ...dbNamespaceAttribute('db'), + 'db.namespace': { type: 'string', value: 'db.sqlite' }, 'db.system.name': { type: 'string', value: 'sqlite' }, 'sentry.origin': { type: 'string', value: 'auto.db.nuxt' }, }); expect(accountSpan?.attributes).toMatchObject({ 'db.query.summary': { type: 'string', value: 'SELECT accounts' }, 'db.query.text': { type: 'string', value: 'SELECT * FROM accounts WHERE id = ?' }, - ...dbNamespaceAttribute('users_db'), + 'db.namespace': { type: 'string', value: 'users_db.sqlite' }, 'db.system.name': { type: 'string', value: 'sqlite' }, 'sentry.origin': { type: 'string', value: 'auto.db.nuxt' }, }); expect(metricSpan?.attributes).toMatchObject({ 'db.query.summary': { type: 'string', value: 'SELECT metrics' }, 'db.query.text': { type: 'string', value: 'SELECT * FROM metrics WHERE id = ?' }, - ...dbNamespaceAttribute('analytics_db'), + 'db.namespace': { type: 'string', value: 'analytics_db.sqlite' }, 'db.system.name': { type: 'string', value: 'sqlite' }, 'sentry.origin': { type: 'string', value: 'auto.db.nuxt' }, }); @@ -129,7 +130,7 @@ test.describe('multiple database instances', () => { 'db.query.summary': { type: 'string', value: 'INSERT logs' }, 'db.query.text': { type: 'string', value: 'INSERT INTO logs (message) VALUES (?)' }, 'db.system.name': { type: 'string', value: 'sqlite' }, - ...dbNamespaceAttribute('db'), + 'db.namespace': { type: 'string', value: 'db.sqlite' }, 'sentry.origin': { type: 'string', value: 'auto.db.nuxt' }, }); @@ -138,7 +139,7 @@ test.describe('multiple database instances', () => { 'db.query.summary': { type: 'string', value: 'INSERT audit_logs' }, 'db.query.text': { type: 'string', value: 'INSERT INTO audit_logs (action) VALUES (?)' }, 'db.system.name': { type: 'string', value: 'sqlite' }, - ...dbNamespaceAttribute('users_db'), + 'db.namespace': { type: 'string', value: 'users_db.sqlite' }, 'sentry.origin': { type: 'string', value: 'auto.db.nuxt' }, }); }); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4/tests/database.test.ts b/dev-packages/e2e-tests/test-applications/nuxt-4/tests/database.test.ts index 0da3cecc7419..74ca13e02b93 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-4/tests/database.test.ts +++ b/dev-packages/e2e-tests/test-applications/nuxt-4/tests/database.test.ts @@ -1,9 +1,10 @@ import { expect, test } from '@playwright/test'; import { collectStreamedSpansUntilSegment, getSpanOp, waitForError } from '@sentry-internal/test-utils'; import { isViteServer, NITRO_ONLY } from './isViteServer'; -import { dbNamespaceAttribute, RUNTIME } from './runtime'; +import { NO_SERVER_CONFIG_INSTRUMENTATION, RUNTIME } from './runtime'; test.skip(isViteServer, NITRO_ONLY); +test.skip(RUNTIME === 'cloudflare', NO_SERVER_CONFIG_INSTRUMENTATION); async function collectDbSpans() { const spans = await collectStreamedSpansUntilSegment( @@ -29,7 +30,7 @@ test.describe('database integration', () => { 'db.query.summary': { type: 'string', value: 'SELECT users' }, 'db.query.text': { type: 'string', value: 'SELECT * FROM users WHERE id = ?' }, 'db.system.name': { type: 'string', value: 'sqlite' }, - ...dbNamespaceAttribute('db'), + 'db.namespace': { type: 'string', value: 'db.sqlite' }, 'sentry.op': { type: 'string', value: 'db.query' }, 'sentry.origin': { type: 'string', value: 'auto.db.nuxt' }, }); @@ -152,8 +153,6 @@ test.describe('database integration', () => { expect(dbException?.mechanism).toEqual({ handled: false, type: 'auto.db.nuxt', - // D1 errors carry a `cause`, so the exception is linked and gets an id. - ...(RUNTIME === 'cloudflare' && { exception_id: 0 }), }); const dbSpan = dbSpans.find(span => span.name === 'SELECT nonexistent_table'); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4/tests/middleware.test.ts b/dev-packages/e2e-tests/test-applications/nuxt-4/tests/middleware.test.ts index cc12ce192ebd..195cc9b10567 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-4/tests/middleware.test.ts +++ b/dev-packages/e2e-tests/test-applications/nuxt-4/tests/middleware.test.ts @@ -2,8 +2,10 @@ import { expect, test } from '@playwright/test'; import { collectStreamedSpansUntilSegment, getSpanOp, waitForError } from '@sentry-internal/test-utils'; import { IMPORT_SURFACES } from './importSurfaces'; import { isViteServer, NITRO_ONLY } from './isViteServer'; +import { NO_SERVER_CONFIG_INSTRUMENTATION, RUNTIME } from './runtime'; test.skip(isViteServer, NITRO_ONLY); +test.skip(RUNTIME === 'cloudflare', NO_SERVER_CONFIG_INSTRUMENTATION); // Nitro runs every file in `server/middleware/` on every request, so each surface tells its spans apart by // middleware name. `nuxt/server` has no object syntax (`onRequest`, `onBeforeResponse`) and no `eventHandler` diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4/tests/runtime.ts b/dev-packages/e2e-tests/test-applications/nuxt-4/tests/runtime.ts index 3870bcf63c4b..f3640262c4db 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-4/tests/runtime.ts +++ b/dev-packages/e2e-tests/test-applications/nuxt-4/tests/runtime.ts @@ -10,10 +10,10 @@ export const MONGOOSE_UNSUPPORTED = 'Cloudflare: Nitro cannot bundle the `mongod export const SERVER_ORIGIN = RUNTIME === 'cloudflare' ? 'auto.http.cloudflare' : 'auto.http.http_server'; -/** `db.namespace` of a db0 span: Nitro names the SQLite file after the database, while a D1 binding names none. */ -export function dbNamespaceAttribute(name: string): { 'db.namespace'?: { type: string; value: string } } { - return RUNTIME === 'cloudflare' ? {} : { 'db.namespace': { type: 'string', value: `${name}.sqlite` } }; -} +// The Cloudflare variant builds without `sentry.server.config.ts`, as the Cloudflare setup of `@sentry/nuxt` documents. +// The module adds the db0, storage, cache and middleware instrumentation only with that file. +export const NO_SERVER_CONFIG_INSTRUMENTATION = + 'Cloudflare: `@sentry/nuxt` instruments db0, storage, cache and middleware only with a `sentry.server.config.ts`'; /** The command and arguments that start the built server on the runtime of the variant. */ export function getServerCommand(preload?: string): [string, string[]] { diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4/tests/storage-aliases.test.ts b/dev-packages/e2e-tests/test-applications/nuxt-4/tests/storage-aliases.test.ts index 9152c4d82905..b9eb33fc8f1d 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-4/tests/storage-aliases.test.ts +++ b/dev-packages/e2e-tests/test-applications/nuxt-4/tests/storage-aliases.test.ts @@ -1,8 +1,10 @@ import { expect, test } from '@playwright/test'; import { collectStreamedSpansUntilSegment } from '@sentry-internal/test-utils'; import { isViteServer, NITRO_ONLY } from './isViteServer'; +import { NO_SERVER_CONFIG_INSTRUMENTATION, RUNTIME } from './runtime'; test.skip(isViteServer, NITRO_ONLY); +test.skip(RUNTIME === 'cloudflare', NO_SERVER_CONFIG_INSTRUMENTATION); async function collectStorageSpans(route: string) { const spans = await collectStreamedSpansUntilSegment('nuxt-4', span => span.attributes['url.path']?.value === route); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4/tests/storage.test.ts b/dev-packages/e2e-tests/test-applications/nuxt-4/tests/storage.test.ts index 1c4ea12af2db..a602f881c3cc 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-4/tests/storage.test.ts +++ b/dev-packages/e2e-tests/test-applications/nuxt-4/tests/storage.test.ts @@ -1,8 +1,10 @@ import { expect, test } from '@playwright/test'; import { collectStreamedSpansUntilSegment } from '@sentry-internal/test-utils'; import { isViteServer, NITRO_ONLY } from './isViteServer'; +import { NO_SERVER_CONFIG_INSTRUMENTATION, RUNTIME } from './runtime'; test.skip(isViteServer, NITRO_ONLY); +test.skip(RUNTIME === 'cloudflare', NO_SERVER_CONFIG_INSTRUMENTATION); async function collectStorageSpans(route: string) { const spans = await collectStreamedSpansUntilSegment('nuxt-4', span => span.attributes['url.path']?.value === route); diff --git a/packages/nuxt/src/module.ts b/packages/nuxt/src/module.ts index 578d5c68379f..8d613d4dd533 100644 --- a/packages/nuxt/src/module.ts +++ b/packages/nuxt/src/module.ts @@ -92,17 +92,19 @@ export default defineNuxtModule({ } else { setupNitroServer(nuxt, serverApi, serverConfigFile, moduleOptions); } - - if (isMinNuxtV4) { - addPlugin({ src: moduleDirResolver.resolve('./runtime/plugins/route-detector.server'), mode: 'server' }); - } else { - addPlugin({ src: moduleDirResolver.resolve('./runtime/plugins/route-detector-legacy.server'), mode: 'server' }); - } } else { // The Cloudflare setup initializes the SDK from `sentryCloudflareNitroPlugin`, so it needs the tunnel too. addTunnelPlugin(serverApi); } + // Names the server spans of pages after their route, with the SDK set up from the server config or from + // `sentryCloudflareNitroPlugin`. + if (isMinNuxtV4) { + addPlugin({ src: moduleDirResolver.resolve('./runtime/plugins/route-detector.server'), mode: 'server' }); + } else { + addPlugin({ src: moduleDirResolver.resolve('./runtime/plugins/route-detector-legacy.server'), mode: 'server' }); + } + if (clientConfigFile || serverConfigFile) { setupSourceMaps(moduleOptions, nuxt, addVitePlugin); } diff --git a/packages/nuxt/src/vite/nitroServer.ts b/packages/nuxt/src/vite/nitroServer.ts index 95477bb9a8e6..91c164ad75f9 100644 --- a/packages/nuxt/src/vite/nitroServer.ts +++ b/packages/nuxt/src/vite/nitroServer.ts @@ -13,7 +13,7 @@ import { import { addDatabaseInstrumentation } from './databaseConfig'; import { addMiddlewareImports, addMiddlewareInstrumentation } from './middlewareConfig'; import { addCacheStorageInstrumentation, addStorageInstrumentation } from './storageConfig'; -import { addRuntimeInjectionTraceDep, isCloudflarePreset, type ServerApi } from './utils'; +import { addRuntimeInjectionTraceDep, type ServerApi } from './utils'; type NitroModuleOptions = Omit & Required>; @@ -50,8 +50,7 @@ export function setupNitroServer( addTunnelPlugin(serverApi); - const sentryServerPlugin = moduleDirResolver.resolve('./runtime/plugins/sentry.server'); - addNitroPlugin(sentryServerPlugin); + addNitroPlugin(moduleDirResolver.resolve('./runtime/plugins/sentry.server')); // Preps the middleware instrumentation module. addMiddlewareImports(); @@ -64,18 +63,6 @@ export function setupNitroServer( return; } - // On Cloudflare, `sentryCloudflareNitroPlugin` adds the meta tags and captures the errors, so this plugin would add - // them a second time. - if (isCloudflarePreset(nitro.options.preset)) { - const withoutSentryServerPlugin = (plugins: T[] | undefined): T[] => - (plugins ?? []).filter(plugin => plugin !== sentryServerPlugin); - nitro.options.plugins = withoutSentryServerPlugin(nitro.options.plugins); - // The prerenderer is built from the original config, with its own preset. - nitro.hooks.hook('prerender:config', config => { - config.plugins = withoutSentryServerPlugin(config.plugins); - }); - } - addMiddlewareInstrumentation(nitro, isNitroV3); if (isNitroV3) { diff --git a/packages/nuxt/test/vite/nitroServer.test.ts b/packages/nuxt/test/vite/nitroServer.test.ts index 03260bc25c15..3543a469575b 100644 --- a/packages/nuxt/test/vite/nitroServer.test.ts +++ b/packages/nuxt/test/vite/nitroServer.test.ts @@ -96,31 +96,4 @@ describe('setupNitroServer', () => { expect(nitro.hooks.hook).toHaveBeenCalledWith('rollup:before', expect.any(Function)); expect(nitro.hooks.hook).toHaveBeenCalledWith('close', expect.any(Function)); }); - - it('drops the sentry.server plugin from the Cloudflare build and its prerenderer', () => { - const { nuxt, callHook } = createFakeNuxt(); - const plugins = ['./runtime/plugins/handler-legacy.server', './runtime/plugins/sentry.server']; - const nitro = { options: { preset: 'cloudflare_module', plugins }, hooks: { hook: vi.fn() } }; - setupNitroServer(nuxt, 'nitro2', SERVER_CONFIG, MODULE_OPTIONS); - - callHook('nitro:init', nitro); - const prerenderConfig = { plugins }; - const prerenderConfigHook = nitro.hooks.hook.mock.calls.find(([name]) => name === 'prerender:config')?.[1]; - prerenderConfigHook?.(prerenderConfig); - - expect(nitro.options.plugins).toEqual(['./runtime/plugins/handler-legacy.server']); - expect(prerenderConfig.plugins).toEqual(['./runtime/plugins/handler-legacy.server']); - }); - - it('keeps the sentry.server plugin on other presets', () => { - const { nuxt, callHook } = createFakeNuxt(); - const plugins = ['./runtime/plugins/handler-legacy.server', './runtime/plugins/sentry.server']; - const nitro = { options: { preset: 'node-server', plugins }, hooks: { hook: vi.fn() } }; - setupNitroServer(nuxt, 'nitro2', SERVER_CONFIG, MODULE_OPTIONS); - - callHook('nitro:init', nitro); - - expect(nitro.options.plugins).toEqual(plugins); - expect(nitro.hooks.hook).not.toHaveBeenCalledWith('prerender:config', expect.any(Function)); - }); });