11import { createHash } from 'node:crypto' ;
2+ import { TraceFlags } from '@opentelemetry/api' ;
23import {
34 CACHE_HIT ,
45 CACHE_ITEM_AGE ,
@@ -16,13 +17,15 @@ import {
1617 CACHE_OPERATION_NAMES ,
1718 debug ,
1819 defineIntegration ,
20+ extractTraceparentData ,
1921 fill ,
2022 getActiveSpan ,
2123 getClient ,
2224 hasSpanStreamingEnabled ,
2325 hasSpansEnabled ,
2426 LRUMap ,
2527 spanIsSampled ,
28+ spanToTraceHeader ,
2629 startSpan ,
2730 timestampInSeconds ,
2831} from '@sentry/core' ;
@@ -70,6 +73,11 @@ interface UseCacheEntry {
7073 expire ?: number ;
7174 /** `cacheTag()` tags, excluding implicit soft tags */
7275 tags ?: unknown [ ] ;
76+ /**
77+ * Set by Sentry, not by Next.js. Holds the `sentry-trace` header of the fill's `cache.put` span.
78+ * Handlers that keep unknown entry properties carry it to hits on other instances.
79+ */
80+ __sentryCacheOrigin ?: unknown ;
7381}
7482
7583type GlobalWithCacheHandlers = typeof globalThis & {
@@ -171,8 +179,8 @@ function recordCacheEntry(span: Span, entry: unknown): boolean {
171179 return hit ;
172180}
173181
174- // Cache origins are scoped per-process (known limitation: no links across server instances — that would require storing metadata on `CacheEntry`).
175- // Hits on entries filled by another instance get no link . Lives on `globalThis` like the rest of the instrumentation state.
182+ // Fallback for handlers that drop unknown entry properties. Only a hit on the instance that did the
183+ // fill can use it . Lives on `globalThis`, like the rest of the instrumentation state.
176184function getCacheOrigins ( ) : LRUMap < string , CacheOrigin > {
177185 const globalWithCacheHandlers = globalThis as GlobalWithCacheHandlers ;
178186 if ( ! globalWithCacheHandlers [ SENTRY_CACHE_ORIGINS ] ) {
@@ -182,19 +190,58 @@ function getCacheOrigins(): LRUMap<string, CacheOrigin> {
182190}
183191
184192/**
185- * Links a cache hit to the fill that wrote this entry revision (matched by timestamp).
186- * A hit with no matching origin (unsampled or dropped refill, another instance, process restart)
187- * gets no link instead of a stale one.
193+ * Links a cache hit to the fill that wrote this entry revision. A hit whose origin is unknown gets
194+ * no link, never a stale one.
188195 */
189196function linkCacheOrigin ( span : Span , originKey : string , entry : unknown ) : void {
197+ const context = getPatchedCacheOrigin ( entry ) ?? getRememberedCacheOrigin ( originKey , entry ) ;
198+ if ( context ) {
199+ span . addLink ( { context, attributes : { [ SENTRY_LINK_TYPE ] : CACHE_ORIGIN_LINK_TYPE } } ) ;
200+ }
201+ }
202+
203+ /**
204+ * Next.js writes an entry only after it runs the cached function, and it never writes back a hit.
205+ * So a patched origin always belongs to the entry revision that carries it. Links point only at
206+ * sampled traces.
207+ */
208+ function getPatchedCacheOrigin ( entry : unknown ) : SpanContextData | undefined {
209+ const { __sentryCacheOrigin } = ( entry ?? { } ) as UseCacheEntry ;
210+ const origin = typeof __sentryCacheOrigin === 'string' ? extractTraceparentData ( __sentryCacheOrigin ) : undefined ;
211+ return origin ?. traceId && origin . parentSpanId && origin . parentSampled
212+ ? { traceId : origin . traceId , spanId : origin . parentSpanId , traceFlags : TraceFlags . SAMPLED }
213+ : undefined ;
214+ }
215+
216+ /** The table can outlive the entry revision it remembers, so the fill timestamp must match. */
217+ function getRememberedCacheOrigin ( originKey : string , entry : unknown ) : SpanContextData | undefined {
190218 const origin = getCacheOrigins ( ) . get ( originKey ) ;
191219 const { timestamp } = ( entry ?? { } ) as UseCacheEntry ;
192- if ( origin && origin . entryTimestamp === timestamp ) {
193- span . addLink ( {
194- context : origin . context ,
195- attributes : { [ SENTRY_LINK_TYPE ] : CACHE_ORIGIN_LINK_TYPE } ,
196- } ) ;
197- }
220+ return origin && origin . entryTimestamp === timestamp ? origin . context : undefined ;
221+ }
222+
223+ /**
224+ * Handlers get the patched promise, not the original one. Some handlers copy the entry in their own
225+ * `then`, which can run before a callback that is added later.
226+ */
227+ function patchCacheOrigin ( pendingEntry : Promise < unknown > , origin : string ) : Promise < unknown > {
228+ const patchedEntry = Promise . resolve ( pendingEntry ) . then ( entry => {
229+ if ( typeof entry === 'object' && entry !== null ) {
230+ try {
231+ // Patch in place, not on a copy. Handlers can replace `entry.value` on the object they get,
232+ // and Next.js keeps a reference to that object.
233+ ( entry as UseCacheEntry ) . __sentryCacheOrigin = origin ;
234+ } catch ( error ) {
235+ // Frozen or proxied entries reject new properties. Hits then fall back to the table.
236+ DEBUG_BUILD && debug . warn ( 'Failed to patch a Next.js cache fill origin' , error ) ;
237+ }
238+ }
239+ return entry ;
240+ } ) ;
241+ // A handler can fail the write without reading the entry. A failed entry then has no other
242+ // rejection handler, and Sentry must not cause an unhandled rejection.
243+ patchedEntry . catch ( ( ) => undefined ) ;
244+ return patchedEntry ;
198245}
199246
200247/**
@@ -285,17 +332,21 @@ function instrumentHandler(handler: unknown): void {
285332
286333 // The handler drains `pendingEntry` (the still-streaming entry) before storing, so this
287334 // span covers producing and storing the entry, not just the write.
288- return startCacheSpan ( CACHE_PUT , digest , sourceFile ? { [ CODE_FILE_PATH ] : sourceFile } : { } , span =>
289- // Only a successful write becomes a fill origin: a failed write leaves no entry or the
290- // previous one (whose origin still stands). A dropped span (`ignoreSpans`) never
291- // reaches Sentry, so a link to it would be broken.
292- Promise . resolve ( originalSet . call ( this , cacheKey , pendingEntry ) ) . then ( result => {
293- if ( span . isRecording ( ) ) {
294- rememberCacheOrigin ( originKeyPrefix + digest , span , pendingEntry ) ;
295- }
335+ return startCacheSpan ( CACHE_PUT , digest , sourceFile ? { [ CODE_FILE_PATH ] : sourceFile } : { } , span => {
336+ // Read `isRecording()` before the write. The span ends with the write, and a handler can
337+ // resolve the write before it reads the entry. A span dropped by `ignoreSpans` never reaches Sentry, so
338+ // a link to it would be broken.
339+ if ( ! span . isRecording ( ) ) {
340+ return originalSet . call ( this , cacheKey , pendingEntry ) ;
341+ }
342+ const patchedEntry = patchCacheOrigin ( pendingEntry , spanToTraceHeader ( span ) ) ;
343+ return Promise . resolve ( originalSet . call ( this , cacheKey , patchedEntry ) ) . then ( result => {
344+ // Only a successful write becomes a fill origin. A failed write leaves no entry or the
345+ // previous one, whose origin still applies.
346+ rememberCacheOrigin ( originKeyPrefix + digest , span , patchedEntry ) ;
296347 return result ;
297- } ) ,
298- ) ;
348+ } ) ;
349+ } ) ;
299350 } ;
300351 } ) ;
301352 } catch ( error ) {
0 commit comments