From 07316f70439a99472f20632a95e2f551d88fc8d8 Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Fri, 30 Oct 2026 14:36:35 +0000 Subject: [PATCH 1/2] feat(nuxt): Instrument Nitro v3 cache storage Nitro v3 keeps cached functions and handlers in an ocache storage that only goes through KV storage with the `kv` cache driver, so the default `memory` and the `fs` drivers produced no cache spans. Wrap the storage that the `#nitro/virtual/cache` module creates at build time. Co-Authored-By: Francesco Gringl-Novy --- .../test-applications/nuxt-5/nuxt.config.ts | 3 - .../nuxt-5/tests/cache.test.ts | 20 +-- .../src/runtime/utils/instrumentStorage.ts | 153 ++++++++++++++---- packages/nuxt/src/vite/nitroServer.ts | 6 +- packages/nuxt/src/vite/storageConfig.ts | 49 ++++++ .../runtime/utils/instrumentStorage.test.ts | 99 ++++++++++++ packages/nuxt/test/vite/storageConfig.test.ts | 77 +++++++++ 7 files changed, 361 insertions(+), 46 deletions(-) create mode 100644 packages/nuxt/test/runtime/utils/instrumentStorage.test.ts create mode 100644 packages/nuxt/test/vite/storageConfig.test.ts diff --git a/dev-packages/e2e-tests/test-applications/nuxt-5/nuxt.config.ts b/dev-packages/e2e-tests/test-applications/nuxt-5/nuxt.config.ts index 78c559af3621..8016834d1167 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-5/nuxt.config.ts +++ b/dev-packages/e2e-tests/test-applications/nuxt-5/nuxt.config.ts @@ -28,9 +28,6 @@ export default defineNuxtConfig({ }, }, nitro: { - // Nitro v3 only routes `defineCachedFunction`/`defineCachedHandler` through KV storage (which the SDK - // instruments) with the `kv` cache driver. The default `memory` driver bypasses KV storage entirely. - cache: { driver: 'kv' }, experimental: { database: true, }, diff --git a/dev-packages/e2e-tests/test-applications/nuxt-5/tests/cache.test.ts b/dev-packages/e2e-tests/test-applications/nuxt-5/tests/cache.test.ts index 676ecc4ab91f..e3cb705269eb 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-5/tests/cache.test.ts +++ b/dev-packages/e2e-tests/test-applications/nuxt-5/tests/cache.test.ts @@ -34,8 +34,8 @@ test.describe('Cache Instrumentation', () => { const getCacheKey = (span: (typeof allCacheSpans)[number]) => span.attributes[SEMANTIC_ATTRIBUTE_CACHE_KEY]?.value; - // Test getItem spans for cachedFunction - should have both cache miss and cache hit - const getItemSpans = findSpansByMethod('getItem'); + // Test get spans for cachedFunction - should have both cache miss and cache hit + const getItemSpans = findSpansByMethod('get'); expect(getItemSpans.length).toBeGreaterThan(0); // Find cache miss (first call to getCachedUser('123')) @@ -51,8 +51,8 @@ test.describe('Cache Instrumentation', () => { 'cache.operation': { type: 'string', value: 'get' }, 'sentry.origin': { type: 'string', value: 'auto.cache.nuxt' }, [SEMANTIC_ATTRIBUTE_CACHE_HIT]: { type: 'boolean', value: false }, - 'db.operation.name': { type: 'string', value: 'getItem' }, - 'db.collection.name': { type: 'string', value: expect.stringMatching(/^(cache)?$/) }, + 'db.operation.name': { type: 'string', value: 'get' }, + 'db.system.name': { type: 'string', value: 'memory' }, }); } @@ -69,13 +69,13 @@ test.describe('Cache Instrumentation', () => { 'cache.operation': { type: 'string', value: 'get' }, 'sentry.origin': { type: 'string', value: 'auto.cache.nuxt' }, [SEMANTIC_ATTRIBUTE_CACHE_HIT]: { type: 'boolean', value: true }, - 'db.operation.name': { type: 'string', value: 'getItem' }, - 'db.collection.name': { type: 'string', value: expect.stringMatching(/^(cache)?$/) }, + 'db.operation.name': { type: 'string', value: 'get' }, + 'db.system.name': { type: 'string', value: 'memory' }, }); } - // Test setItem spans for cachedFunction - when cache miss occurs, value is set - const setItemSpans = findSpansByMethod('setItem'); + // Test set spans for cachedFunction - when cache miss occurs, value is set + const setItemSpans = findSpansByMethod('set'); expect(setItemSpans.length).toBeGreaterThan(0); const cacheSetSpan = setItemSpans.find( @@ -86,8 +86,8 @@ test.describe('Cache Instrumentation', () => { 'sentry.op': { type: 'string', value: 'cache.put' }, 'cache.operation': { type: 'string', value: 'put' }, 'sentry.origin': { type: 'string', value: 'auto.cache.nuxt' }, - 'db.operation.name': { type: 'string', value: 'setItem' }, - 'db.collection.name': { type: 'string', value: expect.stringMatching(/^(cache)?$/) }, + 'db.operation.name': { type: 'string', value: 'set' }, + 'db.system.name': { type: 'string', value: 'memory' }, }); } diff --git a/packages/nuxt/src/runtime/utils/instrumentStorage.ts b/packages/nuxt/src/runtime/utils/instrumentStorage.ts index 1055b4ca5876..19af91694503 100644 --- a/packages/nuxt/src/runtime/utils/instrumentStorage.ts +++ b/packages/nuxt/src/runtime/utils/instrumentStorage.ts @@ -32,6 +32,7 @@ import type { Driver, Storage } from 'unstorage'; interface CacheEntry { value?: T; expires?: number; + stale?: boolean; } /** @@ -180,35 +181,123 @@ function createMethodWrapper( 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(CACHE_HIT, resolveCacheHit(methodName, args[0], result)); - } - - return result; - } catch (error) { - span.setStatus({ code: SPAN_STATUS_ERROR, message: 'internal_error' }); - captureException(error, { - mechanism: { - handled: false, - type: options.attributes?.[SENTRY_ORIGIN], - }, - }); - - // Re-throw the error to be handled by the caller - throw error; - } finally { - await flushIfServerless(); - } - }); + return traceCacheOperation( + options, + () => target.apply(thisArg, args), + CACHE_HIT_METHODS.has(methodName) ? result => resolveCacheHit(methodName, args[0], result) : undefined, + ); }, }); } +/** + * Runs a cache operation in a span, optionally recording whether the result was a cache hit. + */ +function traceCacheOperation( + options: StartSpanOptions, + operation: () => unknown, + getCacheHit?: (result: unknown) => boolean, +): Promise { + return startSpan(options, async span => { + try { + const result = await operation(); + span.setStatus({ code: SPAN_STATUS_OK }); + + if (getCacheHit) { + span.setAttribute(CACHE_HIT, getCacheHit(result)); + } + + return result; + } catch (error) { + span.setStatus({ code: SPAN_STATUS_ERROR, message: 'internal_error' }); + captureException(error, { + mechanism: { + handled: false, + type: options.attributes?.[SENTRY_ORIGIN], + }, + }); + + // Re-throw the error to be handled by the caller + throw error; + } finally { + await flushIfServerless(); + } + }); +} + +/** + * The storage behind Nitro v3's `defineCachedFunction` and `defineCachedHandler` (an `ocache` storage). + */ +interface NitroCacheStorage { + get(key: string): unknown; + set(key: string, value: unknown, opts?: { ttl?: number }): unknown; +} + +/** + * Instruments the storage that Nitro v3 caches functions and handlers in. + * + * Unlike Nitro v2, Nitro v3 only writes its cache to KV storage with the `kv` cache driver, so the KV + * driver instrumentation doesn't see the default `memory` or the `fs` cache driver. + * The storage is patched in place because `ocache` reads other properties, like `binary`, from it. + */ +export function instrumentCacheStorage( + storage: MaybeInstrumented, + driverName: string, +): T { + if (storage.__sentry_instrumented__) { + return storage; + } + + const originalGet = storage.get.bind(storage); + const originalSet = storage.set.bind(storage); + + storage.get = (key: string) => + traceCacheOperation( + createCacheStorageSpanStartOptions('get', CACHE_GET, key, driverName), + () => originalGet(key), + isCacheEntryHit, + ); + + storage.set = (key: string, value: unknown, opts?: { ttl?: number }) => + // `ocache` deletes an entry by setting it to `null` + traceCacheOperation( + createCacheStorageSpanStartOptions('set', value == null ? CACHE_REMOVE : CACHE_PUT, key, driverName), + () => originalSet(key, value, opts), + ); + + storage.__sentry_instrumented__ = true; + + return storage; +} + +function createCacheStorageSpanStartOptions( + methodName: keyof NitroCacheStorage, + cacheOperation: typeof CACHE_GET | typeof CACHE_PUT | typeof CACHE_REMOVE, + key: string, + driverName: string, +): StartSpanOptions { + const client = getClient(); + return { + // With span streaming, span names have to be low cardinality, so we can't fall back to the cache key. + name: client && hasSpanStreamingEnabled(client) ? cacheOperation : key, + attributes: { + [SENTRY_OP]: cacheOperation, + [CACHE_OPERATION]: CACHE_OPERATION_NAMES[cacheOperation], + [SENTRY_ORIGIN]: 'auto.cache.nuxt', + [CACHE_KEY]: key, + [DB_OPERATION_NAME]: methodName, + [DB_SYSTEM_NAME]: driverName, + }, + }; +} + +/** + * `ocache` storage holds full cache entries, so a stored but expired or stale entry is a miss. + */ +function isCacheEntryHit(entry: unknown): boolean { + return isObjectLike(entry) && !(entry as CacheEntry).stale && isFreshCacheEntry(entry); +} + /** * Wraps the storage mount method to instrument the driver on future mounts. */ @@ -338,13 +427,7 @@ function validateCacheEntry( key: string, entry: CacheEntry | CacheEntry, ): boolean { - if (entry.value == null) { - 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)) { + if (!isFreshCacheEntry(entry)) { return false; } @@ -369,6 +452,12 @@ function validateCacheEntry( return true; } +function isFreshCacheEntry(entry: CacheEntry): entry is CacheEntry & { value: NonNullable } { + // 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 + return entry.value != null && Date.now() <= (entry.expires || 0); +} + /** * Checks if the cache entry is a response cache entry. */ diff --git a/packages/nuxt/src/vite/nitroServer.ts b/packages/nuxt/src/vite/nitroServer.ts index 0a7f358f1182..5c3b4a26dbf1 100644 --- a/packages/nuxt/src/vite/nitroServer.ts +++ b/packages/nuxt/src/vite/nitroServer.ts @@ -12,7 +12,7 @@ import { } from './addServerConfig'; import { addDatabaseInstrumentation } from './databaseConfig'; import { addMiddlewareImports, addMiddlewareInstrumentation } from './middlewareConfig'; -import { addStorageInstrumentation } from './storageConfig'; +import { addCacheStorageInstrumentation, addStorageInstrumentation } from './storageConfig'; import { addRuntimeInjectionTraceDep, type ServerApi } from './utils'; type NitroModuleOptions = Omit & @@ -65,6 +65,10 @@ export function setupNitroServer( addMiddlewareInstrumentation(nitro, isNitroV3); + if (isNitroV3) { + addCacheStorageInstrumentation(nitro); + } + if (deprecatedInjectMode) { setupDeprecatedInjectMode(nitro, deprecatedInjectMode, isNitroV3, serverConfigFile, moduleOptions); return; diff --git a/packages/nuxt/src/vite/storageConfig.ts b/packages/nuxt/src/vite/storageConfig.ts index 517c7884e36c..da94ea4ad1ab 100644 --- a/packages/nuxt/src/vite/storageConfig.ts +++ b/packages/nuxt/src/vite/storageConfig.ts @@ -1,7 +1,12 @@ import { addNitroPlugin, createResolver } from '@nuxt/kit'; import type { Nuxt } from '@nuxt/schema'; +import type { Nitro } from 'nitropack/types'; +import type { InputPluginOption } from 'rollup'; import { addServerTemplate } from '../vendor/server-template'; +const NITRO_CACHE_VIRTUAL_MODULE_ID = '#nitro/virtual/cache'; +const CREATE_CACHE_STORAGE_EXPORT = 'export const createCacheStorage ='; + /** * Prepares the storage config export to be used in the runtime storage instrumentation. */ @@ -23,3 +28,47 @@ export function addStorageInstrumentation(nuxt: Nuxt, isLegacyNitro: boolean): v addNitroPlugin(moduleDirResolver.resolve('./runtime/plugins/storage.server')); } } + +/** + * Instruments the storage behind Nitro v3's cached functions and handlers. + * + * Nitro v3 creates that storage in the `#nitro/virtual/cache` module, which is generated at build time and + * can't be reached at runtime, so the module's `createCacheStorage` export is wrapped while bundling. + */ +export function addCacheStorageInstrumentation(nitro: Nitro): void { + nitro.hooks.hook('rollup:before', (nitro, rollupConfig) => { + if (!rollupConfig.plugins) { + rollupConfig.plugins = []; + } + + if (!Array.isArray(rollupConfig.plugins)) { + rollupConfig.plugins = [rollupConfig.plugins]; + } + + rollupConfig.plugins.push(cacheStorageInstrumentationPlugin(nitro)); + }); +} + +function cacheStorageInstrumentationPlugin(nitro: Nitro): InputPluginOption { + const instrumentStoragePath = createResolver(import.meta.url).resolve('./runtime/utils/instrumentStorage'); + + return { + name: 'sentry-nuxt-cache-storage-instrumentation', + transform(code: string, id: string) { + // The `kv` driver re-exports a KV-backed storage, which the storage plugin already instruments + if (id !== NITRO_CACHE_VIRTUAL_MODULE_ID || !code.includes(CREATE_CACHE_STORAGE_EXPORT)) { + return null; + } + + const driverName = (nitro.options as { cache?: { driver?: string } }).cache?.driver ?? 'memory'; + + return { + code: `import { instrumentCacheStorage } from ${JSON.stringify(instrumentStoragePath)}; +${code.replace(CREATE_CACHE_STORAGE_EXPORT, 'const createUninstrumentedCacheStorage =')} +export const createCacheStorage = () => instrumentCacheStorage(createUninstrumentedCacheStorage(), ${JSON.stringify(driverName)}); +`, + map: null, + }; + }, + }; +} diff --git a/packages/nuxt/test/runtime/utils/instrumentStorage.test.ts b/packages/nuxt/test/runtime/utils/instrumentStorage.test.ts new file mode 100644 index 000000000000..bea477d53846 --- /dev/null +++ b/packages/nuxt/test/runtime/utils/instrumentStorage.test.ts @@ -0,0 +1,99 @@ +import type * as SentryCore from '@sentry/core'; +import { describe, expect, it, vi } from 'vitest'; +import { instrumentCacheStorage } from '../../../src/runtime/utils/instrumentStorage'; + +const { setAttributeMock, startSpanMock } = vi.hoisted(() => { + const setAttributeMock = vi.fn(); + return { + setAttributeMock, + startSpanMock: vi.fn((_options: unknown, callback: (span: unknown) => unknown) => + callback({ setAttribute: setAttributeMock, setStatus: vi.fn() }), + ), + }; +}); + +vi.mock('@sentry/core', async importOriginal => ({ + ...(await importOriginal()), + startSpan: startSpanMock, +})); + +function createStorage(entries: Record = {}) { + const map = new Map(Object.entries(entries)); + return { + binary: true, + get: vi.fn((key: string) => map.get(key) ?? null), + set: vi.fn((key: string, value: unknown) => void map.set(key, value)), + }; +} + +const KEY = '/cache:nitrofunctions.abc:getUser:user:1.json'; + +describe('instrumentCacheStorage', () => { + it('traces reads and records a fresh entry as a cache hit', async () => { + const entry = { value: { id: 1 }, expires: Date.now() + 60_000 }; + const storage = instrumentCacheStorage(createStorage({ [KEY]: entry }), 'memory'); + + await expect(storage.get(KEY)).resolves.toBe(entry); + + expect(startSpanMock).toHaveBeenLastCalledWith( + { + name: KEY, + attributes: { + 'sentry.op': 'cache.get', + 'cache.operation': 'get', + 'sentry.origin': 'auto.cache.nuxt', + 'cache.key': KEY, + 'db.operation.name': 'get', + 'db.system.name': 'memory', + }, + }, + expect.any(Function), + ); + expect(setAttributeMock).toHaveBeenLastCalledWith('cache.hit', true); + }); + + it.each([ + ['a missing entry', undefined], + ['an expired entry', { value: { id: 1 }, expires: Date.now() - 1 }], + ['a stale entry', { value: { id: 1 }, expires: Date.now() + 60_000, stale: true }], + ])('records %s as a cache miss', async (_, entry) => { + const storage = instrumentCacheStorage(createStorage(entry ? { [KEY]: entry } : {}), 'memory'); + + await storage.get(KEY); + + expect(setAttributeMock).toHaveBeenLastCalledWith('cache.hit', false); + }); + + it('traces writes, and writes of `null` as removals', async () => { + const original = createStorage(); + const originalSet = original.set; + const storage = instrumentCacheStorage(original, 'fs'); + + await storage.set(KEY, { value: 1 }, { ttl: 60 }); + expect(originalSet).toHaveBeenLastCalledWith(KEY, { value: 1 }, { ttl: 60 }); + expect(startSpanMock).toHaveBeenLastCalledWith( + expect.objectContaining({ + attributes: expect.objectContaining({ 'sentry.op': 'cache.put', 'db.operation.name': 'set' }), + }), + expect.any(Function), + ); + + await storage.set(KEY, null); + expect(startSpanMock).toHaveBeenLastCalledWith( + expect.objectContaining({ attributes: expect.objectContaining({ 'sentry.op': 'cache.remove' }) }), + expect.any(Function), + ); + }); + + it('patches the storage in place and only once', async () => { + const original = createStorage(); + + const storage = instrumentCacheStorage(instrumentCacheStorage(original, 'memory'), 'memory'); + startSpanMock.mockClear(); + await storage.get(KEY); + + expect(storage).toBe(original); + expect(storage.binary).toBe(true); + expect(startSpanMock).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/nuxt/test/vite/storageConfig.test.ts b/packages/nuxt/test/vite/storageConfig.test.ts new file mode 100644 index 000000000000..fbc91701c09b --- /dev/null +++ b/packages/nuxt/test/vite/storageConfig.test.ts @@ -0,0 +1,77 @@ +import type { Nitro } from 'nitropack/types'; +import { describe, expect, it, vi } from 'vitest'; +import { addCacheStorageInstrumentation } from '../../src/vite/storageConfig'; + +vi.mock('@nuxt/kit', () => ({ + addNitroPlugin: vi.fn(), + createResolver: () => ({ resolve: (path: string) => `/module/${path.replace('./', '')}` }), +})); + +vi.mock('../../src/vendor/server-template', () => ({ + addServerTemplate: vi.fn(), +})); + +type TransformHook = (code: string, id: string) => { code: string } | null; + +function getCacheStorageTransform(cacheDriver?: string): TransformHook { + let rollupBeforeHook: ((nitro: Nitro, config: { plugins?: unknown }) => void) | undefined; + const nitro = { + options: { cache: cacheDriver ? { driver: cacheDriver } : undefined }, + hooks: { hook: (_name: string, callback: typeof rollupBeforeHook) => (rollupBeforeHook = callback) }, + } as unknown as Nitro; + + addCacheStorageInstrumentation(nitro); + + const rollupConfig: { plugins?: unknown } = {}; + rollupBeforeHook?.(nitro, rollupConfig); + + return (rollupConfig.plugins as Array<{ transform: TransformHook }>)[0]!.transform; +} + +const MEMORY_CACHE_MODULE = `import { createMemoryStorage } from "ocache"; +export const createCacheStorage = () => createMemoryStorage({}); + +export const cacheFunctionDefaults = {}; +`; + +describe('addCacheStorageInstrumentation', () => { + it('wraps the storage created by the cache virtual module', () => { + const transform = getCacheStorageTransform('fs'); + + expect(transform(MEMORY_CACHE_MODULE, '#nitro/virtual/cache')?.code).toBe( + `import { instrumentCacheStorage } from "/module/runtime/utils/instrumentStorage"; +import { createMemoryStorage } from "ocache"; +const createUninstrumentedCacheStorage = () => createMemoryStorage({}); + +export const cacheFunctionDefaults = {}; + +export const createCacheStorage = () => instrumentCacheStorage(createUninstrumentedCacheStorage(), "fs"); +`, + ); + }); + + it('defaults the driver name to the default memory driver', () => { + const transform = getCacheStorageTransform(); + + expect(transform(MEMORY_CACHE_MODULE, '#nitro/virtual/cache')?.code).toContain( + 'instrumentCacheStorage(createUninstrumentedCacheStorage(), "memory")', + ); + }); + + it('leaves the kv driver to the storage driver instrumentation', () => { + const transform = getCacheStorageTransform('kv'); + + expect( + transform( + 'export { createKVCacheStorage as createCacheStorage } from "#nitro/runtime/cache-kv";', + '#nitro/virtual/cache', + ), + ).toBeNull(); + }); + + it('ignores other modules', () => { + const transform = getCacheStorageTransform(); + + expect(transform(MEMORY_CACHE_MODULE, '/app/server/api/cache.ts')).toBeNull(); + }); +}); From 9a64d17129b0ed5a4b6c11f28bc1201c9118fc6d Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Fri, 30 Oct 2026 14:45:43 +0000 Subject: [PATCH 2/2] test(nuxt): Type cache storage mock set options --- packages/nuxt/test/runtime/utils/instrumentStorage.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/nuxt/test/runtime/utils/instrumentStorage.test.ts b/packages/nuxt/test/runtime/utils/instrumentStorage.test.ts index bea477d53846..c49de40a595e 100644 --- a/packages/nuxt/test/runtime/utils/instrumentStorage.test.ts +++ b/packages/nuxt/test/runtime/utils/instrumentStorage.test.ts @@ -22,7 +22,7 @@ function createStorage(entries: Record = {}) { return { binary: true, get: vi.fn((key: string) => map.get(key) ?? null), - set: vi.fn((key: string, value: unknown) => void map.set(key, value)), + set: vi.fn((key: string, value: unknown, _opts?: { ttl?: number }) => void map.set(key, value)), }; }