From 1a2ee30e069d99068e93706ab1ba00c411b1a96b Mon Sep 17 00:00:00 2001 From: Charly Gomez Date: Tue, 13 Oct 2026 13:27:14 +0200 Subject: [PATCH 1/5] ref(nextjs): Move the app router pageload instrumentation into its own file Co-Authored-By: Claude Fable 5.1 --- .../src/client/browserTracingIntegration.ts | 2 +- .../appRouterPageloadInstrumentation.ts | 117 ++++++++++++++++++ .../appRouterRoutingInstrumentation.ts | 95 +------------- .../routing/nextRoutingInstrumentation.ts | 3 +- packages/nextjs/src/common/traceMetaTags.ts | 7 ++ .../nextRoutingInstrumentation.test.ts | 11 +- 6 files changed, 135 insertions(+), 100 deletions(-) create mode 100644 packages/nextjs/src/client/routing/appRouterPageloadInstrumentation.ts create mode 100644 packages/nextjs/src/common/traceMetaTags.ts 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..0c6844225dc1 --- /dev/null +++ b/packages/nextjs/src/client/routing/appRouterPageloadInstrumentation.ts @@ -0,0 +1,117 @@ +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, + markCachedShell, + readShellMarker, + 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 span = startBrowserTracingPageLoadSpan(client, spanOptions, traceMetaTags); + const scope = getCurrentScope(); + const { normalizedRequest } = scope.getScopeData().sdkProcessingMetadata; + if (normalizedRequest) { + scope.setSDKProcessingMetadata({ normalizedRequest: { ...normalizedRequest, url: documentUrl } }); + } + const shellMarker = readShellMarker(); + if (span?.isRecording() && shellMarker) { + markCachedShell(span, shellMarker); + } + 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 108637e5de2e..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 = 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/common/traceMetaTags.ts b/packages/nextjs/src/common/traceMetaTags.ts new file mode 100644 index 000000000000..8204c141f547 --- /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 shell is cached and served to + * later visitors, so the client never continues it, it only marks the pageload as served from a cached + * shell and links the request that generated it. + */ +export const SHELL_META_TAG_NAME = 'sentry-shell'; 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(), From 24d49e5e9cc68708a55e81fe62da2cac0dc89ead Mon Sep 17 00:00:00 2001 From: Charly Gomez Date: Tue, 13 Oct 2026 13:27:31 +0200 Subject: [PATCH 2/5] feat(nextjs): Mark pageloads that were served from a cached shell A visitor of a cached page gets a pageload in its own trace with no server span, which looks like missing server instrumentation. While Next.js prerenders a document, the server now writes a `sentry-shell` meta tag with the trace of the prerender instead of nothing. The client never continues it: it sets `sentry.nextjs.cached_shell` on the pageload and adds a `cache_origin` span link to the request that generated the shell. Co-Authored-By: Claude Fable 5.1 --- CHANGELOG.md | 4 ++ .../cacheOrigin-pageloadConnection.spec.ts | 33 ++++++++++---- .../cacheOrigin-pageloadConnection.spec.ts | 33 ++++++++++---- .../src/client/routing/traceMetaTags.ts | 27 +++++++++-- .../getFinalConfigObjectUtils.ts | 2 + .../nextjs/src/server/nextSentryPropagator.ts | 15 +++++-- .../appRouterRoutingInstrumentation.test.ts | 45 ++++++++++++++++--- .../nextjs/test/client/traceMetaTags.test.ts | 38 ++++++++++++++++ .../nextjs/test/cloudflare/withSentry.test.ts | 2 +- .../maybeSetClientTraceMetadataOption.test.ts | 11 +++-- .../test/server/nextSentryPropagator.test.ts | 18 ++++---- 11 files changed, 186 insertions(+), 42 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 18182d0b8e1d..5a00cd8b5917 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -39,6 +39,10 @@ Work in this release was contributed by @4ktLuffy, @rubas, and @dasilvaluis. Tha If you set up OpenTelemetry yourself and register `SentryPropagator`, register `NextSentryPropagator` instead and pass your tracer provider to `markPrerendersOnTracerProvider`, so that your prerendered pages carry no trace information either. +- **feat(nextjs): Mark pageloads that were served from a cached shell** + + A pageload whose document is a shell that Next.js prerendered at runtime, for example an ISR page or a `cacheComponents` page generated on first visit, now carries the `sentry.nextjs.cached_shell` attribute and a `cache_origin` span link to the request that generated the shell. 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. + Work in this release was contributed by @adityareddy-dev, @jpdefo, @Jxxunnn, @philipatkinson, @Shubham-Padkonde, @mikamikasuki, and @mketiku. Thank you for your contributions! - **feat(deno): Add incoming request options to `denoServeIntegration`** 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..7b20a76b9f32 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-shell` +// 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-shell` 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; cachedShell: 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-shell"]').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, + cachedShell: pageloadTx.contexts?.trace?.data?.['sentry.nextjs.cached_shell'], + }; }; 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.cachedShell).toBe(true); + expect(second.cachedShell).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-shell"]').count()).toBe(0); expect(pageloadTx.contexts?.trace?.trace_id).toBeTruthy(); expect(pageloadTx.contexts?.trace?.links).toBeUndefined(); + expect(pageloadTx.contexts?.trace?.data?.['sentry.nextjs.cached_shell']).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 89f9dc32d26c..3cfa7a5e6976 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-shell` +// 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-shell` 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; cachedShell: 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-shell"]').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, + cachedShell: pageloadSpan.attributes['sentry.nextjs.cached_shell']?.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.cachedShell).toBe(true); + expect(second.cachedShell).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,10 +115,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-shell"]').count()).toBe(0); expect(pageloadSpan.trace_id).toBeTruthy(); expect(pageloadSpan.links).toBeUndefined(); + expect(pageloadSpan.attributes['sentry.nextjs.cached_shell']).toBeUndefined(); }); test('keeps a navigation that starts while the pageload waits for the trace meta tag', async ({ page }) => { diff --git a/packages/nextjs/src/client/routing/traceMetaTags.ts b/packages/nextjs/src/client/routing/traceMetaTags.ts index 8e1138000ec5..46acd3427d01 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 { extractTraceparentData, GLOBAL_OBJ } from '@sentry/core'; +import { SHELL_META_TAG_NAME } from '../../common/traceMetaTags'; import { WINDOW } from '@sentry/react'; // `@opentelemetry/api` `TraceFlags`, inlined because core does not export its copies. @@ -15,6 +15,9 @@ export interface TraceMetaTagValues { baggage: string | undefined; } +/** Set on a pageload whose document is a shell that Next.js prerendered and served from its cache. */ +export const CACHED_SHELL_ATTRIBUTE = 'sentry.nextjs.cached_shell'; + function getDocument(): Document | undefined { return WINDOW.document; } @@ -135,7 +138,7 @@ export function waitForTraceMetaTag( * 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 { +export function addTraceMetaTagLink(span: Span, { sentryTrace }: TraceMetaTagValues, linkType?: string): void { const traceparent = extractTraceparentData(sentryTrace); if (!traceparent?.traceId || !traceparent.parentSpanId) { return; @@ -147,6 +150,22 @@ export function addTraceMetaTagLink(span: Span, { sentryTrace }: TraceMetaTagVal 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 ?? SERVER_REQUEST_LINK_TYPE }, }); } + +/** Reads the marker the server writes into a shell while it prerenders it, see `nextSentryPropagator.ts`. */ +export function readShellMarker(): string | undefined { + const document = getDocument(); + return document && lastMetaContent(document, SHELL_META_TAG_NAME); +} + +/** + * Marks a pageload whose document is a cached shell and links the request that prerendered the shell. + * The marker is never continued: the shell is served to every later visitor, and the request behind it + * handled none of their visits. + */ +export function markCachedShell(span: Span, marker: string): void { + span.setAttribute(CACHED_SHELL_ATTRIBUTE, true); + addTraceMetaTagLink(span, { sentryTrace: marker, baggage: undefined }, 'cache_origin'); +} diff --git a/packages/nextjs/src/config/withSentryConfig/getFinalConfigObjectUtils.ts b/packages/nextjs/src/config/withSentryConfig/getFinalConfigObjectUtils.ts index 68520bc1e16d..fe619e29247e 100644 --- a/packages/nextjs/src/config/withSentryConfig/getFinalConfigObjectUtils.ts +++ b/packages/nextjs/src/config/withSentryConfig/getFinalConfigObjectUtils.ts @@ -1,3 +1,4 @@ +import { SHELL_META_TAG_NAME } from '../../common/traceMetaTags'; import { debug, isMatchingPattern, parseSemver } from '@sentry/core'; import { getSentryRelease } from '@sentry/node'; import * as fs from 'fs'; @@ -192,6 +193,7 @@ export function maybeSetClientTraceMetadataOption( incomingUserNextConfigObject.experimental.clientTraceMetadata = [ 'baggage', 'sentry-trace', + SHELL_META_TAG_NAME, ...(incomingUserNextConfigObject.experimental?.clientTraceMetadata || []), ]; } diff --git a/packages/nextjs/src/server/nextSentryPropagator.ts b/packages/nextjs/src/server/nextSentryPropagator.ts index 1fd3c3438307..e45f1d03fb1f 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 { SHELL_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-shell` 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, SHELL_META_TAG_NAME, sentryTrace); + } } } diff --git a/packages/nextjs/test/client/appRouterRoutingInstrumentation.test.ts b/packages/nextjs/test/client/appRouterRoutingInstrumentation.test.ts index 0d8bdded0e0d..ac997d7fda90 100644 --- a/packages/nextjs/test/client/appRouterRoutingInstrumentation.test.ts +++ b/packages/nextjs/test/client/appRouterRoutingInstrumentation.test.ts @@ -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(); @@ -325,14 +330,40 @@ 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('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-shell'; + 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?.['sentry.nextjs.cached_shell']).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 4b9fde700e92..39179e9b2fc7 100644 --- a/packages/nextjs/test/client/traceMetaTags.test.ts +++ b/packages/nextjs/test/client/traceMetaTags.test.ts @@ -4,6 +4,8 @@ import type { Span } from '@sentry/core'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { addTraceMetaTagLink, + markCachedShell, + readShellMarker, canWaitForTraceMetaTag, readTraceMetaTags, waitForTraceMetaTag, @@ -219,3 +221,39 @@ describe('addTraceMetaTagLink', () => { expect(span.addLink).not.toHaveBeenCalled(); }); }); + +describe('readShellMarker', () => { + it('returns nothing without a sentry-shell tag', () => { + expect(readShellMarker()).toBeUndefined(); + }); + + it('reads the last sentry-shell tag', () => { + appendMeta(document.head, 'sentry-shell', HEAD_TRACE); + appendMeta(document.body, 'sentry-shell', BODY_TRACE); + + expect(readShellMarker()).toBe(BODY_TRACE); + }); +}); + +describe('markCachedShell', () => { + it('sets the attribute and links the prerender as cache origin', () => { + const span = { setAttribute: vi.fn(), addLink: vi.fn() } as unknown as Span; + + markCachedShell(span, HEAD_TRACE); + + expect(span.setAttribute).toHaveBeenCalledWith('sentry.nextjs.cached_shell', true); + expect(span.addLink).toHaveBeenCalledWith({ + context: { traceId: '11111111111111111111111111111111', spanId: '1111111111111111', traceFlags: 1 }, + attributes: { 'sentry.link.type': 'cache_origin' }, + }); + }); + + it('sets the attribute even when the marker is not a sentry-trace value', () => { + const span = { setAttribute: vi.fn(), addLink: vi.fn() } as unknown as Span; + + markCachedShell(span, 'prerendered'); + + expect(span.setAttribute).toHaveBeenCalledWith('sentry.nextjs.cached_shell', true); + expect(span.addLink).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/nextjs/test/cloudflare/withSentry.test.ts b/packages/nextjs/test/cloudflare/withSentry.test.ts index d24bb75f3f61..66c93b859d5b 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-shell', 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..ff260d83ba65 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-shell']); }); 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-shell', + '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-shell']); }); }); diff --git a/packages/nextjs/test/server/nextSentryPropagator.test.ts b/packages/nextjs/test/server/nextSentryPropagator.test.ts index 03c2e40e4e2d..c1740eca9bea 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-shell': `${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-shell': 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-shell']); }); 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-shell': 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-shell']); }); 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-shell']); span.end(); }); }); From 0ef2163024ab1fbeaac46ee828603fa9bc3a253f Mon Sep 17 00:00:00 2001 From: Charly Gomez Date: Tue, 13 Oct 2026 13:42:40 +0200 Subject: [PATCH 3/5] fix(nextjs): Mark a cached shell at pageload span start The attribute and the link are part of the start options now, so `tracesSampler` and `ignoreSpans` see that a pageload came from a cached shell. Co-Authored-By: Claude Fable 5.1 --- .../appRouterPageloadInstrumentation.ts | 20 +++++++--- .../src/client/routing/traceMetaTags.ts | 38 +++++++++++-------- .../appRouterRoutingInstrumentation.test.ts | 4 +- .../nextjs/test/client/traceMetaTags.test.ts | 31 +++++++-------- 4 files changed, 52 insertions(+), 41 deletions(-) diff --git a/packages/nextjs/src/client/routing/appRouterPageloadInstrumentation.ts b/packages/nextjs/src/client/routing/appRouterPageloadInstrumentation.ts index 0c6844225dc1..5874a18cbb85 100644 --- a/packages/nextjs/src/client/routing/appRouterPageloadInstrumentation.ts +++ b/packages/nextjs/src/client/routing/appRouterPageloadInstrumentation.ts @@ -19,8 +19,8 @@ import { stripTrailingSlash } from './parameterization'; import type { TraceMetaTagValues, TraceMetaTagWait } from './traceMetaTags'; import { addTraceMetaTagLink, + cachedShellSpanOptions, canWaitForTraceMetaTag, - markCachedShell, readShellMarker, readTraceMetaTags, waitForTraceMetaTag, @@ -53,16 +53,24 @@ function startPageloadSpan( documentUrl: string, traceMetaTags?: TraceMetaTagValues, ): Span | undefined { - const span = startBrowserTracingPageLoadSpan(client, spanOptions, traceMetaTags); + const shellMarker = readShellMarker(); + const shellOptions = shellMarker ? cachedShellSpanOptions(shellMarker) : undefined; + const span = startBrowserTracingPageLoadSpan( + client, + shellOptions + ? { + ...spanOptions, + attributes: { ...spanOptions.attributes, ...shellOptions.attributes }, + links: shellOptions.links, + } + : spanOptions, + traceMetaTags, + ); const scope = getCurrentScope(); const { normalizedRequest } = scope.getScopeData().sdkProcessingMetadata; if (normalizedRequest) { scope.setSDKProcessingMetadata({ normalizedRequest: { ...normalizedRequest, url: documentUrl } }); } - const shellMarker = readShellMarker(); - if (span?.isRecording() && shellMarker) { - markCachedShell(span, shellMarker); - } return span; } diff --git a/packages/nextjs/src/client/routing/traceMetaTags.ts b/packages/nextjs/src/client/routing/traceMetaTags.ts index 46acd3427d01..c916ba9b6cc6 100644 --- a/packages/nextjs/src/client/routing/traceMetaTags.ts +++ b/packages/nextjs/src/client/routing/traceMetaTags.ts @@ -1,5 +1,5 @@ import { SENTRY_LINK_TYPE } from '@sentry/conventions/attributes'; -import type { Span } from '@sentry/core'; +import type { Span, SpanLink, StartSpanOptions } from '@sentry/core'; import { extractTraceparentData, GLOBAL_OBJ } from '@sentry/core'; import { SHELL_META_TAG_NAME } from '../../common/traceMetaTags'; import { WINDOW } from '@sentry/react'; @@ -134,24 +134,31 @@ 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, linkType?: string): 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]: linkType ?? 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); + } } /** Reads the marker the server writes into a shell while it prerenders it, see `nextSentryPropagator.ts`. */ @@ -161,11 +168,12 @@ export function readShellMarker(): string | undefined { } /** - * Marks a pageload whose document is a cached shell and links the request that prerendered the shell. + * The start options that mark a pageload whose document is a cached shell and link the request that + * prerendered the shell. Applied at span start, so `tracesSampler` and `ignoreSpans` see the attribute. * The marker is never continued: the shell is served to every later visitor, and the request behind it * handled none of their visits. */ -export function markCachedShell(span: Span, marker: string): void { - span.setAttribute(CACHED_SHELL_ATTRIBUTE, true); - addTraceMetaTagLink(span, { sentryTrace: marker, baggage: undefined }, 'cache_origin'); +export function cachedShellSpanOptions(marker: string): Pick { + const link = traceMetaTagLink(marker, 'cache_origin'); + return { attributes: { [CACHED_SHELL_ATTRIBUTE]: true }, ...(link && { links: [link] }) }; } diff --git a/packages/nextjs/test/client/appRouterRoutingInstrumentation.test.ts b/packages/nextjs/test/client/appRouterRoutingInstrumentation.test.ts index ac997d7fda90..1e2fe3174ccf 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'; @@ -361,7 +361,7 @@ describe('appRouterInstrumentPageLoad', () => { expect.objectContaining({ trace_id: '11111111111111111111111111111111', span_id: '1111111111111111', - attributes: expect.objectContaining({ 'sentry.link.type': 'cache_origin' }), + 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 39179e9b2fc7..708e094865cd 100644 --- a/packages/nextjs/test/client/traceMetaTags.test.ts +++ b/packages/nextjs/test/client/traceMetaTags.test.ts @@ -4,7 +4,7 @@ import type { Span } from '@sentry/core'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { addTraceMetaTagLink, - markCachedShell, + cachedShellSpanOptions, readShellMarker, canWaitForTraceMetaTag, readTraceMetaTags, @@ -235,25 +235,20 @@ describe('readShellMarker', () => { }); }); -describe('markCachedShell', () => { - it('sets the attribute and links the prerender as cache origin', () => { - const span = { setAttribute: vi.fn(), addLink: vi.fn() } as unknown as Span; - - markCachedShell(span, HEAD_TRACE); - - expect(span.setAttribute).toHaveBeenCalledWith('sentry.nextjs.cached_shell', true); - expect(span.addLink).toHaveBeenCalledWith({ - context: { traceId: '11111111111111111111111111111111', spanId: '1111111111111111', traceFlags: 1 }, - attributes: { 'sentry.link.type': 'cache_origin' }, +describe('cachedShellSpanOptions', () => { + it('marks the span and links the prerender as cache origin', () => { + expect(cachedShellSpanOptions(HEAD_TRACE)).toEqual({ + attributes: { 'sentry.nextjs.cached_shell': true }, + links: [ + { + context: { traceId: '11111111111111111111111111111111', spanId: '1111111111111111', traceFlags: 1 }, + attributes: { [SENTRY_LINK_TYPE]: 'cache_origin' }, + }, + ], }); }); - it('sets the attribute even when the marker is not a sentry-trace value', () => { - const span = { setAttribute: vi.fn(), addLink: vi.fn() } as unknown as Span; - - markCachedShell(span, 'prerendered'); - - expect(span.setAttribute).toHaveBeenCalledWith('sentry.nextjs.cached_shell', true); - expect(span.addLink).not.toHaveBeenCalled(); + it('marks the span even when the marker is not a sentry-trace value', () => { + expect(cachedShellSpanOptions('prerendered')).toEqual({ attributes: { 'sentry.nextjs.cached_shell': true } }); }); }); From 0827ad5f0bb674ba8d4fb9919a295d5282abdc5a Mon Sep 17 00:00:00 2001 From: Charly Gomez Date: Tue, 13 Oct 2026 13:56:09 +0200 Subject: [PATCH 4/5] ref(nextjs): Name the cached document attribute and marker tag framework neutral `document.cached` and `sentry-prerender` instead of a Next.js specific attribute and `sentry-shell`, so other frameworks with prerendering can set the same attribute. Co-Authored-By: Claude Fable 5.1 --- CHANGELOG.md | 2 +- .../cacheOrigin-pageloadConnection.spec.ts | 18 ++++++------- .../cacheOrigin-pageloadConnection.spec.ts | 18 ++++++------- .../appRouterPageloadInstrumentation.ts | 14 +++++----- .../src/client/routing/traceMetaTags.ts | 24 ++++++++--------- packages/nextjs/src/common/traceMetaTags.ts | 8 +++--- .../getFinalConfigObjectUtils.ts | 4 +-- .../nextjs/src/server/nextSentryPropagator.ts | 6 ++--- .../appRouterRoutingInstrumentation.test.ts | 4 +-- .../nextjs/test/client/traceMetaTags.test.ts | 26 +++++++++---------- .../nextjs/test/cloudflare/withSentry.test.ts | 2 +- .../maybeSetClientTraceMetadataOption.test.ts | 6 ++--- .../test/server/nextSentryPropagator.test.ts | 12 ++++----- 13 files changed, 72 insertions(+), 72 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5a00cd8b5917..f7e39fc594b9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -41,7 +41,7 @@ Work in this release was contributed by @4ktLuffy, @rubas, and @dasilvaluis. Tha - **feat(nextjs): Mark pageloads that were served from a cached shell** - A pageload whose document is a shell that Next.js prerendered at runtime, for example an ISR page or a `cacheComponents` page generated on first visit, now carries the `sentry.nextjs.cached_shell` attribute and a `cache_origin` span link to the request that generated the shell. 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. + 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. Work in this release was contributed by @adityareddy-dev, @jpdefo, @Jxxunnn, @philipatkinson, @Shubham-Padkonde, @mikamikasuki, and @mketiku. Thank you for your contributions! 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 7b20a76b9f32..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,7 @@ const APP = 'nextjs-16-cacheComponents'; // The server only writes `sentry-trace` meta tags for a render that happened for the current request, // never while it prerenders a shell. So a tag in the document can always be continued; the only // question is when it arrives. The resumed part of a prerendered shell arrives after the SDK started, -// which the pageload instrumentation waits for. A shell prerendered at runtime carries a `sentry-shell` +// 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 }) => { @@ -58,11 +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, only a `sentry-shell` marker with its own + // 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; cachedShell: unknown }> => { + const visit = async (): Promise<{ traceId: string | undefined; links: unknown; documentCached: unknown }> => { const context = await browser.newContext(); const page: Page = await context.newPage(); @@ -76,7 +76,7 @@ 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-shell"]').count()).toBe(1); + expect(await page.locator('meta[name="sentry-prerender"]').count()).toBe(1); const pageloadTx = await pageloadTxPromise; await context.close(); @@ -84,7 +84,7 @@ test('does not share a trace between visitors of a shell generated at request ti return { traceId: pageloadTx.contexts?.trace?.trace_id, links: pageloadTx.contexts?.trace?.links, - cachedShell: pageloadTx.contexts?.trace?.data?.['sentry.nextjs.cached_shell'], + documentCached: pageloadTx.contexts?.trace?.data?.['document.cached'], }; }; @@ -94,8 +94,8 @@ 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.cachedShell).toBe(true); - expect(second.cachedShell).toBe(true); + 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 } => { @@ -122,8 +122,8 @@ test('starts a fresh trace on a fully static page', async ({ page }) => { // 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-shell"]').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?.['sentry.nextjs.cached_shell']).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 3cfa7a5e6976..bb77dfc33e73 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,7 @@ const APP = 'nextjs-16-streaming-cacheComponents'; // The server only writes `sentry-trace` meta tags for a render that happened for the current request, // never while it prerenders a shell. So a tag in the document can always be continued; the only // question is when it arrives. The resumed part of a prerendered shell arrives after the SDK started, -// which the pageload instrumentation waits for. A shell prerendered at runtime carries a `sentry-shell` +// 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 }) => { @@ -56,11 +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, only a `sentry-shell` marker with its own + // 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; cachedShell: unknown }> => { + const visit = async (): Promise<{ traceId: string | undefined; links: unknown; documentCached: unknown }> => { const context = await browser.newContext(); const page: Page = await context.newPage(); @@ -71,7 +71,7 @@ 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-shell"]').count()).toBe(1); + expect(await page.locator('meta[name="sentry-prerender"]').count()).toBe(1); const pageloadSpan = await pageloadSpanPromise; await context.close(); @@ -79,7 +79,7 @@ test('does not share a trace between visitors of a shell generated at request ti return { traceId: pageloadSpan.trace_id, links: pageloadSpan.links, - cachedShell: pageloadSpan.attributes['sentry.nextjs.cached_shell']?.value, + documentCached: pageloadSpan.attributes['document.cached']?.value, }; }; @@ -89,8 +89,8 @@ 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.cachedShell).toBe(true); - expect(second.cachedShell).toBe(true); + 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 } => { @@ -117,10 +117,10 @@ test('starts a fresh trace on a fully static page', async ({ page }) => { // 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-shell"]').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['sentry.nextjs.cached_shell']).toBeUndefined(); + expect(pageloadSpan.attributes['document.cached']).toBeUndefined(); }); test('keeps a navigation that starts while the pageload waits for the trace meta tag', async ({ page }) => { diff --git a/packages/nextjs/src/client/routing/appRouterPageloadInstrumentation.ts b/packages/nextjs/src/client/routing/appRouterPageloadInstrumentation.ts index 5874a18cbb85..eac4dee7685e 100644 --- a/packages/nextjs/src/client/routing/appRouterPageloadInstrumentation.ts +++ b/packages/nextjs/src/client/routing/appRouterPageloadInstrumentation.ts @@ -19,9 +19,9 @@ import { stripTrailingSlash } from './parameterization'; import type { TraceMetaTagValues, TraceMetaTagWait } from './traceMetaTags'; import { addTraceMetaTagLink, - cachedShellSpanOptions, + cachedDocumentSpanOptions, canWaitForTraceMetaTag, - readShellMarker, + readPrerenderMarker, readTraceMetaTags, waitForTraceMetaTag, } from './traceMetaTags'; @@ -53,15 +53,15 @@ function startPageloadSpan( documentUrl: string, traceMetaTags?: TraceMetaTagValues, ): Span | undefined { - const shellMarker = readShellMarker(); - const shellOptions = shellMarker ? cachedShellSpanOptions(shellMarker) : undefined; + const prerenderMarker = readPrerenderMarker(); + const prerenderOptions = prerenderMarker ? cachedDocumentSpanOptions(prerenderMarker) : undefined; const span = startBrowserTracingPageLoadSpan( client, - shellOptions + prerenderOptions ? { ...spanOptions, - attributes: { ...spanOptions.attributes, ...shellOptions.attributes }, - links: shellOptions.links, + attributes: { ...spanOptions.attributes, ...prerenderOptions.attributes }, + links: prerenderOptions.links, } : spanOptions, traceMetaTags, diff --git a/packages/nextjs/src/client/routing/traceMetaTags.ts b/packages/nextjs/src/client/routing/traceMetaTags.ts index c916ba9b6cc6..a815b0321e42 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, SpanLink, StartSpanOptions } from '@sentry/core'; import { extractTraceparentData, GLOBAL_OBJ } from '@sentry/core'; -import { SHELL_META_TAG_NAME } from '../../common/traceMetaTags'; +import { PRERENDER_META_TAG_NAME } from '../../common/traceMetaTags'; import { WINDOW } from '@sentry/react'; // `@opentelemetry/api` `TraceFlags`, inlined because core does not export its copies. @@ -15,8 +15,8 @@ export interface TraceMetaTagValues { baggage: string | undefined; } -/** Set on a pageload whose document is a shell that Next.js prerendered and served from its cache. */ -export const CACHED_SHELL_ATTRIBUTE = 'sentry.nextjs.cached_shell'; +/** 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; @@ -161,19 +161,19 @@ export function addTraceMetaTagLink(span: Span, { sentryTrace }: TraceMetaTagVal } } -/** Reads the marker the server writes into a shell while it prerenders it, see `nextSentryPropagator.ts`. */ -export function readShellMarker(): string | undefined { +/** 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, SHELL_META_TAG_NAME); + return document && lastMetaContent(document, PRERENDER_META_TAG_NAME); } /** - * The start options that mark a pageload whose document is a cached shell and link the request that - * prerendered the shell. Applied at span start, so `tracesSampler` and `ignoreSpans` see the attribute. - * The marker is never continued: the shell is served to every later visitor, and the request behind it - * handled none of their visits. + * 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 cachedShellSpanOptions(marker: string): Pick { +export function cachedDocumentSpanOptions(marker: string): Pick { const link = traceMetaTagLink(marker, 'cache_origin'); - return { attributes: { [CACHED_SHELL_ATTRIBUTE]: true }, ...(link && { links: [link] }) }; + 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 index 8204c141f547..aab29a353393 100644 --- a/packages/nextjs/src/common/traceMetaTags.ts +++ b/packages/nextjs/src/common/traceMetaTags.ts @@ -1,7 +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 shell is cached and served to - * later visitors, so the client never continues it, it only marks the pageload as served from a cached - * shell and links the request that generated it. + * 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 SHELL_META_TAG_NAME = 'sentry-shell'; +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 fe619e29247e..9f5bdd5b0835 100644 --- a/packages/nextjs/src/config/withSentryConfig/getFinalConfigObjectUtils.ts +++ b/packages/nextjs/src/config/withSentryConfig/getFinalConfigObjectUtils.ts @@ -1,4 +1,4 @@ -import { SHELL_META_TAG_NAME } from '../../common/traceMetaTags'; +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'; @@ -193,7 +193,7 @@ export function maybeSetClientTraceMetadataOption( incomingUserNextConfigObject.experimental.clientTraceMetadata = [ 'baggage', 'sentry-trace', - SHELL_META_TAG_NAME, + PRERENDER_META_TAG_NAME, ...(incomingUserNextConfigObject.experimental?.clientTraceMetadata || []), ]; } diff --git a/packages/nextjs/src/server/nextSentryPropagator.ts b/packages/nextjs/src/server/nextSentryPropagator.ts index e45f1d03fb1f..538fb28e725b 100644 --- a/packages/nextjs/src/server/nextSentryPropagator.ts +++ b/packages/nextjs/src/server/nextSentryPropagator.ts @@ -1,14 +1,14 @@ import type { Context, TextMapSetter, TracerProvider } from '@opentelemetry/api'; import { propagation } from '@opentelemetry/api'; import { SentryPropagator } from '@sentry/opentelemetry'; -import { SHELL_META_TAG_NAME } from '../common/traceMetaTags'; +import { PRERENDER_META_TAG_NAME } from '../common/traceMetaTags'; import { isPrerenderContext, markPrerendersOnTracerProvider } from './prerenderContext'; /** * A `SentryPropagator` that does not hand out the trace meta tags of a document while Next.js * prerenders it. Trace context baked into a prerendered shell would be replayed to every visitor the * shell is served to, so the browser could continue a trace that never handled its request. The shell - * gets a `sentry-shell` marker tag with the prerender's trace instead, which the client only ever links. + * 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 @@ -28,7 +28,7 @@ export class NextSentryPropagator extends SentryPropagator { super.inject(ctx, values, { set: (c, key, value) => ((c as Record)[key] = value) }); const sentryTrace = values['sentry-trace']; if (sentryTrace) { - setter.set(carrier, SHELL_META_TAG_NAME, 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 1e2fe3174ccf..c3a7f546af3f 100644 --- a/packages/nextjs/test/client/appRouterRoutingInstrumentation.test.ts +++ b/packages/nextjs/test/client/appRouterRoutingInstrumentation.test.ts @@ -345,7 +345,7 @@ describe('appRouterInstrumentPageLoad', () => { const { core, pageload } = await setup('static'); Object.defineProperty(document, 'readyState', { value: 'complete', configurable: true }); const meta = document.createElement('meta'); - meta.name = 'sentry-shell'; + meta.name = 'sentry-prerender'; meta.content = '11111111111111111111111111111111-1111111111111111-1'; document.head.appendChild(meta); @@ -355,7 +355,7 @@ describe('appRouterInstrumentPageLoad', () => { expect(span).toBeDefined(); const spanJson = core.spanToJSON(span!); expect(spanJson.attributes?.[SENTRY_OP]).toBe(PAGELOAD); - expect(spanJson.attributes?.['sentry.nextjs.cached_shell']).toBe(true); + expect(spanJson.attributes?.['document.cached']).toBe(true); expect(spanJson.trace_id).not.toBe('11111111111111111111111111111111'); expect(spanJson.links).toEqual([ expect.objectContaining({ diff --git a/packages/nextjs/test/client/traceMetaTags.test.ts b/packages/nextjs/test/client/traceMetaTags.test.ts index 708e094865cd..19f44927a153 100644 --- a/packages/nextjs/test/client/traceMetaTags.test.ts +++ b/packages/nextjs/test/client/traceMetaTags.test.ts @@ -4,8 +4,8 @@ import type { Span } from '@sentry/core'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { addTraceMetaTagLink, - cachedShellSpanOptions, - readShellMarker, + cachedDocumentSpanOptions, + readPrerenderMarker, canWaitForTraceMetaTag, readTraceMetaTags, waitForTraceMetaTag, @@ -222,23 +222,23 @@ describe('addTraceMetaTagLink', () => { }); }); -describe('readShellMarker', () => { - it('returns nothing without a sentry-shell tag', () => { - expect(readShellMarker()).toBeUndefined(); +describe('readPrerenderMarker', () => { + it('returns nothing without a sentry-prerender tag', () => { + expect(readPrerenderMarker()).toBeUndefined(); }); - it('reads the last sentry-shell tag', () => { - appendMeta(document.head, 'sentry-shell', HEAD_TRACE); - appendMeta(document.body, 'sentry-shell', BODY_TRACE); + it('reads the last sentry-prerender tag', () => { + appendMeta(document.head, 'sentry-prerender', HEAD_TRACE); + appendMeta(document.body, 'sentry-prerender', BODY_TRACE); - expect(readShellMarker()).toBe(BODY_TRACE); + expect(readPrerenderMarker()).toBe(BODY_TRACE); }); }); -describe('cachedShellSpanOptions', () => { +describe('cachedDocumentSpanOptions', () => { it('marks the span and links the prerender as cache origin', () => { - expect(cachedShellSpanOptions(HEAD_TRACE)).toEqual({ - attributes: { 'sentry.nextjs.cached_shell': true }, + expect(cachedDocumentSpanOptions(HEAD_TRACE)).toEqual({ + attributes: { 'document.cached': true }, links: [ { context: { traceId: '11111111111111111111111111111111', spanId: '1111111111111111', traceFlags: 1 }, @@ -249,6 +249,6 @@ describe('cachedShellSpanOptions', () => { }); it('marks the span even when the marker is not a sentry-trace value', () => { - expect(cachedShellSpanOptions('prerendered')).toEqual({ attributes: { 'sentry.nextjs.cached_shell': true } }); + 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 66c93b859d5b..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([{ key: 'sentry-shell', value: expect.stringMatching(/-1$/) }]); + 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 ff260d83ba65..7507399ce7a7 100644 --- a/packages/nextjs/test/config/maybeSetClientTraceMetadataOption.test.ts +++ b/packages/nextjs/test/config/maybeSetClientTraceMetadataOption.test.ts @@ -6,7 +6,7 @@ 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', 'sentry-shell']); + expect(config.experimental?.clientTraceMetadata).toEqual(['baggage', 'sentry-trace', 'sentry-prerender']); }); it('preserves user-provided clientTraceMetadata entries', () => { @@ -15,7 +15,7 @@ describe('maybeSetClientTraceMetadataOption', () => { expect(config.experimental?.clientTraceMetadata).toEqual([ 'baggage', 'sentry-trace', - 'sentry-shell', + 'sentry-prerender', 'my-custom-key', ]); }); @@ -24,6 +24,6 @@ describe('maybeSetClientTraceMetadataOption', () => { // 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', 'sentry-shell']); + expect(config.experimental?.clientTraceMetadata).toEqual(['baggage', 'sentry-trace', 'sentry-prerender']); }); }); diff --git a/packages/nextjs/test/server/nextSentryPropagator.test.ts b/packages/nextjs/test/server/nextSentryPropagator.test.ts index c1740eca9bea..0fc4903996ec 100644 --- a/packages/nextjs/test/server/nextSentryPropagator.test.ts +++ b/packages/nextjs/test/server/nextSentryPropagator.test.ts @@ -86,7 +86,7 @@ describe('NextSentryPropagator', () => { trace.getTracer('next.js').startActiveSpan('prerender route (app) /[id]', PRERENDER_SPAN_OPTIONS, span => { expect(isPrerenderContext(context.active())).toBe(true); expect(inject()).toEqual({ - 'sentry-shell': `${span.spanContext().traceId}-${span.spanContext().spanId}-1`, + 'sentry-prerender': `${span.spanContext().traceId}-${span.spanContext().spanId}-1`, }); span.end(); }); @@ -94,7 +94,7 @@ describe('NextSentryPropagator', () => { it('keeps propagating to outgoing requests while a route is prerendered', () => { trace.getTracer('next.js').startActiveSpan('prerender route (app) /[id]', PRERENDER_SPAN_OPTIONS, span => { - expect(inject()).toEqual({ 'sentry-shell': expect.stringMatching(/-1$/) }); + expect(inject()).toEqual({ 'sentry-prerender': expect.stringMatching(/-1$/) }); expect(injectHeaders()['sentry-trace']).toBe(`${span.spanContext().traceId}-${span.spanContext().spanId}-1`); span.end(); }); @@ -103,7 +103,7 @@ 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(Object.keys(inject())).toEqual(['sentry-shell']); + expect(Object.keys(inject())).toEqual(['sentry-prerender']); }); span.end(); }); @@ -120,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({ 'sentry-shell': expect.stringMatching(/-0$/) }); + expect(inject()).toEqual({ 'sentry-prerender': expect.stringMatching(/-0$/) }); prerenderSpan.end(); }); span.end(); @@ -131,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(Object.keys(inject())).toEqual(['sentry-shell']); + expect(Object.keys(inject())).toEqual(['sentry-prerender']); }); span.end(); @@ -147,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(Object.keys(inject())).toEqual(['sentry-shell']); + expect(Object.keys(inject())).toEqual(['sentry-prerender']); span.end(); }); }); From 86f1ac5d9388c49ed3a0a2120aeb75edbeb0ac1c Mon Sep 17 00:00:00 2001 From: s1gr1d <32902192+s1gr1d@users.noreply.github.com> Date: Tue, 27 Oct 2026 08:43:56 +0100 Subject: [PATCH 5/5] add tests, rename markers --- CHANGELOG.md | 4 +- .../app/runtime-shell/page.tsx | 27 +++++ .../app/slow-hole/page.tsx | 24 ++++ .../nextjs-16-cacheComponents/package.json | 1 + .../cacheOrigin-pageloadConnection.spec.ts | 111 ++++++++++++++++-- .../app/runtime-shell/page.tsx | 27 +++++ .../package.json | 1 + .../cacheOrigin-pageloadConnection.spec.ts | 84 +++++++++++-- .../nextjs-16/tests/isr-routes.test.ts | 3 +- .../appRouterPageloadInstrumentation.ts | 17 +-- .../src/client/routing/traceMetaTags.ts | 48 +++++--- packages/nextjs/src/common/traceMetaTags.ts | 9 +- .../getFinalConfigObjectUtils.ts | 6 +- .../nextjs/src/server/nextSentryPropagator.ts | 10 +- .../appRouterRoutingInstrumentation.test.ts | 103 +++++++++++++--- .../nextjs/test/client/traceMetaTags.test.ts | 77 ++++++++---- .../nextjs/test/cloudflare/withSentry.test.ts | 2 +- .../maybeSetClientTraceMetadataOption.test.ts | 8 +- .../test/server/nextSentryPropagator.test.ts | 18 +-- 19 files changed, 453 insertions(+), 127 deletions(-) create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/app/runtime-shell/page.tsx create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/app/slow-hole/page.tsx create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/app/runtime-shell/page.tsx diff --git a/CHANGELOG.md b/CHANGELOG.md index f7e39fc594b9..693d7e194c95 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -39,9 +39,9 @@ Work in this release was contributed by @4ktLuffy, @rubas, and @dasilvaluis. Tha If you set up OpenTelemetry yourself and register `SentryPropagator`, register `NextSentryPropagator` instead and pass your tracer provider to `markPrerendersOnTracerProvider`, so that your prerendered pages carry no trace information either. -- **feat(nextjs): Mark pageloads that were served from a cached shell** +- **feat(nextjs): Mark pageloads of cached pages ([#25318](https://github.com/getsentry/sentry-javascript/pull/25318))** - 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. + 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 @adityareddy-dev, @jpdefo, @Jxxunnn, @philipatkinson, @Shubham-Padkonde, @mikamikasuki, and @mketiku. Thank you for your contributions! 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 855491173d4f..d49099b1632e 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 00e62ac623db..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,8 +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. 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. +// 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()`) @@ -58,11 +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, 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. + // 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; documentCached: unknown }> => { + const visit = async (): Promise<{ traceId: string | undefined; links: unknown; serverRenderingType: unknown }> => { const context = await browser.newContext(); const page: Page = await context.newPage(); @@ -76,7 +77,7 @@ 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); + expect(await page.locator('meta[name="sentry-cache-origin"]').count()).toBe(1); const pageloadTx = await pageloadTxPromise; await context.close(); @@ -84,7 +85,7 @@ test('does not share a trace between visitors of a shell generated at request ti return { traceId: pageloadTx.contexts?.trace?.trace_id, links: pageloadTx.contexts?.trace?.links, - documentCached: pageloadTx.contexts?.trace?.data?.['document.cached'], + serverRenderingType: pageloadTx.contexts?.trace?.data?.['page.server_rendering.type'], }; }; @@ -94,19 +95,75 @@ 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.documentCached).toBe(true); - expect(second.documentCached).toBe(true); + expect(first.serverRenderingType).toBe('static'); + expect(second.serverRenderingType).toBe('static'); - // Both link the one request that prerendered the shell, which is neither visitor's own trace. + // 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'); + 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 }) => { test.skip(process.env.TEST_ENV !== 'production', 'Pages are only prerendered in production builds'); @@ -122,8 +179,36 @@ test('starts a fresh trace on a fully static page', async ({ page }) => { // 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(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?.['document.cached']).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 3e6d5d60e233..80866014dc23 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 bb77dfc33e73..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,8 +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. 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. +// 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()`) @@ -56,11 +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, 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. + // 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; documentCached: unknown }> => { + const visit = async (): Promise<{ traceId: string | undefined; links: unknown; serverRenderingType: unknown }> => { const context = await browser.newContext(); const page: Page = await context.newPage(); @@ -71,7 +72,7 @@ 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); + expect(await page.locator('meta[name="sentry-cache-origin"]').count()).toBe(1); const pageloadSpan = await pageloadSpanPromise; await context.close(); @@ -79,7 +80,7 @@ test('does not share a trace between visitors of a shell generated at request ti return { traceId: pageloadSpan.trace_id, links: pageloadSpan.links, - documentCached: pageloadSpan.attributes['document.cached']?.value, + serverRenderingType: pageloadSpan.attributes['page.server_rendering.type']?.value, }; }; @@ -89,19 +90,76 @@ 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.documentCached).toBe(true); - expect(second.documentCached).toBe(true); + expect(first.serverRenderingType).toBe('static'); + expect(second.serverRenderingType).toBe('static'); - // Both link the one request that prerendered the shell, which is neither visitor's own trace. + // 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'); + 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 }) => { test.skip(process.env.TEST_ENV !== 'production', 'Pages are only prerendered in production builds'); @@ -117,10 +175,10 @@ test('starts a fresh trace on a fully static page', async ({ page }) => { // 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(await page.locator('meta[name="sentry-cache-origin"]').count()).toBe(0); expect(pageloadSpan.trace_id).toBeTruthy(); expect(pageloadSpan.links).toBeUndefined(); - expect(pageloadSpan.attributes['document.cached']).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/routing/appRouterPageloadInstrumentation.ts b/packages/nextjs/src/client/routing/appRouterPageloadInstrumentation.ts index eac4dee7685e..bab9ad8e2bda 100644 --- a/packages/nextjs/src/client/routing/appRouterPageloadInstrumentation.ts +++ b/packages/nextjs/src/client/routing/appRouterPageloadInstrumentation.ts @@ -19,11 +19,10 @@ import { stripTrailingSlash } from './parameterization'; import type { TraceMetaTagValues, TraceMetaTagWait } from './traceMetaTags'; import { addTraceMetaTagLink, - cachedDocumentSpanOptions, canWaitForTraceMetaTag, - readPrerenderMarker, readTraceMetaTags, waitForTraceMetaTag, + withCacheOrigin, } from './traceMetaTags'; /** @@ -53,19 +52,7 @@ function startPageloadSpan( 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 span = startBrowserTracingPageLoadSpan(client, withCacheOrigin(spanOptions, traceMetaTags), traceMetaTags); const scope = getCurrentScope(); const { normalizedRequest } = scope.getScopeData().sdkProcessingMetadata; if (normalizedRequest) { diff --git a/packages/nextjs/src/client/routing/traceMetaTags.ts b/packages/nextjs/src/client/routing/traceMetaTags.ts index a815b0321e42..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, SpanLink, StartSpanOptions } from '@sentry/core'; import { extractTraceparentData, GLOBAL_OBJ } from '@sentry/core'; -import { PRERENDER_META_TAG_NAME } from '../../common/traceMetaTags'; +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,14 +9,15 @@ 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; } -/** 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'; +// 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; @@ -161,19 +162,38 @@ export function addTraceMetaTagLink(span: Span, { sentryTrace }: TraceMetaTagVal } } -/** 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); +/** + * 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'; } /** - * 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. + * 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 cachedDocumentSpanOptions(marker: string): Pick { - const link = traceMetaTagLink(marker, 'cache_origin'); - return { attributes: { [DOCUMENT_CACHED_ATTRIBUTE]: true }, ...(link && { links: [link] }) }; +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 index aab29a353393..90ae909bf962 100644 --- a/packages/nextjs/src/common/traceMetaTags.ts +++ b/packages/nextjs/src/common/traceMetaTags.ts @@ -1,7 +1,6 @@ /** - * 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. + * 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 PRERENDER_META_TAG_NAME = 'sentry-prerender'; +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 9f5bdd5b0835..b37f3710f2b0 100644 --- a/packages/nextjs/src/config/withSentryConfig/getFinalConfigObjectUtils.ts +++ b/packages/nextjs/src/config/withSentryConfig/getFinalConfigObjectUtils.ts @@ -1,4 +1,4 @@ -import { PRERENDER_META_TAG_NAME } from '../../common/traceMetaTags'; +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'; @@ -193,13 +193,13 @@ export function maybeSetClientTraceMetadataOption( incomingUserNextConfigObject.experimental.clientTraceMetadata = [ 'baggage', 'sentry-trace', - PRERENDER_META_TAG_NAME, + 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 538fb28e725b..b583beff3465 100644 --- a/packages/nextjs/src/server/nextSentryPropagator.ts +++ b/packages/nextjs/src/server/nextSentryPropagator.ts @@ -1,14 +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 { PRERENDER_META_TAG_NAME } from '../common/traceMetaTags'; +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. The shell - * gets a `sentry-prerender` marker tag with the prerender's trace instead, which the client only ever links. + * 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 @@ -25,10 +25,10 @@ export class NextSentryPropagator extends SentryPropagator { } const values: Record = {}; - super.inject(ctx, values, { set: (c, key, value) => ((c as Record)[key] = value) }); + super.inject(ctx, values, defaultTextMapSetter); const sentryTrace = values['sentry-trace']; if (sentryTrace) { - setter.set(carrier, PRERENDER_META_TAG_NAME, 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 c3a7f546af3f..25ecbfdd3f80 100644 --- a/packages/nextjs/test/client/appRouterRoutingInstrumentation.test.ts +++ b/packages/nextjs/test/client/appRouterRoutingInstrumentation.test.ts @@ -74,6 +74,9 @@ async function setup(traceLifecycle: 'stream' | 'static'): Promise<{ }> { 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'); @@ -274,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'); }); @@ -286,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) { @@ -341,29 +366,71 @@ describe('appRouterInstrumentPageLoad', () => { }, ); - it('marks a pageload whose document is a cached shell and links the prerender', async () => { + it('starts a new trace for a static page and links the prerender as cache origin', 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); + setReadyState('complete'); + appendMeta(document.head, 'sentry-cache-origin', PRERENDER_SENTRY_TRACE); pageload.appRouterInstrumentPageLoad(core.getClient()!); - const span = core.getActiveSpan(); - expect(span).toBeDefined(); - const spanJson = core.spanToJSON(span!); + const spanJson = core.spanToJSON(core.getActiveSpan()!); expect(spanJson.attributes?.[SENTRY_OP]).toBe(PAGELOAD); - expect(spanJson.attributes?.['document.cached']).toBe(true); - expect(spanJson.trace_id).not.toBe('11111111111111111111111111111111'); + 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([ - expect.objectContaining({ - trace_id: '11111111111111111111111111111111', - span_id: '1111111111111111', - attributes: expect.objectContaining({ [SENTRY_LINK_TYPE]: 'cache_origin' }), - }), + { + trace_id: '22222222222222222222222222222222', + span_id: '2222222222222222', + sampled: true, + attributes: { [SENTRY_LINK_TYPE]: 'server_request' }, + }, ]); - meta.remove(); }); }); diff --git a/packages/nextjs/test/client/traceMetaTags.test.ts b/packages/nextjs/test/client/traceMetaTags.test.ts index 19f44927a153..695ad7c558bb 100644 --- a/packages/nextjs/test/client/traceMetaTags.test.ts +++ b/packages/nextjs/test/client/traceMetaTags.test.ts @@ -1,14 +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, - cachedDocumentSpanOptions, - readPrerenderMarker, canWaitForTraceMetaTag, readTraceMetaTags, waitForTraceMetaTag, + withCacheOrigin, } from '../../src/client/routing/traceMetaTags'; const HEAD_TRACE = '11111111111111111111111111111111-1111111111111111-1'; @@ -222,33 +222,62 @@ describe('addTraceMetaTagLink', () => { }); }); -describe('readPrerenderMarker', () => { - it('returns nothing without a sentry-prerender tag', () => { - expect(readPrerenderMarker()).toBeUndefined(); +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('reads the last sentry-prerender tag', () => { - appendMeta(document.head, 'sentry-prerender', HEAD_TRACE); - appendMeta(document.body, 'sentry-prerender', BODY_TRACE); + 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(readPrerenderMarker()).toBe(BODY_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], + }); }); -}); -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' }, - }, - ], + // 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('marks the span even when the marker is not a sentry-trace value', () => { - expect(cachedDocumentSpanOptions('prerendered')).toEqual({ attributes: { 'document.cached': true } }); + 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 ca02d59f5b11..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([{ key: 'sentry-prerender', value: expect.stringMatching(/-1$/) }]); + 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 7507399ce7a7..3dd60f97f848 100644 --- a/packages/nextjs/test/config/maybeSetClientTraceMetadataOption.test.ts +++ b/packages/nextjs/test/config/maybeSetClientTraceMetadataOption.test.ts @@ -3,10 +3,10 @@ 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', 'sentry-prerender']); + expect(config.experimental?.clientTraceMetadata).toEqual(['baggage', 'sentry-trace', 'sentry-cache-origin']); }); it('preserves user-provided clientTraceMetadata entries', () => { @@ -15,7 +15,7 @@ describe('maybeSetClientTraceMetadataOption', () => { expect(config.experimental?.clientTraceMetadata).toEqual([ 'baggage', 'sentry-trace', - 'sentry-prerender', + 'sentry-cache-origin', 'my-custom-key', ]); }); @@ -24,6 +24,6 @@ describe('maybeSetClientTraceMetadataOption', () => { // 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', 'sentry-prerender']); + expect(config.experimental?.clientTraceMetadata).toEqual(['baggage', 'sentry-trace', 'sentry-cache-origin']); }); }); diff --git a/packages/nextjs/test/server/nextSentryPropagator.test.ts b/packages/nextjs/test/server/nextSentryPropagator.test.ts index 0fc4903996ec..d437b1b8a164 100644 --- a/packages/nextjs/test/server/nextSentryPropagator.test.ts +++ b/packages/nextjs/test/server/nextSentryPropagator.test.ts @@ -82,11 +82,11 @@ describe('NextSentryPropagator', () => { }); }); - it('hands out only the shell marker with the trace of the prerender 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({ - 'sentry-prerender': `${span.spanContext().traceId}-${span.spanContext().spanId}-1`, + 'sentry-cache-origin': `${span.spanContext().traceId}-${span.spanContext().spanId}-1`, }); span.end(); }); @@ -94,22 +94,22 @@ describe('NextSentryPropagator', () => { it('keeps propagating to outgoing requests while a route is prerendered', () => { trace.getTracer('next.js').startActiveSpan('prerender route (app) /[id]', PRERENDER_SPAN_OPTIONS, span => { - expect(inject()).toEqual({ 'sentry-prerender': expect.stringMatching(/-1$/) }); + expect(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(Object.keys(inject())).toEqual(['sentry-prerender']); + expect(Object.keys(inject())).toEqual(['sentry-cache-origin']); }); span.end(); }); }); - it('hands out only the shell marker 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 => { @@ -120,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({ 'sentry-prerender': expect.stringMatching(/-0$/) }); + expect(inject()).toEqual({ 'sentry-cache-origin': expect.stringMatching(/-0$/) }); prerenderSpan.end(); }); span.end(); @@ -131,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(Object.keys(inject())).toEqual(['sentry-prerender']); + expect(Object.keys(inject())).toEqual(['sentry-cache-origin']); }); span.end(); @@ -147,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(Object.keys(inject())).toEqual(['sentry-prerender']); + expect(Object.keys(inject())).toEqual(['sentry-cache-origin']); span.end(); }); });