diff --git a/CHANGELOG.md b/CHANGELOG.md index f6108e1d9a7c..34238db2d80c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,10 @@ 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-16-cacheComponents/tests/cacheOrigin-pageloadConnection.spec.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/tests/cacheOrigin-pageloadConnection.spec.ts index 900ce43679e9..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 @@ -7,7 +7,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-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()`) @@ -30,7 +31,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 +58,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-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 }> => { + const visit = async (): Promise<{ traceId: string | undefined; links: unknown; documentCached: unknown }> => { const context = await browser.newContext(); const page: Page = await context.newPage(); @@ -75,11 +76,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-prerender"]').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, + documentCached: pageloadTx.contexts?.trace?.data?.['document.cached'], + }; }; const first = await visit(); @@ -88,8 +94,17 @@ 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.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 }) => { @@ -105,8 +120,10 @@ 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-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-streaming-cacheComponents/tests/cacheOrigin-pageloadConnection.spec.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/tests/cacheOrigin-pageloadConnection.spec.ts index 7ebf36478add..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 @@ -7,7 +7,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-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()`) @@ -28,7 +29,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 +56,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-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 }> => { + const visit = async (): Promise<{ traceId: string | undefined; links: unknown; documentCached: unknown }> => { const context = await browser.newContext(); const page: Page = await context.newPage(); @@ -70,11 +71,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-prerender"]').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, + documentCached: pageloadSpan.attributes['document.cached']?.value, + }; }; const first = await visit(); @@ -83,8 +89,17 @@ 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.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 }) => { @@ -100,8 +115,10 @@ 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-prerender"]').count()).toBe(0); expect(pageloadSpan.trace_id).toBeTruthy(); expect(pageloadSpan.links).toBeUndefined(); + expect(pageloadSpan.attributes['document.cached']).toBeUndefined(); }); diff --git a/packages/nextjs/src/client/browserTracingIntegration.ts b/packages/nextjs/src/client/browserTracingIntegration.ts index 748e488828d8..b8872cf95a1d 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..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 f5df240cd46c..8dd47257c3b6 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, @@ -28,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({ @@ -98,92 +93,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 = 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, - ); -} - 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 75eeca09ec9e..0cec03935c82 100644 --- a/packages/nextjs/src/client/routing/traceMetaTags.ts +++ b/packages/nextjs/src/client/routing/traceMetaTags.ts @@ -1,6 +1,7 @@ -import type { Span } from '@sentry/core'; -import { extractTraceparentData } from '@sentry/core'; -import { GLOBAL_OBJ } from '@sentry/core'; +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. @@ -12,6 +13,9 @@ export interface TraceMetaTagValues { 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; } @@ -128,21 +132,46 @@ 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, }, - }); + ...(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/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/withSentryConfig/getFinalConfigObjectUtils.ts b/packages/nextjs/src/config/withSentryConfig/getFinalConfigObjectUtils.ts index 9c684a6acc02..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'; @@ -148,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/nextSentryPropagator.ts b/packages/nextjs/src/server/nextSentryPropagator.ts index 1fd3c3438307..538fb28e725b 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 { 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. + * 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 @@ -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, { set: (c, key, value) => ((c as Record)[key] = value) }); + const sentryTrace = values['sentry-trace']; + if (sentryTrace) { + setter.set(carrier, PRERENDER_META_TAG_NAME, sentryTrace); + } } } diff --git a/packages/nextjs/test/client/appRouterRoutingInstrumentation.test.ts b/packages/nextjs/test/client/appRouterRoutingInstrumentation.test.ts index 7a96e4ab9afc..a9fa6fe586b5 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,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({ @@ -93,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)', () => { @@ -291,13 +296,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(); @@ -315,4 +320,30 @@ describe('appRouterInstrumentPageLoad', () => { '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/traceMetaTags.test.ts b/packages/nextjs/test/client/traceMetaTags.test.ts index e05bca779da6..34e8a5d03f35 100644 --- a/packages/nextjs/test/client/traceMetaTags.test.ts +++ b/packages/nextjs/test/client/traceMetaTags.test.ts @@ -1,8 +1,11 @@ // @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, @@ -216,3 +219,34 @@ describe('addTraceMetaTagLink', () => { 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/cloudflare/withSentry.test.ts b/packages/nextjs/test/cloudflare/withSentry.test.ts index d24bb75f3f61..ca02d59f5b11 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-prerender', 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..7507399ce7a7 100644 --- a/packages/nextjs/test/config/maybeSetClientTraceMetadataOption.test.ts +++ b/packages/nextjs/test/config/maybeSetClientTraceMetadataOption.test.ts @@ -6,19 +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('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-prerender']); }); }); 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..0fc4903996ec 100644 --- a/packages/nextjs/test/server/nextSentryPropagator.test.ts +++ b/packages/nextjs/test/server/nextSentryPropagator.test.ts @@ -82,17 +82,19 @@ describe('NextSentryPropagator', () => { }); }); - it('hands out nothing while a route is prerendered', () => { + 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({}); + 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({}); + expect(inject()).toEqual({ 'sentry-prerender': expect.stringMatching(/-1$/) }); expect(injectHeaders()['sentry-trace']).toBe(`${span.spanContext().traceId}-${span.spanContext().spanId}-1`); span.end(); }); @@ -101,13 +103,13 @@ describe('NextSentryPropagator', () => { 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(inject()).toEqual({}); + expect(Object.keys(inject())).toEqual(['sentry-prerender']); }); span.end(); }); }); - it('hands out nothing while a route is prerendered in an unsampled trace', () => { + 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 => { @@ -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-prerender': 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-prerender']); }); 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-prerender']); span.end(); }); });