Skip to content
Merged
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
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
export const revalidate = 60; // ISR: revalidate every 60 seconds
export const dynamicParams = true; // Allow dynamic params beyond generateStaticParams

export async function generateStaticParams(): Promise<Array<{ product: string }>> {
return [{ product: 'laptop' }, { product: 'phone' }, { product: 'tablet' }];
}

export default async function ISRProductPage({ params }: { params: Promise<{ product: string }> }) {
const { product } = await params;

return (
<div>
<h1>ISR Product: {product}</h1>
<div id="isr-product-id">{product}</div>
</div>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
export const revalidate = 60; // ISR: revalidate every 60 seconds
export const dynamicParams = true;

export async function generateStaticParams(): Promise<never[]> {
return [];
}

export default function ISRStaticPage() {
return (
<div>
<h1>ISR Static Page</h1>
<div id="isr-static-marker">static-isr</div>
</div>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
// No generateStaticParams - this is NOT an ISR page
export default async function NonISRPage({ params }: { params: Promise<{ item: string }> }) {
const { item } = await params;

return (
<div>
<h1>Non-ISR Dynamic Page: {item}</h1>
<div id="non-isr-item-id">{item}</div>
</div>
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,11 @@ import * as Sentry from '@sentry/nextjs';

Sentry.init({
environment: 'qa', // dynamic sampling bias to keep transactions
dsn: process.env.NEXT_PUBLIC_E2E_TEST_DSN,
dsn: 'https://username@domain/123',
tunnel: `http://localhost:3031/`, // proxy server
tracesSampleRate: 1.0,
sendDefaultPii: true,
debug: true,
});

export const onRouterTransitionStart = Sentry.captureRouterTransitionStart;
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
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');

// Wait for page to be fully loaded
await expect(page.locator('#isr-product-id')).toHaveText('laptop');

// 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('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-static-marker')).toHaveText('static-isr');

// 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('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');

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
for (let i = 0; i < 5; i++) {
const transactionPromise = waitForTransaction('nextjs-15', async transactionEvent => {
return !!(
transactionEvent.transaction === '/isr-test/:product' && transactionEvent.contexts?.trace?.op === 'pageload'
);
});

if (i === 0) {
await page.goto('/isr-test/laptop');
} else {
await page.reload();
}

const transaction = await transactionPromise;
const traceId = transaction.contexts?.trace?.trace_id;

expect(traceId).toBeDefined();
expect(traceId).toMatch(/[a-f0-9]{32}/);
traceIds.push(traceId!);
}

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

test('ISR route should be identified correctly in the route manifest', async ({ page }) => {
const transactionPromise = waitForTransaction('nextjs-15', async transactionEvent => {
return transactionEvent.transaction === '/isr-test/:product' && transactionEvent.contexts?.trace?.op === 'pageload';
});

await page.goto('/isr-test/laptop');
const transaction = await transactionPromise;

// Verify the transaction is properly parameterized
expect(transaction).toMatchObject({
transaction: '/isr-test/:product',
transaction_info: { source: 'route' },
contexts: {
trace: {
data: {
'sentry.source': 'route',
},
},
},
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
export const revalidate = 60; // ISR: revalidate every 60 seconds
export const dynamicParams = true; // Allow dynamic params beyond generateStaticParams

export async function generateStaticParams(): Promise<Array<{ product: string }>> {
return [{ product: 'laptop' }, { product: 'phone' }, { product: 'tablet' }];
}

export default async function ISRProductPage({ params }: { params: Promise<{ product: string }> }) {
const { product } = await params;

return (
<div>
<h1>ISR Product: {product}</h1>
<div id="isr-product-id">{product}</div>
</div>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
export const revalidate = 60; // ISR: revalidate every 60 seconds
export const dynamicParams = true;

export async function generateStaticParams(): Promise<never[]> {
return [];
}

export default function ISRStaticPage() {
return (
<div>
<h1>ISR Static Page</h1>
<div id="isr-static-marker">static-isr</div>
</div>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
// No generateStaticParams - this is NOT an ISR page
export default async function NonISRPage({ params }: { params: Promise<{ item: string }> }) {
const { item } = await params;

return (
<div>
<h1>Non-ISR Dynamic Page: {item}</h1>
<div id="non-isr-item-id">{item}</div>
</div>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
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');

// Wait for page to be fully loaded
await expect(page.locator('#isr-product-id')).toHaveText('laptop');

// 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('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-static-marker')).toHaveText('static-isr');

// 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('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');

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
for (let i = 0; i < 5; i++) {
const transactionPromise = waitForTransaction('nextjs-16', async transactionEvent => {
return !!(
transactionEvent.transaction === '/isr-test/:product' && transactionEvent.contexts?.trace?.op === 'pageload'
);
});

if (i === 0) {
await page.goto('/isr-test/laptop');
} else {
await page.reload();
}

const transaction = await transactionPromise;
const traceId = transaction.contexts?.trace?.trace_id;

expect(traceId).toBeDefined();
expect(traceId).toMatch(/[a-f0-9]{32}/);
traceIds.push(traceId!);
}

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

test('ISR route should be identified correctly in the route manifest', async ({ page }) => {
const transactionPromise = waitForTransaction('nextjs-16', async transactionEvent => {
return transactionEvent.transaction === '/isr-test/:product' && transactionEvent.contexts?.trace?.op === 'pageload';
});

await page.goto('/isr-test/laptop');
const transaction = await transactionPromise;

// Verify the transaction is properly parameterized
expect(transaction).toMatchObject({
transaction: '/isr-test/:product',
transaction_info: { source: 'route' },
contexts: {
trace: {
data: {
'sentry.source': 'route',
},
},
},
});
});
7 changes: 7 additions & 0 deletions packages/nextjs/src/client/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { isRedirectNavigationError } from '../common/nextNavigationErrorUtils';
import { browserTracingIntegration } from './browserTracingIntegration';
import { nextjsClientStackFrameNormalizationIntegration } from './clientNormalizationIntegration';
import { INCOMPLETE_APP_ROUTER_INSTRUMENTATION_TRANSACTION_NAME } from './routing/appRouterRoutingInstrumentation';
import { removeIsrSsgTraceMetaTags } from './routing/isrRoutingTracing';
import { applyTunnelRouteOption } from './tunnelRoute';

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

// Remove cached trace meta tags for ISR/SSG pages before initializing
// This prevents the browser tracing integration from using stale trace IDs
if (typeof __SENTRY_TRACING__ === 'undefined' || __SENTRY_TRACING__) {
removeIsrSsgTraceMetaTags();
}

const opts = {
environment: getVercelEnv(true) || process.env.NODE_ENV,
defaultIntegrations: getDefaultIntegrations(options),
Expand Down
61 changes: 61 additions & 0 deletions packages/nextjs/src/client/routing/isrRoutingTracing.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import { WINDOW } from '@sentry/react';
import { getManifest, maybeParameterizeRoute } from './parameterization';

/**
* Cache for ISR/SSG route checks. Exported for testing purposes.
* @internal
*/
export const IS_ISR_SSG_ROUTE_CACHE = new Map<string, boolean>();
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This should be an LRUMap

Copy link
Collaborator Author

@logaretm logaretm Nov 17, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh, I didn't see that 🤦‍♂️ I will amend in a PR.


/**
* Check if the current page is an ISR/SSG route by checking the route manifest.
* @internal Exported for testing purposes.
*/
export function isIsrSsgRoute(pathname: string): boolean {
// Early parameterization to get the cache key
const parameterizedPath = maybeParameterizeRoute(pathname);
const pathToCheck = parameterizedPath || pathname;

// Check cache using the parameterized path as the key
if (IS_ISR_SSG_ROUTE_CACHE.has(pathToCheck)) {
return IS_ISR_SSG_ROUTE_CACHE.get(pathToCheck) as boolean;
}

// Cache miss get the manifest
const manifest = getManifest();
if (!manifest?.isrRoutes || !Array.isArray(manifest.isrRoutes) || manifest.isrRoutes.length === 0) {
IS_ISR_SSG_ROUTE_CACHE.set(pathToCheck, false);
return false;
}

const isIsrSsgRoute = manifest.isrRoutes.includes(pathToCheck);
IS_ISR_SSG_ROUTE_CACHE.set(pathToCheck, isIsrSsgRoute);

return isIsrSsgRoute;
}

/**
* Remove sentry-trace and baggage meta tags from the DOM if this is an ISR/SSG page.
* This prevents the browser tracing integration from using stale/cached trace IDs.
*/
export function removeIsrSsgTraceMetaTags(): void {
if (!WINDOW.document || !isIsrSsgRoute(WINDOW.location.pathname)) {
return;
}

// Helper function to remove a meta tag
function removeMetaTag(metaName: string): void {
try {
const meta = WINDOW.document.querySelector(`meta[name="${metaName}"]`);
if (meta) {
meta.remove();
}
} catch {
// ignore errors when removing the meta tag
}
}

// Remove the meta tags so browserTracingIntegration won't pick them up
removeMetaTag('sentry-trace');
removeMetaTag('baggage');
}
3 changes: 2 additions & 1 deletion packages/nextjs/src/client/routing/parameterization.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,7 @@ function getCompiledRegex(regexString: string): RegExp | null {
* Get and cache the route manifest from the global object.
* @returns The parsed route manifest or null if not available/invalid.
*/
function getManifest(): RouteManifest | null {
export function getManifest(): RouteManifest | null {
if (
!globalWithInjectedManifest?._sentryRouteManifest ||
typeof globalWithInjectedManifest._sentryRouteManifest !== 'string'
Expand All @@ -96,6 +96,7 @@ function getManifest(): RouteManifest | null {
let manifest: RouteManifest = {
staticRoutes: [],
dynamicRoutes: [],
isrRoutes: [],
};

// Shallow check if the manifest is actually what we expect it to be
Expand Down
Loading
Loading