diff --git a/dev-packages/e2e-tests/test-applications/nuxt-5/.gitignore b/dev-packages/e2e-tests/test-applications/nuxt-5/.gitignore new file mode 100644 index 000000000000..4a7f73a2ed0d --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-5/.gitignore @@ -0,0 +1,24 @@ +# Nuxt dev/build outputs +.output +.data +.nuxt +.nitro +.cache +dist + +# Node dependencies +node_modules + +# Logs +logs +*.log + +# Misc +.DS_Store +.fleet +.idea + +# Local env files +.env +.env.* +!.env.example diff --git a/dev-packages/e2e-tests/test-applications/nuxt-5/.npmrc b/dev-packages/e2e-tests/test-applications/nuxt-5/.npmrc new file mode 100644 index 000000000000..070f80f05092 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-5/.npmrc @@ -0,0 +1,2 @@ +@sentry:registry=http://127.0.0.1:4873 +@sentry-internal:registry=http://127.0.0.1:4873 diff --git a/dev-packages/e2e-tests/test-applications/nuxt-5/app/components/ErrorButton.vue b/dev-packages/e2e-tests/test-applications/nuxt-5/app/components/ErrorButton.vue new file mode 100644 index 000000000000..b686436bee17 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-5/app/components/ErrorButton.vue @@ -0,0 +1,22 @@ + + + diff --git a/dev-packages/e2e-tests/test-applications/nuxt-5/app/composables/use-sentry-test-tag.ts b/dev-packages/e2e-tests/test-applications/nuxt-5/app/composables/use-sentry-test-tag.ts new file mode 100644 index 000000000000..0d6642ca3d8c --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-5/app/composables/use-sentry-test-tag.ts @@ -0,0 +1,8 @@ +// fixme: this needs to be imported from @sentry/core, not @sentry/nuxt in dev mode (because of import-in-the-middle error) +// This could also be a problem with the specific setup of the pnpm E2E test setup, because this could not be reproduced outside of the E2E test. +// Related to this: https://github.com/getsentry/sentry-javascript/issues/15204#issuecomment-2948908130 +import { setTag } from '@sentry/nuxt'; + +export default function useSentryTestTag(): void { + setTag('test-tag', null); +} diff --git a/dev-packages/e2e-tests/test-applications/nuxt-5/app/pages/client-error.vue b/dev-packages/e2e-tests/test-applications/nuxt-5/app/pages/client-error.vue new file mode 100644 index 000000000000..7d9cce216273 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-5/app/pages/client-error.vue @@ -0,0 +1,16 @@ + + + diff --git a/dev-packages/e2e-tests/test-applications/nuxt-5/app/pages/fetch-server-routes.vue b/dev-packages/e2e-tests/test-applications/nuxt-5/app/pages/fetch-server-routes.vue new file mode 100644 index 000000000000..089d77a2eee9 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-5/app/pages/fetch-server-routes.vue @@ -0,0 +1,18 @@ + + + diff --git a/dev-packages/e2e-tests/test-applications/nuxt-5/app/pages/index.vue b/dev-packages/e2e-tests/test-applications/nuxt-5/app/pages/index.vue new file mode 100644 index 000000000000..57a583eb43b1 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-5/app/pages/index.vue @@ -0,0 +1,20 @@ + + + diff --git a/dev-packages/e2e-tests/test-applications/nuxt-5/app/pages/rendering-modes/client-side-only-page.vue b/dev-packages/e2e-tests/test-applications/nuxt-5/app/pages/rendering-modes/client-side-only-page.vue new file mode 100644 index 000000000000..fb41b62b3308 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-5/app/pages/rendering-modes/client-side-only-page.vue @@ -0,0 +1 @@ + diff --git a/dev-packages/e2e-tests/test-applications/nuxt-5/app/pages/rendering-modes/isr-1h-cached-page.vue b/dev-packages/e2e-tests/test-applications/nuxt-5/app/pages/rendering-modes/isr-1h-cached-page.vue new file mode 100644 index 000000000000..e702eca86715 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-5/app/pages/rendering-modes/isr-1h-cached-page.vue @@ -0,0 +1 @@ + diff --git a/dev-packages/e2e-tests/test-applications/nuxt-5/app/pages/rendering-modes/isr-cached-page.vue b/dev-packages/e2e-tests/test-applications/nuxt-5/app/pages/rendering-modes/isr-cached-page.vue new file mode 100644 index 000000000000..780adc07de53 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-5/app/pages/rendering-modes/isr-cached-page.vue @@ -0,0 +1 @@ + diff --git a/dev-packages/e2e-tests/test-applications/nuxt-5/app/pages/rendering-modes/pre-rendered-page.vue b/dev-packages/e2e-tests/test-applications/nuxt-5/app/pages/rendering-modes/pre-rendered-page.vue new file mode 100644 index 000000000000..25b423a4c442 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-5/app/pages/rendering-modes/pre-rendered-page.vue @@ -0,0 +1 @@ + diff --git a/dev-packages/e2e-tests/test-applications/nuxt-5/app/pages/rendering-modes/swr-1h-cached-page.vue b/dev-packages/e2e-tests/test-applications/nuxt-5/app/pages/rendering-modes/swr-1h-cached-page.vue new file mode 100644 index 000000000000..24918924f4a9 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-5/app/pages/rendering-modes/swr-1h-cached-page.vue @@ -0,0 +1 @@ + diff --git a/dev-packages/e2e-tests/test-applications/nuxt-5/app/pages/rendering-modes/swr-cached-page.vue b/dev-packages/e2e-tests/test-applications/nuxt-5/app/pages/rendering-modes/swr-cached-page.vue new file mode 100644 index 000000000000..d0d8e7241968 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-5/app/pages/rendering-modes/swr-cached-page.vue @@ -0,0 +1 @@ + diff --git a/dev-packages/e2e-tests/test-applications/nuxt-5/app/pages/test-param/[param].vue b/dev-packages/e2e-tests/test-applications/nuxt-5/app/pages/test-param/[param].vue new file mode 100644 index 000000000000..019404aaf460 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-5/app/pages/test-param/[param].vue @@ -0,0 +1,22 @@ + + + diff --git a/dev-packages/e2e-tests/test-applications/nuxt-5/app/pages/test-param/user/[userId].vue b/dev-packages/e2e-tests/test-applications/nuxt-5/app/pages/test-param/user/[userId].vue new file mode 100644 index 000000000000..41daf0460b05 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-5/app/pages/test-param/user/[userId].vue @@ -0,0 +1,16 @@ + + + diff --git a/dev-packages/e2e-tests/test-applications/nuxt-5/modules/another-module.ts b/dev-packages/e2e-tests/test-applications/nuxt-5/modules/another-module.ts new file mode 100644 index 000000000000..9c1a3ca80487 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-5/modules/another-module.ts @@ -0,0 +1,9 @@ +import { defineNuxtModule } from 'nuxt/kit'; + +// Just a fake module to check if the SDK works alongside other local Nuxt modules without breaking the build +export default defineNuxtModule({ + meta: { name: 'another-module' }, + setup() { + console.log('another-module setup called'); + }, +}); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-5/nuxt-start-dev-server.bash b/dev-packages/e2e-tests/test-applications/nuxt-5/nuxt-start-dev-server.bash new file mode 100644 index 000000000000..a1831f1e8e76 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-5/nuxt-start-dev-server.bash @@ -0,0 +1,46 @@ +#!/bin/bash +# To enable Sentry in Nuxt dev, it needs the sentry.server.config.mjs file from the .nuxt folder. +# First, we need to start 'nuxt dev' to generate the file, and then start 'nuxt dev' again with the NODE_OPTIONS to have Sentry enabled. + +# Using a different port to avoid playwright already starting with the tests for port 3030 +TEMP_PORT=3035 + +# 1. Start dev in background - this generates .nuxt folder +pnpm dev -p $TEMP_PORT & +DEV_PID=$! + +# 2. Wait for the sentry.server.config.mjs file to appear +echo "Waiting for .nuxt/dev/sentry.server.config.mjs file..." +COUNTER=0 +while [ ! -f ".nuxt/dev/sentry.server.config.mjs" ] && [ $COUNTER -lt 30 ]; do + sleep 1 + ((COUNTER++)) +done + +if [ ! -f ".nuxt/dev/sentry.server.config.mjs" ]; then + echo "ERROR: .nuxt/dev/sentry.server.config.mjs file never appeared!" + echo "This usually means the Nuxt dev server failed to start or generate the file. Try to rerun the test." + pkill -P $DEV_PID || kill $DEV_PID + exit 1 +fi + +# 3. Cleanup +echo "Found .nuxt/dev/sentry.server.config.mjs, stopping 'nuxt dev' process..." +pkill -P $DEV_PID || kill $DEV_PID + +# Wait for port to be released +echo "Waiting for port $TEMP_PORT to be released..." +COUNTER=0 +# Check if port is still in use +while lsof -i :$TEMP_PORT > /dev/null 2>&1 && [ $COUNTER -lt 10 ]; do + sleep 1 + ((COUNTER++)) +done + +if lsof -i :$TEMP_PORT > /dev/null 2>&1; then + echo "WARNING: Port $TEMP_PORT still in use after 10 seconds, proceeding anyway..." +else + echo "Port $TEMP_PORT released successfully" +fi + +echo "Nuxt dev server can now be started with '--import ./.nuxt/dev/sentry.server.config.mjs'" diff --git a/dev-packages/e2e-tests/test-applications/nuxt-5/nuxt.config.ts b/dev-packages/e2e-tests/test-applications/nuxt-5/nuxt.config.ts new file mode 100644 index 000000000000..bdef334cfa88 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-5/nuxt.config.ts @@ -0,0 +1,47 @@ +// https://nuxt.com/docs/api/configuration/nuxt-config +export default defineNuxtConfig({ + compatibilityDate: '2025-07-15', + imports: { autoImport: false }, + + routeRules: { + '/rendering-modes/client-side-only-page': { ssr: false }, + '/rendering-modes/isr-cached-page': { isr: true }, + '/rendering-modes/isr-1h-cached-page': { isr: 3600 }, + '/rendering-modes/swr-cached-page': { swr: true }, + '/rendering-modes/swr-1h-cached-page': { swr: 3600 }, + '/rendering-modes/pre-rendered-page': { prerender: true }, + }, + + modules: ['@pinia/nuxt', '@sentry/nuxt/module'], + runtimeConfig: { + public: { + sentry: { + dsn: 'https://public@dsn.ingest.sentry.io/1337', + }, + }, + }, + nitro: { + experimental: { + database: true, + }, + database: { + default: { + connector: 'sqlite', + options: { name: 'db' }, + }, + users: { + connector: 'sqlite', + options: { name: 'users_db' }, + }, + analytics: { + connector: 'sqlite', + options: { name: 'analytics_db' }, + }, + }, + storage: { + 'test-storage': { + driver: 'memory', + }, + }, + }, +}); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-5/package.json b/dev-packages/e2e-tests/test-applications/nuxt-5/package.json new file mode 100644 index 000000000000..ad5b209a6b22 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-5/package.json @@ -0,0 +1,38 @@ +{ + "name": "nuxt-5", + "private": true, + "type": "module", + "scripts": { + "build": "nuxt build", + "dev": "nuxt dev", + "generate": "nuxt generate", + "preview": "nuxt preview", + "start": "node .output/server/index.mjs", + "start:import": "node --import ./.output/server/sentry.server.config.mjs .output/server/index.mjs", + "clean": "npx nuxi cleanup", + "test": "playwright test", + "test:prod": "TEST_ENV=production playwright test", + "test:dev": "bash ./nuxt-start-dev-server.bash && TEST_ENV=development playwright test environment", + "test:build": "pnpm install && pnpm build", + "test:build-canary": "pnpm add nuxt@npm:nuxt-nightly@latest && pnpm add nitro@npm:nitro-nightly@latest && pnpm install --force && pnpm build", + "test:assert": "pnpm test:prod && pnpm test:dev" + }, + "//": [ + "Currently, we need to install the latest version of Nitro and the Nuxt nightlies as those contain Nuxt v5", + "TODO: remove nitro from dependencies" + ], + "dependencies": { + "@pinia/nuxt": "^0.11.3", + "@sentry/nuxt": "latest || *", + "nitro": "latest", + "nuxt": "npm:nuxt-nightly@5x" + }, + "devDependencies": { + "@playwright/test": "~1.56.0", + "@sentry-internal/test-utils": "link:../../../test-utils" + }, + "volta": { + "extends": "../../package.json", + "node": "22.20.0" + } +} diff --git a/dev-packages/e2e-tests/test-applications/nuxt-5/playwright.config.ts b/dev-packages/e2e-tests/test-applications/nuxt-5/playwright.config.ts new file mode 100644 index 000000000000..b86690ca086c --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-5/playwright.config.ts @@ -0,0 +1,25 @@ +import { getPlaywrightConfig } from '@sentry-internal/test-utils'; + +const testEnv = process.env.TEST_ENV; + +if (!testEnv) { + throw new Error('No test env defined'); +} + +const getStartCommand = () => { + if (testEnv === 'development') { + return "NODE_OPTIONS='--import ./.nuxt/dev/sentry.server.config.mjs' nuxt dev -p 3030"; + } + + if (testEnv === 'production') { + return 'pnpm start:import'; + } + + throw new Error(`Unknown test env: ${testEnv}`); +}; + +const config = getPlaywrightConfig({ + startCommand: getStartCommand(), +}); + +export default config; diff --git a/dev-packages/e2e-tests/test-applications/nuxt-5/public/favicon.ico b/dev-packages/e2e-tests/test-applications/nuxt-5/public/favicon.ico new file mode 100644 index 000000000000..18993ad91cfd Binary files /dev/null and b/dev-packages/e2e-tests/test-applications/nuxt-5/public/favicon.ico differ diff --git a/dev-packages/e2e-tests/test-applications/nuxt-5/sentry.client.config.ts b/dev-packages/e2e-tests/test-applications/nuxt-5/sentry.client.config.ts new file mode 100644 index 000000000000..900df0811faa --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-5/sentry.client.config.ts @@ -0,0 +1,23 @@ +import * as Sentry from '@sentry/nuxt'; +import { /* usePinia,*/ useRuntimeConfig } from '#imports'; + +Sentry.init({ + dsn: useRuntimeConfig().public.sentry.dsn, + tunnel: `http://localhost:3031/`, // proxy server + tracesSampleRate: 1.0, + integrations: [ + /* Sentry.piniaIntegration(usePinia(), { + actionTransformer: action => `${action}.transformed`, + stateTransformer: state => ({ + transformed: true, + ...state, + }), + }), + */ + Sentry.vueIntegration({ + tracingOptions: { + trackComponents: true, + }, + }), + ], +}); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-5/sentry.server.config.ts b/dev-packages/e2e-tests/test-applications/nuxt-5/sentry.server.config.ts new file mode 100644 index 000000000000..26519911072b --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-5/sentry.server.config.ts @@ -0,0 +1,7 @@ +import * as Sentry from '@sentry/nuxt'; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + tracesSampleRate: 1.0, // Capture 100% of the transactions + tunnel: 'http://localhost:3031/', // proxy server +}); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-5/server/api/cache-test.ts b/dev-packages/e2e-tests/test-applications/nuxt-5/server/api/cache-test.ts new file mode 100644 index 000000000000..1f537ec4fee7 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-5/server/api/cache-test.ts @@ -0,0 +1,86 @@ +import { defineHandler } from 'nitro'; +import { getQuery } from 'nitro/h3'; +import { defineCachedFunction, defineCachedHandler } from 'nitro/cache'; + +// Test cachedFunction +const getCachedUser = defineCachedFunction( + async (userId: string) => { + return { + id: userId, + name: `User ${userId}`, + email: `user${userId}@example.com`, + timestamp: Date.now(), + }; + }, + { + maxAge: 60, + name: 'getCachedUser', + getKey: (userId: string) => `user:${userId}`, + }, +); + +// Test cachedFunction with different options +const getCachedData = defineCachedFunction( + async (key: string) => { + return { + key, + value: `cached-value-${key}`, + timestamp: Date.now(), + }; + }, + { + maxAge: 120, + name: 'getCachedData', + getKey: (key: string) => `data:${key}`, + }, +); + +// Test defineCachedEventHandler +const cachedHandler = defineCachedHandler( + async event => { + return { + message: 'This response is cached', + timestamp: Date.now(), + path: event.path, + }; + }, + { + maxAge: 60, + name: 'cachedHandler', + }, +); + +export default defineHandler(async event => { + const results: Record = {}; + const testKey = String(getQuery(event).user ?? '123'); + const dataKey = String(getQuery(event).data ?? 'test-key'); + + // Test cachedFunction - first call (cache miss) + const user1 = await getCachedUser(testKey); + results.cachedUser1 = user1; + + // Test cachedFunction - second call (cache hit) + const user2 = await getCachedUser(testKey); + results.cachedUser2 = user2; + + // Test cachedFunction with different key (cache miss) + const user3 = await getCachedUser(`${testKey}456`); + results.cachedUser3 = user3; + + // Test another cachedFunction + const data1 = await getCachedData(dataKey); + results.cachedData1 = data1; + + // Test cachedFunction - cache hit + const data2 = await getCachedData(dataKey); + results.cachedData2 = data2; + + // Test cachedEventHandler by calling it + const cachedResponse = await cachedHandler(event); + results.cachedResponse = cachedResponse; + + return { + success: true, + results, + }; +}); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-5/server/api/db-multi-test.ts b/dev-packages/e2e-tests/test-applications/nuxt-5/server/api/db-multi-test.ts new file mode 100644 index 000000000000..8fbe09098e8c --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-5/server/api/db-multi-test.ts @@ -0,0 +1,104 @@ +import { defineHandler } from 'nitro'; +import { useDatabase } from 'nitro/database'; +import { getQuery } from 'nitro/h3'; + +export default defineHandler(async event => { + const query = getQuery(event); + const method = query.method as string; + + switch (method) { + case 'default-db': { + // Test default database instance + const db = useDatabase(); + await db.exec('CREATE TABLE IF NOT EXISTS default_table (id INTEGER PRIMARY KEY, data TEXT)'); + await db.exec(`INSERT OR REPLACE INTO default_table (id, data) VALUES (1, 'default data')`); + const stmt = db.prepare('SELECT * FROM default_table WHERE id = ?'); + const result = await stmt.get(1); + return { success: true, database: 'default', result }; + } + + case 'users-db': { + // Test named database instance 'users' + const usersDb = useDatabase('users'); + await usersDb.exec( + 'CREATE TABLE IF NOT EXISTS user_profiles (id INTEGER PRIMARY KEY, username TEXT, email TEXT)', + ); + await usersDb.exec( + `INSERT OR REPLACE INTO user_profiles (id, username, email) VALUES (1, 'john_doe', 'john@example.com')`, + ); + const stmt = usersDb.prepare('SELECT * FROM user_profiles WHERE id = ?'); + const result = await stmt.get(1); + return { success: true, database: 'users', result }; + } + + case 'analytics-db': { + // Test named database instance 'analytics' + const analyticsDb = useDatabase('analytics'); + await analyticsDb.exec( + 'CREATE TABLE IF NOT EXISTS events (id INTEGER PRIMARY KEY, event_name TEXT, count INTEGER)', + ); + await analyticsDb.exec(`INSERT OR REPLACE INTO events (id, event_name, count) VALUES (1, 'page_view', 100)`); + const stmt = analyticsDb.prepare('SELECT * FROM events WHERE id = ?'); + const result = await stmt.get(1); + return { success: true, database: 'analytics', result }; + } + + case 'multiple-dbs': { + // Test operations across multiple databases in a single request + const defaultDb = useDatabase(); + const usersDb = useDatabase('users'); + const analyticsDb = useDatabase('analytics'); + + // Create tables and insert data in all databases + await defaultDb.exec('CREATE TABLE IF NOT EXISTS sessions (id INTEGER PRIMARY KEY, token TEXT)'); + await defaultDb.exec(`INSERT OR REPLACE INTO sessions (id, token) VALUES (1, 'session-token-123')`); + + await usersDb.exec('CREATE TABLE IF NOT EXISTS accounts (id INTEGER PRIMARY KEY, account_name TEXT)'); + await usersDb.exec(`INSERT OR REPLACE INTO accounts (id, account_name) VALUES (1, 'Premium Account')`); + + await analyticsDb.exec( + 'CREATE TABLE IF NOT EXISTS metrics (id INTEGER PRIMARY KEY, metric_name TEXT, value REAL)', + ); + await analyticsDb.exec( + `INSERT OR REPLACE INTO metrics (id, metric_name, value) VALUES (1, 'conversion_rate', 0.25)`, + ); + + // Query from all databases + const sessionResult = await defaultDb.prepare('SELECT * FROM sessions WHERE id = ?').get(1); + const accountResult = await usersDb.prepare('SELECT * FROM accounts WHERE id = ?').get(1); + const metricResult = await analyticsDb.prepare('SELECT * FROM metrics WHERE id = ?').get(1); + + return { + success: true, + results: { + default: sessionResult, + users: accountResult, + analytics: metricResult, + }, + }; + } + + case 'sql-template-multi': { + // Test SQL template tag across multiple databases + const defaultDb = useDatabase(); + const usersDb = useDatabase('users'); + + await defaultDb.exec('CREATE TABLE IF NOT EXISTS logs (id INTEGER PRIMARY KEY, message TEXT)'); + await usersDb.exec('CREATE TABLE IF NOT EXISTS audit_logs (id INTEGER PRIMARY KEY, action TEXT)'); + + const defaultResult = await defaultDb.sql`INSERT INTO logs (message) VALUES (${'test message'})`; + const usersResult = await usersDb.sql`INSERT INTO audit_logs (action) VALUES (${'user_login'})`; + + return { + success: true, + results: { + default: defaultResult, + users: usersResult, + }, + }; + } + + default: + return { error: 'Unknown method' }; + } +}); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-5/server/api/db-test.ts b/dev-packages/e2e-tests/test-applications/nuxt-5/server/api/db-test.ts new file mode 100644 index 000000000000..6e17444c30bf --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-5/server/api/db-test.ts @@ -0,0 +1,72 @@ +import { defineHandler } from 'nitro'; +import { getQuery } from 'nitro/h3'; +import { useDatabase } from 'nitro/database'; + +export default defineHandler(async event => { + const db = useDatabase(); + const query = getQuery(event); + const method = query.method as string; + + switch (method) { + case 'prepare-get': { + await db.exec('CREATE TABLE IF NOT EXISTS users (id INTEGER PRIMARY KEY, name TEXT, email TEXT)'); + await db.exec(`INSERT OR REPLACE INTO users (id, name, email) VALUES (1, 'Test User', 'test@example.com')`); + const stmt = db.prepare('SELECT * FROM users WHERE id = ?'); + const result = await stmt.get(1); + return { success: true, result }; + } + + 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)`); + const stmt = db.prepare('SELECT * FROM products WHERE price > ?'); + const results = await stmt.all(10); + return { success: true, count: results.length, results }; + } + + case 'prepare-run': { + await db.exec('CREATE TABLE IF NOT EXISTS orders (id INTEGER PRIMARY KEY, customer TEXT, amount REAL)'); + const stmt = db.prepare('INSERT INTO orders (customer, amount) VALUES (?, ?)'); + const result = await stmt.run('John Doe', 99.99); + return { success: true, result }; + } + + 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)`); + const stmt = db.prepare('SELECT * FROM items WHERE category = ?'); + const boundStmt = stmt.bind('electronics'); + const results = await boundStmt.all(); + return { success: true, count: results.length, results }; + } + + case 'sql': { + await db.exec('CREATE TABLE IF NOT EXISTS messages (id INTEGER PRIMARY KEY, content TEXT, created_at TEXT)'); + const timestamp = new Date().toISOString(); + const results = await db.sql`INSERT INTO messages (content, created_at) VALUES (${'Hello World'}, ${timestamp})`; + return { success: true, results }; + } + + case 'exec': { + await db.exec('DROP TABLE IF EXISTS logs'); + await db.exec('CREATE TABLE logs (id INTEGER PRIMARY KEY, message TEXT, level TEXT)'); + const result = await db.exec(`INSERT INTO logs (message, level) VALUES ('Test log', 'INFO')`); + return { success: true, result }; + } + + case 'error': { + const stmt = db.prepare('SELECT * FROM nonexistent_table WHERE invalid_column = ?'); + await stmt.get(1); + return { success: false, message: 'Should have thrown an error' }; + } + + default: + return { error: 'Unknown method' }; + } +}); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-5/server/api/middleware-test.ts b/dev-packages/e2e-tests/test-applications/nuxt-5/server/api/middleware-test.ts new file mode 100644 index 000000000000..6ac58ac0ebee --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-5/server/api/middleware-test.ts @@ -0,0 +1,15 @@ +import { defineHandler } from 'nitro'; + +export default defineHandler(async event => { + // Simple API endpoint that will trigger all server middleware + return { + message: 'Server middleware test endpoint', + path: event.path, + method: event.method, + headers: { + 'x-first-middleware': event.res?.headers.get('x-first-middleware'), + 'x-second-middleware': event.res?.headers.get('x-second-middleware'), + 'x-auth-middleware': event.res?.headers.get('x-auth-middleware'), + }, + }; +}); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-5/server/api/nitro-fetch.ts b/dev-packages/e2e-tests/test-applications/nuxt-5/server/api/nitro-fetch.ts new file mode 100644 index 000000000000..8bc4cff56610 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-5/server/api/nitro-fetch.ts @@ -0,0 +1,5 @@ +import { defineHandler } from 'nitro'; + +export default defineHandler(async () => { + return await $fetch('https://example.com'); +}); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-5/server/api/param-error/[param].ts b/dev-packages/e2e-tests/test-applications/nuxt-5/server/api/param-error/[param].ts new file mode 100644 index 000000000000..3422c275abe0 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-5/server/api/param-error/[param].ts @@ -0,0 +1,5 @@ +import { defineHandler } from 'nitro'; + +export default defineHandler(_e => { + throw new Error('Nuxt 4 Param Server error'); +}); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-5/server/api/server-error.ts b/dev-packages/e2e-tests/test-applications/nuxt-5/server/api/server-error.ts new file mode 100644 index 000000000000..23b89ce2c287 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-5/server/api/server-error.ts @@ -0,0 +1,5 @@ +import { defineHandler } from 'nitro'; + +export default defineHandler(event => { + throw new Error('Nuxt 4 Server error'); +}); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-5/server/api/storage-aliases-test.ts b/dev-packages/e2e-tests/test-applications/nuxt-5/server/api/storage-aliases-test.ts new file mode 100644 index 000000000000..eb41287ad23d --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-5/server/api/storage-aliases-test.ts @@ -0,0 +1,46 @@ +import { useStorage } from 'nitro/storage'; +import { defineHandler } from 'nitro'; + +export default defineHandler(async _event => { + const storage = useStorage('test-storage'); + + // Test all alias methods (get, set, del, remove) + const results: Record = {}; + + // Test set (alias for setItem) + await storage.set('alias:user', { name: 'Jane Doe', role: 'admin' }); + results.set = 'success'; + + // Test get (alias for getItem) + const user = await storage.get('alias:user'); + results.get = user; + + // Test has (alias for hasItem) + const hasUser = await storage.has('alias:user'); + results.has = hasUser; + + // Setup for delete tests + await storage.set('alias:temp1', 'temp1'); + await storage.set('alias:temp2', 'temp2'); + + // Test del (alias for removeItem) + await storage.del('alias:temp1'); + results.del = 'success'; + + // Test remove (alias for removeItem) + await storage.remove('alias:temp2'); + results.remove = 'success'; + + // Verify deletions worked + const hasTemp1 = await storage.has('alias:temp1'); + const hasTemp2 = await storage.has('alias:temp2'); + results.verifyDeletions = !hasTemp1 && !hasTemp2; + + // Clean up + await storage.clear(); + + return { + success: true, + results, + }; +}); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-5/server/api/storage-test.ts b/dev-packages/e2e-tests/test-applications/nuxt-5/server/api/storage-test.ts new file mode 100644 index 000000000000..992f00fee4df --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-5/server/api/storage-test.ts @@ -0,0 +1,54 @@ +import { useStorage } from 'nitro/storage'; +import { defineHandler } from 'nitro'; + +export default defineHandler(async _event => { + const storage = useStorage('test-storage'); + + // Test all instrumented methods + const results: Record = {}; + + // Test setItem + await storage.setItem('user:123', { name: 'John Doe', email: 'john@example.com' }); + results.setItem = 'success'; + + // Test setItemRaw + await storage.setItemRaw('raw:data', Buffer.from('raw data')); + results.setItemRaw = 'success'; + + // Manually set batch items (setItems not supported by memory driver) + await storage.setItem('batch:1', 'value1'); + await storage.setItem('batch:2', 'value2'); + + // Test hasItem + const hasUser = await storage.hasItem('user:123'); + results.hasItem = hasUser; + + // Test getItem + const user = await storage.getItem('user:123'); + results.getItem = user; + + // Test getItemRaw + const rawData = await storage.getItemRaw('raw:data'); + results.getItemRaw = rawData?.toString(); + + // Test getKeys + const keys = await storage.getKeys('batch:'); + results.getKeys = keys; + + // Test removeItem + await storage.removeItem('batch:1'); + results.removeItem = 'success'; + + // Test clear + await storage.clear(); + results.clear = 'success'; + + // Verify clear worked + const keysAfterClear = await storage.getKeys(); + results.keysAfterClear = keysAfterClear; + + return { + success: true, + results, + }; +}); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-5/server/api/test-param/[param].ts b/dev-packages/e2e-tests/test-applications/nuxt-5/server/api/test-param/[param].ts new file mode 100644 index 000000000000..e0ad305d2b9d --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-5/server/api/test-param/[param].ts @@ -0,0 +1,8 @@ +import { defineHandler } from 'nitro'; +import { getRouterParam } from 'nitro/h3'; + +export default defineHandler(event => { + const param = getRouterParam(event, 'param'); + + return `Param: ${param}!`; +}); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-5/server/api/user/[userId].ts b/dev-packages/e2e-tests/test-applications/nuxt-5/server/api/user/[userId].ts new file mode 100644 index 000000000000..d50d5d435912 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-5/server/api/user/[userId].ts @@ -0,0 +1,8 @@ +import { defineHandler } from 'nitro'; +import { getRouterParam } from 'nitro/h3'; + +export default defineHandler(event => { + const userId = getRouterParam(event, 'userId'); + + return `UserId Param: ${userId}!`; +}); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-5/server/middleware/01.first.ts b/dev-packages/e2e-tests/test-applications/nuxt-5/server/middleware/01.first.ts new file mode 100644 index 000000000000..9d86cbafcbbd --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-5/server/middleware/01.first.ts @@ -0,0 +1,6 @@ +import { defineHandler } from 'nitro'; + +export default defineHandler(async event => { + // Set a header to indicate this middleware ran + event.res?.headers.set('x-first-middleware', 'executed'); +}); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-5/server/middleware/02.second.ts b/dev-packages/e2e-tests/test-applications/nuxt-5/server/middleware/02.second.ts new file mode 100644 index 000000000000..01a184dfcc54 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-5/server/middleware/02.second.ts @@ -0,0 +1,6 @@ +import { defineHandler } from 'nitro'; + +export default defineHandler(async event => { + // Set a header to indicate this middleware ran + event.res?.headers.set('x-second-middleware', 'executed'); +}); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-5/server/middleware/03.auth.ts b/dev-packages/e2e-tests/test-applications/nuxt-5/server/middleware/03.auth.ts new file mode 100644 index 000000000000..7216e9fc7560 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-5/server/middleware/03.auth.ts @@ -0,0 +1,13 @@ +import { defineHandler } from 'nitro'; +import { getQuery } from 'nitro/h3'; + +export default defineHandler(async event => { + // Check if we should throw an error + const query = getQuery(event); + if (query.throwError === 'true') { + throw new Error('Auth middleware error'); + } + + // Set a header to indicate this middleware ran + event.res?.headers.set('x-auth-middleware', 'executed'); +}); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-5/server/middleware/04.hooks.ts b/dev-packages/e2e-tests/test-applications/nuxt-5/server/middleware/04.hooks.ts new file mode 100644 index 000000000000..726cfaba8c10 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-5/server/middleware/04.hooks.ts @@ -0,0 +1,37 @@ +import { defineHandler } from 'nitro'; +import { getQuery } from 'nitro/h3'; + +export default defineHandler({ + onRequest: async event => { + // Set a header to indicate the onRequest hook ran + event.res?.headers.set('x-hooks-onrequest', 'executed'); + + // Check if we should throw an error in onRequest + const query = getQuery(event); + if (query.throwOnRequestError === 'true') { + throw new Error('OnRequest hook error'); + } + }, + + handler: async event => { + // Set a header to indicate the main handler ran + event.res?.headers.set('x-hooks-handler', 'executed'); + + // Check if we should throw an error in handler + const query = getQuery(event); + if (query.throwHandlerError === 'true') { + throw new Error('Handler error'); + } + }, + + onBeforeResponse: async (event, response) => { + // Set a header to indicate the onBeforeResponse hook ran + event.res?.headers.set('x-hooks-onbeforeresponse', 'executed'); + + // Check if we should throw an error in onBeforeResponse + const query = getQuery(event); + if (query.throwOnBeforeResponseError === 'true') { + throw new Error('OnBeforeResponse hook error'); + } + }, +}); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-5/server/middleware/05.array-hooks.ts b/dev-packages/e2e-tests/test-applications/nuxt-5/server/middleware/05.array-hooks.ts new file mode 100644 index 000000000000..f0bac6fb3113 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-5/server/middleware/05.array-hooks.ts @@ -0,0 +1,48 @@ +import { defineHandler } from 'nitro'; +import { getQuery } from 'nitro/h3'; + +export default defineHandler({ + // Array of onRequest handlers + onRequest: [ + async event => { + event.res?.headers.set('x-array-onrequest-0', 'executed'); + + const query = getQuery(event); + if (query.throwOnRequest0Error === 'true') { + throw new Error('OnRequest[0] hook error'); + } + }, + async event => { + event.res?.headers.set('x-array-onrequest-1', 'executed'); + + const query = getQuery(event); + if (query.throwOnRequest1Error === 'true') { + throw new Error('OnRequest[1] hook error'); + } + }, + ], + + handler: async event => { + event.res?.headers.set('x-array-handler', 'executed'); + }, + + // Array of onBeforeResponse handlers + onBeforeResponse: [ + async (event, response) => { + event.res?.headers.set('x-array-onbeforeresponse-0', 'executed'); + + const query = getQuery(event); + if (query.throwOnBeforeResponse0Error === 'true') { + throw new Error('OnBeforeResponse[0] hook error'); + } + }, + async (event, response) => { + event.res?.headers.set('x-array-onbeforeresponse-1', 'executed'); + + const query = getQuery(event); + if (query.throwOnBeforeResponse1Error === 'true') { + throw new Error('OnBeforeResponse[1] hook error'); + } + }, + ], +}); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-5/server/tsconfig.json b/dev-packages/e2e-tests/test-applications/nuxt-5/server/tsconfig.json new file mode 100644 index 000000000000..b9ed69c19eaf --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-5/server/tsconfig.json @@ -0,0 +1,3 @@ +{ + "extends": "../.nuxt/tsconfig.server.json" +} diff --git a/dev-packages/e2e-tests/test-applications/nuxt-5/start-event-proxy.mjs b/dev-packages/e2e-tests/test-applications/nuxt-5/start-event-proxy.mjs new file mode 100644 index 000000000000..1bff06b86eef --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-5/start-event-proxy.mjs @@ -0,0 +1,6 @@ +import { startEventProxyServer } from '@sentry-internal/test-utils'; + +startEventProxyServer({ + port: 3031, + proxyServerName: 'nuxt-5', +}); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-5/tests/cache.test.ts b/dev-packages/e2e-tests/test-applications/nuxt-5/tests/cache.test.ts new file mode 100644 index 000000000000..7a660b88d714 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-5/tests/cache.test.ts @@ -0,0 +1,161 @@ +import { expect, test } from '@playwright/test'; +import { waitForTransaction } from '@sentry-internal/test-utils'; +import { SEMANTIC_ATTRIBUTE_SENTRY_OP, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN } from '@sentry/nuxt'; + +test.describe('Cache Instrumentation', () => { + const SEMANTIC_ATTRIBUTE_CACHE_KEY = 'cache.key'; + const SEMANTIC_ATTRIBUTE_CACHE_HIT = 'cache.hit'; + + test('instruments cachedFunction and cachedEventHandler calls and creates spans with correct attributes', async ({ + request, + }) => { + const transactionPromise = waitForTransaction('nuxt-5', transactionEvent => { + return transactionEvent.transaction?.includes('GET /api/cache-test') ?? false; + }); + + const response = await request.get('/api/cache-test'); + expect(response.status()).toBe(200); + + const transaction = await transactionPromise; + + // Helper to find spans by operation + const findSpansByOp = (op: string) => { + return transaction.spans?.filter(span => span.data?.[SEMANTIC_ATTRIBUTE_SENTRY_OP] === op) || []; + }; + + // Test that we have cache operations from cachedFunction and cachedEventHandler + const allCacheSpans = transaction.spans?.filter( + span => span.data?.[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN] === 'auto.cache.nuxt', + ); + expect(allCacheSpans?.length).toBeGreaterThan(0); + + // Test getItem spans for cachedFunction - should have both cache miss and cache hit + const getItemSpans = findSpansByOp('cache.get_item'); + expect(getItemSpans.length).toBeGreaterThan(0); + + // Find cache miss (first call to getCachedUser('123')) + const cacheMissSpan = getItemSpans.find( + span => + typeof span.data?.[SEMANTIC_ATTRIBUTE_CACHE_KEY] === 'string' && + span.data[SEMANTIC_ATTRIBUTE_CACHE_KEY].includes('user:123') && + !span.data?.[SEMANTIC_ATTRIBUTE_CACHE_HIT], + ); + if (cacheMissSpan) { + expect(cacheMissSpan.data).toMatchObject({ + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'cache.get_item', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.cache.nuxt', + [SEMANTIC_ATTRIBUTE_CACHE_HIT]: false, + 'db.operation.name': 'getItem', + 'db.collection.name': expect.stringMatching(/^(cache)?$/), + }); + } + + // Find cache hit (second call to getCachedUser('123')) + const cacheHitSpan = getItemSpans.find( + span => + typeof span.data?.[SEMANTIC_ATTRIBUTE_CACHE_KEY] === 'string' && + span.data[SEMANTIC_ATTRIBUTE_CACHE_KEY].includes('user:123') && + span.data?.[SEMANTIC_ATTRIBUTE_CACHE_HIT], + ); + if (cacheHitSpan) { + expect(cacheHitSpan.data).toMatchObject({ + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'cache.get_item', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.cache.nuxt', + [SEMANTIC_ATTRIBUTE_CACHE_HIT]: true, + 'db.operation.name': 'getItem', + 'db.collection.name': expect.stringMatching(/^(cache)?$/), + }); + } + + // Test setItem spans for cachedFunction - when cache miss occurs, value is set + const setItemSpans = findSpansByOp('cache.set_item'); + expect(setItemSpans.length).toBeGreaterThan(0); + + const cacheSetSpan = setItemSpans.find( + span => + typeof span.data?.[SEMANTIC_ATTRIBUTE_CACHE_KEY] === 'string' && + span.data[SEMANTIC_ATTRIBUTE_CACHE_KEY].includes('user:123'), + ); + if (cacheSetSpan) { + expect(cacheSetSpan.data).toMatchObject({ + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'cache.set_item', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.cache.nuxt', + 'db.operation.name': 'setItem', + 'db.collection.name': expect.stringMatching(/^(cache)?$/), + }); + } + + // Test that we have spans for different cached functions + const dataKeySpans = getItemSpans.filter( + span => + typeof span.data?.[SEMANTIC_ATTRIBUTE_CACHE_KEY] === 'string' && + span.data[SEMANTIC_ATTRIBUTE_CACHE_KEY].includes('data:test-key'), + ); + expect(dataKeySpans.length).toBeGreaterThan(0); + + // Test that we have spans for cachedEventHandler + const cachedHandlerSpans = getItemSpans.filter( + span => + typeof span.data?.[SEMANTIC_ATTRIBUTE_CACHE_KEY] === 'string' && + span.data[SEMANTIC_ATTRIBUTE_CACHE_KEY].includes('cachedHandler'), + ); + expect(cachedHandlerSpans.length).toBeGreaterThan(0); + + // Verify all cache spans have OK status + allCacheSpans?.forEach(span => { + expect(span.status).toBe('ok'); + }); + + // Verify cache spans are properly nested under the transaction + allCacheSpans?.forEach(span => { + expect(span.parent_span_id).toBeDefined(); + }); + }); + + test('correctly tracks cache hits and misses for cachedFunction', async ({ request }) => { + // Use a unique key for this test to ensure fresh cache state + const uniqueUser = `test-${Date.now()}`; + const uniqueData = `data-${Date.now()}`; + + const transactionPromise = waitForTransaction('nuxt-5', transactionEvent => { + return transactionEvent.transaction?.includes('GET /api/cache-test') ?? false; + }); + + await request.get(`/api/cache-test?user=${uniqueUser}&data=${uniqueData}`); + const transaction1 = await transactionPromise; + + // Get all cache-related spans + const allCacheSpans = transaction1.spans?.filter( + span => span.data?.[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN] === 'auto.cache.nuxt', + ); + + // We should have cache operations + expect(allCacheSpans?.length).toBeGreaterThan(0); + + // Get all getItem operations + const allGetItemSpans = allCacheSpans?.filter( + span => span.data?.[SEMANTIC_ATTRIBUTE_SENTRY_OP] === 'cache.get_item', + ); + + // Get all setItem operations + const allSetItemSpans = allCacheSpans?.filter( + span => span.data?.[SEMANTIC_ATTRIBUTE_SENTRY_OP] === 'cache.set_item', + ); + + // We should have both get and set operations + expect(allGetItemSpans?.length).toBeGreaterThan(0); + expect(allSetItemSpans?.length).toBeGreaterThan(0); + + // Check for cache misses (cache.hit = false) + const cacheMissSpans = allGetItemSpans?.filter(span => span.data?.[SEMANTIC_ATTRIBUTE_CACHE_HIT] === false); + + // Check for cache hits (cache.hit = true) + const cacheHitSpans = allGetItemSpans?.filter(span => span.data?.[SEMANTIC_ATTRIBUTE_CACHE_HIT] === true); + + // We should have at least one cache miss (first calls to getCachedUser and getCachedData) + expect(cacheMissSpans?.length).toBeGreaterThanOrEqual(1); + + // We should have at least one cache hit (second calls to getCachedUser and getCachedData) + expect(cacheHitSpans?.length).toBeGreaterThanOrEqual(1); + }); +}); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-5/tests/database-multi.test.ts b/dev-packages/e2e-tests/test-applications/nuxt-5/tests/database-multi.test.ts new file mode 100644 index 000000000000..9257bbc0e8a2 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-5/tests/database-multi.test.ts @@ -0,0 +1,156 @@ +import { expect, test } from '@playwright/test'; +import { waitForTransaction } from '@sentry-internal/test-utils'; + +test.describe('multiple database instances', () => { + test('instruments default database instance', async ({ request }) => { + const transactionPromise = waitForTransaction('nuxt-5', transactionEvent => { + return transactionEvent.transaction === 'GET /api/db-multi-test'; + }); + + await request.get('/api/db-multi-test?method=default-db'); + + const transaction = await transactionPromise; + + const dbSpans = transaction.spans?.filter(span => span.op === 'db.query'); + + expect(dbSpans).toBeDefined(); + expect(dbSpans!.length).toBeGreaterThan(0); + + // Check that we have the SELECT span + const selectSpan = dbSpans?.find(span => span.description?.includes('SELECT * FROM default_table')); + expect(selectSpan).toBeDefined(); + expect(selectSpan?.op).toBe('db.query'); + expect(selectSpan?.data?.['db.system.name']).toBe('sqlite'); + expect(selectSpan?.data?.['sentry.origin']).toBe('auto.db.nuxt'); + }); + + test('instruments named database instance (users)', async ({ request }) => { + const transactionPromise = waitForTransaction('nuxt-5', transactionEvent => { + return transactionEvent.transaction === 'GET /api/db-multi-test'; + }); + + await request.get('/api/db-multi-test?method=users-db'); + + const transaction = await transactionPromise; + + const dbSpans = transaction.spans?.filter(span => span.op === 'db.query'); + + expect(dbSpans).toBeDefined(); + expect(dbSpans!.length).toBeGreaterThan(0); + + // Check that we have the SELECT span from users database + const selectSpan = dbSpans?.find(span => span.description?.includes('SELECT * FROM user_profiles')); + expect(selectSpan).toBeDefined(); + expect(selectSpan?.op).toBe('db.query'); + expect(selectSpan?.data?.['db.system.name']).toBe('sqlite'); + expect(selectSpan?.data?.['sentry.origin']).toBe('auto.db.nuxt'); + }); + + test('instruments named database instance (analytics)', async ({ request }) => { + const transactionPromise = waitForTransaction('nuxt-5', transactionEvent => { + return transactionEvent.transaction === 'GET /api/db-multi-test'; + }); + + await request.get('/api/db-multi-test?method=analytics-db'); + + const transaction = await transactionPromise; + + const dbSpans = transaction.spans?.filter(span => span.op === 'db.query'); + + expect(dbSpans).toBeDefined(); + expect(dbSpans!.length).toBeGreaterThan(0); + + // Check that we have the SELECT span from analytics database + const selectSpan = dbSpans?.find(span => span.description?.includes('SELECT * FROM events')); + expect(selectSpan).toBeDefined(); + expect(selectSpan?.op).toBe('db.query'); + expect(selectSpan?.data?.['db.system.name']).toBe('sqlite'); + expect(selectSpan?.data?.['sentry.origin']).toBe('auto.db.nuxt'); + }); + + test('instruments multiple database instances in single request', async ({ request }) => { + const transactionPromise = waitForTransaction('nuxt-5', transactionEvent => { + return transactionEvent.transaction === 'GET /api/db-multi-test'; + }); + + await request.get('/api/db-multi-test?method=multiple-dbs'); + + const transaction = await transactionPromise; + + const dbSpans = transaction.spans?.filter(span => span.op === 'db.query'); + + expect(dbSpans).toBeDefined(); + expect(dbSpans!.length).toBeGreaterThan(0); + + // Check that we have spans from all three databases + const sessionSpan = dbSpans?.find(span => span.description?.includes('SELECT * FROM sessions')); + const accountSpan = dbSpans?.find(span => span.description?.includes('SELECT * FROM accounts')); + const metricSpan = dbSpans?.find(span => span.description?.includes('SELECT * FROM metrics')); + + expect(sessionSpan).toBeDefined(); + expect(sessionSpan?.op).toBe('db.query'); + expect(sessionSpan?.data?.['db.system.name']).toBe('sqlite'); + + expect(accountSpan).toBeDefined(); + expect(accountSpan?.op).toBe('db.query'); + expect(accountSpan?.data?.['db.system.name']).toBe('sqlite'); + + expect(metricSpan).toBeDefined(); + expect(metricSpan?.op).toBe('db.query'); + expect(metricSpan?.data?.['db.system.name']).toBe('sqlite'); + + // All should have the same origin + expect(sessionSpan?.data?.['sentry.origin']).toBe('auto.db.nuxt'); + expect(accountSpan?.data?.['sentry.origin']).toBe('auto.db.nuxt'); + expect(metricSpan?.data?.['sentry.origin']).toBe('auto.db.nuxt'); + }); + + test('instruments SQL template tag across multiple databases', async ({ request }) => { + const transactionPromise = waitForTransaction('nuxt-5', transactionEvent => { + return transactionEvent.transaction === 'GET /api/db-multi-test'; + }); + + await request.get('/api/db-multi-test?method=sql-template-multi'); + + const transaction = await transactionPromise; + + const dbSpans = transaction.spans?.filter(span => span.op === 'db.query'); + + expect(dbSpans).toBeDefined(); + expect(dbSpans!.length).toBeGreaterThan(0); + + // Check that we have INSERT spans from both databases + const logsInsertSpan = dbSpans?.find(span => span.description?.includes('INSERT INTO logs')); + const auditLogsInsertSpan = dbSpans?.find(span => span.description?.includes('INSERT INTO audit_logs')); + + expect(logsInsertSpan).toBeDefined(); + expect(logsInsertSpan?.op).toBe('db.query'); + expect(logsInsertSpan?.data?.['db.system.name']).toBe('sqlite'); + expect(logsInsertSpan?.data?.['sentry.origin']).toBe('auto.db.nuxt'); + + expect(auditLogsInsertSpan).toBeDefined(); + expect(auditLogsInsertSpan?.op).toBe('db.query'); + expect(auditLogsInsertSpan?.data?.['db.system.name']).toBe('sqlite'); + expect(auditLogsInsertSpan?.data?.['sentry.origin']).toBe('auto.db.nuxt'); + }); + + test('creates correct span count for multiple database operations', async ({ request }) => { + const transactionPromise = waitForTransaction('nuxt-5', transactionEvent => { + return transactionEvent.transaction === 'GET /api/db-multi-test'; + }); + + await request.get('/api/db-multi-test?method=multiple-dbs'); + + const transaction = await transactionPromise; + + const dbSpans = transaction.spans?.filter(span => span.op === 'db.query'); + + // We should have multiple spans: + // - 3 CREATE TABLE (exec) spans + // - 3 INSERT (exec) spans + // - 3 SELECT (prepare + get) spans + // Total should be at least 9 spans + expect(dbSpans).toBeDefined(); + expect(dbSpans!.length).toBeGreaterThanOrEqual(9); + }); +}); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-5/tests/database.test.ts b/dev-packages/e2e-tests/test-applications/nuxt-5/tests/database.test.ts new file mode 100644 index 000000000000..331b41d90ccf --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-5/tests/database.test.ts @@ -0,0 +1,197 @@ +import { expect, test } from '@playwright/test'; +import { waitForError, waitForTransaction } from '@sentry-internal/test-utils'; + +test.describe('database integration', () => { + test('captures db.prepare().get() span', async ({ request }) => { + const transactionPromise = waitForTransaction('nuxt-5', transactionEvent => { + return transactionEvent.transaction === 'GET /api/db-test'; + }); + + await request.get('/api/db-test?method=prepare-get'); + + const transaction = await transactionPromise; + + const dbSpan = transaction.spans?.find(span => span.op === 'db.query' && span.description?.includes('SELECT')); + + expect(dbSpan).toBeDefined(); + expect(dbSpan?.op).toBe('db.query'); + expect(dbSpan?.description).toBe('SELECT * FROM users WHERE id = ?'); + expect(dbSpan?.data?.['db.system.name']).toBe('sqlite'); + expect(dbSpan?.data?.['db.query.text']).toBe('SELECT * FROM users WHERE id = ?'); + expect(dbSpan?.data?.['sentry.origin']).toBe('auto.db.nuxt'); + }); + + test('captures db.prepare().all() span', async ({ request }) => { + const transactionPromise = waitForTransaction('nuxt-5', transactionEvent => { + return transactionEvent.transaction === 'GET /api/db-test'; + }); + + await request.get('/api/db-test?method=prepare-all'); + + const transaction = await transactionPromise; + + const dbSpan = transaction.spans?.find( + span => span.op === 'db.query' && span.description?.includes('SELECT * FROM products'), + ); + + expect(dbSpan).toBeDefined(); + expect(dbSpan?.op).toBe('db.query'); + expect(dbSpan?.description).toBe('SELECT * FROM products WHERE price > ?'); + expect(dbSpan?.data?.['db.system.name']).toBe('sqlite'); + expect(dbSpan?.data?.['db.query.text']).toBe('SELECT * FROM products WHERE price > ?'); + expect(dbSpan?.data?.['sentry.origin']).toBe('auto.db.nuxt'); + }); + + test('captures db.prepare().run() span', async ({ request }) => { + const transactionPromise = waitForTransaction('nuxt-5', transactionEvent => { + return transactionEvent.transaction === 'GET /api/db-test'; + }); + + await request.get('/api/db-test?method=prepare-run'); + + const transaction = await transactionPromise; + + const dbSpan = transaction.spans?.find( + span => span.op === 'db.query' && span.description?.includes('INSERT INTO orders'), + ); + + expect(dbSpan).toBeDefined(); + expect(dbSpan?.op).toBe('db.query'); + expect(dbSpan?.description).toBe('INSERT INTO orders (customer, amount) VALUES (?, ?)'); + expect(dbSpan?.data?.['db.system.name']).toBe('sqlite'); + expect(dbSpan?.data?.['db.query.text']).toBe('INSERT INTO orders (customer, amount) VALUES (?, ?)'); + expect(dbSpan?.data?.['sentry.origin']).toBe('auto.db.nuxt'); + }); + + test('captures db.prepare().bind().all() span', async ({ request }) => { + const transactionPromise = waitForTransaction('nuxt-5', transactionEvent => { + return transactionEvent.transaction === 'GET /api/db-test'; + }); + + await request.get('/api/db-test?method=prepare-bind'); + + const transaction = await transactionPromise; + + const dbSpan = transaction.spans?.find( + span => span.op === 'db.query' && span.description?.includes('SELECT * FROM items'), + ); + + expect(dbSpan).toBeDefined(); + expect(dbSpan?.op).toBe('db.query'); + expect(dbSpan?.description).toBe('SELECT * FROM items WHERE category = ?'); + expect(dbSpan?.data?.['db.system.name']).toBe('sqlite'); + expect(dbSpan?.data?.['db.query.text']).toBe('SELECT * FROM items WHERE category = ?'); + expect(dbSpan?.data?.['sentry.origin']).toBe('auto.db.nuxt'); + }); + + test('captures db.sql template tag span', async ({ request }) => { + const transactionPromise = waitForTransaction('nuxt-5', transactionEvent => { + return transactionEvent.transaction === 'GET /api/db-test'; + }); + + await request.get('/api/db-test?method=sql'); + + const transaction = await transactionPromise; + + const dbSpan = transaction.spans?.find( + span => span.op === 'db.query' && span.description?.includes('INSERT INTO messages'), + ); + + expect(dbSpan).toBeDefined(); + expect(dbSpan?.op).toBe('db.query'); + expect(dbSpan?.description).toContain('INSERT INTO messages'); + expect(dbSpan?.data?.['db.system.name']).toBe('sqlite'); + expect(dbSpan?.data?.['db.query.text']).toContain('INSERT INTO messages'); + expect(dbSpan?.data?.['sentry.origin']).toBe('auto.db.nuxt'); + }); + + test('captures db.exec() span', async ({ request }) => { + const transactionPromise = waitForTransaction('nuxt-5', transactionEvent => { + return transactionEvent.transaction === 'GET /api/db-test'; + }); + + await request.get('/api/db-test?method=exec'); + + const transaction = await transactionPromise; + + const dbSpan = transaction.spans?.find( + span => span.op === 'db.query' && span.description?.includes('INSERT INTO logs'), + ); + + expect(dbSpan).toBeDefined(); + expect(dbSpan?.op).toBe('db.query'); + expect(dbSpan?.description).toBe(`INSERT INTO logs (message, level) VALUES ('Test log', 'INFO')`); + expect(dbSpan?.data?.['db.system.name']).toBe('sqlite'); + expect(dbSpan?.data?.['db.query.text']).toBe(`INSERT INTO logs (message, level) VALUES ('Test log', 'INFO')`); + expect(dbSpan?.data?.['sentry.origin']).toBe('auto.db.nuxt'); + }); + + test('captures database error and marks span as failed', async ({ request }) => { + const errorPromise = waitForError('nuxt-5', errorEvent => { + return !!errorEvent?.exception?.values?.[0]?.value?.includes('no such table'); + }); + + const transactionPromise = waitForTransaction('nuxt-5', transactionEvent => { + return transactionEvent.transaction === 'GET /api/db-test'; + }); + + await request.get('/api/db-test?method=error').catch(() => { + // Expected to fail + }); + + const [error, transaction] = await Promise.all([errorPromise, transactionPromise]); + + expect(error).toBeDefined(); + expect(error.exception?.values?.[0]?.value).toContain('no such table'); + expect(error.exception?.values?.[0]?.mechanism).toEqual({ + handled: false, + type: 'auto.db.nuxt', + }); + + const dbSpan = transaction.spans?.find( + span => span.op === 'db.query' && span.description?.includes('SELECT * FROM nonexistent_table'), + ); + + expect(dbSpan).toBeDefined(); + expect(dbSpan?.op).toBe('db.query'); + expect(dbSpan?.description).toBe('SELECT * FROM nonexistent_table WHERE invalid_column = ?'); + expect(dbSpan?.data?.['db.system.name']).toBe('sqlite'); + expect(dbSpan?.data?.['db.query.text']).toBe('SELECT * FROM nonexistent_table WHERE invalid_column = ?'); + expect(dbSpan?.data?.['sentry.origin']).toBe('auto.db.nuxt'); + expect(dbSpan?.status).toBe('internal_error'); + }); + + test('captures breadcrumb for db.exec() queries', async ({ request }) => { + const transactionPromise = waitForTransaction('nuxt-5', transactionEvent => { + return transactionEvent.transaction === 'GET /api/db-test'; + }); + + await request.get('/api/db-test?method=exec'); + + const transaction = await transactionPromise; + + const dbBreadcrumb = transaction.breadcrumbs?.find( + breadcrumb => breadcrumb.category === 'query' && breadcrumb.message?.includes('INSERT INTO logs'), + ); + + expect(dbBreadcrumb).toBeDefined(); + expect(dbBreadcrumb?.category).toBe('query'); + expect(dbBreadcrumb?.message).toBe(`INSERT INTO logs (message, level) VALUES ('Test log', 'INFO')`); + expect(dbBreadcrumb?.data?.['db.query.text']).toBe(`INSERT INTO logs (message, level) VALUES ('Test log', 'INFO')`); + }); + + test('multiple database operations in single request create multiple spans', async ({ request }) => { + const transactionPromise = waitForTransaction('nuxt-5', transactionEvent => { + return transactionEvent.transaction === 'GET /api/db-test'; + }); + + await request.get('/api/db-test?method=prepare-get'); + + const transaction = await transactionPromise; + + const dbSpans = transaction.spans?.filter(span => span.op === 'db.query'); + + expect(dbSpans).toBeDefined(); + expect(dbSpans!.length).toBeGreaterThanOrEqual(1); + }); +}); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-5/tests/environment.test.ts b/dev-packages/e2e-tests/test-applications/nuxt-5/tests/environment.test.ts new file mode 100644 index 000000000000..93f9935d048f --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-5/tests/environment.test.ts @@ -0,0 +1,77 @@ +import { expect, test } from '@playwright/test'; +import { waitForError, waitForTransaction } from '@sentry-internal/test-utils'; +import { isDevMode } from './isDevMode'; + +test.describe('environment detection', async () => { + test('sets correct environment for client-side errors', async ({ page }) => { + const errorPromise = waitForError('nuxt-5', async errorEvent => { + return errorEvent?.exception?.values?.[0]?.value === 'Error thrown from nuxt-5 E2E test app'; + }); + + // We have to wait for networkidle in dev mode because clicking the button is a no-op otherwise (network requests are blocked during page load) + await page.goto(`/client-error`, isDevMode ? { waitUntil: 'networkidle' } : {}); + await page.locator('#errorBtn').click(); + + const error = await errorPromise; + + if (isDevMode) { + expect(error.environment).toBe('development'); + } else { + expect(error.environment).toBe('production'); + } + }); + + test('sets correct environment for client-side transactions', async ({ page }) => { + const transactionPromise = waitForTransaction('nuxt-5', async transactionEvent => { + return transactionEvent.transaction === '/test-param/:param()'; + }); + + await page.goto(`/test-param/1234`); + + const transaction = await transactionPromise; + + if (isDevMode) { + expect(transaction.environment).toBe('development'); + } else { + expect(transaction.environment).toBe('production'); + } + }); + + test('sets correct environment for server-side errors', async ({ page }) => { + const errorPromise = waitForError('nuxt-5', async errorEvent => { + return errorEvent?.exception?.values?.[0]?.value === 'Nuxt 4 Server error'; + }); + + await page.goto(`/fetch-server-routes`, isDevMode ? { waitUntil: 'networkidle' } : {}); + await page.getByText('Fetch Server API Error', { exact: true }).click(); + + const error = await errorPromise; + + expect(error.transaction).toBe('GET /api/server-error'); + + if (isDevMode) { + expect(error.environment).toBe('development'); + } else { + expect(error.environment).toBe('production'); + } + }); + + test('sets correct environment for server-side transactions', async ({ page }) => { + const transactionPromise = waitForTransaction('nuxt-5', async transactionEvent => { + return transactionEvent.transaction === 'GET /api/nitro-fetch'; + }); + + await page.goto(`/fetch-server-routes`, isDevMode ? { waitUntil: 'networkidle' } : {}); + await page.getByText('Fetch Nitro $fetch', { exact: true }).click(); + + const transaction = await transactionPromise; + + expect(transaction.contexts.trace.op).toBe('http.server'); + + if (isDevMode) { + expect(transaction.environment).toBe('development'); + } else { + expect(transaction.environment).toBe('production'); + } + }); +}); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-5/tests/errors.client.test.ts b/dev-packages/e2e-tests/test-applications/nuxt-5/tests/errors.client.test.ts new file mode 100644 index 000000000000..2c6a1be53662 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-5/tests/errors.client.test.ts @@ -0,0 +1,151 @@ +import { expect, test } from '@playwright/test'; +import { waitForError } from '@sentry-internal/test-utils'; + +test.describe('client-side errors', async () => { + test('captures error thrown on click', async ({ page }) => { + const errorPromise = waitForError('nuxt-5', async errorEvent => { + return errorEvent?.exception?.values?.[0]?.value === 'Error thrown from nuxt-5 E2E test app'; + }); + + await page.goto(`/client-error`); + await page.locator('#errorBtn').click(); + + const error = await errorPromise; + + expect(error.transaction).toEqual('/client-error'); + expect(error).toMatchObject({ + exception: { + values: [ + { + type: 'Error', + value: 'Error thrown from nuxt-5 E2E test app', + mechanism: { + handled: false, + type: 'auto.function.nuxt.vue-error', + }, + }, + ], + }, + }); + }); + + test('captures error thrown in NuxtErrorBoundary', async ({ page }) => { + const errorPromise = waitForError('nuxt-5', async errorEvent => { + return errorEvent?.exception?.values?.[0]?.value === 'Error thrown in Error Boundary'; + }); + + await page.goto(`/client-error`); + await page.locator('#error-in-error-boundary').click(); + + const error = await errorPromise; + + const expectedBreadcrumb = { + category: 'console', + message: 'Additional functionality in NuxtErrorBoundary', + }; + + const matchingBreadcrumb = error.breadcrumbs.find( + (breadcrumb: { category: string; message: string }) => + breadcrumb.category === expectedBreadcrumb.category && breadcrumb.message === expectedBreadcrumb.message, + ); + + expect(matchingBreadcrumb).toBeTruthy(); + expect(matchingBreadcrumb?.category).toBe(expectedBreadcrumb.category); + expect(matchingBreadcrumb?.message).toBe(expectedBreadcrumb.message); + + expect(error.transaction).toEqual('/client-error'); + expect(error.sdk.name).toEqual('sentry.javascript.nuxt'); + expect(error).toMatchObject({ + exception: { + values: [ + { + type: 'Error', + value: 'Error thrown in Error Boundary', + mechanism: { + handled: false, + type: 'auto.function.nuxt.vue-error', + }, + }, + ], + }, + }); + }); + + test('shows parametrized route on button error', async ({ page }) => { + const errorPromise = waitForError('nuxt-5', async errorEvent => { + return errorEvent?.exception?.values?.[0]?.value === 'Error thrown from Param Route Button'; + }); + + await page.goto(`/test-param/1234`); + await page.locator('#errorBtn').click(); + + const error = await errorPromise; + + expect(error.sdk.name).toEqual('sentry.javascript.nuxt'); + expect(error.transaction).toEqual('/test-param/:param()'); + expect(error.request.url).toMatch(/\/test-param\/1234/); + expect(error).toMatchObject({ + exception: { + values: [ + { + type: 'Error', + value: 'Error thrown from Param Route Button', + mechanism: { + handled: false, + type: 'auto.function.nuxt.vue-error', + }, + }, + ], + }, + }); + }); + + test('page is still interactive after client error', async ({ page }) => { + const error1Promise = waitForError('nuxt-5', async errorEvent => { + return errorEvent?.exception?.values?.[0]?.value === 'Error thrown from nuxt-5 E2E test app'; + }); + + await page.goto(`/client-error`); + await page.locator('#errorBtn').click(); + + const error1 = await error1Promise; + + const error2Promise = waitForError('nuxt-5', async errorEvent => { + return errorEvent?.exception?.values?.[0]?.value === 'Another Error thrown from nuxt-5 E2E test app'; + }); + + await page.locator('#errorBtn2').click(); + + const error2 = await error2Promise; + + expect(error1).toMatchObject({ + exception: { + values: [ + { + type: 'Error', + value: 'Error thrown from nuxt-5 E2E test app', + mechanism: { + handled: false, + type: 'auto.function.nuxt.vue-error', + }, + }, + ], + }, + }); + + expect(error2).toMatchObject({ + exception: { + values: [ + { + type: 'Error', + value: 'Another Error thrown from nuxt-5 E2E test app', + mechanism: { + handled: false, + type: 'auto.function.nuxt.vue-error', + }, + }, + ], + }, + }); + }); +}); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-5/tests/errors.server.test.ts b/dev-packages/e2e-tests/test-applications/nuxt-5/tests/errors.server.test.ts new file mode 100644 index 000000000000..163dfd28c80a --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-5/tests/errors.server.test.ts @@ -0,0 +1,66 @@ +import { expect, test } from '@playwright/test'; +import { waitForError } from '@sentry-internal/test-utils'; + +test.describe('server-side errors', async () => { + test('captures api fetch error (fetched on click)', async ({ page }) => { + const errorPromise = waitForError('nuxt-5', async errorEvent => { + return errorEvent?.exception?.values?.[0]?.value === 'Nuxt 4 Server error'; + }); + + await page.goto(`/fetch-server-routes`); + await page.getByText('Fetch Server API Error', { exact: true }).click(); + + const error = await errorPromise; + + expect(error.transaction).toEqual('GET /api/server-error'); + + const exception0 = error.exception.values[0]; + const exception1 = error.exception.values[1]; + + expect(exception0.type).toEqual('Error'); + expect(exception0.value).toEqual('Nuxt 4 Server error'); + expect(exception0.mechanism).toEqual({ + handled: false, + type: 'auto.function.nuxt.nitro', + exception_id: 1, + parent_id: 0, + source: 'cause', + }); + + expect(exception1.type).toEqual('HTTPError'); + expect(exception1.value).toEqual('Nuxt 4 Server error'); + // TODO: This isn't correct but requires adjustment in the core SDK + expect(exception1.mechanism).toEqual({ handled: true, type: 'generic', exception_id: 0 }); + }); + + test('captures api fetch error (fetched on click) with parametrized route', async ({ page }) => { + const errorPromise = waitForError('nuxt-5', async errorEvent => { + return errorEvent?.exception?.values?.[0]?.value === 'Nuxt 4 Param Server error'; + }); + + await page.goto(`/test-param/1234`); + await page.getByRole('button', { name: 'Fetch Server API Error', exact: true }).click(); + + const error = await errorPromise; + + expect(error.transaction).toEqual('GET /api/param-error/1234'); + + const exception0 = error.exception.values[0]; + const exception1 = error.exception.values[1]; + + expect(exception0.type).toEqual('Error'); + expect(exception0.value).toEqual('Nuxt 4 Param Server error'); + expect(exception0.mechanism).toEqual({ + handled: false, + type: 'auto.function.nuxt.nitro', + exception_id: 1, + parent_id: 0, + source: 'cause', + }); + + expect(exception1.type).toEqual('HTTPError'); + expect(exception1.value).toEqual('Nuxt 4 Param Server error'); + // TODO: This isn't correct but requires adjustment in the core SDK + expect(exception1.mechanism).toEqual({ handled: true, type: 'generic', exception_id: 0 }); + }); +}); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-5/tests/isDevMode.ts b/dev-packages/e2e-tests/test-applications/nuxt-5/tests/isDevMode.ts new file mode 100644 index 000000000000..d2be94232110 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-5/tests/isDevMode.ts @@ -0,0 +1 @@ +export const isDevMode = !!process.env.TEST_ENV && process.env.TEST_ENV.includes('development'); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-5/tests/middleware.test.ts b/dev-packages/e2e-tests/test-applications/nuxt-5/tests/middleware.test.ts new file mode 100644 index 000000000000..3c314b80b59c --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-5/tests/middleware.test.ts @@ -0,0 +1,333 @@ +import { expect, test } from '@playwright/test'; +import { waitForTransaction, waitForError } from '@sentry-internal/test-utils'; + +// TODO: Skipped for Nuxt 5 as the SDK is not yet updated for that +test.describe.skip('Server Middleware Instrumentation', () => { + test('should create separate spans for each server middleware', async ({ request }) => { + const serverTxnEventPromise = waitForTransaction('nuxt-5', txnEvent => { + return txnEvent.transaction?.includes('GET /api/middleware-test') ?? false; + }); + + // Make request to the API endpoint that will trigger all server middleware + const response = await request.get('/api/middleware-test'); + expect(response.status()).toBe(200); + + const responseData = await response.json(); + expect(responseData.message).toBe('Server middleware test endpoint'); + + const serverTxnEvent = await serverTxnEventPromise; + + // Verify that we have spans for each middleware + const middlewareSpans = serverTxnEvent.spans?.filter(span => span.op === 'middleware.nuxt') || []; + + // 3 simple + 3 hooks (onRequest+handler+onBeforeResponse) + 5 array hooks (2 onRequest + 1 handler + 2 onBeforeResponse + expect(middlewareSpans).toHaveLength(11); + + // Check for specific middleware spans + const firstMiddlewareSpan = middlewareSpans.find(span => span.data?.['nuxt.middleware.name'] === '01.first'); + const secondMiddlewareSpan = middlewareSpans.find(span => span.data?.['nuxt.middleware.name'] === '02.second'); + const authMiddlewareSpan = middlewareSpans.find(span => span.data?.['nuxt.middleware.name'] === '03.auth'); + const hooksOnRequestSpan = middlewareSpans.find(span => span.data?.['nuxt.middleware.name'] === '04.hooks'); + const arrayHooksHandlerSpan = middlewareSpans.find( + span => span.data?.['nuxt.middleware.name'] === '05.array-hooks', + ); + + expect(firstMiddlewareSpan).toBeDefined(); + expect(secondMiddlewareSpan).toBeDefined(); + expect(authMiddlewareSpan).toBeDefined(); + expect(hooksOnRequestSpan).toBeDefined(); + expect(arrayHooksHandlerSpan).toBeDefined(); + + // Verify each span has the correct attributes + [firstMiddlewareSpan, secondMiddlewareSpan, authMiddlewareSpan].forEach(span => { + expect(span).toEqual( + expect.objectContaining({ + op: 'middleware.nuxt', + data: expect.objectContaining({ + 'sentry.op': 'middleware.nuxt', + 'sentry.origin': 'auto.middleware.nuxt', + 'sentry.source': 'custom', + 'http.request.method': 'GET', + 'http.route': '/api/middleware-test', + }), + parent_span_id: expect.stringMatching(/[a-f0-9]{16}/), + span_id: expect.stringMatching(/[a-f0-9]{16}/), + trace_id: expect.stringMatching(/[a-f0-9]{32}/), + }), + ); + }); + + // Verify spans have different span IDs (each middleware gets its own span) + const spanIds = middlewareSpans.map(span => span.span_id); + const uniqueSpanIds = new Set(spanIds); + // 3 simple + 3 hooks (onRequest+handler+onBeforeResponse) + 5 array hooks (2 onRequest + 1 handler + 2 onBeforeResponse) + expect(uniqueSpanIds.size).toBe(11); + + // Verify spans share the same trace ID + const traceIds = middlewareSpans.map(span => span.trace_id); + const uniqueTraceIds = new Set(traceIds); + expect(uniqueTraceIds.size).toBe(1); + }); + + test('middleware spans should have proper parent-child relationship', async ({ request }) => { + const serverTxnEventPromise = waitForTransaction('nuxt-5', txnEvent => { + return txnEvent.transaction?.includes('GET /api/middleware-test') ?? false; + }); + + await request.get('/api/middleware-test'); + const serverTxnEvent = await serverTxnEventPromise; + + const middlewareSpans = serverTxnEvent.spans?.filter(span => span.op === 'middleware.nuxt') || []; + + // All middleware spans should be children of the main transaction + middlewareSpans.forEach(span => { + expect(span.parent_span_id).toBe(serverTxnEvent.contexts?.trace?.span_id); + }); + }); + + test('should capture errors thrown in middleware and associate them with the span', async ({ request }) => { + const serverTxnEventPromise = waitForTransaction('nuxt-5', txnEvent => { + return txnEvent.transaction?.includes('GET /api/middleware-test') ?? false; + }); + + const errorEventPromise = waitForError('nuxt-5', errorEvent => { + return errorEvent?.exception?.values?.[0]?.value === 'Auth middleware error'; + }); + + // Make request with query param to trigger error in auth middleware + const response = await request.get('/api/middleware-test?throwError=true'); + + // The request should fail due to the middleware error + expect(response.status()).toBe(500); + + const [serverTxnEvent, errorEvent] = await Promise.all([serverTxnEventPromise, errorEventPromise]); + + // Find the auth middleware span + const authMiddlewareSpan = serverTxnEvent.spans?.find( + span => span.op === 'middleware.nuxt' && span.data?.['nuxt.middleware.name'] === '03.auth', + ); + + expect(authMiddlewareSpan).toBeDefined(); + + // Verify the span has error status + expect(authMiddlewareSpan?.status).toBe('internal_error'); + + // Verify the error event is associated with the correct transaction + expect(errorEvent.transaction).toContain('GET /api/middleware-test'); + + // Verify the error has the correct mechanism + expect(errorEvent.exception?.values?.[0]).toEqual( + expect.objectContaining({ + value: 'Auth middleware error', + type: 'Error', + mechanism: expect.objectContaining({ + handled: false, + type: 'auto.middleware.nuxt', + }), + }), + ); + }); + + test('should create spans for onRequest and onBeforeResponse hooks', async ({ request }) => { + const serverTxnEventPromise = waitForTransaction('nuxt-5', txnEvent => { + return txnEvent.transaction?.includes('GET /api/middleware-test') ?? false; + }); + + // Make request to trigger middleware with hooks + const response = await request.get('/api/middleware-test'); + expect(response.status()).toBe(200); + + const serverTxnEvent = await serverTxnEventPromise; + const middlewareSpans = serverTxnEvent.spans?.filter(span => span.op === 'middleware.nuxt') || []; + + // Find spans for the hooks middleware + const hooksSpans = middlewareSpans.filter(span => span.data?.['nuxt.middleware.name'] === '04.hooks'); + + // Should have spans for onRequest, handler, and onBeforeResponse + expect(hooksSpans).toHaveLength(3); + + // Find specific hook spans + const onRequestSpan = hooksSpans.find(span => span.data?.['nuxt.middleware.hook.name'] === 'onRequest'); + const handlerSpan = hooksSpans.find(span => span.data?.['nuxt.middleware.hook.name'] === 'handler'); + const onBeforeResponseSpan = hooksSpans.find( + span => span.data?.['nuxt.middleware.hook.name'] === 'onBeforeResponse', + ); + + expect(onRequestSpan).toBeDefined(); + expect(handlerSpan).toBeDefined(); + expect(onBeforeResponseSpan).toBeDefined(); + + // Verify span names include hook types + expect(onRequestSpan?.description).toBe('04.hooks.onRequest'); + expect(handlerSpan?.description).toBe('04.hooks'); + expect(onBeforeResponseSpan?.description).toBe('04.hooks.onBeforeResponse'); + + // Verify all spans have correct middleware name (without hook suffix) + [onRequestSpan, handlerSpan, onBeforeResponseSpan].forEach(span => { + expect(span?.data?.['nuxt.middleware.name']).toBe('04.hooks'); + }); + + // Verify hook-specific attributes + expect(onRequestSpan?.data?.['nuxt.middleware.hook.name']).toBe('onRequest'); + expect(handlerSpan?.data?.['nuxt.middleware.hook.name']).toBe('handler'); + expect(onBeforeResponseSpan?.data?.['nuxt.middleware.hook.name']).toBe('onBeforeResponse'); + + // Verify no index attributes for single hooks + expect(onRequestSpan?.data).not.toHaveProperty('nuxt.middleware.hook.index'); + expect(handlerSpan?.data).not.toHaveProperty('nuxt.middleware.hook.index'); + expect(onBeforeResponseSpan?.data).not.toHaveProperty('nuxt.middleware.hook.index'); + }); + + test('should create spans with index attributes for array hooks', async ({ request }) => { + const serverTxnEventPromise = waitForTransaction('nuxt-5', txnEvent => { + return txnEvent.transaction?.includes('GET /api/middleware-test') ?? false; + }); + + // Make request to trigger middleware with array hooks + const response = await request.get('/api/middleware-test'); + expect(response.status()).toBe(200); + + const serverTxnEvent = await serverTxnEventPromise; + const middlewareSpans = serverTxnEvent.spans?.filter(span => span.op === 'middleware.nuxt') || []; + + // Find spans for the array hooks middleware + const arrayHooksSpans = middlewareSpans.filter(span => span.data?.['nuxt.middleware.name'] === '05.array-hooks'); + + // Should have spans for 2 onRequest + 1 handler + 2 onBeforeResponse = 5 spans + expect(arrayHooksSpans).toHaveLength(5); + + // Find onRequest array spans + const onRequestSpans = arrayHooksSpans.filter(span => span.data?.['nuxt.middleware.hook.name'] === 'onRequest'); + expect(onRequestSpans).toHaveLength(2); + + // Find onBeforeResponse array spans + const onBeforeResponseSpans = arrayHooksSpans.filter( + span => span.data?.['nuxt.middleware.hook.name'] === 'onBeforeResponse', + ); + expect(onBeforeResponseSpans).toHaveLength(2); + + // Find handler span + const handlerSpan = arrayHooksSpans.find(span => span.data?.['nuxt.middleware.hook.name'] === 'handler'); + expect(handlerSpan).toBeDefined(); + + // Verify index attributes for onRequest array + const onRequest0Span = onRequestSpans.find(span => span.data?.['nuxt.middleware.hook.index'] === 0); + const onRequest1Span = onRequestSpans.find(span => span.data?.['nuxt.middleware.hook.index'] === 1); + + expect(onRequest0Span).toBeDefined(); + expect(onRequest1Span).toBeDefined(); + + // Verify index attributes for onBeforeResponse array + const onBeforeResponse0Span = onBeforeResponseSpans.find(span => span.data?.['nuxt.middleware.hook.index'] === 0); + const onBeforeResponse1Span = onBeforeResponseSpans.find(span => span.data?.['nuxt.middleware.hook.index'] === 1); + + expect(onBeforeResponse0Span).toBeDefined(); + expect(onBeforeResponse1Span).toBeDefined(); + + // Verify span names for array handlers + expect(onRequest0Span?.description).toBe('05.array-hooks.onRequest'); + expect(onRequest1Span?.description).toBe('05.array-hooks.onRequest'); + expect(onBeforeResponse0Span?.description).toBe('05.array-hooks.onBeforeResponse'); + expect(onBeforeResponse1Span?.description).toBe('05.array-hooks.onBeforeResponse'); + + // Verify handler has no index + expect(handlerSpan?.data).not.toHaveProperty('nuxt.middleware.hook.index'); + }); + + test('should handle errors in onRequest hooks', async ({ request }) => { + const serverTxnEventPromise = waitForTransaction('nuxt-5', txnEvent => { + return txnEvent.transaction?.includes('GET /api/middleware-test') ?? false; + }); + + const errorEventPromise = waitForError('nuxt-5', errorEvent => { + return errorEvent?.exception?.values?.[0]?.value === 'OnRequest hook error'; + }); + + // Make request with query param to trigger error in onRequest + const response = await request.get('/api/middleware-test?throwOnRequestError=true'); + expect(response.status()).toBe(500); + + const [serverTxnEvent, errorEvent] = await Promise.all([serverTxnEventPromise, errorEventPromise]); + + // Find the onRequest span that should have error status + const onRequestSpan = serverTxnEvent.spans?.find( + span => + span.op === 'middleware.nuxt' && + span.data?.['nuxt.middleware.name'] === '04.hooks' && + span.data?.['nuxt.middleware.hook.name'] === 'onRequest', + ); + + expect(onRequestSpan).toBeDefined(); + expect(onRequestSpan?.status).toBe('internal_error'); + expect(errorEvent.exception?.values?.[0]?.value).toBe('OnRequest hook error'); + }); + + test('should handle errors in onBeforeResponse hooks', async ({ request }) => { + const serverTxnEventPromise = waitForTransaction('nuxt-5', txnEvent => { + return txnEvent.transaction?.includes('GET /api/middleware-test') ?? false; + }); + + const errorEventPromise = waitForError('nuxt-5', errorEvent => { + return errorEvent?.exception?.values?.[0]?.value === 'OnBeforeResponse hook error'; + }); + + // Make request with query param to trigger error in onBeforeResponse + const response = await request.get('/api/middleware-test?throwOnBeforeResponseError=true'); + expect(response.status()).toBe(500); + + const [serverTxnEvent, errorEvent] = await Promise.all([serverTxnEventPromise, errorEventPromise]); + + // Find the onBeforeResponse span that should have error status + const onBeforeResponseSpan = serverTxnEvent.spans?.find( + span => + span.op === 'middleware.nuxt' && + span.data?.['nuxt.middleware.name'] === '04.hooks' && + span.data?.['nuxt.middleware.hook.name'] === 'onBeforeResponse', + ); + + expect(onBeforeResponseSpan).toBeDefined(); + expect(onBeforeResponseSpan?.status).toBe('internal_error'); + expect(errorEvent.exception?.values?.[0]?.value).toBe('OnBeforeResponse hook error'); + }); + + test('should handle errors in array hooks with proper index attribution', async ({ request }) => { + const serverTxnEventPromise = waitForTransaction('nuxt-5', txnEvent => { + return txnEvent.transaction?.includes('GET /api/middleware-test') ?? false; + }); + + const errorEventPromise = waitForError('nuxt-5', errorEvent => { + return errorEvent?.exception?.values?.[0]?.value === 'OnRequest[1] hook error'; + }); + + // Make request with query param to trigger error in second onRequest handler + const response = await request.get('/api/middleware-test?throwOnRequest1Error=true'); + expect(response.status()).toBe(500); + + const [serverTxnEvent, errorEvent] = await Promise.all([serverTxnEventPromise, errorEventPromise]); + + // Find the second onRequest span that should have error status + const onRequest1Span = serverTxnEvent.spans?.find( + span => + span.op === 'middleware.nuxt' && + span.data?.['nuxt.middleware.name'] === '05.array-hooks' && + span.data?.['nuxt.middleware.hook.name'] === 'onRequest' && + span.data?.['nuxt.middleware.hook.index'] === 1, + ); + + expect(onRequest1Span).toBeDefined(); + expect(onRequest1Span?.status).toBe('internal_error'); + expect(errorEvent.exception?.values?.[0]?.value).toBe('OnRequest[1] hook error'); + + // Verify the first onRequest handler still executed successfully + const onRequest0Span = serverTxnEvent.spans?.find( + span => + span.op === 'middleware.nuxt' && + span.data?.['nuxt.middleware.name'] === '05.array-hooks' && + span.data?.['nuxt.middleware.hook.name'] === 'onRequest' && + span.data?.['nuxt.middleware.hook.index'] === 0, + ); + + expect(onRequest0Span).toBeDefined(); + expect(onRequest0Span?.status).not.toBe('internal_error'); + }); +}); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-5/tests/pinia.test.ts b/dev-packages/e2e-tests/test-applications/nuxt-5/tests/pinia.test.ts new file mode 100644 index 000000000000..fa1529187286 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-5/tests/pinia.test.ts @@ -0,0 +1,36 @@ +import { expect, test } from '@playwright/test'; +import { waitForError } from '@sentry-internal/test-utils'; + +// TODO: Pinia does not yet support Nuxt 5, so this test is skipped for now. +test.skip('sends pinia action breadcrumbs and state context', async ({ page }) => { + await page.goto('/pinia-cart'); + + await page.locator('#item-input').fill('item'); + await page.locator('#item-add').click(); + + const errorPromise = waitForError('nuxt-5', async errorEvent => { + return errorEvent?.exception?.values?.[0].value === 'This is an error'; + }); + + await page.locator('#throw-error').click(); + + const error = await errorPromise; + + expect(error).toBeTruthy(); + expect(error.breadcrumbs?.length).toBeGreaterThan(0); + + const actionBreadcrumb = error.breadcrumbs?.find(breadcrumb => breadcrumb.category === 'pinia.action'); + + expect(actionBreadcrumb).toBeDefined(); + expect(actionBreadcrumb?.message).toBe('Store: cart | Action: addItem.transformed'); + expect(actionBreadcrumb?.level).toBe('info'); + + const stateContext = error.contexts?.state?.state; + + expect(stateContext).toBeDefined(); + expect(stateContext?.type).toBe('pinia'); + expect(stateContext?.value).toEqual({ + transformed: true, + cart: { rawItems: ['item'] }, + }); +}); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-5/tests/storage-aliases.test.ts b/dev-packages/e2e-tests/test-applications/nuxt-5/tests/storage-aliases.test.ts new file mode 100644 index 000000000000..c6ff331a2780 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-5/tests/storage-aliases.test.ts @@ -0,0 +1,108 @@ +import { expect, test } from '@playwright/test'; +import { waitForTransaction } from '@sentry-internal/test-utils'; +import { SEMANTIC_ATTRIBUTE_SENTRY_OP, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN } from '@sentry/nuxt'; + +test.describe('Storage Instrumentation - Aliases', () => { + const prefixKey = (key: string) => `test-storage:${key}`; + const SEMANTIC_ATTRIBUTE_CACHE_KEY = 'cache.key'; + const SEMANTIC_ATTRIBUTE_CACHE_HIT = 'cache.hit'; + + test('instruments storage alias methods (get, set, has, del, remove) and creates spans', async ({ request }) => { + const transactionPromise = waitForTransaction('nuxt-5', transactionEvent => { + return transactionEvent.transaction?.includes('GET /api/storage-aliases-test') ?? false; + }); + + const response = await request.get('/api/storage-aliases-test'); + expect(response.status()).toBe(200); + + const transaction = await transactionPromise; + + // Helper to find spans by operation + const findSpansByOp = (op: string) => { + return transaction.spans?.filter(span => span.data?.[SEMANTIC_ATTRIBUTE_SENTRY_OP] === op) || []; + }; + + // Test set (alias for setItem) + const setSpans = findSpansByOp('cache.set_item'); + expect(setSpans.length).toBeGreaterThanOrEqual(1); + const setSpan = setSpans.find(span => span.data?.[SEMANTIC_ATTRIBUTE_CACHE_KEY] === prefixKey('alias:user')); + expect(setSpan).toBeDefined(); + expect(setSpan?.data).toMatchObject({ + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'cache.set_item', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.cache.nuxt', + [SEMANTIC_ATTRIBUTE_CACHE_KEY]: prefixKey('alias:user'), + 'db.operation.name': 'setItem', + 'db.collection.name': 'test-storage', + 'db.system.name': 'memory', + }); + expect(setSpan?.description).toBe(prefixKey('alias:user')); + + // Test get (alias for getItem) + const getSpans = findSpansByOp('cache.get_item'); + expect(getSpans.length).toBeGreaterThanOrEqual(1); + const getSpan = getSpans.find(span => span.data?.[SEMANTIC_ATTRIBUTE_CACHE_KEY] === prefixKey('alias:user')); + expect(getSpan).toBeDefined(); + expect(getSpan?.data).toMatchObject({ + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'cache.get_item', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.cache.nuxt', + [SEMANTIC_ATTRIBUTE_CACHE_KEY]: prefixKey('alias:user'), + [SEMANTIC_ATTRIBUTE_CACHE_HIT]: true, + 'db.operation.name': 'getItem', + 'db.collection.name': 'test-storage', + 'db.system.name': 'memory', + }); + expect(getSpan?.description).toBe(prefixKey('alias:user')); + + // Test has (alias for hasItem) + const hasSpans = findSpansByOp('cache.has_item'); + expect(hasSpans.length).toBeGreaterThanOrEqual(1); + const hasSpan = hasSpans.find(span => span.data?.[SEMANTIC_ATTRIBUTE_CACHE_KEY] === prefixKey('alias:user')); + expect(hasSpan).toBeDefined(); + expect(hasSpan?.data).toMatchObject({ + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'cache.has_item', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.cache.nuxt', + [SEMANTIC_ATTRIBUTE_CACHE_KEY]: prefixKey('alias:user'), + [SEMANTIC_ATTRIBUTE_CACHE_HIT]: true, + 'db.operation.name': 'hasItem', + 'db.collection.name': 'test-storage', + 'db.system.name': 'memory', + }); + + // Test del and remove (both aliases for removeItem) + const removeSpans = findSpansByOp('cache.remove_item'); + expect(removeSpans.length).toBeGreaterThanOrEqual(2); // Should have both del and remove calls + + const delSpan = removeSpans.find(span => span.data?.[SEMANTIC_ATTRIBUTE_CACHE_KEY] === prefixKey('alias:temp1')); + expect(delSpan).toBeDefined(); + expect(delSpan?.data).toMatchObject({ + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'cache.remove_item', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.cache.nuxt', + [SEMANTIC_ATTRIBUTE_CACHE_KEY]: prefixKey('alias:temp1'), + 'db.operation.name': 'removeItem', + 'db.collection.name': 'test-storage', + 'db.system.name': 'memory', + }); + expect(delSpan?.description).toBe(prefixKey('alias:temp1')); + + const removeSpan = removeSpans.find(span => span.data?.[SEMANTIC_ATTRIBUTE_CACHE_KEY] === prefixKey('alias:temp2')); + expect(removeSpan).toBeDefined(); + expect(removeSpan?.data).toMatchObject({ + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'cache.remove_item', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.cache.nuxt', + [SEMANTIC_ATTRIBUTE_CACHE_KEY]: prefixKey('alias:temp2'), + 'db.operation.name': 'removeItem', + 'db.collection.name': 'test-storage', + 'db.system.name': 'memory', + }); + expect(removeSpan?.description).toBe(prefixKey('alias:temp2')); + + // Verify all spans have OK status + const allStorageSpans = transaction.spans?.filter( + span => span.data?.[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN] === 'auto.cache.nuxt', + ); + expect(allStorageSpans?.length).toBeGreaterThan(0); + allStorageSpans?.forEach(span => { + expect(span.status).toBe('ok'); + }); + }); +}); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-5/tests/storage.test.ts b/dev-packages/e2e-tests/test-applications/nuxt-5/tests/storage.test.ts new file mode 100644 index 000000000000..b0d9af9142da --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-5/tests/storage.test.ts @@ -0,0 +1,151 @@ +import { expect, test } from '@playwright/test'; +import { waitForTransaction } from '@sentry-internal/test-utils'; +import { SEMANTIC_ATTRIBUTE_SENTRY_OP, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN } from '@sentry/nuxt'; + +test.describe('Storage Instrumentation', () => { + const prefixKey = (key: string) => `test-storage:${key}`; + const SEMANTIC_ATTRIBUTE_CACHE_KEY = 'cache.key'; + const SEMANTIC_ATTRIBUTE_CACHE_HIT = 'cache.hit'; + + test('instruments all storage operations and creates spans with correct attributes', async ({ request }) => { + const transactionPromise = waitForTransaction('nuxt-5', transactionEvent => { + return transactionEvent.transaction?.includes('GET /api/storage-test') ?? false; + }); + + const response = await request.get('/api/storage-test'); + expect(response.status()).toBe(200); + + const transaction = await transactionPromise; + + // Helper to find spans by operation + const findSpansByOp = (op: string) => { + return transaction.spans?.filter(span => span.data?.[SEMANTIC_ATTRIBUTE_SENTRY_OP] === op) || []; + }; + + // Test setItem spans + const setItemSpans = findSpansByOp('cache.set_item'); + expect(setItemSpans.length).toBeGreaterThanOrEqual(1); + const setItemSpan = setItemSpans.find(span => span.data?.[SEMANTIC_ATTRIBUTE_CACHE_KEY] === prefixKey('user:123')); + expect(setItemSpan).toBeDefined(); + expect(setItemSpan?.data).toMatchObject({ + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'cache.set_item', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.cache.nuxt', + [SEMANTIC_ATTRIBUTE_CACHE_KEY]: prefixKey('user:123'), + 'db.operation.name': 'setItem', + 'db.collection.name': 'test-storage', + 'db.system.name': 'memory', + }); + expect(setItemSpan?.description).toBe(prefixKey('user:123')); + + // Test setItemRaw spans + const setItemRawSpans = findSpansByOp('cache.set_item_raw'); + expect(setItemRawSpans.length).toBeGreaterThanOrEqual(1); + const setItemRawSpan = setItemRawSpans.find( + span => span.data?.[SEMANTIC_ATTRIBUTE_CACHE_KEY] === prefixKey('raw:data'), + ); + expect(setItemRawSpan).toBeDefined(); + expect(setItemRawSpan?.data).toMatchObject({ + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'cache.set_item_raw', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.cache.nuxt', + [SEMANTIC_ATTRIBUTE_CACHE_KEY]: prefixKey('raw:data'), + 'db.operation.name': 'setItemRaw', + 'db.collection.name': 'test-storage', + 'db.system.name': 'memory', + }); + + // Test hasItem spans - should have cache hit attribute + const hasItemSpans = findSpansByOp('cache.has_item'); + expect(hasItemSpans.length).toBeGreaterThanOrEqual(1); + const hasItemSpan = hasItemSpans.find(span => span.data?.[SEMANTIC_ATTRIBUTE_CACHE_KEY] === prefixKey('user:123')); + expect(hasItemSpan).toBeDefined(); + expect(hasItemSpan?.data).toMatchObject({ + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'cache.has_item', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.cache.nuxt', + [SEMANTIC_ATTRIBUTE_CACHE_KEY]: prefixKey('user:123'), + [SEMANTIC_ATTRIBUTE_CACHE_HIT]: true, + 'db.operation.name': 'hasItem', + 'db.collection.name': 'test-storage', + 'db.system.name': 'memory', + }); + + // Test getItem spans - should have cache hit attribute + const getItemSpans = findSpansByOp('cache.get_item'); + expect(getItemSpans.length).toBeGreaterThanOrEqual(1); + const getItemSpan = getItemSpans.find(span => span.data?.[SEMANTIC_ATTRIBUTE_CACHE_KEY] === prefixKey('user:123')); + expect(getItemSpan).toBeDefined(); + expect(getItemSpan?.data).toMatchObject({ + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'cache.get_item', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.cache.nuxt', + [SEMANTIC_ATTRIBUTE_CACHE_KEY]: prefixKey('user:123'), + [SEMANTIC_ATTRIBUTE_CACHE_HIT]: true, + 'db.operation.name': 'getItem', + 'db.collection.name': 'test-storage', + 'db.system.name': 'memory', + }); + expect(getItemSpan?.description).toBe(prefixKey('user:123')); + + // Test getItemRaw spans - should have cache hit attribute + const getItemRawSpans = findSpansByOp('cache.get_item_raw'); + expect(getItemRawSpans.length).toBeGreaterThanOrEqual(1); + const getItemRawSpan = getItemRawSpans.find( + span => span.data?.[SEMANTIC_ATTRIBUTE_CACHE_KEY] === prefixKey('raw:data'), + ); + expect(getItemRawSpan).toBeDefined(); + expect(getItemRawSpan?.data).toMatchObject({ + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'cache.get_item_raw', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.cache.nuxt', + [SEMANTIC_ATTRIBUTE_CACHE_KEY]: prefixKey('raw:data'), + [SEMANTIC_ATTRIBUTE_CACHE_HIT]: true, + 'db.operation.name': 'getItemRaw', + 'db.collection.name': 'test-storage', + 'db.system.name': 'memory', + }); + + // Test getKeys spans + const getKeysSpans = findSpansByOp('cache.get_keys'); + expect(getKeysSpans.length).toBeGreaterThanOrEqual(1); + expect(getKeysSpans[0]?.data).toMatchObject({ + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'cache.get_keys', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.cache.nuxt', + 'db.operation.name': 'getKeys', + 'db.collection.name': 'test-storage', + 'db.system.name': 'memory', + }); + + // Test removeItem spans + const removeItemSpans = findSpansByOp('cache.remove_item'); + expect(removeItemSpans.length).toBeGreaterThanOrEqual(1); + const removeItemSpan = removeItemSpans.find( + span => span.data?.[SEMANTIC_ATTRIBUTE_CACHE_KEY] === prefixKey('batch:1'), + ); + expect(removeItemSpan).toBeDefined(); + expect(removeItemSpan?.data).toMatchObject({ + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'cache.remove_item', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.cache.nuxt', + [SEMANTIC_ATTRIBUTE_CACHE_KEY]: prefixKey('batch:1'), + 'db.operation.name': 'removeItem', + 'db.collection.name': 'test-storage', + 'db.system.name': 'memory', + }); + + // Test clear spans + const clearSpans = findSpansByOp('cache.clear'); + expect(clearSpans.length).toBeGreaterThanOrEqual(1); + expect(clearSpans[0]?.data).toMatchObject({ + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'cache.clear', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.cache.nuxt', + 'db.operation.name': 'clear', + 'db.collection.name': 'test-storage', + 'db.system.name': 'memory', + }); + + // Verify all spans have OK status + const allStorageSpans = transaction.spans?.filter( + span => span.data?.[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN] === 'auto.cache.nuxt', + ); + expect(allStorageSpans?.length).toBeGreaterThan(0); + allStorageSpans?.forEach(span => { + expect(span.status).toBe('ok'); + }); + }); +}); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-5/tests/tracing.cached-html.test.ts b/dev-packages/e2e-tests/test-applications/nuxt-5/tests/tracing.cached-html.test.ts new file mode 100644 index 000000000000..7c7d51af4d4f --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-5/tests/tracing.cached-html.test.ts @@ -0,0 +1,208 @@ +import { expect, test, type Page } from '@playwright/test'; +import { waitForTransaction } from '@sentry-internal/test-utils'; + +test.describe('Rendering Modes with Cached HTML', () => { + test('changes tracing meta tags with multiple requests on Client-Side only page', async ({ page }) => { + await testChangingTracingMetaTagsOnISRPage(page, '/rendering-modes/client-side-only-page', 'Client Side Only Page'); + }); + + test('changes tracing meta tags with multiple requests on ISR-cached page', async ({ page }) => { + await testChangingTracingMetaTagsOnISRPage(page, '/rendering-modes/isr-cached-page', 'ISR Cached Page'); + }); + + test('changes tracing meta tags with multiple requests on 1h ISR-cached page', async ({ page }) => { + await testChangingTracingMetaTagsOnISRPage(page, '/rendering-modes/isr-1h-cached-page', 'ISR 1h Cached Page'); + }); + + // TODO: Make test work with Nuxt 5 + test.skip('exclude tracing meta tags on SWR-cached page', async ({ page }) => { + await testExcludeTracingMetaTagsOnCachedPage(page, '/rendering-modes/swr-cached-page', 'SWR Cached Page'); + }); + + // TODO: Make test work with Nuxt 5 + test.skip('exclude tracing meta tags on SWR 1h cached page', async ({ page }) => { + await testExcludeTracingMetaTagsOnCachedPage(page, '/rendering-modes/swr-1h-cached-page', 'SWR 1h Cached Page'); + }); + + test('exclude tracing meta tags on pre-rendered page', async ({ page }) => { + await testExcludeTracingMetaTagsOnCachedPage(page, '/rendering-modes/pre-rendered-page', 'Pre-Rendered Page'); + }); +}); + +/** + * Tests that tracing meta-tags change with multiple requests on ISR-cached pages + * This utility handles the common pattern of: + * 1. Making two requests to an ISR-cached page + * 2. Verifying tracing meta-tags are present and change between requests + * 3. Verifying distributed tracing works correctly for both requests + * 4. Verifying trace IDs are different between requests + * + * @param page - Playwright page object + * @param routePath - The route path to test (e.g., '/rendering-modes/isr-cached-page') + * @param expectedPageText - The text to verify is visible on the page (e.g., 'ISR Cached Page') + */ +export async function testChangingTracingMetaTagsOnISRPage( + page: Page, + routePath: string, + expectedPageText: string, +): Promise { + // === 1. Request === + const clientTxnEventPromise1 = waitForTransaction('nuxt-5', txnEvent => { + return txnEvent.transaction === routePath; + }); + + const serverTxnEventPromise1 = waitForTransaction('nuxt-5', txnEvent => { + return txnEvent.transaction?.includes(`GET ${routePath}`) ?? false; + }); + + const [_1, clientTxnEvent1, serverTxnEvent1] = await Promise.all([ + page.goto(routePath), + clientTxnEventPromise1, + serverTxnEventPromise1, + expect(page.getByText(expectedPageText, { exact: true })).toBeVisible(), + ]); + + const baggageMetaTagContent1 = await page.locator('meta[name="baggage"]').getAttribute('content'); + const sentryTraceMetaTagContent1 = await page.locator('meta[name="sentry-trace"]').getAttribute('content'); + const [htmlMetaTraceId1] = sentryTraceMetaTagContent1?.split('-') || []; + + // === 2. Request === + + const clientTxnEventPromise2 = waitForTransaction('nuxt-5', txnEvent => { + return txnEvent.transaction === routePath; + }); + + const serverTxnEventPromise2 = waitForTransaction('nuxt-5', txnEvent => { + return txnEvent.transaction?.includes(`GET ${routePath}`) ?? false; + }); + + const [_2, clientTxnEvent2, serverTxnEvent2] = await Promise.all([ + page.goto(routePath), + clientTxnEventPromise2, + serverTxnEventPromise2, + expect(page.getByText(expectedPageText, { exact: true })).toBeVisible(), + ]); + + const baggageMetaTagContent2 = await page.locator('meta[name="baggage"]').getAttribute('content'); + const sentryTraceMetaTagContent2 = await page.locator('meta[name="sentry-trace"]').getAttribute('content'); + const [htmlMetaTraceId2] = sentryTraceMetaTagContent2?.split('-') || []; + + const serverTxnEvent1TraceId = serverTxnEvent1.contexts?.trace?.trace_id; + const serverTxnEvent2TraceId = serverTxnEvent2.contexts?.trace?.trace_id; + + await test.step('Test distributed trace from 1. request', () => { + expect(baggageMetaTagContent1).toContain(`sentry-trace_id=${serverTxnEvent1TraceId}`); + + expect(clientTxnEvent1.contexts?.trace?.trace_id).toBe(serverTxnEvent1TraceId); + expect(clientTxnEvent1.contexts?.trace?.parent_span_id).toBe(serverTxnEvent1.contexts?.trace?.span_id); + expect(serverTxnEvent1.contexts?.trace?.trace_id).toBe(htmlMetaTraceId1); + }); + + await test.step('Test distributed trace from 2. request', () => { + expect(baggageMetaTagContent2).toContain(`sentry-trace_id=${serverTxnEvent2TraceId}`); + + expect(clientTxnEvent2.contexts?.trace?.trace_id).toBe(serverTxnEvent2TraceId); + expect(clientTxnEvent2.contexts?.trace?.parent_span_id).toBe(serverTxnEvent2.contexts?.trace?.span_id); + expect(serverTxnEvent2.contexts?.trace?.trace_id).toBe(htmlMetaTraceId2); + }); + + await test.step('Test that trace IDs from subsequent requests are different', () => { + // Different trace IDs for the server transactions + expect(serverTxnEvent1TraceId).toBeDefined(); + expect(serverTxnEvent1TraceId).not.toBe(serverTxnEvent2TraceId); + expect(serverTxnEvent1TraceId).not.toBe(htmlMetaTraceId2); + }); +} + +/** + * Tests that tracing meta-tags are excluded on cached pages (SWR, pre-rendered, etc.) + * This utility handles the common pattern of: + * 1. Making two requests to a cached page + * 2. Verifying no tracing meta-tags are present + * 3. Verifying only the first request creates a server transaction + * 4. Verifying traces are not distributed + * + * @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') + * @returns Object containing transaction events for additional custom assertions + */ +export async function testExcludeTracingMetaTagsOnCachedPage( + page: Page, + routePath: string, + expectedPageText: string, +): Promise { + // === 1. Request === + const clientTxnEventPromise1 = waitForTransaction('nuxt-5', txnEvent => { + return txnEvent.transaction === routePath; + }); + + // Only the 1. request creates a server transaction + const serverTxnEventPromise1 = waitForTransaction('nuxt-5', txnEvent => { + return txnEvent.transaction?.includes(`GET ${routePath}`) ?? false; + }); + + const [_1, clientTxnEvent1, serverTxnEvent1] = await Promise.all([ + page.goto(routePath), + clientTxnEventPromise1, + serverTxnEventPromise1, + expect(page.getByText(expectedPageText, { exact: true })).toBeVisible(), + ]); + + // Verify no baggage and sentry-trace meta-tags are present on first request + expect(await page.locator('meta[name="baggage"]').count()).toBe(0); + expect(await page.locator('meta[name="sentry-trace"]').count()).toBe(0); + + // === 2. Request === + + await page.goto(routePath); + + const clientTxnEventPromise2 = waitForTransaction('nuxt-5', txnEvent => { + return txnEvent.transaction === routePath; + }); + + let serverTxnEvent2 = undefined; + const serverTxnEventPromise2 = Promise.race([ + waitForTransaction('nuxt-5', txnEvent => { + return txnEvent.transaction?.includes(`GET ${routePath}`) ?? false; + }), + new Promise((_, reject) => setTimeout(() => reject(new Error('No second server transaction expected')), 2000)), + ]); + + try { + serverTxnEvent2 = await serverTxnEventPromise2; + throw new Error('Second server transaction should not have been sent'); + } catch (error) { + expect(error.message).toBe('No second server transaction expected'); + } + + const [clientTxnEvent2] = await Promise.all([ + clientTxnEventPromise2, + expect(page.getByText(expectedPageText, { exact: true })).toBeVisible(), + ]); + + const clientTxnEvent1TraceId = clientTxnEvent1.contexts?.trace?.trace_id; + const clientTxnEvent2TraceId = clientTxnEvent2.contexts?.trace?.trace_id; + + const serverTxnEvent1TraceId = serverTxnEvent1.contexts?.trace?.trace_id; + const serverTxnEvent2TraceId = serverTxnEvent2?.contexts?.trace?.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); + expect(await page.locator('meta[name="sentry-trace"]').count()).toBe(0); + }); + + await test.step('1. Server Transaction and all Client Transactions are defined', () => { + expect(serverTxnEvent1TraceId).toBeDefined(); + expect(clientTxnEvent1TraceId).toBeDefined(); + expect(clientTxnEvent2TraceId).toBeDefined(); + expect(serverTxnEvent2).toBeUndefined(); + expect(serverTxnEvent2TraceId).toBeUndefined(); + }); + + await test.step('Trace is not distributed', () => { + // Cannot create distributed trace as HTML Meta Tags are not added (caching leads to multiple usages of the same server trace id) + expect(clientTxnEvent1TraceId).not.toBe(clientTxnEvent2TraceId); + expect(clientTxnEvent1TraceId).not.toBe(serverTxnEvent1TraceId); + }); +} diff --git a/dev-packages/e2e-tests/test-applications/nuxt-5/tests/tracing.client.test.ts b/dev-packages/e2e-tests/test-applications/nuxt-5/tests/tracing.client.test.ts new file mode 100644 index 000000000000..d4d4b141fa16 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-5/tests/tracing.client.test.ts @@ -0,0 +1,57 @@ +import { expect, test } from '@playwright/test'; +import { waitForTransaction } from '@sentry-internal/test-utils'; +import type { Span } from '@sentry/nuxt'; + +test('sends a pageload root span with a parameterized URL', async ({ page }) => { + const transactionPromise = waitForTransaction('nuxt-5', async transactionEvent => { + return transactionEvent.transaction === '/test-param/:param()'; + }); + + await page.goto(`/test-param/1234`); + + const rootSpan = await transactionPromise; + + expect(rootSpan).toMatchObject({ + contexts: { + trace: { + data: { + 'sentry.source': 'route', + 'sentry.origin': 'auto.pageload.vue', + 'sentry.op': 'pageload', + 'params.param': '1234', + }, + op: 'pageload', + origin: 'auto.pageload.vue', + }, + }, + transaction: '/test-param/:param()', + transaction_info: { + source: 'route', + }, + }); +}); + +test('sends component tracking spans when `trackComponents` is enabled', async ({ page }) => { + const transactionPromise = waitForTransaction('nuxt-5', async transactionEvent => { + return transactionEvent.transaction === '/client-error'; + }); + + await page.goto(`/client-error`); + + const rootSpan = await transactionPromise; + const errorButtonSpan = rootSpan.spans.find((span: Span) => span.description === 'Vue '); + + const expected = { + data: { 'sentry.origin': 'auto.ui.vue', 'sentry.op': 'ui.vue.mount' }, + description: 'Vue ', + op: 'ui.vue.mount', + parent_span_id: expect.stringMatching(/[a-f0-9]{16}/), + span_id: expect.stringMatching(/[a-f0-9]{16}/), + start_timestamp: expect.any(Number), + timestamp: expect.any(Number), + trace_id: expect.stringMatching(/[a-f0-9]{32}/), + origin: 'auto.ui.vue', + }; + + expect(errorButtonSpan).toMatchObject(expected); +}); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-5/tests/tracing.server.test.ts b/dev-packages/e2e-tests/test-applications/nuxt-5/tests/tracing.server.test.ts new file mode 100644 index 000000000000..ebd367d96031 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-5/tests/tracing.server.test.ts @@ -0,0 +1,64 @@ +import { expect, test } from '@playwright/test'; +import { waitForError, waitForTransaction } from '@sentry-internal/test-utils'; +import { SEMANTIC_ATTRIBUTE_SENTRY_OP, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN } from '@sentry/nuxt'; + +test('sends a server action transaction on pageload', async ({ page }) => { + const transactionPromise = waitForTransaction('nuxt-5', transactionEvent => { + return transactionEvent.transaction.includes('GET /test-param/'); + }); + + await page.goto('/test-param/1234'); + + const transaction = await transactionPromise; + + expect(transaction.contexts.trace).toEqual( + expect.objectContaining({ + data: expect.objectContaining({ + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'http.server', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.http.otel.http', + }), + }), + ); +}); + +test('does not send transactions for build asset folder "_nuxt"', async ({ page }) => { + let buildAssetFolderOccurred = false; + + waitForTransaction('nuxt-5', transactionEvent => { + if (transactionEvent.transaction?.match(/^GET \/_nuxt\//)) { + buildAssetFolderOccurred = true; + } + return false; // expects to return a boolean (but not relevant here) + }); + + const transactionEventPromise = waitForTransaction('nuxt-5', transactionEvent => { + return transactionEvent.transaction.includes('GET /test-param/'); + }); + + await page.goto('/test-param/1234'); + + const transactionEvent = await transactionEventPromise; + + expect(buildAssetFolderOccurred).toBe(false); + + expect(transactionEvent.transaction).toBe('GET /test-param/:param()'); +}); + +// TODO: Make test work with Nuxt 5 +test.skip('captures server API calls made with Nitro $fetch', async ({ page }) => { + const transactionPromise = waitForTransaction('nuxt-5', async transactionEvent => { + return transactionEvent.transaction === 'GET /api/nitro-fetch'; + }); + + await page.goto(`/fetch-server-routes`); + await page.getByText('Fetch Nitro $fetch', { exact: true }).click(); + + const httpServerFetchSpan = await transactionPromise; + const httpClientSpan = httpServerFetchSpan.spans.find(span => span.description === 'GET https://example.com/'); + + expect(httpServerFetchSpan.transaction).toEqual('GET /api/nitro-fetch'); + expect(httpServerFetchSpan.contexts.trace.op).toEqual('http.server'); + + expect(httpClientSpan.parent_span_id).toEqual(httpServerFetchSpan.contexts.trace.span_id); + expect(httpClientSpan.op).toEqual('http.client'); +}); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-5/tests/tracing.test.ts b/dev-packages/e2e-tests/test-applications/nuxt-5/tests/tracing.test.ts new file mode 100644 index 000000000000..e136d5635a29 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-5/tests/tracing.test.ts @@ -0,0 +1,158 @@ +import { expect, test } from '@playwright/test'; +import { waitForTransaction } from '@sentry-internal/test-utils'; + +test.describe('distributed tracing', () => { + const PARAM = 's0me-param'; + + test('capture a distributed pageload trace', async ({ page }) => { + const clientTxnEventPromise = waitForTransaction('nuxt-5', txnEvent => { + return txnEvent.transaction === '/test-param/:param()'; + }); + + const serverTxnEventPromise = waitForTransaction('nuxt-5', txnEvent => { + return txnEvent.transaction?.includes('GET /test-param/') ?? false; + }); + + const [_, clientTxnEvent, serverTxnEvent] = await Promise.all([ + page.goto(`/test-param/${PARAM}`), + clientTxnEventPromise, + serverTxnEventPromise, + expect(page.getByText(`Param: ${PARAM}`)).toBeVisible(), + ]); + + 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`); + expect(baggageMetaTagContent).toContain(`sentry-trace_id=${serverTxnEvent.contexts?.trace?.trace_id}`); + expect(baggageMetaTagContent).toContain('sentry-sampled=true'); + expect(baggageMetaTagContent).toContain('sentry-sample_rate=1'); + + const sentryTraceMetaTagContent = await page.locator('meta[name="sentry-trace"]').getAttribute('content'); + const [metaTraceId, metaParentSpanId, metaSampled] = sentryTraceMetaTagContent?.split('-') || []; + + expect(metaSampled).toBe('1'); + + expect(clientTxnEvent).toMatchObject({ + transaction: '/test-param/:param()', + transaction_info: { source: 'route' }, + type: 'transaction', + contexts: { + trace: { + op: 'pageload', + origin: 'auto.pageload.vue', + trace_id: metaTraceId, + parent_span_id: metaParentSpanId, + }, + }, + }); + + expect(serverTxnEvent).toMatchObject({ + transaction: `GET /test-param/:param()`, // parametrized route + transaction_info: { source: 'route' }, + type: 'transaction', + contexts: { + trace: { + op: 'http.server', + origin: 'auto.http.otel.http', + }, + }, + }); + + // connected trace + expect(clientTxnEvent.contexts?.trace?.trace_id).toBeDefined(); + expect(clientTxnEvent.contexts?.trace?.parent_span_id).toBeDefined(); + + expect(clientTxnEvent.contexts?.trace?.trace_id).toBe(serverTxnEvent.contexts?.trace?.trace_id); + expect(clientTxnEvent.contexts?.trace?.parent_span_id).toBe(serverTxnEvent.contexts?.trace?.span_id); + expect(serverTxnEvent.contexts?.trace?.trace_id).toBe(metaTraceId); + }); + + // TODO: Make test work with Nuxt 5 + test.skip('capture a distributed trace from a client-side API request with parametrized routes', async ({ page }) => { + const clientTxnEventPromise = waitForTransaction('nuxt-5', txnEvent => { + return txnEvent.transaction === '/test-param/user/:userId()'; + }); + const ssrTxnEventPromise = waitForTransaction('nuxt-5', txnEvent => { + return txnEvent.transaction?.includes('GET /test-param/user') ?? false; + }); + const serverReqTxnEventPromise = waitForTransaction('nuxt-5', txnEvent => { + return txnEvent.transaction?.includes('GET /api/user/') ?? false; + }); + + // Navigate to the page which will trigger an API call from the client-side + await page.goto(`/test-param/user/${PARAM}`); + + const [clientTxnEvent, ssrTxnEvent, serverReqTxnEvent] = await Promise.all([ + clientTxnEventPromise, + ssrTxnEventPromise, + serverReqTxnEventPromise, + ]); + + const httpClientSpan = clientTxnEvent?.spans?.find(span => span.description === `GET /api/user/${PARAM}`); + + expect(clientTxnEvent).toEqual( + expect.objectContaining({ + type: 'transaction', + transaction: '/test-param/user/:userId()', // parametrized route + transaction_info: { source: 'route' }, + contexts: expect.objectContaining({ + trace: expect.objectContaining({ + op: 'pageload', + origin: 'auto.pageload.vue', + }), + }), + }), + ); + + expect(httpClientSpan).toBeDefined(); + expect(httpClientSpan).toEqual( + expect.objectContaining({ + description: `GET /api/user/${PARAM}`, // fixme: parametrize + parent_span_id: clientTxnEvent.contexts?.trace?.span_id, // pageload span is parent + data: expect.objectContaining({ + url: `/api/user/${PARAM}`, + type: 'fetch', + 'sentry.op': 'http.client', + 'sentry.origin': 'auto.http.browser', + 'http.method': 'GET', + }), + }), + ); + + expect(ssrTxnEvent).toEqual( + expect.objectContaining({ + type: 'transaction', + transaction: `GET /test-param/user/:userId()`, // parametrized route + transaction_info: { source: 'route' }, + contexts: expect.objectContaining({ + trace: expect.objectContaining({ + op: 'http.server', + origin: 'auto.http.otel.http', + }), + }), + }), + ); + + expect(serverReqTxnEvent).toEqual( + expect.objectContaining({ + type: 'transaction', + transaction: `GET /api/user/:userId`, // parametrized route + transaction_info: { source: 'route' }, + contexts: expect.objectContaining({ + trace: expect.objectContaining({ + op: 'http.server', + origin: 'auto.http.otel.http', + parent_span_id: httpClientSpan?.span_id, // http.client span is parent + }), + }), + }), + ); + + // All 3 transactions and the http.client span should share the same trace_id + expect(clientTxnEvent.contexts?.trace?.trace_id).toBeDefined(); + expect(clientTxnEvent.contexts?.trace?.trace_id).toBe(httpClientSpan?.trace_id); + expect(clientTxnEvent.contexts?.trace?.trace_id).toBe(ssrTxnEvent.contexts?.trace?.trace_id); + expect(clientTxnEvent.contexts?.trace?.trace_id).toBe(serverReqTxnEvent.contexts?.trace?.trace_id); + }); +}); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-5/tsconfig.json b/dev-packages/e2e-tests/test-applications/nuxt-5/tsconfig.json new file mode 100644 index 000000000000..a746f2a70c28 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-5/tsconfig.json @@ -0,0 +1,4 @@ +{ + // https://nuxt.com/docs/guide/concepts/typescript + "extends": "./.nuxt/tsconfig.json" +} diff --git a/packages/nuxt/package.json b/packages/nuxt/package.json index 1ec523450789..322dc969ff0d 100644 --- a/packages/nuxt/package.json +++ b/packages/nuxt/package.json @@ -45,8 +45,8 @@ "access": "public" }, "peerDependencies": { - "nuxt": ">=3.7.0 || 4.x", - "nitro": "3.x" + "nuxt": ">=3.7.0 || 4.x || 5.x", + "nitro": "2.x || 3.x" }, "peerDependenciesMeta": { "nitro": { @@ -62,7 +62,8 @@ "@sentry/node-core": "10.45.0", "@sentry/rollup-plugin": "^5.1.1", "@sentry/vite-plugin": "^5.1.0", - "@sentry/vue": "10.45.0" + "@sentry/vue": "10.45.0", + "local-pkg": "^1.1.2" }, "devDependencies": { "@nuxt/module-builder": "^0.8.4", diff --git a/packages/nuxt/src/module.ts b/packages/nuxt/src/module.ts index 490fde751473..0c1e43031742 100644 --- a/packages/nuxt/src/module.ts +++ b/packages/nuxt/src/module.ts @@ -15,7 +15,7 @@ import { addDatabaseInstrumentation } from './vite/databaseConfig'; import { addMiddlewareImports, addMiddlewareInstrumentation } from './vite/middlewareConfig'; import { setupSourceMaps } from './vite/sourceMaps'; import { addStorageInstrumentation } from './vite/storageConfig'; -import { addOTelCommonJSImportAlias, findDefaultSdkInitFile } from './vite/utils'; +import { addOTelCommonJSImportAlias, findDefaultSdkInitFile, getNitroMajorVersion } from './vite/utils'; export type ModuleOptions = SentryNuxtModuleOptions; @@ -28,7 +28,7 @@ export default defineNuxtModule({ }, }, defaults: {}, - setup(moduleOptionsParam, nuxt) { + async setup(moduleOptionsParam, nuxt) { if (moduleOptionsParam?.enabled === false) { return; } @@ -78,22 +78,33 @@ export default defineNuxtModule({ } const serverConfigFile = findDefaultSdkInitFile('server', nuxt); + const isNitroV3 = (await getNitroMajorVersion()) >= 3; if (serverConfigFile) { - addServerPlugin(moduleDirResolver.resolve('./runtime/plugins/handler-legacy.server')); + if (isNitroV3) { + addServerPlugin(moduleDirResolver.resolve('./runtime/plugins/handler.server')); + } else { + addServerPlugin(moduleDirResolver.resolve('./runtime/plugins/handler-legacy.server')); + } + addServerPlugin(moduleDirResolver.resolve('./runtime/plugins/sentry.server')); addPlugin({ src: moduleDirResolver.resolve('./runtime/plugins/route-detector.server'), mode: 'server', }); + + // Preps the middleware instrumentation module. + addMiddlewareImports(); + addStorageInstrumentation(nuxt, !isNitroV3); + addDatabaseInstrumentation(nuxt.options.nitro, !isNitroV3, moduleOptions); } if (clientConfigFile || serverConfigFile) { setupSourceMaps(moduleOptions, nuxt, addVitePlugin); } - addOTelCommonJSImportAlias(nuxt); + addOTelCommonJSImportAlias(nuxt, isNitroV3); const pagesDataTemplate = addTemplate({ filename: 'sentry--nuxt-pages-data.mjs', @@ -115,13 +126,6 @@ export default defineNuxtModule({ }; }); - // Preps the the middleware instrumentation module. - if (serverConfigFile) { - addMiddlewareImports(); - addStorageInstrumentation(nuxt); - addDatabaseInstrumentation(nuxt.options.nitro, moduleOptions); - } - // Add the sentry config file to the include array nuxt.hook('prepare:types', options => { const tsConfig = options.tsConfig as { include?: string[] }; @@ -147,7 +151,7 @@ export default defineNuxtModule({ return; } - if (serverConfigFile) { + if (serverConfigFile && !isNitroV3) { addMiddlewareInstrumentation(nitro); } diff --git a/packages/nuxt/src/runtime/plugins/sentry.server.ts b/packages/nuxt/src/runtime/plugins/sentry.server.ts index e3bf7854e673..7ea91e36cf25 100644 --- a/packages/nuxt/src/runtime/plugins/sentry.server.ts +++ b/packages/nuxt/src/runtime/plugins/sentry.server.ts @@ -13,11 +13,18 @@ export default (nitroApp => { // @ts-expect-error - 'render:html' is a valid hook name in the Nuxt context nitroApp.hooks.hook('render:html', (html: NuxtRenderHTMLContext, { event }: { event: H3Event }) => { - const headers = event.node.res?.getHeaders() || {}; + // h3 v1 (Nuxt 4): event.node.res.getHeaders(); h3 v2 (Nuxt 5): event.node is undefined + const nodeResHeadersH3v1 = event.node?.res?.getHeaders() || {}; - const isPreRenderedPage = Object.keys(headers).includes('x-nitro-prerender'); - // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access - const isSWRCachedPage = event?.context?.cache?.options.swr as boolean | undefined; + // 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 = event?.context?.cache?.options?.swr as boolean | undefined; if (!isPreRenderedPage && !isSWRCachedPage) { addSentryTracingMetaTags(html.head); diff --git a/packages/nuxt/src/vite/databaseConfig.ts b/packages/nuxt/src/vite/databaseConfig.ts index 5b8bf008421d..b025157339b3 100644 --- a/packages/nuxt/src/vite/databaseConfig.ts +++ b/packages/nuxt/src/vite/databaseConfig.ts @@ -7,7 +7,11 @@ import { addServerTemplate } from '../vendor/server-template'; /** * Sets up the database instrumentation. */ -export function addDatabaseInstrumentation(nitro: NitroConfig, moduleOptions?: SentryNuxtModuleOptions): void { +export function addDatabaseInstrumentation( + nitro: NitroConfig, + isLegacyNitro: boolean, + moduleOptions?: SentryNuxtModuleOptions, +): void { if (!nitro.experimental?.database) { // We cannot use DEBUG_BUILD here because it is a runtime flag, so it is not available for build time scripts // So we have to pass in the module options to the build time script @@ -38,5 +42,9 @@ export function addDatabaseInstrumentation(nitro: NitroConfig, moduleOptions?: S }, }); - addServerPlugin(createResolver(import.meta.url).resolve('./runtime/plugins/database-legacy.server')); + if (isLegacyNitro) { + addServerPlugin(createResolver(import.meta.url).resolve('./runtime/plugins/database-legacy.server')); + } else { + addServerPlugin(createResolver(import.meta.url).resolve('./runtime/plugins/database.server')); + } } diff --git a/packages/nuxt/src/vite/storageConfig.ts b/packages/nuxt/src/vite/storageConfig.ts index 87f843f05796..393f8b12e59b 100644 --- a/packages/nuxt/src/vite/storageConfig.ts +++ b/packages/nuxt/src/vite/storageConfig.ts @@ -5,7 +5,7 @@ import { addServerTemplate } from '../vendor/server-template'; /** * Prepares the storage config export to be used in the runtime storage instrumentation. */ -export function addStorageInstrumentation(nuxt: Nuxt): void { +export function addStorageInstrumentation(nuxt: Nuxt, isLegacyNitro: boolean): void { const moduleDirResolver = createResolver(import.meta.url); const userStorageMounts = Object.keys(nuxt.options.nitro.storage || {}); @@ -17,5 +17,9 @@ export function addStorageInstrumentation(nuxt: Nuxt): void { }, }); - addServerPlugin(moduleDirResolver.resolve('./runtime/plugins/storage-legacy.server')); + if (isLegacyNitro) { + addServerPlugin(moduleDirResolver.resolve('./runtime/plugins/storage-legacy.server')); + } else { + addServerPlugin(moduleDirResolver.resolve('./runtime/plugins/storage.server')); + } } diff --git a/packages/nuxt/src/vite/utils.ts b/packages/nuxt/src/vite/utils.ts index 6b1092a952bc..86eafaae2c9b 100644 --- a/packages/nuxt/src/vite/utils.ts +++ b/packages/nuxt/src/vite/utils.ts @@ -3,6 +3,24 @@ import { consoleSandbox } from '@sentry/core'; import * as fs from 'fs'; import * as path from 'path'; +/** + * Gets the major version of the installed nitro package. + * Returns 2 as the default if nitro is not found or the version cannot be determined. + */ +export async function getNitroMajorVersion(): Promise { + try { + const { getPackageInfo } = await import('local-pkg'); + const info = await getPackageInfo('nitro'); + if (info?.version) { + const major = parseInt(info.version.split('.')[0] ?? '2', 10); + return isNaN(major) ? 2 : major; + } + } catch { + // If local-pkg is unavailable or nitro is not found, default to v2 + } + return 2; +} + /** * Find the default SDK init file for the given type (client or server). * The sentry.server.config file is prioritized over the instrument.server file. @@ -190,8 +208,8 @@ export function constructFunctionReExport(pathWithQuery: string, entryId: string * * @see https://nuxt.com/docs/guide/concepts/esm#aliasing-libraries */ -export function addOTelCommonJSImportAlias(nuxt: Nuxt): void { - if (!nuxt.options.dev) { +export function addOTelCommonJSImportAlias(nuxt: Nuxt, isNitroV3 = false): void { + if (!nuxt.options.dev || isNitroV3) { return; } diff --git a/packages/nuxt/test/vite/databaseConfig.test.ts b/packages/nuxt/test/vite/databaseConfig.test.ts index 4d95fc7a4df0..e987ab3984a5 100644 --- a/packages/nuxt/test/vite/databaseConfig.test.ts +++ b/packages/nuxt/test/vite/databaseConfig.test.ts @@ -34,7 +34,7 @@ describe('addDatabaseInstrumentation', () => { const nitroConfig: NitroConfig = {}; const moduleOptions: SentryNuxtModuleOptions = { debug: true }; - addDatabaseInstrumentation(nitroConfig, moduleOptions); + addDatabaseInstrumentation(nitroConfig, false, moduleOptions); expect(consoleLogSpy).toHaveBeenCalledWith( '[Sentry] [Nitro Database Plugin]: No database configuration found. Skipping database instrumentation.', @@ -45,7 +45,7 @@ describe('addDatabaseInstrumentation', () => { const nitroConfig: NitroConfig = {}; const moduleOptions: SentryNuxtModuleOptions = { debug: false }; - addDatabaseInstrumentation(nitroConfig, moduleOptions); + addDatabaseInstrumentation(nitroConfig, false, moduleOptions); expect(consoleLogSpy).not.toHaveBeenCalled(); }); @@ -53,7 +53,7 @@ describe('addDatabaseInstrumentation', () => { it('should not log debug message when moduleOptions is undefined', () => { const nitroConfig: NitroConfig = {}; - addDatabaseInstrumentation(nitroConfig, undefined); + addDatabaseInstrumentation(nitroConfig, false, undefined); expect(consoleLogSpy).not.toHaveBeenCalled(); }); @@ -62,7 +62,7 @@ describe('addDatabaseInstrumentation', () => { const nitroConfig: NitroConfig = {}; const moduleOptions: SentryNuxtModuleOptions = {}; - addDatabaseInstrumentation(nitroConfig, moduleOptions); + addDatabaseInstrumentation(nitroConfig, false, moduleOptions); expect(consoleLogSpy).not.toHaveBeenCalled(); }); @@ -71,7 +71,7 @@ describe('addDatabaseInstrumentation', () => { const nitroConfig: NitroConfig = { experimental: { database: false } }; const moduleOptions: SentryNuxtModuleOptions = { debug: true }; - addDatabaseInstrumentation(nitroConfig, moduleOptions); + addDatabaseInstrumentation(nitroConfig, false, moduleOptions); expect(consoleLogSpy).toHaveBeenCalledWith( '[Sentry] [Nitro Database Plugin]: No database configuration found. Skipping database instrumentation.', diff --git a/packages/nuxt/test/vite/utils.test.ts b/packages/nuxt/test/vite/utils.test.ts index 4911a06b6f2f..2be73259305a 100644 --- a/packages/nuxt/test/vite/utils.test.ts +++ b/packages/nuxt/test/vite/utils.test.ts @@ -427,4 +427,14 @@ describe('addOTelCommonJSImportAlias', () => { expect(nuxtMock.options.alias).toBeUndefined(); }); + + it('does not add alias when in Nitro v3+ (Rolldown incompatibility)', () => { + const nuxtMock: Nuxt = { + options: { dev: true }, + } as unknown as Nuxt; + + addOTelCommonJSImportAlias(nuxtMock, true); + + expect(nuxtMock.options.alias).toBeUndefined(); + }); });