Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 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
16399b6
Merge remote-tracking branch 'origin/develop' into chargome/feat/next…
s1gr1d Oct 20, 2026
766d5b8
docs(nextjs): Mention the prerender guard for custom OpenTelemetry se…
s1gr1d Oct 20, 2026
9a21c6f
docs(nextjs): Address only setups with `SentryPropagator` in the chan…
s1gr1d Oct 20, 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
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,12 @@
});
```

- **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.

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.

Work in this release was contributed by @adityareddy-dev, @jpdefo, @Jxxunnn, @philipatkinson, @Shubham-Padkonde, @mikamikasuki, and @mketiku. Thank you for your contributions!

- **feat(deno): Add incoming request options to `denoServeIntegration`**
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');
Comment thread
chargome marked this conversation as resolved.

// 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
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,9 +1,10 @@
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();
// The resumed part of the document, which carries the tag, arrives after the SDK started. Enabled
// once the pageload instrumentation waits for it.
test.skip(true, 'Needs the deferred pageload start');
Comment thread
chargome marked this conversation as resolved.

// 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.
Expand Down
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
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { expect, test } from '@playwright/test';
import { getSpanOp, waitForError, waitForStreamedSpan, waitForStreamedSpans } from '@sentry-internal/test-utils';
import { getSpanOp, waitForError, waitForStreamedSpans } from '@sentry-internal/test-utils';

test('Should render cached component', async ({ page }) => {
const spansPromise = waitForStreamedSpans('nextjs-16-streaming-cacheComponents', spans => {
Expand Down Expand Up @@ -93,38 +93,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 serverSpanPromise = waitForStreamedSpan('nextjs-16-streaming-cacheComponents', span => {
return span.name === 'GET /pageload-tracing' && getSpanOp(span) === 'http.server' && span.is_segment;
});

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

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

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

const [serverSpan, pageloadSpan] = await Promise.all([serverSpanPromise, pageloadSpanPromise]);

expect(pageloadSpan.attributes).toMatchObject({
['sentry.segment.name.source']: { value: 'route', type: 'string' },
['url.path']: { value: '/pageload-tracing', type: 'string' },
['url.template']: { value: '/pageload-tracing', type: 'string' },
});
// Under Cache Components the can be 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(pageloadSpan.trace_id).toBeTruthy();
expect(serverSpan.trace_id).not.toBe(pageloadSpan.trace_id);

// 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,9 +1,10 @@
import { expect, test } from '@playwright/test';
import { getSpanOp, waitForStreamedSpan } 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();
// The resumed part of the document, which carries the tag, arrives after the SDK started. Enabled
// once the pageload instrumentation waits for it.
test.skip(true, 'Needs the deferred pageload start');

// 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` root span.
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
// A short revalidation window, so a test can make the server prerender this page again at runtime.
export const revalidate = 1;

export default function ISRRuntimePage() {
return (
<div>
<h1>ISR Runtime Page</h1>
<div id="isr-runtime-rendered-at">{Date.now()}</div>
</div>
);
}
Loading
Loading