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 9c6245a06f5c..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,6 +39,10 @@ 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); + 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(); 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..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. @@ -57,6 +61,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 +104,14 @@ 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); + 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`. 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/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 abd984c429dc..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 @@ -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)/`. @@ -36,6 +40,10 @@ test('links a cached layout hit to the trace that filled the layout entry', asyn const putSpan = findCacheSpan(missSpans, 'cache.put'); expect(putSpan).toBeDefined(); + 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(); expect(hitGetSpan!.attributes['cache.key']).toEqual(putSpan!.attributes['cache.key']); @@ -114,6 +122,16 @@ 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). + 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 2704de1a11f6..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. @@ -30,4 +34,35 @@ 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); + 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. + 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..e51e5e148e05 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 @@ -102,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( @@ -114,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, @@ -250,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 { @@ -273,9 +281,11 @@ function instrumentHandler(handler: unknown): void { } 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, 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. 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..587b57bf9820 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'); @@ -77,6 +78,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, @@ -484,6 +486,62 @@ 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('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({})); + + // 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 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()); + }); + + // 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.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()); + }); + }); + 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); + }); + }); +});