Skip to content
Draft
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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,10 @@

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

- **feat(nextjs): Mark pageloads of cached pages ([#25318](https://github.com/getsentry/sentry-javascript/pull/25318))**

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.

Work in this release was contributed by @lazerg and @chiliec. Thank you for your contributions!

## 11.10.0
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { cacheLife } from 'next/cache';
import { headers } from 'next/headers';
import { Suspense } from 'react';

// The shell goes stale after a second, so a later visit makes the server prerender it again at
// runtime. An `expire` below 5 minutes would make Next.js leave this out of the shell.
async function RenderedAt() {
'use cache';
cacheLife({ revalidate: 1, expire: 3600 });
return <p id="rendered-at">{Date.now()}</p>;
}

async function RequestContent() {
await headers();
return <p id="request-content">Rendered for this request</p>;
}

export default function Page() {
return (
<>
<RenderedAt />
<Suspense fallback={<p>Loading...</p>}>
<RequestContent />
</Suspense>
</>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { headers } from 'next/headers';
import Link from 'next/link';
import { Suspense } from 'react';

// The dynamic part, and with it the trace meta tag, streams in seconds after the shell, so a test
// can navigate while the pageload still waits for the tag.
async function SlowContent() {
await headers();
await new Promise(resolve => setTimeout(resolve, 4000));
return <p id="slow-content">Slow content</p>;
}

export default function Page() {
return (
<>
<Link id="navigate" href="/static-shell">
Static shell
</Link>
<Suspense fallback={<p id="slow-fallback">Loading...</p>}>
<SlowContent />
</Suspense>
</>
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@
"devDependencies": {
"@playwright/test": "1.64.1",
"@sentry-internal/test-utils": "file:../../packed/sentry-internal-test-utils-packed.tgz",
"@sentry/conventions": "0.27.0",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
Expand Down
Original file line number Diff line number Diff line change
@@ -1,13 +1,15 @@
import type { Page } from '@playwright/test';
import { expect, test } from '@playwright/test';
import { SENTRY_LINK_TYPE } from '@sentry/conventions/attributes';
import { waitForTransaction } from '@sentry-internal/test-utils';

const APP = 'nextjs-16-cacheComponents';

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

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

expect(serverTx.contexts?.trace?.trace_id).toBeTruthy();
expect(pageloadTx.contexts?.trace?.trace_id).toBe(serverTx.contexts?.trace?.trace_id);
expect(pageloadTx.contexts?.trace?.links).toBeUndefined();
});

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

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

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

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

const pageloadTx = await pageloadTxPromise;
await context.close();

return { traceId: pageloadTx.contexts?.trace?.trace_id, links: pageloadTx.contexts?.trace?.links };
return {
traceId: pageloadTx.contexts?.trace?.trace_id,
links: pageloadTx.contexts?.trace?.links,
serverRenderingType: pageloadTx.contexts?.trace?.data?.['page.server_rendering.type'],
};
};

const first = await visit();
Expand All @@ -88,8 +95,73 @@ test('does not share a trace between visitors of a shell generated at request ti
expect(first.traceId).toBeTruthy();
expect(second.traceId).toBeTruthy();
expect(second.traceId).not.toBe(first.traceId);
expect(first.links).toBeUndefined();
expect(second.links).toBeUndefined();
expect(first.serverRenderingType).toBe('static');
expect(second.serverRenderingType).toBe('static');

// Both link the request that prerendered the shell, which is the first visitor's own request. The
// client cannot tell the first visit from later ones, so that pageload starts a fresh trace too.
const linkOf = (links: unknown): { trace_id: string; attributes?: Record<string, unknown> } => {
const link = (links as Array<{ trace_id: string; attributes?: Record<string, unknown> }>)[0];
expect(link?.attributes?.[SENTRY_LINK_TYPE]).toBe('cache_origin');
return link!;
};
expect(linkOf(first.links).trace_id).toBe(linkOf(second.links).trace_id);
expect(linkOf(second.links).trace_id).not.toBe(second.traceId);
});

test('continues the request trace on a shell prerendered at runtime and links the prerender', async ({
browser,
page,
baseURL,
}) => {
test.skip(process.env.TEST_ENV !== 'production', 'Shells are only cached in production builds');

// The shell from the build carries no `sentry-cache-origin` tag. Wait until a visit serves the
// shell that the server prerendered again at runtime.
const warmup = await browser.newPage();
await warmup.goto('/runtime-shell');
const renderedAtBuild = await warmup.locator('#rendered-at').textContent();
await expect
.poll(
async () => {
await warmup.waitForTimeout(1500);
await warmup.goto('/runtime-shell');
return warmup.locator('#rendered-at').textContent();
},
{ timeout: 30_000 },
)
.not.toBe(renderedAtBuild);
await warmup.close();

const pageloadTxPromise = waitForTransaction(APP, transactionEvent => {
return (
transactionEvent.contexts?.trace?.op === 'pageload' &&
transactionEvent.contexts?.trace?.data?.['url.full'] === `${baseURL}/runtime-shell?visit=last`
);
});

await page.goto('/runtime-shell?visit=last');
await expect(page.locator('#request-content')).toHaveText('Rendered for this request');
const [requestTraceId] = (await page.locator('meta[name="sentry-trace"]').getAttribute('content'))!.split('-');
const [prerenderTraceId, prerenderSpanId] = (await page
.locator('meta[name="sentry-cache-origin"]')
.getAttribute('content'))!.split('-');

const pageloadTx = await pageloadTxPromise;

// Only the shell came from the cache. The rest of the page rendered for this request, and the
// pageload continues that trace.
expect(pageloadTx.contexts?.trace?.trace_id).toBe(requestTraceId);
expect(pageloadTx.contexts?.trace?.data?.['page.server_rendering.type']).toBe('partially_static');
expect(pageloadTx.contexts?.trace?.links).toEqual([
{
trace_id: prerenderTraceId,
span_id: prerenderSpanId,
sampled: true,
attributes: { [SENTRY_LINK_TYPE]: 'cache_origin' },
},
]);
expect(prerenderTraceId).not.toBe(requestTraceId);
});

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

const pageloadTx = await pageloadTxPromise;

// A build-time shell carries no trace context at all.
// A build-time shell carries no trace context and no marker, the SDK does not run during the build.
expect(await page.locator('meta[name="sentry-trace"]').count()).toBe(0);
expect(await page.locator('meta[name="sentry-cache-origin"]').count()).toBe(0);
expect(pageloadTx.contexts?.trace?.trace_id).toBeTruthy();
expect(pageloadTx.contexts?.trace?.links).toBeUndefined();
expect(pageloadTx.contexts?.trace?.data?.['page.server_rendering.type']).toBeUndefined();
});

test('keeps a navigation that starts while the pageload waits for the trace meta tag', async ({ page }) => {
test.skip(process.env.TEST_ENV !== 'production', 'Only a prerendered shell makes the trace meta tag arrive late');

const pageloadTxPromise = waitForTransaction(APP, transactionEvent => {
return transactionEvent.contexts?.trace?.op === 'pageload' && transactionEvent.transaction === '/slow-hole';
});

const navigationTxPromise = waitForTransaction(APP, transactionEvent => {
return transactionEvent.contexts?.trace?.op === 'navigation' && transactionEvent.transaction === '/static-shell';
});

await page.goto('/slow-hole', { waitUntil: 'commit' });
await page.waitForFunction(() => !!(window as { next?: { router?: unknown } }).next?.router);

// The resumed part, which carries the tag, has not arrived yet, so the pageload is still waiting.
await expect(page.locator('#slow-fallback')).toBeVisible();
expect(await page.locator('meta[name="sentry-trace"]').count()).toBe(0);

await page.locator('#navigate').click();
await expect(page.locator('#static-shell')).toHaveText('Static shell');

const [pageloadTx, navigationTx] = await Promise.all([pageloadTxPromise, navigationTxPromise]);

// The navigation ends the pageload like any pageload it interrupts, and runs to its own idle timeout.
expect(pageloadTx.contexts?.trace?.data?.['sentry.idle_span_finish_reason']).toBe('cancelled');
expect(navigationTx.contexts?.trace?.data?.['sentry.idle_span_finish_reason']).toBe('idleTimeout');
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { cacheLife } from 'next/cache';
import { headers } from 'next/headers';
import { Suspense } from 'react';

// The shell goes stale after a second, so a later visit makes the server prerender it again at
// runtime. An `expire` below 5 minutes would make Next.js leave this out of the shell.
async function RenderedAt() {
'use cache';
cacheLife({ revalidate: 1, expire: 3600 });
return <p id="rendered-at">{Date.now()}</p>;
}

async function RequestContent() {
await headers();
return <p id="request-content">Rendered for this request</p>;
}

export default function Page() {
return (
<>
<RenderedAt />
<Suspense fallback={<p>Loading...</p>}>
<RequestContent />
</Suspense>
</>
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@
"devDependencies": {
"@playwright/test": "1.64.1",
"@sentry-internal/test-utils": "file:../../packed/sentry-internal-test-utils-packed.tgz",
"@sentry/conventions": "0.27.0",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
Expand Down
Loading
Loading