Skip to content

Commit 2ad0528

Browse files
chargomeclaudes1gr1d
authored
feat(nextjs): Never emit trace meta tags from a prerender (#25141)
Under `cacheComponents` the SDK did not enable `experimental.clientTraceMetadata` (#21141). A shell that Next.js prerenders during one request is cached and served to later visitors, and a `sentry-trace` tag inside it would attach all of them to that one request. ISR pages had the same problem, worked around on the client by removing the tags on routes with `generateStaticParams` (#18192). Next.js computes the trace tags of a document inside the `prerender route (app)` span when it prerenders, and outside of it when it renders for a request, including when it resumes a shell. The server SDK now marks that span on the OpenTelemetry context, and its propagator hands out nothing on a marked context. A prerendered shell never contains a trace, so any tag that reaches the browser belongs to the current request. The mark sits on the context rather than the span because an unsampled request makes the span non-recording, with no attributes to recognize it by, and a `sampled=0` tag in the shell would drop every later pageload. Checked in Next.js 15.0 through 16.4. With that, `clientTraceMetadata` is enabled again under `cacheComponents`, and the client-side ISR workaround and the `isrRoutes` manifest field are removed. The guard covers ISR shells too, including `revalidate` routes the file scan never found. The guard is applied to the tracer provider and propagator the Node SDK registers, and `withSentry` of `@sentry/nextjs/cloudflare` applies it to its own setup, since ISR pages revalidate inside a request of the Worker. A custom OpenTelemetry setup does not get it automatically. If it registers `SentryPropagator`, it can register the exported `NextSentryPropagator` instead and pass its tracer provider to `markPrerendersOnTracerProvider`. Setups with only the OpenTelemetry W3C propagators, which includes `@vercel/otel` by default, write no `sentry-trace` tag, so nothing changes for them. The docs follow in sentry-docs. An ISR page with `revalidate = 1` makes the e2e suite exercise the guard at runtime; the other ISR shells come from the build, where the SDK does not run. On its own this PR keeps the current client behaviour: tags in `<head>` are continued and are now always fresh. The tag of a resumed shell arrives with the resumed part, which the client only reads in #25142. <img width="2240" height="1332" alt="figure-shell" src="https://github.com/user-attachments/assets/809726dd-cddb-4fab-bfee-83bcdc35dbab" /> Refs #24944, see #24298 --------- Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com> Co-authored-by: s1gr1d <32902192+s1gr1d@users.noreply.github.com>
1 parent 62a794d commit 2ad0528

46 files changed

Lines changed: 571 additions & 1077 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎CHANGELOG.md‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,12 @@
3131
});
3232
```
3333

34+
- **feat(nextjs): Never emit trace meta tags from a prerender ([#25141](https://github.com/getsentry/sentry-javascript/pull/25141))**
35+
36+
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.
37+
38+
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.
39+
3440
Work in this release was contributed by @adityareddy-dev, @jpdefo, @Jxxunnn, @philipatkinson, @Shubham-Padkonde, @mikamikasuki, and @mketiku. Thank you for your contributions!
3541

3642
- **feat(deno): Add incoming request options to `denoServeIntegration`**

‎dev-packages/e2e-tests/test-applications/nextjs-15/tests/isr-routes.test.ts‎

Lines changed: 38 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -1,50 +1,57 @@
11
import { expect, test } from '@playwright/test';
22
import { getSpanOp, waitForStreamedSpan } from '@sentry-internal/test-utils';
33

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

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

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

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

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

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

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

33-
await expect(page.locator('meta[name="sentry-trace"]')).toHaveCount(0);
34-
await expect(page.locator('meta[name="baggage"]')).toHaveCount(0);
29+
// The shell was prerendered, and a prerender writes no trace tags
30+
await expect(page.locator('meta[name="sentry-trace"]')).toHaveCount(0);
31+
await expect(page.locator('meta[name="baggage"]')).toHaveCount(0);
32+
});
33+
34+
test('does not carry trace meta tags for different ISR dynamic route values', async ({ page }) => {
35+
// Test with 'phone' (one of the pre-generated static params)
36+
await page.goto('/isr-test/phone');
37+
await expect(page.locator('#isr-product-id')).toHaveText('phone');
3538

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

40-
await expect(page.locator('meta[name="sentry-trace"]')).toHaveCount(0);
41-
await expect(page.locator('meta[name="baggage"]')).toHaveCount(0);
42+
// Test with 'tablet'
43+
await page.goto('/isr-test/tablet');
44+
await expect(page.locator('#isr-product-id')).toHaveText('tablet');
45+
46+
await expect(page.locator('meta[name="sentry-trace"]')).toHaveCount(0);
47+
await expect(page.locator('meta[name="baggage"]')).toHaveCount(0);
48+
});
4249
});
4350

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

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

67-
// Verify all 5 page loads have unique trace IDs (no reuse of cached/stale meta tags)
74+
// Verify all 5 page loads have unique trace IDs (no trace replayed from the cached HTML)
6875
const uniqueTraceIds = new Set(traceIds);
6976
expect(uniqueTraceIds.size).toBe(5);
7077
});

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

Lines changed: 0 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -81,38 +81,6 @@ test('Should generate metadata async', async ({ page }) => {
8181
await expect(page).toHaveTitle('Product: 1');
8282
});
8383

84-
test('Prerendered shell does not stitch the pageload onto a stale trace', async ({ page }) => {
85-
const serverTxPromise = waitForTransaction('nextjs-16-cacheComponents', async transactionEvent => {
86-
return (
87-
transactionEvent.contexts?.trace?.op === 'http.server' && transactionEvent.transaction === 'GET /pageload-tracing'
88-
);
89-
});
90-
91-
const pageloadTxPromise = waitForTransaction('nextjs-16-cacheComponents', async transactionEvent => {
92-
return transactionEvent.contexts?.trace?.op === 'pageload' && transactionEvent.transaction === '/pageload-tracing';
93-
});
94-
95-
await page.goto('/pageload-tracing');
96-
97-
await expect(page.locator('#todos-fetched')).toHaveText('Todos fetched: 5');
98-
99-
const [serverTx, pageloadTx] = await Promise.all([serverTxPromise, pageloadTxPromise]);
100-
101-
const serverTraceId = serverTx.contexts?.trace?.trace_id;
102-
const pageloadTraceId = pageloadTx.contexts?.trace?.trace_id;
103-
104-
// Under Cache Components the shell is prerendered and rendered in a context detached from the
105-
// runtime server request, so a `sentry-trace` meta tag would carry a stale/unrelated trace. The
106-
// SDK therefore does not enable the trace meta tags, and the browser pageload starts a fresh trace
107-
// instead of stitching onto a trace that doesn't match the server request.
108-
expect(pageloadTraceId).toBeTruthy();
109-
expect(serverTraceId).not.toBe(pageloadTraceId);
110-
111-
// No trace meta tags should be injected when Cache Components is enabled.
112-
expect(await page.locator('meta[name="sentry-trace"]').count()).toBe(0);
113-
expect(await page.locator('meta[name="baggage"]').count()).toBe(0);
114-
});
115-
11684
test('Should prerender a page that captures an exception in generateMetadata', async ({ page }) => {
11785
await page.goto('/capture-metadata');
11886

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

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
11
import { expect, test } from '@playwright/test';
22
import { waitForTransaction } from '@sentry-internal/test-utils';
33

4-
// TODO: Remove the inverse assertion in cacheComponents.spec.ts ("Prerendered shell does not stitch the pageload onto a stale trace") when this test passes.
54
test('connects the pageload trace to the runtime server request', async ({ page }) => {
6-
test.fail();
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');
78

89
// The shell is prerendered, but the page's dynamic part (`DynamicContent` awaits `headers()`)
910
// runs on the server on every request and produces the `GET /pageload-tracing` transaction.

‎dev-packages/e2e-tests/test-applications/nextjs-16-static/tests/isr-routes.test.ts‎

Lines changed: 38 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -1,50 +1,57 @@
11
import { expect, test } from '@playwright/test';
22
import { waitForTransaction } from '@sentry-internal/test-utils';
33

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

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

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

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

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

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

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

33-
await expect(page.locator('meta[name="sentry-trace"]')).toHaveCount(0);
34-
await expect(page.locator('meta[name="baggage"]')).toHaveCount(0);
29+
// The shell was prerendered, and a prerender writes no trace tags
30+
await expect(page.locator('meta[name="sentry-trace"]')).toHaveCount(0);
31+
await expect(page.locator('meta[name="baggage"]')).toHaveCount(0);
32+
});
33+
34+
test('does not carry trace meta tags for different ISR dynamic route values', async ({ page }) => {
35+
// Test with 'phone' (one of the pre-generated static params)
36+
await page.goto('/isr-test/phone');
37+
await expect(page.locator('#isr-product-id')).toHaveText('phone');
3538

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

40-
await expect(page.locator('meta[name="sentry-trace"]')).toHaveCount(0);
41-
await expect(page.locator('meta[name="baggage"]')).toHaveCount(0);
42+
// Test with 'tablet'
43+
await page.goto('/isr-test/tablet');
44+
await expect(page.locator('#isr-product-id')).toHaveText('tablet');
45+
46+
await expect(page.locator('meta[name="sentry-trace"]')).toHaveCount(0);
47+
await expect(page.locator('meta[name="baggage"]')).toHaveCount(0);
48+
});
4249
});
4350

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

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

69-
// Verify all 5 page loads have unique trace IDs (no reuse of cached/stale meta tags)
76+
// Verify all 5 page loads have unique trace IDs (no trace replayed from the cached HTML)
7077
const uniqueTraceIds = new Set(traceIds);
7178
expect(uniqueTraceIds.size).toBe(5);
7279
});

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

Lines changed: 1 addition & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { expect, test } from '@playwright/test';
2-
import { getSpanOp, waitForError, waitForStreamedSpan, waitForStreamedSpans } from '@sentry-internal/test-utils';
2+
import { getSpanOp, waitForError, waitForStreamedSpans } from '@sentry-internal/test-utils';
33

44
test('Should render cached component', async ({ page }) => {
55
const spansPromise = waitForStreamedSpans('nextjs-16-streaming-cacheComponents', spans => {
@@ -93,38 +93,6 @@ test('Should generate metadata async', async ({ page }) => {
9393
await expect(page).toHaveTitle('Product: 1');
9494
});
9595

96-
test('Prerendered shell does not stitch the pageload onto a stale trace', async ({ page }) => {
97-
const serverSpanPromise = waitForStreamedSpan('nextjs-16-streaming-cacheComponents', span => {
98-
return span.name === 'GET /pageload-tracing' && getSpanOp(span) === 'http.server' && span.is_segment;
99-
});
100-
101-
const pageloadSpanPromise = waitForStreamedSpan('nextjs-16-streaming-cacheComponents', span => {
102-
return span.name === '/pageload-tracing' && getSpanOp(span) === 'pageload' && span.is_segment;
103-
});
104-
105-
await page.goto('/pageload-tracing');
106-
107-
await expect(page.locator('#todos-fetched')).toHaveText('Todos fetched: 5');
108-
109-
const [serverSpan, pageloadSpan] = await Promise.all([serverSpanPromise, pageloadSpanPromise]);
110-
111-
expect(pageloadSpan.attributes).toMatchObject({
112-
['sentry.segment.name.source']: { value: 'route', type: 'string' },
113-
['url.path']: { value: '/pageload-tracing', type: 'string' },
114-
['url.template']: { value: '/pageload-tracing', type: 'string' },
115-
});
116-
// Under Cache Components the can be prerendered and rendered in a context detached from the
117-
// runtime server request, so a `sentry-trace` meta tag would carry a stale/unrelated trace. The
118-
// SDK therefore does not enable the trace meta tags, and the browser pageload starts a fresh trace
119-
// instead of stitching onto a trace that doesn't match the server request.
120-
expect(pageloadSpan.trace_id).toBeTruthy();
121-
expect(serverSpan.trace_id).not.toBe(pageloadSpan.trace_id);
122-
123-
// No trace meta tags should be injected when Cache Components is enabled.
124-
expect(await page.locator('meta[name="sentry-trace"]').count()).toBe(0);
125-
expect(await page.locator('meta[name="baggage"]').count()).toBe(0);
126-
});
127-
12896
test('Should prerender a page that captures an exception in generateMetadata', async ({ page }) => {
12997
await page.goto('/capture-metadata');
13098

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

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
11
import { expect, test } from '@playwright/test';
22
import { getSpanOp, waitForStreamedSpan } from '@sentry-internal/test-utils';
33

4-
// TODO: Remove the inverse assertion in cacheComponents.spec.ts ("Prerendered shell does not stitch the pageload onto a stale trace") when this test passes.
54
test('connects the pageload trace to the runtime server request', async ({ page }) => {
6-
test.fail();
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');
78

89
// The shell is prerendered, but the page's dynamic part (`DynamicContent` awaits `headers()`)
910
// runs on the server on every request and produces the `GET /pageload-tracing` root span.
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
// A short revalidation window, so a test can make the server prerender this page again at runtime.
2+
export const revalidate = 1;
3+
4+
export default function ISRRuntimePage() {
5+
return (
6+
<div>
7+
<h1>ISR Runtime Page</h1>
8+
<div id="isr-runtime-rendered-at">{Date.now()}</div>
9+
</div>
10+
);
11+
}

0 commit comments

Comments
 (0)