Skip to content

Commit 60c12e8

Browse files
authored
feat(nextjs): Link use cache hits to their fill across instances (#25448)
`use cache` hits get a `cache_origin` link to their fill. The SDK remembered fills per process. Previously, deployments on serverless (e.g. Vercel), another instance usually did the fill, so most hits got no link. The SDK now also stores the fill's `sentry-trace` value on the cache entry, so other instances find it. The per-process table stays as the fallback for handlers that drop unknown fields. - In a Vercel test, about 85% of `use cache: remote` hits came from another instance - With the locally built SDK on Vercel, all 148 logged hits linked to their fill (also across instances) Closes #24296 Ref #12888 [JS-3657](https://linear.app/getsentry/issue/JS-3657/decide-the-origin-transport-mechanism-cache-written-to-different)
1 parent eb60783 commit 60c12e8

2 files changed

Lines changed: 307 additions & 85 deletions

File tree

‎packages/nextjs/src/server/useCacheInstrumentation.ts‎

Lines changed: 72 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { createHash } from 'node:crypto';
2+
import { TraceFlags } from '@opentelemetry/api';
23
import {
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

7583
type 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.
176184
function 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
*/
189196
function 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

Comments
 (0)