Skip to content

Commit 11b4573

Browse files
chargomeclaude
andcommitted
feat(nextjs): Mark pageloads that were served from a cached shell
A visitor of a cached page gets a pageload in its own trace with no server span, which looks like missing server instrumentation. While Next.js prerenders a document, the server now writes a `sentry-shell` meta tag with the trace of the prerender instead of nothing. The client never continues it: it sets `sentry.nextjs.cached_shell` on the pageload and adds a `cache_origin` span link to the request that generated the shell. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
1 parent c41a798 commit 11b4573

11 files changed

Lines changed: 184 additions & 38 deletions

File tree

‎CHANGELOG.md‎

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

99
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.
1010

11+
- **feat(nextjs): Mark pageloads that were served from a cached shell**
12+
13+
A pageload whose document is a shell that Next.js prerendered at runtime, for example an ISR page or a `cacheComponents` page generated on first visit, now carries the `sentry.nextjs.cached_shell` attribute and a `cache_origin` span link to the request that generated the shell. That tells apart "nothing ran on the server for this visit" from missing server instrumentation. The link is never continued as a parent, so pageloads keep their own traces and sampling decisions.
14+
1115
## 11.7.0
1216

1317
### Important Changes

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

Lines changed: 25 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,8 @@ const APP = 'nextjs-16-cacheComponents';
77
// The server only writes `sentry-trace` meta tags for a render that happened for the current request,
88
// never while it prerenders a shell. So a tag in the document can always be continued; the only
99
// question is when it arrives. The resumed part of a prerendered shell arrives after the SDK started,
10-
// which the pageload instrumentation waits for.
10+
// which the pageload instrumentation waits for. A shell prerendered at runtime carries a `sentry-shell`
11+
// marker instead, which marks the pageload as served from a cached shell and links the prerender.
1112

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

3132
expect(serverTx.contexts?.trace?.trace_id).toBeTruthy();
3233
expect(pageloadTx.contexts?.trace?.trace_id).toBe(serverTx.contexts?.trace?.trace_id);
33-
expect(pageloadTx.contexts?.trace?.links).toBeUndefined();
3434
});
3535

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

6060
// `[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.
61+
// shell for this id. The prerender writes no trace tag, only a `sentry-shell` marker with its own
62+
// trace, so every visitor starts a fresh trace that links the prerender and is marked as a cached shell.
6263
const id = `shell-${Date.now()}`;
6364

64-
const visit = async (): Promise<{ traceId: string | undefined; links: unknown }> => {
65+
const visit = async (): Promise<{ traceId: string | undefined; links: unknown; cachedShell: unknown }> => {
6566
const context = await browser.newContext();
6667
const page: Page = await context.newPage();
6768

@@ -75,11 +76,16 @@ test('does not share a trace between visitors of a shell generated at request ti
7576
await page.goto(`/${id}`);
7677
await expect(page.locator('#result')).toHaveText(`Hello, ${id}!`);
7778
expect(await page.locator('meta[name="sentry-trace"]').count()).toBe(0);
79+
expect(await page.locator('meta[name="sentry-shell"]').count()).toBe(1);
7880

7981
const pageloadTx = await pageloadTxPromise;
8082
await context.close();
8183

82-
return { traceId: pageloadTx.contexts?.trace?.trace_id, links: pageloadTx.contexts?.trace?.links };
84+
return {
85+
traceId: pageloadTx.contexts?.trace?.trace_id,
86+
links: pageloadTx.contexts?.trace?.links,
87+
cachedShell: pageloadTx.contexts?.trace?.data?.['sentry.nextjs.cached_shell'],
88+
};
8389
};
8490

8591
const first = await visit();
@@ -88,8 +94,17 @@ test('does not share a trace between visitors of a shell generated at request ti
8894
expect(first.traceId).toBeTruthy();
8995
expect(second.traceId).toBeTruthy();
9096
expect(second.traceId).not.toBe(first.traceId);
91-
expect(first.links).toBeUndefined();
92-
expect(second.links).toBeUndefined();
97+
expect(first.cachedShell).toBe(true);
98+
expect(second.cachedShell).toBe(true);
99+
100+
// Both link the one request that prerendered the shell, which is neither visitor's own trace.
101+
const linkOf = (links: unknown): { trace_id: string; attributes?: Record<string, unknown> } => {
102+
const link = (links as Array<{ trace_id: string; attributes?: Record<string, unknown> }>)[0];
103+
expect(link?.attributes?.['sentry.link.type']).toBe('cache_origin');
104+
return link!;
105+
};
106+
expect(linkOf(first.links).trace_id).toBe(linkOf(second.links).trace_id);
107+
expect(linkOf(second.links).trace_id).not.toBe(second.traceId);
93108
});
94109

95110
test('starts a fresh trace on a fully static page', async ({ page }) => {
@@ -105,8 +120,10 @@ test('starts a fresh trace on a fully static page', async ({ page }) => {
105120

106121
const pageloadTx = await pageloadTxPromise;
107122

108-
// A build-time shell carries no trace context at all.
123+
// A build-time shell carries no trace context and no marker, the SDK does not run during the build.
109124
expect(await page.locator('meta[name="sentry-trace"]').count()).toBe(0);
125+
expect(await page.locator('meta[name="sentry-shell"]').count()).toBe(0);
110126
expect(pageloadTx.contexts?.trace?.trace_id).toBeTruthy();
111127
expect(pageloadTx.contexts?.trace?.links).toBeUndefined();
128+
expect(pageloadTx.contexts?.trace?.data?.['sentry.nextjs.cached_shell']).toBeUndefined();
112129
});

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

Lines changed: 25 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,8 @@ const APP = 'nextjs-16-streaming-cacheComponents';
77
// The server only writes `sentry-trace` meta tags for a render that happened for the current request,
88
// never while it prerenders a shell. So a tag in the document can always be continued; the only
99
// question is when it arrives. The resumed part of a prerendered shell arrives after the SDK started,
10-
// which the pageload instrumentation waits for.
10+
// which the pageload instrumentation waits for. A shell prerendered at runtime carries a `sentry-shell`
11+
// marker instead, which marks the pageload as served from a cached shell and links the prerender.
1112

1213
test('connects the pageload trace to the runtime server request', async ({ page }) => {
1314
// The shell is prerendered, but the page's dynamic part (`DynamicContent` awaits `headers()`)
@@ -28,7 +29,6 @@ test('connects the pageload trace to the runtime server request', async ({ page
2829

2930
expect(serverSpan.trace_id).toBeTruthy();
3031
expect(pageloadSpan.trace_id).toBe(serverSpan.trace_id);
31-
expect(pageloadSpan.links).toBeUndefined();
3232
});
3333

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

5858
// `[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.
59+
// shell for this id. The prerender writes no trace tag, only a `sentry-shell` marker with its own
60+
// trace, so every visitor starts a fresh trace that links the prerender and is marked as a cached shell.
6061
const id = `shell-${Date.now()}`;
6162

62-
const visit = async (): Promise<{ traceId: string | undefined; links: unknown }> => {
63+
const visit = async (): Promise<{ traceId: string | undefined; links: unknown; cachedShell: unknown }> => {
6364
const context = await browser.newContext();
6465
const page: Page = await context.newPage();
6566

@@ -70,11 +71,16 @@ test('does not share a trace between visitors of a shell generated at request ti
7071
await page.goto(`/${id}`);
7172
await expect(page.locator('#result')).toHaveText(`Hello, ${id}!`);
7273
expect(await page.locator('meta[name="sentry-trace"]').count()).toBe(0);
74+
expect(await page.locator('meta[name="sentry-shell"]').count()).toBe(1);
7375

7476
const pageloadSpan = await pageloadSpanPromise;
7577
await context.close();
7678

77-
return { traceId: pageloadSpan.trace_id, links: pageloadSpan.links };
79+
return {
80+
traceId: pageloadSpan.trace_id,
81+
links: pageloadSpan.links,
82+
cachedShell: pageloadSpan.attributes['sentry.nextjs.cached_shell']?.value,
83+
};
7884
};
7985

8086
const first = await visit();
@@ -83,8 +89,17 @@ test('does not share a trace between visitors of a shell generated at request ti
8389
expect(first.traceId).toBeTruthy();
8490
expect(second.traceId).toBeTruthy();
8591
expect(second.traceId).not.toBe(first.traceId);
86-
expect(first.links).toBeUndefined();
87-
expect(second.links).toBeUndefined();
92+
expect(first.cachedShell).toBe(true);
93+
expect(second.cachedShell).toBe(true);
94+
95+
// Both link the one request that prerendered the shell, which is neither visitor's own trace.
96+
const linkOf = (links: unknown): { trace_id: string; attributes?: Record<string, { value: unknown }> } => {
97+
const link = (links as Array<{ trace_id: string; attributes?: Record<string, { value: unknown }> }>)[0];
98+
expect(link?.attributes?.['sentry.link.type']?.value).toBe('cache_origin');
99+
return link!;
100+
};
101+
expect(linkOf(first.links).trace_id).toBe(linkOf(second.links).trace_id);
102+
expect(linkOf(second.links).trace_id).not.toBe(second.traceId);
88103
});
89104

90105
test('starts a fresh trace on a fully static page', async ({ page }) => {
@@ -100,8 +115,10 @@ test('starts a fresh trace on a fully static page', async ({ page }) => {
100115

101116
const pageloadSpan = await pageloadSpanPromise;
102117

103-
// A build-time shell carries no trace context at all.
118+
// A build-time shell carries no trace context and no marker, the SDK does not run during the build.
104119
expect(await page.locator('meta[name="sentry-trace"]').count()).toBe(0);
120+
expect(await page.locator('meta[name="sentry-shell"]').count()).toBe(0);
105121
expect(pageloadSpan.trace_id).toBeTruthy();
106122
expect(pageloadSpan.links).toBeUndefined();
123+
expect(pageloadSpan.attributes['sentry.nextjs.cached_shell']).toBeUndefined();
107124
});

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

Lines changed: 24 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
1+
import { SENTRY_LINK_TYPE } from '@sentry/conventions/attributes';
12
import type { Span } from '@sentry/core';
2-
import { extractTraceparentData } from '@sentry/core';
3-
import { GLOBAL_OBJ } from '@sentry/core';
3+
import { extractTraceparentData, GLOBAL_OBJ } from '@sentry/core';
4+
import { SHELL_META_TAG_NAME } from '../../common/traceMetaTags';
45
import { WINDOW } from '@sentry/react';
56

67
// `@opentelemetry/api` `TraceFlags`, inlined because core does not export its copies.
@@ -12,6 +13,9 @@ export interface TraceMetaTagValues {
1213
baggage: string | undefined;
1314
}
1415

16+
/** Set on a pageload whose document is a shell that Next.js prerendered and served from its cache. */
17+
export const CACHED_SHELL_ATTRIBUTE = 'sentry.nextjs.cached_shell';
18+
1519
function getDocument(): Document | undefined {
1620
return WINDOW.document;
1721
}
@@ -132,7 +136,7 @@ export function waitForTraceMetaTag(
132136
* Adds a span link to the span described by a `sentry-trace` value. Used when the tag arrives after
133137
* the pageload span exists, which can only link the server request, not continue it.
134138
*/
135-
export function addTraceMetaTagLink(span: Span, { sentryTrace }: TraceMetaTagValues): void {
139+
export function addTraceMetaTagLink(span: Span, { sentryTrace }: TraceMetaTagValues, linkType?: string): void {
136140
const traceparent = extractTraceparentData(sentryTrace);
137141
if (!traceparent?.traceId || !traceparent.parentSpanId) {
138142
return;
@@ -144,5 +148,22 @@ export function addTraceMetaTagLink(span: Span, { sentryTrace }: TraceMetaTagVal
144148
spanId: traceparent.parentSpanId,
145149
traceFlags: traceparent.parentSampled ? TRACE_FLAG_SAMPLED : TRACE_FLAG_NONE,
146150
},
151+
...(linkType && { attributes: { [SENTRY_LINK_TYPE]: linkType } }),
147152
});
148153
}
154+
155+
/** Reads the marker the server writes into a shell while it prerenders it, see `nextSentryPropagator.ts`. */
156+
export function readShellMarker(): string | undefined {
157+
const document = getDocument();
158+
return document && lastMetaContent(document, SHELL_META_TAG_NAME);
159+
}
160+
161+
/**
162+
* Marks a pageload whose document is a cached shell and links the request that prerendered the shell.
163+
* The marker is never continued: the shell is served to every later visitor, and the request behind it
164+
* handled none of their visits.
165+
*/
166+
export function markCachedShell(span: Span, marker: string): void {
167+
span.setAttribute(CACHED_SHELL_ATTRIBUTE, true);
168+
addTraceMetaTagLink(span, { sentryTrace: marker, baggage: undefined }, 'cache_origin');
169+
}

‎packages/nextjs/src/config/withSentryConfig/getFinalConfigObjectUtils.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { SHELL_META_TAG_NAME } from '../../common/traceMetaTags';
12
import { debug, isMatchingPattern, parseSemver } from '@sentry/core';
23
import { getSentryRelease } from '@sentry/node';
34
import * as fs from 'fs';
@@ -148,6 +149,7 @@ export function maybeSetClientTraceMetadataOption(
148149
incomingUserNextConfigObject.experimental.clientTraceMetadata = [
149150
'baggage',
150151
'sentry-trace',
152+
SHELL_META_TAG_NAME,
151153
...(incomingUserNextConfigObject.experimental?.clientTraceMetadata || []),
152154
];
153155
}

‎packages/nextjs/src/server/nextSentryPropagator.ts‎

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,14 @@
11
import type { Context, TextMapSetter, TracerProvider } from '@opentelemetry/api';
22
import { propagation } from '@opentelemetry/api';
33
import { SentryPropagator } from '@sentry/opentelemetry';
4+
import { SHELL_META_TAG_NAME } from '../common/traceMetaTags';
45
import { isPrerenderContext, markPrerendersOnTracerProvider } from './prerenderContext';
56

67
/**
78
* A `SentryPropagator` that does not hand out the trace meta tags of a document while Next.js
89
* prerenders it. Trace context baked into a prerendered shell would be replayed to every visitor the
9-
* shell is served to, so the browser could continue a trace that never handled its request.
10+
* shell is served to, so the browser could continue a trace that never handled its request. The shell
11+
* gets a `sentry-shell` marker tag with the prerender's trace instead, which the client only ever links.
1012
*
1113
* The SDK registers it when it sets up OpenTelemetry. An app that sets up OpenTelemetry itself
1214
* registers it in place of `SentryPropagator` and calls `markPrerendersOnTracerProvider` with its
@@ -17,10 +19,17 @@ export class NextSentryPropagator extends SentryPropagator {
1719
public inject(ctx: Context, carrier: unknown, setter: TextMapSetter): void {
1820
// Next.js collects the meta tag values into an array; every other caller, such as the outgoing
1921
// request instrumentations, passes a headers object and keeps propagating during a prerender.
20-
if (Array.isArray(carrier) && isPrerenderContext(ctx)) {
22+
if (!Array.isArray(carrier) || !isPrerenderContext(ctx)) {
23+
super.inject(ctx, carrier, setter);
2124
return;
2225
}
23-
super.inject(ctx, carrier, setter);
26+
27+
const values: Record<string, string> = {};
28+
super.inject(ctx, values, { set: (c, key, value) => ((c as Record<string, string>)[key] = value) });
29+
const sentryTrace = values['sentry-trace'];
30+
if (sentryTrace) {
31+
setter.set(carrier, SHELL_META_TAG_NAME, sentryTrace);
32+
}
2433
}
2534
}
2635

‎packages/nextjs/test/client/appRouterRoutingInstrumentation.test.ts‎

Lines changed: 35 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -12,13 +12,16 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
1212
import '@sentry/core';
1313
import '@sentry/react';
1414
import '../../src/client/routing/appRouterRoutingInstrumentation';
15+
import '../../src/client/routing/appRouterPageloadInstrumentation';
1516
import type * as AppRouterInstrumentation from '../../src/client/routing/appRouterRoutingInstrumentation';
17+
import type * as AppRouterPageload from '../../src/client/routing/appRouterPageloadInstrumentation';
1618
import type * as RouteProvider from '../../src/client/routing/routeProvider';
1719
import type { RouteManifest } from '../../src/config/manifest/types';
1820

1921
type Core = typeof SentryCore;
2022
type React = typeof SentryReact;
2123
type Instrumentation = typeof AppRouterInstrumentation;
24+
type PageloadInstrumentation = typeof AppRouterPageload;
2225
type RouteProviderModule = typeof RouteProvider;
2326

2427
interface NextRouter {
@@ -67,11 +70,13 @@ async function setup(traceLifecycle: 'stream' | 'static'): Promise<{
6770
router: NextRouter;
6871
client: Client;
6972
instrumentation: Instrumentation;
73+
pageload: PageloadInstrumentation;
7074
}> {
7175
vi.resetModules();
7276
const core: Core = await import('@sentry/core');
7377
const react: React = await import('@sentry/react');
7478
const instrumentation: Instrumentation = await import('../../src/client/routing/appRouterRoutingInstrumentation');
79+
const pageload: PageloadInstrumentation = await import('../../src/client/routing/appRouterPageloadInstrumentation');
7580
const routeProvider: RouteProviderModule = await import('../../src/client/routing/routeProvider');
7681

7782
const client = new react.BrowserClient({
@@ -93,7 +98,7 @@ async function setup(traceLifecycle: 'stream' | 'static'): Promise<{
9398
instrumentation.appRouterInstrumentNavigation(client);
9499
await vi.waitFor(() => expect(router.back).not.toBe(originalBack));
95100

96-
return { core, router, client, instrumentation };
101+
return { core, router, client, instrumentation, pageload };
97102
}
98103

99104
describe('appRouterInstrumentNavigation (router-patch mode)', () => {
@@ -291,13 +296,13 @@ describe('appRouterInstrumentPageLoad', () => {
291296
});
292297

293298
it('records the URL of the document when the pageload starts after a navigation changed it', async () => {
294-
const { core, instrumentation } = await setup('static');
299+
const { core, pageload } = await setup('static');
295300

296-
instrumentation.appRouterInstrumentPageLoad(core.getClient()!);
301+
pageload.appRouterInstrumentPageLoad(core.getClient()!);
297302
const spanBeforeSettling = core.getActiveSpan();
298303

299304
window.history.replaceState({}, '', '/navigation/42/router-back');
300-
instrumentation.settlePendingPageloadWait();
305+
pageload.settlePendingPageloadWait();
301306

302307
const span = core.getActiveSpan();
303308
expect(span).toBeDefined();
@@ -315,4 +320,30 @@ describe('appRouterInstrumentPageLoad', () => {
315320
'http://localhost:3000/navigation?from=document',
316321
);
317322
});
323+
324+
it('marks a pageload whose document is a cached shell and links the prerender', async () => {
325+
const { core, pageload } = await setup('static');
326+
Object.defineProperty(document, 'readyState', { value: 'complete', configurable: true });
327+
const meta = document.createElement('meta');
328+
meta.name = 'sentry-shell';
329+
meta.content = '11111111111111111111111111111111-1111111111111111-1';
330+
document.head.appendChild(meta);
331+
332+
pageload.appRouterInstrumentPageLoad(core.getClient()!);
333+
334+
const span = core.getActiveSpan();
335+
expect(span).toBeDefined();
336+
const spanJson = core.spanToJSON(span!);
337+
expect(spanJson.attributes?.[SENTRY_OP]).toBe(PAGELOAD);
338+
expect(spanJson.attributes?.['sentry.nextjs.cached_shell']).toBe(true);
339+
expect(spanJson.trace_id).not.toBe('11111111111111111111111111111111');
340+
expect(spanJson.links).toEqual([
341+
expect.objectContaining({
342+
trace_id: '11111111111111111111111111111111',
343+
span_id: '1111111111111111',
344+
attributes: expect.objectContaining({ 'sentry.link.type': 'cache_origin' }),
345+
}),
346+
]);
347+
meta.remove();
348+
});
318349
});

0 commit comments

Comments
 (0)