From 2ae6bcc47e39e6c93766bc9bd4a17f7ea3d89964 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Thu, 9 Oct 2025 13:25:39 +0300 Subject: [PATCH 01/20] feat: initial db instrumentation --- packages/nuxt/src/module.ts | 2 + .../src/runtime/plugins/database.server.ts | 78 +++++++++++++++++++ packages/nuxt/src/vite/databaseConfig.ts | 19 +++++ 3 files changed, 99 insertions(+) create mode 100644 packages/nuxt/src/runtime/plugins/database.server.ts create mode 100644 packages/nuxt/src/vite/databaseConfig.ts diff --git a/packages/nuxt/src/module.ts b/packages/nuxt/src/module.ts index 947eb2710f4d..deddf68a270f 100644 --- a/packages/nuxt/src/module.ts +++ b/packages/nuxt/src/module.ts @@ -10,6 +10,7 @@ import { consoleSandbox } from '@sentry/core'; import * as path from 'path'; import type { SentryNuxtModuleOptions } from './common/types'; import { addDynamicImportEntryFileWrapper, addSentryTopImport, addServerConfigToBuild } from './vite/addServerConfig'; +import { addDatabaseInstrumentation } from './vite/databaseConfig'; import { addMiddlewareImports, addMiddlewareInstrumentation } from './vite/middlewareConfig'; import { setupSourceMaps } from './vite/sourceMaps'; import { addStorageInstrumentation } from './vite/storageConfig'; @@ -128,6 +129,7 @@ export default defineNuxtModule({ if (serverConfigFile) { addMiddlewareImports(); addStorageInstrumentation(nuxt); + addDatabaseInstrumentation(nuxt); } 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..574ea4200685 --- /dev/null +++ b/packages/nuxt/src/runtime/plugins/database.server.ts @@ -0,0 +1,78 @@ +import { + type SpanAttributes, + captureException, + debug, + flushIfServerless, + SEMANTIC_ATTRIBUTE_SENTRY_OP, + SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, + SPAN_STATUS_ERROR, + SPAN_STATUS_OK, + startSpan, +} from '@sentry/core'; +import type { Database } from 'db0'; +// eslint-disable-next-line import/no-extraneous-dependencies +import { defineNitroPlugin, useDatabase } from 'nitropack/runtime'; + +/** + * Creates a Nitro plugin that instruments the database calls. + */ +export default defineNitroPlugin(() => { + const db = useDatabase(); + + debug.log('@sentry/nuxt: Instrumenting database...'); + + instrumentDatabase(db); + + debug.log('@sentry/nuxt: Database instrumented.'); +}); + +function instrumentDatabase(db: Database): void { + db.sql = new Proxy(db.sql, { + apply(target, thisArg, args: Parameters) { + const query = args[0]?.[0]; + const attributes = getSpanAttributes(db, query); + + return startSpan( + { + name: query || 'db.query', + attributes, + }, + async span => { + try { + const result = await target.apply(thisArg, args); + span.setStatus({ code: SPAN_STATUS_OK }); + + return result; + } catch (error) { + span.setStatus({ code: SPAN_STATUS_ERROR, message: 'internal_error' }); + captureException(error, { + mechanism: { + handled: false, + type: attributes[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN], + }, + }); + + // Re-throw the error to be handled by the caller + throw error; + } finally { + await flushIfServerless(); + } + }, + ); + }, + }); +} + +function getSpanAttributes(db: Database, query?: string): SpanAttributes { + const attributes: SpanAttributes = { + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.db.nuxt', + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'db', + 'db.system': db.dialect, + }; + + if (query) { + attributes['db.query'] = query; + } + + return attributes; +} diff --git a/packages/nuxt/src/vite/databaseConfig.ts b/packages/nuxt/src/vite/databaseConfig.ts new file mode 100644 index 000000000000..d4da6ee9bea1 --- /dev/null +++ b/packages/nuxt/src/vite/databaseConfig.ts @@ -0,0 +1,19 @@ +import { addServerPlugin, createResolver } from '@nuxt/kit'; +import { consoleSandbox } from '@sentry/core'; +import type { Nuxt } from 'nuxt/schema'; + +/** + * Sets up the database instrumentation. + */ +export function addDatabaseInstrumentation(nuxt: Nuxt): void { + if (!nuxt.options.nitro?.database) { + consoleSandbox(() => { + // eslint-disable-next-line no-console + console.log('[Sentry] No database configuration found. Skipping database instrumentation.'); + }); + + return; + } + + addServerPlugin(createResolver(import.meta.url).resolve('./runtime/plugins/database.server')); +} From 097797163cd7bb7b78411f3ff3aef156020a7ad0 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Thu, 9 Oct 2025 16:31:34 +0300 Subject: [PATCH 02/20] fix: instrument all execution methods --- .../src/runtime/plugins/database.server.ts | 191 ++++++++++++++---- packages/nuxt/src/vite/databaseConfig.ts | 2 +- 2 files changed, 149 insertions(+), 44 deletions(-) diff --git a/packages/nuxt/src/runtime/plugins/database.server.ts b/packages/nuxt/src/runtime/plugins/database.server.ts index 574ea4200685..841d4b4084c0 100644 --- a/packages/nuxt/src/runtime/plugins/database.server.ts +++ b/packages/nuxt/src/runtime/plugins/database.server.ts @@ -1,18 +1,24 @@ import { - type SpanAttributes, + type StartSpanOptions, + addBreadcrumb, captureException, debug, flushIfServerless, - SEMANTIC_ATTRIBUTE_SENTRY_OP, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, SPAN_STATUS_ERROR, - SPAN_STATUS_OK, startSpan, } from '@sentry/core'; -import type { Database } from 'db0'; +import type { Database, PreparedStatement } from 'db0'; // eslint-disable-next-line import/no-extraneous-dependencies import { defineNitroPlugin, useDatabase } from 'nitropack/runtime'; +type PreparedStatementType = 'get' | 'run' | 'all' | 'raw'; + +/** + * Keeps track of prepared statements that have been patched. + */ +const patchedStatement = new WeakSet(); + /** * Creates a Nitro plugin that instruments the database calls. */ @@ -27,52 +33,151 @@ export default defineNitroPlugin(() => { }); 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 attributes = getSpanAttributes(db, query); - - return startSpan( - { - name: query || 'db.query', - attributes, - }, - async span => { - try { - const result = await target.apply(thisArg, args); - span.setStatus({ code: SPAN_STATUS_OK }); - - return result; - } catch (error) { - span.setStatus({ code: SPAN_STATUS_ERROR, message: 'internal_error' }); - captureException(error, { - mechanism: { - handled: false, - type: attributes[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN], - }, - }); - - // Re-throw the error to be handled by the caller - throw error; - } finally { - await flushIfServerless(); - } - }, - ); + const query = args[0]?.[0] ?? ''; + const opts = createStartSpanOptions(query, db.dialect); + + return startSpan(opts, async span => { + try { + const result = await target.apply(thisArg, args); + + return result; + } catch (error) { + span.setStatus({ code: SPAN_STATUS_ERROR, message: 'internal_error' }); + captureException(error, { + mechanism: { + handled: false, + type: opts.attributes?.[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN], + }, + }); + + // Re-throw the error to be handled by the caller + throw error; + } finally { + await flushIfServerless(); + } + }); + }, + }); + + db.exec = new Proxy(db.exec, { + apply(target, thisArg, args: Parameters) { + return startSpan(createStartSpanOptions(args[0], db.dialect, 'run'), async () => { + const result = await target.apply(thisArg, args); + + createBreadcrumb(args[0], 'run'); + + return result; + }); }, }); } -function getSpanAttributes(db: Database, query?: string): SpanAttributes { - const attributes: SpanAttributes = { - [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.db.nuxt', - [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'db', - 'db.system': db.dialect, - }; +/** + * 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); + }, + }); - if (query) { - attributes['db.query'] = query; + 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; } - return attributes; + // eslint-disable-next-line @typescript-eslint/unbound-method + statement.get = new Proxy(statement.get, { + apply(target, thisArg, args: Parameters) { + return startSpan(createStartSpanOptions(query, dialect, 'get'), async () => { + const result = await target.apply(thisArg, args); + createBreadcrumb(query, 'get'); + + return result; + }); + }, + }); + + // eslint-disable-next-line @typescript-eslint/unbound-method + statement.run = new Proxy(statement.run, { + apply(target, thisArg, args: Parameters) { + return startSpan(createStartSpanOptions(query, dialect, 'run'), async () => { + const result = await target.apply(thisArg, args); + createBreadcrumb(query, 'run'); + + return result; + }); + }, + }); + + // eslint-disable-next-line @typescript-eslint/unbound-method + statement.all = new Proxy(statement.all, { + apply(target, thisArg, args: Parameters) { + return startSpan(createStartSpanOptions(query, dialect, 'all'), async () => { + const result = await target.apply(thisArg, args); + // Since all has no regular shape, we can assume if it returns an array, it's a success. + createBreadcrumb(query, 'all'); + + return result; + }); + }, + }); + + patchedStatement.add(statement); + + return statement; +} + +function createBreadcrumb(query: string, type: PreparedStatementType): void { + addBreadcrumb({ + category: 'query', + message: query, + data: { + 'db.query_type': type, + }, + }); +} + +/** + * Creates a start span options object. + */ +function createStartSpanOptions(query: string, dialect: string, type?: PreparedStatementType): StartSpanOptions { + return { + op: 'db.query', + name: query, + attributes: { + 'db.system': dialect, + 'db.query_type': type, + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.db.nuxt', + }, + }; } diff --git a/packages/nuxt/src/vite/databaseConfig.ts b/packages/nuxt/src/vite/databaseConfig.ts index d4da6ee9bea1..f81674988dfb 100644 --- a/packages/nuxt/src/vite/databaseConfig.ts +++ b/packages/nuxt/src/vite/databaseConfig.ts @@ -6,7 +6,7 @@ import type { Nuxt } from 'nuxt/schema'; * Sets up the database instrumentation. */ export function addDatabaseInstrumentation(nuxt: Nuxt): void { - if (!nuxt.options.nitro?.database) { + if (!nuxt.options.nitro?.experimental?.database && !nuxt.options.nitro?.database) { consoleSandbox(() => { // eslint-disable-next-line no-console console.log('[Sentry] No database configuration found. Skipping database instrumentation.'); From 4db9beb1d275e57a9f9b8dbfa904ee73e438bf5c Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Mon, 13 Oct 2025 12:06:28 +0300 Subject: [PATCH 03/20] fix: handle exceptions properly across all db methods --- .../src/runtime/plugins/database.server.ts | 103 ++++++++++-------- 1 file changed, 56 insertions(+), 47 deletions(-) diff --git a/packages/nuxt/src/runtime/plugins/database.server.ts b/packages/nuxt/src/runtime/plugins/database.server.ts index 841d4b4084c0..45fa5e20ab13 100644 --- a/packages/nuxt/src/runtime/plugins/database.server.ts +++ b/packages/nuxt/src/runtime/plugins/database.server.ts @@ -1,4 +1,5 @@ import { + type Span, type StartSpanOptions, addBreadcrumb, captureException, @@ -19,6 +20,11 @@ type PreparedStatementType = 'get' | 'run' | 'all' | 'raw'; */ 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. */ @@ -49,38 +55,19 @@ function instrumentDatabase(db: Database): void { const query = args[0]?.[0] ?? ''; const opts = createStartSpanOptions(query, db.dialect); - return startSpan(opts, async span => { - try { - const result = await target.apply(thisArg, args); - - return result; - } catch (error) { - span.setStatus({ code: SPAN_STATUS_ERROR, message: 'internal_error' }); - captureException(error, { - mechanism: { - handled: false, - type: opts.attributes?.[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN], - }, - }); - - // Re-throw the error to be handled by the caller - throw error; - } finally { - await flushIfServerless(); - } - }); + 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, 'run'), async () => { - const result = await target.apply(thisArg, args); - - createBreadcrumb(args[0], 'run'); - - return result; - }); + return startSpan( + createStartSpanOptions(args[0], db.dialect, 'run'), + handleSpanStart(() => target.apply(thisArg, args), { query: args[0], type: 'run' }), + ); }, }); } @@ -118,37 +105,30 @@ function instrumentPreparedStatementQueries( // eslint-disable-next-line @typescript-eslint/unbound-method statement.get = new Proxy(statement.get, { apply(target, thisArg, args: Parameters) { - return startSpan(createStartSpanOptions(query, dialect, 'get'), async () => { - const result = await target.apply(thisArg, args); - createBreadcrumb(query, 'get'); - - return result; - }); + return startSpan( + createStartSpanOptions(query, dialect, 'get'), + handleSpanStart(() => target.apply(thisArg, args), { query, type: 'get' }), + ); }, }); // eslint-disable-next-line @typescript-eslint/unbound-method statement.run = new Proxy(statement.run, { apply(target, thisArg, args: Parameters) { - return startSpan(createStartSpanOptions(query, dialect, 'run'), async () => { - const result = await target.apply(thisArg, args); - createBreadcrumb(query, 'run'); - - return result; - }); + return startSpan( + createStartSpanOptions(query, dialect, 'run'), + handleSpanStart(() => target.apply(thisArg, args), { query, type: 'run' }), + ); }, }); // eslint-disable-next-line @typescript-eslint/unbound-method statement.all = new Proxy(statement.all, { apply(target, thisArg, args: Parameters) { - return startSpan(createStartSpanOptions(query, dialect, 'all'), async () => { - const result = await target.apply(thisArg, args); - // Since all has no regular shape, we can assume if it returns an array, it's a success. - createBreadcrumb(query, 'all'); - - return result; - }); + return startSpan( + createStartSpanOptions(query, dialect, 'all'), + handleSpanStart(() => target.apply(thisArg, args), { query, type: 'all' }), + ); }, }); @@ -157,6 +137,35 @@ function instrumentPreparedStatementQueries( return statement; } +/** + * Creates a span start callback handler + */ +function handleSpanStart(fn: () => unknown, breadcrumbOpts?: { query: string; type: PreparedStatementType }) { + return async (span: Span) => { + try { + const result = await fn(); + if (breadcrumbOpts) { + createBreadcrumb(breadcrumbOpts.query, breadcrumbOpts.type); + } + + 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, type: PreparedStatementType): void { addBreadcrumb({ category: 'query', @@ -177,7 +186,7 @@ function createStartSpanOptions(query: string, dialect: string, type?: PreparedS attributes: { 'db.system': dialect, 'db.query_type': type, - [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.db.nuxt', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: SENTRY_ORIGIN, }, }; } From f6da4ec497ba4f37d8edfaf6373654d934a67235 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Mon, 13 Oct 2025 12:24:34 +0300 Subject: [PATCH 04/20] fix: semantic attrs --- .../src/runtime/plugins/database.server.ts | 32 +++++++++---------- 1 file changed, 15 insertions(+), 17 deletions(-) diff --git a/packages/nuxt/src/runtime/plugins/database.server.ts b/packages/nuxt/src/runtime/plugins/database.server.ts index 45fa5e20ab13..d0c3612e1590 100644 --- a/packages/nuxt/src/runtime/plugins/database.server.ts +++ b/packages/nuxt/src/runtime/plugins/database.server.ts @@ -13,8 +13,6 @@ import type { Database, PreparedStatement } from 'db0'; // eslint-disable-next-line import/no-extraneous-dependencies import { defineNitroPlugin, useDatabase } from 'nitropack/runtime'; -type PreparedStatementType = 'get' | 'run' | 'all' | 'raw'; - /** * Keeps track of prepared statements that have been patched. */ @@ -65,8 +63,8 @@ function instrumentDatabase(db: Database): void { db.exec = new Proxy(db.exec, { apply(target, thisArg, args: Parameters) { return startSpan( - createStartSpanOptions(args[0], db.dialect, 'run'), - handleSpanStart(() => target.apply(thisArg, args), { query: args[0], type: 'run' }), + createStartSpanOptions(args[0], db.dialect), + handleSpanStart(() => target.apply(thisArg, args), { query: args[0] }), ); }, }); @@ -106,8 +104,8 @@ function instrumentPreparedStatementQueries( statement.get = new Proxy(statement.get, { apply(target, thisArg, args: Parameters) { return startSpan( - createStartSpanOptions(query, dialect, 'get'), - handleSpanStart(() => target.apply(thisArg, args), { query, type: 'get' }), + createStartSpanOptions(query, dialect), + handleSpanStart(() => target.apply(thisArg, args), { query }), ); }, }); @@ -116,8 +114,8 @@ function instrumentPreparedStatementQueries( statement.run = new Proxy(statement.run, { apply(target, thisArg, args: Parameters) { return startSpan( - createStartSpanOptions(query, dialect, 'run'), - handleSpanStart(() => target.apply(thisArg, args), { query, type: 'run' }), + createStartSpanOptions(query, dialect), + handleSpanStart(() => target.apply(thisArg, args), { query }), ); }, }); @@ -126,8 +124,8 @@ function instrumentPreparedStatementQueries( statement.all = new Proxy(statement.all, { apply(target, thisArg, args: Parameters) { return startSpan( - createStartSpanOptions(query, dialect, 'all'), - handleSpanStart(() => target.apply(thisArg, args), { query, type: 'all' }), + createStartSpanOptions(query, dialect), + handleSpanStart(() => target.apply(thisArg, args), { query }), ); }, }); @@ -140,12 +138,12 @@ function instrumentPreparedStatementQueries( /** * Creates a span start callback handler */ -function handleSpanStart(fn: () => unknown, breadcrumbOpts?: { query: string; type: PreparedStatementType }) { +function handleSpanStart(fn: () => unknown, breadcrumbOpts?: { query: string }) { return async (span: Span) => { try { const result = await fn(); if (breadcrumbOpts) { - createBreadcrumb(breadcrumbOpts.query, breadcrumbOpts.type); + createBreadcrumb(breadcrumbOpts.query); } return result; @@ -166,12 +164,12 @@ function handleSpanStart(fn: () => unknown, breadcrumbOpts?: { query: string; ty }; } -function createBreadcrumb(query: string, type: PreparedStatementType): void { +function createBreadcrumb(query: string): void { addBreadcrumb({ category: 'query', message: query, data: { - 'db.query_type': type, + 'db.query.text': query, }, }); } @@ -179,13 +177,13 @@ function createBreadcrumb(query: string, type: PreparedStatementType): void { /** * Creates a start span options object. */ -function createStartSpanOptions(query: string, dialect: string, type?: PreparedStatementType): StartSpanOptions { +function createStartSpanOptions(query: string, dialect: string): StartSpanOptions { return { op: 'db.query', name: query, attributes: { - 'db.system': dialect, - 'db.query_type': type, + 'db.system.name': dialect, + 'db.query.text': query, [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: SENTRY_ORIGIN, }, }; From 862df3a4bf41cdda80079d1e7b2e4e02a2470a11 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Mon, 13 Oct 2025 15:20:58 +0300 Subject: [PATCH 05/20] fix: only check the relevant config for enabling db --- packages/nuxt/src/vite/databaseConfig.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/nuxt/src/vite/databaseConfig.ts b/packages/nuxt/src/vite/databaseConfig.ts index f81674988dfb..3bccbf1eaed4 100644 --- a/packages/nuxt/src/vite/databaseConfig.ts +++ b/packages/nuxt/src/vite/databaseConfig.ts @@ -6,7 +6,7 @@ import type { Nuxt } from 'nuxt/schema'; * Sets up the database instrumentation. */ export function addDatabaseInstrumentation(nuxt: Nuxt): void { - if (!nuxt.options.nitro?.experimental?.database && !nuxt.options.nitro?.database) { + if (!nuxt.options.nitro?.experimental?.database) { consoleSandbox(() => { // eslint-disable-next-line no-console console.log('[Sentry] No database configuration found. Skipping database instrumentation.'); From 19ea6f1a3100ebe00f21a2b68ff8d4e85795d0b1 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Mon, 13 Oct 2025 15:21:30 +0300 Subject: [PATCH 06/20] tests: added nuxt-3 tests --- .../test-applications/nuxt-3/nuxt.config.ts | 5 + .../nuxt-3/server/api/db-test.ts | 69 ++++++ .../nuxt-3/tests/database.test.ts | 197 ++++++++++++++++++ 3 files changed, 271 insertions(+) create mode 100644 dev-packages/e2e-tests/test-applications/nuxt-3/server/api/db-test.ts create mode 100644 dev-packages/e2e-tests/test-applications/nuxt-3/tests/database.test.ts diff --git a/dev-packages/e2e-tests/test-applications/nuxt-3/nuxt.config.ts b/dev-packages/e2e-tests/test-applications/nuxt-3/nuxt.config.ts index 8ea55702863c..cc3057e893c0 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 @@ -4,6 +4,11 @@ export default defineNuxtConfig({ imports: { autoImport: false, }, + nitro: { + experimental: { + database: true, + }, + }, runtimeConfig: { public: { sentry: { 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..96c243493654 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-3/server/api/db-test.ts @@ -0,0 +1,69 @@ +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('CREATE TABLE IF NOT EXISTS logs (id INTEGER PRIMARY KEY, message TEXT, level TEXT)'); + const result = await db.exec(`INSERT INTO logs (message, level) VALUES ('Test log', 'INFO')`); + return { success: true, result }; + } + + case 'error': { + const stmt = db.prepare('SELECT * FROM nonexistent_table WHERE invalid_column = ?'); + await stmt.get(1); + return { success: false, message: 'Should have thrown an error' }; + } + + default: + return { error: 'Unknown method' }; + } +}); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-3/tests/database.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); + }); +}); From 7a29f961042bc2aa58e5f5e9aa4db9016ec03d4a Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Mon, 13 Oct 2025 15:45:54 +0300 Subject: [PATCH 07/20] fix: handle an edge case where db isn't initialized during build time yet --- .../src/runtime/plugins/database.server.ts | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/packages/nuxt/src/runtime/plugins/database.server.ts b/packages/nuxt/src/runtime/plugins/database.server.ts index d0c3612e1590..9a9778035af4 100644 --- a/packages/nuxt/src/runtime/plugins/database.server.ts +++ b/packages/nuxt/src/runtime/plugins/database.server.ts @@ -27,13 +27,23 @@ const SENTRY_ORIGIN = 'auto.db.nuxt'; * Creates a Nitro plugin that instruments the database calls. */ export default defineNitroPlugin(() => { - const db = useDatabase(); + try { + debug.log('@sentry/nuxt: Instrumenting database...'); - debug.log('@sentry/nuxt: Instrumenting database...'); + const db = useDatabase(); - instrumentDatabase(db); + instrumentDatabase(db); - debug.log('@sentry/nuxt: Database instrumented.'); + debug.log('@sentry/nuxt: Database 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('@sentry/nuxt: Database instrumentation skipped during build time.'); + return; + } + + debug.error('@sentry/nuxt: Failed to instrument database:', error); + } }); function instrumentDatabase(db: Database): void { From 70bc5dc05bba3b53fc9b5d9c867c84559cb84611 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Mon, 13 Oct 2025 15:46:16 +0300 Subject: [PATCH 08/20] tests: nuxt tests and upgrade node version --- .../test-applications/nuxt-3/package.json | 3 +- .../test-applications/nuxt-4/nuxt.config.ts | 6 +- .../test-applications/nuxt-4/package.json | 3 +- .../nuxt-4/server/api/db-test.ts | 69 ++++++ .../nuxt-4/tests/database.test.ts | 197 ++++++++++++++++++ 5 files changed, 275 insertions(+), 3 deletions(-) create mode 100644 dev-packages/e2e-tests/test-applications/nuxt-4/server/api/db-test.ts create mode 100644 dev-packages/e2e-tests/test-applications/nuxt-4/tests/database.test.ts 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-4/nuxt.config.ts b/dev-packages/e2e-tests/test-applications/nuxt-4/nuxt.config.ts index 50924877649d..7c8957b9ff74 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,11 @@ export default defineNuxtConfig({ }, modules: ['@pinia/nuxt', '@sentry/nuxt/module'], - + nitro: { + experimental: { + database: true, + }, + }, runtimeConfig: { public: { sentry: { diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4/package.json b/dev-packages/e2e-tests/test-applications/nuxt-4/package.json index b16b7ee2b236..a5d36c1f6a61 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-4/package.json +++ b/dev-packages/e2e-tests/test-applications/nuxt-4/package.json @@ -25,7 +25,8 @@ "@sentry-internal/test-utils": "link:../../../test-utils" }, "volta": { - "extends": "../../package.json" + "extends": "../../package.json", + "node": "22.20.0" }, "sentryTest": { "optionalVariants": [ diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4/server/api/db-test.ts b/dev-packages/e2e-tests/test-applications/nuxt-4/server/api/db-test.ts new file mode 100644 index 000000000000..7817c0b4db3d --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-4/server/api/db-test.ts @@ -0,0 +1,69 @@ +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('CREATE TABLE IF NOT EXISTS logs (id INTEGER PRIMARY KEY, message TEXT, level TEXT)'); + const result = await db.exec(`INSERT INTO logs (message, level) VALUES ('Test log', 'INFO')`); + return { success: true, result }; + } + + case 'error': { + const stmt = db.prepare('SELECT * FROM nonexistent_table WHERE invalid_column = ?'); + await stmt.get(1); + return { success: false, message: 'Should have thrown an error' }; + } + + default: + return { error: 'Unknown method' }; + } +}); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4/tests/database.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); + }); +}); From bbf5c789c4ddea0ae8da0b7d977265449c4cb3bb Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Wed, 15 Oct 2025 13:17:07 +0300 Subject: [PATCH 09/20] feat: support multiple database configurations --- packages/nuxt/src/index.types.ts | 1 + .../nuxt/src/runtime/plugins/database.server.ts | 14 +++++++++----- packages/nuxt/src/vite/databaseConfig.ts | 16 ++++++++++++++++ 3 files changed, 26 insertions(+), 5 deletions(-) diff --git a/packages/nuxt/src/index.types.ts b/packages/nuxt/src/index.types.ts index 7abb16d197e3..ed6ae3a0cf71 100644 --- a/packages/nuxt/src/index.types.ts +++ b/packages/nuxt/src/index.types.ts @@ -26,3 +26,4 @@ export declare const openFeatureIntegration: typeof clientSdk.openFeatureIntegra export declare const OpenFeatureIntegrationHook: typeof clientSdk.OpenFeatureIntegrationHook; export declare const statsigIntegration: typeof clientSdk.statsigIntegration; export declare const unleashIntegration: typeof clientSdk.unleashIntegration; +export declare const growthbookIntegration: typeof clientSdk.growthbookIntegration; diff --git a/packages/nuxt/src/runtime/plugins/database.server.ts b/packages/nuxt/src/runtime/plugins/database.server.ts index 9a9778035af4..e86b5f857434 100644 --- a/packages/nuxt/src/runtime/plugins/database.server.ts +++ b/packages/nuxt/src/runtime/plugins/database.server.ts @@ -12,6 +12,8 @@ import { 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. @@ -28,13 +30,15 @@ const SENTRY_ORIGIN = 'auto.db.nuxt'; */ export default defineNitroPlugin(() => { try { - debug.log('@sentry/nuxt: Instrumenting database...'); + debug.log('@sentry/nuxt: Instrumenting databases...'); - const db = useDatabase(); - - instrumentDatabase(db); + for (const instance of databaseInstances) { + debug.log('@sentry/nuxt: Instrumenting database instance:', instance); + const db = useDatabase(instance); + instrumentDatabase(db); + } - debug.log('@sentry/nuxt: Database instrumented.'); + debug.log('@sentry/nuxt: 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)) { diff --git a/packages/nuxt/src/vite/databaseConfig.ts b/packages/nuxt/src/vite/databaseConfig.ts index 3bccbf1eaed4..e50ed1ec4b4c 100644 --- a/packages/nuxt/src/vite/databaseConfig.ts +++ b/packages/nuxt/src/vite/databaseConfig.ts @@ -1,6 +1,7 @@ import { addServerPlugin, createResolver } from '@nuxt/kit'; import { consoleSandbox } from '@sentry/core'; import type { Nuxt } from 'nuxt/schema'; +import { addServerTemplate } from '../vendor/server-template'; /** * Sets up the database instrumentation. @@ -15,5 +16,20 @@ export function addDatabaseInstrumentation(nuxt: Nuxt): void { return; } + /** + * This is a different flag 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(nuxt.options.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')); } From 224d86b6c9b3d3267f1c1982d113fe74f1755469 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Wed, 15 Oct 2025 13:34:18 +0300 Subject: [PATCH 10/20] fix: build stuff --- packages/nuxt/src/index.types.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/nuxt/src/index.types.ts b/packages/nuxt/src/index.types.ts index ed6ae3a0cf71..7abb16d197e3 100644 --- a/packages/nuxt/src/index.types.ts +++ b/packages/nuxt/src/index.types.ts @@ -26,4 +26,3 @@ export declare const openFeatureIntegration: typeof clientSdk.openFeatureIntegra export declare const OpenFeatureIntegrationHook: typeof clientSdk.OpenFeatureIntegrationHook; export declare const statsigIntegration: typeof clientSdk.statsigIntegration; export declare const unleashIntegration: typeof clientSdk.unleashIntegration; -export declare const growthbookIntegration: typeof clientSdk.growthbookIntegration; From 0d0589127ac19c974872ed7c64067c8c2f754e42 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Wed, 15 Oct 2025 13:38:04 +0300 Subject: [PATCH 11/20] docs: clarified comment --- packages/nuxt/src/vite/databaseConfig.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/nuxt/src/vite/databaseConfig.ts b/packages/nuxt/src/vite/databaseConfig.ts index e50ed1ec4b4c..cf7750032b95 100644 --- a/packages/nuxt/src/vite/databaseConfig.ts +++ b/packages/nuxt/src/vite/databaseConfig.ts @@ -17,7 +17,7 @@ export function addDatabaseInstrumentation(nuxt: Nuxt): void { } /** - * This is a different flag than the one in experimental.database, this configures multiple database instances. + * 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 */ From e10e7e0025d8663b6d73aa593098d3c75b1457d7 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Wed, 15 Oct 2025 15:03:28 +0300 Subject: [PATCH 12/20] fix: merge issues with db config --- .../e2e-tests/test-applications/nuxt-3/nuxt.config.ts | 8 +++----- .../e2e-tests/test-applications/nuxt-4/nuxt.config.ts | 8 +++----- 2 files changed, 6 insertions(+), 10 deletions(-) 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 cc3057e893c0..85fd137b9fe5 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 @@ -4,11 +4,6 @@ export default defineNuxtConfig({ imports: { autoImport: false, }, - nitro: { - experimental: { - database: true, - }, - }, runtimeConfig: { public: { sentry: { @@ -17,6 +12,9 @@ export default defineNuxtConfig({ }, }, nitro: { + experimental: { + database: true, + }, storage: { 'test-storage': { driver: 'memory', 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 7c8957b9ff74..f9c8d9eed822 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,11 +13,6 @@ export default defineNuxtConfig({ }, modules: ['@pinia/nuxt', '@sentry/nuxt/module'], - nitro: { - experimental: { - database: true, - }, - }, runtimeConfig: { public: { sentry: { @@ -26,6 +21,9 @@ export default defineNuxtConfig({ }, }, nitro: { + experimental: { + database: true, + }, storage: { 'test-storage': { driver: 'memory', From c6f0462f71e0234efc1e337b1358d2cdfd015c3b Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Wed, 15 Oct 2025 15:20:03 +0300 Subject: [PATCH 13/20] tests: added multiple database config test --- .../test-applications/nuxt-3/nuxt.config.ts | 14 ++ .../nuxt-3/server/api/db-multi-test.ts | 102 ++++++++++++ .../nuxt-3/tests/database-multi.test.ts | 156 ++++++++++++++++++ .../test-applications/nuxt-4/nuxt.config.ts | 14 ++ .../nuxt-4/server/api/db-multi-test.ts | 102 ++++++++++++ .../nuxt-4/tests/database-multi.test.ts | 156 ++++++++++++++++++ 6 files changed, 544 insertions(+) create mode 100644 dev-packages/e2e-tests/test-applications/nuxt-3/server/api/db-multi-test.ts create mode 100644 dev-packages/e2e-tests/test-applications/nuxt-3/tests/database-multi.test.ts create mode 100644 dev-packages/e2e-tests/test-applications/nuxt-4/server/api/db-multi-test.ts create mode 100644 dev-packages/e2e-tests/test-applications/nuxt-4/tests/database-multi.test.ts 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 85fd137b9fe5..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 @@ -15,6 +15,20 @@ export default defineNuxtConfig({ 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/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/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-4/nuxt.config.ts b/dev-packages/e2e-tests/test-applications/nuxt-4/nuxt.config.ts index f9c8d9eed822..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 @@ -24,6 +24,20 @@ export default defineNuxtConfig({ 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/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/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); + }); +}); From 59dc46ef1a9e3a31001d7381a13aaf06bea8a7c7 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Wed, 15 Oct 2025 15:58:50 +0300 Subject: [PATCH 14/20] fix: op attr --- packages/nuxt/src/runtime/plugins/database.server.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/nuxt/src/runtime/plugins/database.server.ts b/packages/nuxt/src/runtime/plugins/database.server.ts index e86b5f857434..5c164fc08579 100644 --- a/packages/nuxt/src/runtime/plugins/database.server.ts +++ b/packages/nuxt/src/runtime/plugins/database.server.ts @@ -5,6 +5,7 @@ import { captureException, debug, flushIfServerless, + SEMANTIC_ATTRIBUTE_SENTRY_OP, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, SPAN_STATUS_ERROR, startSpan, @@ -193,12 +194,12 @@ function createBreadcrumb(query: string): void { */ function createStartSpanOptions(query: string, dialect: string): StartSpanOptions { return { - op: 'db.query', name: query, attributes: { 'db.system.name': dialect, 'db.query.text': query, [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: SENTRY_ORIGIN, + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'db.query', }, }; } From 726e466d9baf7585f811e8ee8cc065e2df52a1af Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Wed, 15 Oct 2025 18:01:41 +0300 Subject: [PATCH 15/20] test: make tests less flakey --- .../e2e-tests/test-applications/nuxt-3/server/api/db-test.ts | 3 ++- .../e2e-tests/test-applications/nuxt-4/server/api/db-test.ts | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) 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 index 96c243493654..2241afdee14d 100644 --- 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 @@ -52,7 +52,8 @@ export default defineEventHandler(async event => { } case 'exec': { - await db.exec('CREATE TABLE IF NOT EXISTS logs (id INTEGER PRIMARY KEY, message TEXT, level TEXT)'); + 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 }; } diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4/server/api/db-test.ts b/dev-packages/e2e-tests/test-applications/nuxt-4/server/api/db-test.ts index 7817c0b4db3d..4460758ab414 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-4/server/api/db-test.ts +++ b/dev-packages/e2e-tests/test-applications/nuxt-4/server/api/db-test.ts @@ -52,7 +52,8 @@ export default defineEventHandler(async event => { } case 'exec': { - await db.exec('CREATE TABLE IF NOT EXISTS logs (id INTEGER PRIMARY KEY, message TEXT, level TEXT)'); + 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 }; } From 110de45e58bb383527acebc70fb9171fe8c96038 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Thu, 16 Oct 2025 17:15:05 +0300 Subject: [PATCH 16/20] fix: comments --- packages/nuxt/src/module.ts | 2 +- packages/nuxt/src/runtime/plugins/database.server.ts | 10 +++++----- packages/nuxt/src/vite/databaseConfig.ts | 12 +++++++----- 3 files changed, 13 insertions(+), 11 deletions(-) diff --git a/packages/nuxt/src/module.ts b/packages/nuxt/src/module.ts index deddf68a270f..e6adc562fe44 100644 --- a/packages/nuxt/src/module.ts +++ b/packages/nuxt/src/module.ts @@ -129,7 +129,7 @@ export default defineNuxtModule({ if (serverConfigFile) { addMiddlewareImports(); addStorageInstrumentation(nuxt); - addDatabaseInstrumentation(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 index 5c164fc08579..635c9b2f4943 100644 --- a/packages/nuxt/src/runtime/plugins/database.server.ts +++ b/packages/nuxt/src/runtime/plugins/database.server.ts @@ -31,23 +31,23 @@ const SENTRY_ORIGIN = 'auto.db.nuxt'; */ export default defineNitroPlugin(() => { try { - debug.log('@sentry/nuxt: Instrumenting databases...'); + debug.log('[Nitro Database Plugin]: Instrumenting databases...'); for (const instance of databaseInstances) { - debug.log('@sentry/nuxt: Instrumenting database instance:', instance); + debug.log('[Nitro Database Plugin]: Instrumenting database instance:', instance); const db = useDatabase(instance); instrumentDatabase(db); } - debug.log('@sentry/nuxt: Databases instrumented.'); + 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('@sentry/nuxt: Database instrumentation skipped during build time.'); + debug.log('[Nitro Database Plugin]: Database instrumentation skipped during build time.'); return; } - debug.error('@sentry/nuxt: Failed to instrument database:', error); + debug.error('[Nitro Database Plugin]: Failed to instrument database:', error); } }); diff --git a/packages/nuxt/src/vite/databaseConfig.ts b/packages/nuxt/src/vite/databaseConfig.ts index cf7750032b95..0a2dc1a435a5 100644 --- a/packages/nuxt/src/vite/databaseConfig.ts +++ b/packages/nuxt/src/vite/databaseConfig.ts @@ -1,16 +1,18 @@ import { addServerPlugin, createResolver } from '@nuxt/kit'; import { consoleSandbox } from '@sentry/core'; -import type { Nuxt } from 'nuxt/schema'; +import type { NitroConfig } from 'nitropack/types'; import { addServerTemplate } from '../vendor/server-template'; /** * Sets up the database instrumentation. */ -export function addDatabaseInstrumentation(nuxt: Nuxt): void { - if (!nuxt.options.nitro?.experimental?.database) { +export function addDatabaseInstrumentation(nitro: NitroConfig): void { + if (!nitro.experimental?.database) { consoleSandbox(() => { // eslint-disable-next-line no-console - console.log('[Sentry] No database configuration found. Skipping database instrumentation.'); + console.log( + '[Sentry] [Nitro Database Plugin]: No database configuration found. Skipping database instrumentation.', + ); }); return; @@ -21,7 +23,7 @@ export function addDatabaseInstrumentation(nuxt: Nuxt): void { * keys represent database names to be passed to `useDatabase(name?)`. * https://nitro.build/guide/database#configuration */ - const databaseInstances = Object.keys(nuxt.options.nitro.database || { default: {} }); + const databaseInstances = Object.keys(nitro.database || { default: {} }); // Create a virtual module to pass this data to runtime addServerTemplate({ From e805c8117f92687f0c12c0150fae9706d5828082 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Fri, 17 Oct 2025 12:27:29 +0300 Subject: [PATCH 17/20] fix: protect against double instrumentation of database instances just in case --- .../nuxt/src/runtime/plugins/database.server.ts | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/packages/nuxt/src/runtime/plugins/database.server.ts b/packages/nuxt/src/runtime/plugins/database.server.ts index 635c9b2f4943..0c91ec0e5d08 100644 --- a/packages/nuxt/src/runtime/plugins/database.server.ts +++ b/packages/nuxt/src/runtime/plugins/database.server.ts @@ -16,6 +16,10 @@ import { defineNitroPlugin, useDatabase } from 'nitropack/runtime'; // @ts-expect-error - This is a virtual module import { databaseInstances } from '#sentry/database-config.mjs'; +type MaybeInstrumentedDatabase = Database & { + __sentry_instrumented__?: boolean; +}; + /** * Keeps track of prepared statements that have been patched. */ @@ -51,7 +55,12 @@ export default defineNitroPlugin(() => { } }); -function instrumentDatabase(db: Database): void { +function instrumentDatabase(db: MaybeInstrumentedDatabase): void { + if (db.__sentry_instrumented__) { + debug.log('[Nitro Database Plugin]: Database already instrumented. Skipping...'); + return; + } + db.prepare = new Proxy(db.prepare, { apply(target, thisArg, args: Parameters) { const [query] = args; @@ -83,6 +92,8 @@ function instrumentDatabase(db: Database): void { ); }, }); + + db.__sentry_instrumented__ = true; } /** From 04a9a449b1801b8d65696214f5f824c6f09909c2 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Fri, 17 Oct 2025 13:39:30 +0300 Subject: [PATCH 18/20] feat: populate span with additional attrs --- .../src/runtime/plugins/database.server.ts | 51 +++-- packages/nuxt/src/runtime/utils/database.ts | 46 ++++ packages/nuxt/src/vite/databaseConfig.ts | 5 +- .../nuxt/test/runtime/utils/database.test.ts | 199 ++++++++++++++++++ 4 files changed, 284 insertions(+), 17 deletions(-) create mode 100644 packages/nuxt/src/runtime/utils/database.ts create mode 100644 packages/nuxt/test/runtime/utils/database.test.ts diff --git a/packages/nuxt/src/runtime/plugins/database.server.ts b/packages/nuxt/src/runtime/plugins/database.server.ts index 0c91ec0e5d08..01969e7f6eec 100644 --- a/packages/nuxt/src/runtime/plugins/database.server.ts +++ b/packages/nuxt/src/runtime/plugins/database.server.ts @@ -13,13 +13,20 @@ import { import type { Database, PreparedStatement } from 'db0'; // eslint-disable-next-line import/no-extraneous-dependencies import { defineNitroPlugin, useDatabase } from 'nitropack/runtime'; +import type { DatabaseConnectionConfig as DatabaseConfig } from 'nitropack/types'; // @ts-expect-error - This is a virtual module -import { databaseInstances } from '#sentry/database-config.mjs'; +import { databaseConfig } from '#sentry/database-config.mjs'; +import { getDatabaseSpanData } from '../utils/database'; type MaybeInstrumentedDatabase = Database & { __sentry_instrumented__?: boolean; }; +interface DatabaseSpanData { + [key: string]: string | undefined; + 'db.system.name': string; +} + /** * Keeps track of prepared statements that have been patched. */ @@ -35,12 +42,14 @@ const SENTRY_ORIGIN = 'auto.db.nuxt'; */ export default defineNitroPlugin(() => { try { + const _databaseConfig = databaseConfig as Record; + const databaseInstances = Object.keys(databaseConfig); debug.log('[Nitro Database Plugin]: Instrumenting databases...'); for (const instance of databaseInstances) { debug.log('[Nitro Database Plugin]: Instrumenting database instance:', instance); const db = useDatabase(instance); - instrumentDatabase(db); + instrumentDatabase(db, _databaseConfig[instance]); } debug.log('[Nitro Database Plugin]: Databases instrumented.'); @@ -55,17 +64,25 @@ export default defineNitroPlugin(() => { } }); -function instrumentDatabase(db: MaybeInstrumentedDatabase): void { +/** + * Instruments a database instance with Sentry. + */ +function instrumentDatabase(db: MaybeInstrumentedDatabase, config?: DatabaseConfig): void { if (db.__sentry_instrumented__) { debug.log('[Nitro Database Plugin]: Database already instrumented. Skipping...'); return; } + const metadata: DatabaseSpanData = { + 'db.system.name': config?.connector ?? db.dialect, + ...getDatabaseSpanData(config), + }; + db.prepare = new Proxy(db.prepare, { apply(target, thisArg, args: Parameters) { const [query] = args; - return instrumentPreparedStatement(target.apply(thisArg, args), query, db.dialect); + return instrumentPreparedStatement(target.apply(thisArg, args), query, metadata); }, }); @@ -75,7 +92,7 @@ function instrumentDatabase(db: MaybeInstrumentedDatabase): void { db.sql = new Proxy(db.sql, { apply(target, thisArg, args: Parameters) { const query = args[0]?.[0] ?? ''; - const opts = createStartSpanOptions(query, db.dialect); + const opts = createStartSpanOptions(query, metadata); return startSpan( opts, @@ -87,7 +104,7 @@ function instrumentDatabase(db: MaybeInstrumentedDatabase): void { db.exec = new Proxy(db.exec, { apply(target, thisArg, args: Parameters) { return startSpan( - createStartSpanOptions(args[0], db.dialect), + createStartSpanOptions(args[0], metadata), handleSpanStart(() => target.apply(thisArg, args), { query: args[0] }), ); }, @@ -102,16 +119,20 @@ function instrumentDatabase(db: MaybeInstrumentedDatabase): void { * 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 { +function instrumentPreparedStatement( + statement: PreparedStatement, + query: string, + data: DatabaseSpanData, +): PreparedStatement { // statement.bind() returns a new instance of D1PreparedStatement, so we have to patch it as well. // eslint-disable-next-line @typescript-eslint/unbound-method statement.bind = new Proxy(statement.bind, { apply(target, thisArg, args: Parameters) { - return instrumentPreparedStatementQueries(target.apply(thisArg, args), query, dialect); + return instrumentPreparedStatementQueries(target.apply(thisArg, args), query, data); }, }); - return instrumentPreparedStatementQueries(statement, query, dialect); + return instrumentPreparedStatementQueries(statement, query, data); } /** @@ -120,7 +141,7 @@ function instrumentPreparedStatement(statement: PreparedStatement, query: string function instrumentPreparedStatementQueries( statement: PreparedStatement, query: string, - dialect: string, + data: DatabaseSpanData, ): PreparedStatement { if (patchedStatement.has(statement)) { return statement; @@ -130,7 +151,7 @@ function instrumentPreparedStatementQueries( statement.get = new Proxy(statement.get, { apply(target, thisArg, args: Parameters) { return startSpan( - createStartSpanOptions(query, dialect), + createStartSpanOptions(query, data), handleSpanStart(() => target.apply(thisArg, args), { query }), ); }, @@ -140,7 +161,7 @@ function instrumentPreparedStatementQueries( statement.run = new Proxy(statement.run, { apply(target, thisArg, args: Parameters) { return startSpan( - createStartSpanOptions(query, dialect), + createStartSpanOptions(query, data), handleSpanStart(() => target.apply(thisArg, args), { query }), ); }, @@ -150,7 +171,7 @@ function instrumentPreparedStatementQueries( statement.all = new Proxy(statement.all, { apply(target, thisArg, args: Parameters) { return startSpan( - createStartSpanOptions(query, dialect), + createStartSpanOptions(query, data), handleSpanStart(() => target.apply(thisArg, args), { query }), ); }, @@ -203,14 +224,14 @@ function createBreadcrumb(query: string): void { /** * Creates a start span options object. */ -function createStartSpanOptions(query: string, dialect: string): StartSpanOptions { +function createStartSpanOptions(query: string, data: DatabaseSpanData): 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', + ...data, }, }; } diff --git a/packages/nuxt/src/runtime/utils/database.ts b/packages/nuxt/src/runtime/utils/database.ts new file mode 100644 index 000000000000..6043e057bb38 --- /dev/null +++ b/packages/nuxt/src/runtime/utils/database.ts @@ -0,0 +1,46 @@ +import type { ConnectorName } from 'db0'; +import type { DatabaseConnectionConfig as DatabaseConfig } from 'nitropack/types'; + +interface DatabaseSpanData { + [key: string]: string | number | undefined; +} + +/** + * Extracts span attributes from the database configuration. + */ +export function getDatabaseSpanData(config?: DatabaseConfig): Partial { + try { + if (!config?.connector) { + // Default to SQLite if no connector is configured + return { + 'db.namespace': 'db.sqlite', + }; + } + + if (config.connector === 'postgresql' || config.connector === 'mysql2') { + return { + 'server.address': config.options?.host, + 'server.port': config.options?.port, + }; + } + + if (config.connector === 'pglite') { + return { + 'db.namespace': config.options?.dataDir, + }; + } + + if ((['better-sqlite3', 'bun', 'sqlite', 'sqlite3'] as ConnectorName[]).includes(config.connector)) { + return { + // DB is the default file name in nitro for sqlite-like connectors + 'db.namespace': `${config.options?.name ?? 'db'}.sqlite`, + }; + } + + return {}; + } catch { + // This is a best effort to get some attributes, so it is not an absolute must + // Since the user can configure invalid options, we should not fail the whole instrumentation. + return {}; + } +} diff --git a/packages/nuxt/src/vite/databaseConfig.ts b/packages/nuxt/src/vite/databaseConfig.ts index 0a2dc1a435a5..dfe27fd9821d 100644 --- a/packages/nuxt/src/vite/databaseConfig.ts +++ b/packages/nuxt/src/vite/databaseConfig.ts @@ -21,15 +21,16 @@ export function addDatabaseInstrumentation(nitro: NitroConfig): void { /** * This is a different option than the one in `experimental.database`, this configures multiple database instances. * keys represent database names to be passed to `useDatabase(name?)`. + * We also use the config to populate database span attributes. * https://nitro.build/guide/database#configuration */ - const databaseInstances = Object.keys(nitro.database || { default: {} }); + const databaseConfig = nitro.database || { default: {} }; // Create a virtual module to pass this data to runtime addServerTemplate({ filename: '#sentry/database-config.mjs', getContents: () => { - return `export const databaseInstances = ${JSON.stringify(databaseInstances)};`; + return `export const databaseConfig = ${JSON.stringify(databaseConfig)};`; }, }); diff --git a/packages/nuxt/test/runtime/utils/database.test.ts b/packages/nuxt/test/runtime/utils/database.test.ts new file mode 100644 index 000000000000..989ae43e7130 --- /dev/null +++ b/packages/nuxt/test/runtime/utils/database.test.ts @@ -0,0 +1,199 @@ +import type { ConnectorName } from 'db0'; +import type { DatabaseConnectionConfig as DatabaseConfig } from 'nitropack/types'; +import { describe, expect, it } from 'vitest'; +import { getDatabaseSpanData } from '../../../src/runtime/utils/database'; + +describe('getDatabaseSpanData', () => { + describe('no config', () => { + it('should return default SQLite namespace when no config provided', () => { + const result = getDatabaseSpanData(); + expect(result).toEqual({ + 'db.namespace': 'db.sqlite', + }); + }); + + it('should return default SQLite namespace when config has no connector', () => { + const result = getDatabaseSpanData({} as DatabaseConfig); + expect(result).toEqual({ + 'db.namespace': 'db.sqlite', + }); + }); + }); + + describe('PostgreSQL connector', () => { + it('should extract host and port for postgresql', () => { + const config: DatabaseConfig = { + connector: 'postgresql' as ConnectorName, + options: { + host: 'localhost', + port: 5432, + }, + }; + + const result = getDatabaseSpanData(config); + expect(result).toEqual({ + 'server.address': 'localhost', + 'server.port': 5432, + }); + }); + + it('should handle missing options for postgresql', () => { + const config: DatabaseConfig = { + connector: 'postgresql' as ConnectorName, + }; + + const result = getDatabaseSpanData(config); + expect(result).toEqual({ + 'server.address': undefined, + 'server.port': undefined, + }); + }); + + it('should handle partial options for postgresql', () => { + const config: DatabaseConfig = { + connector: 'postgresql' as ConnectorName, + options: { + host: 'pg-host', + }, + }; + + const result = getDatabaseSpanData(config); + expect(result).toEqual({ + 'server.address': 'pg-host', + 'server.port': undefined, + }); + }); + }); + + describe('MySQL connector', () => { + it('should extract host and port for mysql2', () => { + const config: DatabaseConfig = { + connector: 'mysql2' as ConnectorName, + options: { + host: 'mysql-host', + port: 3306, + }, + }; + + const result = getDatabaseSpanData(config); + expect(result).toEqual({ + 'server.address': 'mysql-host', + 'server.port': 3306, + }); + }); + + it('should handle missing options for mysql2', () => { + const config: DatabaseConfig = { + connector: 'mysql2' as ConnectorName, + }; + + const result = getDatabaseSpanData(config); + expect(result).toEqual({ + 'server.address': undefined, + 'server.port': undefined, + }); + }); + }); + + describe('PGLite connector', () => { + it('should extract dataDir for pglite', () => { + const config: DatabaseConfig = { + connector: 'pglite' as ConnectorName, + options: { + dataDir: '/path/to/data', + }, + }; + + const result = getDatabaseSpanData(config); + expect(result).toEqual({ + 'db.namespace': '/path/to/data', + }); + }); + + it('should handle missing dataDir for pglite', () => { + const config: DatabaseConfig = { + connector: 'pglite' as ConnectorName, + options: {}, + }; + + const result = getDatabaseSpanData(config); + expect(result).toEqual({ + 'db.namespace': undefined, + }); + }); + }); + + describe('SQLite-like connectors', () => { + it.each(['better-sqlite3', 'bun', 'sqlite', 'sqlite3'] as ConnectorName[])( + 'should extract database name for %s', + connector => { + const config: DatabaseConfig = { + connector, + options: { + name: 'custom-db', + }, + }; + + const result = getDatabaseSpanData(config); + expect(result).toEqual({ + 'db.namespace': 'custom-db.sqlite', + }); + }, + ); + + it.each(['better-sqlite3', 'bun', 'sqlite', 'sqlite3'] as ConnectorName[])( + 'should use default name for %s when name is not provided', + connector => { + const config: DatabaseConfig = { + connector, + options: {}, + }; + + const result = getDatabaseSpanData(config); + expect(result).toEqual({ + 'db.namespace': 'db.sqlite', + }); + }, + ); + + it.each(['better-sqlite3', 'bun', 'sqlite', 'sqlite3'] as ConnectorName[])( + 'should handle missing options for %s', + connector => { + const config: DatabaseConfig = { + connector, + }; + + const result = getDatabaseSpanData(config); + expect(result).toEqual({ + 'db.namespace': 'db.sqlite', + }); + }, + ); + }); + + describe('unsupported connector', () => { + it('should return empty object for unsupported connector', () => { + const config: DatabaseConfig = { + connector: 'unknown-connector' as ConnectorName, + }; + + const result = getDatabaseSpanData(config); + expect(result).toEqual({}); + }); + }); + + describe('error handling', () => { + it('should return empty object when accessing invalid config throws', () => { + // Simulate a config that might throw during access + const invalidConfig = { + connector: 'postgresql' as ConnectorName, + get options(): never { + throw new Error('Invalid access'); + }, + }; + + const result = getDatabaseSpanData(invalidConfig as unknown as DatabaseConfig); + expect(result).toEqual({}); + }); + }); +}); From 5a67c001898e21678a23465e79626d80480d00fa Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Fri, 17 Oct 2025 13:43:11 +0300 Subject: [PATCH 19/20] refactor: rename util file --- packages/nuxt/src/runtime/plugins/database.server.ts | 2 +- .../src/runtime/utils/{database.ts => database-span-data.ts} | 0 .../utils/{database.test.ts => database-span-data.test.ts} | 2 +- 3 files changed, 2 insertions(+), 2 deletions(-) rename packages/nuxt/src/runtime/utils/{database.ts => database-span-data.ts} (100%) rename packages/nuxt/test/runtime/utils/{database.test.ts => database-span-data.test.ts} (99%) diff --git a/packages/nuxt/src/runtime/plugins/database.server.ts b/packages/nuxt/src/runtime/plugins/database.server.ts index 01969e7f6eec..d0f8e3a9eca6 100644 --- a/packages/nuxt/src/runtime/plugins/database.server.ts +++ b/packages/nuxt/src/runtime/plugins/database.server.ts @@ -16,7 +16,7 @@ import { defineNitroPlugin, useDatabase } from 'nitropack/runtime'; import type { DatabaseConnectionConfig as DatabaseConfig } from 'nitropack/types'; // @ts-expect-error - This is a virtual module import { databaseConfig } from '#sentry/database-config.mjs'; -import { getDatabaseSpanData } from '../utils/database'; +import { getDatabaseSpanData } from '../utils/database-span-data'; type MaybeInstrumentedDatabase = Database & { __sentry_instrumented__?: boolean; diff --git a/packages/nuxt/src/runtime/utils/database.ts b/packages/nuxt/src/runtime/utils/database-span-data.ts similarity index 100% rename from packages/nuxt/src/runtime/utils/database.ts rename to packages/nuxt/src/runtime/utils/database-span-data.ts diff --git a/packages/nuxt/test/runtime/utils/database.test.ts b/packages/nuxt/test/runtime/utils/database-span-data.test.ts similarity index 99% rename from packages/nuxt/test/runtime/utils/database.test.ts rename to packages/nuxt/test/runtime/utils/database-span-data.test.ts index 989ae43e7130..fc4f4b376af8 100644 --- a/packages/nuxt/test/runtime/utils/database.test.ts +++ b/packages/nuxt/test/runtime/utils/database-span-data.test.ts @@ -1,7 +1,7 @@ import type { ConnectorName } from 'db0'; import type { DatabaseConnectionConfig as DatabaseConfig } from 'nitropack/types'; import { describe, expect, it } from 'vitest'; -import { getDatabaseSpanData } from '../../../src/runtime/utils/database'; +import { getDatabaseSpanData } from '../../../src/runtime/utils/database-span-data'; describe('getDatabaseSpanData', () => { describe('no config', () => { From ce6f4bdfeeffc1f1d89df5e62a04fd0e433f488e Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Fri, 17 Oct 2025 14:06:03 +0300 Subject: [PATCH 20/20] refactor: re-use same type --- packages/nuxt/src/runtime/plugins/database.server.ts | 7 +------ packages/nuxt/src/runtime/utils/database-span-data.ts | 2 +- 2 files changed, 2 insertions(+), 7 deletions(-) diff --git a/packages/nuxt/src/runtime/plugins/database.server.ts b/packages/nuxt/src/runtime/plugins/database.server.ts index d0f8e3a9eca6..9cdff58d336e 100644 --- a/packages/nuxt/src/runtime/plugins/database.server.ts +++ b/packages/nuxt/src/runtime/plugins/database.server.ts @@ -16,17 +16,12 @@ import { defineNitroPlugin, useDatabase } from 'nitropack/runtime'; import type { DatabaseConnectionConfig as DatabaseConfig } from 'nitropack/types'; // @ts-expect-error - This is a virtual module import { databaseConfig } from '#sentry/database-config.mjs'; -import { getDatabaseSpanData } from '../utils/database-span-data'; +import { type DatabaseSpanData, getDatabaseSpanData } from '../utils/database-span-data'; type MaybeInstrumentedDatabase = Database & { __sentry_instrumented__?: boolean; }; -interface DatabaseSpanData { - [key: string]: string | undefined; - 'db.system.name': string; -} - /** * Keeps track of prepared statements that have been patched. */ diff --git a/packages/nuxt/src/runtime/utils/database-span-data.ts b/packages/nuxt/src/runtime/utils/database-span-data.ts index 6043e057bb38..e5d9c8dc7cec 100644 --- a/packages/nuxt/src/runtime/utils/database-span-data.ts +++ b/packages/nuxt/src/runtime/utils/database-span-data.ts @@ -1,7 +1,7 @@ import type { ConnectorName } from 'db0'; import type { DatabaseConnectionConfig as DatabaseConfig } from 'nitropack/types'; -interface DatabaseSpanData { +export interface DatabaseSpanData { [key: string]: string | number | undefined; }