Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Original file line number Diff line number Diff line change
@@ -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)/`.

Expand Down Expand Up @@ -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);
Expand Down
Original file line number Diff line number Diff line change
@@ -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.
Expand Down Expand Up @@ -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({
Expand Down Expand Up @@ -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) ?? [];
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)/`.

Expand Down Expand Up @@ -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']);
Expand Down Expand Up @@ -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));

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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();
});
16 changes: 13 additions & 3 deletions packages/nextjs/src/server/useCacheInstrumentation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
CACHE_OPERATION,
CACHE_TAGS,
CACHE_TTL,
CODE_FILE_PATH,
SENTRY_LINK_TYPE,
SENTRY_ORIGIN,
} from '@sentry/conventions/attributes';
Expand All @@ -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
Expand Down Expand Up @@ -102,7 +104,12 @@ function shouldRecordCacheSpan(): boolean {
return !!activeSpan && spanIsSampled(activeSpan);
}

function startCacheSpan<T>(op: typeof CACHE_GET | typeof CACHE_PUT, digest: string, callback: (span: Span) => T): T {
function startCacheSpan<T>(
op: typeof CACHE_GET | typeof CACHE_PUT,
digest: string,
extraAttributes: Record<string, string>,
callback: (span: Span) => T,
): T {
const client = getClient();

return startSpan(
Expand All @@ -114,6 +121,7 @@ function startCacheSpan<T>(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,
Expand Down Expand Up @@ -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 {
Expand All @@ -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.
Expand Down
134 changes: 134 additions & 0 deletions packages/nextjs/src/server/useCacheSourceFile.ts
Original file line number Diff line number Diff line change
@@ -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<string, { filename?: unknown } | undefined>;

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 `<length>:<content>`, 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;
}
}
Loading
Loading