Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
1b068ed
feat(nextjs): Never emit trace meta tags from a prerender
chargome Oct 7, 2026
9394a8b
test(nextjs): Skip the ISR meta tag assertions in every dev variant
chargome Oct 7, 2026
edf0d98
fix(nextjs): Key the prerender trace guard on the OpenTelemetry context
chargome Oct 8, 2026
408c7cc
fix(nextjs): Also recognize a prerender span that is activated by its…
chargome Oct 8, 2026
5b173c4
fix(nextjs): Apply the prerender trace guard to `withSentry` on Cloud…
chargome Oct 9, 2026
fe3af83
test(nextjs): Cover an ISR page that is prerendered again at runtime
chargome Oct 9, 2026
dd8445b
docs(nextjs): Shorten the changelog entry
chargome Oct 12, 2026
0ab39be
docs(nextjs): Reword the changelog entry
chargome Oct 12, 2026
2c8bb46
docs(nextjs): Write the changelog entry in the Important Changes format
chargome Oct 12, 2026
dd54af4
ref(nextjs): Split the prerender context marking out of the propagato…
chargome Oct 12, 2026
a2aeaf5
test(nextjs): Skip the prerendered ISR tests from one describe block
chargome Oct 12, 2026
d4fcbc3
docs(nextjs): Drop the internals wording from the changelog entry
chargome Oct 12, 2026
c0c0993
feat(nextjs): Export the prerender guard for custom OpenTelemetry setups
chargome Oct 12, 2026
89d0ecc
docs(nextjs): Simplify the changelog entry
chargome Oct 12, 2026
f0e687f
test(nextjs): Skip the prerendered ISR assertions on vinext
chargome Oct 12, 2026
6651191
docs(nextjs): Keep the changelog entry under Unreleased after the rebase
chargome Oct 13, 2026
bf8dec5
feat(nextjs): Wait for the resumed part of a document before starting…
chargome Oct 7, 2026
b05fd2b
fix(nextjs): Record the document URL on a pageload that starts after …
chargome Oct 8, 2026
33ff1b5
ref(nextjs): Pass the client to the pageload URL filter and use conve…
chargome Oct 8, 2026
2511028
docs(nextjs): Correct what a navigation during the pageload wait does
chargome Oct 9, 2026
f86d6f6
docs(nextjs): Mention the deferred pageload start in the changelog entry
chargome Oct 12, 2026
c248cc5
feat(nextjs): Only wait for the trace meta tag when cacheComponents i…
chargome Oct 12, 2026
c41a798
ref(nextjs): Move the app router pageload instrumentation into its ow…
chargome Oct 13, 2026
11b4573
feat(nextjs): Mark pageloads that were served from a cached shell
chargome Oct 13, 2026
5d2d542
fix(nextjs): Mark a cached shell at pageload span start
chargome Oct 13, 2026
26177fa
ref(nextjs): Name the cached document attribute and marker tag framew…
chargome Oct 13, 2026
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
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,14 @@

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

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

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.

- **feat(nextjs): Mark pageloads that were served from a cached shell**

A pageload whose document was served from a cache, for example an ISR page or a `cacheComponents` page generated on first visit, now carries the `document.cached` attribute and a `cache_origin` span link to the request that prerendered the page. 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.

## 11.7.0

### Important Changes
Expand Down
Original file line number Diff line number Diff line change
@@ -1,50 +1,57 @@
import { expect, test } from '@playwright/test';
import { getSpanOp, waitForStreamedSpan } from '@sentry-internal/test-utils';

test('should remove sentry-trace and baggage meta tags on ISR dynamic route page load', async ({ page }) => {
// Navigate to ISR page
await page.goto('/isr-test/laptop');
const isProductionBuild = process.env.TEST_ENV === 'production' || process.env.TEST_ENV === 'prod-turbopack';

// Wait for page to be fully loaded
await expect(page.locator('#isr-product-id')).toHaveText('laptop');
test.describe('prerendered ISR pages', () => {
// The dev server renders ISR pages for every request, so there the trace tags are fresh and kept.
test.skip(!isProductionBuild, 'ISR pages are only prerendered in production builds');

// Check that sentry-trace and baggage meta tags are removed for ISR pages
await expect(page.locator('meta[name="sentry-trace"]')).toHaveCount(0);
await expect(page.locator('meta[name="baggage"]')).toHaveCount(0);
});
test('does not carry trace meta tags on an ISR dynamic route', async ({ page }) => {
// Navigate to ISR page
await page.goto('/isr-test/laptop');

test('should remove sentry-trace and baggage meta tags on ISR static route', async ({ page }) => {
// Navigate to ISR static page
await page.goto('/isr-test/static');
// Wait for page to be fully loaded
await expect(page.locator('#isr-product-id')).toHaveText('laptop');

// Wait for page to be fully loaded
await expect(page.locator('#isr-static-marker')).toHaveText('static-isr');
// The shell was prerendered, and a prerender writes no trace tags
await expect(page.locator('meta[name="sentry-trace"]')).toHaveCount(0);
await expect(page.locator('meta[name="baggage"]')).toHaveCount(0);
});

// Check that sentry-trace and baggage meta tags are removed for ISR pages
await expect(page.locator('meta[name="sentry-trace"]')).toHaveCount(0);
await expect(page.locator('meta[name="baggage"]')).toHaveCount(0);
});
test('does not carry trace meta tags on an ISR static route', async ({ page }) => {
// Navigate to ISR static page
await page.goto('/isr-test/static');

test('should remove meta tags for different ISR dynamic route values', async ({ page }) => {
// Test with 'phone' (one of the pre-generated static params)
await page.goto('/isr-test/phone');
await expect(page.locator('#isr-product-id')).toHaveText('phone');
// Wait for page to be fully loaded
await expect(page.locator('#isr-static-marker')).toHaveText('static-isr');

await expect(page.locator('meta[name="sentry-trace"]')).toHaveCount(0);
await expect(page.locator('meta[name="baggage"]')).toHaveCount(0);
// The shell was prerendered, and a prerender writes no trace tags
await expect(page.locator('meta[name="sentry-trace"]')).toHaveCount(0);
await expect(page.locator('meta[name="baggage"]')).toHaveCount(0);
});

test('does not carry trace meta tags for different ISR dynamic route values', async ({ page }) => {
// Test with 'phone' (one of the pre-generated static params)
await page.goto('/isr-test/phone');
await expect(page.locator('#isr-product-id')).toHaveText('phone');

// Test with 'tablet'
await page.goto('/isr-test/tablet');
await expect(page.locator('#isr-product-id')).toHaveText('tablet');
await expect(page.locator('meta[name="sentry-trace"]')).toHaveCount(0);
await expect(page.locator('meta[name="baggage"]')).toHaveCount(0);

await expect(page.locator('meta[name="sentry-trace"]')).toHaveCount(0);
await expect(page.locator('meta[name="baggage"]')).toHaveCount(0);
// Test with 'tablet'
await page.goto('/isr-test/tablet');
await expect(page.locator('#isr-product-id')).toHaveText('tablet');

await expect(page.locator('meta[name="sentry-trace"]')).toHaveCount(0);
await expect(page.locator('meta[name="baggage"]')).toHaveCount(0);
});
});

test('should create unique traces for ISR pages on each visit', async ({ page }) => {
const traceIds: string[] = [];

// Load the same ISR page 5 times to ensure cached HTML meta tags are consistently removed
// Load the same ISR page 5 times: the cached HTML carries no trace tag, so every visit starts its own trace
for (let i = 0; i < 5; i++) {
const spanPromise = waitForStreamedSpan('nextjs-15', span => {
return span.name === '/isr-test/:product' && getSpanOp(span) === 'pageload' && span.is_segment;
Expand All @@ -64,7 +71,7 @@ test('should create unique traces for ISR pages on each visit', async ({ page })
traceIds.push(traceId!);
}

// Verify all 5 page loads have unique trace IDs (no reuse of cached/stale meta tags)
// Verify all 5 page loads have unique trace IDs (no trace replayed from the cached HTML)
const uniqueTraceIds = new Set(traceIds);
expect(uniqueTraceIds.size).toBe(5);
});
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { headers } from 'next/headers';

// Opts out of the static shell: the whole page renders on the server for every request, so the
// trace meta tags land in <head> like in a classic dynamic render.
export const instant = false;

export default async function Page() {
const requestHeaders = await headers();
return <h1 id="blocking">Blocking render for {requestHeaders.get('user-agent') ? 'a browser' : 'nobody'}</h1>;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
// No request data anywhere, so the whole page is a static shell and nothing renders per request.
export default function Page() {
return <h1 id="static-shell">Static shell</h1>;
}
Original file line number Diff line number Diff line change
Expand Up @@ -81,38 +81,6 @@ test('Should generate metadata async', async ({ page }) => {
await expect(page).toHaveTitle('Product: 1');
});

test('Prerendered shell does not stitch the pageload onto a stale trace', async ({ page }) => {
const serverTxPromise = waitForTransaction('nextjs-16-cacheComponents', async transactionEvent => {
return (
transactionEvent.contexts?.trace?.op === 'http.server' && transactionEvent.transaction === 'GET /pageload-tracing'
);
});

const pageloadTxPromise = waitForTransaction('nextjs-16-cacheComponents', async transactionEvent => {
return transactionEvent.contexts?.trace?.op === 'pageload' && transactionEvent.transaction === '/pageload-tracing';
});

await page.goto('/pageload-tracing');

await expect(page.locator('#todos-fetched')).toHaveText('Todos fetched: 5');

const [serverTx, pageloadTx] = await Promise.all([serverTxPromise, pageloadTxPromise]);

const serverTraceId = serverTx.contexts?.trace?.trace_id;
const pageloadTraceId = pageloadTx.contexts?.trace?.trace_id;

// Under Cache Components the shell is prerendered and rendered in a context detached from the
// runtime server request, so a `sentry-trace` meta tag would carry a stale/unrelated trace. The
// SDK therefore does not enable the trace meta tags, and the browser pageload starts a fresh trace
// instead of stitching onto a trace that doesn't match the server request.
expect(pageloadTraceId).toBeTruthy();
expect(serverTraceId).not.toBe(pageloadTraceId);

// No trace meta tags should be injected when Cache Components is enabled.
expect(await page.locator('meta[name="sentry-trace"]').count()).toBe(0);
expect(await page.locator('meta[name="baggage"]').count()).toBe(0);
});

test('Should prerender a page that captures an exception in generateMetadata', async ({ page }) => {
await page.goto('/capture-metadata');

Expand Down
Original file line number Diff line number Diff line change
@@ -1,20 +1,25 @@
import type { Page } from '@playwright/test';
import { expect, test } from '@playwright/test';
import { waitForTransaction } from '@sentry-internal/test-utils';

// TODO: Remove the inverse assertion in cacheComponents.spec.ts ("Prerendered shell does not stitch the pageload onto a stale trace") when this test passes.
test('connects the pageload trace to the runtime server request', async ({ page }) => {
test.fail();
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. A shell prerendered at runtime carries a `sentry-prerender`
// marker instead, which marks the pageload as served from a cached shell 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()`)
// runs on the server on every request and produces the `GET /pageload-tracing` transaction.
// A fully prerendered page would produce no server transaction.
const serverTxPromise = waitForTransaction('nextjs-16-cacheComponents', transactionEvent => {
const serverTxPromise = waitForTransaction(APP, transactionEvent => {
return (
transactionEvent.contexts?.trace?.op === 'http.server' && transactionEvent.transaction === 'GET /pageload-tracing'
);
});

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

Expand All @@ -27,3 +32,98 @@ 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);
});

test('connects the pageload trace to a blocking render', async ({ page }) => {
const serverTxPromise = waitForTransaction(APP, transactionEvent => {
return transactionEvent.contexts?.trace?.op === 'http.server' && transactionEvent.transaction === 'GET /blocking';
});

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

await page.goto('/blocking');

await expect(page.locator('#blocking')).toHaveText('Blocking render for a browser');

const [serverTx, pageloadTx] = await Promise.all([serverTxPromise, pageloadTxPromise]);

// The whole page renders per request, so the tag in <head> is this request's and is continued.
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('does not share a trace between visitors of a shell generated at request time', async ({ browser }) => {
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, only a `sentry-prerender` marker with its own
// trace, so every visitor starts a fresh trace that links the prerender and is marked as a cached shell.
const id = `shell-${Date.now()}`;

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

const pageloadTxPromise = waitForTransaction(APP, transactionEvent => {
return (
transactionEvent.contexts?.trace?.op === 'pageload' &&
transactionEvent.contexts?.trace?.data?.['url.path'] === `/${id}`
);
});

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-prerender"]').count()).toBe(1);

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

return {
traceId: pageloadTx.contexts?.trace?.trace_id,
links: pageloadTx.contexts?.trace?.links,
documentCached: pageloadTx.contexts?.trace?.data?.['document.cached'],
};
};

const first = await visit();
const second = await visit();

expect(first.traceId).toBeTruthy();
expect(second.traceId).toBeTruthy();
expect(second.traceId).not.toBe(first.traceId);
expect(first.documentCached).toBe(true);
expect(second.documentCached).toBe(true);

// Both link the one request that prerendered the shell, which is neither visitor's own trace.
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('starts a fresh trace on a fully static page', async ({ page }) => {
test.skip(process.env.TEST_ENV !== 'production', 'Pages are only prerendered in production builds');

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

await page.goto('/static-shell');

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

const pageloadTx = await pageloadTxPromise;

// 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-prerender"]').count()).toBe(0);
expect(pageloadTx.contexts?.trace?.trace_id).toBeTruthy();
expect(pageloadTx.contexts?.trace?.links).toBeUndefined();
expect(pageloadTx.contexts?.trace?.data?.['document.cached']).toBeUndefined();
});
Original file line number Diff line number Diff line change
@@ -1,50 +1,57 @@
import { expect, test } from '@playwright/test';
import { waitForTransaction } from '@sentry-internal/test-utils';

test('should remove sentry-trace and baggage meta tags on ISR dynamic route page load', async ({ page }) => {
// Navigate to ISR page
await page.goto('/isr-test/laptop');
const isProductionBuild = process.env.TEST_ENV === 'production' || process.env.TEST_ENV === 'prod-turbopack';

// Wait for page to be fully loaded
await expect(page.locator('#isr-product-id')).toHaveText('laptop');
test.describe('prerendered ISR pages', () => {
// The dev server renders ISR pages for every request, so there the trace tags are fresh and kept.
test.skip(!isProductionBuild, 'ISR pages are only prerendered in production builds');

// Check that sentry-trace and baggage meta tags are removed for ISR pages
await expect(page.locator('meta[name="sentry-trace"]')).toHaveCount(0);
await expect(page.locator('meta[name="baggage"]')).toHaveCount(0);
});
test('does not carry trace meta tags on an ISR dynamic route', async ({ page }) => {
// Navigate to ISR page
await page.goto('/isr-test/laptop');

test('should remove sentry-trace and baggage meta tags on ISR static route', async ({ page }) => {
// Navigate to ISR static page
await page.goto('/isr-test/static');
// Wait for page to be fully loaded
await expect(page.locator('#isr-product-id')).toHaveText('laptop');

// Wait for page to be fully loaded
await expect(page.locator('#isr-static-marker')).toHaveText('static-isr');
// The shell was prerendered, and a prerender writes no trace tags
await expect(page.locator('meta[name="sentry-trace"]')).toHaveCount(0);
await expect(page.locator('meta[name="baggage"]')).toHaveCount(0);
});

// Check that sentry-trace and baggage meta tags are removed for ISR pages
await expect(page.locator('meta[name="sentry-trace"]')).toHaveCount(0);
await expect(page.locator('meta[name="baggage"]')).toHaveCount(0);
});
test('does not carry trace meta tags on an ISR static route', async ({ page }) => {
// Navigate to ISR static page
await page.goto('/isr-test/static');

test('should remove meta tags for different ISR dynamic route values', async ({ page }) => {
// Test with 'phone' (one of the pre-generated static params)
await page.goto('/isr-test/phone');
await expect(page.locator('#isr-product-id')).toHaveText('phone');
// Wait for page to be fully loaded
await expect(page.locator('#isr-static-marker')).toHaveText('static-isr');

await expect(page.locator('meta[name="sentry-trace"]')).toHaveCount(0);
await expect(page.locator('meta[name="baggage"]')).toHaveCount(0);
// The shell was prerendered, and a prerender writes no trace tags
await expect(page.locator('meta[name="sentry-trace"]')).toHaveCount(0);
await expect(page.locator('meta[name="baggage"]')).toHaveCount(0);
});

test('does not carry trace meta tags for different ISR dynamic route values', async ({ page }) => {
// Test with 'phone' (one of the pre-generated static params)
await page.goto('/isr-test/phone');
await expect(page.locator('#isr-product-id')).toHaveText('phone');

// Test with 'tablet'
await page.goto('/isr-test/tablet');
await expect(page.locator('#isr-product-id')).toHaveText('tablet');
await expect(page.locator('meta[name="sentry-trace"]')).toHaveCount(0);
await expect(page.locator('meta[name="baggage"]')).toHaveCount(0);

await expect(page.locator('meta[name="sentry-trace"]')).toHaveCount(0);
await expect(page.locator('meta[name="baggage"]')).toHaveCount(0);
// Test with 'tablet'
await page.goto('/isr-test/tablet');
await expect(page.locator('#isr-product-id')).toHaveText('tablet');

await expect(page.locator('meta[name="sentry-trace"]')).toHaveCount(0);
await expect(page.locator('meta[name="baggage"]')).toHaveCount(0);
});
});

test('should create unique transactions for ISR pages on each visit', async ({ page }) => {
const traceIds: string[] = [];

// Load the same ISR page 5 times to ensure cached HTML meta tags are consistently removed
// Load the same ISR page 5 times: the cached HTML carries no trace tag, so every visit starts its own trace
for (let i = 0; i < 5; i++) {
const transactionPromise = waitForTransaction('nextjs-16-static', async transactionEvent => {
return !!(
Expand All @@ -66,7 +73,7 @@ test('should create unique transactions for ISR pages on each visit', async ({ p
traceIds.push(traceId!);
}

// Verify all 5 page loads have unique trace IDs (no reuse of cached/stale meta tags)
// Verify all 5 page loads have unique trace IDs (no trace replayed from the cached HTML)
const uniqueTraceIds = new Set(traceIds);
expect(uniqueTraceIds.size).toBe(5);
});
Expand Down
Loading
Loading