diff --git a/dev-packages/e2e-tests/test-applications/nuxt-3/nuxt.config.ts b/dev-packages/e2e-tests/test-applications/nuxt-3/nuxt.config.ts index 8ea55702863c..8f920a41e76e 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-3/nuxt.config.ts +++ b/dev-packages/e2e-tests/test-applications/nuxt-3/nuxt.config.ts @@ -12,6 +12,23 @@ export default defineNuxtConfig({ }, }, 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-3/package.json b/dev-packages/e2e-tests/test-applications/nuxt-3/package.json index b38943d6e3eb..bbf0ced23c12 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-3/package.json +++ b/dev-packages/e2e-tests/test-applications/nuxt-3/package.json @@ -33,6 +33,7 @@ ] }, "volta": { - "extends": "../../package.json" + "extends": "../../package.json", + "node": "22.20.0" } } diff --git a/dev-packages/e2e-tests/test-applications/nuxt-3/server/api/db-multi-test.ts b/dev-packages/e2e-tests/test-applications/nuxt-3/server/api/db-multi-test.ts new file mode 100644 index 000000000000..383617421ec7 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-3/server/api/db-multi-test.ts @@ -0,0 +1,102 @@ +import { defineEventHandler, getQuery, useDatabase } from '#imports'; + +export default defineEventHandler(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-3/server/api/db-test.ts b/dev-packages/e2e-tests/test-applications/nuxt-3/server/api/db-test.ts new file mode 100644 index 000000000000..2241afdee14d --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-3/server/api/db-test.ts @@ -0,0 +1,70 @@ +import { defineEventHandler, getQuery, useDatabase } from '#imports'; + +export default defineEventHandler(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-3/tests/database-multi.test.ts b/dev-packages/e2e-tests/test-applications/nuxt-3/tests/database-multi.test.ts new file mode 100644 index 000000000000..a229b4db34cb --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-3/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-3', 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-3', 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-3', 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-3', 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-3', 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-3', 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-3/tests/database.test.ts b/dev-packages/e2e-tests/test-applications/nuxt-3/tests/database.test.ts new file mode 100644 index 000000000000..ecb0e32133db --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-3/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-3', 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-3', 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-3', 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-3', 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-3', 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-3', 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-3', errorEvent => { + return !!errorEvent?.exception?.values?.[0]?.value?.includes('no such table'); + }); + + const transactionPromise = waitForTransaction('nuxt-3', 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-3', 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-3', 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-4/nuxt.config.ts b/dev-packages/e2e-tests/test-applications/nuxt-4/nuxt.config.ts index 50924877649d..c7acd2b12328 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-4/nuxt.config.ts +++ b/dev-packages/e2e-tests/test-applications/nuxt-4/nuxt.config.ts @@ -13,7 +13,6 @@ export default defineNuxtConfig({ }, modules: ['@pinia/nuxt', '@sentry/nuxt/module'], - runtimeConfig: { public: { sentry: { @@ -22,6 +21,23 @@ export default defineNuxtConfig({ }, }, 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-4/package.json b/dev-packages/e2e-tests/test-applications/nuxt-4/package.json index b16b7ee2b236..a5d36c1f6a61 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-4/package.json +++ b/dev-packages/e2e-tests/test-applications/nuxt-4/package.json @@ -25,7 +25,8 @@ "@sentry-internal/test-utils": "link:../../../test-utils" }, "volta": { - "extends": "../../package.json" + "extends": "../../package.json", + "node": "22.20.0" }, "sentryTest": { "optionalVariants": [ diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4/server/api/db-multi-test.ts b/dev-packages/e2e-tests/test-applications/nuxt-4/server/api/db-multi-test.ts new file mode 100644 index 000000000000..53f110c1ce28 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-4/server/api/db-multi-test.ts @@ -0,0 +1,102 @@ +import { useDatabase, defineEventHandler, getQuery } from '#imports'; + +export default defineEventHandler(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-4/server/api/db-test.ts b/dev-packages/e2e-tests/test-applications/nuxt-4/server/api/db-test.ts new file mode 100644 index 000000000000..4460758ab414 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-4/server/api/db-test.ts @@ -0,0 +1,70 @@ +import { useDatabase, defineEventHandler, getQuery } from '#imports'; + +export default defineEventHandler(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-4/tests/database-multi.test.ts b/dev-packages/e2e-tests/test-applications/nuxt-4/tests/database-multi.test.ts new file mode 100644 index 000000000000..9d995fa1b37c --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-4/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-4', 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-4', 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-4', 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-4', 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-4', 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-4', 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-4/tests/database.test.ts b/dev-packages/e2e-tests/test-applications/nuxt-4/tests/database.test.ts new file mode 100644 index 000000000000..9b9fdd892563 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-4/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-4', 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-4', 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-4', 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-4', 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-4', 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-4', 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-4', errorEvent => { + return !!errorEvent?.exception?.values?.[0]?.value?.includes('no such table'); + }); + + const transactionPromise = waitForTransaction('nuxt-4', 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-4', 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-4', 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/packages/nuxt/src/module.ts b/packages/nuxt/src/module.ts index 947eb2710f4d..e6adc562fe44 100644 --- a/packages/nuxt/src/module.ts +++ b/packages/nuxt/src/module.ts @@ -10,6 +10,7 @@ import { consoleSandbox } from '@sentry/core'; import * as path from 'path'; import type { SentryNuxtModuleOptions } from './common/types'; import { addDynamicImportEntryFileWrapper, addSentryTopImport, addServerConfigToBuild } from './vite/addServerConfig'; +import { addDatabaseInstrumentation } from './vite/databaseConfig'; import { addMiddlewareImports, addMiddlewareInstrumentation } from './vite/middlewareConfig'; import { setupSourceMaps } from './vite/sourceMaps'; import { addStorageInstrumentation } from './vite/storageConfig'; @@ -128,6 +129,7 @@ export default defineNuxtModule({ if (serverConfigFile) { addMiddlewareImports(); addStorageInstrumentation(nuxt); + addDatabaseInstrumentation(nuxt.options.nitro); } nuxt.hooks.hook('nitro:init', nitro => { diff --git a/packages/nuxt/src/runtime/plugins/database.server.ts b/packages/nuxt/src/runtime/plugins/database.server.ts new file mode 100644 index 000000000000..9cdff58d336e --- /dev/null +++ b/packages/nuxt/src/runtime/plugins/database.server.ts @@ -0,0 +1,232 @@ +import { + type Span, + type StartSpanOptions, + addBreadcrumb, + captureException, + debug, + flushIfServerless, + SEMANTIC_ATTRIBUTE_SENTRY_OP, + SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, + SPAN_STATUS_ERROR, + startSpan, +} from '@sentry/core'; +import type { Database, PreparedStatement } from 'db0'; +// eslint-disable-next-line import/no-extraneous-dependencies +import { defineNitroPlugin, useDatabase } from 'nitropack/runtime'; +import type { DatabaseConnectionConfig as DatabaseConfig } from 'nitropack/types'; +// @ts-expect-error - This is a virtual module +import { databaseConfig } from '#sentry/database-config.mjs'; +import { type DatabaseSpanData, getDatabaseSpanData } from '../utils/database-span-data'; + +type MaybeInstrumentedDatabase = Database & { + __sentry_instrumented__?: boolean; +}; + +/** + * Keeps track of prepared statements that have been patched. + */ +const patchedStatement = new WeakSet(); + +/** + * The Sentry origin for the database plugin. + */ +const SENTRY_ORIGIN = 'auto.db.nuxt'; + +/** + * Creates a Nitro plugin that instruments the database calls. + */ +export default defineNitroPlugin(() => { + try { + const _databaseConfig = databaseConfig as Record; + const databaseInstances = Object.keys(databaseConfig); + debug.log('[Nitro Database Plugin]: Instrumenting databases...'); + + for (const instance of databaseInstances) { + debug.log('[Nitro Database Plugin]: Instrumenting database instance:', instance); + const db = useDatabase(instance); + instrumentDatabase(db, _databaseConfig[instance]); + } + + debug.log('[Nitro Database Plugin]: Databases instrumented.'); + } catch (error) { + // During build time, we can't use the useDatabase function, so we just log an error. + if (error instanceof Error && /Cannot access 'instances'/.test(error.message)) { + debug.log('[Nitro Database Plugin]: Database instrumentation skipped during build time.'); + return; + } + + debug.error('[Nitro Database Plugin]: Failed to instrument database:', error); + } +}); + +/** + * Instruments a database instance with Sentry. + */ +function instrumentDatabase(db: MaybeInstrumentedDatabase, config?: DatabaseConfig): void { + if (db.__sentry_instrumented__) { + debug.log('[Nitro Database Plugin]: Database already instrumented. Skipping...'); + return; + } + + const metadata: DatabaseSpanData = { + 'db.system.name': config?.connector ?? db.dialect, + ...getDatabaseSpanData(config), + }; + + db.prepare = new Proxy(db.prepare, { + apply(target, thisArg, args: Parameters) { + const [query] = args; + + return instrumentPreparedStatement(target.apply(thisArg, args), query, metadata); + }, + }); + + // Sadly the `.sql` template tag doesn't call `db.prepare` internally and it calls the connector's `.prepare` directly + // So we have to patch it manually, and would mean we would have less info in the spans. + // https://github.com/unjs/db0/blob/main/src/database.ts#L64 + db.sql = new Proxy(db.sql, { + apply(target, thisArg, args: Parameters) { + const query = args[0]?.[0] ?? ''; + const opts = createStartSpanOptions(query, metadata); + + return startSpan( + opts, + handleSpanStart(() => target.apply(thisArg, args)), + ); + }, + }); + + db.exec = new Proxy(db.exec, { + apply(target, thisArg, args: Parameters) { + return startSpan( + createStartSpanOptions(args[0], metadata), + handleSpanStart(() => target.apply(thisArg, args), { query: args[0] }), + ); + }, + }); + + db.__sentry_instrumented__ = true; +} + +/** + * Instruments a DB prepared statement with Sentry. + * + * This is meant to be used as a top-level call, under the hood it calls `instrumentPreparedStatementQueries` + * to patch the query methods. The reason for this abstraction is to ensure that the `bind` method is also patched. + */ +function instrumentPreparedStatement( + statement: PreparedStatement, + query: string, + data: DatabaseSpanData, +): PreparedStatement { + // statement.bind() returns a new instance of D1PreparedStatement, so we have to patch it as well. + // eslint-disable-next-line @typescript-eslint/unbound-method + statement.bind = new Proxy(statement.bind, { + apply(target, thisArg, args: Parameters) { + return instrumentPreparedStatementQueries(target.apply(thisArg, args), query, data); + }, + }); + + return instrumentPreparedStatementQueries(statement, query, data); +} + +/** + * Patches the query methods of a DB prepared statement with Sentry. + */ +function instrumentPreparedStatementQueries( + statement: PreparedStatement, + query: string, + data: DatabaseSpanData, +): PreparedStatement { + if (patchedStatement.has(statement)) { + return statement; + } + + // eslint-disable-next-line @typescript-eslint/unbound-method + statement.get = new Proxy(statement.get, { + apply(target, thisArg, args: Parameters) { + return startSpan( + createStartSpanOptions(query, data), + handleSpanStart(() => target.apply(thisArg, args), { query }), + ); + }, + }); + + // eslint-disable-next-line @typescript-eslint/unbound-method + statement.run = new Proxy(statement.run, { + apply(target, thisArg, args: Parameters) { + return startSpan( + createStartSpanOptions(query, data), + handleSpanStart(() => target.apply(thisArg, args), { query }), + ); + }, + }); + + // eslint-disable-next-line @typescript-eslint/unbound-method + statement.all = new Proxy(statement.all, { + apply(target, thisArg, args: Parameters) { + return startSpan( + createStartSpanOptions(query, data), + handleSpanStart(() => target.apply(thisArg, args), { query }), + ); + }, + }); + + patchedStatement.add(statement); + + return statement; +} + +/** + * Creates a span start callback handler + */ +function handleSpanStart(fn: () => unknown, breadcrumbOpts?: { query: string }) { + return async (span: Span) => { + try { + const result = await fn(); + if (breadcrumbOpts) { + createBreadcrumb(breadcrumbOpts.query); + } + + return result; + } catch (error) { + span.setStatus({ code: SPAN_STATUS_ERROR, message: 'internal_error' }); + captureException(error, { + mechanism: { + handled: false, + type: SENTRY_ORIGIN, + }, + }); + + // Re-throw the error to be handled by the caller + throw error; + } finally { + await flushIfServerless(); + } + }; +} + +function createBreadcrumb(query: string): void { + addBreadcrumb({ + category: 'query', + message: query, + data: { + 'db.query.text': query, + }, + }); +} + +/** + * Creates a start span options object. + */ +function createStartSpanOptions(query: string, data: DatabaseSpanData): StartSpanOptions { + return { + name: query, + attributes: { + 'db.query.text': query, + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: SENTRY_ORIGIN, + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'db.query', + ...data, + }, + }; +} diff --git a/packages/nuxt/src/runtime/utils/database-span-data.ts b/packages/nuxt/src/runtime/utils/database-span-data.ts new file mode 100644 index 000000000000..e5d9c8dc7cec --- /dev/null +++ b/packages/nuxt/src/runtime/utils/database-span-data.ts @@ -0,0 +1,46 @@ +import type { ConnectorName } from 'db0'; +import type { DatabaseConnectionConfig as DatabaseConfig } from 'nitropack/types'; + +export interface DatabaseSpanData { + [key: string]: string | number | undefined; +} + +/** + * Extracts span attributes from the database configuration. + */ +export function getDatabaseSpanData(config?: DatabaseConfig): Partial { + try { + if (!config?.connector) { + // Default to SQLite if no connector is configured + return { + 'db.namespace': 'db.sqlite', + }; + } + + if (config.connector === 'postgresql' || config.connector === 'mysql2') { + return { + 'server.address': config.options?.host, + 'server.port': config.options?.port, + }; + } + + if (config.connector === 'pglite') { + return { + 'db.namespace': config.options?.dataDir, + }; + } + + if ((['better-sqlite3', 'bun', 'sqlite', 'sqlite3'] as ConnectorName[]).includes(config.connector)) { + return { + // DB is the default file name in nitro for sqlite-like connectors + 'db.namespace': `${config.options?.name ?? 'db'}.sqlite`, + }; + } + + return {}; + } catch { + // This is a best effort to get some attributes, so it is not an absolute must + // Since the user can configure invalid options, we should not fail the whole instrumentation. + return {}; + } +} diff --git a/packages/nuxt/src/vite/databaseConfig.ts b/packages/nuxt/src/vite/databaseConfig.ts new file mode 100644 index 000000000000..dfe27fd9821d --- /dev/null +++ b/packages/nuxt/src/vite/databaseConfig.ts @@ -0,0 +1,38 @@ +import { addServerPlugin, createResolver } from '@nuxt/kit'; +import { consoleSandbox } from '@sentry/core'; +import type { NitroConfig } from 'nitropack/types'; +import { addServerTemplate } from '../vendor/server-template'; + +/** + * Sets up the database instrumentation. + */ +export function addDatabaseInstrumentation(nitro: NitroConfig): void { + if (!nitro.experimental?.database) { + consoleSandbox(() => { + // eslint-disable-next-line no-console + console.log( + '[Sentry] [Nitro Database Plugin]: No database configuration found. Skipping database instrumentation.', + ); + }); + + return; + } + + /** + * This is a different option than the one in `experimental.database`, this configures multiple database instances. + * keys represent database names to be passed to `useDatabase(name?)`. + * We also use the config to populate database span attributes. + * https://nitro.build/guide/database#configuration + */ + const databaseConfig = nitro.database || { default: {} }; + + // Create a virtual module to pass this data to runtime + addServerTemplate({ + filename: '#sentry/database-config.mjs', + getContents: () => { + return `export const databaseConfig = ${JSON.stringify(databaseConfig)};`; + }, + }); + + addServerPlugin(createResolver(import.meta.url).resolve('./runtime/plugins/database.server')); +} diff --git a/packages/nuxt/test/runtime/utils/database-span-data.test.ts b/packages/nuxt/test/runtime/utils/database-span-data.test.ts new file mode 100644 index 000000000000..fc4f4b376af8 --- /dev/null +++ b/packages/nuxt/test/runtime/utils/database-span-data.test.ts @@ -0,0 +1,199 @@ +import type { ConnectorName } from 'db0'; +import type { DatabaseConnectionConfig as DatabaseConfig } from 'nitropack/types'; +import { describe, expect, it } from 'vitest'; +import { getDatabaseSpanData } from '../../../src/runtime/utils/database-span-data'; + +describe('getDatabaseSpanData', () => { + describe('no config', () => { + it('should return default SQLite namespace when no config provided', () => { + const result = getDatabaseSpanData(); + expect(result).toEqual({ + 'db.namespace': 'db.sqlite', + }); + }); + + it('should return default SQLite namespace when config has no connector', () => { + const result = getDatabaseSpanData({} as DatabaseConfig); + expect(result).toEqual({ + 'db.namespace': 'db.sqlite', + }); + }); + }); + + describe('PostgreSQL connector', () => { + it('should extract host and port for postgresql', () => { + const config: DatabaseConfig = { + connector: 'postgresql' as ConnectorName, + options: { + host: 'localhost', + port: 5432, + }, + }; + + const result = getDatabaseSpanData(config); + expect(result).toEqual({ + 'server.address': 'localhost', + 'server.port': 5432, + }); + }); + + it('should handle missing options for postgresql', () => { + const config: DatabaseConfig = { + connector: 'postgresql' as ConnectorName, + }; + + const result = getDatabaseSpanData(config); + expect(result).toEqual({ + 'server.address': undefined, + 'server.port': undefined, + }); + }); + + it('should handle partial options for postgresql', () => { + const config: DatabaseConfig = { + connector: 'postgresql' as ConnectorName, + options: { + host: 'pg-host', + }, + }; + + const result = getDatabaseSpanData(config); + expect(result).toEqual({ + 'server.address': 'pg-host', + 'server.port': undefined, + }); + }); + }); + + describe('MySQL connector', () => { + it('should extract host and port for mysql2', () => { + const config: DatabaseConfig = { + connector: 'mysql2' as ConnectorName, + options: { + host: 'mysql-host', + port: 3306, + }, + }; + + const result = getDatabaseSpanData(config); + expect(result).toEqual({ + 'server.address': 'mysql-host', + 'server.port': 3306, + }); + }); + + it('should handle missing options for mysql2', () => { + const config: DatabaseConfig = { + connector: 'mysql2' as ConnectorName, + }; + + const result = getDatabaseSpanData(config); + expect(result).toEqual({ + 'server.address': undefined, + 'server.port': undefined, + }); + }); + }); + + describe('PGLite connector', () => { + it('should extract dataDir for pglite', () => { + const config: DatabaseConfig = { + connector: 'pglite' as ConnectorName, + options: { + dataDir: '/path/to/data', + }, + }; + + const result = getDatabaseSpanData(config); + expect(result).toEqual({ + 'db.namespace': '/path/to/data', + }); + }); + + it('should handle missing dataDir for pglite', () => { + const config: DatabaseConfig = { + connector: 'pglite' as ConnectorName, + options: {}, + }; + + const result = getDatabaseSpanData(config); + expect(result).toEqual({ + 'db.namespace': undefined, + }); + }); + }); + + describe('SQLite-like connectors', () => { + it.each(['better-sqlite3', 'bun', 'sqlite', 'sqlite3'] as ConnectorName[])( + 'should extract database name for %s', + connector => { + const config: DatabaseConfig = { + connector, + options: { + name: 'custom-db', + }, + }; + + const result = getDatabaseSpanData(config); + expect(result).toEqual({ + 'db.namespace': 'custom-db.sqlite', + }); + }, + ); + + it.each(['better-sqlite3', 'bun', 'sqlite', 'sqlite3'] as ConnectorName[])( + 'should use default name for %s when name is not provided', + connector => { + const config: DatabaseConfig = { + connector, + options: {}, + }; + + const result = getDatabaseSpanData(config); + expect(result).toEqual({ + 'db.namespace': 'db.sqlite', + }); + }, + ); + + it.each(['better-sqlite3', 'bun', 'sqlite', 'sqlite3'] as ConnectorName[])( + 'should handle missing options for %s', + connector => { + const config: DatabaseConfig = { + connector, + }; + + const result = getDatabaseSpanData(config); + expect(result).toEqual({ + 'db.namespace': 'db.sqlite', + }); + }, + ); + }); + + describe('unsupported connector', () => { + it('should return empty object for unsupported connector', () => { + const config: DatabaseConfig = { + connector: 'unknown-connector' as ConnectorName, + }; + + const result = getDatabaseSpanData(config); + expect(result).toEqual({}); + }); + }); + + describe('error handling', () => { + it('should return empty object when accessing invalid config throws', () => { + // Simulate a config that might throw during access + const invalidConfig = { + connector: 'postgresql' as ConnectorName, + get options(): never { + throw new Error('Invalid access'); + }, + }; + + const result = getDatabaseSpanData(invalidConfig as unknown as DatabaseConfig); + expect(result).toEqual({}); + }); + }); +});