diff --git a/CHANGELOG.md b/CHANGELOG.md index 29fecaa4a5ad..d6900883bcaa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,10 @@ - "You miss 100 percent of the chances you don't take. — Wayne Gretzky" — Michael Scott +- **feat(nextjs): Mark pageloads of cached pages ([#25318](https://github.com/getsentry/sentry-javascript/pull/25318))** + + When Next.js serves a page from its cache, for example an ISR page or a `cacheComponents` shell, the pageload now has the `page.server_rendering.type` attribute. It is `static` if the whole page came from the cache, and `partially_static` if parts of it rendered for this request. Then the pageload stays connected to this request, as before. If the request that rendered the cached page was sampled, the pageload also links to it. So in Sentry, a cached page no longer looks like missing server instrumentation. + Work in this release was contributed by @lazerg and @chiliec. Thank you for your contributions! ## 11.10.0 diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/app/runtime-shell/page.tsx b/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/app/runtime-shell/page.tsx new file mode 100644 index 000000000000..1cf4a1dd4663 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/app/runtime-shell/page.tsx @@ -0,0 +1,27 @@ +import { cacheLife } from 'next/cache'; +import { headers } from 'next/headers'; +import { Suspense } from 'react'; + +// The shell goes stale after a second, so a later visit makes the server prerender it again at +// runtime. An `expire` below 5 minutes would make Next.js leave this out of the shell. +async function RenderedAt() { + 'use cache'; + cacheLife({ revalidate: 1, expire: 3600 }); + return

{Date.now()}

; +} + +async function RequestContent() { + await headers(); + return

Rendered for this request

; +} + +export default function Page() { + return ( + <> + + Loading...

}> + +
+ + ); +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/app/slow-hole/page.tsx b/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/app/slow-hole/page.tsx new file mode 100644 index 000000000000..201e4102cb0d --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/app/slow-hole/page.tsx @@ -0,0 +1,24 @@ +import { headers } from 'next/headers'; +import Link from 'next/link'; +import { Suspense } from 'react'; + +// The dynamic part, and with it the trace meta tag, streams in seconds after the shell, so a test +// can navigate while the pageload still waits for the tag. +async function SlowContent() { + await headers(); + await new Promise(resolve => setTimeout(resolve, 4000)); + return

Slow content

; +} + +export default function Page() { + return ( + <> + + Static shell + + Loading...

}> + +
+ + ); +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/package.json b/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/package.json index eba44d444994..2534a55b6b69 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/package.json +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/package.json @@ -35,6 +35,7 @@ "devDependencies": { "@playwright/test": "1.64.1", "@sentry-internal/test-utils": "file:../../packed/sentry-internal-test-utils-packed.tgz", + "@sentry/conventions": "0.27.0", "@types/node": "^20", "@types/react": "^19", "@types/react-dom": "^19", 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 900ce43679e9..f06c4da961cd 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,5 +1,6 @@ import type { Page } from '@playwright/test'; import { expect, test } from '@playwright/test'; +import { SENTRY_LINK_TYPE } from '@sentry/conventions/attributes'; import { waitForTransaction } from '@sentry-internal/test-utils'; const APP = 'nextjs-16-cacheComponents'; @@ -7,7 +8,8 @@ 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. +// which the pageload instrumentation waits for. A shell prerendered at runtime carries a +// `sentry-cache-origin` tag instead, which marks the pageload as static 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()`) @@ -30,7 +32,6 @@ 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); - expect(pageloadTx.contexts?.trace?.links).toBeUndefined(); }); test('connects the pageload trace to a blocking render', async ({ page }) => { @@ -58,10 +59,11 @@ test('does not share a trace between visitors of a shell generated at request ti 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, so every visitor starts a fresh trace. + // shell for this id. The prerender writes no trace tag, only a `sentry-cache-origin` tag with its own + // trace, so every visitor starts a fresh trace that links the prerender and is marked as static. const id = `shell-${Date.now()}`; - const visit = async (): Promise<{ traceId: string | undefined; links: unknown }> => { + const visit = async (): Promise<{ traceId: string | undefined; links: unknown; serverRenderingType: unknown }> => { const context = await browser.newContext(); const page: Page = await context.newPage(); @@ -75,11 +77,16 @@ test('does not share a trace between visitors of a shell generated at request ti 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-cache-origin"]').count()).toBe(1); const pageloadTx = await pageloadTxPromise; await context.close(); - return { traceId: pageloadTx.contexts?.trace?.trace_id, links: pageloadTx.contexts?.trace?.links }; + return { + traceId: pageloadTx.contexts?.trace?.trace_id, + links: pageloadTx.contexts?.trace?.links, + serverRenderingType: pageloadTx.contexts?.trace?.data?.['page.server_rendering.type'], + }; }; const first = await visit(); @@ -88,8 +95,73 @@ test('does not share a trace between visitors of a shell generated at request ti expect(first.traceId).toBeTruthy(); expect(second.traceId).toBeTruthy(); expect(second.traceId).not.toBe(first.traceId); - expect(first.links).toBeUndefined(); - expect(second.links).toBeUndefined(); + expect(first.serverRenderingType).toBe('static'); + expect(second.serverRenderingType).toBe('static'); + + // Both link the request that prerendered the shell, which is the first visitor's own request. The + // client cannot tell the first visit from later ones, so that pageload starts a fresh trace too. + 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('continues the request trace on a shell prerendered at runtime and links the prerender', async ({ + browser, + page, + baseURL, +}) => { + test.skip(process.env.TEST_ENV !== 'production', 'Shells are only cached in production builds'); + + // The shell from the build carries no `sentry-cache-origin` tag. Wait until a visit serves the + // shell that the server prerendered again at runtime. + const warmup = await browser.newPage(); + await warmup.goto('/runtime-shell'); + const renderedAtBuild = await warmup.locator('#rendered-at').textContent(); + await expect + .poll( + async () => { + await warmup.waitForTimeout(1500); + await warmup.goto('/runtime-shell'); + return warmup.locator('#rendered-at').textContent(); + }, + { timeout: 30_000 }, + ) + .not.toBe(renderedAtBuild); + await warmup.close(); + + const pageloadTxPromise = waitForTransaction(APP, transactionEvent => { + return ( + transactionEvent.contexts?.trace?.op === 'pageload' && + transactionEvent.contexts?.trace?.data?.['url.full'] === `${baseURL}/runtime-shell?visit=last` + ); + }); + + await page.goto('/runtime-shell?visit=last'); + await expect(page.locator('#request-content')).toHaveText('Rendered for this request'); + const [requestTraceId] = (await page.locator('meta[name="sentry-trace"]').getAttribute('content'))!.split('-'); + const [prerenderTraceId, prerenderSpanId] = (await page + .locator('meta[name="sentry-cache-origin"]') + .getAttribute('content'))!.split('-'); + + const pageloadTx = await pageloadTxPromise; + + // Only the shell came from the cache. The rest of the page rendered for this request, and the + // pageload continues that trace. + expect(pageloadTx.contexts?.trace?.trace_id).toBe(requestTraceId); + expect(pageloadTx.contexts?.trace?.data?.['page.server_rendering.type']).toBe('partially_static'); + expect(pageloadTx.contexts?.trace?.links).toEqual([ + { + trace_id: prerenderTraceId, + span_id: prerenderSpanId, + sampled: true, + attributes: { [SENTRY_LINK_TYPE]: 'cache_origin' }, + }, + ]); + expect(prerenderTraceId).not.toBe(requestTraceId); }); test('starts a fresh trace on a fully static page', async ({ page }) => { @@ -105,8 +177,38 @@ test('starts a fresh trace on a fully static page', async ({ page }) => { const pageloadTx = await pageloadTxPromise; - // A build-time shell carries no trace context at all. + // 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-cache-origin"]').count()).toBe(0); expect(pageloadTx.contexts?.trace?.trace_id).toBeTruthy(); expect(pageloadTx.contexts?.trace?.links).toBeUndefined(); + expect(pageloadTx.contexts?.trace?.data?.['page.server_rendering.type']).toBeUndefined(); +}); + +test('keeps a navigation that starts while the pageload waits for the trace meta tag', async ({ page }) => { + test.skip(process.env.TEST_ENV !== 'production', 'Only a prerendered shell makes the trace meta tag arrive late'); + + const pageloadTxPromise = waitForTransaction(APP, transactionEvent => { + return transactionEvent.contexts?.trace?.op === 'pageload' && transactionEvent.transaction === '/slow-hole'; + }); + + const navigationTxPromise = waitForTransaction(APP, transactionEvent => { + return transactionEvent.contexts?.trace?.op === 'navigation' && transactionEvent.transaction === '/static-shell'; + }); + + await page.goto('/slow-hole', { waitUntil: 'commit' }); + await page.waitForFunction(() => !!(window as { next?: { router?: unknown } }).next?.router); + + // The resumed part, which carries the tag, has not arrived yet, so the pageload is still waiting. + await expect(page.locator('#slow-fallback')).toBeVisible(); + expect(await page.locator('meta[name="sentry-trace"]').count()).toBe(0); + + await page.locator('#navigate').click(); + await expect(page.locator('#static-shell')).toHaveText('Static shell'); + + const [pageloadTx, navigationTx] = await Promise.all([pageloadTxPromise, navigationTxPromise]); + + // The navigation ends the pageload like any pageload it interrupts, and runs to its own idle timeout. + expect(pageloadTx.contexts?.trace?.data?.['sentry.idle_span_finish_reason']).toBe('cancelled'); + expect(navigationTx.contexts?.trace?.data?.['sentry.idle_span_finish_reason']).toBe('idleTimeout'); }); diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/app/runtime-shell/page.tsx b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/app/runtime-shell/page.tsx new file mode 100644 index 000000000000..1cf4a1dd4663 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/app/runtime-shell/page.tsx @@ -0,0 +1,27 @@ +import { cacheLife } from 'next/cache'; +import { headers } from 'next/headers'; +import { Suspense } from 'react'; + +// The shell goes stale after a second, so a later visit makes the server prerender it again at +// runtime. An `expire` below 5 minutes would make Next.js leave this out of the shell. +async function RenderedAt() { + 'use cache'; + cacheLife({ revalidate: 1, expire: 3600 }); + return

{Date.now()}

; +} + +async function RequestContent() { + await headers(); + return

Rendered for this request

; +} + +export default function Page() { + return ( + <> + + Loading...

}> + +
+ + ); +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/package.json b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/package.json index 24c37deeba72..c78827f5595f 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/package.json +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/package.json @@ -35,6 +35,7 @@ "devDependencies": { "@playwright/test": "1.64.1", "@sentry-internal/test-utils": "file:../../packed/sentry-internal-test-utils-packed.tgz", + "@sentry/conventions": "0.27.0", "@types/node": "^20", "@types/react": "^19", "@types/react-dom": "^19", 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 89f9dc32d26c..3d1512789f82 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,5 +1,6 @@ import type { Page } from '@playwright/test'; import { expect, test } from '@playwright/test'; +import { SENTRY_LINK_TYPE } from '@sentry/conventions/attributes'; import { getSpanOp, waitForStreamedSpan } from '@sentry-internal/test-utils'; const APP = 'nextjs-16-streaming-cacheComponents'; @@ -7,7 +8,8 @@ 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. +// which the pageload instrumentation waits for. A shell prerendered at runtime carries a +// `sentry-cache-origin` tag instead, which marks the pageload as static 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()`) @@ -28,7 +30,6 @@ 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); - expect(pageloadSpan.links).toBeUndefined(); }); test('connects the pageload trace to a blocking render', async ({ page }) => { @@ -56,10 +57,11 @@ test('does not share a trace between visitors of a shell generated at request ti 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, so every visitor starts a fresh trace. + // shell for this id. The prerender writes no trace tag, only a `sentry-cache-origin` tag with its own + // trace, so every visitor starts a fresh trace that links the prerender and is marked as static. const id = `shell-${Date.now()}`; - const visit = async (): Promise<{ traceId: string | undefined; links: unknown }> => { + const visit = async (): Promise<{ traceId: string | undefined; links: unknown; serverRenderingType: unknown }> => { const context = await browser.newContext(); const page: Page = await context.newPage(); @@ -70,11 +72,16 @@ test('does not share a trace between visitors of a shell generated at request ti 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-cache-origin"]').count()).toBe(1); const pageloadSpan = await pageloadSpanPromise; await context.close(); - return { traceId: pageloadSpan.trace_id, links: pageloadSpan.links }; + return { + traceId: pageloadSpan.trace_id, + links: pageloadSpan.links, + serverRenderingType: pageloadSpan.attributes['page.server_rendering.type']?.value, + }; }; const first = await visit(); @@ -83,8 +90,74 @@ test('does not share a trace between visitors of a shell generated at request ti expect(first.traceId).toBeTruthy(); expect(second.traceId).toBeTruthy(); expect(second.traceId).not.toBe(first.traceId); - expect(first.links).toBeUndefined(); - expect(second.links).toBeUndefined(); + expect(first.serverRenderingType).toBe('static'); + expect(second.serverRenderingType).toBe('static'); + + // Both link the request that prerendered the shell, which is the first visitor's own request. The + // client cannot tell the first visit from later ones, so that pageload starts a fresh trace too. + 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('continues the request trace on a shell prerendered at runtime and links the prerender', async ({ + browser, + page, + baseURL, +}) => { + test.skip(process.env.TEST_ENV !== 'production', 'Shells are only cached in production builds'); + + // The shell from the build carries no `sentry-cache-origin` tag. Wait until a visit serves the + // shell that the server prerendered again at runtime. + const warmup = await browser.newPage(); + await warmup.goto('/runtime-shell'); + const renderedAtBuild = await warmup.locator('#rendered-at').textContent(); + await expect + .poll( + async () => { + await warmup.waitForTimeout(1500); + await warmup.goto('/runtime-shell'); + return warmup.locator('#rendered-at').textContent(); + }, + { timeout: 30_000 }, + ) + .not.toBe(renderedAtBuild); + await warmup.close(); + + const pageloadSpanPromise = waitForStreamedSpan(APP, span => { + return ( + getSpanOp(span) === 'pageload' && + span.is_segment && + span.attributes['url.full']?.value === `${baseURL}/runtime-shell?visit=last` + ); + }); + + await page.goto('/runtime-shell?visit=last'); + await expect(page.locator('#request-content')).toHaveText('Rendered for this request'); + const [requestTraceId] = (await page.locator('meta[name="sentry-trace"]').getAttribute('content'))!.split('-'); + const [prerenderTraceId, prerenderSpanId] = (await page + .locator('meta[name="sentry-cache-origin"]') + .getAttribute('content'))!.split('-'); + + const pageloadSpan = await pageloadSpanPromise; + + // Only the shell came from the cache. The rest of the page rendered for this request, and the + // pageload continues that trace. + expect(pageloadSpan.trace_id).toBe(requestTraceId); + expect(pageloadSpan.attributes['page.server_rendering.type']?.value).toBe('partially_static'); + expect(pageloadSpan.links).toEqual([ + { + trace_id: prerenderTraceId, + span_id: prerenderSpanId, + sampled: true, + attributes: { [SENTRY_LINK_TYPE]: { type: 'string', value: 'cache_origin' } }, + }, + ]); + expect(prerenderTraceId).not.toBe(requestTraceId); }); test('starts a fresh trace on a fully static page', async ({ page }) => { @@ -100,10 +173,12 @@ test('starts a fresh trace on a fully static page', async ({ page }) => { const pageloadSpan = await pageloadSpanPromise; - // A build-time shell carries no trace context at all. + // 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-cache-origin"]').count()).toBe(0); expect(pageloadSpan.trace_id).toBeTruthy(); expect(pageloadSpan.links).toBeUndefined(); + expect(pageloadSpan.attributes['page.server_rendering.type']).toBeUndefined(); }); test('keeps a navigation that starts while the pageload waits for the trace meta tag', async ({ page }) => { 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 5a210e8426c6..98de20bf60f3 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 @@ -51,7 +51,7 @@ test.describe('prerendered ISR pages', () => { 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 }) => { + test('carries only the cache origin 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. @@ -71,6 +71,7 @@ test.describe('prerendered ISR pages', () => { 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-cache-origin"]')).toHaveCount(1); }); }); diff --git a/packages/nextjs/src/client/browserTracingIntegration.ts b/packages/nextjs/src/client/browserTracingIntegration.ts index acd961a77c8e..0ca0cbec4a15 100644 --- a/packages/nextjs/src/client/browserTracingIntegration.ts +++ b/packages/nextjs/src/client/browserTracingIntegration.ts @@ -1,6 +1,6 @@ import type { Integration } from '@sentry/core'; import { browserTracingIntegration as originalBrowserTracingIntegration, isBotUserAgent } from '@sentry/react'; -import { settlePendingPageloadWait } from './routing/appRouterRoutingInstrumentation'; +import { settlePendingPageloadWait } from './routing/appRouterPageloadInstrumentation'; import { nextRouterInstrumentNavigation, nextRouterInstrumentPageLoad } from './routing/nextRoutingInstrumentation'; /** diff --git a/packages/nextjs/src/client/routing/appRouterPageloadInstrumentation.ts b/packages/nextjs/src/client/routing/appRouterPageloadInstrumentation.ts new file mode 100644 index 000000000000..bab9ad8e2bda --- /dev/null +++ b/packages/nextjs/src/client/routing/appRouterPageloadInstrumentation.ts @@ -0,0 +1,112 @@ +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, + canWaitForTraceMetaTag, + readTraceMetaTags, + waitForTraceMetaTag, + withCacheOrigin, +} 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 span = startBrowserTracingPageLoadSpan(client, withCacheOrigin(spanOptions, traceMetaTags), 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 = filterCollectedUrl(WINDOW.location.href, client); + 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]: documentUrl, + ...(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 ba6e5c88986e..9f24219a1ca8 100644 --- a/packages/nextjs/src/client/routing/appRouterRoutingInstrumentation.ts +++ b/packages/nextjs/src/client/routing/appRouterRoutingInstrumentation.ts @@ -1,25 +1,20 @@ -import type { Client, Span, StartSpanOptions } from '@sentry/core'; +import type { Client, Span } from '@sentry/core'; import { - getCurrentScope, GLOBAL_OBJ, hasSpanStreamingEnabled, isObjectLike, NAVIGATION_SPAN_NAME_FALLBACK, - PAGELOAD_SPAN_NAME_FALLBACK, filterCollectedUrl, timestampInSeconds, } from '@sentry/core'; import { startBrowserTracingNavigationSpan, - startBrowserTracingPageLoadSpan, WINDOW, getAbsoluteUrl, resolveCurrentRoute, resolveRoute, } from '@sentry/react'; import { stripTrailingSlash } from './parameterization'; -import type { TraceMetaTagValues, TraceMetaTagWait } from './traceMetaTags'; -import { addTraceMetaTagLink, canWaitForTraceMetaTag, readTraceMetaTags, waitForTraceMetaTag } from './traceMetaTags'; import { SENTRY_OP, SENTRY_SEGMENT_NAME_SOURCE, @@ -29,7 +24,7 @@ import { SENTRY_ORIGIN, NAVIGATION_TYPE, } 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({ @@ -99,92 +94,6 @@ let navigationRoutingMode: 'router-patch' | 'transition-start-hook' = 'router-pa const currentRouterPatchingNavigationSpanRef: NavigationSpanRef = { current: undefined }; -/** - * 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 span = startBrowserTracingPageLoadSpan(client, 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 = filterCollectedUrl(WINDOW.location.href, client); - 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]: documentUrl, - ...(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, - ); -} - interface NavigationSpanRef { current: Span | undefined; } 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/traceMetaTags.ts b/packages/nextjs/src/client/routing/traceMetaTags.ts index 8e1138000ec5..0d85189e8b4b 100644 --- a/packages/nextjs/src/client/routing/traceMetaTags.ts +++ b/packages/nextjs/src/client/routing/traceMetaTags.ts @@ -1,7 +1,7 @@ import { SENTRY_LINK_TYPE } from '@sentry/conventions/attributes'; -import type { Span } from '@sentry/core'; -import { extractTraceparentData } from '@sentry/core'; -import { GLOBAL_OBJ } from '@sentry/core'; +import type { Span, SpanLink, StartSpanOptions } from '@sentry/core'; +import { extractTraceparentData, GLOBAL_OBJ } from '@sentry/core'; +import { CACHE_ORIGIN_META_TAG_NAME } from '../../common/traceMetaTags'; import { WINDOW } from '@sentry/react'; // `@opentelemetry/api` `TraceFlags`, inlined because core does not export its copies. @@ -9,12 +9,16 @@ const TRACE_FLAG_SAMPLED = 0x1; const TRACE_FLAG_NONE = 0x0; const SERVER_REQUEST_LINK_TYPE = 'server_request'; +const CACHE_ORIGIN_LINK_TYPE = 'cache_origin'; export interface TraceMetaTagValues { sentryTrace: string; baggage: string | undefined; } +// Not in `@sentry/conventions` yet: https://github.com/getsentry/sentry-conventions/pull/740 +const PAGE_SERVER_RENDERING_TYPE = 'page.server_rendering.type'; + function getDocument(): Document | undefined { return WINDOW.document; } @@ -131,22 +135,65 @@ export function waitForTraceMetaTag( return { giveUp: () => settle(onGiveUp) }; } -/** - * 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 { +/** 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; + return undefined; } - - span.addLink({ + return { context: { traceId: traceparent.traceId, spanId: traceparent.parentSpanId, traceFlags: traceparent.parentSampled ? TRACE_FLAG_SAMPLED : TRACE_FLAG_NONE, }, - attributes: { [SENTRY_LINK_TYPE]: SERVER_REQUEST_LINK_TYPE }, - }); + 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, SERVER_REQUEST_LINK_TYPE); + if (link) { + span.addLink(link); + } +} + +/** + * How the server rendered a document with a `sentry-cache-origin` tag. With trace meta tags of this + * request, only the shell was reused. Unset while a resumed part can still bring those tags. + */ +function getServerRenderingType(traceMetaTags: TraceMetaTagValues | undefined): string | undefined { + if (traceMetaTags) { + return 'partially_static'; + } + return canWaitForTraceMetaTag() ? undefined : 'static'; +} + +/** + * Marks a pageload whose document carries a `sentry-cache-origin` tag with how the server rendered + * it, and links the render that the page reuses. Applied at span start, so `tracesSampler` and + * `ignoreSpans` see both. Like a `use cache` hit, the pageload links only a sampled render. + */ +export function withCacheOrigin(spanOptions: StartSpanOptions, traceMetaTags?: TraceMetaTagValues): StartSpanOptions { + const document = getDocument(); + const cacheOrigin = document && lastMetaContent(document, CACHE_ORIGIN_META_TAG_NAME); + if (!cacheOrigin) { + return spanOptions; + } + + const link = traceMetaTagLink(cacheOrigin, CACHE_ORIGIN_LINK_TYPE); + const isSampled = link?.context.traceFlags === TRACE_FLAG_SAMPLED; + const serverRenderingType = getServerRenderingType(traceMetaTags); + return { + ...spanOptions, + attributes: { + ...spanOptions.attributes, + ...(serverRenderingType && { [PAGE_SERVER_RENDERING_TYPE]: serverRenderingType }), + }, + links: link && isSampled ? [...(spanOptions.links ?? []), link] : spanOptions.links, + }; } diff --git a/packages/nextjs/src/common/traceMetaTags.ts b/packages/nextjs/src/common/traceMetaTags.ts new file mode 100644 index 000000000000..90ae909bf962 --- /dev/null +++ b/packages/nextjs/src/common/traceMetaTags.ts @@ -0,0 +1,6 @@ +/** + * Name of the meta tag the server writes in place of `sentry-trace` while Next.js prerenders a document. + * Its content is the `sentry-trace` value of the prerender. The client turns it into a `cache_origin` + * link and never continues it, because the document is served to every later visitor. + */ +export const CACHE_ORIGIN_META_TAG_NAME = 'sentry-cache-origin'; diff --git a/packages/nextjs/src/config/withSentryConfig/getFinalConfigObjectUtils.ts b/packages/nextjs/src/config/withSentryConfig/getFinalConfigObjectUtils.ts index 7ca66c51d1f7..a90529b8d24d 100644 --- a/packages/nextjs/src/config/withSentryConfig/getFinalConfigObjectUtils.ts +++ b/packages/nextjs/src/config/withSentryConfig/getFinalConfigObjectUtils.ts @@ -1,3 +1,4 @@ +import { CACHE_ORIGIN_META_TAG_NAME } from '../../common/traceMetaTags'; import { debug, isMatchingPattern, parseSemver } from '@sentry/core'; import { getSentryRelease } from '@sentry/node'; import * as fs from 'fs'; @@ -194,12 +195,13 @@ export function maybeSetClientTraceMetadataOption( incomingUserNextConfigObject.experimental.clientTraceMetadata = [ 'baggage', 'sentry-trace', + CACHE_ORIGIN_META_TAG_NAME, ...(incomingUserNextConfigObject.experimental?.clientTraceMetadata || []), ]; } } else { getBuildLogger(silent).log( - "[@sentry/nextjs] The Sentry SDK was not able to determine your Next.js version. If you are using Next.js version 15 or greater, please add `experimental.clientTraceMetadata: ['sentry-trace', 'baggage']` to your Next.js config to enable pageload tracing for App Router.", + "[@sentry/nextjs] The Sentry SDK was not able to determine your Next.js version. If you are using Next.js version 15 or greater, please add `experimental.clientTraceMetadata: ['sentry-trace', 'baggage', 'sentry-cache-origin']` to your Next.js config to enable pageload tracing for App Router.", ); } } diff --git a/packages/nextjs/src/server/nextSentryPropagator.ts b/packages/nextjs/src/server/nextSentryPropagator.ts index 1fd3c3438307..b583beff3465 100644 --- a/packages/nextjs/src/server/nextSentryPropagator.ts +++ b/packages/nextjs/src/server/nextSentryPropagator.ts @@ -1,12 +1,14 @@ import type { Context, TextMapSetter, TracerProvider } from '@opentelemetry/api'; -import { propagation } from '@opentelemetry/api'; +import { defaultTextMapSetter, propagation } from '@opentelemetry/api'; import { SentryPropagator } from '@sentry/opentelemetry'; +import { CACHE_ORIGIN_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. + * shell is served to, so the browser could continue a trace that never handled its request. The shell + * gets the prerender's trace in a `sentry-cache-origin` tag instead, which the client only 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 @@ -17,10 +19,17 @@ export class NextSentryPropagator extends SentryPropagator { 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)) { + if (!Array.isArray(carrier) || !isPrerenderContext(ctx)) { + super.inject(ctx, carrier, setter); return; } - super.inject(ctx, carrier, setter); + + const values: Record = {}; + super.inject(ctx, values, defaultTextMapSetter); + const sentryTrace = values['sentry-trace']; + if (sentryTrace) { + setter.set(carrier, CACHE_ORIGIN_META_TAG_NAME, sentryTrace); + } } } diff --git a/packages/nextjs/test/client/appRouterRoutingInstrumentation.test.ts b/packages/nextjs/test/client/appRouterRoutingInstrumentation.test.ts index 0d8bdded0e0d..25ecbfdd3f80 100644 --- a/packages/nextjs/test/client/appRouterRoutingInstrumentation.test.ts +++ b/packages/nextjs/test/client/appRouterRoutingInstrumentation.test.ts @@ -1,5 +1,5 @@ // @vitest-environment jsdom -import { SENTRY_OP, URL_FULL, URL_PATH } from '@sentry/conventions/attributes'; +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'; @@ -12,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 { @@ -67,11 +70,16 @@ async function setup(traceLifecycle: 'stream' | 'static'): Promise<{ router: NextRouter; client: Client; instrumentation: Instrumentation; + pageload: PageloadInstrumentation; }> { vi.resetModules(); const core: Core = await import('@sentry/core'); + // The scopes live on a global carrier that `vi.resetModules()` keeps, so the active span of an + // earlier test would otherwise become the parent of the spans of this one. + core.getMainCarrier().__SENTRY__ = undefined; 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({ @@ -93,7 +101,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)', () => { @@ -269,11 +277,30 @@ describe('appRouterInstrumentNavigation with basePath', () => { describe('appRouterInstrumentPageLoad', () => { const originalReadyState = Object.getOwnPropertyDescriptor(Document.prototype, 'readyState'); + const PRERENDER_TRACE_ID = '11111111111111111111111111111111'; + const PRERENDER_SENTRY_TRACE = `${PRERENDER_TRACE_ID}-1111111111111111-1`; + const PRERENDER_LINK = { + trace_id: PRERENDER_TRACE_ID, + span_id: '1111111111111111', + sampled: true, + attributes: { [SENTRY_LINK_TYPE]: 'cache_origin' }, + }; + + function setReadyState(value: DocumentReadyState): void { + Object.defineProperty(document, 'readyState', { value, configurable: true }); + } + + function appendMeta(parent: HTMLElement, name: string, content: string): void { + const meta = document.createElement('meta'); + meta.setAttribute('name', name); + meta.setAttribute('content', content); + parent.appendChild(meta); + } beforeEach(() => { globalWithNext._sentryRouteManifest = JSON.stringify(manifest); window.history.replaceState({}, '', '/navigation?from=document'); - Object.defineProperty(document, 'readyState', { value: 'loading', configurable: true }); + setReadyState('loading'); vi.spyOn(performance, 'getEntriesByType').mockReturnValue([]); vi.stubEnv('_sentryCacheComponents', 'true'); }); @@ -281,6 +308,9 @@ describe('appRouterInstrumentPageLoad', () => { afterEach(() => { delete globalWithNext.next; delete globalWithNext._sentryRouteManifest; + document.head.innerHTML = ''; + document.body.innerHTML = ''; + vi.useRealTimers(); // @ts-expect-error deleting the instance override restores the prototype getter delete document.readyState; if (originalReadyState) { @@ -291,13 +321,13 @@ describe('appRouterInstrumentPageLoad', () => { }); it('records the URL of the document when the pageload starts after a navigation changed it', async () => { - const { core, instrumentation } = await setup('static'); + const { core, pageload } = await setup('static'); - instrumentation.appRouterInstrumentPageLoad(core.getClient()!); + pageload.appRouterInstrumentPageLoad(core.getClient()!); const spanBeforeSettling = core.getActiveSpan(); window.history.replaceState({}, '', '/navigation/42/router-back'); - instrumentation.settlePendingPageloadWait(); + pageload.settlePendingPageloadWait(); const span = core.getActiveSpan(); expect(span).toBeDefined(); @@ -325,14 +355,82 @@ describe('appRouterInstrumentPageLoad', () => { async ({ cacheComponents }) => { vi.stubEnv('_sentryCacheComponents', cacheComponents); window.history.replaceState({}, '', '/navigation?token=2c4f9e1b&from=document'); - const { core, instrumentation } = await setup('static'); + const { core, pageload } = await setup('static'); - instrumentation.appRouterInstrumentPageLoad(core.getClient()!); - instrumentation.settlePendingPageloadWait(); + pageload.appRouterInstrumentPageLoad(core.getClient()!); + pageload.settlePendingPageloadWait(); const filteredUrl = 'http://localhost:3000/navigation?token=[Filtered]&from=document'; expect(core.spanToJSON(core.getActiveSpan()!).attributes?.[URL_FULL]).toBe(filteredUrl); expect(core.getCurrentScope().getScopeData().sdkProcessingMetadata.normalizedRequest?.url).toBe(filteredUrl); }, ); + + it('starts a new trace for a static page and links the prerender as cache origin', async () => { + const { core, pageload } = await setup('static'); + setReadyState('complete'); + appendMeta(document.head, 'sentry-cache-origin', PRERENDER_SENTRY_TRACE); + + pageload.appRouterInstrumentPageLoad(core.getClient()!); + + const spanJson = core.spanToJSON(core.getActiveSpan()!); + expect(spanJson.attributes?.[SENTRY_OP]).toBe(PAGELOAD); + expect(spanJson.attributes?.['page.server_rendering.type']).toBe('static'); + expect(spanJson.trace_id).not.toBe(PRERENDER_TRACE_ID); + expect(spanJson.links).toEqual([PRERENDER_LINK]); + }); + + // A shell that was prerendered at runtime keeps its tag, and the part resumed for this request + // brings its own `sentry-trace` tag. + it('continues the server trace of a resumed cached shell and links the prerender as cache origin', async () => { + const { core, pageload } = await setup('static'); + setReadyState('complete'); + appendMeta(document.head, 'sentry-cache-origin', PRERENDER_SENTRY_TRACE); + appendMeta(document.body, 'sentry-trace', '22222222222222222222222222222222-2222222222222222-1'); + + pageload.appRouterInstrumentPageLoad(core.getClient()!); + + const spanJson = core.spanToJSON(core.getActiveSpan()!); + expect(spanJson.trace_id).toBe('22222222222222222222222222222222'); + expect(spanJson.parent_span_id).toBe('2222222222222222'); + expect(spanJson.attributes?.['page.server_rendering.type']).toBe('partially_static'); + expect(spanJson.links).toEqual([PRERENDER_LINK]); + }); + + it('links the prerender without a type when a navigation starts the pageload before the resumed part', async () => { + const { core, pageload } = await setup('static'); + appendMeta(document.head, 'sentry-cache-origin', PRERENDER_SENTRY_TRACE); + + pageload.appRouterInstrumentPageLoad(core.getClient()!); + pageload.settlePendingPageloadWait(); + + const spanJson = core.spanToJSON(core.getActiveSpan()!); + expect(spanJson.attributes?.[SENTRY_OP]).toBe(PAGELOAD); + expect(spanJson.attributes).not.toHaveProperty('page.server_rendering.type'); + expect(spanJson.links).toEqual([PRERENDER_LINK]); + }); + + it('links the server request when its trace meta tag arrives after the wait gave up', async () => { + const { core, pageload } = await setup('static'); + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }); + + pageload.appRouterInstrumentPageLoad(core.getClient()!); + vi.advanceTimersByTime(10_000); + const span = core.getActiveSpan()!; + appendMeta(document.body, 'sentry-trace', '22222222222222222222222222222222-2222222222222222-1'); + // MutationObserver callbacks run as microtasks. + await Promise.resolve(); + + const spanJson = core.spanToJSON(span); + expect(spanJson.attributes?.[SENTRY_OP]).toBe(PAGELOAD); + expect(spanJson.trace_id).not.toBe('22222222222222222222222222222222'); + expect(spanJson.links).toEqual([ + { + trace_id: '22222222222222222222222222222222', + span_id: '2222222222222222', + sampled: true, + attributes: { [SENTRY_LINK_TYPE]: 'server_request' }, + }, + ]); + }); }); diff --git a/packages/nextjs/test/client/traceMetaTags.test.ts b/packages/nextjs/test/client/traceMetaTags.test.ts index 4b9fde700e92..695ad7c558bb 100644 --- a/packages/nextjs/test/client/traceMetaTags.test.ts +++ b/packages/nextjs/test/client/traceMetaTags.test.ts @@ -1,12 +1,14 @@ // @vitest-environment jsdom -import { SENTRY_LINK_TYPE } from '@sentry/conventions/attributes'; -import type { Span } from '@sentry/core'; +import { SENTRY_LINK_TYPE, SENTRY_OP } from '@sentry/conventions/attributes'; +import { PAGELOAD } from '@sentry/conventions/op'; +import type { Span, StartSpanOptions } from '@sentry/core'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { addTraceMetaTagLink, canWaitForTraceMetaTag, readTraceMetaTags, waitForTraceMetaTag, + withCacheOrigin, } from '../../src/client/routing/traceMetaTags'; const HEAD_TRACE = '11111111111111111111111111111111-1111111111111111-1'; @@ -219,3 +221,63 @@ describe('addTraceMetaTagLink', () => { expect(span.addLink).not.toHaveBeenCalled(); }); }); + +describe('withCacheOrigin', () => { + const PAGELOAD_OPTIONS: StartSpanOptions = { name: '/products/:id', attributes: { [SENTRY_OP]: PAGELOAD } }; + const PRERENDER_LINK = { + context: { traceId: '11111111111111111111111111111111', spanId: '1111111111111111', traceFlags: 1 }, + attributes: { [SENTRY_LINK_TYPE]: 'cache_origin' }, + }; + + it('returns the options unchanged for a document without a sentry-cache-origin tag', () => { + appendMeta(document.head, 'sentry-trace', HEAD_TRACE); + + expect(withCacheOrigin(PAGELOAD_OPTIONS, { sentryTrace: HEAD_TRACE, baggage: undefined })).toBe(PAGELOAD_OPTIONS); + }); + + it('marks a fully reused page as static and links a sampled render as cache origin', () => { + appendMeta(document.head, 'sentry-cache-origin', HEAD_TRACE); + + expect(withCacheOrigin(PAGELOAD_OPTIONS)).toEqual({ + name: '/products/:id', + attributes: { [SENTRY_OP]: PAGELOAD, 'page.server_rendering.type': 'static' }, + links: [PRERENDER_LINK], + }); + }); + + it('marks a page as partially static when the pageload continues the trace of this request', () => { + appendMeta(document.head, 'sentry-cache-origin', HEAD_TRACE); + + expect(withCacheOrigin(PAGELOAD_OPTIONS, { sentryTrace: BODY_TRACE, baggage: undefined })).toEqual({ + name: '/products/:id', + attributes: { [SENTRY_OP]: PAGELOAD, 'page.server_rendering.type': 'partially_static' }, + links: [PRERENDER_LINK], + }); + }); + + // A navigation or the wait cap can start the pageload before the resumed part arrives. + it('leaves the type unset while a resumed part can still bring the trace of this request', () => { + appendMeta(document.head, 'sentry-cache-origin', HEAD_TRACE); + setReadyState('loading'); + vi.spyOn(performance, 'getEntriesByType').mockReturnValue([]); + + expect(withCacheOrigin(PAGELOAD_OPTIONS)).toEqual({ + name: '/products/:id', + attributes: { [SENTRY_OP]: PAGELOAD }, + links: [PRERENDER_LINK], + }); + }); + + it.each([ + { tag: 'holds an unsampled trace', content: '11111111111111111111111111111111-1111111111111111-0' }, + { tag: 'holds a trace without a sampling decision', content: '11111111111111111111111111111111-1111111111111111' }, + { tag: 'holds no sentry-trace value', content: 'prerendered' }, + ])('marks the page without a link when the tag $tag', ({ content }) => { + appendMeta(document.head, 'sentry-cache-origin', content); + + expect(withCacheOrigin(PAGELOAD_OPTIONS)).toEqual({ + name: '/products/:id', + attributes: { [SENTRY_OP]: PAGELOAD, 'page.server_rendering.type': 'static' }, + }); + }); +}); diff --git a/packages/nextjs/test/cloudflare/withSentry.test.ts b/packages/nextjs/test/cloudflare/withSentry.test.ts index d24bb75f3f61..46bc97e520be 100644 --- a/packages/nextjs/test/cloudflare/withSentry.test.ts +++ b/packages/nextjs/test/cloudflare/withSentry.test.ts @@ -287,7 +287,7 @@ describe('withSentry', () => { props: {}, } as never); - expect(carrier).toEqual([]); + expect(carrier).toEqual([{ key: 'sentry-cache-origin', value: expect.stringMatching(/-1$/) }]); expect(requestCarrier['sentry-trace']).toMatch(/^[0-9a-f]{32}-[0-9a-f]{16}-1$/); }); diff --git a/packages/nextjs/test/config/maybeSetClientTraceMetadataOption.test.ts b/packages/nextjs/test/config/maybeSetClientTraceMetadataOption.test.ts index 6de4a3a697fd..3dd60f97f848 100644 --- a/packages/nextjs/test/config/maybeSetClientTraceMetadataOption.test.ts +++ b/packages/nextjs/test/config/maybeSetClientTraceMetadataOption.test.ts @@ -3,22 +3,27 @@ import type { NextConfigObject } from '../../src/config/types'; import { maybeSetClientTraceMetadataOption } from '../../src/config/withSentryConfig/getFinalConfigObjectUtils'; describe('maybeSetClientTraceMetadataOption', () => { - it('adds sentry-trace and baggage to clientTraceMetadata on supported Next.js versions', () => { + it('adds baggage, sentry-trace, and sentry-cache-origin 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-cache-origin']); }); 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-cache-origin', + 'my-custom-key', + ]); }); 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).toEqual(['baggage', 'sentry-trace']); + expect(config.experimental?.clientTraceMetadata).toEqual(['baggage', 'sentry-trace', 'sentry-cache-origin']); }); }); 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 index 03c2e40e4e2d..d437b1b8a164 100644 --- a/packages/nextjs/test/server/nextSentryPropagator.test.ts +++ b/packages/nextjs/test/server/nextSentryPropagator.test.ts @@ -82,32 +82,34 @@ describe('NextSentryPropagator', () => { }); }); - it('hands out nothing while a route is prerendered', () => { + it('hands out only the trace of the prerender as cache origin 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({}); + expect(inject()).toEqual({ + 'sentry-cache-origin': `${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({}); + expect(inject()).toEqual({ 'sentry-cache-origin': 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', () => { + it('hands out only the cache origin 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(inject()).toEqual({}); + expect(Object.keys(inject())).toEqual(['sentry-cache-origin']); }); span.end(); }); }); - it('hands out nothing while a route is prerendered in an unsampled trace', () => { + it('hands out only the cache origin while a route is prerendered in an unsampled trace', () => { setup(0); trace.getTracer('next.js').startActiveSpan('GET /[id]', span => { @@ -118,7 +120,7 @@ describe('NextSentryPropagator', () => { .getTracer('next.js') .startActiveSpan('prerender route (app) /[id]', PRERENDER_SPAN_OPTIONS, prerenderSpan => { expect(prerenderSpan.isRecording()).toBe(false); - expect(inject()).toEqual({}); + expect(inject()).toEqual({ 'sentry-cache-origin': expect.stringMatching(/-0$/) }); prerenderSpan.end(); }); span.end(); @@ -129,7 +131,7 @@ describe('NextSentryPropagator', () => { 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(inject()).toEqual({}); + expect(Object.keys(inject())).toEqual(['sentry-cache-origin']); }); span.end(); @@ -145,7 +147,7 @@ describe('NextSentryPropagator', () => { .getTracer('next.js') .startActiveSpan('prerender route (app) /[id]', PRERENDER_SPAN_OPTIONS, context.active(), span => { expect(isPrerenderContext(context.active())).toBe(true); - expect(inject()).toEqual({}); + expect(Object.keys(inject())).toEqual(['sentry-cache-origin']); span.end(); }); });