diff --git a/CHANGELOG.md b/CHANGELOG.md index 0727144b61f9..34238db2d80c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,14 @@ - "You miss 100 percent of the chances you don't take. — Wayne Gretzky" — Michael Scott +- **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. The pageload waits for the trace information when it arrives with the part of the page that streams in after a cached shell. + +- **feat(nextjs): Mark pageloads that were served from a cached shell** + + A pageload whose document was served from a cache, for example an ISR page or a `cacheComponents` page generated on first visit, now carries the `document.cached` attribute and a `cache_origin` span link to the request that prerendered the page. That tells apart "nothing ran on the server for this visit" from missing server instrumentation. The link is never continued as a parent, so pageloads keep their own traces and sampling decisions. + ## 11.7.0 ### Important Changes diff --git a/dev-packages/e2e-tests/test-applications/nextjs-15/tests/isr-routes.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-15/tests/isr-routes.test.ts index 705b9208675e..8379d544a78e 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-15/tests/isr-routes.test.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-15/tests/isr-routes.test.ts @@ -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'); - // 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; @@ -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); }); diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/app/blocking/page.tsx b/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/app/blocking/page.tsx new file mode 100644 index 000000000000..babed4b8590c --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/app/blocking/page.tsx @@ -0,0 +1,10 @@ +import { headers } from 'next/headers'; + +// Opts out of the static shell: the whole page renders on the server for every request, so the +// trace meta tags land in like in a classic dynamic render. +export const instant = false; + +export default async function Page() { + const requestHeaders = await headers(); + return

Blocking render for {requestHeaders.get('user-agent') ? 'a browser' : 'nobody'}

; +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/app/static-shell/page.tsx b/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/app/static-shell/page.tsx new file mode 100644 index 000000000000..3ebb9903b2e1 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/app/static-shell/page.tsx @@ -0,0 +1,4 @@ +// No request data anywhere, so the whole page is a static shell and nothing renders per request. +export default function Page() { + return

Static shell

; +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/tests/cacheComponents.spec.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/tests/cacheComponents.spec.ts index 3cb2472c0927..29c90f5bc423 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/tests/cacheComponents.spec.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/tests/cacheComponents.spec.ts @@ -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'); diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/tests/cacheOrigin-pageloadConnection.spec.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/tests/cacheOrigin-pageloadConnection.spec.ts index c182106f5d10..00e62ac623db 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/tests/cacheOrigin-pageloadConnection.spec.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/tests/cacheOrigin-pageloadConnection.spec.ts @@ -1,20 +1,25 @@ +import type { Page } from '@playwright/test'; 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(); +const APP = 'nextjs-16-cacheComponents'; + +// The server only writes `sentry-trace` meta tags for a render that happened for the current request, +// never while it prerenders a shell. So a tag in the document can always be continued; the only +// question is when it arrives. The resumed part of a prerendered shell arrives after the SDK started, +// which the pageload instrumentation waits for. A shell prerendered at runtime carries a `sentry-prerender` +// marker instead, which marks the pageload as served from a cached shell and links the prerender. +test('connects the pageload trace to the runtime server request', async ({ page }) => { // 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. - // A fully prerendered page would produce no server transaction. - const serverTxPromise = waitForTransaction('nextjs-16-cacheComponents', transactionEvent => { + const serverTxPromise = waitForTransaction(APP, transactionEvent => { return ( transactionEvent.contexts?.trace?.op === 'http.server' && transactionEvent.transaction === 'GET /pageload-tracing' ); }); - const pageloadTxPromise = waitForTransaction('nextjs-16-cacheComponents', transactionEvent => { + const pageloadTxPromise = waitForTransaction(APP, transactionEvent => { return transactionEvent.contexts?.trace?.op === 'pageload' && transactionEvent.transaction === '/pageload-tracing'; }); @@ -27,3 +32,98 @@ test('connects the pageload trace to the runtime server request', async ({ page expect(serverTx.contexts?.trace?.trace_id).toBeTruthy(); expect(pageloadTx.contexts?.trace?.trace_id).toBe(serverTx.contexts?.trace?.trace_id); }); + +test('connects the pageload trace to a blocking render', async ({ page }) => { + const serverTxPromise = waitForTransaction(APP, transactionEvent => { + return transactionEvent.contexts?.trace?.op === 'http.server' && transactionEvent.transaction === 'GET /blocking'; + }); + + const pageloadTxPromise = waitForTransaction(APP, transactionEvent => { + return transactionEvent.contexts?.trace?.op === 'pageload' && transactionEvent.transaction === '/blocking'; + }); + + await page.goto('/blocking'); + + await expect(page.locator('#blocking')).toHaveText('Blocking render for a browser'); + + const [serverTx, pageloadTx] = await Promise.all([serverTxPromise, pageloadTxPromise]); + + // The whole page renders per request, so the tag in is this request's and is continued. + expect(serverTx.contexts?.trace?.trace_id).toBeTruthy(); + expect(pageloadTx.contexts?.trace?.trace_id).toBe(serverTx.contexts?.trace?.trace_id); + expect(pageloadTx.contexts?.trace?.links).toBeUndefined(); +}); + +test('does not share a trace between visitors of a shell generated at request time', async ({ browser }) => { + test.skip(process.env.TEST_ENV !== 'production', 'Shells are only cached in production builds'); + + // `[id]` only lists `test` in `generateStaticParams`, so the first visit prerenders and caches the + // shell for this id. The prerender writes no trace tag, only a `sentry-prerender` marker with its own + // trace, so every visitor starts a fresh trace that links the prerender and is marked as a cached shell. + const id = `shell-${Date.now()}`; + + const visit = async (): Promise<{ traceId: string | undefined; links: unknown; documentCached: unknown }> => { + const context = await browser.newContext(); + const page: Page = await context.newPage(); + + const pageloadTxPromise = waitForTransaction(APP, transactionEvent => { + return ( + transactionEvent.contexts?.trace?.op === 'pageload' && + transactionEvent.contexts?.trace?.data?.['url.path'] === `/${id}` + ); + }); + + await page.goto(`/${id}`); + await expect(page.locator('#result')).toHaveText(`Hello, ${id}!`); + expect(await page.locator('meta[name="sentry-trace"]').count()).toBe(0); + expect(await page.locator('meta[name="sentry-prerender"]').count()).toBe(1); + + const pageloadTx = await pageloadTxPromise; + await context.close(); + + return { + traceId: pageloadTx.contexts?.trace?.trace_id, + links: pageloadTx.contexts?.trace?.links, + documentCached: pageloadTx.contexts?.trace?.data?.['document.cached'], + }; + }; + + const first = await visit(); + const second = await visit(); + + expect(first.traceId).toBeTruthy(); + expect(second.traceId).toBeTruthy(); + expect(second.traceId).not.toBe(first.traceId); + expect(first.documentCached).toBe(true); + expect(second.documentCached).toBe(true); + + // Both link the one request that prerendered the shell, which is neither visitor's own trace. + const linkOf = (links: unknown): { trace_id: string; attributes?: Record } => { + const link = (links as Array<{ trace_id: string; attributes?: Record }>)[0]; + expect(link?.attributes?.['sentry.link.type']).toBe('cache_origin'); + return link!; + }; + expect(linkOf(first.links).trace_id).toBe(linkOf(second.links).trace_id); + expect(linkOf(second.links).trace_id).not.toBe(second.traceId); +}); + +test('starts a fresh trace on a fully static page', async ({ page }) => { + test.skip(process.env.TEST_ENV !== 'production', 'Pages are only prerendered in production builds'); + + const pageloadTxPromise = waitForTransaction(APP, transactionEvent => { + return transactionEvent.contexts?.trace?.op === 'pageload' && transactionEvent.transaction === '/static-shell'; + }); + + await page.goto('/static-shell'); + + await expect(page.locator('#static-shell')).toHaveText('Static shell'); + + const pageloadTx = await pageloadTxPromise; + + // A build-time shell carries no trace context and no marker, the SDK does not run during the build. + expect(await page.locator('meta[name="sentry-trace"]').count()).toBe(0); + expect(await page.locator('meta[name="sentry-prerender"]').count()).toBe(0); + expect(pageloadTx.contexts?.trace?.trace_id).toBeTruthy(); + expect(pageloadTx.contexts?.trace?.links).toBeUndefined(); + expect(pageloadTx.contexts?.trace?.data?.['document.cached']).toBeUndefined(); +}); diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-static/tests/isr-routes.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-static/tests/isr-routes.test.ts index 4fde597dfeaa..5d363110424d 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-16-static/tests/isr-routes.test.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-static/tests/isr-routes.test.ts @@ -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 !!( @@ -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); }); diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/app/blocking/page.tsx b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/app/blocking/page.tsx new file mode 100644 index 000000000000..babed4b8590c --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/app/blocking/page.tsx @@ -0,0 +1,10 @@ +import { headers } from 'next/headers'; + +// Opts out of the static shell: the whole page renders on the server for every request, so the +// trace meta tags land in like in a classic dynamic render. +export const instant = false; + +export default async function Page() { + const requestHeaders = await headers(); + return

Blocking render for {requestHeaders.get('user-agent') ? 'a browser' : 'nobody'}

; +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/app/static-shell/page.tsx b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/app/static-shell/page.tsx new file mode 100644 index 000000000000..3ebb9903b2e1 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/app/static-shell/page.tsx @@ -0,0 +1,4 @@ +// No request data anywhere, so the whole page is a static shell and nothing renders per request. +export default function Page() { + return

Static shell

; +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/tests/cacheComponents.spec.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/tests/cacheComponents.spec.ts index 49f2f49cb5b2..35a5c82080f5 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/tests/cacheComponents.spec.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/tests/cacheComponents.spec.ts @@ -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 => { @@ -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'); diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/tests/cacheOrigin-pageloadConnection.spec.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/tests/cacheOrigin-pageloadConnection.spec.ts index efc35fd92c3d..6bb500aaf613 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/tests/cacheOrigin-pageloadConnection.spec.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/tests/cacheOrigin-pageloadConnection.spec.ts @@ -1,18 +1,23 @@ +import type { Page } from '@playwright/test'; 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(); +const APP = 'nextjs-16-streaming-cacheComponents'; + +// The server only writes `sentry-trace` meta tags for a render that happened for the current request, +// never while it prerenders a shell. So a tag in the document can always be continued; the only +// question is when it arrives. The resumed part of a prerendered shell arrives after the SDK started, +// which the pageload instrumentation waits for. A shell prerendered at runtime carries a `sentry-prerender` +// marker instead, which marks the pageload as served from a cached shell and links the prerender. +test('connects the pageload trace to the runtime server request', async ({ page }) => { // 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. - // A fully prerendered page would produce no server span. - const serverSpanPromise = waitForStreamedSpan('nextjs-16-streaming-cacheComponents', span => { + const serverSpanPromise = waitForStreamedSpan(APP, span => { return span.name === 'GET /pageload-tracing' && getSpanOp(span) === 'http.server' && span.is_segment; }); - const pageloadSpanPromise = waitForStreamedSpan('nextjs-16-streaming-cacheComponents', span => { + const pageloadSpanPromise = waitForStreamedSpan(APP, span => { return span.name === '/pageload-tracing' && getSpanOp(span) === 'pageload' && span.is_segment; }); @@ -25,3 +30,95 @@ test('connects the pageload trace to the runtime server request', async ({ page expect(serverSpan.trace_id).toBeTruthy(); expect(pageloadSpan.trace_id).toBe(serverSpan.trace_id); }); + +test('connects the pageload trace to a blocking render', async ({ page }) => { + const serverSpanPromise = waitForStreamedSpan(APP, span => { + return span.name === 'GET /blocking' && getSpanOp(span) === 'http.server' && span.is_segment; + }); + + const pageloadSpanPromise = waitForStreamedSpan(APP, span => { + return span.name === '/blocking' && getSpanOp(span) === 'pageload' && span.is_segment; + }); + + await page.goto('/blocking'); + + await expect(page.locator('#blocking')).toHaveText('Blocking render for a browser'); + + const [serverSpan, pageloadSpan] = await Promise.all([serverSpanPromise, pageloadSpanPromise]); + + // The whole page renders per request, so the tag in is this request's and is continued. + expect(serverSpan.trace_id).toBeTruthy(); + expect(pageloadSpan.trace_id).toBe(serverSpan.trace_id); + expect(pageloadSpan.links).toBeUndefined(); +}); + +test('does not share a trace between visitors of a shell generated at request time', async ({ browser }) => { + test.skip(process.env.TEST_ENV !== 'production', 'Shells are only cached in production builds'); + + // `[id]` only lists `test` in `generateStaticParams`, so the first visit prerenders and caches the + // shell for this id. The prerender writes no trace tag, only a `sentry-prerender` marker with its own + // trace, so every visitor starts a fresh trace that links the prerender and is marked as a cached shell. + const id = `shell-${Date.now()}`; + + const visit = async (): Promise<{ traceId: string | undefined; links: unknown; documentCached: unknown }> => { + const context = await browser.newContext(); + const page: Page = await context.newPage(); + + const pageloadSpanPromise = waitForStreamedSpan(APP, span => { + return getSpanOp(span) === 'pageload' && span.is_segment && span.attributes['url.path']?.value === `/${id}`; + }); + + await page.goto(`/${id}`); + await expect(page.locator('#result')).toHaveText(`Hello, ${id}!`); + expect(await page.locator('meta[name="sentry-trace"]').count()).toBe(0); + expect(await page.locator('meta[name="sentry-prerender"]').count()).toBe(1); + + const pageloadSpan = await pageloadSpanPromise; + await context.close(); + + return { + traceId: pageloadSpan.trace_id, + links: pageloadSpan.links, + documentCached: pageloadSpan.attributes['document.cached']?.value, + }; + }; + + const first = await visit(); + const second = await visit(); + + expect(first.traceId).toBeTruthy(); + expect(second.traceId).toBeTruthy(); + expect(second.traceId).not.toBe(first.traceId); + expect(first.documentCached).toBe(true); + expect(second.documentCached).toBe(true); + + // Both link the one request that prerendered the shell, which is neither visitor's own trace. + const linkOf = (links: unknown): { trace_id: string; attributes?: Record } => { + const link = (links as Array<{ trace_id: string; attributes?: Record }>)[0]; + expect(link?.attributes?.['sentry.link.type']?.value).toBe('cache_origin'); + return link!; + }; + expect(linkOf(first.links).trace_id).toBe(linkOf(second.links).trace_id); + expect(linkOf(second.links).trace_id).not.toBe(second.traceId); +}); + +test('starts a fresh trace on a fully static page', async ({ page }) => { + test.skip(process.env.TEST_ENV !== 'production', 'Pages are only prerendered in production builds'); + + const pageloadSpanPromise = waitForStreamedSpan(APP, span => { + return span.name === '/static-shell' && getSpanOp(span) === 'pageload' && span.is_segment; + }); + + await page.goto('/static-shell'); + + await expect(page.locator('#static-shell')).toHaveText('Static shell'); + + const pageloadSpan = await pageloadSpanPromise; + + // A build-time shell carries no trace context and no marker, the SDK does not run during the build. + expect(await page.locator('meta[name="sentry-trace"]').count()).toBe(0); + expect(await page.locator('meta[name="sentry-prerender"]').count()).toBe(0); + expect(pageloadSpan.trace_id).toBeTruthy(); + expect(pageloadSpan.links).toBeUndefined(); + expect(pageloadSpan.attributes['document.cached']).toBeUndefined(); +}); diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16/app/isr-test/runtime/page.tsx b/dev-packages/e2e-tests/test-applications/nextjs-16/app/isr-test/runtime/page.tsx new file mode 100644 index 000000000000..097ccb54cc68 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16/app/isr-test/runtime/page.tsx @@ -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 ( +
+

ISR Runtime Page

+
{Date.now()}
+
+ ); +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16/tests/isr-routes.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-16/tests/isr-routes.test.ts index ce215ef457e9..5a210e8426c6 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-16/tests/isr-routes.test.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-16/tests/isr-routes.test.ts @@ -1,50 +1,83 @@ 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'); - - // Wait for page to be fully loaded - await expect(page.locator('#isr-product-id')).toHaveText('laptop'); +const isProductionBuild = process.env.TEST_ENV === 'production' || process.env.TEST_ENV === 'prod-turbopack'; + +test.describe('prerendered ISR pages', () => { + // The dev server and vinext render ISR pages for every request, so there the trace tags are fresh + // and kept. + test.skip( + !isProductionBuild || !!process.env.VINEXT, + 'ISR pages are only prerendered by a production build of Next.js', + ); + + test('does not carry trace meta tags on an ISR dynamic route', 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'); + + // 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 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'); - // 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 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('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); - 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'); - // 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('does not carry trace meta tags on an ISR page that was prerendered again at runtime', async ({ page }) => { + // The first visit serves the shell from the build. Once its revalidation window passed, a visit serves the stale + // shell and makes the server prerender the page again, inside that request, with the SDK running. The visits after + // that serve the shell from that prerender. + await page.goto('/isr-test/runtime'); + const renderedAtBuild = await page.locator('#isr-runtime-rendered-at').textContent(); + + await expect + .poll( + async () => { + await page.waitForTimeout(1500); + await page.goto('/isr-test/runtime'); + return page.locator('#isr-runtime-rendered-at').textContent(); + }, + { timeout: 30_000 }, + ) + .not.toBe(renderedAtBuild); + + 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-16', span => { return span.name === '/isr-test/:product' && getSpanOp(span) === 'pageload' && span.is_segment; @@ -64,7 +97,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); }); diff --git a/packages/nextjs/src/client/browserTracingIntegration.ts b/packages/nextjs/src/client/browserTracingIntegration.ts index cd957d1d62b5..b8872cf95a1d 100644 --- a/packages/nextjs/src/client/browserTracingIntegration.ts +++ b/packages/nextjs/src/client/browserTracingIntegration.ts @@ -1,5 +1,6 @@ import type { Integration } from '@sentry/core'; import { browserTracingIntegration as originalBrowserTracingIntegration, isBotUserAgent } from '@sentry/react'; +import { settlePendingPageloadWait } from './routing/appRouterPageloadInstrumentation'; import { nextRouterInstrumentNavigation, nextRouterInstrumentPageLoad } from './routing/nextRoutingInstrumentation'; /** @@ -41,6 +42,14 @@ export function browserTracingIntegration( nextRouterInstrumentNavigation(client); } + // Registered before the browser tracing integration's own handler, which creates the navigation + // span: a pageload that is still waiting for its trace meta tag has to exist by then, so the + // handler ends it like any pageload a navigation interrupts. Started later, the pageload would + // end the navigation span instead. + if (instrumentPageLoad) { + client.on('startNavigationSpan', settlePendingPageloadWait); + } + browserTracingIntegrationInstance.afterAllSetup(client); if (instrumentPageLoad) { diff --git a/packages/nextjs/src/client/index.ts b/packages/nextjs/src/client/index.ts index d5c7e11a1d06..9f1ed4e4f768 100644 --- a/packages/nextjs/src/client/index.ts +++ b/packages/nextjs/src/client/index.ts @@ -11,7 +11,6 @@ import { getClientVercelEnv } from '../common/getVercelEnv'; import { isRedirectNavigationError } from '../common/nextNavigationErrorUtils'; import { browserTracingIntegration } from './browserTracingIntegration'; import { nextjsClientStackFrameNormalizationIntegration } from './clientNormalizationIntegration'; -import { removeIsrSsgTraceMetaTags } from './routing/isrRoutingTracing'; import { createNextRouteProvider } from './routing/routeProvider'; import { applyTunnelRouteOption } from './tunnelRoute'; @@ -45,12 +44,6 @@ export function init(options: BrowserOptions): Client | undefined { }); } - // 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: options.environment || process.env.SENTRY_ENVIRONMENT || getClientVercelEnv() || process.env.NODE_ENV, defaultIntegrations: getDefaultIntegrations(options), diff --git a/packages/nextjs/src/client/routing/appRouterPageloadInstrumentation.ts b/packages/nextjs/src/client/routing/appRouterPageloadInstrumentation.ts new file mode 100644 index 000000000000..1849040426d4 --- /dev/null +++ b/packages/nextjs/src/client/routing/appRouterPageloadInstrumentation.ts @@ -0,0 +1,125 @@ +import { + SENTRY_OP, + SENTRY_ORIGIN, + SENTRY_SEGMENT_NAME_SOURCE, + URL_FULL, + URL_PATH, + URL_TEMPLATE, +} from '@sentry/conventions/attributes'; +import { PAGELOAD } from '@sentry/conventions/op'; +import type { Client, Span, StartSpanOptions } from '@sentry/core'; +import { + filterCollectedUrl, + getCurrentScope, + hasSpanStreamingEnabled, + PAGELOAD_SPAN_NAME_FALLBACK, +} from '@sentry/core'; +import { resolveCurrentRoute, startBrowserTracingPageLoadSpan, WINDOW } from '@sentry/react'; +import { stripTrailingSlash } from './parameterization'; +import type { TraceMetaTagValues, TraceMetaTagWait } from './traceMetaTags'; +import { + addTraceMetaTagLink, + cachedDocumentSpanOptions, + canWaitForTraceMetaTag, + readPrerenderMarker, + readTraceMetaTags, + waitForTraceMetaTag, +} from './traceMetaTags'; + +/** + * How long the pageload start may wait for the resumed part of a Cache Components document. The wait + * normally ends with `DOMContentLoaded`; the cap only matters for a response the server keeps open. + */ +const PAGELOAD_TRACE_META_TAG_TIMEOUT_MS = 10_000; + +let pendingPageloadWait: TraceMetaTagWait | undefined; + +/** + * Starts a pageload that is still waiting for its trace meta tag with a fresh trace. Called when a + * navigation starts, before the navigation span is created, so the navigation ends the pageload like + * any pageload it interrupts, and the pageload is still recorded. + */ +export function settlePendingPageloadWait(): void { + pendingPageloadWait?.giveUp(); +} + +/** + * Starts the pageload span with the request data of the document. The span may start after a + * navigation already changed the location, so the browser SDK would otherwise read the new one. + */ +function startPageloadSpan( + client: Client, + spanOptions: StartSpanOptions, + documentUrl: string, + traceMetaTags?: TraceMetaTagValues, +): Span | undefined { + const prerenderMarker = readPrerenderMarker(); + const prerenderOptions = prerenderMarker ? cachedDocumentSpanOptions(prerenderMarker) : undefined; + const span = startBrowserTracingPageLoadSpan( + client, + prerenderOptions + ? { + ...spanOptions, + attributes: { ...spanOptions.attributes, ...prerenderOptions.attributes }, + links: prerenderOptions.links, + } + : spanOptions, + traceMetaTags, + ); + const scope = getCurrentScope(); + const { normalizedRequest } = scope.getScopeData().sdkProcessingMetadata; + if (normalizedRequest) { + scope.setSDKProcessingMetadata({ normalizedRequest: { ...normalizedRequest, url: documentUrl } }); + } + return span; +} + +/** Instruments the Next.js app router for pageloads. */ +export function appRouterInstrumentPageLoad(client: Client): void { + const documentUrl = WINDOW.location.href; + const pathname = stripTrailingSlash(WINDOW.location.pathname); + const parameterizedPathname = resolveCurrentRoute(client); + const spanOptions: StartSpanOptions = { + // With span streaming, span names have to be low cardinality, so we can't fall back to the URL. + name: parameterizedPathname ?? (hasSpanStreamingEnabled(client) ? PAGELOAD_SPAN_NAME_FALLBACK : pathname), + // pageload should always start at timeOrigin (and needs to be in s, not ms) + attributes: { + [SENTRY_OP]: PAGELOAD, + [SENTRY_ORIGIN]: 'auto.pageload.nextjs.app_router_instrumentation', + [SENTRY_SEGMENT_NAME_SOURCE]: parameterizedPathname ? 'route' : 'url', + [URL_PATH]: WINDOW.location.pathname, + [URL_FULL]: filterCollectedUrl(documentUrl, client), + ...(parameterizedPathname && { [URL_TEMPLATE]: parameterizedPathname }), + }, + }; + + // A tag that is already in the document was rendered for this request and can be continued. A + // Cache Components document that resumes a prerendered shell gets its tag with the resumed part, + // which usually arrives after the SDK started. Waiting for it lets the pageload continue the server + // trace instead of linking it; the span's start is backdated to the time origin either way. + const traceMetaTags = readTraceMetaTags(); + if (traceMetaTags || !canWaitForTraceMetaTag()) { + startPageloadSpan(client, spanOptions, documentUrl, traceMetaTags); + return; + } + + pendingPageloadWait = waitForTraceMetaTag( + values => { + pendingPageloadWait = undefined; + startPageloadSpan(client, spanOptions, documentUrl, values); + }, + () => { + pendingPageloadWait = undefined; + const span = startPageloadSpan(client, spanOptions, documentUrl); + // Only reachable through the cap or a navigation while the document still streams: a tag that + // shows up after this can no longer be continued, but the server request can still be linked. + if (span) { + waitForTraceMetaTag( + values => span.isRecording() && addTraceMetaTagLink(span, values), + () => undefined, + ); + } + }, + PAGELOAD_TRACE_META_TAG_TIMEOUT_MS, + ); +} diff --git a/packages/nextjs/src/client/routing/appRouterRoutingInstrumentation.ts b/packages/nextjs/src/client/routing/appRouterRoutingInstrumentation.ts index 4b7e9b1c7ae5..8dd47257c3b6 100644 --- a/packages/nextjs/src/client/routing/appRouterRoutingInstrumentation.ts +++ b/packages/nextjs/src/client/routing/appRouterRoutingInstrumentation.ts @@ -4,13 +4,11 @@ import { hasSpanStreamingEnabled, isObjectLike, NAVIGATION_SPAN_NAME_FALLBACK, - PAGELOAD_SPAN_NAME_FALLBACK, filterCollectedUrl, timestampInSeconds, } from '@sentry/core'; import { startBrowserTracingNavigationSpan, - startBrowserTracingPageLoadSpan, WINDOW, getAbsoluteUrl, resolveCurrentRoute, @@ -25,7 +23,7 @@ import { URL_TEMPLATE, SENTRY_ORIGIN, } from '@sentry/conventions/attributes'; -import { NAVIGATION, PAGELOAD } from '@sentry/conventions/op'; +import { NAVIGATION } from '@sentry/conventions/op'; function setNavigationSpanUrlAttributes(span: Span, urlPath: string, urlOrPath: string): void { span.setAttributes({ @@ -95,23 +93,6 @@ let navigationRoutingMode: 'router-patch' | 'transition-start-hook' = 'router-pa const currentRouterPatchingNavigationSpanRef: NavigationSpanRef = { current: undefined }; -/** Instruments the Next.js app router for pageloads. */ -export function appRouterInstrumentPageLoad(client: Client): void { - const pathname = stripTrailingSlash(WINDOW.location.pathname); - const parameterizedPathname = resolveCurrentRoute(client); - startBrowserTracingPageLoadSpan(client, { - // With span streaming, span names have to be low cardinality, so we can't fall back to the URL. - name: parameterizedPathname ?? (hasSpanStreamingEnabled(client) ? PAGELOAD_SPAN_NAME_FALLBACK : pathname), - // pageload should always start at timeOrigin (and needs to be in s, not ms) - attributes: { - [SENTRY_OP]: PAGELOAD, - [SENTRY_ORIGIN]: 'auto.pageload.nextjs.app_router_instrumentation', - [SENTRY_SEGMENT_NAME_SOURCE]: parameterizedPathname ? 'route' : 'url', - ...(parameterizedPathname && { [URL_TEMPLATE]: parameterizedPathname }), - }, - }); -} - interface NavigationSpanRef { current: Span | undefined; } diff --git a/packages/nextjs/src/client/routing/isrRoutingTracing.ts b/packages/nextjs/src/client/routing/isrRoutingTracing.ts deleted file mode 100644 index de5308cbb7ef..000000000000 --- a/packages/nextjs/src/client/routing/isrRoutingTracing.ts +++ /dev/null @@ -1,63 +0,0 @@ -import { LRUMap } from '@sentry/core'; -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 LRUMap(100); - -/** - * 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 - const cachedResult = IS_ISR_SSG_ROUTE_CACHE.get(pathToCheck); - if (cachedResult !== undefined) { - return cachedResult; - } - - // 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'); -} diff --git a/packages/nextjs/src/client/routing/nextRoutingInstrumentation.ts b/packages/nextjs/src/client/routing/nextRoutingInstrumentation.ts index f9a31f3f0e79..e453ac6e6871 100644 --- a/packages/nextjs/src/client/routing/nextRoutingInstrumentation.ts +++ b/packages/nextjs/src/client/routing/nextRoutingInstrumentation.ts @@ -1,6 +1,7 @@ import type { Client } from '@sentry/core'; import { WINDOW } from '@sentry/react'; -import { appRouterInstrumentNavigation, appRouterInstrumentPageLoad } from './appRouterRoutingInstrumentation'; +import { appRouterInstrumentNavigation } from './appRouterRoutingInstrumentation'; +import { appRouterInstrumentPageLoad } from './appRouterPageloadInstrumentation'; import { pagesRouterInstrumentNavigation } from './pagesRouterNavigationInstrumentation'; import { pagesRouterInstrumentPageLoad } from './pagesRouterRoutingInstrumentation'; diff --git a/packages/nextjs/src/client/routing/parameterization.ts b/packages/nextjs/src/client/routing/parameterization.ts index 9b366d705637..619b6d7f8069 100644 --- a/packages/nextjs/src/client/routing/parameterization.ts +++ b/packages/nextjs/src/client/routing/parameterization.ts @@ -147,7 +147,6 @@ export function getManifest(): RouteManifest | null { let manifest: RouteManifest = { staticRoutes: [], dynamicRoutes: [], - isrRoutes: [], }; // Shallow check if the manifest is actually what we expect it to be diff --git a/packages/nextjs/src/client/routing/traceMetaTags.ts b/packages/nextjs/src/client/routing/traceMetaTags.ts new file mode 100644 index 000000000000..0cec03935c82 --- /dev/null +++ b/packages/nextjs/src/client/routing/traceMetaTags.ts @@ -0,0 +1,177 @@ +import { SENTRY_LINK_TYPE } from '@sentry/conventions/attributes'; +import type { Span, SpanLink, StartSpanOptions } from '@sentry/core'; +import { extractTraceparentData, GLOBAL_OBJ } from '@sentry/core'; +import { PRERENDER_META_TAG_NAME } from '../../common/traceMetaTags'; +import { WINDOW } from '@sentry/react'; + +// `@opentelemetry/api` `TraceFlags`, inlined because core does not export its copies. +const TRACE_FLAG_SAMPLED = 0x1; +const TRACE_FLAG_NONE = 0x0; + +export interface TraceMetaTagValues { + sentryTrace: string; + baggage: string | undefined; +} + +/** Set on a pageload whose document was served from a cache, so no server render ran for the request. */ +export const DOCUMENT_CACHED_ATTRIBUTE = 'document.cached'; + +function getDocument(): Document | undefined { + return WINDOW.document; +} + +const globalWithInjectedValues = GLOBAL_OBJ as typeof GLOBAL_OBJ & { _sentryCacheComponents?: string }; + +/** Whether the build enabled `cacheComponents`, so a document can resume a prerendered shell. */ +function hasCacheComponents(): boolean { + return (process.env._sentryCacheComponents ?? globalWithInjectedValues._sentryCacheComponents) === 'true'; +} + +function lastMetaContent(document: Document, name: string): string | undefined { + const tags = document.querySelectorAll(`meta[name="${name}"]`); + return tags[tags.length - 1]?.getAttribute('content') || undefined; +} + +/** + * Reads the `sentry-trace` and `baggage` meta tags of the document. The server only writes them for + * a render that happened for this request (see `nextSentryPropagator.ts`), so whichever tag is + * present may be continued. The last one wins: a document stitched from a shell and a resumed part + * carries the resumed part's tag last. + */ +export function readTraceMetaTags(): TraceMetaTagValues | undefined { + const document = getDocument(); + const sentryTrace = document && lastMetaContent(document, 'sentry-trace'); + return document && sentryTrace ? { sentryTrace, baggage: lastMetaContent(document, 'baggage') } : undefined; +} + +/** + * Whether the response carried the trace in a `Server-Timing` header. The generic browser tracing + * integration continues it when it starts the pageload, so there is nothing to wait for. + */ +export function hasServerTimingTraceContext(): boolean { + // The cast is required for the declaration build, which resolves `getEntriesByType('navigation')` + // to `PerformanceEntry[]` without `serverTiming`. + const navigation = WINDOW.performance?.getEntriesByType?.('navigation')[0] as PerformanceNavigationTiming | undefined; + return !!navigation?.serverTiming?.some(entry => entry.name === 'sentry-trace'); +} + +/** + * Whether the SDK can still expect a `sentry-trace` meta tag to arrive: the app has `cacheComponents` + * enabled, so a document can resume a prerendered shell and get its tag with the resumed part, the + * document is still streaming, and nothing else already carries the trace. Without `cacheComponents` + * a tag is either in `` from the start or never comes, so waiting would only hold the pageload + * back. + */ +export function canWaitForTraceMetaTag(): boolean { + return ( + hasCacheComponents() && + getDocument()?.readyState === 'loading' && + typeof MutationObserver !== 'undefined' && + !hasServerTimingTraceContext() + ); +} + +export interface TraceMetaTagWait { + /** Stops waiting and reports that no tag will be used, unless the wait already settled. */ + giveUp(): void; +} + +/** + * Waits for a `sentry-trace` meta tag to show up while the document is still streaming. The resumed + * part of a Cache Components page arrives after the SDK started whenever the dynamic hole takes + * longer than loading the SDK, which is the common case. + * + * Calls `onTag` once with the tag, or `onGiveUp` once when the document finished parsing without + * one, `timeoutMs` passed, or `giveUp()` was called, whichever comes first. Settles with `onGiveUp` + * right away when `canWaitForTraceMetaTag()` is false. + */ +export function waitForTraceMetaTag( + onTag: (values: TraceMetaTagValues) => void, + onGiveUp: () => void, + timeoutMs?: number, +): TraceMetaTagWait { + const document = getDocument(); + if (!document || !canWaitForTraceMetaTag()) { + onGiveUp(); + return { giveUp: () => undefined }; + } + + let settled = false; + const settle = (callback: () => void): void => { + if (settled) { + return; + } + settled = true; + clearTimeout(timeout); + observer.disconnect(); + document.removeEventListener('DOMContentLoaded', onDocumentParsed); + callback(); + }; + + const checkForTag = (): boolean => { + const values = readTraceMetaTags(); + if (values) { + settle(() => onTag(values)); + } + return !!values; + }; + + // The tag can sit in the last chunk, whose mutation records may not have been delivered yet. + const onDocumentParsed = (): void => { + if (!checkForTag()) { + settle(onGiveUp); + } + }; + + const observer = new MutationObserver(checkForTag); + const timeout = timeoutMs === undefined ? undefined : setTimeout(() => settle(onGiveUp), timeoutMs); + + observer.observe(document.documentElement, { childList: true, subtree: true }); + document.addEventListener('DOMContentLoaded', onDocumentParsed); + + return { giveUp: () => settle(onGiveUp) }; +} + +/** The span link to the span a `sentry-trace` value describes, if the value is one. */ +function traceMetaTagLink(sentryTrace: string, linkType?: string): SpanLink | undefined { + const traceparent = extractTraceparentData(sentryTrace); + if (!traceparent?.traceId || !traceparent.parentSpanId) { + return undefined; + } + return { + context: { + traceId: traceparent.traceId, + spanId: traceparent.parentSpanId, + traceFlags: traceparent.parentSampled ? TRACE_FLAG_SAMPLED : TRACE_FLAG_NONE, + }, + ...(linkType && { attributes: { [SENTRY_LINK_TYPE]: linkType } }), + }; +} + +/** + * Adds a span link to the span described by a `sentry-trace` value. Used when the tag arrives after + * the pageload span exists, which can only link the server request, not continue it. + */ +export function addTraceMetaTagLink(span: Span, { sentryTrace }: TraceMetaTagValues): void { + const link = traceMetaTagLink(sentryTrace); + if (link) { + span.addLink(link); + } +} + +/** Reads the marker the server writes into a document while it prerenders it, see `nextSentryPropagator.ts`. */ +export function readPrerenderMarker(): string | undefined { + const document = getDocument(); + return document && lastMetaContent(document, PRERENDER_META_TAG_NAME); +} + +/** + * The start options that mark a pageload whose document was served from a cache and link the request + * that prerendered it. Applied at span start, so `tracesSampler` and `ignoreSpans` see the attribute. + * The marker is never continued: the document is served to every later visitor, and the request behind + * it handled none of their visits. + */ +export function cachedDocumentSpanOptions(marker: string): Pick { + const link = traceMetaTagLink(marker, 'cache_origin'); + return { attributes: { [DOCUMENT_CACHED_ATTRIBUTE]: true }, ...(link && { links: [link] }) }; +} diff --git a/packages/nextjs/src/cloudflare/index.ts b/packages/nextjs/src/cloudflare/index.ts index c97b7298a143..9c43cff4b5a3 100644 --- a/packages/nextjs/src/cloudflare/index.ts +++ b/packages/nextjs/src/cloudflare/index.ts @@ -5,21 +5,19 @@ import type { CloudflareOptions } from '@sentry/cloudflare'; import { withSentry as withSentryCloudflare } from '@sentry/cloudflare'; import type { Integration } from '@sentry/core'; import { applySdkMetadata, extractTraceparentData, getRootSpan, GLOBAL_OBJ, spanToJSON } from '@sentry/core'; -import { - registerPrepareSpanScope, - SentryPropagator, - setOpenTelemetryContextAsyncContextStrategy, -} from '@sentry/opentelemetry'; +import { registerPrepareSpanScope, setOpenTelemetryContextAsyncContextStrategy } from '@sentry/opentelemetry'; import { ATTR_NEXT_SPAN_TYPE } from '../common/nextSpanAttributes'; import { isTunnelRouteSpan } from '../common/utils/dropMiddlewareTunnelRequests'; import { dropReactControlFlowErrorsEventProcessor } from '../common/utils/dropReactControlFlowErrors'; import { markAsyncContextOwnedByNextjsCloudflare } from '../common/utils/responseEnd'; +import { NextSentryPropagator } from '../server/nextSentryPropagator'; +import { getGlobalTracerProvider, markPrerendersOnTracerProvider } from '../server/prerenderContext'; import { addNextjsServerSpanHooks, NEXTJS_SERVER_IGNORE_SPANS } from '../server/serverSpanHooks'; import { nextjsUseCacheIntegration } from '../server/useCacheInstrumentation'; export * from '@sentry/cloudflare'; -class NextjsCloudflarePropagator extends SentryPropagator { +class NextjsCloudflarePropagator extends NextSentryPropagator { /** @inheritDoc */ public extract(ctx: Context, carrier: unknown, getter: TextMapGetter): Context { // Next.js extracts the request headers again from the root context when it misses its router server context, e.g. @@ -43,6 +41,9 @@ const nextjsIntegration = (): Integration => ({ name: 'Nextjs', setup: client => { if ((client.getOptions() as CloudflareOptions).enableOpenTelemetrySetup) { + // ISR pages revalidate inside a request of the Worker, so the prerender guard has to cover the tracer + // provider `@sentry/cloudflare` registered as well. + markPrerendersOnTracerProvider(getGlobalTracerProvider()); propagation.setGlobalPropagator(new NextjsCloudflarePropagator()); } client.getOptions().release ??= (GLOBAL_OBJ as { _sentryRelease?: string })._sentryRelease; diff --git a/packages/nextjs/src/common/traceMetaTags.ts b/packages/nextjs/src/common/traceMetaTags.ts new file mode 100644 index 000000000000..aab29a353393 --- /dev/null +++ b/packages/nextjs/src/common/traceMetaTags.ts @@ -0,0 +1,7 @@ +/** + * Name of the meta tag the server writes instead of `sentry-trace` while Next.js prerenders a document. + * Its content is the `sentry-trace` value of the prerender. A prerendered document is cached and served + * to later visitors, so the client never continues it, it only marks the pageload as served from a + * cached document and links the request that generated it. + */ +export const PRERENDER_META_TAG_NAME = 'sentry-prerender'; diff --git a/packages/nextjs/src/config/manifest/createRouteManifest.ts b/packages/nextjs/src/config/manifest/createRouteManifest.ts index bf1765bfce4f..33f91585b4ad 100644 --- a/packages/nextjs/src/config/manifest/createRouteManifest.ts +++ b/packages/nextjs/src/config/manifest/createRouteManifest.ts @@ -135,20 +135,6 @@ function hasOptionalPrefix(paramNames: string[], localeParamNames: string[]): bo return localeParamNames.includes(firstParam); } -/** - * Check if a page file exports generateStaticParams (ISR/SSG indicator) - */ -function checkForGenerateStaticParams(pageFilePath: string): boolean { - try { - const content = fs.readFileSync(pageFilePath, 'utf8'); - // check for generateStaticParams export - // the regex covers `export function generateStaticParams`, `export async function generateStaticParams`, `export const generateStaticParams` - return /export\s+(async\s+)?function\s+generateStaticParams|export\s+const\s+generateStaticParams/.test(content); - } catch { - return false; - } -} - function scanAppDirectory( dir: string, basePath: string = '', @@ -158,7 +144,6 @@ function scanAppDirectory( ): RouteManifest { const dynamicRoutes: RouteInfo[] = []; const staticRoutes: RouteInfo[] = []; - const isrRoutes: string[] = []; try { const entries = fs.readdirSync(dir, { withFileTypes: true }); @@ -169,14 +154,6 @@ function scanAppDirectory( const routePath = includeRouteGroups ? basePath || '/' : normalizeRouteGroupPath(basePath || '/'); const isDynamic = routePath.includes(':'); - // Check if this page has generateStaticParams (ISR/SSG indicator) - const pageFilePath = path.join(dir, pageFile.name); - const hasGenerateStaticParams = checkForGenerateStaticParams(pageFilePath); - - if (hasGenerateStaticParams) { - isrRoutes.push(routePath); - } - if (isDynamic) { const { regex, paramNames, hasOptionalPrefix } = buildRegexForDynamicRoute(routePath, localeParamNames); dynamicRoutes.push({ @@ -217,14 +194,13 @@ function scanAppDirectory( dynamicRoutes.push(...subRoutes.dynamicRoutes); staticRoutes.push(...subRoutes.staticRoutes); - isrRoutes.push(...subRoutes.isrRoutes); } } } catch (error) { getBuildLogger(silent).warn('[@sentry/nextjs] Error building route manifest:', error); } - return { dynamicRoutes, staticRoutes, isrRoutes }; + return { dynamicRoutes, staticRoutes }; } /** @@ -249,7 +225,6 @@ export function createRouteManifest(options?: CreateRouteManifestOptions): Route if (!targetDir) { return { - isrRoutes: [], dynamicRoutes: [], staticRoutes: [], }; @@ -267,7 +242,7 @@ export function createRouteManifest(options?: CreateRouteManifestOptions): Route return manifestCache; } - const { dynamicRoutes, staticRoutes, isrRoutes } = scanAppDirectory( + const { dynamicRoutes, staticRoutes } = scanAppDirectory( targetDir, options?.basePath, options?.includeRouteGroups, @@ -278,7 +253,6 @@ export function createRouteManifest(options?: CreateRouteManifestOptions): Route const manifest: RouteManifest = { dynamicRoutes, staticRoutes, - isrRoutes, }; // set cache diff --git a/packages/nextjs/src/config/manifest/types.ts b/packages/nextjs/src/config/manifest/types.ts index 99fc42fb27d7..0a0946be70f7 100644 --- a/packages/nextjs/src/config/manifest/types.ts +++ b/packages/nextjs/src/config/manifest/types.ts @@ -34,9 +34,4 @@ export type RouteManifest = { * List of all static routes */ staticRoutes: RouteInfo[]; - - /** - * List of ISR/SSG routes (routes with generateStaticParams) - */ - isrRoutes: string[]; }; diff --git a/packages/nextjs/src/config/withSentryConfig/buildTime.ts b/packages/nextjs/src/config/withSentryConfig/buildTime.ts index b93d9ea2fc9c..0338807eb239 100644 --- a/packages/nextjs/src/config/withSentryConfig/buildTime.ts +++ b/packages/nextjs/src/config/withSentryConfig/buildTime.ts @@ -69,6 +69,11 @@ export function setUpBuildTimeVariables( buildTimeVariables._sentryRelease = releaseName; } + // The client only waits for a late trace meta tag when a document can resume a prerendered shell. + if (userNextConfig.cacheComponents) { + buildTimeVariables._sentryCacheComponents = 'true'; + } + // See `client/routing/nextRoutingInstrumentation.ts`. if (hasOnlyAppRouterPages(projectDir)) { buildTimeVariables._sentryHasPagesRouter = 'false'; diff --git a/packages/nextjs/src/config/withSentryConfig/getFinalConfigObjectUtils.ts b/packages/nextjs/src/config/withSentryConfig/getFinalConfigObjectUtils.ts index c457cb75aaf0..c0791a981500 100644 --- a/packages/nextjs/src/config/withSentryConfig/getFinalConfigObjectUtils.ts +++ b/packages/nextjs/src/config/withSentryConfig/getFinalConfigObjectUtils.ts @@ -1,3 +1,4 @@ +import { PRERENDER_META_TAG_NAME } from '../../common/traceMetaTags'; import { debug, isMatchingPattern, parseSemver } from '@sentry/core'; import { getSentryRelease } from '@sentry/node'; import * as fs from 'fs'; @@ -128,7 +129,6 @@ export function filterRouteManifest(manifest: RouteManifest, excludeFilter: Excl return { staticRoutes: manifest.staticRoutes.filter(r => !shouldExclude(r.path)), dynamicRoutes: manifest.dynamicRoutes.filter(r => !shouldExclude(r.path)), - isrRoutes: manifest.isrRoutes.filter(r => !shouldExclude(r)), }; } @@ -140,16 +140,6 @@ export function maybeSetClientTraceMetadataOption( nextJsVersion: string | undefined, silent?: boolean, ): void { - // With Cache Components enabled, the page shell — and therefore the document's `sentry-trace`/ - // `baggage` meta tags — can be prerendered and rendered in an async context detached from the - // runtime `http.server` request. The trace captured into those meta tags is therefore stale and - // unrelated to the actual request. If we enabled `clientTraceMetadata`, the browser pageload would - // continue that stale trace, producing a misleading distributed trace. So we don't enable the meta - // tags here; the browser pageload starts a fresh trace instead. - if (incomingUserNextConfigObject.cacheComponents) { - return; - } - // Add the `clientTraceMetadata` experimental option based on Next.js version. The option got introduced in Next.js version 15.0.0 (actually 14.3.0-canary.64). // Adding the option on lower versions will cause Next.js to print nasty warnings we wouldn't confront our users with. if (nextJsVersion) { @@ -159,6 +149,7 @@ export function maybeSetClientTraceMetadataOption( incomingUserNextConfigObject.experimental.clientTraceMetadata = [ 'baggage', 'sentry-trace', + PRERENDER_META_TAG_NAME, ...(incomingUserNextConfigObject.experimental?.clientTraceMetadata || []), ]; } diff --git a/packages/nextjs/src/server/index.ts b/packages/nextjs/src/server/index.ts index 6dea488a7d9d..95a90b08fb38 100644 --- a/packages/nextjs/src/server/index.ts +++ b/packages/nextjs/src/server/index.ts @@ -23,6 +23,7 @@ import { setCloudflareWorkerRelease, } from '../common/utils/responseEnd'; import { distDirRewriteFramesIntegration } from './distDirRewriteFramesIntegration'; +import { registerNextSentryPropagator } from './nextSentryPropagator'; import { addNextjsServerSpanHooks, NEXTJS_SERVER_IGNORE_SPANS } from './serverSpanHooks'; import { prepareSafeIdGeneratorContext } from './prepareSafeIdGeneratorContext'; import { nextjsUseCacheIntegration } from './useCacheInstrumentation'; @@ -183,6 +184,14 @@ export function init(options: NodeOptions): NodeClient | undefined { setCloudflareWorkerRelease(opts.release); } + // Next.js reads the trace meta tags of a document through the global propagator. Only the setup the + // Node SDK owns gets the Next.js-aware one here; `withSentry` of `@sentry/nextjs/cloudflare` applies it + // to its own setup, and another OpenTelemetry setup (for example `@vercel/otel`) keeps its own tracer + // provider and propagator. + if (client?.traceProvider) { + registerNextSentryPropagator(client.traceProvider); + } + // On the client, not the global scope, so a later `init()` after // `close()` does not stack another copy. In a request of `withSentry` on // Workers, `init` creates no client, so the global scope gets them, which @@ -230,3 +239,8 @@ export function init(options: NodeOptions): NodeClient | undefined { export * from '../common'; export { wrapApiHandlerWithSentry } from '../common/pages-router-instrumentation/wrapApiHandlerWithSentry'; + +// For apps that set up OpenTelemetry themselves (`enableOpenTelemetrySetup: false`), which otherwise +// write the trace meta tags of a prerendered shell with the propagator they registered. +export { NextSentryPropagator } from './nextSentryPropagator'; +export { markPrerendersOnTracerProvider } from './prerenderContext'; diff --git a/packages/nextjs/src/server/nextSentryPropagator.ts b/packages/nextjs/src/server/nextSentryPropagator.ts new file mode 100644 index 000000000000..538fb28e725b --- /dev/null +++ b/packages/nextjs/src/server/nextSentryPropagator.ts @@ -0,0 +1,47 @@ +import type { Context, TextMapSetter, TracerProvider } from '@opentelemetry/api'; +import { propagation } from '@opentelemetry/api'; +import { SentryPropagator } from '@sentry/opentelemetry'; +import { PRERENDER_META_TAG_NAME } from '../common/traceMetaTags'; +import { isPrerenderContext, markPrerendersOnTracerProvider } from './prerenderContext'; + +/** + * A `SentryPropagator` that does not hand out the trace meta tags of a document while Next.js + * prerenders it. Trace context baked into a prerendered shell would be replayed to every visitor the + * shell is served to, so the browser could continue a trace that never handled its request. The shell + * gets a `sentry-prerender` marker tag with the prerender's trace instead, which the client only ever links. + * + * The SDK registers it when it sets up OpenTelemetry. An app that sets up OpenTelemetry itself + * registers it in place of `SentryPropagator` and calls `markPrerendersOnTracerProvider` with its + * tracer provider, which this propagator relies on to recognize a prerender. + */ +export class NextSentryPropagator extends SentryPropagator { + /** @inheritDoc */ + public inject(ctx: Context, carrier: unknown, setter: TextMapSetter): void { + // Next.js collects the meta tag values into an array; every other caller, such as the outgoing + // request instrumentations, passes a headers object and keeps propagating during a prerender. + if (!Array.isArray(carrier) || !isPrerenderContext(ctx)) { + super.inject(ctx, carrier, setter); + return; + } + + const values: Record = {}; + super.inject(ctx, values, { set: (c, key, value) => ((c as Record)[key] = value) }); + const sentryTrace = values['sentry-trace']; + if (sentryTrace) { + setter.set(carrier, PRERENDER_META_TAG_NAME, sentryTrace); + } + } +} + +/** + * Makes the tracer provider the Node SDK registered mark prerenders on the OpenTelemetry context, and + * replaces the propagator it registered with one that hands out no meta tags on a marked context. + */ +export function registerNextSentryPropagator(provider: TracerProvider): void { + markPrerendersOnTracerProvider(provider); + + // The OpenTelemetry API refuses a second global propagator, so the one from the Node SDK setup has + // to be unregistered first. + propagation.disable(); + propagation.setGlobalPropagator(new NextSentryPropagator()); +} diff --git a/packages/nextjs/src/server/prerenderContext.ts b/packages/nextjs/src/server/prerenderContext.ts new file mode 100644 index 000000000000..e5de40bed829 --- /dev/null +++ b/packages/nextjs/src/server/prerenderContext.ts @@ -0,0 +1,109 @@ +import type { Context, Span, SpanOptions, Tracer, TracerProvider } from '@opentelemetry/api'; +import { context, createContextKey, trace } from '@opentelemetry/api'; +import { ATTR_NEXT_SPAN_NAME, ATTR_NEXT_SPAN_TYPE } from '../common/nextSpanAttributes'; + +const APP_RENDER_BODY_SPAN_TYPE = 'AppRender.getBodyResult'; +const PRERENDER_ROUTE_SPAN_NAME_PREFIX = 'prerender route'; + +const PRERENDER_CONTEXT_KEY = createContextKey('sentry.nextjs.prerender'); + +// Prerender spans started with `startSpan`, which the caller activates on a context of its own. +const prerenderSpans = new WeakSet(); + +/** + * Whether these are the start arguments of the span Next.js wraps a prerender in. Next.js computes the + * `sentry-trace` and `baggage` meta tags of a document inside that span, and a prerender produces a + * shell that is cached and served to later visitors. A render for a request, including the resume of + * a prerendered shell, computes the tags outside of it. + * + * @internal + */ +export function isPrerenderSpanStart(name: string, options: SpanOptions | undefined): boolean { + const attributes = options?.attributes; + const spanName = attributes?.[ATTR_NEXT_SPAN_NAME] ?? name; + return ( + attributes?.[ATTR_NEXT_SPAN_TYPE] === APP_RENDER_BODY_SPAN_TYPE && + typeof spanName === 'string' && + spanName.startsWith(PRERENDER_ROUTE_SPAN_NAME_PREFIX) + ); +} + +/** + * Whether the context belongs to a prerender started through a tracer of a marked provider. + * + * @internal + */ +export function isPrerenderContext(ctx: Context): boolean { + if (ctx.getValue(PRERENDER_CONTEXT_KEY) === true) { + return true; + } + const span = trace.getSpan(ctx); + return !!span && prerenderSpans.has(span); +} + +/** + * Marks a prerender on the OpenTelemetry context rather than on the span. The context is inherited by + * everything that runs inside the prerender, and it exists even when the request is not sampled, in + * which case the prerender span is non-recording and carries no attributes to recognize it by. + */ +function wrapTracer(tracer: Tracer): Tracer { + return { + startSpan: (name, options, ctx) => { + const span = tracer.startSpan(name, options, ctx); + if (isPrerenderSpanStart(name, options)) { + prerenderSpans.add(span); + } + return span; + }, + startActiveSpan: (name: string, ...args: unknown[]) => { + const startActiveSpan = tracer.startActiveSpan.bind(tracer) as (name: string, ...args: unknown[]) => unknown; + const options = typeof args[0] === 'function' ? undefined : (args[0] as SpanOptions | undefined); + if (!isPrerenderSpanStart(name, options)) { + return startActiveSpan(name, ...args); + } + + const explicitContext = args.length === 3 ? (args[1] as Context) : undefined; + if (explicitContext) { + return startActiveSpan(name, args[0], explicitContext.setValue(PRERENDER_CONTEXT_KEY, true), args[2]); + } + return context.with(context.active().setValue(PRERENDER_CONTEXT_KEY, true), () => startActiveSpan(name, ...args)); + }, + }; +} + +const markedProviders = new WeakSet(); + +/** + * Makes a tracer provider mark prerenders of Next.js on the OpenTelemetry context, by wrapping the + * tracers it hands out. `NextSentryPropagator` relies on the mark. The SDK calls this for the provider it + * registers; an app that sets up OpenTelemetry itself calls it with its own provider. Safe to call + * repeatedly with the same provider, which the per-request `init` on Cloudflare does. + */ +export function markPrerendersOnTracerProvider(provider: TracerProvider): void { + if (markedProviders.has(provider)) { + return; + } + markedProviders.add(provider); + + const wrappedTracers = new WeakMap(); + const getTracer = provider.getTracer.bind(provider); + provider.getTracer = (...args) => { + const tracer = getTracer(...args); + let wrapped = wrappedTracers.get(tracer); + if (!wrapped) { + wrapped = wrapTracer(tracer); + wrappedTracers.set(tracer, wrapped); + } + return wrapped; + }; +} + +/** + * The tracer provider behind the global OpenTelemetry API, which is a proxy around the registered one. + * + * @internal + */ +export function getGlobalTracerProvider(): TracerProvider { + const provider = trace.getTracerProvider(); + return 'getDelegate' in provider ? (provider as { getDelegate(): TracerProvider }).getDelegate() : provider; +} diff --git a/packages/nextjs/test/client/appRouterRoutingInstrumentation.test.ts b/packages/nextjs/test/client/appRouterRoutingInstrumentation.test.ts index 0f8a49816253..a9fa6fe586b5 100644 --- a/packages/nextjs/test/client/appRouterRoutingInstrumentation.test.ts +++ b/packages/nextjs/test/client/appRouterRoutingInstrumentation.test.ts @@ -1,4 +1,6 @@ // @vitest-environment jsdom +import { SENTRY_LINK_TYPE, SENTRY_OP, URL_FULL, URL_PATH } from '@sentry/conventions/attributes'; +import { PAGELOAD } from '@sentry/conventions/op'; import type { Client } from '@sentry/core'; import type * as SentryCore from '@sentry/core'; import type * as SentryReact from '@sentry/react'; @@ -10,13 +12,16 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import '@sentry/core'; import '@sentry/react'; import '../../src/client/routing/appRouterRoutingInstrumentation'; +import '../../src/client/routing/appRouterPageloadInstrumentation'; import type * as AppRouterInstrumentation from '../../src/client/routing/appRouterRoutingInstrumentation'; +import type * as AppRouterPageload from '../../src/client/routing/appRouterPageloadInstrumentation'; import type * as RouteProvider from '../../src/client/routing/routeProvider'; import type { RouteManifest } from '../../src/config/manifest/types'; type Core = typeof SentryCore; type React = typeof SentryReact; type Instrumentation = typeof AppRouterInstrumentation; +type PageloadInstrumentation = typeof AppRouterPageload; type RouteProviderModule = typeof RouteProvider; interface NextRouter { @@ -48,7 +53,6 @@ const manifest: RouteManifest = { hasOptionalPrefix: false, }, ], - isrRoutes: [], }; function sleep(ms: number): Promise { @@ -66,11 +70,13 @@ async function setup(traceLifecycle: 'stream' | 'static'): Promise<{ router: NextRouter; client: Client; instrumentation: Instrumentation; + pageload: PageloadInstrumentation; }> { vi.resetModules(); const core: Core = await import('@sentry/core'); const react: React = await import('@sentry/react'); const instrumentation: Instrumentation = await import('../../src/client/routing/appRouterRoutingInstrumentation'); + const pageload: PageloadInstrumentation = await import('../../src/client/routing/appRouterPageloadInstrumentation'); const routeProvider: RouteProviderModule = await import('../../src/client/routing/routeProvider'); const client = new react.BrowserClient({ @@ -92,7 +98,7 @@ async function setup(traceLifecycle: 'stream' | 'static'): Promise<{ instrumentation.appRouterInstrumentNavigation(client); await vi.waitFor(() => expect(router.back).not.toBe(originalBack)); - return { core, router, client, instrumentation }; + return { core, router, client, instrumentation, pageload }; } describe('appRouterInstrumentNavigation (router-patch mode)', () => { @@ -265,3 +271,79 @@ describe('appRouterInstrumentNavigation with basePath', () => { expect(core.spanToJSON(span!).name).toBe('/my-app/navigation/:param/router-push'); }); }); + +describe('appRouterInstrumentPageLoad', () => { + const originalReadyState = Object.getOwnPropertyDescriptor(Document.prototype, 'readyState'); + + beforeEach(() => { + globalWithNext._sentryRouteManifest = JSON.stringify(manifest); + window.history.replaceState({}, '', '/navigation?from=document'); + Object.defineProperty(document, 'readyState', { value: 'loading', configurable: true }); + vi.spyOn(performance, 'getEntriesByType').mockReturnValue([]); + vi.stubEnv('_sentryCacheComponents', 'true'); + }); + + afterEach(() => { + delete globalWithNext.next; + delete globalWithNext._sentryRouteManifest; + // @ts-expect-error deleting the instance override restores the prototype getter + delete document.readyState; + if (originalReadyState) { + Object.defineProperty(Document.prototype, 'readyState', originalReadyState); + } + vi.restoreAllMocks(); + vi.unstubAllEnvs(); + }); + + it('records the URL of the document when the pageload starts after a navigation changed it', async () => { + const { core, pageload } = await setup('static'); + + pageload.appRouterInstrumentPageLoad(core.getClient()!); + const spanBeforeSettling = core.getActiveSpan(); + + window.history.replaceState({}, '', '/navigation/42/router-back'); + pageload.settlePendingPageloadWait(); + + const span = core.getActiveSpan(); + expect(span).toBeDefined(); + expect(span).not.toBe(spanBeforeSettling); + const spanJson = core.spanToJSON(span!); + expect(spanJson.name).toBe('/navigation'); + expect(spanJson.attributes?.[SENTRY_OP]).toBe(PAGELOAD); + expect(spanJson.attributes).toEqual( + expect.objectContaining({ + [URL_PATH]: '/navigation', + [URL_FULL]: 'http://localhost:3000/navigation?from=document', + }), + ); + expect(core.getCurrentScope().getScopeData().sdkProcessingMetadata.normalizedRequest?.url).toBe( + 'http://localhost:3000/navigation?from=document', + ); + }); + + it('marks a pageload whose document is a cached shell and links the prerender', async () => { + const { core, pageload } = await setup('static'); + Object.defineProperty(document, 'readyState', { value: 'complete', configurable: true }); + const meta = document.createElement('meta'); + meta.name = 'sentry-prerender'; + meta.content = '11111111111111111111111111111111-1111111111111111-1'; + document.head.appendChild(meta); + + pageload.appRouterInstrumentPageLoad(core.getClient()!); + + const span = core.getActiveSpan(); + expect(span).toBeDefined(); + const spanJson = core.spanToJSON(span!); + expect(spanJson.attributes?.[SENTRY_OP]).toBe(PAGELOAD); + expect(spanJson.attributes?.['document.cached']).toBe(true); + expect(spanJson.trace_id).not.toBe('11111111111111111111111111111111'); + expect(spanJson.links).toEqual([ + expect.objectContaining({ + trace_id: '11111111111111111111111111111111', + span_id: '1111111111111111', + attributes: expect.objectContaining({ [SENTRY_LINK_TYPE]: 'cache_origin' }), + }), + ]); + meta.remove(); + }); +}); diff --git a/packages/nextjs/test/client/isrRoutingTracing.test.ts b/packages/nextjs/test/client/isrRoutingTracing.test.ts deleted file mode 100644 index cdca740727dd..000000000000 --- a/packages/nextjs/test/client/isrRoutingTracing.test.ts +++ /dev/null @@ -1,398 +0,0 @@ -import { WINDOW } from '@sentry/react'; -import { JSDOM } from 'jsdom'; -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { - IS_ISR_SSG_ROUTE_CACHE, - isIsrSsgRoute, - removeIsrSsgTraceMetaTags, -} from '../../src/client/routing/isrRoutingTracing'; -import type { RouteManifest } from '../../src/config/manifest/types'; - -const globalWithInjectedValues = WINDOW as typeof WINDOW & { - _sentryRouteManifest?: string; -}; - -describe('isrRoutingTracing', () => { - let dom: JSDOM; - - beforeEach(() => { - // Set up a fresh DOM environment for each test - dom = new JSDOM('', { - url: 'https://example.com/', - }); - Object.defineProperty(global, 'document', { value: dom.window.document, writable: true }); - Object.defineProperty(global, 'location', { value: dom.window.location, writable: true }); - - // Clear the injected manifest - delete globalWithInjectedValues._sentryRouteManifest; - }); - - afterEach(() => { - // Clean up - vi.clearAllMocks(); - }); - - describe('removeIsrSsgTraceMetaTags', () => { - const mockManifest: RouteManifest = { - staticRoutes: [{ path: '/' }, { path: '/blog' }], - dynamicRoutes: [ - { - path: '/products/:id', - regex: '^/products/([^/]+?)(?:/)?$', - paramNames: ['id'], - hasOptionalPrefix: false, - }, - { - path: '/posts/:slug', - regex: '^/posts/([^/]+?)(?:/)?$', - paramNames: ['slug'], - hasOptionalPrefix: false, - }, - ], - isrRoutes: ['/', '/blog', '/products/:id', '/posts/:slug'], - }; - - it('should remove meta tags when on a static ISR route', () => { - // Set up DOM with meta tags - const sentryTraceMeta = dom.window.document.createElement('meta'); - sentryTraceMeta.setAttribute('name', 'sentry-trace'); - sentryTraceMeta.setAttribute('content', 'trace-id-12345'); - dom.window.document.head.appendChild(sentryTraceMeta); - - const baggageMeta = dom.window.document.createElement('meta'); - baggageMeta.setAttribute('name', 'baggage'); - baggageMeta.setAttribute('content', 'sentry-trace-id=12345'); - dom.window.document.head.appendChild(baggageMeta); - - // Set up route manifest (as stringified JSON, which is how it's injected in production) - globalWithInjectedValues._sentryRouteManifest = JSON.stringify(mockManifest); - - // Set location to an ISR route - Object.defineProperty(global, 'location', { - value: { ...dom.window.location, pathname: '/blog' }, - writable: true, - }); - - // Call the function - removeIsrSsgTraceMetaTags(); - - // Verify meta tags were removed - expect(dom.window.document.querySelector('meta[name="sentry-trace"]')).toBeNull(); - expect(dom.window.document.querySelector('meta[name="baggage"]')).toBeNull(); - }); - - it('should remove meta tags when on a dynamic ISR route', () => { - // Set up DOM with meta tags - const sentryTraceMeta = dom.window.document.createElement('meta'); - sentryTraceMeta.setAttribute('name', 'sentry-trace'); - sentryTraceMeta.setAttribute('content', 'trace-id-12345'); - dom.window.document.head.appendChild(sentryTraceMeta); - - const baggageMeta = dom.window.document.createElement('meta'); - baggageMeta.setAttribute('name', 'baggage'); - baggageMeta.setAttribute('content', 'sentry-trace-id=12345'); - dom.window.document.head.appendChild(baggageMeta); - - // Set up route manifest - globalWithInjectedValues._sentryRouteManifest = JSON.stringify(mockManifest); - - // Set location to a dynamic ISR route - Object.defineProperty(global, 'location', { - value: { ...dom.window.location, pathname: '/products/123' }, - writable: true, - }); - - // Call the function - removeIsrSsgTraceMetaTags(); - - // Verify meta tags were removed - expect(dom.window.document.querySelector('meta[name="sentry-trace"]')).toBeNull(); - expect(dom.window.document.querySelector('meta[name="baggage"]')).toBeNull(); - }); - - it('should NOT remove meta tags when on a non-ISR route', () => { - // Set up DOM with meta tags - const sentryTraceMeta = dom.window.document.createElement('meta'); - sentryTraceMeta.setAttribute('name', 'sentry-trace'); - sentryTraceMeta.setAttribute('content', 'trace-id-12345'); - dom.window.document.head.appendChild(sentryTraceMeta); - - const baggageMeta = dom.window.document.createElement('meta'); - baggageMeta.setAttribute('name', 'baggage'); - baggageMeta.setAttribute('content', 'sentry-trace-id=12345'); - dom.window.document.head.appendChild(baggageMeta); - - // Set up route manifest - globalWithInjectedValues._sentryRouteManifest = JSON.stringify(mockManifest); - - // Set location to a non-ISR route - Object.defineProperty(global, 'location', { - value: { ...dom.window.location, pathname: '/regular-page' }, - writable: true, - }); - - // Call the function - removeIsrSsgTraceMetaTags(); - - // Verify meta tags were NOT removed - expect(dom.window.document.querySelector('meta[name="sentry-trace"]')).not.toBeNull(); - expect(dom.window.document.querySelector('meta[name="baggage"]')).not.toBeNull(); - }); - - it('should handle missing manifest gracefully', () => { - // Clear cache to ensure fresh state - IS_ISR_SSG_ROUTE_CACHE.clear(); - - // Set up DOM with meta tags - const sentryTraceMeta = dom.window.document.createElement('meta'); - sentryTraceMeta.setAttribute('name', 'sentry-trace'); - sentryTraceMeta.setAttribute('content', 'trace-id-12345'); - dom.window.document.head.appendChild(sentryTraceMeta); - - // No manifest set - // globalWithInjectedValues._sentryRouteManifest is undefined - - // Set location - Object.defineProperty(global, 'location', { - value: { ...dom.window.location, pathname: '/blog' }, - writable: true, - }); - - // Call the function (should not throw) - expect(() => removeIsrSsgTraceMetaTags()).not.toThrow(); - - // Verify meta tags were NOT removed (no manifest means no ISR detection) - expect(dom.window.document.querySelector('meta[name="sentry-trace"]')).not.toBeNull(); - }); - - it('should handle invalid JSON manifest gracefully', () => { - // Clear cache to ensure fresh state - IS_ISR_SSG_ROUTE_CACHE.clear(); - - // Set up DOM with meta tags - const sentryTraceMeta = dom.window.document.createElement('meta'); - sentryTraceMeta.setAttribute('name', 'sentry-trace'); - sentryTraceMeta.setAttribute('content', 'trace-id-12345'); - dom.window.document.head.appendChild(sentryTraceMeta); - - // Set up invalid manifest - globalWithInjectedValues._sentryRouteManifest = 'invalid json {'; - - // Set location - Object.defineProperty(global, 'location', { - value: { ...dom.window.location, pathname: '/blog' }, - writable: true, - }); - - // Call the function (should not throw) - expect(() => removeIsrSsgTraceMetaTags()).not.toThrow(); - - // Verify meta tags were NOT removed (invalid manifest means no ISR detection) - expect(dom.window.document.querySelector('meta[name="sentry-trace"]')).not.toBeNull(); - }); - - it('should handle manifest with no ISR routes', () => { - // Set up DOM with meta tags - const sentryTraceMeta = dom.window.document.createElement('meta'); - sentryTraceMeta.setAttribute('name', 'sentry-trace'); - sentryTraceMeta.setAttribute('content', 'trace-id-12345'); - dom.window.document.head.appendChild(sentryTraceMeta); - - // Set up manifest with no ISR routes - const manifestWithNoISR: RouteManifest = { - staticRoutes: [{ path: '/' }], - dynamicRoutes: [], - isrRoutes: [], - }; - globalWithInjectedValues._sentryRouteManifest = JSON.stringify(manifestWithNoISR); - - // Set location - Object.defineProperty(global, 'location', { - value: { ...dom.window.location, pathname: '/' }, - writable: true, - }); - - // Call the function - removeIsrSsgTraceMetaTags(); - - // Verify meta tags were NOT removed (no ISR routes in manifest) - expect(dom.window.document.querySelector('meta[name="sentry-trace"]')).not.toBeNull(); - }); - - it('should handle missing meta tags gracefully', () => { - // Set up DOM without meta tags - - // Set up route manifest - globalWithInjectedValues._sentryRouteManifest = JSON.stringify(mockManifest); - - // Set location to an ISR route - Object.defineProperty(global, 'location', { - value: { ...dom.window.location, pathname: '/blog' }, - writable: true, - }); - - // Call the function (should not throw) - expect(() => removeIsrSsgTraceMetaTags()).not.toThrow(); - - // Verify no errors and still no meta tags - expect(dom.window.document.querySelector('meta[name="sentry-trace"]')).toBeNull(); - expect(dom.window.document.querySelector('meta[name="baggage"]')).toBeNull(); - }); - - it('should work with parameterized dynamic routes', () => { - // Set up DOM with meta tags - const sentryTraceMeta = dom.window.document.createElement('meta'); - sentryTraceMeta.setAttribute('name', 'sentry-trace'); - sentryTraceMeta.setAttribute('content', 'trace-id-12345'); - dom.window.document.head.appendChild(sentryTraceMeta); - - const baggageMeta = dom.window.document.createElement('meta'); - baggageMeta.setAttribute('name', 'baggage'); - baggageMeta.setAttribute('content', 'sentry-trace-id=12345'); - dom.window.document.head.appendChild(baggageMeta); - - // Set up route manifest - globalWithInjectedValues._sentryRouteManifest = JSON.stringify(mockManifest); - - // Set location to a different dynamic ISR route value - Object.defineProperty(global, 'location', { - value: { ...dom.window.location, pathname: '/posts/my-awesome-post' }, - writable: true, - }); - - // Call the function - removeIsrSsgTraceMetaTags(); - - // Verify meta tags were removed (should match /posts/:slug) - expect(dom.window.document.querySelector('meta[name="sentry-trace"]')).toBeNull(); - expect(dom.window.document.querySelector('meta[name="baggage"]')).toBeNull(); - }); - }); - - describe('isIsrSsgRoute caching', () => { - const mockManifest: RouteManifest = { - staticRoutes: [{ path: '/' }, { path: '/blog' }], - dynamicRoutes: [ - { - path: '/products/:id', - regex: '^/products/([^/]+?)(?:/)?$', - paramNames: ['id'], - hasOptionalPrefix: false, - }, - { - path: '/posts/:slug', - regex: '^/posts/([^/]+?)(?:/)?$', - paramNames: ['slug'], - hasOptionalPrefix: false, - }, - ], - isrRoutes: ['/', '/blog', '/products/:id', '/posts/:slug'], - }; - - beforeEach(() => { - // Clear cache before each test - IS_ISR_SSG_ROUTE_CACHE.clear(); - // Set up route manifest - globalWithInjectedValues._sentryRouteManifest = JSON.stringify(mockManifest); - }); - - it('should cache results by parameterized route, not concrete pathname', () => { - // First call with /products/123 - const result1 = isIsrSsgRoute('/products/123'); - expect(result1).toBe(true); - expect(IS_ISR_SSG_ROUTE_CACHE.size).toBe(1); - expect(IS_ISR_SSG_ROUTE_CACHE.get('/products/:id')).toBeDefined(); - - // Second call with different concrete path /products/456 - const result2 = isIsrSsgRoute('/products/456'); - expect(result2).toBe(true); - // Cache size should still be 1 - both paths map to same parameterized route - expect(IS_ISR_SSG_ROUTE_CACHE.size).toBe(1); - expect(IS_ISR_SSG_ROUTE_CACHE.get('/products/:id')).toBeDefined(); - - // Third call with yet another path /products/999 - const result3 = isIsrSsgRoute('/products/999'); - expect(result3).toBe(true); - // Still just 1 cache entry - expect(IS_ISR_SSG_ROUTE_CACHE.size).toBe(1); - }); - - it('should use cached results on subsequent calls with same route pattern', () => { - // Clear cache - IS_ISR_SSG_ROUTE_CACHE.clear(); - - // First call - cache miss, will populate cache - isIsrSsgRoute('/products/1'); - expect(IS_ISR_SSG_ROUTE_CACHE.size).toBe(1); - expect(IS_ISR_SSG_ROUTE_CACHE.get('/products/:id')).toBeDefined(); - - // Second call with different concrete path - cache hit - const result2 = isIsrSsgRoute('/products/2'); - expect(result2).toBe(true); - // Cache size unchanged - using cached result - expect(IS_ISR_SSG_ROUTE_CACHE.size).toBe(1); - - // Third call - still cache hit - const result3 = isIsrSsgRoute('/products/3'); - expect(result3).toBe(true); - expect(IS_ISR_SSG_ROUTE_CACHE.size).toBe(1); - }); - - it('should cache false results for non-ISR routes', () => { - const result1 = isIsrSsgRoute('/not-an-isr-route'); - expect(result1).toBe(false); - expect(IS_ISR_SSG_ROUTE_CACHE.get('/not-an-isr-route')).toBeDefined(); - expect(IS_ISR_SSG_ROUTE_CACHE.get('/not-an-isr-route')).toBe(false); - - // Second call should use cache - const result2 = isIsrSsgRoute('/not-an-isr-route'); - expect(result2).toBe(false); - }); - - it('should cache false results when manifest is invalid', () => { - IS_ISR_SSG_ROUTE_CACHE.clear(); - globalWithInjectedValues._sentryRouteManifest = 'invalid json'; - - const result = isIsrSsgRoute('/any-route'); - expect(result).toBe(false); - expect(IS_ISR_SSG_ROUTE_CACHE.get('/any-route')).toBeDefined(); - expect(IS_ISR_SSG_ROUTE_CACHE.get('/any-route')).toBe(false); - }); - - it('should cache static routes without parameterization', () => { - const result1 = isIsrSsgRoute('/blog'); - expect(result1).toBe(true); - expect(IS_ISR_SSG_ROUTE_CACHE.get('/blog')).toBeDefined(); - - // Second call should use cache - const result2 = isIsrSsgRoute('/blog'); - expect(result2).toBe(true); - }); - - it('should maintain separate cache entries for different route patterns', () => { - // Check multiple different routes - isIsrSsgRoute('/products/1'); - isIsrSsgRoute('/posts/hello'); - isIsrSsgRoute('/blog'); - isIsrSsgRoute('/'); - - // Should have 4 cache entries (one for each unique route pattern) - expect(IS_ISR_SSG_ROUTE_CACHE.size).toBe(4); - expect(IS_ISR_SSG_ROUTE_CACHE.get('/products/:id')).toBeDefined(); - expect(IS_ISR_SSG_ROUTE_CACHE.get('/posts/:slug')).toBeDefined(); - expect(IS_ISR_SSG_ROUTE_CACHE.get('/blog')).toBeDefined(); - expect(IS_ISR_SSG_ROUTE_CACHE.get('/')).toBeDefined(); - }); - - it('should efficiently handle multiple calls to same dynamic route with different params', () => { - // Simulate real-world scenario with many different product IDs - for (let i = 1; i <= 100; i++) { - isIsrSsgRoute(`/products/${i}`); - } - - // Should only have 1 cache entry despite 100 calls - expect(IS_ISR_SSG_ROUTE_CACHE.size).toBe(1); - expect(IS_ISR_SSG_ROUTE_CACHE.get('/products/:id')).toBeDefined(); - }); - }); -}); diff --git a/packages/nextjs/test/client/routeProvider.test.ts b/packages/nextjs/test/client/routeProvider.test.ts index 90721c503867..91ecce723198 100644 --- a/packages/nextjs/test/client/routeProvider.test.ts +++ b/packages/nextjs/test/client/routeProvider.test.ts @@ -14,7 +14,6 @@ let originalDocument: unknown; const MANIFEST = JSON.stringify({ staticRoutes: [{ path: '/about' }], dynamicRoutes: [{ path: '/users/:id', regex: '^/users/([^/]+)$', paramNames: ['id'] }], - isrRoutes: [], }); function makeClient(): BrowserClient { diff --git a/packages/nextjs/test/client/traceMetaTags.test.ts b/packages/nextjs/test/client/traceMetaTags.test.ts new file mode 100644 index 000000000000..34e8a5d03f35 --- /dev/null +++ b/packages/nextjs/test/client/traceMetaTags.test.ts @@ -0,0 +1,252 @@ +// @vitest-environment jsdom +import { SENTRY_LINK_TYPE } from '@sentry/conventions/attributes'; +import type { Span } from '@sentry/core'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { + addTraceMetaTagLink, + cachedDocumentSpanOptions, + readPrerenderMarker, + canWaitForTraceMetaTag, + readTraceMetaTags, + waitForTraceMetaTag, +} from '../../src/client/routing/traceMetaTags'; + +const HEAD_TRACE = '11111111111111111111111111111111-1111111111111111-1'; +const BODY_TRACE = '22222222222222222222222222222222-2222222222222222-1'; + +function appendMeta(parent: HTMLElement, name: string, content: string): HTMLMetaElement { + const meta = document.createElement('meta'); + meta.setAttribute('name', name); + meta.setAttribute('content', content); + parent.appendChild(meta); + return meta; +} + +const originalReadyState = Object.getOwnPropertyDescriptor(Document.prototype, 'readyState'); + +function setReadyState(value: DocumentReadyState): void { + Object.defineProperty(document, 'readyState', { value, configurable: true }); +} + +beforeEach(() => { + document.head.innerHTML = ''; + document.body.innerHTML = ''; + vi.stubEnv('_sentryCacheComponents', 'true'); +}); + +afterEach(() => { + // @ts-expect-error deleting the instance override restores the prototype getter + delete document.readyState; + if (originalReadyState) { + Object.defineProperty(Document.prototype, 'readyState', originalReadyState); + } + vi.restoreAllMocks(); + vi.unstubAllEnvs(); + vi.useRealTimers(); +}); + +describe('readTraceMetaTags', () => { + it('returns nothing for a document without trace meta tags', () => { + expect(readTraceMetaTags()).toBeUndefined(); + }); + + it('reads a tag from ', () => { + appendMeta(document.head, 'sentry-trace', HEAD_TRACE); + appendMeta(document.head, 'baggage', 'sentry-trace_id=1111'); + + expect(readTraceMetaTags()).toEqual({ sentryTrace: HEAD_TRACE, baggage: 'sentry-trace_id=1111' }); + expect(document.querySelectorAll('meta[name="sentry-trace"]')).toHaveLength(1); + }); + + it('reads a tag from ', () => { + appendMeta(document.body, 'sentry-trace', BODY_TRACE); + + expect(readTraceMetaTags()).toEqual({ sentryTrace: BODY_TRACE, baggage: undefined }); + }); + + it('prefers the last tag in document order', () => { + appendMeta(document.head, 'sentry-trace', HEAD_TRACE); + appendMeta(document.head, 'baggage', 'sentry-trace_id=1111'); + appendMeta(document.body, 'sentry-trace', BODY_TRACE); + appendMeta(document.body, 'baggage', 'sentry-trace_id=2222'); + + expect(readTraceMetaTags()).toEqual({ sentryTrace: BODY_TRACE, baggage: 'sentry-trace_id=2222' }); + }); +}); + +describe('canWaitForTraceMetaTag', () => { + it('is true while the document streams and no Server-Timing trace exists', () => { + setReadyState('loading'); + vi.spyOn(performance, 'getEntriesByType').mockReturnValue([]); + + expect(canWaitForTraceMetaTag()).toBe(true); + }); + + it('is false when the build did not enable cacheComponents', () => { + vi.stubEnv('_sentryCacheComponents', ''); + setReadyState('loading'); + vi.spyOn(performance, 'getEntriesByType').mockReturnValue([]); + + expect(canWaitForTraceMetaTag()).toBe(false); + }); + + it('is false once the document finished parsing', () => { + setReadyState('interactive'); + + expect(canWaitForTraceMetaTag()).toBe(false); + }); + + it('is false when the response carried the trace in Server-Timing', () => { + setReadyState('loading'); + vi.spyOn(performance, 'getEntriesByType').mockReturnValue([ + { serverTiming: [{ name: 'sentry-trace', description: BODY_TRACE, duration: 0 }] } as unknown as PerformanceEntry, + ]); + + expect(canWaitForTraceMetaTag()).toBe(false); + }); +}); + +describe('waitForTraceMetaTag', () => { + beforeEach(() => { + setReadyState('loading'); + vi.useFakeTimers(); + }); + + it('reports a tag that is streamed in after the SDK started, once', async () => { + const onTag = vi.fn(); + const onGiveUp = vi.fn(); + + waitForTraceMetaTag(onTag, onGiveUp, 1000); + appendMeta(document.body, 'sentry-trace', BODY_TRACE); + appendMeta(document.body, 'baggage', 'sentry-trace_id=2222'); + await vi.advanceTimersByTimeAsync(0); + appendMeta(document.body, 'sentry-trace', HEAD_TRACE); + document.dispatchEvent(new Event('DOMContentLoaded')); + await vi.advanceTimersByTimeAsync(2000); + + expect(onTag).toHaveBeenCalledTimes(1); + expect(onTag).toHaveBeenCalledWith({ sentryTrace: BODY_TRACE, baggage: 'sentry-trace_id=2222' }); + expect(onGiveUp).not.toHaveBeenCalled(); + }); + + it('gives up when the document finished parsing without a tag', async () => { + const onTag = vi.fn(); + const onGiveUp = vi.fn(); + + waitForTraceMetaTag(onTag, onGiveUp, 1000); + document.dispatchEvent(new Event('DOMContentLoaded')); + appendMeta(document.body, 'sentry-trace', BODY_TRACE); + await vi.advanceTimersByTimeAsync(2000); + + expect(onGiveUp).toHaveBeenCalledTimes(1); + expect(onTag).not.toHaveBeenCalled(); + }); + + it('still finds a tag from the last chunk when the document finished parsing', () => { + const onTag = vi.fn(); + const onGiveUp = vi.fn(); + + waitForTraceMetaTag(onTag, onGiveUp, 1000); + appendMeta(document.body, 'sentry-trace', BODY_TRACE); + document.dispatchEvent(new Event('DOMContentLoaded')); + + expect(onTag).toHaveBeenCalledWith({ sentryTrace: BODY_TRACE, baggage: undefined }); + expect(onGiveUp).not.toHaveBeenCalled(); + }); + + it('gives up after the timeout while the document is still streaming', async () => { + const onTag = vi.fn(); + const onGiveUp = vi.fn(); + + waitForTraceMetaTag(onTag, onGiveUp, 1000); + await vi.advanceTimersByTimeAsync(1000); + appendMeta(document.body, 'sentry-trace', BODY_TRACE); + await vi.advanceTimersByTimeAsync(0); + + expect(onGiveUp).toHaveBeenCalledTimes(1); + expect(onTag).not.toHaveBeenCalled(); + }); + + it('gives up early when asked, once', async () => { + const onTag = vi.fn(); + const onGiveUp = vi.fn(); + + const wait = waitForTraceMetaTag(onTag, onGiveUp, 1000); + wait.giveUp(); + wait.giveUp(); + appendMeta(document.body, 'sentry-trace', BODY_TRACE); + await vi.advanceTimersByTimeAsync(2000); + + expect(onGiveUp).toHaveBeenCalledTimes(1); + expect(onTag).not.toHaveBeenCalled(); + }); + + it('gives up right away when the document already finished parsing', async () => { + setReadyState('complete'); + const onTag = vi.fn(); + const onGiveUp = vi.fn(); + + waitForTraceMetaTag(onTag, onGiveUp, 1000); + + expect(onGiveUp).toHaveBeenCalledTimes(1); + expect(onTag).not.toHaveBeenCalled(); + }); +}); + +describe('addTraceMetaTagLink', () => { + it('links the span described by the sentry-trace value and carries the sampled flag', () => { + const span = { addLink: vi.fn() } as unknown as Span; + + addTraceMetaTagLink(span, { sentryTrace: HEAD_TRACE, baggage: undefined }); + addTraceMetaTagLink(span, { + sentryTrace: '33333333333333333333333333333333-3333333333333333-0', + baggage: undefined, + }); + + expect(span.addLink).toHaveBeenNthCalledWith(1, { + context: { traceId: '11111111111111111111111111111111', spanId: '1111111111111111', traceFlags: 1 }, + }); + expect(span.addLink).toHaveBeenNthCalledWith(2, { + context: { traceId: '33333333333333333333333333333333', spanId: '3333333333333333', traceFlags: 0 }, + }); + }); + + it('ignores values that are not a sentry-trace header', () => { + const span = { addLink: vi.fn() } as unknown as Span; + + addTraceMetaTagLink(span, { sentryTrace: 'not-a-trace', baggage: undefined }); + + expect(span.addLink).not.toHaveBeenCalled(); + }); +}); + +describe('readPrerenderMarker', () => { + it('returns nothing without a sentry-prerender tag', () => { + expect(readPrerenderMarker()).toBeUndefined(); + }); + + it('reads the last sentry-prerender tag', () => { + appendMeta(document.head, 'sentry-prerender', HEAD_TRACE); + appendMeta(document.body, 'sentry-prerender', BODY_TRACE); + + expect(readPrerenderMarker()).toBe(BODY_TRACE); + }); +}); + +describe('cachedDocumentSpanOptions', () => { + it('marks the span and links the prerender as cache origin', () => { + expect(cachedDocumentSpanOptions(HEAD_TRACE)).toEqual({ + attributes: { 'document.cached': true }, + links: [ + { + context: { traceId: '11111111111111111111111111111111', spanId: '1111111111111111', traceFlags: 1 }, + attributes: { [SENTRY_LINK_TYPE]: 'cache_origin' }, + }, + ], + }); + }); + + it('marks the span even when the marker is not a sentry-trace value', () => { + expect(cachedDocumentSpanOptions('prerendered')).toEqual({ attributes: { 'document.cached': true } }); + }); +}); diff --git a/packages/nextjs/test/clientSdk.test.ts b/packages/nextjs/test/clientSdk.test.ts index 03cd83dbe8b6..9f16580546c7 100644 --- a/packages/nextjs/test/clientSdk.test.ts +++ b/packages/nextjs/test/clientSdk.test.ts @@ -193,7 +193,6 @@ describe('Client init()', () => { globalWithManifest._sentryRouteManifest = JSON.stringify({ staticRoutes: [{ path: '/' }], dynamicRoutes: [], - isrRoutes: [], }); init({ dsn: TEST_DSN, tracesSampleRate: 1.0 }); diff --git a/packages/nextjs/test/cloudflare/withSentry.test.ts b/packages/nextjs/test/cloudflare/withSentry.test.ts index ff3395f62b4e..ca02d59f5b11 100644 --- a/packages/nextjs/test/cloudflare/withSentry.test.ts +++ b/packages/nextjs/test/cloudflare/withSentry.test.ts @@ -16,7 +16,7 @@ import { } from '@sentry/core'; import { afterEach, describe, expect, it, vi } from 'vitest'; import { withSentry } from '../../src/cloudflare'; -import { ATTR_NEXT_SPAN_TYPE } from '../../src/common/nextSpanAttributes'; +import { ATTR_NEXT_SPAN_NAME, ATTR_NEXT_SPAN_TYPE } from '../../src/common/nextSpanAttributes'; import { init as initEdge } from '../../src/edge'; import { init as initServer } from '../../src/server'; import { NEXTJS_SERVER_IGNORE_SPANS } from '../../src/server/serverSpanHooks'; @@ -257,6 +257,40 @@ describe('withSentry', () => { expect(carrier['sentry-trace']).toBe(`${activeSpan?.spanContext().traceId}-${activeSpan?.spanContext().spanId}-1`); }); + it('injects no meta tags while Next.js prerenders a page in a request', async () => { + // Next.js collects the meta tag values with an array carrier. + const carrier: Array<{ key: string; value: string }> = []; + const requestCarrier: Record = {}; + const handler = withSentry(() => ({ dsn: DSN, tracesSampleRate: 1 }), { + fetch: () => { + trace.getTracer('next.js').startActiveSpan( + 'prerender route (app) /isr', + { + attributes: { + [ATTR_NEXT_SPAN_TYPE]: 'AppRender.getBodyResult', + [ATTR_NEXT_SPAN_NAME]: 'prerender route (app) /isr', + }, + }, + span => { + propagation.inject(context.active(), carrier, { set: (c, key, value) => c.push({ key, value }) }); + span.end(); + }, + ); + propagation.inject(context.active(), requestCarrier); + return new Response('ok'); + }, + }); + + await handler.fetch?.(new Request('https://example.com/') as never, {}, { + waitUntil: vi.fn(), + passThroughOnException: vi.fn(), + props: {}, + } as never); + + expect(carrier).toEqual([{ key: 'sentry-prerender', value: expect.stringMatching(/-1$/) }]); + expect(requestCarrier['sentry-trace']).toMatch(/^[0-9a-f]{32}-[0-9a-f]{16}-1$/); + }); + it('does not set the global propagator when the callback turns off the OpenTelemetry setup', async () => { const setGlobalPropagator = vi.spyOn(propagation, 'setGlobalPropagator'); const handler = withSentry(() => ({ dsn: DSN, enableOpenTelemetrySetup: false }), { diff --git a/packages/nextjs/test/config/manifest/excludeRoutesFromManifest.test.ts b/packages/nextjs/test/config/manifest/excludeRoutesFromManifest.test.ts index a22af530b332..bf0255071375 100644 --- a/packages/nextjs/test/config/manifest/excludeRoutesFromManifest.test.ts +++ b/packages/nextjs/test/config/manifest/excludeRoutesFromManifest.test.ts @@ -17,7 +17,6 @@ describe('routeManifestInjection.exclude', () => { { path: '/admin/users/:id', regex: '^/admin/users/([^/]+)$', paramNames: ['id'] }, { path: '/secret-feature/:id', regex: '^/secret-feature/([^/]+)$', paramNames: ['id'] }, ], - isrRoutes: ['/blog', '/admin/reports', '/internal/stats'], }; describe('with no filter', () => { @@ -34,7 +33,6 @@ describe('routeManifestInjection.exclude', () => { // All routes containing '/admin' are excluded expect(result.staticRoutes.map(r => r.path)).toEqual(['/', '/about', '/internal/secret', '/public/page']); expect(result.dynamicRoutes.map(r => r.path)).toEqual(['/users/:id', '/secret-feature/:id']); - expect(result.isrRoutes).toEqual(['/blog', '/internal/stats']); }); it('should exclude routes matching multiple string patterns', () => { @@ -47,7 +45,6 @@ describe('routeManifestInjection.exclude', () => { '/internal/secret', '/public/page', ]); - expect(result.isrRoutes).toEqual(['/admin/reports', '/internal/stats']); }); it('should match substrings anywhere in the route', () => { @@ -71,14 +68,12 @@ describe('routeManifestInjection.exclude', () => { expect(result.staticRoutes.map(r => r.path)).toEqual(['/', '/about', '/internal/secret', '/public/page']); expect(result.dynamicRoutes.map(r => r.path)).toEqual(['/users/:id', '/secret-feature/:id']); - expect(result.isrRoutes).toEqual(['/blog', '/internal/stats']); }); it('should support multiple regex patterns', () => { const result = filterRouteManifest(mockManifest, [/^\/admin/, /^\/internal/]); expect(result.staticRoutes.map(r => r.path)).toEqual(['/', '/about', '/public/page']); - expect(result.isrRoutes).toEqual(['/blog']); }); it('should support partial regex matches', () => { @@ -116,7 +111,6 @@ describe('routeManifestInjection.exclude', () => { expect(result.staticRoutes.map(r => r.path)).toEqual(['/', '/about', '/internal/secret', '/public/page']); expect(result.dynamicRoutes.map(r => r.path)).toEqual(['/users/:id', '/secret-feature/:id']); - expect(result.isrRoutes).toEqual(['/blog', '/internal/stats']); }); it('should support complex filter logic', () => { @@ -127,7 +121,6 @@ describe('routeManifestInjection.exclude', () => { expect(result.staticRoutes.map(r => r.path)).toEqual(['/', '/about', '/public/page']); expect(result.dynamicRoutes.map(r => r.path)).toEqual(['/users/:id']); - expect(result.isrRoutes).toEqual(['/blog']); }); }); @@ -136,7 +129,6 @@ describe('routeManifestInjection.exclude', () => { const emptyManifest: RouteManifest = { staticRoutes: [], dynamicRoutes: [], - isrRoutes: [], }; const result = filterRouteManifest(emptyManifest, [/admin/]); @@ -148,7 +140,6 @@ describe('routeManifestInjection.exclude', () => { expect(result.staticRoutes).toEqual([]); expect(result.dynamicRoutes).toEqual([]); - expect(result.isrRoutes).toEqual([]); }); it('should handle filter that excludes nothing', () => { diff --git a/packages/nextjs/test/config/manifest/suites/base-path/base-path.test.ts b/packages/nextjs/test/config/manifest/suites/base-path/base-path.test.ts index 7a9a88033d85..097e3f603693 100644 --- a/packages/nextjs/test/config/manifest/suites/base-path/base-path.test.ts +++ b/packages/nextjs/test/config/manifest/suites/base-path/base-path.test.ts @@ -19,7 +19,6 @@ describe('basePath', () => { hasOptionalPrefix: false, }, ], - isrRoutes: [], }); }); diff --git a/packages/nextjs/test/config/manifest/suites/catchall-at-root/catchall-at-root.test.ts b/packages/nextjs/test/config/manifest/suites/catchall-at-root/catchall-at-root.test.ts index 4e8e62199592..8d78f24a0986 100644 --- a/packages/nextjs/test/config/manifest/suites/catchall-at-root/catchall-at-root.test.ts +++ b/packages/nextjs/test/config/manifest/suites/catchall-at-root/catchall-at-root.test.ts @@ -16,7 +16,6 @@ describe('catchall', () => { hasOptionalPrefix: false, }, ], - isrRoutes: [], }); }); diff --git a/packages/nextjs/test/config/manifest/suites/catchall/catchall.test.ts b/packages/nextjs/test/config/manifest/suites/catchall/catchall.test.ts index 34b9334dba05..d259a1a38223 100644 --- a/packages/nextjs/test/config/manifest/suites/catchall/catchall.test.ts +++ b/packages/nextjs/test/config/manifest/suites/catchall/catchall.test.ts @@ -16,7 +16,6 @@ describe('catchall', () => { hasOptionalPrefix: false, }, ], - isrRoutes: [], }); }); diff --git a/packages/nextjs/test/config/manifest/suites/dynamic/dynamic.test.ts b/packages/nextjs/test/config/manifest/suites/dynamic/dynamic.test.ts index fb0111d941e5..2ea4b4aca5d8 100644 --- a/packages/nextjs/test/config/manifest/suites/dynamic/dynamic.test.ts +++ b/packages/nextjs/test/config/manifest/suites/dynamic/dynamic.test.ts @@ -34,7 +34,6 @@ describe('dynamic', () => { hasOptionalPrefix: false, }, ], - isrRoutes: [], }); }); diff --git a/packages/nextjs/test/config/manifest/suites/file-extensions/file-extensions.test.ts b/packages/nextjs/test/config/manifest/suites/file-extensions/file-extensions.test.ts index 79daf2d4d58c..2c898b1e8e96 100644 --- a/packages/nextjs/test/config/manifest/suites/file-extensions/file-extensions.test.ts +++ b/packages/nextjs/test/config/manifest/suites/file-extensions/file-extensions.test.ts @@ -16,7 +16,6 @@ describe('file-extensions', () => { { path: '/typescript' }, ], dynamicRoutes: [], - isrRoutes: [], }); }); }); diff --git a/packages/nextjs/test/config/manifest/suites/isr/app/articles/[category]/[slug]/page.tsx b/packages/nextjs/test/config/manifest/suites/isr/app/articles/[category]/[slug]/page.tsx deleted file mode 100644 index 24b26184353a..000000000000 --- a/packages/nextjs/test/config/manifest/suites/isr/app/articles/[category]/[slug]/page.tsx +++ /dev/null @@ -1,2 +0,0 @@ -// Nested dynamic ISR page -export async function generateStaticParams(): Promise {} diff --git a/packages/nextjs/test/config/manifest/suites/isr/app/blog/page.tsx b/packages/nextjs/test/config/manifest/suites/isr/app/blog/page.tsx deleted file mode 100644 index 027e160f91d3..000000000000 --- a/packages/nextjs/test/config/manifest/suites/isr/app/blog/page.tsx +++ /dev/null @@ -1,2 +0,0 @@ -// Static ISR page -export async function generateStaticParams(): Promise {} diff --git a/packages/nextjs/test/config/manifest/suites/isr/app/docs/[[...path]]/page.tsx b/packages/nextjs/test/config/manifest/suites/isr/app/docs/[[...path]]/page.tsx deleted file mode 100644 index f98be769d9c3..000000000000 --- a/packages/nextjs/test/config/manifest/suites/isr/app/docs/[[...path]]/page.tsx +++ /dev/null @@ -1,2 +0,0 @@ -// Optional catchall ISR page -export async function generateStaticParams(): Promise {} diff --git a/packages/nextjs/test/config/manifest/suites/isr/app/guides/[...segments]/page.tsx b/packages/nextjs/test/config/manifest/suites/isr/app/guides/[...segments]/page.tsx deleted file mode 100644 index b22caba78d2f..000000000000 --- a/packages/nextjs/test/config/manifest/suites/isr/app/guides/[...segments]/page.tsx +++ /dev/null @@ -1,2 +0,0 @@ -// Required catchall ISR page -export async function generateStaticParams(): Promise {} diff --git a/packages/nextjs/test/config/manifest/suites/isr/app/page.tsx b/packages/nextjs/test/config/manifest/suites/isr/app/page.tsx deleted file mode 100644 index b4aef2560f50..000000000000 --- a/packages/nextjs/test/config/manifest/suites/isr/app/page.tsx +++ /dev/null @@ -1,2 +0,0 @@ -// Static ISR page at root -export async function generateStaticParams(): Promise {} diff --git a/packages/nextjs/test/config/manifest/suites/isr/app/posts/[slug]/page.tsx b/packages/nextjs/test/config/manifest/suites/isr/app/posts/[slug]/page.tsx deleted file mode 100644 index a68fb122c81d..000000000000 --- a/packages/nextjs/test/config/manifest/suites/isr/app/posts/[slug]/page.tsx +++ /dev/null @@ -1,2 +0,0 @@ -// Dynamic ISR with async function -export const generateStaticParams = async (): Promise => {}; diff --git a/packages/nextjs/test/config/manifest/suites/isr/app/products/[id]/page.tsx b/packages/nextjs/test/config/manifest/suites/isr/app/products/[id]/page.tsx deleted file mode 100644 index f18f341f1f43..000000000000 --- a/packages/nextjs/test/config/manifest/suites/isr/app/products/[id]/page.tsx +++ /dev/null @@ -1,2 +0,0 @@ -// Dynamic ISR page with generateStaticParams -export async function generateStaticParams(): Promise {} diff --git a/packages/nextjs/test/config/manifest/suites/isr/app/regular/page.tsx b/packages/nextjs/test/config/manifest/suites/isr/app/regular/page.tsx deleted file mode 100644 index b46b50a71d44..000000000000 --- a/packages/nextjs/test/config/manifest/suites/isr/app/regular/page.tsx +++ /dev/null @@ -1,2 +0,0 @@ -// Regular page without ISR (no generateStaticParams) -export {}; diff --git a/packages/nextjs/test/config/manifest/suites/isr/app/users/[id]/profile/page.tsx b/packages/nextjs/test/config/manifest/suites/isr/app/users/[id]/profile/page.tsx deleted file mode 100644 index 3774c141885d..000000000000 --- a/packages/nextjs/test/config/manifest/suites/isr/app/users/[id]/profile/page.tsx +++ /dev/null @@ -1,2 +0,0 @@ -// Mixed static-dynamic ISR page -export async function generateStaticParams(): Promise {} diff --git a/packages/nextjs/test/config/manifest/suites/isr/isr.test.ts b/packages/nextjs/test/config/manifest/suites/isr/isr.test.ts deleted file mode 100644 index 56d097df0180..000000000000 --- a/packages/nextjs/test/config/manifest/suites/isr/isr.test.ts +++ /dev/null @@ -1,345 +0,0 @@ -import path from 'path'; -import { describe, expect, test } from 'vitest'; -import { createRouteManifest } from '../../../../../src/config/manifest/createRouteManifest'; - -describe('ISR route detection and matching', () => { - const manifest = createRouteManifest({ appDirPath: path.join(__dirname, 'app') }); - - describe('ISR detection', () => { - test('should detect static ISR pages with generateStaticParams', () => { - expect(manifest.isrRoutes).toContain('/'); - expect(manifest.isrRoutes).toContain('/blog'); - }); - - test('should detect dynamic ISR pages with generateStaticParams', () => { - expect(manifest.isrRoutes).toContain('/products/:id'); - expect(manifest.isrRoutes).toContain('/posts/:slug'); - }); - - test('should detect nested dynamic ISR pages', () => { - expect(manifest.isrRoutes).toContain('/articles/:category/:slug'); - }); - - test('should detect optional catchall ISR pages', () => { - expect(manifest.isrRoutes).toContain('/docs/:path*?'); - }); - - test('should detect required catchall ISR pages', () => { - expect(manifest.isrRoutes).toContain('/guides/:segments*'); - }); - - test('should detect mixed static-dynamic ISR pages', () => { - expect(manifest.isrRoutes).toContain('/users/:id/profile'); - }); - - test('should NOT detect pages without generateStaticParams as ISR', () => { - expect(manifest.isrRoutes).not.toContain('/regular'); - }); - - test('should detect both function and const generateStaticParams', () => { - // /blog uses function declaration - // /posts/[slug] uses const declaration - expect(manifest.isrRoutes).toContain('/blog'); - expect(manifest.isrRoutes).toContain('/posts/:slug'); - }); - - test('should detect async generateStaticParams', () => { - // Multiple pages use async - this should work - expect(manifest.isrRoutes).toContain('/products/:id'); - expect(manifest.isrRoutes).toContain('/posts/:slug'); - }); - }); - - describe('Route matching against pathnames', () => { - describe('single dynamic segment ISR routes', () => { - test('should match /products/:id against various product IDs', () => { - const route = manifest.dynamicRoutes.find(r => r.path === '/products/:id'); - expect(route).toBeDefined(); - const regex = new RegExp(route!.regex!); - - // Should match - expect(regex.test('/products/1')).toBe(true); - expect(regex.test('/products/123')).toBe(true); - expect(regex.test('/products/abc-def')).toBe(true); - expect(regex.test('/products/product-with-dashes')).toBe(true); - expect(regex.test('/products/UPPERCASE')).toBe(true); - - // Should NOT match - expect(regex.test('/products')).toBe(false); - expect(regex.test('/products/')).toBe(false); - expect(regex.test('/products/123/extra')).toBe(false); - expect(regex.test('/product/123')).toBe(false); // typo - }); - - test('should match /posts/:slug against various slugs', () => { - const route = manifest.dynamicRoutes.find(r => r.path === '/posts/:slug'); - expect(route).toBeDefined(); - const regex = new RegExp(route!.regex!); - - // Should match - expect(regex.test('/posts/hello')).toBe(true); - expect(regex.test('/posts/world')).toBe(true); - expect(regex.test('/posts/my-awesome-post')).toBe(true); - expect(regex.test('/posts/post_with_underscores')).toBe(true); - - // Should NOT match - expect(regex.test('/posts')).toBe(false); - expect(regex.test('/posts/')).toBe(false); - expect(regex.test('/posts/hello/world')).toBe(false); - }); - }); - - describe('nested dynamic segments ISR routes', () => { - test('should match /articles/:category/:slug against various paths', () => { - const route = manifest.dynamicRoutes.find(r => r.path === '/articles/:category/:slug'); - expect(route).toBeDefined(); - const regex = new RegExp(route!.regex!); - - // Should match - expect(regex.test('/articles/tech/nextjs-guide')).toBe(true); - expect(regex.test('/articles/tech/react-tips')).toBe(true); - expect(regex.test('/articles/programming/typescript-advanced')).toBe(true); - expect(regex.test('/articles/news/breaking-news-2024')).toBe(true); - - // Should NOT match - expect(regex.test('/articles')).toBe(false); - expect(regex.test('/articles/tech')).toBe(false); - expect(regex.test('/articles/tech/nextjs-guide/extra')).toBe(false); - - // Extract parameters - const match = '/articles/tech/nextjs-guide'.match(regex); - expect(match).toBeTruthy(); - expect(match?.[1]).toBe('tech'); - expect(match?.[2]).toBe('nextjs-guide'); - }); - }); - - describe('mixed static-dynamic ISR routes', () => { - test('should match /users/:id/profile against user profile paths', () => { - const route = manifest.dynamicRoutes.find(r => r.path === '/users/:id/profile'); - expect(route).toBeDefined(); - const regex = new RegExp(route!.regex!); - - // Should match - expect(regex.test('/users/user1/profile')).toBe(true); - expect(regex.test('/users/user2/profile')).toBe(true); - expect(regex.test('/users/john-doe/profile')).toBe(true); - expect(regex.test('/users/123/profile')).toBe(true); - - // Should NOT match - expect(regex.test('/users/user1')).toBe(false); - expect(regex.test('/users/user1/profile/edit')).toBe(false); - expect(regex.test('/users/profile')).toBe(false); - expect(regex.test('/user/user1/profile')).toBe(false); // typo - - // Extract parameter - const match = '/users/john-doe/profile'.match(regex); - expect(match).toBeTruthy(); - expect(match?.[1]).toBe('john-doe'); - }); - }); - - describe('optional catchall ISR routes', () => { - test('should match /docs/:path*? against various documentation paths', () => { - const route = manifest.dynamicRoutes.find(r => r.path === '/docs/:path*?'); - expect(route).toBeDefined(); - const regex = new RegExp(route!.regex!); - - // Should match - with paths - expect(regex.test('/docs/getting-started')).toBe(true); - expect(regex.test('/docs/api/reference')).toBe(true); - expect(regex.test('/docs/guides/installation/quick-start')).toBe(true); - expect(regex.test('/docs/a')).toBe(true); - expect(regex.test('/docs/a/b/c/d/e')).toBe(true); - - // Should match - without path (optional catchall) - expect(regex.test('/docs')).toBe(true); - - // Should NOT match - expect(regex.test('/doc')).toBe(false); // typo - expect(regex.test('/')).toBe(false); - expect(regex.test('/documents/test')).toBe(false); - - // Extract parameters - const matchWithPath = '/docs/api/reference'.match(regex); - expect(matchWithPath).toBeTruthy(); - expect(matchWithPath?.[1]).toBe('api/reference'); - - const matchNoPath = '/docs'.match(regex); - expect(matchNoPath).toBeTruthy(); - // Optional catchall without path - expect(matchNoPath?.[1]).toBeUndefined(); - }); - }); - - describe('required catchall ISR routes', () => { - test('should match /guides/:segments* against guide paths', () => { - const route = manifest.dynamicRoutes.find(r => r.path === '/guides/:segments*'); - expect(route).toBeDefined(); - const regex = new RegExp(route!.regex!); - - // Should match - with paths (required) - expect(regex.test('/guides/intro')).toBe(true); - expect(regex.test('/guides/advanced/topics')).toBe(true); - expect(regex.test('/guides/getting-started/installation/setup')).toBe(true); - - // Should NOT match - without path (required catchall needs at least one segment) - expect(regex.test('/guides')).toBe(false); - expect(regex.test('/guides/')).toBe(false); - - // Should NOT match - wrong path - expect(regex.test('/guide/intro')).toBe(false); // typo - expect(regex.test('/')).toBe(false); - - // Extract parameters - const match = '/guides/advanced/topics'.match(regex); - expect(match).toBeTruthy(); - expect(match?.[1]).toBe('advanced/topics'); - }); - }); - - describe('real-world pathname simulations', () => { - test('should identify ISR pages from window.location.pathname examples', () => { - const testCases = [ - { pathname: '/', isISR: true, matchedRoute: '/' }, - { pathname: '/blog', isISR: true, matchedRoute: '/blog' }, - { pathname: '/products/123', isISR: true, matchedRoute: '/products/:id' }, - { pathname: '/products/gaming-laptop', isISR: true, matchedRoute: '/products/:id' }, - { pathname: '/posts/hello-world', isISR: true, matchedRoute: '/posts/:slug' }, - { pathname: '/articles/tech/nextjs-guide', isISR: true, matchedRoute: '/articles/:category/:slug' }, - { pathname: '/users/john/profile', isISR: true, matchedRoute: '/users/:id/profile' }, - { pathname: '/docs', isISR: true, matchedRoute: '/docs/:path*?' }, - { pathname: '/docs/getting-started', isISR: true, matchedRoute: '/docs/:path*?' }, - { pathname: '/docs/api/reference/advanced', isISR: true, matchedRoute: '/docs/:path*?' }, - { pathname: '/guides/intro', isISR: true, matchedRoute: '/guides/:segments*' }, - { pathname: '/guides/advanced/topics/performance', isISR: true, matchedRoute: '/guides/:segments*' }, - { pathname: '/regular', isISR: false, matchedRoute: null }, - ]; - - testCases.forEach(({ pathname, isISR, matchedRoute }) => { - // Check if pathname matches any ISR route - let foundMatch = false; - let foundRoute = null; - - // Check static ISR routes - if (manifest.isrRoutes.includes(pathname)) { - foundMatch = true; - foundRoute = pathname; - } - - // Check dynamic ISR routes - if (!foundMatch) { - for (const route of manifest.dynamicRoutes) { - if (manifest.isrRoutes.includes(route.path)) { - const regex = new RegExp(route.regex!); - if (regex.test(pathname)) { - foundMatch = true; - foundRoute = route.path; - break; - } - } - } - } - - expect(foundMatch).toBe(isISR); - if (matchedRoute) { - expect(foundRoute).toBe(matchedRoute); - } - }); - }); - }); - - describe('edge cases and special characters', () => { - test('should handle paths with special characters in dynamic segments', () => { - const route = manifest.dynamicRoutes.find(r => r.path === '/products/:id'); - const regex = new RegExp(route!.regex!); - - expect(regex.test('/products/product-123')).toBe(true); - expect(regex.test('/products/product_456')).toBe(true); - expect(regex.test('/products/PRODUCT-ABC')).toBe(true); - expect(regex.test('/products/2024-new-product')).toBe(true); - }); - - test('should handle deeply nested catchall paths', () => { - const route = manifest.dynamicRoutes.find(r => r.path === '/docs/:path*?'); - const regex = new RegExp(route!.regex!); - - expect(regex.test('/docs/a/b/c/d/e/f/g/h/i/j')).toBe(true); - }); - - test('should not match paths with trailing slashes if route does not have them', () => { - const route = manifest.dynamicRoutes.find(r => r.path === '/products/:id'); - const regex = new RegExp(route!.regex!); - - // Most Next.js routes don't match trailing slashes - expect(regex.test('/products/123/')).toBe(false); - }); - }); - - describe('parameter extraction for ISR routes', () => { - test('should extract single parameter from ISR route', () => { - const route = manifest.dynamicRoutes.find(r => r.path === '/products/:id'); - const regex = new RegExp(route!.regex!); - - const match = '/products/gaming-laptop'.match(regex); - expect(match).toBeTruthy(); - expect(route?.paramNames).toEqual(['id']); - expect(match?.[1]).toBe('gaming-laptop'); - }); - - test('should extract multiple parameters from nested ISR route', () => { - const route = manifest.dynamicRoutes.find(r => r.path === '/articles/:category/:slug'); - const regex = new RegExp(route!.regex!); - - const match = '/articles/programming/typescript-advanced'.match(regex); - expect(match).toBeTruthy(); - expect(route?.paramNames).toEqual(['category', 'slug']); - expect(match?.[1]).toBe('programming'); - expect(match?.[2]).toBe('typescript-advanced'); - }); - - test('should extract catchall parameter from ISR route', () => { - const route = manifest.dynamicRoutes.find(r => r.path === '/docs/:path*?'); - const regex = new RegExp(route!.regex!); - - const match = '/docs/api/reference/advanced'.match(regex); - expect(match).toBeTruthy(); - expect(route?.paramNames).toEqual(['path']); - expect(match?.[1]).toBe('api/reference/advanced'); - }); - }); - }); - - describe('complete manifest structure', () => { - test('should have correct structure with all route types', () => { - expect(manifest).toHaveProperty('staticRoutes'); - expect(manifest).toHaveProperty('dynamicRoutes'); - expect(manifest).toHaveProperty('isrRoutes'); - expect(Array.isArray(manifest.staticRoutes)).toBe(true); - expect(Array.isArray(manifest.dynamicRoutes)).toBe(true); - expect(Array.isArray(manifest.isrRoutes)).toBe(true); - }); - - test('should include both ISR and non-ISR routes in main route lists', () => { - // ISR static routes should be in staticRoutes - expect(manifest.staticRoutes.some(r => r.path === '/')).toBe(true); - expect(manifest.staticRoutes.some(r => r.path === '/blog')).toBe(true); - - // Non-ISR static routes should also be in staticRoutes - expect(manifest.staticRoutes.some(r => r.path === '/regular')).toBe(true); - - // ISR dynamic routes should be in dynamicRoutes - expect(manifest.dynamicRoutes.some(r => r.path === '/products/:id')).toBe(true); - }); - - test('should only include ISR routes in isrRoutes list', () => { - // ISR routes should be in the list - expect(manifest.isrRoutes).toContain('/'); - expect(manifest.isrRoutes).toContain('/blog'); - expect(manifest.isrRoutes).toContain('/products/:id'); - - // Non-ISR routes should NOT be in the list - expect(manifest.isrRoutes).not.toContain('/regular'); - }); - }); -}); diff --git a/packages/nextjs/test/config/manifest/suites/route-groups/route-groups.test.ts b/packages/nextjs/test/config/manifest/suites/route-groups/route-groups.test.ts index 32ac315b3571..e34dcde80600 100644 --- a/packages/nextjs/test/config/manifest/suites/route-groups/route-groups.test.ts +++ b/packages/nextjs/test/config/manifest/suites/route-groups/route-groups.test.ts @@ -29,7 +29,6 @@ describe('route-groups', () => { hasOptionalPrefix: false, }, ], - isrRoutes: [], }); // Verify we have 9 static routes total (including duplicates from special chars) expect(manifest.staticRoutes).toHaveLength(9); @@ -79,7 +78,6 @@ describe('route-groups', () => { hasOptionalPrefix: false, }, ], - isrRoutes: [], }); expect(manifest.staticRoutes).toHaveLength(9); }); diff --git a/packages/nextjs/test/config/manifest/suites/static/static.test.ts b/packages/nextjs/test/config/manifest/suites/static/static.test.ts index 6701ef0875d4..a6f03f49b6fe 100644 --- a/packages/nextjs/test/config/manifest/suites/static/static.test.ts +++ b/packages/nextjs/test/config/manifest/suites/static/static.test.ts @@ -8,7 +8,6 @@ describe('static', () => { expect(manifest).toEqual({ staticRoutes: [{ path: '/' }, { path: '/some/nested' }, { path: '/user' }, { path: '/users' }], dynamicRoutes: [], - isrRoutes: [], }); }); }); diff --git a/packages/nextjs/test/config/maybeSetClientTraceMetadataOption.test.ts b/packages/nextjs/test/config/maybeSetClientTraceMetadataOption.test.ts index 6d46c20b1e11..7507399ce7a7 100644 --- a/packages/nextjs/test/config/maybeSetClientTraceMetadataOption.test.ts +++ b/packages/nextjs/test/config/maybeSetClientTraceMetadataOption.test.ts @@ -6,21 +6,24 @@ describe('maybeSetClientTraceMetadataOption', () => { it('adds sentry-trace and baggage to clientTraceMetadata on supported Next.js versions', () => { const config: NextConfigObject = {}; maybeSetClientTraceMetadataOption(config, '15.0.0'); - expect(config.experimental?.clientTraceMetadata).toEqual(['baggage', 'sentry-trace']); + expect(config.experimental?.clientTraceMetadata).toEqual(['baggage', 'sentry-trace', 'sentry-prerender']); }); it('preserves user-provided clientTraceMetadata entries', () => { const config: NextConfigObject = { experimental: { clientTraceMetadata: ['my-custom-key'] } }; maybeSetClientTraceMetadataOption(config, '15.0.0'); - expect(config.experimental?.clientTraceMetadata).toEqual(['baggage', 'sentry-trace', 'my-custom-key']); + expect(config.experimental?.clientTraceMetadata).toEqual([ + 'baggage', + 'sentry-trace', + 'sentry-prerender', + 'my-custom-key', + ]); }); - it('does NOT enable trace meta tags when Cache Components is enabled', () => { - // With Cache Components, the shell is prerendered/detached from the request, so the meta-tag - // trace would be stale. The SDK skips enabling `clientTraceMetadata` so the browser pageload - // starts a fresh trace instead of stitching onto a stale trace. + it('enables trace meta tags when Cache Components is enabled', () => { + // The server withholds the tags while it prerenders, see `nextSentryPropagator.ts`. const config: NextConfigObject = { cacheComponents: true }; maybeSetClientTraceMetadataOption(config, '16.0.0'); - expect(config.experimental?.clientTraceMetadata).toBeUndefined(); + expect(config.experimental?.clientTraceMetadata).toEqual(['baggage', 'sentry-trace', 'sentry-prerender']); }); }); diff --git a/packages/nextjs/test/config/turbopack/constructTurbopackConfig.test.ts b/packages/nextjs/test/config/turbopack/constructTurbopackConfig.test.ts index d647c4d78ccf..844ac3f97644 100644 --- a/packages/nextjs/test/config/turbopack/constructTurbopackConfig.test.ts +++ b/packages/nextjs/test/config/turbopack/constructTurbopackConfig.test.ts @@ -32,7 +32,6 @@ describe('constructTurbopackConfig', () => { { path: '/users', regex: '/users' }, { path: '/api/health', regex: '/api/health' }, ], - isrRoutes: [], }; const mockSentryOptions = {}; @@ -285,7 +284,7 @@ describe('constructTurbopackConfig', () => { describe('with edge cases', () => { it('should handle empty route manifest', () => { const userNextConfig: NextConfigObject = {}; - const emptyManifest: RouteManifest = { dynamicRoutes: [], staticRoutes: [], isrRoutes: [] }; + const emptyManifest: RouteManifest = { dynamicRoutes: [], staticRoutes: [] }; const result = constructTurbopackConfig({ userNextConfig, @@ -325,7 +324,6 @@ describe('constructTurbopackConfig', () => { it('should handle complex route manifest', () => { const userNextConfig: NextConfigObject = {}; const complexManifest: RouteManifest = { - isrRoutes: [], dynamicRoutes: [ { path: '/users/[id]/posts/[postId]', regex: '/users/([^/]+)/posts/([^/]+)', paramNames: ['id', 'postId'] }, { path: '/api/[...params]', regex: '/api/(.+)', paramNames: ['params'] }, @@ -901,7 +899,7 @@ describe('condition field version gating', () => { const result = constructTurbopackConfig({ userNextConfig, - routeManifest: { dynamicRoutes: [], staticRoutes: [], isrRoutes: [] }, + routeManifest: { dynamicRoutes: [], staticRoutes: [] }, nextJsVersion: '17.0.0', }); @@ -1064,7 +1062,6 @@ describe('moduleMetadataInjection with applicationKey', () => { const mockRouteManifest: RouteManifest = { dynamicRoutes: [], staticRoutes: [{ path: '/', regex: '/' }], - isrRoutes: [], }; const result = constructTurbopackConfig({ @@ -1264,7 +1261,6 @@ describe('componentAnnotation with turbopackReactComponentAnnotation', () => { const mockRouteManifest: RouteManifest = { dynamicRoutes: [], staticRoutes: [{ path: '/', regex: '/' }], - isrRoutes: [], }; const result = constructTurbopackConfig({ diff --git a/packages/nextjs/test/config/withSentryConfig/buildTime.test.ts b/packages/nextjs/test/config/withSentryConfig/buildTime.test.ts index b81e3c48ee86..184ede925bb5 100644 --- a/packages/nextjs/test/config/withSentryConfig/buildTime.test.ts +++ b/packages/nextjs/test/config/withSentryConfig/buildTime.test.ts @@ -58,3 +58,17 @@ describe('setUpBuildTimeVariables', () => { }); }); }); + +describe('_sentryCacheComponents', () => { + it('is set when the Next.js config enables cacheComponents', () => { + const config: NextConfigObject = { cacheComponents: true }; + setUpBuildTimeVariables(config, {}, undefined); + expect(config.env?._sentryCacheComponents).toBe('true'); + }); + + it('is left unset otherwise', () => { + const config: NextConfigObject = {}; + setUpBuildTimeVariables(config, {}, undefined); + expect(config.env?._sentryCacheComponents).toBeUndefined(); + }); +}); diff --git a/packages/nextjs/test/performance/nextRoutingInstrumentation.test.ts b/packages/nextjs/test/performance/nextRoutingInstrumentation.test.ts index 4b6b117e2117..bc107683f4ba 100644 --- a/packages/nextjs/test/performance/nextRoutingInstrumentation.test.ts +++ b/packages/nextjs/test/performance/nextRoutingInstrumentation.test.ts @@ -2,10 +2,8 @@ import type { Client } from '@sentry/core'; import { WINDOW } from '@sentry/react'; import { JSDOM } from 'jsdom'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { - appRouterInstrumentNavigation, - appRouterInstrumentPageLoad, -} from '../../src/client/routing/appRouterRoutingInstrumentation'; +import { appRouterInstrumentNavigation } from '../../src/client/routing/appRouterRoutingInstrumentation'; +import { appRouterInstrumentPageLoad } from '../../src/client/routing/appRouterPageloadInstrumentation'; import { nextRouterInstrumentNavigation, nextRouterInstrumentPageLoad, @@ -13,9 +11,12 @@ import { import { pagesRouterInstrumentNavigation } from '../../src/client/routing/pagesRouterNavigationInstrumentation'; import { pagesRouterInstrumentPageLoad } from '../../src/client/routing/pagesRouterRoutingInstrumentation'; +vi.mock('../../src/client/routing/appRouterPageloadInstrumentation', () => ({ + appRouterInstrumentPageLoad: vi.fn(), +})); + vi.mock('../../src/client/routing/appRouterRoutingInstrumentation', () => ({ appRouterInstrumentNavigation: vi.fn(), - appRouterInstrumentPageLoad: vi.fn(), })); vi.mock('../../src/client/routing/pagesRouterNavigationInstrumentation', () => ({ pagesRouterInstrumentNavigation: vi.fn(), diff --git a/packages/nextjs/test/server/nextSentryPropagator.test.ts b/packages/nextjs/test/server/nextSentryPropagator.test.ts new file mode 100644 index 000000000000..0fc4903996ec --- /dev/null +++ b/packages/nextjs/test/server/nextSentryPropagator.test.ts @@ -0,0 +1,164 @@ +import { context, propagation, trace } from '@opentelemetry/api'; +import { getCurrentScope, setCurrentClient, startSpan } from '@sentry/core'; +import { NodeClient, getDefaultIntegrations } from '@sentry/node'; +import { SentryPropagator, SentryTracerProvider } from '@sentry/opentelemetry'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { ATTR_NEXT_SPAN_NAME, ATTR_NEXT_SPAN_TYPE } from '../../src/common/nextSpanAttributes'; +import { NextSentryPropagator, registerNextSentryPropagator } from '../../src/server/nextSentryPropagator'; +import { isPrerenderContext, isPrerenderSpanStart } from '../../src/server/prerenderContext'; + +const PRERENDER_SPAN_OPTIONS = { + attributes: { + [ATTR_NEXT_SPAN_TYPE]: 'AppRender.getBodyResult', + [ATTR_NEXT_SPAN_NAME]: 'prerender route (app) /[id]', + }, +}; + +const RENDER_SPAN_OPTIONS = { + attributes: { + [ATTR_NEXT_SPAN_TYPE]: 'AppRender.getBodyResult', + [ATTR_NEXT_SPAN_NAME]: 'render route (app) /blocking', + }, +}; + +// Next.js collects the meta tag values with an array carrier, see `getTracePropagationData` of its tracer. +function inject(): Record { + const entries: Array<{ key: string; value: string }> = []; + propagation.inject(context.active(), entries, { + set: (c, key, value) => (c as typeof entries).push({ key, value }), + }); + return Object.fromEntries(entries.map(({ key, value }) => [key, value])); +} + +function injectHeaders(): Record { + const headers: Record = {}; + propagation.inject(context.active(), headers); + return headers; +} + +function setup(tracesSampleRate: number): void { + const client = new NodeClient({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + tracesSampleRate, + enableOpenTelemetrySetup: true, + integrations: getDefaultIntegrations({}), + stackParser: () => [], + transport: () => ({ send: () => Promise.resolve({}), flush: () => Promise.resolve(true) }), + }); + setCurrentClient(client); + client.init(); + + const provider = new SentryTracerProvider(); + trace.setGlobalTracerProvider(provider); + propagation.setGlobalPropagator(new SentryPropagator()); + registerNextSentryPropagator(provider); +} + +describe('NextSentryPropagator', () => { + beforeEach(() => setup(1)); + + afterEach(() => { + getCurrentScope().setClient(undefined); + trace.disable(); + context.disable(); + propagation.disable(); + vi.restoreAllMocks(); + }); + + it('replaces the registered global propagator', () => { + const injectSpy = vi.spyOn(NextSentryPropagator.prototype, 'inject'); + inject(); + expect(injectSpy).toHaveBeenCalledTimes(1); + }); + + it('hands out trace context while a route is rendered for a request', () => { + trace.getTracer('next.js').startActiveSpan('render route (app) /blocking', RENDER_SPAN_OPTIONS, span => { + expect(isPrerenderContext(context.active())).toBe(false); + expect(inject()).toEqual({ + 'sentry-trace': `${span.spanContext().traceId}-${span.spanContext().spanId}-1`, + baggage: expect.stringContaining('sentry-trace_id='), + }); + span.end(); + }); + }); + + it('hands out only the shell marker with the trace of the prerender while a route is prerendered', () => { + trace.getTracer('next.js').startActiveSpan('prerender route (app) /[id]', PRERENDER_SPAN_OPTIONS, span => { + expect(isPrerenderContext(context.active())).toBe(true); + expect(inject()).toEqual({ + 'sentry-prerender': `${span.spanContext().traceId}-${span.spanContext().spanId}-1`, + }); + span.end(); + }); + }); + + it('keeps propagating to outgoing requests while a route is prerendered', () => { + trace.getTracer('next.js').startActiveSpan('prerender route (app) /[id]', PRERENDER_SPAN_OPTIONS, span => { + expect(inject()).toEqual({ 'sentry-prerender': expect.stringMatching(/-1$/) }); + expect(injectHeaders()['sentry-trace']).toBe(`${span.spanContext().traceId}-${span.spanContext().spanId}-1`); + span.end(); + }); + }); + + it('hands out nothing inside a span nested in a prerender', () => { + trace.getTracer('next.js').startActiveSpan('prerender route (app) /[id]', PRERENDER_SPAN_OPTIONS, span => { + startSpan({ name: 'server component' }, () => { + expect(Object.keys(inject())).toEqual(['sentry-prerender']); + }); + span.end(); + }); + }); + + it('hands out only the shell marker while a route is prerendered in an unsampled trace', () => { + setup(0); + + trace.getTracer('next.js').startActiveSpan('GET /[id]', span => { + expect(span.isRecording()).toBe(false); + expect(inject()['sentry-trace']).toMatch(/-0$/); + + trace + .getTracer('next.js') + .startActiveSpan('prerender route (app) /[id]', PRERENDER_SPAN_OPTIONS, prerenderSpan => { + expect(prerenderSpan.isRecording()).toBe(false); + expect(inject()).toEqual({ 'sentry-prerender': expect.stringMatching(/-0$/) }); + prerenderSpan.end(); + }); + span.end(); + }); + }); + + it('recognizes a prerender span that was started inactive and activated by the caller', () => { + const span = trace.getTracer('next.js').startSpan('prerender route (app) /[id]', PRERENDER_SPAN_OPTIONS); + context.with(trace.setSpan(context.active(), span), () => { + expect(isPrerenderContext(context.active())).toBe(true); + expect(Object.keys(inject())).toEqual(['sentry-prerender']); + }); + span.end(); + + const renderSpan = trace.getTracer('next.js').startSpan('render route (app) /blocking', RENDER_SPAN_OPTIONS); + context.with(trace.setSpan(context.active(), renderSpan), () => { + expect(inject()['sentry-trace']).toBeDefined(); + }); + renderSpan.end(); + }); + + it('marks an explicitly passed context', () => { + trace + .getTracer('next.js') + .startActiveSpan('prerender route (app) /[id]', PRERENDER_SPAN_OPTIONS, context.active(), span => { + expect(isPrerenderContext(context.active())).toBe(true); + expect(Object.keys(inject())).toEqual(['sentry-prerender']); + span.end(); + }); + }); + + it('does not mistake other Next.js spans for a prerender', () => { + expect( + isPrerenderSpanStart('prerender route (app) /x', { + attributes: { [ATTR_NEXT_SPAN_TYPE]: 'NextNodeServer.getLayoutOrPageModule' }, + }), + ).toBe(false); + expect(isPrerenderSpanStart('prerender route (app) /x', undefined)).toBe(false); + expect(isPrerenderSpanStart('prerender route (app) /x', PRERENDER_SPAN_OPTIONS)).toBe(true); + }); +}); diff --git a/packages/nextjs/test/serverSdk.test.ts b/packages/nextjs/test/serverSdk.test.ts index 61d15736a3d0..7ef42977be47 100644 --- a/packages/nextjs/test/serverSdk.test.ts +++ b/packages/nextjs/test/serverSdk.test.ts @@ -6,6 +6,13 @@ import * as SentryNode from '@sentry/node'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { TRANSACTION_ATTR_SHOULD_DROP_TRANSACTION } from '../src/common/span-attributes-with-logic-attached'; import { init } from '../src/server'; +import type * as NextSentryPropagatorModule from '../src/server/nextSentryPropagator'; +import { registerNextSentryPropagator } from '../src/server/nextSentryPropagator'; + +vi.mock('../src/server/nextSentryPropagator', async importOriginal => ({ + ...(await importOriginal()), + registerNextSentryPropagator: vi.fn(), +})); // normally this is set as part of the build process, so mock it here (GLOBAL_OBJ as typeof GLOBAL_OBJ & { _sentryRewriteFramesDistDir: string })._sentryRewriteFramesDistDir = '.next'; @@ -63,6 +70,33 @@ describe('Server init()', () => { ); }); + describe('trace propagator', () => { + it('replaces the propagator when the Node SDK set up OpenTelemetry itself', () => { + const traceProvider = {}; + nodeInit.mockReturnValueOnce({ + on: vi.fn(), + addEventProcessor: vi.fn(), + traceProvider, + } as unknown as ReturnType); + + init({}); + + expect(registerNextSentryPropagator).toHaveBeenCalledWith(traceProvider); + }); + + it('leaves a foreign OpenTelemetry setup alone', () => { + nodeInit.mockReturnValueOnce({ + on: vi.fn(), + addEventProcessor: vi.fn(), + traceProvider: undefined, + } as unknown as ReturnType); + + init({}); + + expect(registerNextSentryPropagator).not.toHaveBeenCalled(); + }); + }); + it("doesn't reinitialize the node SDK if already initialized", () => { expect(nodeInit).toHaveBeenCalledTimes(0); init({});