Skip to content

Commit fa2f4e1

Browse files
chargomeclaude
andcommitted
feat(nextjs): Wait for the resumed part of a document before starting the pageload
A document that resumes a prerendered shell gets its sentry-trace tag with the resumed chunk, which lands in <body> after the SDK has usually already run. A pageload created at that point can only link the server trace. Continue whatever tag is in the document, last one wins, and when there is no tag, no Server-Timing trace and the document is still streaming, wait for the tag before creating the pageload span, bounded by DOMContentLoaded and a 10 second cap. The span's start is backdated to the time origin and its children come from the performance timeline, so it looks the same as before, only inside the server's trace. A client-side navigation during the wait starts the pageload with a fresh trace first, so the navigation is filed as its redirect instead of being the only record of the visit. Fixes #24944 Refs JS-3863 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
1 parent 9341fac commit fa2f4e1

11 files changed

Lines changed: 606 additions & 18 deletions

File tree

‎CHANGELOG.md‎

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

55
- "You miss 100 percent of the chances you don't take. — Wayne Gretzky" — Michael Scott
66

7-
- **feat(nextjs)**: Browser pageloads of apps with `cacheComponents` enabled now connect to the server request again. The SDK stopped emitting `sentry-trace` meta tags for these apps because a cached shell could replay the trace of the request that rendered it to every later visitor. The server SDK now refuses to hand out trace context while Next.js prerenders a document, so cached shells never carry a trace, and a tag that is in the document was always rendered for the current request. This also covers ISR pages, which no longer need the client-side meta tag removal based on `generateStaticParams`; the `isrRoutes` field of the injected route manifest is gone.
7+
- **feat(nextjs)**: Browser pageloads of apps with `cacheComponents` enabled now connect to the server request again. The SDK stopped emitting `sentry-trace` meta tags for these apps because a cached shell could replay the trace of the request that rendered it to every later visitor. The server SDK now refuses to hand out trace context while Next.js prerenders a document, so cached shells never carry a trace, and a tag that is in the document was always rendered for the current request. This also covers ISR pages, which no longer need the client-side meta tag removal based on `generateStaticParams`; the `isrRoutes` field of the injected route manifest is gone. Because the resumed part of a Cache Components document usually arrives after the SDK started, the pageload span is created once that part has streamed in, or once the document finished parsing, with its start still at the time origin. Fetches that fire before that point are not attached to the pageload.
88

99
- **fix(cloudflare)**: Durable Object constructors now run in their own isolation scope. When work that the constructor starts, for example a `blockConcurrencyWhile` callback, calls a method of the instance, that call is no longer treated as an incoming RPC call, so an error the instance catches itself is no longer reported as unhandled. As a result, `Sentry.setTag()`, `setUser()` and `setContext()` in a constructor now apply only to that constructor work. They no longer reach later `fetch`, RPC or `alarm` invocations, because the scope they used to write to is shared by every Durable Object and handler in the isolate. Use `initialScope` for static data, and set per-instance data in each handler.
1010

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: 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: 86 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,80 @@ 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();
28107
});

‎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+
// Registered before the browser tracing integration's own handler, which creates the navigation
46+
// span: a pageload that is still waiting for its trace meta tag has to exist by then so the
47+
// navigation can be filed as its redirect.
48+
if (instrumentPageLoad) {
49+
client.on('startNavigationSpan', settlePendingPageloadWait);
50+
}
51+
4452
browserTracingIntegrationInstance.afterAllSetup(client);
4553

4654
if (instrumentPageLoad) {

‎packages/nextjs/src/client/routing/appRouterRoutingInstrumentation.ts‎

Lines changed: 52 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import type { Client, Span } from '@sentry/core';
1+
import type { Client, Span, StartSpanOptions } from '@sentry/core';
22
import {
33
GLOBAL_OBJ,
44
hasSpanStreamingEnabled,
@@ -17,6 +17,8 @@ import {
1717
resolveRoute,
1818
} from '@sentry/react';
1919
import { stripTrailingSlash } from './parameterization';
20+
import type { TraceMetaTagWait } from './traceMetaTags';
21+
import { addTraceMetaTagLink, canWaitForTraceMetaTag, readTraceMetaTags, waitForTraceMetaTag } from './traceMetaTags';
2022
import {
2123
SENTRY_OP,
2224
SENTRY_SEGMENT_NAME_SOURCE,
@@ -95,11 +97,28 @@ let navigationRoutingMode: 'router-patch' | 'transition-start-hook' = 'router-pa
9597

9698
const currentRouterPatchingNavigationSpanRef: NavigationSpanRef = { current: undefined };
9799

100+
/**
101+
* How long the pageload start may wait for the resumed part of a Cache Components document. The wait
102+
* normally ends with `DOMContentLoaded`; the cap only matters for a response the server keeps open.
103+
*/
104+
const PAGELOAD_TRACE_META_TAG_TIMEOUT_MS = 10_000;
105+
106+
let pendingPageloadWait: TraceMetaTagWait | undefined;
107+
108+
/**
109+
* Starts a pageload that is still waiting for its trace meta tag with a fresh trace. Called when a
110+
* navigation starts, before the navigation span is created, so that the navigation is filed as a
111+
* redirect of this pageload rather than becoming the only record of the visit.
112+
*/
113+
export function settlePendingPageloadWait(): void {
114+
pendingPageloadWait?.giveUp();
115+
}
116+
98117
/** Instruments the Next.js app router for pageloads. */
99118
export function appRouterInstrumentPageLoad(client: Client): void {
100119
const pathname = stripTrailingSlash(WINDOW.location.pathname);
101120
const parameterizedPathname = resolveCurrentRoute(client);
102-
startBrowserTracingPageLoadSpan(client, {
121+
const spanOptions: StartSpanOptions = {
103122
// With span streaming, span names have to be low cardinality, so we can't fall back to the URL.
104123
name: parameterizedPathname ?? (hasSpanStreamingEnabled(client) ? PAGELOAD_SPAN_NAME_FALLBACK : pathname),
105124
// pageload should always start at timeOrigin (and needs to be in s, not ms)
@@ -109,7 +128,37 @@ export function appRouterInstrumentPageLoad(client: Client): void {
109128
[SENTRY_SEGMENT_NAME_SOURCE]: parameterizedPathname ? 'route' : 'url',
110129
...(parameterizedPathname && { [URL_TEMPLATE]: parameterizedPathname }),
111130
},
112-
});
131+
};
132+
133+
// A tag that is already in the document was rendered for this request and can be continued. A
134+
// Cache Components document that resumes a prerendered shell gets its tag with the resumed part,
135+
// which usually arrives after the SDK started. Waiting for it lets the pageload continue the server
136+
// trace instead of linking it; the span's start is backdated to the time origin either way.
137+
const traceMetaTags = readTraceMetaTags();
138+
if (traceMetaTags || !canWaitForTraceMetaTag()) {
139+
startBrowserTracingPageLoadSpan(client, spanOptions, traceMetaTags);
140+
return;
141+
}
142+
143+
pendingPageloadWait = waitForTraceMetaTag(
144+
values => {
145+
pendingPageloadWait = undefined;
146+
startBrowserTracingPageLoadSpan(client, spanOptions, values);
147+
},
148+
() => {
149+
pendingPageloadWait = undefined;
150+
const span = startBrowserTracingPageLoadSpan(client, spanOptions);
151+
// Only reachable through the cap or a navigation while the document still streams: a tag that
152+
// shows up after this can no longer be continued, but the server request can still be linked.
153+
if (span) {
154+
waitForTraceMetaTag(
155+
values => span.isRecording() && addTraceMetaTagLink(span, values),
156+
() => undefined,
157+
);
158+
}
159+
},
160+
PAGELOAD_TRACE_META_TAG_TIMEOUT_MS,
161+
);
113162
}
114163

115164
interface NavigationSpanRef {

0 commit comments

Comments
 (0)