From 057c6a77114d6c8a1baac91d550504e1dd6701df Mon Sep 17 00:00:00 2001 From: s1gr1d <32902192+s1gr1d@users.noreply.github.com> Date: Tue, 29 Sep 2026 11:39:26 +0200 Subject: [PATCH 1/4] feat(nextjs): Add `code.file.path` to `use cache` fill spans --- .../tests/cacheOriginLinks-nesting.spec.ts | 2 + .../tests/useCacheSpans.spec.ts | 10 +- .../tests/cacheOriginLinks-nesting.spec.ts | 10 + .../tests/useCacheSpans.spec.ts | 29 +++ .../src/server/useCacheInstrumentation.ts | 25 +- .../nextjs/src/server/useCacheSourceFile.ts | 134 ++++++++++ .../server/useCacheInstrumentation.test.ts | 74 ++++++ .../test/server/useCacheSourceFile.test.ts | 236 ++++++++++++++++++ 8 files changed, 513 insertions(+), 7 deletions(-) create mode 100644 packages/nextjs/src/server/useCacheSourceFile.ts create mode 100644 packages/nextjs/test/server/useCacheSourceFile.test.ts diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/tests/cacheOriginLinks-nesting.spec.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/tests/cacheOriginLinks-nesting.spec.ts index 9c6245a06f5c..ad7b3670c42a 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/tests/cacheOriginLinks-nesting.spec.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/tests/cacheOriginLinks-nesting.spec.ts @@ -35,6 +35,8 @@ test('links a cached layout hit to the trace that filled the layout entry', asyn const putSpans = (missTx.spans ?? []).filter(span => span.op === 'cache.put'); expect(new Set(putSpans.map(span => span.description)).size).toBe(1); + expect(putSpans[0]!.data?.['code.file.path']).toBe('app/(cached-nesting)/cached-mid-layout/[id]/layout.tsx'); + const hitGetSpan = hitTx.spans?.find(span => span.op === 'cache.get' && span.data?.['cache.hit'] === true); expect(hitGetSpan).toBeDefined(); expect(hitGetSpan?.description).toBe(putSpans[0]!.description); 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 dadb754e0a9b..5ba09c2782ee 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 @@ -57,6 +57,9 @@ test('Should create cache spans around `use cache` functions', async ({ request }), }); + // Route handler cache functions have no server-reference manifest entry, so no source file. + expect(putSpan!.data).not.toHaveProperty('code.file.path'); + const hitGetSpan = hitTx.spans?.find(span => span.op === 'cache.get'); expect(hitGetSpan).toBeDefined(); expect(hitGetSpan).toMatchObject({ @@ -97,7 +100,12 @@ test('Should create cache spans for `use cache` inside a rendered page', async ( await request.get(`/use-cache-page?id=${id}`); const hitTx = await hitTxPromise; - expect(missTx.spans?.some(span => span.op === 'cache.put')).toBe(true); + // The source file on a fill span marks which cached function produced the entry. + const missPutSpans = missTx.spans?.filter(span => span.op === 'cache.put') ?? []; + expect(missPutSpans.length).toBeGreaterThan(0); + for (const putSpan of missPutSpans) { + expect(putSpan.data?.['code.file.path']).toBe('app/use-cache-page/page.tsx'); + } // 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) ?? []; diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/tests/cacheOriginLinks-nesting.spec.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/tests/cacheOriginLinks-nesting.spec.ts index abd984c429dc..828e867ae3d7 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/tests/cacheOriginLinks-nesting.spec.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/tests/cacheOriginLinks-nesting.spec.ts @@ -36,6 +36,8 @@ test('links a cached layout hit to the trace that filled the layout entry', asyn const putSpan = findCacheSpan(missSpans, 'cache.put'); expect(putSpan).toBeDefined(); + expect(putSpan!.attributes['code.file.path']?.value).toBe('app/(cached-nesting)/cached-mid-layout/[id]/layout.tsx'); + const hitGetSpan = findCacheSpan(hitSpans, 'cache.get', true); expect(hitGetSpan).toBeDefined(); 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 const fillPutSpans = fillSpans.filter(span => getSpanOp(span) === 'cache.put'); expect(new Set(fillPutSpans.map(span => JSON.stringify(span.attributes['cache.key']?.value))).size).toBe(2); + // One fill per file: the layout (multipart key) and the cached component (JSON key). + expect(new Set(fillPutSpans.map(span => span.attributes['code.file.path']?.value))).toEqual( + new Set([ + 'app/(cached-nesting)/mixed-lifetimes/[id]/layout.tsx', + 'app/(cached-nesting)/mixed-lifetimes/[id]/page.tsx', + ]), + ); + // Sleep past the layout's `expire` (2s); the component entry stays valid for hours. await new Promise(resolve => setTimeout(resolve, 3_000)); diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/tests/useCacheSpans.spec.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/tests/useCacheSpans.spec.ts index 2704de1a11f6..472d8e975e47 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/tests/useCacheSpans.spec.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/tests/useCacheSpans.spec.ts @@ -30,4 +30,33 @@ test('uses low-cardinality names for `use cache` spans', async ({ request }) => expect(putSpan).toBeDefined(); expect(putSpan!.name).toBe('cache.put'); expect(putSpan!.attributes['cache.key']?.value).toEqual(cacheKeyDigest); + + // Route handler cache functions have no server-reference manifest entry, so no source file. + expect(putSpan!.attributes['code.file.path']).toBeUndefined(); +}); + +test('sets the source file of the cached component on `cache.put` spans', async ({ request }) => { + const id = crypto.randomUUID(); + + const spansPromise = collectStreamedSpans('nextjs-16-streaming-cacheComponents', spansOfTrace => { + return ( + spansOfTrace.some(span => span.name === 'GET /cached-sibling-components' && span.is_segment) && + spansOfTrace.filter(span => getSpanOp(span) === 'cache.put').length >= 2 + ); + }); + + await request.get(`/cached-sibling-components?id=${id}`); + const spans = await spansPromise; + + // Both sibling entries come from the same page file. + const putSpans = spans.filter(span => getSpanOp(span) === 'cache.put'); + expect(putSpans.length).toBeGreaterThanOrEqual(2); + for (const putSpan of putSpans) { + expect(putSpan.attributes['code.file.path']?.value).toBe('app/cached-sibling-components/page.tsx'); + } + + // The source file marks the producer of an entry. Reads do not carry it. + const getSpan = findCacheSpan(spans, 'cache.get'); + expect(getSpan).toBeDefined(); + expect(getSpan!.attributes['code.file.path']).toBeUndefined(); }); diff --git a/packages/nextjs/src/server/useCacheInstrumentation.ts b/packages/nextjs/src/server/useCacheInstrumentation.ts index ac0a32373790..c6449cba85f4 100644 --- a/packages/nextjs/src/server/useCacheInstrumentation.ts +++ b/packages/nextjs/src/server/useCacheInstrumentation.ts @@ -6,6 +6,7 @@ import { CACHE_OPERATION, CACHE_TAGS, CACHE_TTL, + CODE_FILE_PATH, SENTRY_LINK_TYPE, SENTRY_ORIGIN, } from '@sentry/conventions/attributes'; @@ -26,6 +27,7 @@ import { timestampInSeconds, } from '@sentry/core'; import { DEBUG_BUILD } from '../common/debug-build'; +import { getCacheFunctionSourceFile } from './useCacheSourceFile'; // 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 @@ -268,24 +270,35 @@ function instrumentHandler(handler: unknown): void { fill(handler, 'set', (originalSet: UseCacheHandler['set']) => { return function (this: UseCacheHandler, cacheKey: string, pendingEntry: Promise): Promise { + const digest = keyDigest(cacheKey); + + // A successful write replaces the entry, so a remembered origin from a previous fill is now wrong. + // An unsampled fill has no span to link to -> remember nothing instead. if (!shouldRecordCacheSpan()) { - return originalSet.call(this, cacheKey, pendingEntry); + return Promise.resolve(originalSet.call(this, cacheKey, pendingEntry)).then(result => { + getCacheOrigins().remove(originKeyPrefix + digest); + return result; + }); } - const digest = keyDigest(cacheKey); + const sourceFile = getCacheFunctionSourceFile(cacheKey); + // 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, digest, span => + return startCacheSpan(CACHE_PUT, digest, span => { + if (sourceFile) { + span.setAttribute(CODE_FILE_PATH, sourceFile); + } // Only a successful write becomes a fill origin: a failed write leaves no entry or the // previous one (whose origin still stands). A dropped span (`ignoreSpans`) never // reaches Sentry, so a link to it would be broken. - Promise.resolve(originalSet.call(this, cacheKey, pendingEntry)).then(result => { + return Promise.resolve(originalSet.call(this, cacheKey, pendingEntry)).then(result => { if (span.isRecording()) { rememberCacheOrigin(originKeyPrefix + digest, span, pendingEntry); } return result; - }), - ); + }); + }); }; }); } catch (error) { diff --git a/packages/nextjs/src/server/useCacheSourceFile.ts b/packages/nextjs/src/server/useCacheSourceFile.ts new file mode 100644 index 000000000000..82a8bd1ac12d --- /dev/null +++ b/packages/nextjs/src/server/useCacheSourceFile.ts @@ -0,0 +1,134 @@ +// Next.js' runtime manifest registry +// https://github.com/vercel/next.js/blob/8e0700c74474498a07b33f58da1c1316f740eb19/packages/next/src/server/app-render/manifests-singleton.ts#L56-L59 +const NEXT_MANIFESTS_SINGLETON = Symbol.for('next.server.manifests'); + +type ManifestEntries = Record; + +type GlobalWithManifests = typeof globalThis & { + [NEXT_MANIFESTS_SINGLETON]?: { + serverActionsManifest?: { + node?: ManifestEntries; + edge?: ManifestEntries; + }; + }; +}; + +interface MultipartField { + content: string; + /** Index of the first character after this field. */ + end: number; +} + +/** + * Reads one field of a multipart cache key, starting at `start`. + * A field is `:`, with the length in lowercase hex counting UTF-16 code units. + * https://github.com/vercel/next.js/blob/8e0700c74474498a07b33f58da1c1316f740eb19/packages/next/src/server/use-cache/use-cache-wrapper.ts#L1815-L1847 + * + * Returns `undefined` when the framing is broken. + */ +function readMultipartField(cacheKey: string, start: number): MultipartField | undefined { + // The characters before the next `:` must be a non-empty hex length. + const colon = cacheKey.indexOf(':', start); + if (colon <= start || !/^[0-9a-f]+$/.test(cacheKey.slice(start, colon))) { + return undefined; + } + + // A length that points past the end of the key means the key is truncated. + const contentStart = colon + 1; + const contentEnd = contentStart + parseInt(cacheKey.slice(start, colon), 16); + if (contentEnd > cacheKey.length) { + return undefined; + } + + return { content: cacheKey.slice(contentStart, contentEnd), end: contentEnd }; +} + +/** + * Cache keys with arguments that do not serialize to JSON (a page's `params` promise, a layout's + * `children`) are serialized FormData: pairs of length-prefixed fields, a field name followed by + * its content. React stores the key parts JSON in the field named `"0"`, which is not necessarily + * the first field. Returns that JSON text, or `undefined` for a malformed key. + */ +function readKeyPartsFromMultipartKey(cacheKey: string): string | undefined { + let position = 0; + while (position < cacheKey.length) { + // Each pair is the field name, then the field content. + const name = readMultipartField(cacheKey, position); + const content = name && readMultipartField(cacheKey, name.end); + if (name === undefined || content === undefined) { + return undefined; + } + + if (name.content === '0') { + return content.content; + } + position = content.end; + } + return undefined; +} + +/** + * Picks the function id out of the decoded key parts. Next.js 16.3 puts the id at index 1 + * (`[buildId, id, args]` in prod; dev appends a fourth part). 16.4 canary moves it to index 0 + * (`[id, args, …]`). Returns `undefined` for any other shape. + */ +function readFunctionIdFromKeyParts(keyParts: unknown): string | undefined { + if (!Array.isArray(keyParts)) { + return undefined; + } + // An args array at index 1 marks the canary shape, where the id sits at index 0. + const functionId = Array.isArray(keyParts[1]) ? keyParts[0] : keyParts[1]; + return typeof functionId === 'string' ? functionId : undefined; +} + +/** + * Manifest filenames start at the repo root, but only the path inside the project is useful. + * The repo-root-to-project part equals the tail of `process.cwd()`, because `next dev`, + * `next start`, and the standalone server all run in the project directory (the standalone + * `server.js` chdirs into its mirrored copy). Unknown layouts keep the full path. + */ +function toProjectRelativePath(filename: string): string { + // Split the cwd on both separators so Windows paths work. The manifest always uses `/`. + const cwdSegments = process.cwd().split(/[\\/]/).filter(Boolean); + const fileSegments = filename.split('/'); + + // Drop the longest filename prefix that matches the cwd tail. Longest first, so the whole + // repo prefix goes, not just a part of it. At least one segment always remains. + const maxOverlap = Math.min(fileSegments.length - 1, cwdSegments.length); + for (let overlap = maxOverlap; overlap > 0; overlap--) { + const cwdTail = cwdSegments.slice(-overlap); + if (cwdTail.every((segment, index) => segment === fileSegments[index])) { + return fileSegments.slice(overlap).join('/'); + } + } + return filename; +} + +/** + * Resolves the source file of the `use cache` function behind a cache key, through the server-reference manifest. + * On Next.js 16.3 the manifest only covers component-tree functions, so route handlers resolve to `undefined`. + * 16.4 canary includes route handlers. + * Any unexpected key or manifest shape returns `undefined`, never a wrong file. + */ +export function getCacheFunctionSourceFile(cacheKey: string): string | undefined { + try { + // The key is `encodeReply(keyParts)`: a plain JSON array when all function arguments + // serialize to JSON, the multipart form otherwise. + const keyPartsJson = cacheKey.startsWith('[') ? cacheKey : readKeyPartsFromMultipartKey(cacheKey); + if (!keyPartsJson) { + return undefined; + } + + const functionId = readFunctionIdFromKeyParts(JSON.parse(keyPartsJson)); + if (functionId === undefined) { + return undefined; + } + + // The manifest has one section per runtime. `NEXT_RUNTIME` is only `'edge'` on edge. + const manifest = (globalThis as GlobalWithManifests)[NEXT_MANIFESTS_SINGLETON]?.serverActionsManifest; + const filename = manifest?.[process.env.NEXT_RUNTIME === 'edge' ? 'edge' : 'node']?.[functionId]?.filename; + return typeof filename === 'string' && filename ? toProjectRelativePath(filename) : undefined; + } catch { + return undefined; + } +} diff --git a/packages/nextjs/test/server/useCacheInstrumentation.test.ts b/packages/nextjs/test/server/useCacheInstrumentation.test.ts index 51037a67c005..69ac8b594edc 100644 --- a/packages/nextjs/test/server/useCacheInstrumentation.test.ts +++ b/packages/nextjs/test/server/useCacheInstrumentation.test.ts @@ -33,6 +33,7 @@ import { _instrumentUseCacheHandlers } from '../../src/server/useCacheInstrument const NEXT_CACHE_HANDLERS_MAP = Symbol.for('@next/cache-handlers-map'); const NEXT_PRIVATE_CACHE_HANDLER = Symbol.for('@next/cache-handlers-private'); +const NEXT_MANIFESTS_SINGLETON = Symbol.for('next.server.manifests'); const SENTRY_CACHE_INSTRUMENTED = Symbol.for('sentry.nextjs.cacheHandlersInstrumented'); const SENTRY_WRAPPED_HANDLERS = Symbol.for('sentry.nextjs.wrappedCacheHandlers'); const SENTRY_CACHE_ORIGINS = Symbol.for('sentry.nextjs.cacheOrigins'); @@ -64,6 +65,7 @@ describe('instrumentUseCacheHandlers', () => { beforeEach(() => { mocks.activeSpan = {}; mocks.sampled = true; + mocks.state.spanCount = 0; mocks.client = undefined; mocks.state.spanCount = 0; mocks.state.recording = true; @@ -77,6 +79,7 @@ describe('instrumentUseCacheHandlers', () => { for (const symbol of [ NEXT_CACHE_HANDLERS_MAP, NEXT_PRIVATE_CACHE_HANDLER, + NEXT_MANIFESTS_SINGLETON, SENTRY_CACHE_INSTRUMENTED, SENTRY_WRAPPED_HANDLERS, SENTRY_CACHE_ORIGINS, @@ -468,6 +471,35 @@ describe('instrumentUseCacheHandlers', () => { expect(mocks.addLink).not.toHaveBeenCalled(); }); + it('forgets a remembered origin when the entry is refilled without a sampled parent span', async () => { + const handler = installWithDefaultHandler({ timestamp: nowMs() }); + + await handler.set('cache-key', Promise.resolve({})); + + mocks.activeSpan = undefined; + await handler.set('cache-key', Promise.resolve({})); + mocks.activeSpan = {}; + + await handler.get('cache-key'); + + expect(mocks.addLink).not.toHaveBeenCalled(); + }); + + it('forgets a remembered origin when the refill `cache.put` span is not recording', async () => { + const handler = installWithDefaultHandler({ timestamp: nowMs() }); + + await handler.set('cache-key', Promise.resolve({})); + + // e.g. the `cache.put` op is filtered via `ignoreSpans` + mocks.state.recording = false; + await handler.set('cache-key', Promise.resolve({})); + mocks.state.recording = true; + + await handler.get('cache-key'); + + expect(mocks.addLink).not.toHaveBeenCalled(); + }); + it('does not remember fills whose write failed', async () => { const entry = { timestamp: nowMs() }; const handler = { @@ -484,6 +516,48 @@ describe('instrumentUseCacheHandlers', () => { }); }); + describe('source file on `cache.put`', () => { + const functionId = 'c05120808bb68f6400d039e720226869fb1f079019'; + const jsonCacheKey = JSON.stringify(['build-id', functionId, [['arg'], {}]]); + + function setManifest(filename: unknown): void { + setGlobal(NEXT_MANIFESTS_SINGLETON, { + serverActionsManifest: { node: { [functionId]: { filename } } }, + }); + } + + it('sets `code.file.path` when the key parses and the manifest knows the function', async () => { + setManifest('app/(cached-nesting)/mixed-lifetimes/[id]/layout.tsx'); + const handler = installWithDefaultHandler(); + + await handler.set(jsonCacheKey, Promise.resolve({})); + + expect(mocks.setAttribute).toHaveBeenCalledWith( + 'code.file.path', + 'app/(cached-nesting)/mixed-lifetimes/[id]/layout.tsx', + ); + }); + + it('does not set `code.file.path` on `cache.get` spans', async () => { + setManifest('app/page.tsx'); + const handler = installWithDefaultHandler({ timestamp: nowMs() }); + + await handler.get(jsonCacheKey); + + expect(mocks.setAttribute).not.toHaveBeenCalledWith('code.file.path', expect.anything()); + }); + + // Key parsing, manifest lookup, and path shortening are covered in `useCacheSourceFile.test.ts`. + it('still writes the entry and omits `code.file.path` when the key does not resolve', async () => { + setManifest('app/page.tsx'); + const handler = installWithDefaultHandler(); + + await expect(handler.set('multipart-encoded-key', Promise.resolve({}))).resolves.toBeUndefined(); + + expect(mocks.setAttribute).not.toHaveBeenCalledWith('code.file.path', expect.anything()); + }); + }); + it('creates a `cache.put` span around handler writes', async () => { const handler = installWithDefaultHandler(); diff --git a/packages/nextjs/test/server/useCacheSourceFile.test.ts b/packages/nextjs/test/server/useCacheSourceFile.test.ts new file mode 100644 index 000000000000..16082bcf21b2 --- /dev/null +++ b/packages/nextjs/test/server/useCacheSourceFile.test.ts @@ -0,0 +1,236 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { getCacheFunctionSourceFile } from '../../src/server/useCacheSourceFile'; + +const NEXT_MANIFESTS_SINGLETON = Symbol.for('next.server.manifests'); + +const FUNCTION_ID = 'c05120808bb68f6400d039e720226869fb1f079019'; +const LAYOUT_FILE = 'app/(cached-nesting)/mixed-lifetimes/[id]/layout.tsx'; + +const stableKey = JSON.stringify(['build-id', FUNCTION_ID, [['arg'], { locale: 'en' }]]); +const canaryKey = JSON.stringify([FUNCTION_ID, [['arg'], { locale: 'en' }], 'implementation-hash']); +const rootPayload = JSON.stringify([ + 'build-id', + FUNCTION_ID, + [[], { children: { then: '$undefined' }, params: '$@1' }], +]); + +function setManifest(filename: unknown, runtime: 'node' | 'edge' = 'node', functionId: string = FUNCTION_ID): void { + (globalThis as Record)[NEXT_MANIFESTS_SINGLETON] = { + serverActionsManifest: { [runtime]: { [functionId]: { filename } } }, + }; +} + +// Mirrors `encodeFormData` in Next.js' `use-cache-wrapper.ts`: each field is +// `:`, lengths counting UTF-16 code units. +function multipartField(name: string, content: string): string { + return `${name.length.toString(16)}:${name}${content.length.toString(16)}:${content}`; +} + +describe('getCacheFunctionSourceFile', () => { + beforeEach(() => { + vi.spyOn(process, 'cwd').mockReturnValue('/repo/apps/web'); + }); + + afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllEnvs(); + Reflect.deleteProperty(globalThis, NEXT_MANIFESTS_SINGLETON); + }); + + describe('JSON cache keys', () => { + it.each([ + ['the stable key shape `[buildId, functionId, args]`', stableKey], + ['the canary key shape `[functionId, args, implementationHash]`', canaryKey], + ])('resolves the source file for %s', (_label, cacheKey) => { + setManifest(LAYOUT_FILE); + + expect(getCacheFunctionSourceFile(cacheKey)).toBe(LAYOUT_FILE); + }); + + it.each([ + ['an empty array', '[]'], + ['a one-element array', JSON.stringify(['build-id'])], + ['a stable-shaped key whose function id is not a string', JSON.stringify(['build-id', 42, [['arg'], {}]])], + ['a canary-shaped key whose function id is not a string', JSON.stringify([42, [['arg'], {}], 'hash'])], + ['invalid JSON that starts like an array', '[truncated'], + ])('resolves no file for %s', (_label, cacheKey) => { + setManifest(LAYOUT_FILE); + + expect(getCacheFunctionSourceFile(cacheKey)).toBeUndefined(); + }); + }); + + describe('multipart cache keys', () => { + it.each([ + ['the key parts in the first field', multipartField('0', rootPayload) + multipartField('1', '{"id":"some-id"}')], + ['the key parts in a later field', multipartField('1', 'binary-ish :: data') + multipartField('0', rootPayload)], + [ + 'a preceding field whose content looks like framing', + multipartField('1', '3:abc') + multipartField('0', rootPayload), + ], + ['a preceding field with empty content', multipartField('1', '') + multipartField('0', rootPayload)], + [ + 'a preceding field with non-ASCII content counted in UTF-16 code units', + multipartField('1', 'héllo 😀 wörld') + multipartField('0', rootPayload), + ], + ])('resolves the source file for a key with %s', (_label, cacheKey) => { + setManifest(LAYOUT_FILE); + + expect(getCacheFunctionSourceFile(cacheKey)).toBe(LAYOUT_FILE); + }); + + it.each([ + ['an empty key', ''], + ['a field length that is not hex', `x:0${rootPayload}`], + ['an uppercase field length (Next.js emits lowercase)', `A:aaaaaaaaaa${multipartField('0', rootPayload)}`], + ['a field length pointing past the end of the key', `1:0ffff:${rootPayload}`], + ['a key truncated inside a field', multipartField('0', rootPayload).slice(0, 10)], + ['a field name without a content field', '1:0'], + ['no field named "0"', multipartField('1', '{"id":"some-id"}')], + ['an empty field named "0"', multipartField('0', '') + multipartField('1', '{"id":"some-id"}')], + ['key parts that are a JSON object instead of an array', multipartField('0', '{"not":"an array"}')], + ])('resolves no file for a key with %s', (_label, cacheKey) => { + setManifest(LAYOUT_FILE); + + expect(getCacheFunctionSourceFile(cacheKey)).toBeUndefined(); + }); + }); + + describe('manifest lookup', () => { + it('resolves no file when the manifest singleton is missing', () => { + expect(getCacheFunctionSourceFile(stableKey)).toBeUndefined(); + }); + + it.each([ + [ + 'the manifest does not know the function id', + (): void => setManifest(LAYOUT_FILE), + JSON.stringify(['build-id', 'other-id', []]), + ], + ['the manifest entry has no filename', (): void => setManifest(undefined), stableKey], + ['the manifest filename is not a string', (): void => setManifest(42), stableKey], + ['the manifest filename is empty', (): void => setManifest(''), stableKey], + ])('resolves no file when %s', (_label, prepare, cacheKey) => { + prepare(); + + expect(getCacheFunctionSourceFile(cacheKey)).toBeUndefined(); + }); + + it('reads the edge section of the manifest on the edge runtime', () => { + vi.stubEnv('NEXT_RUNTIME', 'edge'); + setManifest(LAYOUT_FILE, 'edge'); + + expect(getCacheFunctionSourceFile(stableKey)).toBe(LAYOUT_FILE); + }); + + it('ignores the node section of the manifest on the edge runtime', () => { + vi.stubEnv('NEXT_RUNTIME', 'edge'); + setManifest(LAYOUT_FILE, 'node'); + + expect(getCacheFunctionSourceFile(stableKey)).toBeUndefined(); + }); + + it('reads the node section of the manifest when `NEXT_RUNTIME` is `nodejs`', () => { + vi.stubEnv('NEXT_RUNTIME', 'nodejs'); + setManifest(LAYOUT_FILE, 'node'); + + expect(getCacheFunctionSourceFile(stableKey)).toBe(LAYOUT_FILE); + }); + }); + + describe('project-relative paths', () => { + it.each([ + [ + 'strips a monorepo prefix that matches the cwd tail', + '/repo/dev-packages/e2e-tests/test-applications/my-app', + 'dev-packages/e2e-tests/test-applications/my-app/app/(group)/page.tsx', + 'app/(group)/page.tsx', + ], + ['keeps a `src/app` layout intact', '/repo/apps/web', 'apps/web/src/app/page.tsx', 'src/app/page.tsx'], + [ + 'is not confused by an `app` directory inside the prefix', + '/repo/packages/app', + 'packages/app/app/page.tsx', + 'app/page.tsx', + ], + ['strips the longest match when the cwd tail repeats', '/repo/a/b/a/b', 'a/b/a/b/page.tsx', 'page.tsx'], + ['splits a Windows cwd on backslashes', 'C:\\repo\\apps\\web', 'apps/web/app/page.tsx', 'app/page.tsx'], + ['ignores a trailing separator on the cwd', '/repo/apps/web/', 'apps/web/app/page.tsx', 'app/page.tsx'], + [ + 'keeps a file outside the app directory', + '/repo/apps/web', + 'apps/web/lib/cached-queries.ts', + 'lib/cached-queries.ts', + ], + [ + 'keeps the full path when the cwd does not match', + '/somewhere/else', + 'dev-packages/my-app/app/page.tsx', + 'dev-packages/my-app/app/page.tsx', + ], + ['never strips the whole path', '/repo/my-app', 'my-app', 'my-app'], + ])('%s', (_label, cwd, filename, expected) => { + vi.spyOn(process, 'cwd').mockReturnValue(cwd); + setManifest(filename); + + expect(getCacheFunctionSourceFile(stableKey)).toBe(expected); + }); + }); + + // Keys and manifest filenames captured verbatim from the `nextjs-16-cacheComponents` e2e app + // (Next 16.3 dev + prod, 16.4.0-canary.57 prod) on 2026-10-02; only the machine-specific part + // of the cwd is generalized. See `_proj-cache-components/research/cache-key-capture-howto.md`. + describe('real-world keys', () => { + const APP_PREFIX = 'dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents'; + + it.each([ + [ + 'a dev JSON key with four key parts', + '["development","80c108a36ae1b809cefcec4ecb7ede5df718f8b93a",[],"0"]', + '80c108a36ae1b809cefcec4ecb7ede5df718f8b93a', + 'app/cache/page.tsx', + ], + [ + 'a dev multipart layout key', + '1:063:["development","c0c36245c7b284678b232881c45d061cc5ce45f174",[{"params":"$@1","children":"$T"}],"0"]1:1c:{"id":"123"}', + 'c0c36245c7b284678b232881c45d061cc5ce45f174', + 'app/(cached-nesting)/mixed-lifetimes/[id]/layout.tsx', + ], + [ + 'a prod JSON key (stable shape)', + '["8SLlD3QlHgVCUdTRW5hzL","c02767c889ce99f14e39a7637aae5cfabf6a939d9a",["default-id"]]', + 'c02767c889ce99f14e39a7637aae5cfabf6a939d9a', + 'app/use-cache-page/page.tsx', + ], + [ + 'a prod multipart layout key (stable shape)', + '1:069:["8SLlD3QlHgVCUdTRW5hzL","c0a941ad6a1202c965f53812b0e13854f898cba7c9",[{"params":"$@1","children":"$T"}]]1:1c:{"id":"123"}', + 'c0a941ad6a1202c965f53812b0e13854f898cba7c9', + 'app/(cached-nesting)/mixed-lifetimes/[id]/layout.tsx', + ], + [ + 'a canary JSON key, third part an array rather than a hash string', + '["c02767c889ce99f14e39a7637aae5cfabf6a939d9a",["default-id"],["eWyFndmUdIGUjY7hj9qJJ"]]', + 'c02767c889ce99f14e39a7637aae5cfabf6a939d9a', + 'app/use-cache-page/page.tsx', + ], + [ + 'a canary multipart layout key', + '1:06b:["c0a941ad6a1202c965f53812b0e13854f898cba7c9",[{"params":"$@1","children":"$T"}],["eWyFndmUdIGUjY7hj9qJJ"]]1:1c:{"id":"123"}', + 'c0a941ad6a1202c965f53812b0e13854f898cba7c9', + 'app/(cached-nesting)/mixed-lifetimes/[id]/layout.tsx', + ], + [ + 'a canary route-handler key (route handlers have manifest entries on 16.4+)', + '["c06943f936da1e0a20710aae915fb68dbaa3527c74",["default-id"],["eWyFndmUdIGUjY7hj9qJJ"]]', + 'c06943f936da1e0a20710aae915fb68dbaa3527c74', + 'app/api/use-cache/route.ts', + ], + ])('resolves %s', (_label, cacheKey, functionId, appRelativePath) => { + vi.spyOn(process, 'cwd').mockReturnValue(`/repo/${APP_PREFIX}`); + setManifest(`${APP_PREFIX}/${appRelativePath}`, 'node', functionId); + + expect(getCacheFunctionSourceFile(cacheKey)).toBe(appRelativePath); + }); + }); +}); From b2703e70bd23ea050f4150e7429040661218a033 Mon Sep 17 00:00:00 2001 From: s1gr1d <32902192+s1gr1d@users.noreply.github.com> Date: Fri, 2 Oct 2026 15:32:36 +0200 Subject: [PATCH 2/4] review suggestions --- .../nextjs-16-cacheComponents/package.json | 2 +- .../tests/cacheOriginLinks-nesting.spec.ts | 8 ++++- .../tests/useCacheSpans.spec.ts | 10 +++++-- .../package.json | 2 +- .../tests/cacheOriginLinks-nesting.spec.ts | 18 +++++++---- .../tests/useCacheSpans.spec.ts | 10 +++++-- .../src/server/useCacheInstrumentation.ts | 10 ++----- .../server/useCacheInstrumentation.test.ts | 30 ------------------- 8 files changed, 39 insertions(+), 51 deletions(-) diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/package.json b/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/package.json index 899d3d4e6c87..309cb063a0a5 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/package.json +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/package.json @@ -20,7 +20,7 @@ "test:build-latest-webpack": "pnpm install && pnpm add next@latest && pnpm build-webpack", "test:build-canary-webpack": "pnpm install && pnpm add next@canary && pnpm build-webpack", "test:assert": "pnpm test:prod && pnpm test:dev", - "test:assert-webpack": "pnpm test:prod && pnpm test:dev-webpack" + "test:assert-webpack": "TEST_BUNDLER=webpack pnpm test:prod && pnpm test:dev-webpack" }, "dependencies": { "@sentry/nextjs": "file:../../packed/sentry-nextjs-packed.tgz", diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/tests/cacheOriginLinks-nesting.spec.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/tests/cacheOriginLinks-nesting.spec.ts index ad7b3670c42a..66c1fdcfa8ee 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/tests/cacheOriginLinks-nesting.spec.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/tests/cacheOriginLinks-nesting.spec.ts @@ -1,6 +1,10 @@ import { expect, test } from '@playwright/test'; import { waitForTransaction } from '@sentry-internal/test-utils'; +// Webpack builds emit no `code.file.path`: the server-reference manifest lookup comes up empty +// there. `TEST_BUNDLER` marks the webpack variant's production run (set in `test:assert-webpack`). +const isWebpackBuild = process.env.TEST_BUNDLER === 'webpack' || process.env.TEST_ENV === 'development-webpack'; + // Origin links (`sentry.link.type: 'cache_origin'` on `cache.get` hit spans, pointing at the // filling `cache.put`) for `use cache` in nested layout trees under `app/(cached-nesting)/`. @@ -35,7 +39,9 @@ test('links a cached layout hit to the trace that filled the layout entry', asyn const putSpans = (missTx.spans ?? []).filter(span => span.op === 'cache.put'); expect(new Set(putSpans.map(span => span.description)).size).toBe(1); - expect(putSpans[0]!.data?.['code.file.path']).toBe('app/(cached-nesting)/cached-mid-layout/[id]/layout.tsx'); + if (!isWebpackBuild) { + expect(putSpans[0]!.data?.['code.file.path']).toBe('app/(cached-nesting)/cached-mid-layout/[id]/layout.tsx'); + } const hitGetSpan = hitTx.spans?.find(span => span.op === 'cache.get' && span.data?.['cache.hit'] === true); expect(hitGetSpan).toBeDefined(); 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 5ba09c2782ee..656b3a0b29da 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,6 +1,10 @@ import { expect, test } from '@playwright/test'; import { waitForTransaction } from '@sentry-internal/test-utils'; +// Webpack builds emit no `code.file.path`: the server-reference manifest lookup comes up empty +// there. `TEST_BUNDLER` marks the webpack variant's production run (set in `test:assert-webpack`). +const isWebpackBuild = process.env.TEST_BUNDLER === 'webpack' || process.env.TEST_ENV === 'development-webpack'; + 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. @@ -103,8 +107,10 @@ test('Should create cache spans for `use cache` inside a rendered page', async ( // The source file on a fill span marks which cached function produced the entry. const missPutSpans = missTx.spans?.filter(span => span.op === 'cache.put') ?? []; expect(missPutSpans.length).toBeGreaterThan(0); - for (const putSpan of missPutSpans) { - expect(putSpan.data?.['code.file.path']).toBe('app/use-cache-page/page.tsx'); + if (!isWebpackBuild) { + for (const putSpan of missPutSpans) { + expect(putSpan.data?.['code.file.path']).toBe('app/use-cache-page/page.tsx'); + } } // A render can read more than one cache entry, so look at every hit instead of the first `cache.get`. diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/package.json b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/package.json index 08d3348c689f..d6b1d3873afd 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/package.json +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/package.json @@ -20,7 +20,7 @@ "test:build-latest-webpack": "pnpm install && pnpm add next@latest && pnpm build-webpack", "test:build-canary-webpack": "pnpm install && pnpm add next@canary && pnpm build-webpack", "test:assert": "pnpm test:prod && pnpm test:dev", - "test:assert-webpack": "pnpm test:prod && pnpm test:dev-webpack" + "test:assert-webpack": "TEST_BUNDLER=webpack pnpm test:prod && pnpm test:dev-webpack" }, "dependencies": { "@sentry/nextjs": "file:../../packed/sentry-nextjs-packed.tgz", diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/tests/cacheOriginLinks-nesting.spec.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/tests/cacheOriginLinks-nesting.spec.ts index 828e867ae3d7..77fcdb60b65a 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/tests/cacheOriginLinks-nesting.spec.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/tests/cacheOriginLinks-nesting.spec.ts @@ -2,6 +2,10 @@ import { expect, test } from '@playwright/test'; import { collectStreamedSpans, getSpanOp } from '@sentry-internal/test-utils'; import { CACHE_ORIGIN_LINK_ATTRIBUTES, findCacheSpan } from './cacheOriginLinks-utils'; +// Webpack builds emit no `code.file.path`: the server-reference manifest lookup comes up empty +// there. `TEST_BUNDLER` marks the webpack variant's production run (set in `test:assert-webpack`). +const isWebpackBuild = process.env.TEST_BUNDLER === 'webpack' || process.env.TEST_ENV === 'development-webpack'; + // Origin links (`sentry.link.type: 'cache_origin'` on `cache.get` hit spans, pointing at the // filling `cache.put`) for `use cache` in nested layout trees under `app/(cached-nesting)/`. @@ -117,12 +121,14 @@ test('links two cached levels to different origin traces after the layout expire expect(new Set(fillPutSpans.map(span => JSON.stringify(span.attributes['cache.key']?.value))).size).toBe(2); // One fill per file: the layout (multipart key) and the cached component (JSON key). - expect(new Set(fillPutSpans.map(span => span.attributes['code.file.path']?.value))).toEqual( - new Set([ - 'app/(cached-nesting)/mixed-lifetimes/[id]/layout.tsx', - 'app/(cached-nesting)/mixed-lifetimes/[id]/page.tsx', - ]), - ); + if (!isWebpackBuild) { + expect(new Set(fillPutSpans.map(span => span.attributes['code.file.path']?.value))).toEqual( + new Set([ + 'app/(cached-nesting)/mixed-lifetimes/[id]/layout.tsx', + 'app/(cached-nesting)/mixed-lifetimes/[id]/page.tsx', + ]), + ); + } // Sleep past the layout's `expire` (2s); the component entry stays valid for hours. await new Promise(resolve => setTimeout(resolve, 3_000)); diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/tests/useCacheSpans.spec.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/tests/useCacheSpans.spec.ts index 472d8e975e47..d183411297f7 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/tests/useCacheSpans.spec.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/tests/useCacheSpans.spec.ts @@ -2,6 +2,10 @@ import { expect, test } from '@playwright/test'; import { collectStreamedSpans, getSpanOp } from '@sentry-internal/test-utils'; import { findCacheSpan } from './cacheOriginLinks-utils'; +// Webpack builds emit no `code.file.path`: the server-reference manifest lookup comes up empty +// there. `TEST_BUNDLER` marks the webpack variant's production run (set in `test:assert-webpack`). +const isWebpackBuild = process.env.TEST_BUNDLER === 'webpack' || process.env.TEST_ENV === 'development-webpack'; + test('uses low-cardinality names for `use cache` spans', async ({ request }) => { // A fresh id makes the request a guaranteed cache miss (the id is part of the cache key), so the // trace contains both a `cache.get` and a `cache.put` span. @@ -51,8 +55,10 @@ test('sets the source file of the cached component on `cache.put` spans', async // Both sibling entries come from the same page file. const putSpans = spans.filter(span => getSpanOp(span) === 'cache.put'); expect(putSpans.length).toBeGreaterThanOrEqual(2); - for (const putSpan of putSpans) { - expect(putSpan.attributes['code.file.path']?.value).toBe('app/cached-sibling-components/page.tsx'); + if (!isWebpackBuild) { + for (const putSpan of putSpans) { + expect(putSpan.attributes['code.file.path']?.value).toBe('app/cached-sibling-components/page.tsx'); + } } // The source file marks the producer of an entry. Reads do not carry it. diff --git a/packages/nextjs/src/server/useCacheInstrumentation.ts b/packages/nextjs/src/server/useCacheInstrumentation.ts index c6449cba85f4..11fd268d095a 100644 --- a/packages/nextjs/src/server/useCacheInstrumentation.ts +++ b/packages/nextjs/src/server/useCacheInstrumentation.ts @@ -270,17 +270,11 @@ function instrumentHandler(handler: unknown): void { fill(handler, 'set', (originalSet: UseCacheHandler['set']) => { return function (this: UseCacheHandler, cacheKey: string, pendingEntry: Promise): Promise { - const digest = keyDigest(cacheKey); - - // A successful write replaces the entry, so a remembered origin from a previous fill is now wrong. - // An unsampled fill has no span to link to -> remember nothing instead. if (!shouldRecordCacheSpan()) { - return Promise.resolve(originalSet.call(this, cacheKey, pendingEntry)).then(result => { - getCacheOrigins().remove(originKeyPrefix + digest); - return result; - }); + return originalSet.call(this, cacheKey, pendingEntry); } + const digest = keyDigest(cacheKey); const sourceFile = getCacheFunctionSourceFile(cacheKey); // The handler drains `pendingEntry` (the still-streaming entry) before storing, so this diff --git a/packages/nextjs/test/server/useCacheInstrumentation.test.ts b/packages/nextjs/test/server/useCacheInstrumentation.test.ts index 69ac8b594edc..aa016c9f6027 100644 --- a/packages/nextjs/test/server/useCacheInstrumentation.test.ts +++ b/packages/nextjs/test/server/useCacheInstrumentation.test.ts @@ -65,7 +65,6 @@ describe('instrumentUseCacheHandlers', () => { beforeEach(() => { mocks.activeSpan = {}; mocks.sampled = true; - mocks.state.spanCount = 0; mocks.client = undefined; mocks.state.spanCount = 0; mocks.state.recording = true; @@ -471,35 +470,6 @@ describe('instrumentUseCacheHandlers', () => { expect(mocks.addLink).not.toHaveBeenCalled(); }); - it('forgets a remembered origin when the entry is refilled without a sampled parent span', async () => { - const handler = installWithDefaultHandler({ timestamp: nowMs() }); - - await handler.set('cache-key', Promise.resolve({})); - - mocks.activeSpan = undefined; - await handler.set('cache-key', Promise.resolve({})); - mocks.activeSpan = {}; - - await handler.get('cache-key'); - - expect(mocks.addLink).not.toHaveBeenCalled(); - }); - - it('forgets a remembered origin when the refill `cache.put` span is not recording', async () => { - const handler = installWithDefaultHandler({ timestamp: nowMs() }); - - await handler.set('cache-key', Promise.resolve({})); - - // e.g. the `cache.put` op is filtered via `ignoreSpans` - mocks.state.recording = false; - await handler.set('cache-key', Promise.resolve({})); - mocks.state.recording = true; - - await handler.get('cache-key'); - - expect(mocks.addLink).not.toHaveBeenCalled(); - }); - it('does not remember fills whose write failed', async () => { const entry = { timestamp: nowMs() }; const handler = { From 40141bf58da41bba519ad2d0a782ef39866e75de Mon Sep 17 00:00:00 2001 From: s1gr1d <32902192+s1gr1d@users.noreply.github.com> Date: Fri, 2 Oct 2026 15:52:02 +0200 Subject: [PATCH 3/4] review suggestions --- .../tests/cacheOriginLinks-nesting.spec.ts | 6 ++++- .../src/server/useCacheInstrumentation.ts | 21 +++++++++------- .../server/useCacheInstrumentation.test.ts | 24 +++++++++++++++---- 3 files changed, 36 insertions(+), 15 deletions(-) diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/tests/cacheOriginLinks-nesting.spec.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/tests/cacheOriginLinks-nesting.spec.ts index 77fcdb60b65a..ec2ff9eb555a 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/tests/cacheOriginLinks-nesting.spec.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/tests/cacheOriginLinks-nesting.spec.ts @@ -40,7 +40,11 @@ test('links a cached layout hit to the trace that filled the layout entry', asyn const putSpan = findCacheSpan(missSpans, 'cache.put'); expect(putSpan).toBeDefined(); - expect(putSpan!.attributes['code.file.path']?.value).toBe('app/(cached-nesting)/cached-mid-layout/[id]/layout.tsx'); + if (!isWebpackBuild) { + expect(putSpan!.attributes['code.file.path']?.value).toBe( + 'app/(cached-nesting)/cached-mid-layout/[id]/layout.tsx', + ); + } const hitGetSpan = findCacheSpan(hitSpans, 'cache.get', true); expect(hitGetSpan).toBeDefined(); diff --git a/packages/nextjs/src/server/useCacheInstrumentation.ts b/packages/nextjs/src/server/useCacheInstrumentation.ts index 11fd268d095a..e51e5e148e05 100644 --- a/packages/nextjs/src/server/useCacheInstrumentation.ts +++ b/packages/nextjs/src/server/useCacheInstrumentation.ts @@ -104,7 +104,12 @@ function shouldRecordCacheSpan(): boolean { return !!activeSpan && spanIsSampled(activeSpan); } -function startCacheSpan(op: typeof CACHE_GET | typeof CACHE_PUT, digest: string, callback: (span: Span) => T): T { +function startCacheSpan( + op: typeof CACHE_GET | typeof CACHE_PUT, + digest: string, + extraAttributes: Record, + callback: (span: Span) => T, +): T { const client = getClient(); return startSpan( @@ -116,6 +121,7 @@ function startCacheSpan(op: typeof CACHE_GET | typeof CACHE_PUT, digest: stri [SENTRY_ORIGIN]: CACHE_SPAN_ORIGIN, [CACHE_KEY]: [digest], [CACHE_OPERATION]: CACHE_OPERATION_NAMES[op], + ...extraAttributes, }, }, callback, @@ -252,7 +258,7 @@ function instrumentHandler(handler: unknown): void { return originalGet.call(this, cacheKey, softTags); } const digest = keyDigest(cacheKey); - return startCacheSpan(CACHE_GET, digest, span => + return startCacheSpan(CACHE_GET, digest, {}, span => // `Promise.resolve` because custom handlers may return the entry synchronously. Promise.resolve(originalGet.call(this, cacheKey, softTags)).then(entry => { try { @@ -279,20 +285,17 @@ function instrumentHandler(handler: unknown): void { // 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, digest, span => { - if (sourceFile) { - span.setAttribute(CODE_FILE_PATH, sourceFile); - } + return startCacheSpan(CACHE_PUT, digest, sourceFile ? { [CODE_FILE_PATH]: sourceFile } : {}, span => // Only a successful write becomes a fill origin: a failed write leaves no entry or the // previous one (whose origin still stands). A dropped span (`ignoreSpans`) never // reaches Sentry, so a link to it would be broken. - return Promise.resolve(originalSet.call(this, cacheKey, pendingEntry)).then(result => { + Promise.resolve(originalSet.call(this, cacheKey, pendingEntry)).then(result => { if (span.isRecording()) { rememberCacheOrigin(originKeyPrefix + digest, span, pendingEntry); } return result; - }); - }); + }), + ); }; }); } catch (error) { diff --git a/packages/nextjs/test/server/useCacheInstrumentation.test.ts b/packages/nextjs/test/server/useCacheInstrumentation.test.ts index aa016c9f6027..587b57bf9820 100644 --- a/packages/nextjs/test/server/useCacheInstrumentation.test.ts +++ b/packages/nextjs/test/server/useCacheInstrumentation.test.ts @@ -496,24 +496,34 @@ describe('instrumentUseCacheHandlers', () => { }); } - it('sets `code.file.path` when the key parses and the manifest knows the function', async () => { + it('starts the `cache.put` span with `code.file.path` when the key parses and the manifest knows the function', async () => { setManifest('app/(cached-nesting)/mixed-lifetimes/[id]/layout.tsx'); const handler = installWithDefaultHandler(); await handler.set(jsonCacheKey, Promise.resolve({})); - expect(mocks.setAttribute).toHaveBeenCalledWith( - 'code.file.path', - 'app/(cached-nesting)/mixed-lifetimes/[id]/layout.tsx', + // In the start options (not set afterwards), so samplers and span processors see it. + expect(mocks.startSpan).toHaveBeenCalledWith( + expect.objectContaining({ + op: 'cache.put', + attributes: expect.objectContaining({ + 'code.file.path': 'app/(cached-nesting)/mixed-lifetimes/[id]/layout.tsx', + }), + }), + expect.any(Function), ); }); - it('does not set `code.file.path` on `cache.get` spans', async () => { + it('does not put `code.file.path` on `cache.get` spans', async () => { setManifest('app/page.tsx'); const handler = installWithDefaultHandler({ timestamp: nowMs() }); await handler.get(jsonCacheKey); + expect(mocks.startSpan).not.toHaveBeenCalledWith( + expect.objectContaining({ attributes: expect.objectContaining({ 'code.file.path': expect.anything() }) }), + expect.any(Function), + ); expect(mocks.setAttribute).not.toHaveBeenCalledWith('code.file.path', expect.anything()); }); @@ -524,6 +534,10 @@ describe('instrumentUseCacheHandlers', () => { await expect(handler.set('multipart-encoded-key', Promise.resolve({}))).resolves.toBeUndefined(); + expect(mocks.startSpan).not.toHaveBeenCalledWith( + expect.objectContaining({ attributes: expect.objectContaining({ 'code.file.path': expect.anything() }) }), + expect.any(Function), + ); expect(mocks.setAttribute).not.toHaveBeenCalledWith('code.file.path', expect.anything()); }); }); From 8094e4cd83b30905087782c72d90487fceefb9df Mon Sep 17 00:00:00 2001 From: s1gr1d <32902192+s1gr1d@users.noreply.github.com> Date: Fri, 2 Oct 2026 15:57:34 +0200 Subject: [PATCH 4/4] format --- .../tests/cacheOriginLinks-nesting.spec.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/tests/cacheOriginLinks-nesting.spec.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/tests/cacheOriginLinks-nesting.spec.ts index ec2ff9eb555a..2c14f39a062f 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/tests/cacheOriginLinks-nesting.spec.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/tests/cacheOriginLinks-nesting.spec.ts @@ -41,9 +41,7 @@ test('links a cached layout hit to the trace that filled the layout entry', asyn expect(putSpan).toBeDefined(); if (!isWebpackBuild) { - expect(putSpan!.attributes['code.file.path']?.value).toBe( - 'app/(cached-nesting)/cached-mid-layout/[id]/layout.tsx', - ); + expect(putSpan!.attributes['code.file.path']?.value).toBe('app/(cached-nesting)/cached-mid-layout/[id]/layout.tsx'); } const hitGetSpan = findCacheSpan(hitSpans, 'cache.get', true);