Skip to content

Commit d28566e

Browse files
chargomeclaudes1gr1d
authored
feat(nextjs): Wait for the resumed part of a document before starting the pageload (#25142)
Builds on #25141. When a document resumes a prerendered shell, its `sentry-trace` tag sits at the start of the resumed part, which arrives after the SDK has started. In a local demo six of seven visits found no tag at init, so the pageload could only link the server trace instead of joining it. The app router pageload now continues whatever tag is in the document, last one wins. In apps with `cacheComponents` enabled, if there is no tag, no `Server-Timing` trace and the document is still streaming, it waits for the tag before creating the pageload span. Other apps keep the immediate start: without `cacheComponents` a tag is either in `<head>` from the start or never comes, so waiting only held the pageload back. The wait ends at `DOMContentLoaded` or after 10 seconds. The span still starts at the time origin and gets its children from the performance timeline, so it looks the same as before, only inside the server's trace. No tag by the end of parsing means a fresh trace, which is the fully static case. A client-side navigation during the wait starts the pageload with a fresh trace first, so the navigation ends it like any pageload it interrupts, as today. Started later, the pageload would end the navigation span instead. The pageload keeps the URL the document was loaded with, even if the navigation already changed it. Waiting was chosen over moving a started span to another trace. That needs core changes and does not work with span streaming once children are flushed. The cost: fetches, errors and clicks between SDK start and the resumed part are not attached to the pageload. The `Server-Timing` follow-ups (JS-3864, JS-3866) remove that window where the header can be set. <img width="2240" height="1904" alt="figure-ppr" src="https://github.com/user-attachments/assets/c6049dff-1ceb-4a09-9467-b2436648cf6c" /> Fixes #24944 🤖 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 ebe8fdc commit d28566e

16 files changed

Lines changed: 865 additions & 18 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@
3333

3434
- **feat(nextjs): Never emit trace meta tags from a prerender ([#25141](https://github.com/getsentry/sentry-javascript/pull/25141))**
3535

36-
Pageloads of apps with `cacheComponents` enabled are connected to their server request again. Pages that Next.js prerenders no longer carry trace information, so a cached page can no longer connect visitors to a request that was not theirs. This also applies to ISR pages.
36+
Pageloads of apps with `cacheComponents` enabled are connected to their server request again. Pages that Next.js prerenders no longer carry trace information, so a cached page can no longer connect visitors to a request that was not theirs. This also applies to ISR pages. The pageload waits for the trace information when it arrives with the part of the page that streams in after a cached shell.
3737

3838
If you set up OpenTelemetry yourself and register `SentryPropagator`, register `NextSentryPropagator` instead and pass your tracer provider to `markPrerendersOnTracerProvider`, so that your prerendered pages carry no trace information either.
3939

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
import { headers } from 'next/headers';
2+
3+
// Opts out of the static shell: the whole page renders on the server for every request, so the
4+
// trace meta tags land in <head> like in a classic dynamic render.
5+
export const instant = false;
6+
7+
export default async function Page() {
8+
const requestHeaders = await headers();
9+
return <h1 id="blocking">Blocking render for {requestHeaders.get('user-agent') ? 'a browser' : 'nobody'}</h1>;
10+
}
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
// No request data anywhere, so the whole page is a static shell and nothing renders per request.
2+
export default function Page() {
3+
return <h1 id="static-shell">Static shell</h1>;
4+
}
Lines changed: 89 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,24 @@
1+
import type { Page } from '@playwright/test';
12
import { expect, test } from '@playwright/test';
23
import { waitForTransaction } from '@sentry-internal/test-utils';
34

4-
test('connects the pageload trace to the runtime server request', async ({ page }) => {
5-
// The resumed part of the document, which carries the tag, arrives after the SDK started. Enabled
6-
// once the pageload instrumentation waits for it.
7-
test.skip(true, 'Needs the deferred pageload start');
5+
const APP = 'nextjs-16-cacheComponents';
6+
7+
// The server only writes `sentry-trace` meta tags for a render that happened for the current request,
8+
// never while it prerenders a shell. So a tag in the document can always be continued; the only
9+
// question is when it arrives. The resumed part of a prerendered shell arrives after the SDK started,
10+
// which the pageload instrumentation waits for.
811

12+
test('connects the pageload trace to the runtime server request', async ({ page }) => {
913
// The shell is prerendered, but the page's dynamic part (`DynamicContent` awaits `headers()`)
1014
// runs on the server on every request and produces the `GET /pageload-tracing` transaction.
11-
// A fully prerendered page would produce no server transaction.
12-
const serverTxPromise = waitForTransaction('nextjs-16-cacheComponents', transactionEvent => {
15+
const serverTxPromise = waitForTransaction(APP, transactionEvent => {
1316
return (
1417
transactionEvent.contexts?.trace?.op === 'http.server' && transactionEvent.transaction === 'GET /pageload-tracing'
1518
);
1619
});
1720

18-
const pageloadTxPromise = waitForTransaction('nextjs-16-cacheComponents', transactionEvent => {
21+
const pageloadTxPromise = waitForTransaction(APP, transactionEvent => {
1922
return transactionEvent.contexts?.trace?.op === 'pageload' && transactionEvent.transaction === '/pageload-tracing';
2023
});
2124

@@ -27,4 +30,83 @@ test('connects the pageload trace to the runtime server request', async ({ page
2730

2831
expect(serverTx.contexts?.trace?.trace_id).toBeTruthy();
2932
expect(pageloadTx.contexts?.trace?.trace_id).toBe(serverTx.contexts?.trace?.trace_id);
33+
expect(pageloadTx.contexts?.trace?.links).toBeUndefined();
34+
});
35+
36+
test('connects the pageload trace to a blocking render', async ({ page }) => {
37+
const serverTxPromise = waitForTransaction(APP, transactionEvent => {
38+
return transactionEvent.contexts?.trace?.op === 'http.server' && transactionEvent.transaction === 'GET /blocking';
39+
});
40+
41+
const pageloadTxPromise = waitForTransaction(APP, transactionEvent => {
42+
return transactionEvent.contexts?.trace?.op === 'pageload' && transactionEvent.transaction === '/blocking';
43+
});
44+
45+
await page.goto('/blocking');
46+
47+
await expect(page.locator('#blocking')).toHaveText('Blocking render for a browser');
48+
49+
const [serverTx, pageloadTx] = await Promise.all([serverTxPromise, pageloadTxPromise]);
50+
51+
// The whole page renders per request, so the tag in <head> is this request's and is continued.
52+
expect(serverTx.contexts?.trace?.trace_id).toBeTruthy();
53+
expect(pageloadTx.contexts?.trace?.trace_id).toBe(serverTx.contexts?.trace?.trace_id);
54+
expect(pageloadTx.contexts?.trace?.links).toBeUndefined();
55+
});
56+
57+
test('does not share a trace between visitors of a shell generated at request time', async ({ browser }) => {
58+
test.skip(process.env.TEST_ENV !== 'production', 'Shells are only cached in production builds');
59+
60+
// `[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+
const id = `shell-${Date.now()}`;
63+
64+
const visit = async (): Promise<{ traceId: string | undefined; links: unknown }> => {
65+
const context = await browser.newContext();
66+
const page: Page = await context.newPage();
67+
68+
const pageloadTxPromise = waitForTransaction(APP, transactionEvent => {
69+
return (
70+
transactionEvent.contexts?.trace?.op === 'pageload' &&
71+
transactionEvent.contexts?.trace?.data?.['url.path'] === `/${id}`
72+
);
73+
});
74+
75+
await page.goto(`/${id}`);
76+
await expect(page.locator('#result')).toHaveText(`Hello, ${id}!`);
77+
expect(await page.locator('meta[name="sentry-trace"]').count()).toBe(0);
78+
79+
const pageloadTx = await pageloadTxPromise;
80+
await context.close();
81+
82+
return { traceId: pageloadTx.contexts?.trace?.trace_id, links: pageloadTx.contexts?.trace?.links };
83+
};
84+
85+
const first = await visit();
86+
const second = await visit();
87+
88+
expect(first.traceId).toBeTruthy();
89+
expect(second.traceId).toBeTruthy();
90+
expect(second.traceId).not.toBe(first.traceId);
91+
expect(first.links).toBeUndefined();
92+
expect(second.links).toBeUndefined();
93+
});
94+
95+
test('starts a fresh trace on a fully static page', async ({ page }) => {
96+
test.skip(process.env.TEST_ENV !== 'production', 'Pages are only prerendered in production builds');
97+
98+
const pageloadTxPromise = waitForTransaction(APP, transactionEvent => {
99+
return transactionEvent.contexts?.trace?.op === 'pageload' && transactionEvent.transaction === '/static-shell';
100+
});
101+
102+
await page.goto('/static-shell');
103+
104+
await expect(page.locator('#static-shell')).toHaveText('Static shell');
105+
106+
const pageloadTx = await pageloadTxPromise;
107+
108+
// A build-time shell carries no trace context at all.
109+
expect(await page.locator('meta[name="sentry-trace"]').count()).toBe(0);
110+
expect(pageloadTx.contexts?.trace?.trace_id).toBeTruthy();
111+
expect(pageloadTx.contexts?.trace?.links).toBeUndefined();
30112
});
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
import { headers } from 'next/headers';
2+
3+
// Opts out of the static shell: the whole page renders on the server for every request, so the
4+
// trace meta tags land in <head> like in a classic dynamic render.
5+
export const instant = false;
6+
7+
export default async function Page() {
8+
const requestHeaders = await headers();
9+
return <h1 id="blocking">Blocking render for {requestHeaders.get('user-agent') ? 'a browser' : 'nobody'}</h1>;
10+
}
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+
}
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
// No request data anywhere, so the whole page is a static shell and nothing renders per request.
2+
export default function Page() {
3+
return <h1 id="static-shell">Static shell</h1>;
4+
}
Lines changed: 117 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,22 @@
1+
import type { Page } from '@playwright/test';
12
import { expect, test } from '@playwright/test';
23
import { getSpanOp, waitForStreamedSpan } from '@sentry-internal/test-utils';
34

4-
test('connects the pageload trace to the runtime server request', async ({ page }) => {
5-
// The resumed part of the document, which carries the tag, arrives after the SDK started. Enabled
6-
// once the pageload instrumentation waits for it.
7-
test.skip(true, 'Needs the deferred pageload start');
5+
const APP = 'nextjs-16-streaming-cacheComponents';
6+
7+
// The server only writes `sentry-trace` meta tags for a render that happened for the current request,
8+
// never while it prerenders a shell. So a tag in the document can always be continued; the only
9+
// question is when it arrives. The resumed part of a prerendered shell arrives after the SDK started,
10+
// which the pageload instrumentation waits for.
811

12+
test('connects the pageload trace to the runtime server request', async ({ page }) => {
913
// The shell is prerendered, but the page's dynamic part (`DynamicContent` awaits `headers()`)
1014
// runs on the server on every request and produces the `GET /pageload-tracing` root span.
11-
// A fully prerendered page would produce no server span.
12-
const serverSpanPromise = waitForStreamedSpan('nextjs-16-streaming-cacheComponents', span => {
15+
const serverSpanPromise = waitForStreamedSpan(APP, span => {
1316
return span.name === 'GET /pageload-tracing' && getSpanOp(span) === 'http.server' && span.is_segment;
1417
});
1518

16-
const pageloadSpanPromise = waitForStreamedSpan('nextjs-16-streaming-cacheComponents', span => {
19+
const pageloadSpanPromise = waitForStreamedSpan(APP, span => {
1720
return span.name === '/pageload-tracing' && getSpanOp(span) === 'pageload' && span.is_segment;
1821
});
1922

@@ -25,4 +28,111 @@ test('connects the pageload trace to the runtime server request', async ({ page
2528

2629
expect(serverSpan.trace_id).toBeTruthy();
2730
expect(pageloadSpan.trace_id).toBe(serverSpan.trace_id);
31+
expect(pageloadSpan.links).toBeUndefined();
32+
});
33+
34+
test('connects the pageload trace to a blocking render', async ({ page }) => {
35+
const serverSpanPromise = waitForStreamedSpan(APP, span => {
36+
return span.name === 'GET /blocking' && getSpanOp(span) === 'http.server' && span.is_segment;
37+
});
38+
39+
const pageloadSpanPromise = waitForStreamedSpan(APP, span => {
40+
return span.name === '/blocking' && getSpanOp(span) === 'pageload' && span.is_segment;
41+
});
42+
43+
await page.goto('/blocking');
44+
45+
await expect(page.locator('#blocking')).toHaveText('Blocking render for a browser');
46+
47+
const [serverSpan, pageloadSpan] = await Promise.all([serverSpanPromise, pageloadSpanPromise]);
48+
49+
// The whole page renders per request, so the tag in <head> is this request's and is continued.
50+
expect(serverSpan.trace_id).toBeTruthy();
51+
expect(pageloadSpan.trace_id).toBe(serverSpan.trace_id);
52+
expect(pageloadSpan.links).toBeUndefined();
53+
});
54+
55+
test('does not share a trace between visitors of a shell generated at request time', async ({ browser }) => {
56+
test.skip(process.env.TEST_ENV !== 'production', 'Shells are only cached in production builds');
57+
58+
// `[id]` only lists `test` in `generateStaticParams`, so the first visit prerenders and caches the
59+
// shell for this id. The prerender writes no trace tag, so every visitor starts a fresh trace.
60+
const id = `shell-${Date.now()}`;
61+
62+
const visit = async (): Promise<{ traceId: string | undefined; links: unknown }> => {
63+
const context = await browser.newContext();
64+
const page: Page = await context.newPage();
65+
66+
const pageloadSpanPromise = waitForStreamedSpan(APP, span => {
67+
return getSpanOp(span) === 'pageload' && span.is_segment && span.attributes['url.path']?.value === `/${id}`;
68+
});
69+
70+
await page.goto(`/${id}`);
71+
await expect(page.locator('#result')).toHaveText(`Hello, ${id}!`);
72+
expect(await page.locator('meta[name="sentry-trace"]').count()).toBe(0);
73+
74+
const pageloadSpan = await pageloadSpanPromise;
75+
await context.close();
76+
77+
return { traceId: pageloadSpan.trace_id, links: pageloadSpan.links };
78+
};
79+
80+
const first = await visit();
81+
const second = await visit();
82+
83+
expect(first.traceId).toBeTruthy();
84+
expect(second.traceId).toBeTruthy();
85+
expect(second.traceId).not.toBe(first.traceId);
86+
expect(first.links).toBeUndefined();
87+
expect(second.links).toBeUndefined();
88+
});
89+
90+
test('starts a fresh trace on a fully static page', async ({ page }) => {
91+
test.skip(process.env.TEST_ENV !== 'production', 'Pages are only prerendered in production builds');
92+
93+
const pageloadSpanPromise = waitForStreamedSpan(APP, span => {
94+
return span.name === '/static-shell' && getSpanOp(span) === 'pageload' && span.is_segment;
95+
});
96+
97+
await page.goto('/static-shell');
98+
99+
await expect(page.locator('#static-shell')).toHaveText('Static shell');
100+
101+
const pageloadSpan = await pageloadSpanPromise;
102+
103+
// A build-time shell carries no trace context at all.
104+
expect(await page.locator('meta[name="sentry-trace"]').count()).toBe(0);
105+
expect(pageloadSpan.trace_id).toBeTruthy();
106+
expect(pageloadSpan.links).toBeUndefined();
107+
});
108+
109+
test('keeps a navigation that starts while the pageload waits for the trace meta tag', async ({ page }) => {
110+
test.skip(process.env.TEST_ENV !== 'production', 'Only a prerendered shell makes the trace meta tag arrive late');
111+
112+
const pageloadSpanPromise = waitForStreamedSpan(APP, span => {
113+
return span.name === '/slow-hole' && getSpanOp(span) === 'pageload' && span.is_segment;
114+
});
115+
116+
const navigationSpanPromise = waitForStreamedSpan(APP, span => {
117+
return span.name === '/static-shell' && getSpanOp(span) === 'navigation' && span.is_segment;
118+
});
119+
120+
await page.goto('/slow-hole', { waitUntil: 'commit' });
121+
await page.waitForFunction(() => !!(window as { next?: { router?: unknown } }).next?.router);
122+
123+
// The resumed part, which carries the tag, has not arrived yet, so the pageload is still waiting.
124+
await expect(page.locator('#slow-fallback')).toBeVisible();
125+
expect(await page.locator('meta[name="sentry-trace"]').count()).toBe(0);
126+
127+
await page.locator('#navigate').click();
128+
await expect(page.locator('#static-shell')).toHaveText('Static shell');
129+
130+
const [pageloadSpan, navigationSpan] = await Promise.all([pageloadSpanPromise, navigationSpanPromise]);
131+
132+
// The navigation ends the pageload like any pageload it interrupts, and runs to its own idle timeout.
133+
expect(pageloadSpan.attributes['sentry.idle_span_finish_reason']).toEqual({ type: 'string', value: 'cancelled' });
134+
expect(navigationSpan.attributes['sentry.idle_span_finish_reason']).toEqual({
135+
type: 'string',
136+
value: 'idleTimeout',
137+
});
28138
});

‎packages/nextjs/src/client/browserTracingIntegration.ts‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import type { Integration } from '@sentry/core';
22
import { browserTracingIntegration as originalBrowserTracingIntegration, isBotUserAgent } from '@sentry/react';
3+
import { settlePendingPageloadWait } from './routing/appRouterRoutingInstrumentation';
34
import { nextRouterInstrumentNavigation, nextRouterInstrumentPageLoad } from './routing/nextRoutingInstrumentation';
45

56
/**
@@ -41,6 +42,13 @@ export function browserTracingIntegration(
4142
nextRouterInstrumentNavigation(client);
4243
}
4344

45+
// A pageload that is still waiting for its trace meta tag has to exist before the navigation span,
46+
// so the navigation ends it like any pageload it interrupts. Started later, the pageload would end
47+
// the navigation span instead.
48+
if (instrumentPageLoad) {
49+
client.on('beforeStartNavigationSpan', settlePendingPageloadWait);
50+
}
51+
4452
browserTracingIntegrationInstance.afterAllSetup(client);
4553

4654
if (instrumentPageLoad) {

0 commit comments

Comments
 (0)