Skip to content

Commit 1679d80

Browse files
authored
fix(nextjs): Drop meta trace tags if rendered page is ISR (#18192)
**Summary** ISR pages will have a `sentry-trace` and `baggage` meta tags rendered on them following the initial render or after the first invalidation causing a cached trace id to be present until the next invalidation. This happens in Next.js 15/16 and both on Turbopack and Webpack. **What I tried and didn't work** I Found no way to conditionally set/unset/change the values set by the `clientTraceMetadata` option, I found nothing useful on unit async storages, nor re-setting the propagation context works. The `clientTraceMetadata` gets called way earlier at the `app-render.tsx` level, which would call our `SentryPropagator.inject()` then. We cannot intercept it either because it runs before the page wrapper is called. The main issue is _timing_: - Suppressing the tracing wouldn't work either because it is too late. Ideally we want a way to tell Next to remove those attributes at runtime, or render them conditionally. - I also tried setting everything that has to do with `sentry-trace` or baggage to dummy values as some sort of "marker" for the SDK on the browser side to drop them, but again it is too late since `clientTraceMetadata` is picked up too early. **Implementation** so I figured a workaround, I decided to do it on the client side by: - Marking ISR page routes via the route manifest we already have. - In `Sentry.init` call we remove the tags before the browser integration has had a chance to grab the meta tags. Not the cleanest way, but I verified the issue by writing tests for it and observing page loads across multiple page visits having the same trace id. The meta deletion forces them to have new id for every visit which is what we want.
1 parent 4ad4d92 commit 1679d80

File tree

32 files changed

+1154
-10
lines changed

32 files changed

+1154
-10
lines changed
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
export const revalidate = 60; // ISR: revalidate every 60 seconds
2+
export const dynamicParams = true; // Allow dynamic params beyond generateStaticParams
3+
4+
export async function generateStaticParams(): Promise<Array<{ product: string }>> {
5+
return [{ product: 'laptop' }, { product: 'phone' }, { product: 'tablet' }];
6+
}
7+
8+
export default async function ISRProductPage({ params }: { params: Promise<{ product: string }> }) {
9+
const { product } = await params;
10+
11+
return (
12+
<div>
13+
<h1>ISR Product: {product}</h1>
14+
<div id="isr-product-id">{product}</div>
15+
</div>
16+
);
17+
}
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
export const revalidate = 60; // ISR: revalidate every 60 seconds
2+
export const dynamicParams = true;
3+
4+
export async function generateStaticParams(): Promise<never[]> {
5+
return [];
6+
}
7+
8+
export default function ISRStaticPage() {
9+
return (
10+
<div>
11+
<h1>ISR Static Page</h1>
12+
<div id="isr-static-marker">static-isr</div>
13+
</div>
14+
);
15+
}
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
// No generateStaticParams - this is NOT an ISR page
2+
export default async function NonISRPage({ params }: { params: Promise<{ item: string }> }) {
3+
const { item } = await params;
4+
5+
return (
6+
<div>
7+
<h1>Non-ISR Dynamic Page: {item}</h1>
8+
<div id="non-isr-item-id">{item}</div>
9+
</div>
10+
);
11+
}

dev-packages/e2e-tests/test-applications/nextjs-15/instrumentation-client.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,11 @@ import * as Sentry from '@sentry/nextjs';
22

33
Sentry.init({
44
environment: 'qa', // dynamic sampling bias to keep transactions
5-
dsn: process.env.NEXT_PUBLIC_E2E_TEST_DSN,
5+
dsn: 'https://username@domain/123',
66
tunnel: `http://localhost:3031/`, // proxy server
77
tracesSampleRate: 1.0,
88
sendDefaultPii: true,
9+
debug: true,
910
});
1011

1112
export const onRouterTransitionStart = Sentry.captureRouterTransitionStart;
Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
import { expect, test } from '@playwright/test';
2+
import { waitForTransaction } from '@sentry-internal/test-utils';
3+
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');
7+
8+
// Wait for page to be fully loaded
9+
await expect(page.locator('#isr-product-id')).toHaveText('laptop');
10+
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+
});
15+
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');
19+
20+
// Wait for page to be fully loaded
21+
await expect(page.locator('#isr-static-marker')).toHaveText('static-isr');
22+
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+
});
27+
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');
32+
33+
await expect(page.locator('meta[name="sentry-trace"]')).toHaveCount(0);
34+
await expect(page.locator('meta[name="baggage"]')).toHaveCount(0);
35+
36+
// Test with 'tablet'
37+
await page.goto('/isr-test/tablet');
38+
await expect(page.locator('#isr-product-id')).toHaveText('tablet');
39+
40+
await expect(page.locator('meta[name="sentry-trace"]')).toHaveCount(0);
41+
await expect(page.locator('meta[name="baggage"]')).toHaveCount(0);
42+
});
43+
44+
test('should create unique transactions for ISR pages on each visit', async ({ page }) => {
45+
const traceIds: string[] = [];
46+
47+
// Load the same ISR page 5 times to ensure cached HTML meta tags are consistently removed
48+
for (let i = 0; i < 5; i++) {
49+
const transactionPromise = waitForTransaction('nextjs-15', async transactionEvent => {
50+
return !!(
51+
transactionEvent.transaction === '/isr-test/:product' && transactionEvent.contexts?.trace?.op === 'pageload'
52+
);
53+
});
54+
55+
if (i === 0) {
56+
await page.goto('/isr-test/laptop');
57+
} else {
58+
await page.reload();
59+
}
60+
61+
const transaction = await transactionPromise;
62+
const traceId = transaction.contexts?.trace?.trace_id;
63+
64+
expect(traceId).toBeDefined();
65+
expect(traceId).toMatch(/[a-f0-9]{32}/);
66+
traceIds.push(traceId!);
67+
}
68+
69+
// Verify all 5 page loads have unique trace IDs (no reuse of cached/stale meta tags)
70+
const uniqueTraceIds = new Set(traceIds);
71+
expect(uniqueTraceIds.size).toBe(5);
72+
});
73+
74+
test('ISR route should be identified correctly in the route manifest', async ({ page }) => {
75+
const transactionPromise = waitForTransaction('nextjs-15', async transactionEvent => {
76+
return transactionEvent.transaction === '/isr-test/:product' && transactionEvent.contexts?.trace?.op === 'pageload';
77+
});
78+
79+
await page.goto('/isr-test/laptop');
80+
const transaction = await transactionPromise;
81+
82+
// Verify the transaction is properly parameterized
83+
expect(transaction).toMatchObject({
84+
transaction: '/isr-test/:product',
85+
transaction_info: { source: 'route' },
86+
contexts: {
87+
trace: {
88+
data: {
89+
'sentry.source': 'route',
90+
},
91+
},
92+
},
93+
});
94+
});
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
export const revalidate = 60; // ISR: revalidate every 60 seconds
2+
export const dynamicParams = true; // Allow dynamic params beyond generateStaticParams
3+
4+
export async function generateStaticParams(): Promise<Array<{ product: string }>> {
5+
return [{ product: 'laptop' }, { product: 'phone' }, { product: 'tablet' }];
6+
}
7+
8+
export default async function ISRProductPage({ params }: { params: Promise<{ product: string }> }) {
9+
const { product } = await params;
10+
11+
return (
12+
<div>
13+
<h1>ISR Product: {product}</h1>
14+
<div id="isr-product-id">{product}</div>
15+
</div>
16+
);
17+
}
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
export const revalidate = 60; // ISR: revalidate every 60 seconds
2+
export const dynamicParams = true;
3+
4+
export async function generateStaticParams(): Promise<never[]> {
5+
return [];
6+
}
7+
8+
export default function ISRStaticPage() {
9+
return (
10+
<div>
11+
<h1>ISR Static Page</h1>
12+
<div id="isr-static-marker">static-isr</div>
13+
</div>
14+
);
15+
}
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
// No generateStaticParams - this is NOT an ISR page
2+
export default async function NonISRPage({ params }: { params: Promise<{ item: string }> }) {
3+
const { item } = await params;
4+
5+
return (
6+
<div>
7+
<h1>Non-ISR Dynamic Page: {item}</h1>
8+
<div id="non-isr-item-id">{item}</div>
9+
</div>
10+
);
11+
}
Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
import { expect, test } from '@playwright/test';
2+
import { waitForTransaction } from '@sentry-internal/test-utils';
3+
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');
7+
8+
// Wait for page to be fully loaded
9+
await expect(page.locator('#isr-product-id')).toHaveText('laptop');
10+
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+
});
15+
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');
19+
20+
// Wait for page to be fully loaded
21+
await expect(page.locator('#isr-static-marker')).toHaveText('static-isr');
22+
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+
});
27+
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');
32+
33+
await expect(page.locator('meta[name="sentry-trace"]')).toHaveCount(0);
34+
await expect(page.locator('meta[name="baggage"]')).toHaveCount(0);
35+
36+
// Test with 'tablet'
37+
await page.goto('/isr-test/tablet');
38+
await expect(page.locator('#isr-product-id')).toHaveText('tablet');
39+
40+
await expect(page.locator('meta[name="sentry-trace"]')).toHaveCount(0);
41+
await expect(page.locator('meta[name="baggage"]')).toHaveCount(0);
42+
});
43+
44+
test('should create unique transactions for ISR pages on each visit', async ({ page }) => {
45+
const traceIds: string[] = [];
46+
47+
// Load the same ISR page 5 times to ensure cached HTML meta tags are consistently removed
48+
for (let i = 0; i < 5; i++) {
49+
const transactionPromise = waitForTransaction('nextjs-16', async transactionEvent => {
50+
return !!(
51+
transactionEvent.transaction === '/isr-test/:product' && transactionEvent.contexts?.trace?.op === 'pageload'
52+
);
53+
});
54+
55+
if (i === 0) {
56+
await page.goto('/isr-test/laptop');
57+
} else {
58+
await page.reload();
59+
}
60+
61+
const transaction = await transactionPromise;
62+
const traceId = transaction.contexts?.trace?.trace_id;
63+
64+
expect(traceId).toBeDefined();
65+
expect(traceId).toMatch(/[a-f0-9]{32}/);
66+
traceIds.push(traceId!);
67+
}
68+
69+
// Verify all 5 page loads have unique trace IDs (no reuse of cached/stale meta tags)
70+
const uniqueTraceIds = new Set(traceIds);
71+
expect(uniqueTraceIds.size).toBe(5);
72+
});
73+
74+
test('ISR route should be identified correctly in the route manifest', async ({ page }) => {
75+
const transactionPromise = waitForTransaction('nextjs-16', async transactionEvent => {
76+
return transactionEvent.transaction === '/isr-test/:product' && transactionEvent.contexts?.trace?.op === 'pageload';
77+
});
78+
79+
await page.goto('/isr-test/laptop');
80+
const transaction = await transactionPromise;
81+
82+
// Verify the transaction is properly parameterized
83+
expect(transaction).toMatchObject({
84+
transaction: '/isr-test/:product',
85+
transaction_info: { source: 'route' },
86+
contexts: {
87+
trace: {
88+
data: {
89+
'sentry.source': 'route',
90+
},
91+
},
92+
},
93+
});
94+
});

packages/nextjs/src/client/index.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { isRedirectNavigationError } from '../common/nextNavigationErrorUtils';
88
import { browserTracingIntegration } from './browserTracingIntegration';
99
import { nextjsClientStackFrameNormalizationIntegration } from './clientNormalizationIntegration';
1010
import { INCOMPLETE_APP_ROUTER_INSTRUMENTATION_TRANSACTION_NAME } from './routing/appRouterRoutingInstrumentation';
11+
import { removeIsrSsgTraceMetaTags } from './routing/isrRoutingTracing';
1112
import { applyTunnelRouteOption } from './tunnelRoute';
1213

1314
export * from '@sentry/react';
@@ -41,6 +42,12 @@ export function init(options: BrowserOptions): Client | undefined {
4142
}
4243
clientIsInitialized = true;
4344

45+
// Remove cached trace meta tags for ISR/SSG pages before initializing
46+
// This prevents the browser tracing integration from using stale trace IDs
47+
if (typeof __SENTRY_TRACING__ === 'undefined' || __SENTRY_TRACING__) {
48+
removeIsrSsgTraceMetaTags();
49+
}
50+
4451
const opts = {
4552
environment: getVercelEnv(true) || process.env.NODE_ENV,
4653
defaultIntegrations: getDefaultIntegrations(options),

0 commit comments

Comments
 (0)