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 52e30cf48569..7cb8c3e1a750 100644 --- a/dev-packages/e2e-tests/README.md +++ b/dev-packages/e2e-tests/README.md @@ -175,13 +175,21 @@ 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. Its Bun and Deno variants build the app on that runtime, so `@sentry/nextjs` loads its `bun` or `deno` entry, which adds - the integrations of `@sentry/bun` or `@sentry/deno`. + the integrations of `@sentry/bun` or `@sentry/deno`. 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. 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 `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 @@ -211,9 +219,10 @@ is the reference setup. `"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. An app that starts its own server can instead bundle it with - `@sentry/bun/plugin`, as `nestjs-12/bun-build.mjs` does. Bun 1.3 renames classes in bundles (`class Foo2`), which - changes span names taken from class names, so `nestjs-12` pins Bun 1.4 (see #26289). + 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. An app that starts its own server can instead + bundle it with `@sentry/bun/plugin`, as `nestjs-12/bun-build.mjs` does. Bun 1.3 renames classes in bundles + (`class Foo2`), which changes span names taken from class names, so `nestjs-12` pins Bun 1.4 (see #26289). ```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 a1cd9f3a8596..f1c6d3f6ac46 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", @@ -18,12 +21,17 @@ "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" }, "//": "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", @@ -33,7 +41,8 @@ "devDependencies": { "@playwright/test": "1.64.1", "@sentry/conventions": "0.32.0", - "@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": [ @@ -61,6 +70,31 @@ "build-command": "pnpm install", "assert-command": "pnpm test:assert:tunnel-basepath-dev", "label": "nuxt-4 (tunnel-basepath-dev)" + }, + { + "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", + "build-command": "pnpm test:build:cloudflare", + "assert-command": "pnpm test:prod", + "label": "nuxt-4 (cloudflare)" + } + ], + "optionalVariants": [ + { + "runtime": "cloudflare", + "build-command": "pnpm test:build:tunnel-basepath-cloudflare", + "assert-command": "pnpm test:assert:tunnel-basepath", + "label": "nuxt-4 (tunnel-basepath-cloudflare)" } ], "canaryVariants": [ 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/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 50a0f6ae09d6..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,8 +1,10 @@ import { expect, test } from '@playwright/test'; import { collectStreamedSpansUntilSegment, getSpanOp } 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 collectDbSpans() { const spans = await collectStreamedSpansUntilSegment( 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..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,8 +1,10 @@ import { expect, test } from '@playwright/test'; import { collectStreamedSpansUntilSegment, getSpanOp, waitForError } 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 collectDbSpans() { const spans = await collectStreamedSpansUntilSegment( 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..f74ae4258bb0 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-4/tests/logs.server.test.ts @@ -0,0 +1,31 @@ +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 }[]; +} + +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/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 new file mode 100644 index 000000000000..f3640262c4db --- /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'; + +// 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[]] { + 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..c00cadd3eab5 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-4/tests/scopes.server.test.ts @@ -0,0 +1,19 @@ +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']; + 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/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/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..3422452185e8 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-4/tests/trace-propagation.server.test.ts @@ -0,0 +1,58 @@ +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, ''); + 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.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/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/runtime/plugins/sentry-cloudflare.server.ts b/packages/nuxt/src/runtime/plugins/sentry-cloudflare.server.ts index ae8e8c68ed2d..0f4aa12b2898 100644 --- a/packages/nuxt/src/runtime/plugins/sentry-cloudflare.server.ts +++ b/packages/nuxt/src/runtime/plugins/sentry-cloudflare.server.ts @@ -2,7 +2,13 @@ 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'; @@ -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; @@ -170,3 +181,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/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/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..651b5b89708d 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,19 @@ 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`. 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"; +const FIXED_NAMESPACE_CHECK = + "(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 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..e328e5f06f81 100644 --- a/packages/server-utils/test/orchestrion/rollup-plugin.test.ts +++ b/packages/server-utils/test/orchestrion/rollup-plugin.test.ts @@ -86,19 +86,57 @@ 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('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', () => { 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(); });