@@ -3,38 +3,45 @@ import { CACHE_ITEM_AGE, CACHE_OPERATION, CACHE_TAGS, CACHE_TTL } from '@sentry/
33import { CACHE_GET , CACHE_PUT } from '@sentry/conventions/op' ;
44import type { Span } from '@sentry/core' ;
55import {
6- _INTERNAL_safeDateNow ,
76 CACHE_OPERATION_NAMES ,
87 debug ,
8+ defineIntegration ,
99 fill ,
10+ getActiveSpan ,
1011 getClient ,
1112 hasSpanStreamingEnabled ,
1213 SEMANTIC_ATTRIBUTE_CACHE_HIT ,
1314 SEMANTIC_ATTRIBUTE_CACHE_KEY ,
1415 SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN ,
16+ spanIsSampled ,
1517 startSpan ,
18+ timestampInSeconds ,
1619} from '@sentry/core' ;
1720import { DEBUG_BUILD } from '../common/debug-build' ;
1821
1922// Next.js shares its `use cache` handlers across bundles via `globalThis`
2023// (`next/src/server/use-cache/handlers.ts`). This module can load once per bundle, so all
21- // double-wrap guards must also live on `globalThis` or on the handler objects .
24+ // double-wrap guards must also live on `globalThis`.
2225const NEXT_CACHE_HANDLERS_MAP = Symbol . for ( '@next/cache-handlers-map' ) ;
2326const NEXT_PRIVATE_CACHE_HANDLER = Symbol . for ( '@next/cache-handlers-private' ) ;
2427const SENTRY_CACHE_INSTRUMENTED = Symbol . for ( 'sentry.nextjs.cacheHandlersInstrumented' ) ;
25- const SENTRY_HANDLER_WRAPPED = Symbol . for ( 'sentry.nextjs.wrappedCacheHandler ' ) ;
28+ const SENTRY_WRAPPED_HANDLERS = Symbol . for ( 'sentry.nextjs.wrappedCacheHandlers ' ) ;
2629
30+ const INTEGRATION_NAME = 'NextjsUseCache' ;
2731const CACHE_SPAN_ORIGIN = 'auto.cache.nextjs' ;
2832
2933// Next.js' `INFINITE_CACHE` sentinel. An `expire` at or above it means "never expires", which carries no signal as a TTL attribute.
3034// https://github.com/vercel/next.js/blob/ed1aab5d386d07ee2f553107dd39995251a6e44e/packages/next/src/lib/constants.ts#L43-L46
3135const NEXT_INFINITE_CACHE = 0xfffffffe ;
3236
37+ // Next.js' `MIN_PRERENDERABLE_EXPIRE` (seconds). The dev server keeps entries at least this long,
38+ // even when their `expire` is shorter.
39+ const NEXT_DEV_MIN_EXPIRE = 300 ;
40+
3341// Next.js vendored types below: https://github.com/vercel/next.js/blob/ed1aab5d386d07ee2f553107dd39995251a6e44e/packages/next/src/server/lib/cache-handlers/types.ts
3442
3543// Subset of Next.js' `CacheHandler`
3644interface UseCacheHandler {
37- [ SENTRY_HANDLER_WRAPPED ] ?: boolean ;
3845 get ( cacheKey : string , softTags ?: string [ ] ) : Promise < unknown > ;
3946 set ( cacheKey : string , pendingEntry : Promise < unknown > ) : Promise < void > ;
4047}
@@ -46,13 +53,14 @@ interface UseCacheEntry {
4653 /** seconds; hard limit after which the entry is discarded on read */
4754 expire ?: number ;
4855 /** `cacheTag()` tags, excluding implicit soft tags */
49- tags ?: string [ ] ;
56+ tags ?: unknown [ ] ;
5057}
5158
5259type GlobalWithCacheHandlers = typeof globalThis & {
5360 [ NEXT_CACHE_HANDLERS_MAP ] ?: Map < string , UseCacheHandler > ;
5461 [ NEXT_PRIVATE_CACHE_HANDLER ] ?: UseCacheHandler ;
5562 [ SENTRY_CACHE_INSTRUMENTED ] ?: boolean ;
63+ [ SENTRY_WRAPPED_HANDLERS ] ?: WeakSet < object > ;
5664} ;
5765
5866/**
@@ -63,19 +71,25 @@ function keyDigest(cacheKey: string): string {
6371 return createHash ( 'sha1' ) . update ( cacheKey ) . digest ( 'hex' ) . slice ( 0 , 12 ) ;
6472}
6573
74+ /**
75+ * Cache reads can be hot, so all span work (including key hashing) is skipped without a sampled
76+ * parent span. Background revalidations still pass: they parent to the serving request's
77+ * (possibly already finished) root span.
78+ */
79+ function shouldRecordCacheSpan ( ) : boolean {
80+ const activeSpan = getActiveSpan ( ) ;
81+ return ! ! activeSpan && spanIsSampled ( activeSpan ) ;
82+ }
83+
6684function startCacheSpan < T > ( op : typeof CACHE_GET | typeof CACHE_PUT , cacheKey : string , callback : ( span : Span ) => T ) : T {
6785 const client = getClient ( ) ;
6886 const digest = keyDigest ( cacheKey ) ;
6987
7088 return startSpan (
7189 {
72- // low cardinality name for span streaming
90+ // low cardinality name for span streaming, so we can't fall back to the cache key
7391 name : client && hasSpanStreamingEnabled ( client ) ? op : digest ,
7492 op,
75- // Without an active parent span (e.g. a detached worker context), a cache span would become
76- // its own orphan transaction. This does not filter background revalidations; those still
77- // parent to the serving request's (possibly already finished) root span.
78- onlyIfParent : true ,
7993 attributes : {
8094 [ SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN ] : CACHE_SPAN_ORIGIN ,
8195 [ SEMANTIC_ATTRIBUTE_CACHE_KEY ] : [ digest ] ,
@@ -87,9 +101,20 @@ function startCacheSpan<T>(op: typeof CACHE_GET | typeof CACHE_PUT, cacheKey: st
87101}
88102
89103/**
90- * `cache.hit` mirrors Next.js' use-cache wrapper: it discards entries past their hard `expire`
91- * limit and re-runs the function, so those count as misses here. Tag-revalidation discards are
92- * not detectable at the handler level and still count as hits.
104+ * Safety net for custom handlers that return entries past `expire`: Next.js discards those and
105+ * re-runs the function. Its default handler already returns no entry in that case.
106+ */
107+ function isExpired ( ageMs : number | undefined , expire : number | undefined ) : boolean {
108+ if ( ageMs === undefined || typeof expire !== 'number' ) {
109+ return false ;
110+ }
111+ const effectiveExpire = process . env . NODE_ENV === 'development' ? Math . max ( expire , NEXT_DEV_MIN_EXPIRE ) : expire ;
112+ return ageMs > effectiveExpire * 1000 ;
113+ }
114+
115+ /**
116+ * A missing entry is a miss. Next.js' default handler also returns no entry for expired, evicted,
117+ * or tag-invalidated entries, so those count as misses too.
93118 */
94119function setEntryAttributes ( span : Span , entry : unknown ) : void {
95120 if ( entry === undefined ) {
@@ -98,54 +123,83 @@ function setEntryAttributes(span: Span, entry: unknown): void {
98123 }
99124
100125 const { timestamp, expire, tags } = ( entry ?? { } ) as UseCacheEntry ;
101- const ageMs = typeof timestamp === 'number' ? _INTERNAL_safeDateNow ( ) - timestamp : undefined ;
102- const isExpired = ageMs !== undefined && typeof expire === 'number' && ageMs > expire * 1000 ;
126+ const ageMs = typeof timestamp === 'number' ? timestampInSeconds ( ) * 1000 - timestamp : undefined ;
103127
104- span . setAttribute ( SEMANTIC_ATTRIBUTE_CACHE_HIT , ! isExpired ) ;
128+ span . setAttribute ( SEMANTIC_ATTRIBUTE_CACHE_HIT , ! isExpired ( ageMs , expire ) ) ;
105129
106130 if ( ageMs !== undefined ) {
107131 // Clamped: with a remote handler, the filling and the reading machine's clocks can drift.
108132 span . setAttribute ( CACHE_ITEM_AGE , Math . max ( 0 , Math . round ( ageMs / 1000 ) ) ) ;
109133 }
110- if ( typeof expire === 'number' && expire < NEXT_INFINITE_CACHE ) {
134+ // A negative `expire` is Next.js' eviction sentinel, not a TTL.
135+ if ( typeof expire === 'number' && expire >= 0 && expire < NEXT_INFINITE_CACHE ) {
111136 span . setAttribute ( CACHE_TTL , expire ) ;
112137 }
113- if ( Array . isArray ( tags ) && tags . length > 0 ) {
114- span . setAttribute ( CACHE_TAGS , tags ) ;
138+ const stringTags = Array . isArray ( tags ) ? tags . filter ( ( tag ) : tag is string => typeof tag === 'string' ) : [ ] ;
139+ if ( stringTags . length > 0 ) {
140+ span . setAttribute ( CACHE_TAGS , stringTags ) ;
115141 }
116142}
117143
118- function instrumentHandler ( handler : unknown ) : void {
119- if (
120- ! handler ||
121- typeof handler !== 'object' ||
122- typeof ( handler as UseCacheHandler ) . get !== 'function' ||
123- typeof ( handler as UseCacheHandler ) . set !== 'function' ||
124- ( handler as UseCacheHandler ) [ SENTRY_HANDLER_WRAPPED ]
125- ) {
126- return ;
144+ function isCacheHandler ( value : unknown ) : value is UseCacheHandler {
145+ return (
146+ typeof value === 'object' &&
147+ value !== null &&
148+ typeof ( value as UseCacheHandler ) . get === 'function' &&
149+ typeof ( value as UseCacheHandler ) . set === 'function'
150+ ) ;
151+ }
152+
153+ // A WeakSet instead of a marker property, because frozen or proxied handlers reject new properties.
154+ function getWrappedHandlers ( ) : WeakSet < object > {
155+ const globalWithCacheHandlers = globalThis as GlobalWithCacheHandlers ;
156+ if ( ! globalWithCacheHandlers [ SENTRY_WRAPPED_HANDLERS ] ) {
157+ globalWithCacheHandlers [ SENTRY_WRAPPED_HANDLERS ] = new WeakSet ( ) ;
127158 }
159+ return globalWithCacheHandlers [ SENTRY_WRAPPED_HANDLERS ] ;
160+ }
128161
129- const cacheHandler = handler as UseCacheHandler ;
130- cacheHandler [ SENTRY_HANDLER_WRAPPED ] = true ;
162+ function instrumentHandler ( handler : unknown ) : void {
163+ // Runs inside Next.js' handler registration, which must never fail because of Sentry.
164+ try {
165+ const wrappedHandlers = getWrappedHandlers ( ) ;
166+ if ( ! isCacheHandler ( handler ) || wrappedHandlers . has ( handler ) ) {
167+ return ;
168+ }
169+ wrappedHandlers . add ( handler ) ;
131170
132- fill ( cacheHandler , 'get' , ( originalGet : UseCacheHandler [ 'get' ] ) => {
133- return function ( this : UseCacheHandler , cacheKey : string , softTags ?: string [ ] ) : Promise < unknown > {
134- return startCacheSpan ( CACHE_GET , cacheKey , async span => {
135- const entry = await originalGet . call ( this , cacheKey , softTags ) ;
136- setEntryAttributes ( span , entry ) ;
137- return entry ;
138- } ) ;
139- } ;
140- } ) ;
171+ fill ( handler , 'get' , ( originalGet : UseCacheHandler [ 'get' ] ) => {
172+ return function ( this : UseCacheHandler , cacheKey : string , softTags ?: string [ ] ) : Promise < unknown > {
173+ if ( ! shouldRecordCacheSpan ( ) ) {
174+ return originalGet . call ( this , cacheKey , softTags ) ;
175+ }
176+ return startCacheSpan ( CACHE_GET , cacheKey , span =>
177+ // `Promise.resolve` because custom handlers may return the entry synchronously.
178+ Promise . resolve ( originalGet . call ( this , cacheKey , softTags ) ) . then ( entry => {
179+ try {
180+ setEntryAttributes ( span , entry ) ;
181+ } catch ( error ) {
182+ DEBUG_BUILD && debug . warn ( 'Failed to read Next.js cache entry metadata' , error ) ;
183+ }
184+ return entry ;
185+ } ) ,
186+ ) ;
187+ } ;
188+ } ) ;
141189
142- fill ( cacheHandler , 'set' , ( originalSet : UseCacheHandler [ 'set' ] ) => {
143- return function ( this : UseCacheHandler , cacheKey : string , pendingEntry : Promise < unknown > ) : Promise < void > {
144- // The handler drains `pendingEntry` (the still-streaming entry) before storing, so this
145- // span covers producing and storing the entry, not just the write.
146- return startCacheSpan ( CACHE_PUT , cacheKey , ( ) => originalSet . call ( this , cacheKey , pendingEntry ) ) ;
147- } ;
148- } ) ;
190+ fill ( handler , 'set' , ( originalSet : UseCacheHandler [ 'set' ] ) => {
191+ return function ( this : UseCacheHandler , cacheKey : string , pendingEntry : Promise < unknown > ) : Promise < void > {
192+ if ( ! shouldRecordCacheSpan ( ) ) {
193+ return originalSet . call ( this , cacheKey , pendingEntry ) ;
194+ }
195+ // The handler drains `pendingEntry` (the still-streaming entry) before storing, so this
196+ // span covers producing and storing the entry, not just the write.
197+ return startCacheSpan ( CACHE_PUT , cacheKey , ( ) => originalSet . call ( this , cacheKey , pendingEntry ) ) ;
198+ } ;
199+ } ) ;
200+ } catch ( error ) {
201+ DEBUG_BUILD && debug . warn ( 'Failed to instrument a Next.js cache handler' , error ) ;
202+ }
149203}
150204
151205function instrumentHandlersMap ( handlersMap : Map < string , UseCacheHandler > ) : void {
@@ -156,8 +210,9 @@ function instrumentHandlersMap(handlersMap: Map<string, UseCacheHandler>): void
156210 // Custom handlers can be registered later (`setCacheHandler`), so wrap new entries as they arrive.
157211 fill ( handlersMap , 'set' , ( originalSet : Map < string , UseCacheHandler > [ 'set' ] ) => {
158212 return function ( this : Map < string , UseCacheHandler > , kind : string , handler : UseCacheHandler ) {
213+ const result = originalSet . call ( this , kind , handler ) ;
159214 instrumentHandler ( handler ) ;
160- return originalSet . call ( this , kind , handler ) ;
215+ return result ;
161216 } ;
162217 } ) ;
163218}
@@ -189,12 +244,13 @@ function instrumentWhenAssigned(symbol: symbol, onValue: (value: unknown) => voi
189244 } ) ;
190245}
191246
192- /**
193- * Wraps Next.js' `use cache` handlers with `cache.get`/`cache.put` spans, so cached function
194- * reads and fills show up in traces with hit/miss information. The registry only exists with
195- * `cacheComponents`/`useCache` enabled; otherwise this only installs inert interceptors.
247+ /** Installs the `use cache` handler instrumentation once per process.
248+ *
249+ * Only exported for testing.
250+ *
251+ * @internal
196252 */
197- export function instrumentUseCacheHandlers ( ) : void {
253+ export function _instrumentUseCacheHandlers ( ) : void {
198254 try {
199255 const globalWithCacheHandlers = globalThis as GlobalWithCacheHandlers ;
200256
@@ -215,3 +271,16 @@ export function instrumentUseCacheHandlers(): void {
215271 DEBUG_BUILD && debug . warn ( 'Failed to instrument Next.js cache handlers' , error ) ;
216272 }
217273}
274+
275+ /**
276+ * Wraps Next.js' `use cache` handlers with `cache.get`/`cache.put` spans, so cached function
277+ * reads and fills show up in traces with hit/miss information.
278+ */
279+ export const nextjsUseCacheIntegration = defineIntegration ( ( ) => {
280+ return {
281+ name : INTEGRATION_NAME ,
282+ setupOnce ( ) {
283+ _instrumentUseCacheHandlers ( ) ;
284+ } ,
285+ } ;
286+ } ) ;
0 commit comments