Skip to content
Merged
Show file tree
Hide file tree
Changes from 9 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',
},
},
},
});
});
5 changes: 5 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,10 @@ 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
removeIsrSsgTraceMetaTags();
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 gated behind the if (typeof __SENTRY_TRACING__ === 'undefined' || __SENTRY_TRACING__) { flag like adding the browserTracingIntegration is.


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

const globalWithInjectedValues = WINDOW as typeof WINDOW & {
_sentryRouteManifest: string | RouteManifest;
};

/**
* Check if the current page is an ISR/SSG route by checking the route manifest.
*/
function isIsrSsgRoute(pathname: 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.

Do you think we should cache the results of this function? Esp as it's calling a JSON.parse of the route manifest?

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.

I think that's a good idea since the manifest shouldn't change in runtime 👍 I can see we already use getManifest which caches the JSON parsing. I will create another cache as well for the route matcher rather than the actual pathname since it may get a lot of entries.

const manifestData = globalWithInjectedValues._sentryRouteManifest;
if (!manifestData) {
return false;
}

let manifest: RouteManifest;
if (typeof manifestData === 'string') {
try {
manifest = JSON.parse(manifestData);
} catch {
return false;
}
} else {
manifest = manifestData;
}

if (!manifest.isrRoutes || !Array.isArray(manifest.isrRoutes) || manifest.isrRoutes.length === 0) {
return false;
}

const parameterizedPath = maybeParameterizeRoute(pathname);
const pathToCheck = parameterizedPath || pathname;

return manifest.isrRoutes.includes(pathToCheck);
}

/**
* 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');
}
1 change: 1 addition & 0 deletions packages/nextjs/src/client/routing/parameterization.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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