From d059df3e93b0ac421123e44311b0be0e46da11a0 Mon Sep 17 00:00:00 2001 From: Nicolas Hrubec Date: Fri, 20 Mar 2026 12:43:36 +0100 Subject: [PATCH] fix(core): Do not overwrite user provided conversation id in Vercel (#19903) We set the conversation id unconditionally based on the `resonse_id`, so if a user calls `Sentry.setConversationId()` explicitly this will be overwritten, which is unexpected. Closes #19904 (added automatically) --- .../runtime/plugins/database-legacy.server.ts | 13 + .../src/runtime/plugins/database.server.ts | 231 +----------- .../runtime/plugins/storage-legacy.server.ts | 12 + .../src/runtime/plugins/storage.server.ts | 312 +--------------- .../src/runtime/utils/database-span-data.ts | 18 +- .../src/runtime/utils/instrumentDatabase.ts | 232 ++++++++++++ .../src/runtime/utils/instrumentStorage.ts | 340 ++++++++++++++++++ packages/nuxt/src/vite/databaseConfig.ts | 2 +- packages/nuxt/src/vite/storageConfig.ts | 2 +- 9 files changed, 626 insertions(+), 536 deletions(-) create mode 100644 packages/nuxt/src/runtime/plugins/database-legacy.server.ts create mode 100644 packages/nuxt/src/runtime/plugins/storage-legacy.server.ts create mode 100644 packages/nuxt/src/runtime/utils/instrumentDatabase.ts create mode 100644 packages/nuxt/src/runtime/utils/instrumentStorage.ts diff --git a/packages/nuxt/src/runtime/plugins/database-legacy.server.ts b/packages/nuxt/src/runtime/plugins/database-legacy.server.ts new file mode 100644 index 000000000000..fc9dca7c964c --- /dev/null +++ b/packages/nuxt/src/runtime/plugins/database-legacy.server.ts @@ -0,0 +1,13 @@ +import type { NitroAppPlugin } from 'nitropack'; +import { useDatabase } from 'nitropack/runtime'; +// @ts-expect-error - This is a virtual module +import { databaseConfig } from '#sentry/database-config.mjs'; +import type { DatabaseConnectionConfig } from '../utils/database-span-data'; +import { createDatabasePlugin } from '../utils/instrumentDatabase'; + +/** + * Nitro plugin that instruments database calls for Nuxt v3/v4 (Nitro v2) + */ +export default (() => { + createDatabasePlugin(useDatabase, databaseConfig as Record); +}) satisfies NitroAppPlugin; diff --git a/packages/nuxt/src/runtime/plugins/database.server.ts b/packages/nuxt/src/runtime/plugins/database.server.ts index ffdc1fceba18..71c4b6011dae 100644 --- a/packages/nuxt/src/runtime/plugins/database.server.ts +++ b/packages/nuxt/src/runtime/plugins/database.server.ts @@ -1,232 +1,13 @@ -import { - addBreadcrumb, - captureException, - debug, - flushIfServerless, - SEMANTIC_ATTRIBUTE_SENTRY_OP, - SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, - type Span, - SPAN_STATUS_ERROR, - startSpan, - type StartSpanOptions, -} from '@sentry/core'; -import type { Database, PreparedStatement } from 'db0'; -import type { NitroAppPlugin } from 'nitropack'; -import { useDatabase } from 'nitropack/runtime'; -import type { DatabaseConnectionConfig as DatabaseConfig } from 'nitropack/types'; +import type { NitroAppPlugin } from 'nitro/types'; +import { useDatabase } from 'nitro/database'; // @ts-expect-error - This is a virtual module import { databaseConfig } from '#sentry/database-config.mjs'; -import { type DatabaseSpanData, getDatabaseSpanData } from '../utils/database-span-data'; - -type MaybeInstrumentedDatabase = Database & { - __sentry_instrumented__?: boolean; -}; - -/** - * Keeps track of prepared statements that have been patched. - */ -const patchedStatement = new WeakSet(); +import type { DatabaseConnectionConfig } from '../utils/database-span-data'; +import { createDatabasePlugin } from '../utils/instrumentDatabase'; /** - * The Sentry origin for the database plugin. - */ -const SENTRY_ORIGIN = 'auto.db.nuxt'; - -/** - * Creates a Nitro plugin that instruments the database calls. + * Nitro plugin that instruments database calls for Nuxt v5+ (Nitro v3+) */ export default (() => { - try { - const _databaseConfig = databaseConfig as Record; - const databaseInstances = Object.keys(databaseConfig); - debug.log('[Nitro Database Plugin]: Instrumenting databases...'); - - for (const instance of databaseInstances) { - debug.log('[Nitro Database Plugin]: Instrumenting database instance:', instance); - const db = useDatabase(instance); - instrumentDatabase(db, _databaseConfig[instance]); - } - - debug.log('[Nitro Database Plugin]: Databases instrumented.'); - } catch (error) { - // During build time, we can't use the useDatabase function, so we just log an error. - if (error instanceof Error && /Cannot access 'instances'/.test(error.message)) { - debug.log('[Nitro Database Plugin]: Database instrumentation skipped during build time.'); - return; - } - - debug.error('[Nitro Database Plugin]: Failed to instrument database:', error); - } + createDatabasePlugin(useDatabase, databaseConfig as Record); }) satisfies NitroAppPlugin; - -/** - * Instruments a database instance with Sentry. - */ -function instrumentDatabase(db: MaybeInstrumentedDatabase, config?: DatabaseConfig): void { - if (db.__sentry_instrumented__) { - debug.log('[Nitro Database Plugin]: Database already instrumented. Skipping...'); - return; - } - - const metadata: DatabaseSpanData = { - 'db.system.name': config?.connector ?? db.dialect, - ...getDatabaseSpanData(config), - }; - - db.prepare = new Proxy(db.prepare, { - apply(target, thisArg, args: Parameters) { - const [query] = args; - - return instrumentPreparedStatement(target.apply(thisArg, args), query, metadata); - }, - }); - - // Sadly the `.sql` template tag doesn't call `db.prepare` internally and it calls the connector's `.prepare` directly - // So we have to patch it manually, and would mean we would have less info in the spans. - // https://github.com/unjs/db0/blob/main/src/database.ts#L64 - db.sql = new Proxy(db.sql, { - apply(target, thisArg, args: Parameters) { - const query = args[0]?.[0] ?? ''; - const opts = createStartSpanOptions(query, metadata); - - return startSpan( - opts, - handleSpanStart(() => target.apply(thisArg, args)), - ); - }, - }); - - db.exec = new Proxy(db.exec, { - apply(target, thisArg, args: Parameters) { - return startSpan( - createStartSpanOptions(args[0], metadata), - handleSpanStart(() => target.apply(thisArg, args), { query: args[0] }), - ); - }, - }); - - db.__sentry_instrumented__ = true; -} - -/** - * Instruments a DB prepared statement with Sentry. - * - * This is meant to be used as a top-level call, under the hood it calls `instrumentPreparedStatementQueries` - * to patch the query methods. The reason for this abstraction is to ensure that the `bind` method is also patched. - */ -function instrumentPreparedStatement( - statement: PreparedStatement, - query: string, - data: DatabaseSpanData, -): PreparedStatement { - // statement.bind() returns a new instance of D1PreparedStatement, so we have to patch it as well. - // eslint-disable-next-line @typescript-eslint/unbound-method - statement.bind = new Proxy(statement.bind, { - apply(target, thisArg, args: Parameters) { - return instrumentPreparedStatementQueries(target.apply(thisArg, args), query, data); - }, - }); - - return instrumentPreparedStatementQueries(statement, query, data); -} - -/** - * Patches the query methods of a DB prepared statement with Sentry. - */ -function instrumentPreparedStatementQueries( - statement: PreparedStatement, - query: string, - data: DatabaseSpanData, -): PreparedStatement { - if (patchedStatement.has(statement)) { - return statement; - } - - // eslint-disable-next-line @typescript-eslint/unbound-method - statement.get = new Proxy(statement.get, { - apply(target, thisArg, args: Parameters) { - return startSpan( - createStartSpanOptions(query, data), - handleSpanStart(() => target.apply(thisArg, args), { query }), - ); - }, - }); - - // eslint-disable-next-line @typescript-eslint/unbound-method - statement.run = new Proxy(statement.run, { - apply(target, thisArg, args: Parameters) { - return startSpan( - createStartSpanOptions(query, data), - handleSpanStart(() => target.apply(thisArg, args), { query }), - ); - }, - }); - - // eslint-disable-next-line @typescript-eslint/unbound-method - statement.all = new Proxy(statement.all, { - apply(target, thisArg, args: Parameters) { - return startSpan( - createStartSpanOptions(query, data), - handleSpanStart(() => target.apply(thisArg, args), { query }), - ); - }, - }); - - patchedStatement.add(statement); - - return statement; -} - -/** - * Creates a span start callback handler - */ -function handleSpanStart(fn: () => unknown, breadcrumbOpts?: { query: string }) { - return async (span: Span) => { - try { - const result = await fn(); - if (breadcrumbOpts) { - createBreadcrumb(breadcrumbOpts.query); - } - - return result; - } catch (error) { - span.setStatus({ code: SPAN_STATUS_ERROR, message: 'internal_error' }); - captureException(error, { - mechanism: { - handled: false, - type: SENTRY_ORIGIN, - }, - }); - - // Re-throw the error to be handled by the caller - throw error; - } finally { - await flushIfServerless(); - } - }; -} - -function createBreadcrumb(query: string): void { - addBreadcrumb({ - category: 'query', - message: query, - data: { - 'db.query.text': query, - }, - }); -} - -/** - * Creates a start span options object. - */ -function createStartSpanOptions(query: string, data: DatabaseSpanData): StartSpanOptions { - return { - name: query, - attributes: { - 'db.query.text': query, - [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: SENTRY_ORIGIN, - [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'db.query', - ...data, - }, - }; -} diff --git a/packages/nuxt/src/runtime/plugins/storage-legacy.server.ts b/packages/nuxt/src/runtime/plugins/storage-legacy.server.ts new file mode 100644 index 000000000000..77b6a390d0e9 --- /dev/null +++ b/packages/nuxt/src/runtime/plugins/storage-legacy.server.ts @@ -0,0 +1,12 @@ +import type { NitroAppPlugin } from 'nitropack'; +import { useStorage } from 'nitropack/runtime'; +// @ts-expect-error - This is a virtual module +import { userStorageMounts } from '#sentry/storage-config.mjs'; +import { createStoragePlugin } from '../utils/instrumentStorage'; + +/** + * Nitro plugin that instruments storage driver calls for Nuxt v3/v4 (Nitro v2) + */ +export default (async _nitroApp => { + await createStoragePlugin(useStorage, userStorageMounts as string[]); +}) satisfies NitroAppPlugin; diff --git a/packages/nuxt/src/runtime/plugins/storage.server.ts b/packages/nuxt/src/runtime/plugins/storage.server.ts index 1c5a3fd678d4..f9763b51d47b 100644 --- a/packages/nuxt/src/runtime/plugins/storage.server.ts +++ b/packages/nuxt/src/runtime/plugins/storage.server.ts @@ -1,314 +1,12 @@ -import { - captureException, - debug, - flushIfServerless, - SEMANTIC_ATTRIBUTE_CACHE_HIT, - SEMANTIC_ATTRIBUTE_CACHE_KEY, - SEMANTIC_ATTRIBUTE_SENTRY_OP, - SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, - SPAN_STATUS_ERROR, - SPAN_STATUS_OK, - type SpanAttributes, - startSpan, - type StartSpanOptions, -} from '@sentry/core'; -import type { NitroAppPlugin } from 'nitropack'; -import { useStorage } from 'nitropack/runtime'; -import type { CacheEntry, ResponseCacheEntry } from 'nitropack/types'; -import type { Driver, Storage } from 'unstorage'; +import type { NitroAppPlugin } from 'nitro/types'; +import { useStorage } from 'nitro/storage'; // @ts-expect-error - This is a virtual module import { userStorageMounts } from '#sentry/storage-config.mjs'; - -type MaybeInstrumented = T & { - __sentry_instrumented__?: boolean; -}; - -type MaybeInstrumentedDriver = MaybeInstrumented; - -type DriverMethod = keyof Driver; +import { createStoragePlugin } from '../utils/instrumentStorage'; /** - * Methods that should have a attribute to indicate a cache hit. - */ -const CACHE_HIT_METHODS = new Set(['hasItem', 'getItem', 'getItemRaw']); - -/** - * Creates a Nitro plugin that instruments the storage driver. + * Nitro plugin that instruments storage driver calls for Nuxt v5+ (Nitro v3+) */ export default (async _nitroApp => { - // This runs at runtime when the Nitro server starts - const storage = useStorage(); - // Mounts are suffixed with a colon, so we need to add it to the set items - const userMounts = new Set((userStorageMounts as string[]).map(m => `${m}:`)); - - debug.log('[storage] Starting to instrument storage drivers...'); - - // Adds cache mount to handle Nitro's cache calls - // Nitro uses the mount to cache functions and event handlers - // https://nitro.build/guide/cache - userMounts.add('cache:'); - // In production, unless the user configured a specific cache driver, Nitro will use the memory driver at root mount. - // Either way, we need to instrument the root mount as well. - userMounts.add(''); - - // Get all mounted storage drivers - const mounts = storage.getMounts(); - for (const mount of mounts) { - // Skip excluded mounts and root mount - if (!userMounts.has(mount.base)) { - continue; - } - - instrumentDriver(mount.driver, mount.base); - } - - // Wrap the mount method to instrument future mounts - storage.mount = wrapStorageMount(storage); + await createStoragePlugin(useStorage, userStorageMounts as string[]); }) satisfies NitroAppPlugin; - -/** - * Instruments a driver by wrapping all method calls using proxies. - */ -function instrumentDriver(driver: MaybeInstrumentedDriver, mountBase: string): Driver { - // Already instrumented, skip... - if (driver.__sentry_instrumented__) { - debug.log(`[storage] Driver already instrumented: "${driver.name}". Skipping...`); - - return driver; - } - - debug.log(`[storage] Instrumenting driver: "${driver.name}" on mount: "${mountBase}"`); - - // List of driver methods to instrument - // get/set/remove are aliases and already use their {method}Item methods - const methodsToInstrument: DriverMethod[] = [ - 'hasItem', - 'getItem', - 'getItemRaw', - 'getItems', - 'setItem', - 'setItemRaw', - 'setItems', - 'removeItem', - 'getKeys', - 'clear', - ]; - - for (const methodName of methodsToInstrument) { - const original = driver[methodName]; - // Skip if method doesn't exist on this driver - if (typeof original !== 'function') { - continue; - } - - // Replace with instrumented - driver[methodName] = createMethodWrapper(original, methodName, driver, mountBase); - } - - // Mark as instrumented - driver.__sentry_instrumented__ = true; - - return driver; -} - -/** - * Creates an instrumented method for the given method. - */ -function createMethodWrapper( - original: (...args: unknown[]) => unknown, - methodName: DriverMethod, - driver: Driver, - mountBase: string, -): (...args: unknown[]) => unknown { - return new Proxy(original, { - async apply(target, thisArg, args) { - const options = createSpanStartOptions(methodName, driver, mountBase, args); - - debug.log(`[storage] Running method: "${methodName}" on driver: "${driver.name ?? 'unknown'}"`); - - return startSpan(options, async span => { - try { - const result = await target.apply(thisArg, args); - span.setStatus({ code: SPAN_STATUS_OK }); - - if (CACHE_HIT_METHODS.has(methodName)) { - span.setAttribute(SEMANTIC_ATTRIBUTE_CACHE_HIT, isCacheHit(args[0], result)); - } - - return result; - } catch (error) { - span.setStatus({ code: SPAN_STATUS_ERROR, message: 'internal_error' }); - captureException(error, { - mechanism: { - handled: false, - type: options.attributes?.[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN], - }, - }); - - // Re-throw the error to be handled by the caller - throw error; - } finally { - await flushIfServerless(); - } - }); - }, - }); -} - -/** - * Wraps the storage mount method to instrument the driver. - */ -function wrapStorageMount(storage: Storage): Storage['mount'] { - const original: MaybeInstrumented = storage.mount; - if (original.__sentry_instrumented__) { - return original; - } - - function mountWithInstrumentation(base: string, driver: Driver): Storage { - debug.log(`[storage] Instrumenting mount: "${base}"`); - - const instrumentedDriver = instrumentDriver(driver, base); - - return original(base, instrumentedDriver); - } - - mountWithInstrumentation.__sentry_instrumented__ = true; - - return mountWithInstrumentation; -} -/** - * Normalizes the method name to snake_case to be used in span names or op. - */ -function normalizeMethodName(methodName: string): string { - return methodName.replace(/[A-Z]/g, letter => `_${letter.toLowerCase()}`); -} - -/** - * Checks if the value is empty, used for cache hit detection. - */ -function isEmptyValue(value: unknown): value is null | undefined { - return value === null || value === undefined; -} - -/** - * Creates the span start options for the storage method. - */ -function createSpanStartOptions( - methodName: keyof Driver, - driver: Driver, - mountBase: string, - args: unknown[], -): StartSpanOptions { - const keys = getCacheKeys(args?.[0], mountBase); - - const attributes: SpanAttributes = { - [SEMANTIC_ATTRIBUTE_SENTRY_OP]: `cache.${normalizeMethodName(methodName)}`, - [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.cache.nuxt', - [SEMANTIC_ATTRIBUTE_CACHE_KEY]: keys.length > 1 ? keys : keys[0], - 'db.operation.name': methodName, - 'db.collection.name': mountBase.replace(/:$/, ''), - 'db.system.name': driver.name ?? 'unknown', - }; - - return { - name: keys.join(', '), - attributes, - }; -} - -/** - * Gets a normalized array of cache keys. - */ -function getCacheKeys(key: unknown, prefix: string): string[] { - // Handles an array of keys - if (Array.isArray(key)) { - return key.map(k => normalizeKey(k, prefix)); - } - - return [normalizeKey(key, prefix)]; -} - -/** - * Normalizes the key to a string for `cache.key` attribute. - */ -function normalizeKey(key: unknown, prefix: string): string { - if (typeof key === 'string') { - return `${prefix}${key}`; - } - - // Handles an object with a key property - if (typeof key === 'object' && key !== null && 'key' in key) { - return `${prefix}${key.key}`; - } - - return `${prefix}${isEmptyValue(key) ? '' : String(key)}`; -} - -const CACHED_FN_HANDLERS_RE = /^nitro:(functions|handlers):/i; - -/** - * Since Nitro's cache may not utilize the driver's TTL, it is possible that the value is present in the cache but won't be used by Nitro. - * The maxAge and expires values are serialized by Nitro in the cache entry. This means the value presence does not necessarily mean a cache hit. - * So in order to properly report cache hits for `defineCachedFunction` and `defineCachedEventHandler` we need to check the cached value ourselves. - * First we check if the key matches the `defineCachedFunction` or `defineCachedEventHandler` key patterns, and if so we check the cached value. - */ -function isCacheHit(key: string, value: unknown): boolean { - try { - const isEmpty = isEmptyValue(value); - // Empty value means no cache hit either way - // Or if key doesn't match the cached function or handler patterns, we can return the empty value check - if (isEmpty || !CACHED_FN_HANDLERS_RE.test(key)) { - return !isEmpty; - } - - return validateCacheEntry(key, JSON.parse(String(value)) as CacheEntry); - } catch { - // this is a best effort, so we return false if we can't validate the cache entry - return false; - } -} - -/** - * Validates the cache entry. - */ -function validateCacheEntry( - key: string, - entry: CacheEntry | CacheEntry, -): boolean { - if (isEmptyValue(entry.value)) { - return false; - } - - // Date.now is used by Nitro internally, so safe to use here. - // https://github.com/nitrojs/nitro/blob/5508f71b77730e967fb131de817725f5aa7c4862/src/runtime/internal/cache.ts#L78 - if (Date.now() > (entry.expires || 0)) { - return false; - } - - /** - * Pulled from Nitro's cache entry validation - * https://github.com/nitrojs/nitro/blob/5508f71b77730e967fb131de817725f5aa7c4862/src/runtime/internal/cache.ts#L223-L241 - */ - if (isResponseCacheEntry(key, entry)) { - if (entry.value.status >= 400) { - return false; - } - - if (entry.value.body === undefined) { - return false; - } - - if (entry.value.headers.etag === 'undefined' || entry.value.headers['last-modified'] === 'undefined') { - return false; - } - } - - return true; -} - -/** - * Checks if the cache entry is a response cache entry. - */ -function isResponseCacheEntry(key: string, _: CacheEntry): _ is CacheEntry { - return key.startsWith('nitro:handlers:'); -} diff --git a/packages/nuxt/src/runtime/utils/database-span-data.ts b/packages/nuxt/src/runtime/utils/database-span-data.ts index e5d9c8dc7cec..d69368c92e1e 100644 --- a/packages/nuxt/src/runtime/utils/database-span-data.ts +++ b/packages/nuxt/src/runtime/utils/database-span-data.ts @@ -1,14 +1,28 @@ import type { ConnectorName } from 'db0'; -import type { DatabaseConnectionConfig as DatabaseConfig } from 'nitropack/types'; export interface DatabaseSpanData { [key: string]: string | number | undefined; } +/** + * A minimal database connection configuration type compatible with both nitropack (Nitro v2) and nitro (Nitro v3+). + * Mirrors the shape of `DatabaseConnectionConfig` from both packages. + */ +export interface DatabaseConnectionConfig { + connector?: ConnectorName; + options?: { + host?: string; + port?: number; + dataDir?: string; + name?: string; + [key: string]: unknown; + }; +} + /** * Extracts span attributes from the database configuration. */ -export function getDatabaseSpanData(config?: DatabaseConfig): Partial { +export function getDatabaseSpanData(config?: DatabaseConnectionConfig): Partial { try { if (!config?.connector) { // Default to SQLite if no connector is configured diff --git a/packages/nuxt/src/runtime/utils/instrumentDatabase.ts b/packages/nuxt/src/runtime/utils/instrumentDatabase.ts new file mode 100644 index 000000000000..9f7d320fe390 --- /dev/null +++ b/packages/nuxt/src/runtime/utils/instrumentDatabase.ts @@ -0,0 +1,232 @@ +import { + addBreadcrumb, + captureException, + debug, + flushIfServerless, + SEMANTIC_ATTRIBUTE_SENTRY_OP, + SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, + type Span, + SPAN_STATUS_ERROR, + startSpan, + type StartSpanOptions, +} from '@sentry/core'; +import type { Database, PreparedStatement } from 'db0'; +import { type DatabaseConnectionConfig, type DatabaseSpanData, getDatabaseSpanData } from './database-span-data'; + +type MaybeInstrumentedDatabase = Database & { + __sentry_instrumented__?: boolean; +}; + +/** + * Keeps track of prepared statements that have been patched. + */ +const patchedStatement = new WeakSet(); + +/** + * The Sentry origin for the database plugin. + */ +const SENTRY_ORIGIN = 'auto.db.nuxt'; + +/** + * Creates the Nitro database plugin setup by instrumenting the configured database instances. + * + * Called from the version-specific plugin entry points (database.server.ts / database-legacy.server.ts) + * which supply the correct `useDatabase` import for their respective Nitro version. + */ +export function createDatabasePlugin( + useDatabase: (name: string) => Database, + databaseConfig: Record, +): void { + try { + const databaseInstances = Object.keys(databaseConfig); + debug.log('[Nitro Database Plugin]: Instrumenting databases...'); + + for (const instance of databaseInstances) { + debug.log('[Nitro Database Plugin]: Instrumenting database instance:', instance); + const db = useDatabase(instance); + instrumentDatabase(db, databaseConfig[instance]); + } + + debug.log('[Nitro Database Plugin]: Databases instrumented.'); + } catch (error) { + // During build time, we can't use the useDatabase function, so we just log an error. + if (error instanceof Error && /Cannot access 'instances'/.test(error.message)) { + debug.log('[Nitro Database Plugin]: Database instrumentation skipped during build time.'); + return; + } + + debug.error('[Nitro Database Plugin]: Failed to instrument database:', error); + } +} + +/** + * Instruments a database instance with Sentry. + */ +function instrumentDatabase(db: MaybeInstrumentedDatabase, config?: DatabaseConnectionConfig): void { + if (db.__sentry_instrumented__) { + debug.log('[Nitro Database Plugin]: Database already instrumented. Skipping...'); + return; + } + + const metadata: DatabaseSpanData = { + 'db.system.name': config?.connector ?? db.dialect, + ...getDatabaseSpanData(config), + }; + + db.prepare = new Proxy(db.prepare, { + apply(target, thisArg, args: Parameters) { + const [query] = args; + + return instrumentPreparedStatement(target.apply(thisArg, args), query, metadata); + }, + }); + + // Sadly the `.sql` template tag doesn't call `db.prepare` internally and it calls the connector's `.prepare` directly + // So we have to patch it manually, and would mean we would have less info in the spans. + // https://github.com/unjs/db0/blob/main/src/database.ts#L64 + db.sql = new Proxy(db.sql, { + apply(target, thisArg, args: Parameters) { + const query = args[0]?.[0] ?? ''; + const opts = createStartSpanOptions(query, metadata); + + return startSpan( + opts, + handleSpanStart(() => target.apply(thisArg, args)), + ); + }, + }); + + db.exec = new Proxy(db.exec, { + apply(target, thisArg, args: Parameters) { + return startSpan( + createStartSpanOptions(args[0], metadata), + handleSpanStart(() => target.apply(thisArg, args), { query: args[0] }), + ); + }, + }); + + db.__sentry_instrumented__ = true; +} + +/** + * Instruments a DB prepared statement with Sentry. + * + * This is meant to be used as a top-level call, under the hood it calls `instrumentPreparedStatementQueries` + * to patch the query methods. The reason for this abstraction is to ensure that the `bind` method is also patched. + */ +function instrumentPreparedStatement( + statement: PreparedStatement, + query: string, + data: DatabaseSpanData, +): PreparedStatement { + // statement.bind() returns a new instance of D1PreparedStatement, so we have to patch it as well. + // eslint-disable-next-line @typescript-eslint/unbound-method + statement.bind = new Proxy(statement.bind, { + apply(target, thisArg, args: Parameters) { + return instrumentPreparedStatementQueries(target.apply(thisArg, args), query, data); + }, + }); + + return instrumentPreparedStatementQueries(statement, query, data); +} + +/** + * Patches the query methods of a DB prepared statement with Sentry. + */ +function instrumentPreparedStatementQueries( + statement: PreparedStatement, + query: string, + data: DatabaseSpanData, +): PreparedStatement { + if (patchedStatement.has(statement)) { + return statement; + } + + // eslint-disable-next-line @typescript-eslint/unbound-method + statement.get = new Proxy(statement.get, { + apply(target, thisArg, args: Parameters) { + return startSpan( + createStartSpanOptions(query, data), + handleSpanStart(() => target.apply(thisArg, args), { query }), + ); + }, + }); + + // eslint-disable-next-line @typescript-eslint/unbound-method + statement.run = new Proxy(statement.run, { + apply(target, thisArg, args: Parameters) { + return startSpan( + createStartSpanOptions(query, data), + handleSpanStart(() => target.apply(thisArg, args), { query }), + ); + }, + }); + + // eslint-disable-next-line @typescript-eslint/unbound-method + statement.all = new Proxy(statement.all, { + apply(target, thisArg, args: Parameters) { + return startSpan( + createStartSpanOptions(query, data), + handleSpanStart(() => target.apply(thisArg, args), { query }), + ); + }, + }); + + patchedStatement.add(statement); + + return statement; +} + +/** + * Creates a span start callback handler. + */ +function handleSpanStart(fn: () => unknown, breadcrumbOpts?: { query: string }) { + return async (span: Span) => { + try { + const result = await fn(); + if (breadcrumbOpts) { + createBreadcrumb(breadcrumbOpts.query); + } + + return result; + } catch (error) { + span.setStatus({ code: SPAN_STATUS_ERROR, message: 'internal_error' }); + captureException(error, { + mechanism: { + handled: false, + type: SENTRY_ORIGIN, + }, + }); + + // Re-throw the error to be handled by the caller + throw error; + } finally { + await flushIfServerless(); + } + }; +} + +function createBreadcrumb(query: string): void { + addBreadcrumb({ + category: 'query', + message: query, + data: { + 'db.query.text': query, + }, + }); +} + +/** + * Creates a start span options object. + */ +function createStartSpanOptions(query: string, data: DatabaseSpanData): StartSpanOptions { + return { + name: query, + attributes: { + 'db.query.text': query, + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: SENTRY_ORIGIN, + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'db.query', + ...data, + }, + }; +} diff --git a/packages/nuxt/src/runtime/utils/instrumentStorage.ts b/packages/nuxt/src/runtime/utils/instrumentStorage.ts new file mode 100644 index 000000000000..e51666aba79b --- /dev/null +++ b/packages/nuxt/src/runtime/utils/instrumentStorage.ts @@ -0,0 +1,340 @@ +import { + captureException, + debug, + flushIfServerless, + SEMANTIC_ATTRIBUTE_CACHE_HIT, + SEMANTIC_ATTRIBUTE_CACHE_KEY, + SEMANTIC_ATTRIBUTE_SENTRY_OP, + SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, + SPAN_STATUS_ERROR, + SPAN_STATUS_OK, + type SpanAttributes, + startSpan, + type StartSpanOptions, +} from '@sentry/core'; +import type { Driver, Storage } from 'unstorage'; + +/** + * A minimal cache entry type compatible with both nitropack (Nitro v2) and nitro (Nitro v3+). + * Mirrors the shape of `CacheEntry` from both packages. + */ +interface CacheEntry { + value?: T; + expires?: number; +} + +/** + * A minimal response cache entry type compatible with both nitropack (Nitro v2) and nitro (Nitro v3+). + * Mirrors the shape of `ResponseCacheEntry` from both packages. + */ +interface ResponseCacheEntry { + status?: number; + body?: unknown; + headers?: Record; +} + +/** + * The Nitro-specific Storage interface that extends unstorage's Storage with `getMounts`. + * Both nitropack and nitro expose a storage with this shape at runtime. + */ +interface NitroStorage extends Storage { + getMounts(): Array<{ base: string; driver: Driver }>; +} + +type MaybeInstrumented = T & { + __sentry_instrumented__?: boolean; +}; + +type MaybeInstrumentedDriver = MaybeInstrumented; + +type DriverMethod = keyof Driver; + +/** + * Methods that should have an attribute to indicate a cache hit. + */ +const CACHE_HIT_METHODS = new Set(['hasItem', 'getItem', 'getItemRaw']); + +/** + * Creates the Nitro storage plugin setup by instrumenting all relevant storage drivers. + * + * The `useStorage` parameter is typed as `() => unknown` because nitropack (Nitro v2) and nitro (Nitro v3+) define `StorageValue` differently. + * Cast to `NitroStorage` is safe since all Nitro versions expose `getMounts()` at runtime. + */ +export async function createStoragePlugin(useStorage: () => unknown, userStorageMounts: string[]): Promise { + // This runs at runtime when the Nitro server starts + const storage = useStorage() as NitroStorage; + // Mounts are suffixed with a colon, so we need to add it to the set items + const userMounts = new Set(userStorageMounts.map(m => `${m}:`)); + + debug.log('[storage] Starting to instrument storage drivers...'); + + // Adds cache mount to handle Nitro's cache calls + // Nitro uses the mount to cache functions and event handlers + // https://nitro.build/guide/cache + userMounts.add('cache:'); + // In production, unless the user configured a specific cache driver, Nitro will use the memory driver at root mount. + // Either way, we need to instrument the root mount as well. + userMounts.add(''); + + // Get all mounted storage drivers + const mounts = storage.getMounts(); + for (const mount of mounts) { + // Skip excluded mounts and root mount + if (!userMounts.has(mount.base)) { + continue; + } + + instrumentDriver(mount.driver, mount.base); + } + + // Wrap the mount method to instrument future mounts + storage.mount = wrapStorageMount(storage); +} + +/** + * Instruments a driver by wrapping all method calls using proxies. + */ +function instrumentDriver(driver: MaybeInstrumentedDriver, mountBase: string): Driver { + // Already instrumented, skip... + if (driver.__sentry_instrumented__) { + debug.log(`[storage] Driver already instrumented: "${driver.name}". Skipping...`); + + return driver; + } + + debug.log(`[storage] Instrumenting driver: "${driver.name}" on mount: "${mountBase}"`); + + // List of driver methods to instrument + // get/set/remove are aliases and already use their {method}Item methods + const methodsToInstrument: DriverMethod[] = [ + 'hasItem', + 'getItem', + 'getItemRaw', + 'getItems', + 'setItem', + 'setItemRaw', + 'setItems', + 'removeItem', + 'getKeys', + 'clear', + ]; + + for (const methodName of methodsToInstrument) { + const original = driver[methodName]; + // Skip if method doesn't exist on this driver + if (typeof original !== 'function') { + continue; + } + + // Replace with instrumented + driver[methodName] = createMethodWrapper(original, methodName, driver, mountBase); + } + + // Mark as instrumented + driver.__sentry_instrumented__ = true; + + return driver; +} + +/** + * Creates an instrumented method for the given method. + */ +function createMethodWrapper( + original: (...args: unknown[]) => unknown, + methodName: DriverMethod, + driver: Driver, + mountBase: string, +): (...args: unknown[]) => unknown { + return new Proxy(original, { + async apply(target, thisArg, args) { + const options = createSpanStartOptions(methodName, driver, mountBase, args); + + debug.log(`[storage] Running method: "${methodName}" on driver: "${driver.name ?? 'unknown'}"`); + + return startSpan(options, async span => { + try { + const result = await target.apply(thisArg, args); + span.setStatus({ code: SPAN_STATUS_OK }); + + if (CACHE_HIT_METHODS.has(methodName)) { + span.setAttribute(SEMANTIC_ATTRIBUTE_CACHE_HIT, isCacheHit(args[0], result)); + } + + return result; + } catch (error) { + span.setStatus({ code: SPAN_STATUS_ERROR, message: 'internal_error' }); + captureException(error, { + mechanism: { + handled: false, + type: options.attributes?.[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN], + }, + }); + + // Re-throw the error to be handled by the caller + throw error; + } finally { + await flushIfServerless(); + } + }); + }, + }); +} + +/** + * Wraps the storage mount method to instrument the driver on future mounts. + */ +function wrapStorageMount(storage: Storage): Storage['mount'] { + const original: MaybeInstrumented = storage.mount; + if (original.__sentry_instrumented__) { + return original; + } + + function mountWithInstrumentation(base: string, driver: Driver): Storage { + debug.log(`[storage] Instrumenting mount: "${base}"`); + + const instrumentedDriver = instrumentDriver(driver, base); + + return original(base, instrumentedDriver); + } + + mountWithInstrumentation.__sentry_instrumented__ = true; + + return mountWithInstrumentation; +} + +/** + * Normalizes the method name to snake_case to be used in span names or op. + */ +function normalizeMethodName(methodName: string): string { + return methodName.replace(/[A-Z]/g, letter => `_${letter.toLowerCase()}`); +} + +/** + * Checks if the value is empty, used for cache hit detection. + */ +function isEmptyValue(value: unknown): value is null | undefined { + return value === null || value === undefined; +} + +/** + * Creates the span start options for the storage method. + */ +function createSpanStartOptions( + methodName: keyof Driver, + driver: Driver, + mountBase: string, + args: unknown[], +): StartSpanOptions { + const keys = getCacheKeys(args?.[0], mountBase); + + const attributes: SpanAttributes = { + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: `cache.${normalizeMethodName(methodName)}`, + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.cache.nuxt', + [SEMANTIC_ATTRIBUTE_CACHE_KEY]: keys.length > 1 ? keys : keys[0], + 'db.operation.name': methodName, + 'db.collection.name': mountBase.replace(/:$/, ''), + 'db.system.name': driver.name ?? 'unknown', + }; + + return { + name: keys.join(', '), + attributes, + }; +} + +/** + * Gets a normalized array of cache keys. + */ +function getCacheKeys(key: unknown, prefix: string): string[] { + // Handles an array of keys + if (Array.isArray(key)) { + return key.map(k => normalizeKey(k, prefix)); + } + + return [normalizeKey(key, prefix)]; +} + +/** + * Normalizes the key to a string for `cache.key` attribute. + */ +function normalizeKey(key: unknown, prefix: string): string { + if (typeof key === 'string') { + return `${prefix}${key}`; + } + + // Handles an object with a key property + if (typeof key === 'object' && key !== null && 'key' in key) { + return `${prefix}${key.key}`; + } + + return `${prefix}${isEmptyValue(key) ? '' : String(key)}`; +} + +const CACHED_FN_HANDLERS_RE = /^nitro:(functions|handlers):/i; + +/** + * Since Nitro's cache may not utilize the driver's TTL, it is possible that the value is present in the cache but won't be used by Nitro. + * The maxAge and expires values are serialized by Nitro in the cache entry. This means the value presence does not necessarily mean a cache hit. + * So in order to properly report cache hits for `defineCachedFunction` and `defineCachedEventHandler` we need to check the cached value ourselves. + * First we check if the key matches the `defineCachedFunction` or `defineCachedEventHandler` key patterns, and if so we check the cached value. + */ +function isCacheHit(key: unknown, value: unknown): boolean { + try { + const isEmpty = isEmptyValue(value); + // Empty value means no cache hit either way + // Or if key doesn't match the cached function or handler patterns, we can return the empty value check + if (isEmpty || typeof key !== 'string' || !CACHED_FN_HANDLERS_RE.test(key)) { + return !isEmpty; + } + + return validateCacheEntry(key, JSON.parse(String(value)) as CacheEntry); + } catch { + // this is a best effort, so we return false if we can't validate the cache entry + return false; + } +} + +/** + * Validates the cache entry. + */ +function validateCacheEntry( + key: string, + entry: CacheEntry | CacheEntry, +): boolean { + if (isEmptyValue(entry.value)) { + return false; + } + + // Date.now is used by Nitro internally, so safe to use here. + // https://github.com/nitrojs/nitro/blob/5508f71b77730e967fb131de817725f5aa7c4862/src/runtime/internal/cache.ts#L78 + if (Date.now() > (entry.expires || 0)) { + return false; + } + + /** + * Pulled from Nitro's cache entry validation + * https://github.com/nitrojs/nitro/blob/5508f71b77730e967fb131de817725f5aa7c4862/src/runtime/internal/cache.ts#L223-L241 + */ + if (isResponseCacheEntry(key, entry)) { + if ((entry.value.status ?? 0) >= 400) { + return false; + } + + if (entry.value.body === undefined) { + return false; + } + + if (entry.value.headers?.etag === 'undefined' || entry.value.headers?.['last-modified'] === 'undefined') { + return false; + } + } + + return true; +} + +/** + * Checks if the cache entry is a response cache entry. + */ +function isResponseCacheEntry(key: string, _: CacheEntry): _ is CacheEntry { + return key.startsWith('nitro:handlers:'); +} diff --git a/packages/nuxt/src/vite/databaseConfig.ts b/packages/nuxt/src/vite/databaseConfig.ts index c806a8f662c2..5b8bf008421d 100644 --- a/packages/nuxt/src/vite/databaseConfig.ts +++ b/packages/nuxt/src/vite/databaseConfig.ts @@ -38,5 +38,5 @@ export function addDatabaseInstrumentation(nitro: NitroConfig, moduleOptions?: S }, }); - addServerPlugin(createResolver(import.meta.url).resolve('./runtime/plugins/database.server')); + addServerPlugin(createResolver(import.meta.url).resolve('./runtime/plugins/database-legacy.server')); } diff --git a/packages/nuxt/src/vite/storageConfig.ts b/packages/nuxt/src/vite/storageConfig.ts index f4f4004d1b50..87f843f05796 100644 --- a/packages/nuxt/src/vite/storageConfig.ts +++ b/packages/nuxt/src/vite/storageConfig.ts @@ -17,5 +17,5 @@ export function addStorageInstrumentation(nuxt: Nuxt): void { }, }); - addServerPlugin(moduleDirResolver.resolve('./runtime/plugins/storage.server')); + addServerPlugin(moduleDirResolver.resolve('./runtime/plugins/storage-legacy.server')); }