Skip to content

Commit 1ed55d2

Browse files
chargomeclaudes1gr1d
authored
feat(nextjs): Mark pageloads of cached pages (#25318)
When Next.js serves a page from its cache (an ISR page, or a `cacheComponents` shell that was prerendered at runtime), the pageload starts its own trace and has no server span. That is correct, but in Sentry it looks like missing server instrumentation. While Next.js prerenders a document, the server now writes a `sentry-cache-origin` meta tag with the `sentry-trace` value of the prerender (instead of nothing). The client never continues it: it sets `page.server_rendering.type` on the pageload (`static` when the whole page came from the cache, `partially_static` when parts of it rendered for this request) and links the prerender with a `cache_origin` link, both at span start, so `tracesSampler` and `ignoreSpans` see them. Like a `use cache` hit, the pageload links only a sampled prerender. So the fixes of #25141 still hold: a cached page does not attach its visitors to a request that was not theirs, and a `sampled=0` value in the tag cannot drop their pageloads. Pages from `next build` get no tag, because the SDK does not run during the build. The attribute and the tag are named without Next.js in them on purpose: other frameworks with prerendering (Nuxt, SvelteKit, Astro, React Router) can set the same attribute. `page.server_rendering.type` is being added to `@sentry/conventions` in getsentry/sentry-conventions#740; `cache_origin` is already a documented `sentry.link.type` value and is what the cache lifecycle view in Sentry reads. Refs JS-3886 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com> Co-authored-by: s1gr1d <32902192+s1gr1d@users.noreply.github.com>
1 parent 0041bc6 commit 1ed55d2

23 files changed

Lines changed: 676 additions & 160 deletions

‎CHANGELOG.md‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,10 @@
1010

1111
`@SentryCron` now sends the schedule and time zone from the method's `@Cron()` decorator, so Sentry creates the monitor on the first run. If you use `@SentryCron` without a `schedule`, Sentry now creates missing monitors, which are billed, and overwrites schedules and time zones set by hand. Pass `fromCronDecorator: false` to turn this off.
1212

13+
- **feat(nextjs): Mark pageloads of cached pages ([#25318](https://github.com/getsentry/sentry-javascript/pull/25318))**
14+
15+
When Next.js serves a page from its cache, for example an ISR page or a `cacheComponents` shell, the pageload now has the `page.server_rendering.type` attribute. It is `static` if the whole page came from the cache, and `partially_static` if parts of it rendered for this request. Then the pageload stays connected to this request, as before. If the request that rendered the cached page was sampled, the pageload also links to it. So in Sentry, a cached page no longer looks like missing server instrumentation.
16+
1317
Work in this release was contributed by @lazerg and @chiliec. Thank you for your contributions!
1418

1519
## 11.10.0
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import { cacheLife } from 'next/cache';
2+
import { headers } from 'next/headers';
3+
import { Suspense } from 'react';
4+
5+
// The shell goes stale after a second, so a later visit makes the server prerender it again at
6+
// runtime. An `expire` below 5 minutes would make Next.js leave this out of the shell.
7+
async function RenderedAt() {
8+
'use cache';
9+
cacheLife({ revalidate: 1, expire: 3600 });
10+
return <p id="rendered-at">{Date.now()}</p>;
11+
}
12+
13+
async function RequestContent() {
14+
await headers();
15+
return <p id="request-content">Rendered for this request</p>;
16+
}
17+
18+
export default function Page() {
19+
return (
20+
<>
21+
<RenderedAt />
22+
<Suspense fallback={<p>Loading...</p>}>
23+
<RequestContent />
24+
</Suspense>
25+
</>
26+
);
27+
}
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import { headers } from 'next/headers';
2+
import Link from 'next/link';
3+
import { Suspense } from 'react';
4+
5+
// The dynamic part, and with it the trace meta tag, streams in seconds after the shell, so a test
6+
// can navigate while the pageload still waits for the tag.
7+
async function SlowContent() {
8+
await headers();
9+
await new Promise(resolve => setTimeout(resolve, 4000));
10+
return <p id="slow-content">Slow content</p>;
11+
}
12+
13+
export default function Page() {
14+
return (
15+
<>
16+
<Link id="navigate" href="/static-shell">
17+
Static shell
18+
</Link>
19+
<Suspense fallback={<p id="slow-fallback">Loading...</p>}>
20+
<SlowContent />
21+
</Suspense>
22+
</>
23+
);
24+
}

‎dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/package.json‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@
3535
"devDependencies": {
3636
"@playwright/test": "1.64.1",
3737
"@sentry-internal/test-utils": "file:../../packed/sentry-internal-test-utils-packed.tgz",
38+
"@sentry/conventions": "0.27.0",
3839
"@types/node": "^20",
3940
"@types/react": "^19",
4041
"@types/react-dom": "^19",

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

Lines changed: 110 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,15 @@
11
import type { Page } from '@playwright/test';
22
import { expect, test } from '@playwright/test';
3+
import { SENTRY_LINK_TYPE } from '@sentry/conventions/attributes';
34
import { waitForTransaction } from '@sentry-internal/test-utils';
45

56
const APP = 'nextjs-16-cacheComponents';
67

78
// The server only writes `sentry-trace` meta tags for a render that happened for the current request,
89
// never while it prerenders a shell. So a tag in the document can always be continued; the only
910
// question is when it arrives. The resumed part of a prerendered shell arrives after the SDK started,
10-
// which the pageload instrumentation waits for.
11+
// which the pageload instrumentation waits for. A shell prerendered at runtime carries a
12+
// `sentry-cache-origin` tag instead, which marks the pageload as static and links the prerender.
1113

1214
test('connects the pageload trace to the runtime server request', async ({ page }) => {
1315
// The shell is prerendered, but the page's dynamic part (`DynamicContent` awaits `headers()`)
@@ -30,7 +32,6 @@ test('connects the pageload trace to the runtime server request', async ({ page
3032

3133
expect(serverTx.contexts?.trace?.trace_id).toBeTruthy();
3234
expect(pageloadTx.contexts?.trace?.trace_id).toBe(serverTx.contexts?.trace?.trace_id);
33-
expect(pageloadTx.contexts?.trace?.links).toBeUndefined();
3435
});
3536

3637
test('connects the pageload trace to a blocking render', async ({ page }) => {
@@ -58,10 +59,11 @@ test('does not share a trace between visitors of a shell generated at request ti
5859
test.skip(process.env.TEST_ENV !== 'production', 'Shells are only cached in production builds');
5960

6061
// `[id]` only lists `test` in `generateStaticParams`, so the first visit prerenders and caches the
61-
// shell for this id. The prerender writes no trace tag, so every visitor starts a fresh trace.
62+
// shell for this id. The prerender writes no trace tag, only a `sentry-cache-origin` tag with its own
63+
// trace, so every visitor starts a fresh trace that links the prerender and is marked as static.
6264
const id = `shell-${Date.now()}`;
6365

64-
const visit = async (): Promise<{ traceId: string | undefined; links: unknown }> => {
66+
const visit = async (): Promise<{ traceId: string | undefined; links: unknown; serverRenderingType: unknown }> => {
6567
const context = await browser.newContext();
6668
const page: Page = await context.newPage();
6769

@@ -75,11 +77,16 @@ test('does not share a trace between visitors of a shell generated at request ti
7577
await page.goto(`/${id}`);
7678
await expect(page.locator('#result')).toHaveText(`Hello, ${id}!`);
7779
expect(await page.locator('meta[name="sentry-trace"]').count()).toBe(0);
80+
expect(await page.locator('meta[name="sentry-cache-origin"]').count()).toBe(1);
7881

7982
const pageloadTx = await pageloadTxPromise;
8083
await context.close();
8184

82-
return { traceId: pageloadTx.contexts?.trace?.trace_id, links: pageloadTx.contexts?.trace?.links };
85+
return {
86+
traceId: pageloadTx.contexts?.trace?.trace_id,
87+
links: pageloadTx.contexts?.trace?.links,
88+
serverRenderingType: pageloadTx.contexts?.trace?.data?.['page.server_rendering.type'],
89+
};
8390
};
8491

8592
const first = await visit();
@@ -88,8 +95,73 @@ test('does not share a trace between visitors of a shell generated at request ti
8895
expect(first.traceId).toBeTruthy();
8996
expect(second.traceId).toBeTruthy();
9097
expect(second.traceId).not.toBe(first.traceId);
91-
expect(first.links).toBeUndefined();
92-
expect(second.links).toBeUndefined();
98+
expect(first.serverRenderingType).toBe('static');
99+
expect(second.serverRenderingType).toBe('static');
100+
101+
// Both link the request that prerendered the shell, which is the first visitor's own request. The
102+
// client cannot tell the first visit from later ones, so that pageload starts a fresh trace too.
103+
const linkOf = (links: unknown): { trace_id: string; attributes?: Record<string, unknown> } => {
104+
const link = (links as Array<{ trace_id: string; attributes?: Record<string, unknown> }>)[0];
105+
expect(link?.attributes?.[SENTRY_LINK_TYPE]).toBe('cache_origin');
106+
return link!;
107+
};
108+
expect(linkOf(first.links).trace_id).toBe(linkOf(second.links).trace_id);
109+
expect(linkOf(second.links).trace_id).not.toBe(second.traceId);
110+
});
111+
112+
test('continues the request trace on a shell prerendered at runtime and links the prerender', async ({
113+
browser,
114+
page,
115+
baseURL,
116+
}) => {
117+
test.skip(process.env.TEST_ENV !== 'production', 'Shells are only cached in production builds');
118+
119+
// The shell from the build carries no `sentry-cache-origin` tag. Wait until a visit serves the
120+
// shell that the server prerendered again at runtime.
121+
const warmup = await browser.newPage();
122+
await warmup.goto('/runtime-shell');
123+
const renderedAtBuild = await warmup.locator('#rendered-at').textContent();
124+
await expect
125+
.poll(
126+
async () => {
127+
await warmup.waitForTimeout(1500);
128+
await warmup.goto('/runtime-shell');
129+
return warmup.locator('#rendered-at').textContent();
130+
},
131+
{ timeout: 30_000 },
132+
)
133+
.not.toBe(renderedAtBuild);
134+
await warmup.close();
135+
136+
const pageloadTxPromise = waitForTransaction(APP, transactionEvent => {
137+
return (
138+
transactionEvent.contexts?.trace?.op === 'pageload' &&
139+
transactionEvent.contexts?.trace?.data?.['url.full'] === `${baseURL}/runtime-shell?visit=last`
140+
);
141+
});
142+
143+
await page.goto('/runtime-shell?visit=last');
144+
await expect(page.locator('#request-content')).toHaveText('Rendered for this request');
145+
const [requestTraceId] = (await page.locator('meta[name="sentry-trace"]').getAttribute('content'))!.split('-');
146+
const [prerenderTraceId, prerenderSpanId] = (await page
147+
.locator('meta[name="sentry-cache-origin"]')
148+
.getAttribute('content'))!.split('-');
149+
150+
const pageloadTx = await pageloadTxPromise;
151+
152+
// Only the shell came from the cache. The rest of the page rendered for this request, and the
153+
// pageload continues that trace.
154+
expect(pageloadTx.contexts?.trace?.trace_id).toBe(requestTraceId);
155+
expect(pageloadTx.contexts?.trace?.data?.['page.server_rendering.type']).toBe('partially_static');
156+
expect(pageloadTx.contexts?.trace?.links).toEqual([
157+
{
158+
trace_id: prerenderTraceId,
159+
span_id: prerenderSpanId,
160+
sampled: true,
161+
attributes: { [SENTRY_LINK_TYPE]: 'cache_origin' },
162+
},
163+
]);
164+
expect(prerenderTraceId).not.toBe(requestTraceId);
93165
});
94166

95167
test('starts a fresh trace on a fully static page', async ({ page }) => {
@@ -105,8 +177,38 @@ test('starts a fresh trace on a fully static page', async ({ page }) => {
105177

106178
const pageloadTx = await pageloadTxPromise;
107179

108-
// A build-time shell carries no trace context at all.
180+
// A build-time shell carries no trace context and no marker, the SDK does not run during the build.
109181
expect(await page.locator('meta[name="sentry-trace"]').count()).toBe(0);
182+
expect(await page.locator('meta[name="sentry-cache-origin"]').count()).toBe(0);
110183
expect(pageloadTx.contexts?.trace?.trace_id).toBeTruthy();
111184
expect(pageloadTx.contexts?.trace?.links).toBeUndefined();
185+
expect(pageloadTx.contexts?.trace?.data?.['page.server_rendering.type']).toBeUndefined();
186+
});
187+
188+
test('keeps a navigation that starts while the pageload waits for the trace meta tag', async ({ page }) => {
189+
test.skip(process.env.TEST_ENV !== 'production', 'Only a prerendered shell makes the trace meta tag arrive late');
190+
191+
const pageloadTxPromise = waitForTransaction(APP, transactionEvent => {
192+
return transactionEvent.contexts?.trace?.op === 'pageload' && transactionEvent.transaction === '/slow-hole';
193+
});
194+
195+
const navigationTxPromise = waitForTransaction(APP, transactionEvent => {
196+
return transactionEvent.contexts?.trace?.op === 'navigation' && transactionEvent.transaction === '/static-shell';
197+
});
198+
199+
await page.goto('/slow-hole', { waitUntil: 'commit' });
200+
await page.waitForFunction(() => !!(window as { next?: { router?: unknown } }).next?.router);
201+
202+
// The resumed part, which carries the tag, has not arrived yet, so the pageload is still waiting.
203+
await expect(page.locator('#slow-fallback')).toBeVisible();
204+
expect(await page.locator('meta[name="sentry-trace"]').count()).toBe(0);
205+
206+
await page.locator('#navigate').click();
207+
await expect(page.locator('#static-shell')).toHaveText('Static shell');
208+
209+
const [pageloadTx, navigationTx] = await Promise.all([pageloadTxPromise, navigationTxPromise]);
210+
211+
// The navigation ends the pageload like any pageload it interrupts, and runs to its own idle timeout.
212+
expect(pageloadTx.contexts?.trace?.data?.['sentry.idle_span_finish_reason']).toBe('cancelled');
213+
expect(navigationTx.contexts?.trace?.data?.['sentry.idle_span_finish_reason']).toBe('idleTimeout');
112214
});
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import { cacheLife } from 'next/cache';
2+
import { headers } from 'next/headers';
3+
import { Suspense } from 'react';
4+
5+
// The shell goes stale after a second, so a later visit makes the server prerender it again at
6+
// runtime. An `expire` below 5 minutes would make Next.js leave this out of the shell.
7+
async function RenderedAt() {
8+
'use cache';
9+
cacheLife({ revalidate: 1, expire: 3600 });
10+
return <p id="rendered-at">{Date.now()}</p>;
11+
}
12+
13+
async function RequestContent() {
14+
await headers();
15+
return <p id="request-content">Rendered for this request</p>;
16+
}
17+
18+
export default function Page() {
19+
return (
20+
<>
21+
<RenderedAt />
22+
<Suspense fallback={<p>Loading...</p>}>
23+
<RequestContent />
24+
</Suspense>
25+
</>
26+
);
27+
}

‎dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/package.json‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@
3535
"devDependencies": {
3636
"@playwright/test": "1.64.1",
3737
"@sentry-internal/test-utils": "file:../../packed/sentry-internal-test-utils-packed.tgz",
38+
"@sentry/conventions": "0.27.0",
3839
"@types/node": "^20",
3940
"@types/react": "^19",
4041
"@types/react-dom": "^19",

0 commit comments

Comments
 (0)