Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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,
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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'))
Expand All @@ -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' },
});
}

Expand All @@ -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(
Expand All @@ -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' },
});
}

Expand Down
153 changes: 121 additions & 32 deletions packages/nuxt/src/runtime/utils/instrumentStorage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ import type { Driver, Storage } from 'unstorage';
interface CacheEntry<T = unknown> {
value?: T;
expires?: number;
stale?: boolean;
}

/**
Expand Down Expand Up @@ -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<unknown> {
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<T extends NitroCacheStorage>(
storage: MaybeInstrumented<T>,
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.
*/
Expand Down Expand Up @@ -338,13 +427,7 @@ function validateCacheEntry(
key: string,
entry: CacheEntry | CacheEntry<ResponseCacheEntry & { status: number }>,
): 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;
}

Expand All @@ -369,6 +452,12 @@ function validateCacheEntry(
return true;
}

function isFreshCacheEntry<T>(entry: CacheEntry<T>): entry is CacheEntry<T> & { value: NonNullable<T> } {
// 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.
*/
Expand Down
6 changes: 5 additions & 1 deletion packages/nuxt/src/vite/nitroServer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<SentryNuxtModuleOptions, 'experimental_entrypointWrappedFunctions'> &
Expand Down Expand Up @@ -65,6 +65,10 @@ export function setupNitroServer(

addMiddlewareInstrumentation(nitro, isNitroV3);

if (isNitroV3) {
addCacheStorageInstrumentation(nitro);
}

if (deprecatedInjectMode) {
setupDeprecatedInjectMode(nitro, deprecatedInjectMode, isNitroV3, serverConfigFile, moduleOptions);
return;
Expand Down
49 changes: 49 additions & 0 deletions packages/nuxt/src/vite/storageConfig.ts
Original file line number Diff line number Diff line change
@@ -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.
*/
Expand All @@ -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,
};
},
};
}
Loading
Loading