diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/tests/useCacheSpans.spec.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/tests/useCacheSpans.spec.ts index b9232e9df611..dadb754e0a9b 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/tests/useCacheSpans.spec.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/tests/useCacheSpans.spec.ts @@ -1,26 +1,28 @@ import { expect, test } from '@playwright/test'; import { waitForTransaction } from '@sentry-internal/test-utils'; -// The SDK does not instrument Next.js' `use cache` handler yet, so these tests are declared with `test.fail()` - -test.fail('Should create cache spans around `use cache` functions', async ({ request }) => { +test('Should create cache spans around `use cache` functions', async ({ request }) => { // A fresh id makes the first request a guaranteed cache miss (the id is part of the cache key) // even when the test is retried against the same server. const id = crypto.randomUUID(); const missTxPromise = waitForTransaction('nextjs-16-cacheComponents', transactionEvent => { - return transactionEvent.transaction === 'GET /api/use-cache'; + return ( + transactionEvent.transaction === 'GET /api/use-cache' && + !!transactionEvent.spans?.some(span => span.op === 'cache.get' && span.data?.['cache.hit'] === false) + ); + }); + + const hitTxPromise = waitForTransaction('nextjs-16-cacheComponents', transactionEvent => { + return ( + transactionEvent.transaction === 'GET /api/use-cache' && + !!transactionEvent.spans?.some(span => span.op === 'cache.get' && span.data?.['cache.hit'] === true) + ); }); const firstResponse = await (await request.get(`/api/use-cache?id=${id}`)).json(); const missTx = await missTxPromise; - // Registered after the first transaction was consumed, so it only matches the cached read. - const hitTxPromise = waitForTransaction( - 'nextjs-16-cacheComponents', - transactionEvent => transactionEvent.transaction === 'GET /api/use-cache', - ); - const secondResponse = await (await request.get(`/api/use-cache?id=${id}`)).json(); const hitTx = await hitTxPromise; @@ -72,44 +74,54 @@ test.fail('Should create cache spans around `use cache` functions', async ({ req }); }); -test.fail('Should create cache spans for `use cache` inside a rendered page', async ({ request }) => { +test('Should create cache spans for `use cache` inside a rendered page', async ({ request }) => { const id = crypto.randomUUID(); const missTxPromise = waitForTransaction('nextjs-16-cacheComponents', transactionEvent => { - return transactionEvent.transaction === 'GET /use-cache-page'; + return ( + transactionEvent.transaction === 'GET /use-cache-page' && + !!transactionEvent.spans?.some(span => span.op === 'cache.get' && span.data?.['cache.hit'] === false) + ); }); - await request.get(`/use-cache-page?id=${id}`); - const missTx = await missTxPromise; - const hitTxPromise = waitForTransaction('nextjs-16-cacheComponents', transactionEvent => { - return transactionEvent.transaction === 'GET /use-cache-page'; + return ( + transactionEvent.transaction === 'GET /use-cache-page' && + !!transactionEvent.spans?.some(span => span.op === 'cache.get' && span.data?.['cache.hit'] === true) + ); }); + await request.get(`/use-cache-page?id=${id}`); + const missTx = await missTxPromise; + await request.get(`/use-cache-page?id=${id}`); const hitTx = await hitTxPromise; expect(missTx.spans?.some(span => span.op === 'cache.put')).toBe(true); - const hitGetSpan = hitTx.spans?.find(span => span.op === 'cache.get'); - expect(hitGetSpan).toMatchObject({ - origin: 'auto.cache.nextjs', - data: expect.objectContaining({ - 'cache.hit': true, - 'cache.operation': 'get', - }), - }); + // A render can read more than one cache entry, so look at every hit instead of the first `cache.get`. + const hitGetSpans = hitTx.spans?.filter(span => span.op === 'cache.get' && span.data?.['cache.hit'] === true) ?? []; + expect(hitGetSpans.length).toBeGreaterThan(0); + for (const hitGetSpan of hitGetSpans) { + expect(hitGetSpan).toMatchObject({ + origin: 'auto.cache.nextjs', + data: expect.objectContaining({ 'cache.operation': 'get' }), + }); + } }); -test.fail('Should report an expired entry as a miss and refill it', async ({ request }) => { - // The dev server serves `use cache` entries past their `expire` limit, so the expiry path only - // exists in production builds. - test.skip(process.env.TEST_ENV !== 'production', 'Entries only hard-expire in production'); +test('Should report an expired entry as a miss and refill it', async ({ request }) => { + // `next dev` keeps every entry for at least 5 minutes, even when its `expire` is shorter. So in + // dev, the delayed request below still gets the cached value, and the entry never expires here. + test.skip(process.env.TEST_ENV !== 'production', 'Entries are only discarded at `expire` in production'); const id = crypto.randomUUID(); const fillTxPromise = waitForTransaction('nextjs-16-cacheComponents', transactionEvent => { - return transactionEvent.transaction === 'GET /api/use-cache-expiring'; + return ( + transactionEvent.transaction === 'GET /api/use-cache-expiring' && + !!transactionEvent.spans?.some(span => span.op === 'cache.put') + ); }); const firstResponse = await (await request.get(`/api/use-cache-expiring?id=${id}`)).json(); @@ -120,7 +132,10 @@ test.fail('Should report an expired entry as a miss and refill it', async ({ req // Registered after the fill transaction was consumed, so it only matches the refill. const refillTxPromise = waitForTransaction('nextjs-16-cacheComponents', transactionEvent => { - return transactionEvent.transaction === 'GET /api/use-cache-expiring'; + return ( + transactionEvent.transaction === 'GET /api/use-cache-expiring' && + !!transactionEvent.spans?.some(span => span.op === 'cache.put') + ); }); const secondResponse = await (await request.get(`/api/use-cache-expiring?id=${id}`)).json(); diff --git a/packages/nextjs/src/server/index.ts b/packages/nextjs/src/server/index.ts index 9765260239a4..d7ce162236fb 100644 --- a/packages/nextjs/src/server/index.ts +++ b/packages/nextjs/src/server/index.ts @@ -28,6 +28,7 @@ import { createLiveRootSpanAdapter } from '../common/utils/liveRootSpanAdapter'; import { enhanceHandleRequestRootSpan } from './enhanceHandleRequestRootSpan'; import { handleOnSpanStart } from './handleOnSpanStart'; import { prepareSafeIdGeneratorContext } from './prepareSafeIdGeneratorContext'; +import { nextjsUseCacheIntegration } from './useCacheInstrumentation'; import { maybeCompleteCronCheckIn } from './vercelCronsMonitoring'; import { maybeCleanupQueueSpan } from './vercelQueuesMonitoring'; @@ -141,6 +142,8 @@ export function init(options: NodeOptions): NodeClient | undefined { customDefaultIntegrations.push(distDirRewriteFramesIntegration({ distDirName })); } + customDefaultIntegrations.push(nextjsUseCacheIntegration()); + // Detect if running on OpenNext/Cloudflare and get runtime config const cloudflareConfig = getCloudflareRuntimeConfig(); diff --git a/packages/nextjs/src/server/useCacheInstrumentation.ts b/packages/nextjs/src/server/useCacheInstrumentation.ts new file mode 100644 index 000000000000..124e2c8b13ca --- /dev/null +++ b/packages/nextjs/src/server/useCacheInstrumentation.ts @@ -0,0 +1,290 @@ +import { createHash } from 'node:crypto'; +import { CACHE_ITEM_AGE, CACHE_OPERATION, CACHE_TAGS, CACHE_TTL } from '@sentry/conventions/attributes'; +import { CACHE_GET, CACHE_PUT } from '@sentry/conventions/op'; +import type { Span } from '@sentry/core'; +import { + CACHE_OPERATION_NAMES, + debug, + defineIntegration, + fill, + getActiveSpan, + getClient, + hasSpanStreamingEnabled, + hasSpansEnabled, + SEMANTIC_ATTRIBUTE_CACHE_HIT, + SEMANTIC_ATTRIBUTE_CACHE_KEY, + SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, + spanIsSampled, + startSpan, + timestampInSeconds, +} from '@sentry/core'; +import { DEBUG_BUILD } from '../common/debug-build'; + +// Next.js shares its `use cache` handlers across bundles via `globalThis` +// (`next/src/server/use-cache/handlers.ts`). This module can load once per bundle, so all +// double-wrap guards must also live on `globalThis`. +const NEXT_CACHE_HANDLERS_MAP = Symbol.for('@next/cache-handlers-map'); +const NEXT_PRIVATE_CACHE_HANDLER = Symbol.for('@next/cache-handlers-private'); +const SENTRY_CACHE_INSTRUMENTED = Symbol.for('sentry.nextjs.cacheHandlersInstrumented'); +const SENTRY_WRAPPED_HANDLERS = Symbol.for('sentry.nextjs.wrappedCacheHandlers'); + +const INTEGRATION_NAME = 'NextjsUseCache'; +const CACHE_SPAN_ORIGIN = 'auto.cache.nextjs'; + +// Next.js' `INFINITE_CACHE` sentinel. An `expire` at or above it means "never expires", which carries no signal as a TTL attribute. +// https://github.com/vercel/next.js/blob/ed1aab5d386d07ee2f553107dd39995251a6e44e/packages/next/src/lib/constants.ts#L43-L46 +const NEXT_INFINITE_CACHE = 0xfffffffe; + +// Next.js' `MIN_PRERENDERABLE_EXPIRE` (seconds). The dev server keeps entries at least this long, +// even when their `expire` is shorter. +const NEXT_DEV_MIN_EXPIRE = 300; + +// Next.js vendored types below: https://github.com/vercel/next.js/blob/ed1aab5d386d07ee2f553107dd39995251a6e44e/packages/next/src/server/lib/cache-handlers/types.ts + +// Subset of Next.js' `CacheHandler` +interface UseCacheHandler { + get(cacheKey: string, softTags?: string[]): Promise; + set(cacheKey: string, pendingEntry: Promise): Promise; +} + +// Subset of Next.js' `CacheEntry`. Fields are optional because custom cache handlers control the entry shape. +interface UseCacheEntry { + /** ms epoch, set to the fill's start time */ + timestamp?: number; + /** seconds; hard limit after which the entry is discarded on read */ + expire?: number; + /** `cacheTag()` tags, excluding implicit soft tags */ + tags?: unknown[]; +} + +type GlobalWithCacheHandlers = typeof globalThis & { + [NEXT_CACHE_HANDLERS_MAP]?: Map; + [NEXT_PRIVATE_CACHE_HANDLER]?: UseCacheHandler; + [SENTRY_CACHE_INSTRUMENTED]?: boolean; + [SENTRY_WRAPPED_HANDLERS]?: WeakSet; +}; + +/** + * Cache keys are long serialized payloads (function id + arguments), so spans carry a digest + * instead. This bounds span size, avoids leaking user data, and still groups identical keys. + */ +function keyDigest(cacheKey: string): string { + return createHash('sha1').update(cacheKey).digest('hex').slice(0, 12); +} + +/** + * Cache reads can be hot, so all span work (including key hashing) is skipped without a sampled + * parent span. Background revalidations still pass: they parent to the serving request's + * (possibly already finished) root span. + */ +function shouldRecordCacheSpan(): boolean { + const activeSpan = getActiveSpan(); + return !!activeSpan && spanIsSampled(activeSpan); +} + +function startCacheSpan(op: typeof CACHE_GET | typeof CACHE_PUT, cacheKey: string, callback: (span: Span) => T): T { + const client = getClient(); + const digest = keyDigest(cacheKey); + + return startSpan( + { + // low cardinality name for span streaming, so we can't fall back to the cache key + name: client && hasSpanStreamingEnabled(client) ? op : digest, + op, + attributes: { + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: CACHE_SPAN_ORIGIN, + [SEMANTIC_ATTRIBUTE_CACHE_KEY]: [digest], + [CACHE_OPERATION]: CACHE_OPERATION_NAMES[op], + }, + }, + callback, + ); +} + +/** + * Safety net for custom handlers that return entries past `expire`: Next.js discards those and + * re-runs the function. Its default handler already returns no entry in that case. + */ +function isExpired(ageMs: number | undefined, expire: number | undefined): boolean { + if (ageMs === undefined || typeof expire !== 'number') { + return false; + } + const effectiveExpire = process.env.NODE_ENV === 'development' ? Math.max(expire, NEXT_DEV_MIN_EXPIRE) : expire; + return ageMs > effectiveExpire * 1000; +} + +/** + * A missing entry is a miss. Next.js' default handler also returns no entry for expired, evicted, + * or tag-invalidated entries, so those count as misses too. + */ +function setEntryAttributes(span: Span, entry: unknown): void { + if (entry === undefined) { + span.setAttribute(SEMANTIC_ATTRIBUTE_CACHE_HIT, false); + return; + } + + const { timestamp, expire, tags } = (entry ?? {}) as UseCacheEntry; + const ageMs = typeof timestamp === 'number' ? timestampInSeconds() * 1000 - timestamp : undefined; + + span.setAttribute(SEMANTIC_ATTRIBUTE_CACHE_HIT, !isExpired(ageMs, expire)); + + if (ageMs !== undefined) { + // Clamped: with a remote handler, the filling and the reading machine's clocks can drift. + span.setAttribute(CACHE_ITEM_AGE, Math.max(0, Math.round(ageMs / 1000))); + } + // A negative `expire` is Next.js' eviction sentinel, not a TTL. + if (typeof expire === 'number' && expire >= 0 && expire < NEXT_INFINITE_CACHE) { + span.setAttribute(CACHE_TTL, expire); + } + const stringTags = Array.isArray(tags) ? tags.filter((tag): tag is string => typeof tag === 'string') : []; + if (stringTags.length > 0) { + span.setAttribute(CACHE_TAGS, stringTags); + } +} + +function isCacheHandler(value: unknown): value is UseCacheHandler { + return ( + typeof value === 'object' && + value !== null && + typeof (value as UseCacheHandler).get === 'function' && + typeof (value as UseCacheHandler).set === 'function' + ); +} + +// A WeakSet instead of a marker property, because frozen or proxied handlers reject new properties. +function getWrappedHandlers(): WeakSet { + const globalWithCacheHandlers = globalThis as GlobalWithCacheHandlers; + if (!globalWithCacheHandlers[SENTRY_WRAPPED_HANDLERS]) { + globalWithCacheHandlers[SENTRY_WRAPPED_HANDLERS] = new WeakSet(); + } + return globalWithCacheHandlers[SENTRY_WRAPPED_HANDLERS]; +} + +function instrumentHandler(handler: unknown): void { + // Runs inside Next.js' handler registration, which must never fail because of Sentry. + try { + const wrappedHandlers = getWrappedHandlers(); + if (!isCacheHandler(handler) || wrappedHandlers.has(handler)) { + return; + } + wrappedHandlers.add(handler); + + fill(handler, 'get', (originalGet: UseCacheHandler['get']) => { + return function (this: UseCacheHandler, cacheKey: string, softTags?: string[]): Promise { + if (!shouldRecordCacheSpan()) { + return originalGet.call(this, cacheKey, softTags); + } + return startCacheSpan(CACHE_GET, cacheKey, span => + // `Promise.resolve` because custom handlers may return the entry synchronously. + Promise.resolve(originalGet.call(this, cacheKey, softTags)).then(entry => { + try { + setEntryAttributes(span, entry); + } catch (error) { + DEBUG_BUILD && debug.warn('Failed to read Next.js cache entry metadata', error); + } + return entry; + }), + ); + }; + }); + + fill(handler, 'set', (originalSet: UseCacheHandler['set']) => { + return function (this: UseCacheHandler, cacheKey: string, pendingEntry: Promise): Promise { + if (!shouldRecordCacheSpan()) { + return originalSet.call(this, cacheKey, pendingEntry); + } + // The handler drains `pendingEntry` (the still-streaming entry) before storing, so this + // span covers producing and storing the entry, not just the write. + return startCacheSpan(CACHE_PUT, cacheKey, () => originalSet.call(this, cacheKey, pendingEntry)); + }; + }); + } catch (error) { + DEBUG_BUILD && debug.warn('Failed to instrument a Next.js cache handler', error); + } +} + +function instrumentHandlersMap(handlersMap: Map): void { + for (const handler of handlersMap.values()) { + instrumentHandler(handler); + } + + // Custom handlers can be registered later (`setCacheHandler`), so wrap new entries as they arrive. + fill(handlersMap, 'set', (originalSet: Map['set']) => { + return function (this: Map, kind: string, handler: UseCacheHandler) { + const result = originalSet.call(this, kind, handler); + instrumentHandler(handler); + return result; + }; + }); +} + +/** + * Calls `onValue` with the registry value now, or when Next.js assigns it. Both orderings occur: + * `next start` creates the registries before `instrumentation.ts` loads, the dev server after. + */ +function instrumentWhenAssigned(symbol: symbol, onValue: (value: unknown) => void): void { + const globalWithCacheHandlers = globalThis as GlobalWithCacheHandlers; + + const existingValue = (globalWithCacheHandlers as Record)[symbol]; + if (existingValue !== undefined) { + onValue(existingValue); + return; + } + + let storedValue: unknown; + Object.defineProperty(globalWithCacheHandlers, symbol, { + configurable: true, + // Non-enumerable so the accessor stays out of copies/spreads of `globalThis` in apps that + // never assign the registry. + enumerable: false, + get: () => storedValue, + set: (value: unknown) => { + storedValue = value; + onValue(value); + }, + }); +} + +/** Installs the `use cache` handler instrumentation once per process. + * + * Only exported for testing. + * + * @internal + */ +export function _instrumentUseCacheHandlers(): void { + try { + const globalWithCacheHandlers = globalThis as GlobalWithCacheHandlers; + + if (globalWithCacheHandlers[SENTRY_CACHE_INSTRUMENTED]) { + return; + } + globalWithCacheHandlers[SENTRY_CACHE_INSTRUMENTED] = true; + + instrumentWhenAssigned(NEXT_CACHE_HANDLERS_MAP, value => { + if (value instanceof Map) { + instrumentHandlersMap(value); + } + }); + + // The dev server keeps `use cache: private` entries in a separate handler outside the map. + instrumentWhenAssigned(NEXT_PRIVATE_CACHE_HANDLER, instrumentHandler); + } catch (error) { + DEBUG_BUILD && debug.warn('Failed to instrument Next.js cache handlers', error); + } +} + +/** + * Wraps Next.js' `use cache` handlers with `cache.get`/`cache.put` spans, so cached function + * reads and fills show up in traces with hit/miss information. + */ +export const nextjsUseCacheIntegration = defineIntegration(() => { + return { + name: INTEGRATION_NAME, + setup(client) { + // The resolved client options also cover tracing enabled via `SENTRY_TRACES_SAMPLE_RATE`. + if (hasSpansEnabled(client.getOptions())) { + _instrumentUseCacheHandlers(); + } + }, + }; +}); diff --git a/packages/nextjs/test/server/useCacheInstrumentation.test.ts b/packages/nextjs/test/server/useCacheInstrumentation.test.ts new file mode 100644 index 000000000000..0534725ab05b --- /dev/null +++ b/packages/nextjs/test/server/useCacheInstrumentation.test.ts @@ -0,0 +1,296 @@ +import type * as SentryCore from '@sentry/core'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +const mocks = vi.hoisted(() => { + const setAttribute = vi.fn(); + return { + setAttribute, + startSpan: vi.fn((_options: unknown, callback: (span: unknown) => unknown) => callback({ setAttribute })), + activeSpan: undefined as object | undefined, + sampled: true, + }; +}); + +vi.mock('@sentry/core', async importOriginal => ({ + ...(await importOriginal()), + getClient: () => undefined, + getActiveSpan: () => mocks.activeSpan, + spanIsSampled: () => mocks.sampled, + startSpan: mocks.startSpan, +})); + +import { _instrumentUseCacheHandlers } from '../../src/server/useCacheInstrumentation'; + +const NEXT_CACHE_HANDLERS_MAP = Symbol.for('@next/cache-handlers-map'); +const NEXT_PRIVATE_CACHE_HANDLER = Symbol.for('@next/cache-handlers-private'); +const SENTRY_CACHE_INSTRUMENTED = Symbol.for('sentry.nextjs.cacheHandlersInstrumented'); +const SENTRY_WRAPPED_HANDLERS = Symbol.for('sentry.nextjs.wrappedCacheHandlers'); + +function createHandler(entry?: unknown) { + return { + get: vi.fn(() => Promise.resolve(entry)), + set: vi.fn(() => Promise.resolve()), + }; +} + +function setGlobal(symbol: symbol, value: unknown): void { + (globalThis as Record)[symbol] = value; +} + +function installWithDefaultHandler(entry?: unknown) { + const handler = createHandler(entry); + setGlobal(NEXT_CACHE_HANDLERS_MAP, new Map([['default', handler]])); + _instrumentUseCacheHandlers(); + return handler; +} + +function nowMs(): number { + // The clock Next.js uses for `CacheEntry.timestamp`. + return performance.timeOrigin + performance.now(); +} + +describe('instrumentUseCacheHandlers', () => { + beforeEach(() => { + mocks.activeSpan = {}; + mocks.sampled = true; + }); + + afterEach(() => { + vi.clearAllMocks(); + vi.unstubAllEnvs(); + // The instrumentation intentionally leaves guards and accessors on `globalThis`; tests have + // to reset them to get a fresh install each time. + for (const symbol of [ + NEXT_CACHE_HANDLERS_MAP, + NEXT_PRIVATE_CACHE_HANDLER, + SENTRY_CACHE_INSTRUMENTED, + SENTRY_WRAPPED_HANDLERS, + ]) { + Reflect.deleteProperty(globalThis, symbol); + } + }); + + describe('installation', () => { + it('wraps handlers that are already registered at init (`next start` ordering)', async () => { + const handler = installWithDefaultHandler(); + + await handler.get('cache-key'); + + expect(mocks.startSpan).toHaveBeenCalledTimes(1); + expect(mocks.startSpan).toHaveBeenCalledWith( + expect.objectContaining({ + op: 'cache.get', + name: expect.stringMatching(/^[0-9a-f]{12}$/), + attributes: expect.objectContaining({ + 'sentry.origin': 'auto.cache.nextjs', + 'cache.operation': 'get', + 'cache.key': [expect.stringMatching(/^[0-9a-f]{12}$/)], + }), + }), + expect.any(Function), + ); + }); + + it('wraps handlers when the registry is assigned after init (dev server ordering)', async () => { + _instrumentUseCacheHandlers(); + + const handler = createHandler(); + setGlobal(NEXT_CACHE_HANDLERS_MAP, new Map([['default', handler]])); + await handler.get('cache-key'); + + expect(mocks.startSpan).toHaveBeenCalledTimes(1); + }); + + it('wraps handlers that are added to the registry later (`setCacheHandler`)', async () => { + const handlersMap = new Map(); + setGlobal(NEXT_CACHE_HANDLERS_MAP, handlersMap); + _instrumentUseCacheHandlers(); + + const handler = createHandler(); + handlersMap.set('custom', handler); + await handler.get('cache-key'); + + expect(mocks.startSpan).toHaveBeenCalledTimes(1); + }); + + it('wraps the private dev cache handler', async () => { + _instrumentUseCacheHandlers(); + + const handler = createHandler(); + setGlobal(NEXT_PRIVATE_CACHE_HANDLER, handler); + await handler.get('cache-key'); + + expect(mocks.startSpan).toHaveBeenCalledTimes(1); + }); + + it('does not double-wrap on repeated init', async () => { + const handler = installWithDefaultHandler(); + _instrumentUseCacheHandlers(); + + await handler.get('cache-key'); + + expect(mocks.startSpan).toHaveBeenCalledTimes(1); + }); + + it('wraps a handler registered under several kinds only once', async () => { + const handler = createHandler(); + setGlobal( + NEXT_CACHE_HANDLERS_MAP, + new Map([ + ['default', handler], + ['remote', handler], + ]), + ); + _instrumentUseCacheHandlers(); + + await handler.get('cache-key'); + + expect(mocks.startSpan).toHaveBeenCalledTimes(1); + }); + + it('ignores registry values that are not cache handlers', () => { + setGlobal(NEXT_CACHE_HANDLERS_MAP, 'not-a-map'); + setGlobal(NEXT_PRIVATE_CACHE_HANDLER, { get: () => Promise.resolve() }); + + expect(() => _instrumentUseCacheHandlers()).not.toThrow(); + expect(mocks.startSpan).not.toHaveBeenCalled(); + }); + }); + + describe('safety', () => { + it('still registers a frozen custom handler', () => { + const handlersMap = new Map(); + setGlobal(NEXT_CACHE_HANDLERS_MAP, handlersMap); + _instrumentUseCacheHandlers(); + + const frozenHandler = Object.freeze(createHandler()); + + expect(() => handlersMap.set('custom', frozenHandler)).not.toThrow(); + expect(handlersMap.get('custom')).toBe(frozenHandler); + }); + + it('still registers a handler whose property access throws', () => { + const handlersMap = new Map(); + setGlobal(NEXT_CACHE_HANDLERS_MAP, handlersMap); + _instrumentUseCacheHandlers(); + + const throwingHandler = new Proxy( + {}, + { + get() { + throw new Error('unexpected property access'); + }, + }, + ); + + expect(() => handlersMap.set('custom', throwingHandler)).not.toThrow(); + expect(handlersMap.get('custom')).toBe(throwingHandler); + }); + + it('skips all span work without an active span', async () => { + mocks.activeSpan = undefined; + const entry = { timestamp: nowMs() }; + const handler = installWithDefaultHandler(entry); + + await expect(handler.get('cache-key')).resolves.toBe(entry); + await handler.set('cache-key', Promise.resolve({})); + + expect(mocks.startSpan).not.toHaveBeenCalled(); + }); + + it('skips all span work when the active span is not sampled', async () => { + mocks.sampled = false; + const handler = installWithDefaultHandler(); + + await handler.get('cache-key'); + + expect(mocks.startSpan).not.toHaveBeenCalled(); + }); + + it('supports custom handlers that return the entry synchronously', async () => { + const entry = { timestamp: nowMs() }; + const handler = { get: (_cacheKey: string) => entry, set: () => Promise.resolve() }; + setGlobal(NEXT_CACHE_HANDLERS_MAP, new Map([['default', handler]])); + _instrumentUseCacheHandlers(); + + await expect(handler.get('cache-key')).resolves.toBe(entry); + expect(mocks.setAttribute).toHaveBeenCalledWith('cache.hit', true); + }); + }); + + describe('entry attributes', () => { + it('reports a miss when the handler returns no entry', async () => { + const handler = installWithDefaultHandler(undefined); + + await handler.get('cache-key'); + + expect(mocks.setAttribute).toHaveBeenCalledWith('cache.hit', false); + expect(mocks.setAttribute).not.toHaveBeenCalledWith('cache.item_age', expect.anything()); + }); + + it('reports a hit with entry metadata for a fresh entry', async () => { + const handler = installWithDefaultHandler({ + timestamp: nowMs() - 5_000, + expire: 3_600, + tags: ['tag-a', 42, 'tag-b'], + }); + + await handler.get('cache-key'); + + expect(mocks.setAttribute).toHaveBeenCalledWith('cache.hit', true); + expect(mocks.setAttribute).toHaveBeenCalledWith('cache.item_age', 5); + expect(mocks.setAttribute).toHaveBeenCalledWith('cache.ttl', 3_600); + expect(mocks.setAttribute).toHaveBeenCalledWith('cache.tags', ['tag-a', 'tag-b']); + }); + + it('reports a miss for an entry past its hard expire limit', async () => { + const handler = installWithDefaultHandler({ timestamp: nowMs() - 120_000, expire: 60 }); + + await handler.get('cache-key'); + + expect(mocks.setAttribute).toHaveBeenCalledWith('cache.hit', false); + expect(mocks.setAttribute).toHaveBeenCalledWith('cache.item_age', 120); + }); + + it('applies the dev server minimum lifetime before reporting an entry as expired', async () => { + vi.stubEnv('NODE_ENV', 'development'); + const handler = installWithDefaultHandler({ timestamp: nowMs() - 120_000, expire: 60 }); + + await handler.get('cache-key'); + + expect(mocks.setAttribute).toHaveBeenCalledWith('cache.hit', true); + }); + + it('omits `cache.ttl` for entries that never expire', async () => { + const handler = installWithDefaultHandler({ timestamp: nowMs(), expire: 0xfffffffe }); + + await handler.get('cache-key'); + + expect(mocks.setAttribute).toHaveBeenCalledWith('cache.hit', true); + expect(mocks.setAttribute).not.toHaveBeenCalledWith('cache.ttl', expect.anything()); + }); + + it('omits `cache.ttl` for evicted entries', async () => { + const handler = installWithDefaultHandler({ timestamp: nowMs(), expire: -1 }); + + await handler.get('cache-key'); + + expect(mocks.setAttribute).toHaveBeenCalledWith('cache.hit', false); + expect(mocks.setAttribute).not.toHaveBeenCalledWith('cache.ttl', expect.anything()); + }); + }); + + it('creates a `cache.put` span around handler writes', async () => { + const handler = installWithDefaultHandler(); + + await handler.set('cache-key', Promise.resolve({})); + + expect(mocks.startSpan).toHaveBeenCalledWith( + expect.objectContaining({ + op: 'cache.put', + attributes: expect.objectContaining({ 'cache.operation': 'put' }), + }), + expect.any(Function), + ); + }); +}); diff --git a/packages/nextjs/test/serverSdk.test.ts b/packages/nextjs/test/serverSdk.test.ts index 01d88970e895..25ba979026b3 100644 --- a/packages/nextjs/test/serverSdk.test.ts +++ b/packages/nextjs/test/serverSdk.test.ts @@ -100,6 +100,50 @@ describe('Server init()', () => { expect(onUncaughtExceptionIntegration).toBeDefined(); }); + describe('`use cache` integration', () => { + const CACHE_HANDLERS_INSTRUMENTED = Symbol.for('sentry.nextjs.cacheHandlersInstrumented'); + const DSN = 'https://public@dsn.ingest.sentry.io/1337'; + + function isUseCacheInstrumented(): boolean { + return (globalThis as Record)[CACHE_HANDLERS_INSTRUMENTED] === true; + } + + afterEach(() => { + vi.unstubAllEnvs(); + Reflect.deleteProperty(globalThis, CACHE_HANDLERS_INSTRUMENTED); + }); + + it('adds the integration to the default integrations', () => { + init({}); + + expect(nodeInit).toHaveBeenLastCalledWith( + expect.objectContaining({ + defaultIntegrations: expect.arrayContaining([expect.objectContaining({ name: 'NextjsUseCache' })]), + }), + ); + }); + + it('instruments the cache handlers when tracing is enabled', () => { + init({ dsn: DSN, tracesSampleRate: 1 }); + + expect(isUseCacheInstrumented()).toBe(true); + }); + + it('instruments the cache handlers when tracing is enabled via `SENTRY_TRACES_SAMPLE_RATE`', () => { + vi.stubEnv('SENTRY_TRACES_SAMPLE_RATE', '1'); + + init({ dsn: DSN }); + + expect(isUseCacheInstrumented()).toBe(true); + }); + + it('does not instrument the cache handlers when tracing is disabled', () => { + init({ dsn: DSN }); + + expect(isUseCacheInstrumented()).toBe(false); + }); + }); + it('supports passing unrelated integrations through options', () => { init({ integrations: [SentryNode.consoleIntegration()] });