Skip to content

Commit 057c6a7

Browse files
committed
feat(nextjs): Add code.file.path to use cache fill spans
1 parent d880cf0 commit 057c6a7

8 files changed

Lines changed: 513 additions & 7 deletions

File tree

‎dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/tests/cacheOriginLinks-nesting.spec.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,8 @@ test('links a cached layout hit to the trace that filled the layout entry', asyn
3535
const putSpans = (missTx.spans ?? []).filter(span => span.op === 'cache.put');
3636
expect(new Set(putSpans.map(span => span.description)).size).toBe(1);
3737

38+
expect(putSpans[0]!.data?.['code.file.path']).toBe('app/(cached-nesting)/cached-mid-layout/[id]/layout.tsx');
39+
3840
const hitGetSpan = hitTx.spans?.find(span => span.op === 'cache.get' && span.data?.['cache.hit'] === true);
3941
expect(hitGetSpan).toBeDefined();
4042
expect(hitGetSpan?.description).toBe(putSpans[0]!.description);

‎dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/tests/useCacheSpans.spec.ts‎

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,9 @@ test('Should create cache spans around `use cache` functions', async ({ request
5757
}),
5858
});
5959

60+
// Route handler cache functions have no server-reference manifest entry, so no source file.
61+
expect(putSpan!.data).not.toHaveProperty('code.file.path');
62+
6063
const hitGetSpan = hitTx.spans?.find(span => span.op === 'cache.get');
6164
expect(hitGetSpan).toBeDefined();
6265
expect(hitGetSpan).toMatchObject({
@@ -97,7 +100,12 @@ test('Should create cache spans for `use cache` inside a rendered page', async (
97100
await request.get(`/use-cache-page?id=${id}`);
98101
const hitTx = await hitTxPromise;
99102

100-
expect(missTx.spans?.some(span => span.op === 'cache.put')).toBe(true);
103+
// The source file on a fill span marks which cached function produced the entry.
104+
const missPutSpans = missTx.spans?.filter(span => span.op === 'cache.put') ?? [];
105+
expect(missPutSpans.length).toBeGreaterThan(0);
106+
for (const putSpan of missPutSpans) {
107+
expect(putSpan.data?.['code.file.path']).toBe('app/use-cache-page/page.tsx');
108+
}
101109

102110
// A render can read more than one cache entry, so look at every hit instead of the first `cache.get`.
103111
const hitGetSpans = hitTx.spans?.filter(span => span.op === 'cache.get' && span.data?.['cache.hit'] === true) ?? [];

‎dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/tests/cacheOriginLinks-nesting.spec.ts‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,8 @@ test('links a cached layout hit to the trace that filled the layout entry', asyn
3636
const putSpan = findCacheSpan(missSpans, 'cache.put');
3737
expect(putSpan).toBeDefined();
3838

39+
expect(putSpan!.attributes['code.file.path']?.value).toBe('app/(cached-nesting)/cached-mid-layout/[id]/layout.tsx');
40+
3941
const hitGetSpan = findCacheSpan(hitSpans, 'cache.get', true);
4042
expect(hitGetSpan).toBeDefined();
4143
expect(hitGetSpan!.attributes['cache.key']).toEqual(putSpan!.attributes['cache.key']);
@@ -114,6 +116,14 @@ test('links two cached levels to different origin traces after the layout expire
114116
const fillPutSpans = fillSpans.filter(span => getSpanOp(span) === 'cache.put');
115117
expect(new Set(fillPutSpans.map(span => JSON.stringify(span.attributes['cache.key']?.value))).size).toBe(2);
116118

119+
// One fill per file: the layout (multipart key) and the cached component (JSON key).
120+
expect(new Set(fillPutSpans.map(span => span.attributes['code.file.path']?.value))).toEqual(
121+
new Set([
122+
'app/(cached-nesting)/mixed-lifetimes/[id]/layout.tsx',
123+
'app/(cached-nesting)/mixed-lifetimes/[id]/page.tsx',
124+
]),
125+
);
126+
117127
// Sleep past the layout's `expire` (2s); the component entry stays valid for hours.
118128
await new Promise(resolve => setTimeout(resolve, 3_000));
119129

‎dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/tests/useCacheSpans.spec.ts‎

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,4 +30,33 @@ test('uses low-cardinality names for `use cache` spans', async ({ request }) =>
3030
expect(putSpan).toBeDefined();
3131
expect(putSpan!.name).toBe('cache.put');
3232
expect(putSpan!.attributes['cache.key']?.value).toEqual(cacheKeyDigest);
33+
34+
// Route handler cache functions have no server-reference manifest entry, so no source file.
35+
expect(putSpan!.attributes['code.file.path']).toBeUndefined();
36+
});
37+
38+
test('sets the source file of the cached component on `cache.put` spans', async ({ request }) => {
39+
const id = crypto.randomUUID();
40+
41+
const spansPromise = collectStreamedSpans('nextjs-16-streaming-cacheComponents', spansOfTrace => {
42+
return (
43+
spansOfTrace.some(span => span.name === 'GET /cached-sibling-components' && span.is_segment) &&
44+
spansOfTrace.filter(span => getSpanOp(span) === 'cache.put').length >= 2
45+
);
46+
});
47+
48+
await request.get(`/cached-sibling-components?id=${id}`);
49+
const spans = await spansPromise;
50+
51+
// Both sibling entries come from the same page file.
52+
const putSpans = spans.filter(span => getSpanOp(span) === 'cache.put');
53+
expect(putSpans.length).toBeGreaterThanOrEqual(2);
54+
for (const putSpan of putSpans) {
55+
expect(putSpan.attributes['code.file.path']?.value).toBe('app/cached-sibling-components/page.tsx');
56+
}
57+
58+
// The source file marks the producer of an entry. Reads do not carry it.
59+
const getSpan = findCacheSpan(spans, 'cache.get');
60+
expect(getSpan).toBeDefined();
61+
expect(getSpan!.attributes['code.file.path']).toBeUndefined();
3362
});

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

Lines changed: 19 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import {
66
CACHE_OPERATION,
77
CACHE_TAGS,
88
CACHE_TTL,
9+
CODE_FILE_PATH,
910
SENTRY_LINK_TYPE,
1011
SENTRY_ORIGIN,
1112
} from '@sentry/conventions/attributes';
@@ -26,6 +27,7 @@ import {
2627
timestampInSeconds,
2728
} from '@sentry/core';
2829
import { DEBUG_BUILD } from '../common/debug-build';
30+
import { getCacheFunctionSourceFile } from './useCacheSourceFile';
2931

3032
// Next.js shares its `use cache` handlers across bundles via `globalThis`
3133
// (`next/src/server/use-cache/handlers.ts`). This module can load once per bundle, so all
@@ -268,24 +270,35 @@ function instrumentHandler(handler: unknown): void {
268270

269271
fill(handler, 'set', (originalSet: UseCacheHandler['set']) => {
270272
return function (this: UseCacheHandler, cacheKey: string, pendingEntry: Promise<unknown>): Promise<void> {
273+
const digest = keyDigest(cacheKey);
274+
275+
// A successful write replaces the entry, so a remembered origin from a previous fill is now wrong.
276+
// An unsampled fill has no span to link to -> remember nothing instead.
271277
if (!shouldRecordCacheSpan()) {
272-
return originalSet.call(this, cacheKey, pendingEntry);
278+
return Promise.resolve(originalSet.call(this, cacheKey, pendingEntry)).then(result => {
279+
getCacheOrigins().remove(originKeyPrefix + digest);
280+
return result;
281+
});
273282
}
274283

275-
const digest = keyDigest(cacheKey);
284+
const sourceFile = getCacheFunctionSourceFile(cacheKey);
285+
276286
// The handler drains `pendingEntry` (the still-streaming entry) before storing, so this
277287
// span covers producing and storing the entry, not just the write.
278-
return startCacheSpan(CACHE_PUT, digest, span =>
288+
return startCacheSpan(CACHE_PUT, digest, span => {
289+
if (sourceFile) {
290+
span.setAttribute(CODE_FILE_PATH, sourceFile);
291+
}
279292
// Only a successful write becomes a fill origin: a failed write leaves no entry or the
280293
// previous one (whose origin still stands). A dropped span (`ignoreSpans`) never
281294
// reaches Sentry, so a link to it would be broken.
282-
Promise.resolve(originalSet.call(this, cacheKey, pendingEntry)).then(result => {
295+
return Promise.resolve(originalSet.call(this, cacheKey, pendingEntry)).then(result => {
283296
if (span.isRecording()) {
284297
rememberCacheOrigin(originKeyPrefix + digest, span, pendingEntry);
285298
}
286299
return result;
287-
}),
288-
);
300+
});
301+
});
289302
};
290303
});
291304
} catch (error) {
Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
1+
// Next.js' runtime manifest registry
2+
// https://github.com/vercel/next.js/blob/8e0700c74474498a07b33f58da1c1316f740eb19/packages/next/src/server/app-render/manifests-singleton.ts#L56-L59
3+
const NEXT_MANIFESTS_SINGLETON = Symbol.for('next.server.manifests');
4+
5+
type ManifestEntries = Record<string, { filename?: unknown } | undefined>;
6+
7+
type GlobalWithManifests = typeof globalThis & {
8+
[NEXT_MANIFESTS_SINGLETON]?: {
9+
serverActionsManifest?: {
10+
node?: ManifestEntries;
11+
edge?: ManifestEntries;
12+
};
13+
};
14+
};
15+
16+
interface MultipartField {
17+
content: string;
18+
/** Index of the first character after this field. */
19+
end: number;
20+
}
21+
22+
/**
23+
* Reads one field of a multipart cache key, starting at `start`.
24+
* A field is `<length>:<content>`, with the length in lowercase hex counting UTF-16 code units.
25+
* https://github.com/vercel/next.js/blob/8e0700c74474498a07b33f58da1c1316f740eb19/packages/next/src/server/use-cache/use-cache-wrapper.ts#L1815-L1847
26+
*
27+
* Returns `undefined` when the framing is broken.
28+
*/
29+
function readMultipartField(cacheKey: string, start: number): MultipartField | undefined {
30+
// The characters before the next `:` must be a non-empty hex length.
31+
const colon = cacheKey.indexOf(':', start);
32+
if (colon <= start || !/^[0-9a-f]+$/.test(cacheKey.slice(start, colon))) {
33+
return undefined;
34+
}
35+
36+
// A length that points past the end of the key means the key is truncated.
37+
const contentStart = colon + 1;
38+
const contentEnd = contentStart + parseInt(cacheKey.slice(start, colon), 16);
39+
if (contentEnd > cacheKey.length) {
40+
return undefined;
41+
}
42+
43+
return { content: cacheKey.slice(contentStart, contentEnd), end: contentEnd };
44+
}
45+
46+
/**
47+
* Cache keys with arguments that do not serialize to JSON (a page's `params` promise, a layout's
48+
* `children`) are serialized FormData: pairs of length-prefixed fields, a field name followed by
49+
* its content. React stores the key parts JSON in the field named `"0"`, which is not necessarily
50+
* the first field. Returns that JSON text, or `undefined` for a malformed key.
51+
*/
52+
function readKeyPartsFromMultipartKey(cacheKey: string): string | undefined {
53+
let position = 0;
54+
while (position < cacheKey.length) {
55+
// Each pair is the field name, then the field content.
56+
const name = readMultipartField(cacheKey, position);
57+
const content = name && readMultipartField(cacheKey, name.end);
58+
if (name === undefined || content === undefined) {
59+
return undefined;
60+
}
61+
62+
if (name.content === '0') {
63+
return content.content;
64+
}
65+
position = content.end;
66+
}
67+
return undefined;
68+
}
69+
70+
/**
71+
* Picks the function id out of the decoded key parts. Next.js 16.3 puts the id at index 1
72+
* (`[buildId, id, args]` in prod; dev appends a fourth part). 16.4 canary moves it to index 0
73+
* (`[id, args, …]`). Returns `undefined` for any other shape.
74+
*/
75+
function readFunctionIdFromKeyParts(keyParts: unknown): string | undefined {
76+
if (!Array.isArray(keyParts)) {
77+
return undefined;
78+
}
79+
// An args array at index 1 marks the canary shape, where the id sits at index 0.
80+
const functionId = Array.isArray(keyParts[1]) ? keyParts[0] : keyParts[1];
81+
return typeof functionId === 'string' ? functionId : undefined;
82+
}
83+
84+
/**
85+
* Manifest filenames start at the repo root, but only the path inside the project is useful.
86+
* The repo-root-to-project part equals the tail of `process.cwd()`, because `next dev`,
87+
* `next start`, and the standalone server all run in the project directory (the standalone
88+
* `server.js` chdirs into its mirrored copy). Unknown layouts keep the full path.
89+
*/
90+
function toProjectRelativePath(filename: string): string {
91+
// Split the cwd on both separators so Windows paths work. The manifest always uses `/`.
92+
const cwdSegments = process.cwd().split(/[\\/]/).filter(Boolean);
93+
const fileSegments = filename.split('/');
94+
95+
// Drop the longest filename prefix that matches the cwd tail. Longest first, so the whole
96+
// repo prefix goes, not just a part of it. At least one segment always remains.
97+
const maxOverlap = Math.min(fileSegments.length - 1, cwdSegments.length);
98+
for (let overlap = maxOverlap; overlap > 0; overlap--) {
99+
const cwdTail = cwdSegments.slice(-overlap);
100+
if (cwdTail.every((segment, index) => segment === fileSegments[index])) {
101+
return fileSegments.slice(overlap).join('/');
102+
}
103+
}
104+
return filename;
105+
}
106+
107+
/**
108+
* Resolves the source file of the `use cache` function behind a cache key, through the server-reference manifest.
109+
* On Next.js 16.3 the manifest only covers component-tree functions, so route handlers resolve to `undefined`.
110+
* 16.4 canary includes route handlers.
111+
* Any unexpected key or manifest shape returns `undefined`, never a wrong file.
112+
*/
113+
export function getCacheFunctionSourceFile(cacheKey: string): string | undefined {
114+
try {
115+
// The key is `encodeReply(keyParts)`: a plain JSON array when all function arguments
116+
// serialize to JSON, the multipart form otherwise.
117+
const keyPartsJson = cacheKey.startsWith('[') ? cacheKey : readKeyPartsFromMultipartKey(cacheKey);
118+
if (!keyPartsJson) {
119+
return undefined;
120+
}
121+
122+
const functionId = readFunctionIdFromKeyParts(JSON.parse(keyPartsJson));
123+
if (functionId === undefined) {
124+
return undefined;
125+
}
126+
127+
// The manifest has one section per runtime. `NEXT_RUNTIME` is only `'edge'` on edge.
128+
const manifest = (globalThis as GlobalWithManifests)[NEXT_MANIFESTS_SINGLETON]?.serverActionsManifest;
129+
const filename = manifest?.[process.env.NEXT_RUNTIME === 'edge' ? 'edge' : 'node']?.[functionId]?.filename;
130+
return typeof filename === 'string' && filename ? toProjectRelativePath(filename) : undefined;
131+
} catch {
132+
return undefined;
133+
}
134+
}

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

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ import { _instrumentUseCacheHandlers } from '../../src/server/useCacheInstrument
3333

3434
const NEXT_CACHE_HANDLERS_MAP = Symbol.for('@next/cache-handlers-map');
3535
const NEXT_PRIVATE_CACHE_HANDLER = Symbol.for('@next/cache-handlers-private');
36+
const NEXT_MANIFESTS_SINGLETON = Symbol.for('next.server.manifests');
3637
const SENTRY_CACHE_INSTRUMENTED = Symbol.for('sentry.nextjs.cacheHandlersInstrumented');
3738
const SENTRY_WRAPPED_HANDLERS = Symbol.for('sentry.nextjs.wrappedCacheHandlers');
3839
const SENTRY_CACHE_ORIGINS = Symbol.for('sentry.nextjs.cacheOrigins');
@@ -64,6 +65,7 @@ describe('instrumentUseCacheHandlers', () => {
6465
beforeEach(() => {
6566
mocks.activeSpan = {};
6667
mocks.sampled = true;
68+
mocks.state.spanCount = 0;
6769
mocks.client = undefined;
6870
mocks.state.spanCount = 0;
6971
mocks.state.recording = true;
@@ -77,6 +79,7 @@ describe('instrumentUseCacheHandlers', () => {
7779
for (const symbol of [
7880
NEXT_CACHE_HANDLERS_MAP,
7981
NEXT_PRIVATE_CACHE_HANDLER,
82+
NEXT_MANIFESTS_SINGLETON,
8083
SENTRY_CACHE_INSTRUMENTED,
8184
SENTRY_WRAPPED_HANDLERS,
8285
SENTRY_CACHE_ORIGINS,
@@ -468,6 +471,35 @@ describe('instrumentUseCacheHandlers', () => {
468471
expect(mocks.addLink).not.toHaveBeenCalled();
469472
});
470473

474+
it('forgets a remembered origin when the entry is refilled without a sampled parent span', async () => {
475+
const handler = installWithDefaultHandler({ timestamp: nowMs() });
476+
477+
await handler.set('cache-key', Promise.resolve({}));
478+
479+
mocks.activeSpan = undefined;
480+
await handler.set('cache-key', Promise.resolve({}));
481+
mocks.activeSpan = {};
482+
483+
await handler.get('cache-key');
484+
485+
expect(mocks.addLink).not.toHaveBeenCalled();
486+
});
487+
488+
it('forgets a remembered origin when the refill `cache.put` span is not recording', async () => {
489+
const handler = installWithDefaultHandler({ timestamp: nowMs() });
490+
491+
await handler.set('cache-key', Promise.resolve({}));
492+
493+
// e.g. the `cache.put` op is filtered via `ignoreSpans`
494+
mocks.state.recording = false;
495+
await handler.set('cache-key', Promise.resolve({}));
496+
mocks.state.recording = true;
497+
498+
await handler.get('cache-key');
499+
500+
expect(mocks.addLink).not.toHaveBeenCalled();
501+
});
502+
471503
it('does not remember fills whose write failed', async () => {
472504
const entry = { timestamp: nowMs() };
473505
const handler = {
@@ -484,6 +516,48 @@ describe('instrumentUseCacheHandlers', () => {
484516
});
485517
});
486518

519+
describe('source file on `cache.put`', () => {
520+
const functionId = 'c05120808bb68f6400d039e720226869fb1f079019';
521+
const jsonCacheKey = JSON.stringify(['build-id', functionId, [['arg'], {}]]);
522+
523+
function setManifest(filename: unknown): void {
524+
setGlobal(NEXT_MANIFESTS_SINGLETON, {
525+
serverActionsManifest: { node: { [functionId]: { filename } } },
526+
});
527+
}
528+
529+
it('sets `code.file.path` when the key parses and the manifest knows the function', async () => {
530+
setManifest('app/(cached-nesting)/mixed-lifetimes/[id]/layout.tsx');
531+
const handler = installWithDefaultHandler();
532+
533+
await handler.set(jsonCacheKey, Promise.resolve({}));
534+
535+
expect(mocks.setAttribute).toHaveBeenCalledWith(
536+
'code.file.path',
537+
'app/(cached-nesting)/mixed-lifetimes/[id]/layout.tsx',
538+
);
539+
});
540+
541+
it('does not set `code.file.path` on `cache.get` spans', async () => {
542+
setManifest('app/page.tsx');
543+
const handler = installWithDefaultHandler({ timestamp: nowMs() });
544+
545+
await handler.get(jsonCacheKey);
546+
547+
expect(mocks.setAttribute).not.toHaveBeenCalledWith('code.file.path', expect.anything());
548+
});
549+
550+
// Key parsing, manifest lookup, and path shortening are covered in `useCacheSourceFile.test.ts`.
551+
it('still writes the entry and omits `code.file.path` when the key does not resolve', async () => {
552+
setManifest('app/page.tsx');
553+
const handler = installWithDefaultHandler();
554+
555+
await expect(handler.set('multipart-encoded-key', Promise.resolve({}))).resolves.toBeUndefined();
556+
557+
expect(mocks.setAttribute).not.toHaveBeenCalledWith('code.file.path', expect.anything());
558+
});
559+
});
560+
487561
it('creates a `cache.put` span around handler writes', async () => {
488562
const handler = installWithDefaultHandler();
489563

0 commit comments

Comments
 (0)