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 0fcccd560af9..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 @@ -11,4 +11,28 @@ 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/cache-test.ts b/dev-packages/e2e-tests/test-applications/nuxt-3/server/api/cache-test.ts new file mode 100644 index 000000000000..b19530e18c96 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-3/server/api/cache-test.ts @@ -0,0 +1,84 @@ +import { cachedFunction, defineCachedEventHandler, defineEventHandler, getQuery } from '#imports'; + +// Test cachedFunction +const getCachedUser = cachedFunction( + 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 = cachedFunction( + 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 = defineCachedEventHandler( + async event => { + return { + message: 'This response is cached', + timestamp: Date.now(), + path: event.path, + }; + }, + { + maxAge: 60, + name: 'cachedHandler', + }, +); + +export default defineEventHandler(async event => { + const results: Record = {}; + const testKey = String(getQuery(event).user ?? ''); + const dataKey = String(getQuery(event).data ?? ''); + + // 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-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/server/api/storage-aliases-test.ts b/dev-packages/e2e-tests/test-applications/nuxt-3/server/api/storage-aliases-test.ts new file mode 100644 index 000000000000..e204453d1000 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-3/server/api/storage-aliases-test.ts @@ -0,0 +1,46 @@ +import { useStorage } from '#imports'; +import { defineEventHandler } from 'h3'; + +export default defineEventHandler(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-3/server/api/storage-test.ts b/dev-packages/e2e-tests/test-applications/nuxt-3/server/api/storage-test.ts new file mode 100644 index 000000000000..f051daf59422 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-3/server/api/storage-test.ts @@ -0,0 +1,54 @@ +import { useStorage } from '#imports'; +import { defineEventHandler } from 'h3'; + +export default defineEventHandler(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-3/tests/cache.test.ts b/dev-packages/e2e-tests/test-applications/nuxt-3/tests/cache.test.ts new file mode 100644 index 000000000000..a1697136ef01 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-3/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-3', transactionEvent => { + return transactionEvent.transaction?.includes('GET /api/cache-test') ?? false; + }); + + const response = await request.get('/api/cache-test?user=123&data=test-key'); + 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-3', 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-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-3/tests/storage-aliases.test.ts b/dev-packages/e2e-tests/test-applications/nuxt-3/tests/storage-aliases.test.ts new file mode 100644 index 000000000000..a721df04b40c --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-3/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-3', 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-3/tests/storage.test.ts b/dev-packages/e2e-tests/test-applications/nuxt-3/tests/storage.test.ts new file mode 100644 index 000000000000..2c451be51135 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-3/tests/storage.test.ts @@ -0,0 +1,154 @@ +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-3', 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-4/nuxt.config.ts b/dev-packages/e2e-tests/test-applications/nuxt-4/nuxt.config.ts index d0ae045f1e9d..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: { @@ -21,4 +20,28 @@ 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/cache-test.ts b/dev-packages/e2e-tests/test-applications/nuxt-4/server/api/cache-test.ts new file mode 100644 index 000000000000..0fb4ace46bd5 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-4/server/api/cache-test.ts @@ -0,0 +1,84 @@ +import { cachedFunction, defineCachedEventHandler, defineEventHandler, getQuery } from '#imports'; + +// Test cachedFunction +const getCachedUser = cachedFunction( + 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 = cachedFunction( + 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 = defineCachedEventHandler( + async event => { + return { + message: 'This response is cached', + timestamp: Date.now(), + path: event.path, + }; + }, + { + maxAge: 60, + name: 'cachedHandler', + }, +); + +export default defineEventHandler(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-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/server/api/storage-aliases-test.ts b/dev-packages/e2e-tests/test-applications/nuxt-4/server/api/storage-aliases-test.ts new file mode 100644 index 000000000000..e204453d1000 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-4/server/api/storage-aliases-test.ts @@ -0,0 +1,46 @@ +import { useStorage } from '#imports'; +import { defineEventHandler } from 'h3'; + +export default defineEventHandler(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-4/server/api/storage-test.ts b/dev-packages/e2e-tests/test-applications/nuxt-4/server/api/storage-test.ts new file mode 100644 index 000000000000..f051daf59422 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-4/server/api/storage-test.ts @@ -0,0 +1,54 @@ +import { useStorage } from '#imports'; +import { defineEventHandler } from 'h3'; + +export default defineEventHandler(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-4/tests/cache.test.ts b/dev-packages/e2e-tests/test-applications/nuxt-4/tests/cache.test.ts new file mode 100644 index 000000000000..1295de002145 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-4/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-4', 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-4', 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-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/dev-packages/e2e-tests/test-applications/nuxt-4/tests/storage-aliases.test.ts b/dev-packages/e2e-tests/test-applications/nuxt-4/tests/storage-aliases.test.ts new file mode 100644 index 000000000000..1e2fc1eb16b1 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-4/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-4', 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-4/tests/storage.test.ts b/dev-packages/e2e-tests/test-applications/nuxt-4/tests/storage.test.ts new file mode 100644 index 000000000000..c171c9b6956f --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-4/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-4', 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/packages/nuxt/src/module.ts b/packages/nuxt/src/module.ts index 1e806e4dc2eb..e6adc562fe44 100644 --- a/packages/nuxt/src/module.ts +++ b/packages/nuxt/src/module.ts @@ -10,8 +10,10 @@ 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'; import { addOTelCommonJSImportAlias, findDefaultSdkInitFile } from './vite/utils'; export type ModuleOptions = SentryNuxtModuleOptions; @@ -126,6 +128,8 @@ export default defineNuxtModule({ // Preps the the middleware instrumentation module. 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..635c9b2f4943 --- /dev/null +++ b/packages/nuxt/src/runtime/plugins/database.server.ts @@ -0,0 +1,205 @@ +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'; +// @ts-expect-error - This is a virtual module +import { databaseInstances } from '#sentry/database-config.mjs'; + +/** + * 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 { + 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); + } + + 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); + } +}); + +function instrumentDatabase(db: Database): void { + db.prepare = new Proxy(db.prepare, { + apply(target, thisArg, args: Parameters) { + const [query] = args; + + return instrumentPreparedStatement(target.apply(thisArg, args), query, db.dialect); + }, + }); + + // 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, db.dialect); + + return startSpan( + opts, + handleSpanStart(() => target.apply(thisArg, args)), + ); + }, + }); + + db.exec = new Proxy(db.exec, { + apply(target, thisArg, args: Parameters) { + return startSpan( + createStartSpanOptions(args[0], db.dialect), + handleSpanStart(() => target.apply(thisArg, args), { query: args[0] }), + ); + }, + }); +} + +/** + * 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, dialect: string): 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, dialect); + }, + }); + + return instrumentPreparedStatementQueries(statement, query, dialect); +} + +/** + * Patches the query methods of a DB prepared statement with Sentry. + */ +function instrumentPreparedStatementQueries( + statement: PreparedStatement, + query: string, + dialect: string, +): 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, dialect), + 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, dialect), + 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, dialect), + 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, dialect: string): StartSpanOptions { + return { + name: query, + attributes: { + 'db.system.name': dialect, + 'db.query.text': query, + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: SENTRY_ORIGIN, + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'db.query', + }, + }; +} diff --git a/packages/nuxt/src/runtime/plugins/storage.server.ts b/packages/nuxt/src/runtime/plugins/storage.server.ts new file mode 100644 index 000000000000..710424d6995e --- /dev/null +++ b/packages/nuxt/src/runtime/plugins/storage.server.ts @@ -0,0 +1,314 @@ +import { + type SpanAttributes, + type StartSpanOptions, + captureException, + debug, + flushIfServerless, + SEMANTIC_ATTRIBUTE_CACHE_HIT, + SEMANTIC_ATTRIBUTE_CACHE_KEY, + SEMANTIC_ATTRIBUTE_SENTRY_OP, + SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, + SPAN_STATUS_ERROR, + SPAN_STATUS_OK, + startSpan, +} from '@sentry/core'; +// eslint-disable-next-line import/no-extraneous-dependencies +import { defineNitroPlugin, useStorage } from 'nitropack/runtime'; +import type { CacheEntry, ResponseCacheEntry } from 'nitropack/types'; +import type { Driver, Storage } from 'unstorage'; +// @ts-expect-error - This is a virtual module +import { userStorageMounts } from '#sentry/storage-config.mjs'; + +type MaybeInstrumented = T & { + __sentry_instrumented__?: boolean; +}; + +type MaybeInstrumentedDriver = MaybeInstrumented; + +type DriverMethod = keyof Driver; + +/** + * Methods that should have a attribute to indicate a cache hit. + */ +const CACHE_HIT_METHODS = new Set(['hasItem', 'getItem', 'getItemRaw']); + +/** + * Creates a Nitro plugin that instruments the storage driver. + */ +export default defineNitroPlugin(async _nitroApp => { + // This runs at runtime when the Nitro server starts + const storage = useStorage(); + // Mounts are suffixed with a colon, so we need to add it to the set items + const userMounts = new Set((userStorageMounts as string[]).map(m => `${m}:`)); + + debug.log('[storage] Starting to instrument storage drivers...'); + + // Adds cache mount to handle Nitro's cache calls + // Nitro uses the mount to cache functions and event handlers + // https://nitro.build/guide/cache + userMounts.add('cache:'); + // In production, unless the user configured a specific cache driver, Nitro will use the memory driver at root mount. + // Either way, we need to instrument the root mount as well. + userMounts.add(''); + + // Get all mounted storage drivers + const mounts = storage.getMounts(); + for (const mount of mounts) { + // Skip excluded mounts and root mount + if (!userMounts.has(mount.base)) { + continue; + } + + instrumentDriver(mount.driver, mount.base); + } + + // Wrap the mount method to instrument future mounts + storage.mount = wrapStorageMount(storage); +}); + +/** + * Instruments a driver by wrapping all method calls using proxies. + */ +function instrumentDriver(driver: MaybeInstrumentedDriver, mountBase: string): Driver { + // Already instrumented, skip... + if (driver.__sentry_instrumented__) { + debug.log(`[storage] Driver already instrumented: "${driver.name}". Skipping...`); + + return driver; + } + + debug.log(`[storage] Instrumenting driver: "${driver.name}" on mount: "${mountBase}"`); + + // List of driver methods to instrument + // get/set/remove are aliases and already use their {method}Item methods + const methodsToInstrument: DriverMethod[] = [ + 'hasItem', + 'getItem', + 'getItemRaw', + 'getItems', + 'setItem', + 'setItemRaw', + 'setItems', + 'removeItem', + 'getKeys', + 'clear', + ]; + + for (const methodName of methodsToInstrument) { + const original = driver[methodName]; + // Skip if method doesn't exist on this driver + if (typeof original !== 'function') { + continue; + } + + // Replace with instrumented + driver[methodName] = createMethodWrapper(original, methodName, driver, mountBase); + } + + // Mark as instrumented + driver.__sentry_instrumented__ = true; + + return driver; +} + +/** + * Creates an instrumented method for the given method. + */ +function createMethodWrapper( + original: (...args: unknown[]) => unknown, + methodName: DriverMethod, + driver: Driver, + mountBase: string, +): (...args: unknown[]) => unknown { + return new Proxy(original, { + async apply(target, thisArg, args) { + const options = createSpanStartOptions(methodName, driver, mountBase, args); + + debug.log(`[storage] Running method: "${methodName}" on driver: "${driver.name ?? 'unknown'}"`); + + return startSpan(options, async span => { + try { + const result = await target.apply(thisArg, args); + span.setStatus({ code: SPAN_STATUS_OK }); + + if (CACHE_HIT_METHODS.has(methodName)) { + span.setAttribute(SEMANTIC_ATTRIBUTE_CACHE_HIT, isCacheHit(args[0], result)); + } + + return result; + } catch (error) { + span.setStatus({ code: SPAN_STATUS_ERROR, message: 'internal_error' }); + captureException(error, { + mechanism: { + handled: false, + type: options.attributes?.[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN], + }, + }); + + // Re-throw the error to be handled by the caller + throw error; + } finally { + await flushIfServerless(); + } + }); + }, + }); +} + +/** + * Wraps the storage mount method to instrument the driver. + */ +function wrapStorageMount(storage: Storage): Storage['mount'] { + const original: MaybeInstrumented = storage.mount; + if (original.__sentry_instrumented__) { + return original; + } + + function mountWithInstrumentation(base: string, driver: Driver): Storage { + debug.log(`[storage] Instrumenting mount: "${base}"`); + + const instrumentedDriver = instrumentDriver(driver, base); + + return original(base, instrumentedDriver); + } + + mountWithInstrumentation.__sentry_instrumented__ = true; + + return mountWithInstrumentation; +} +/** + * Normalizes the method name to snake_case to be used in span names or op. + */ +function normalizeMethodName(methodName: string): string { + return methodName.replace(/[A-Z]/g, letter => `_${letter.toLowerCase()}`); +} + +/** + * Checks if the value is empty, used for cache hit detection. + */ +function isEmptyValue(value: unknown): value is null | undefined { + return value === null || value === undefined; +} + +/** + * Creates the span start options for the storage method. + */ +function createSpanStartOptions( + methodName: keyof Driver, + driver: Driver, + mountBase: string, + args: unknown[], +): StartSpanOptions { + const keys = getCacheKeys(args?.[0], mountBase); + + const attributes: SpanAttributes = { + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: `cache.${normalizeMethodName(methodName)}`, + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.cache.nuxt', + [SEMANTIC_ATTRIBUTE_CACHE_KEY]: keys.length > 1 ? keys : keys[0], + 'db.operation.name': methodName, + 'db.collection.name': mountBase.replace(/:$/, ''), + 'db.system.name': driver.name ?? 'unknown', + }; + + return { + name: keys.join(', '), + attributes, + }; +} + +/** + * Gets a normalized array of cache keys. + */ +function getCacheKeys(key: unknown, prefix: string): string[] { + // Handles an array of keys + if (Array.isArray(key)) { + return key.map(k => normalizeKey(k, prefix)); + } + + return [normalizeKey(key, prefix)]; +} + +/** + * Normalizes the key to a string for `cache.key` attribute. + */ +function normalizeKey(key: unknown, prefix: string): string { + if (typeof key === 'string') { + return `${prefix}${key}`; + } + + // Handles an object with a key property + if (typeof key === 'object' && key !== null && 'key' in key) { + return `${prefix}${key.key}`; + } + + return `${prefix}${isEmptyValue(key) ? '' : String(key)}`; +} + +const CACHED_FN_HANDLERS_RE = /^nitro:(functions|handlers):/i; + +/** + * Since Nitro's cache may not utilize the driver's TTL, it is possible that the value is present in the cache but won't be used by Nitro. + * The maxAge and expires values are serialized by Nitro in the cache entry. This means the value presence does not necessarily mean a cache hit. + * So in order to properly report cache hits for `defineCachedFunction` and `defineCachedEventHandler` we need to check the cached value ourselves. + * First we check if the key matches the `defineCachedFunction` or `defineCachedEventHandler` key patterns, and if so we check the cached value. + */ +function isCacheHit(key: string, value: unknown): boolean { + try { + const isEmpty = isEmptyValue(value); + // Empty value means no cache hit either way + // Or if key doesn't match the cached function or handler patterns, we can return the empty value check + if (isEmpty || !CACHED_FN_HANDLERS_RE.test(key)) { + return !isEmpty; + } + + return validateCacheEntry(key, JSON.parse(String(value)) as CacheEntry); + } catch (error) { + // this is a best effort, so we return false if we can't validate the cache entry + return false; + } +} + +/** + * Validates the cache entry. + */ +function validateCacheEntry( + key: string, + entry: CacheEntry | CacheEntry, +): boolean { + if (isEmptyValue(entry.value)) { + return false; + } + + // Date.now is used by Nitro internally, so safe to use here. + // https://github.com/nitrojs/nitro/blob/5508f71b77730e967fb131de817725f5aa7c4862/src/runtime/internal/cache.ts#L78 + if (Date.now() > (entry.expires || 0)) { + return false; + } + + /** + * Pulled from Nitro's cache entry validation + * https://github.com/nitrojs/nitro/blob/5508f71b77730e967fb131de817725f5aa7c4862/src/runtime/internal/cache.ts#L223-L241 + */ + if (isResponseCacheEntry(key, entry)) { + if (entry.value.status >= 400) { + return false; + } + + if (entry.value.body === undefined) { + return false; + } + + if (entry.value.headers.etag === 'undefined' || entry.value.headers['last-modified'] === 'undefined') { + return false; + } + } + + return true; +} + +/** + * Checks if the cache entry is a response cache entry. + */ +function isResponseCacheEntry(key: string, _: CacheEntry): _ is CacheEntry { + return key.startsWith('nitro:handlers:'); +} diff --git a/packages/nuxt/src/server/sdk.ts b/packages/nuxt/src/server/sdk.ts index dcd2f46caec9..edbd26b3d707 100644 --- a/packages/nuxt/src/server/sdk.ts +++ b/packages/nuxt/src/server/sdk.ts @@ -1,5 +1,5 @@ import * as path from 'node:path'; -import type { Client, EventProcessor, Integration } from '@sentry/core'; +import type { Client, Event, EventProcessor, Integration } from '@sentry/core'; import { applySdkMetadata, debug, flush, getGlobalScope, vercelWaitUntil } from '@sentry/core'; import { type NodeOptions, @@ -40,7 +40,7 @@ export function init(options: SentryNuxtServerOptions): Client | undefined { export function lowQualityTransactionsFilter(options: SentryNuxtServerOptions): EventProcessor { return Object.assign( (event => { - if (event.type !== 'transaction' || !event.transaction) { + if (event.type !== 'transaction' || !event.transaction || isCacheEvent(event)) { return event; } @@ -111,3 +111,10 @@ async function flushSafelyWithTimeout(): Promise { DEBUG_BUILD && debug.log('Error while flushing events:\n', e); } } + +/** + * Checks if the event is a cache event. + */ +function isCacheEvent(e: Event): boolean { + return e.contexts?.trace?.origin === 'auto.cache.nuxt'; +} diff --git a/packages/nuxt/src/vendor/server-template.ts b/packages/nuxt/src/vendor/server-template.ts new file mode 100644 index 000000000000..afdb46345d5c --- /dev/null +++ b/packages/nuxt/src/vendor/server-template.ts @@ -0,0 +1,17 @@ +import { useNuxt } from '@nuxt/kit'; +import type { NuxtTemplate } from 'nuxt/schema'; + +/** + * Adds a virtual file that can be used within the Nuxt Nitro server build. + * Available in NuxtKit v4, so we are porting it here. + * https://github.com/nuxt/nuxt/blob/d6df732eec1a3bd442bdb325b0335beb7e10cd64/packages/kit/src/template.ts#L55-L62 + */ +export function addServerTemplate(template: NuxtTemplate): NuxtTemplate { + const nuxt = useNuxt(); + if (template.filename) { + nuxt.options.nitro.virtual = nuxt.options.nitro.virtual || {}; + nuxt.options.nitro.virtual[template.filename] = template.getContents; + } + + return template; +} diff --git a/packages/nuxt/src/vite/databaseConfig.ts b/packages/nuxt/src/vite/databaseConfig.ts new file mode 100644 index 000000000000..0a2dc1a435a5 --- /dev/null +++ b/packages/nuxt/src/vite/databaseConfig.ts @@ -0,0 +1,37 @@ +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?)`. + * https://nitro.build/guide/database#configuration + */ + const databaseInstances = Object.keys(nitro.database || { default: {} }); + + // Create a virtual module to pass this data to runtime + addServerTemplate({ + filename: '#sentry/database-config.mjs', + getContents: () => { + return `export const databaseInstances = ${JSON.stringify(databaseInstances)};`; + }, + }); + + 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 new file mode 100644 index 000000000000..c0838ad154b8 --- /dev/null +++ b/packages/nuxt/src/vite/storageConfig.ts @@ -0,0 +1,21 @@ +import { addServerPlugin, createResolver } from '@nuxt/kit'; +import type { Nuxt } from 'nuxt/schema'; +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 { + const moduleDirResolver = createResolver(import.meta.url); + const userStorageMounts = Object.keys(nuxt.options.nitro.storage || {}); + + // Create a virtual module to pass this data to runtime + addServerTemplate({ + filename: '#sentry/storage-config.mjs', + getContents: () => { + return `export const userStorageMounts = ${JSON.stringify(userStorageMounts)};`; + }, + }); + + addServerPlugin(moduleDirResolver.resolve('./runtime/plugins/storage.server')); +}