From c41a79825570e535e1407e3ed738db086fd22326 Mon Sep 17 00:00:00 2001 From: Charly Gomez Date: Tue, 13 Oct 2026 13:27:14 +0200 Subject: [PATCH 1/4] 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 748e488828d8..b8872cf95a1d 100644 --- a/packages/nextjs/src/client/browserTracingIntegration.ts +++ b/packages/nextjs/src/client/browserTracingIntegration.ts @@ -1,6 +1,6 @@ import type { Integration } from '@sentry/core'; import { browserTracingIntegration as originalBrowserTracingIntegration, isBotUserAgent } from '@sentry/react'; -import { settlePendingPageloadWait } from './routing/appRouterRoutingInstrumentation'; +import { settlePendingPageloadWait } from './routing/appRouterPageloadInstrumentation'; import { nextRouterInstrumentNavigation, nextRouterInstrumentPageLoad } from './routing/nextRoutingInstrumentation'; /** diff --git a/packages/nextjs/src/client/routing/appRouterPageloadInstrumentation.ts b/packages/nextjs/src/client/routing/appRouterPageloadInstrumentation.ts new file mode 100644 index 000000000000..c39f597f1999 --- /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 = WINDOW.location.href; + const pathname = stripTrailingSlash(WINDOW.location.pathname); + const parameterizedPathname = resolveCurrentRoute(client); + const spanOptions: StartSpanOptions = { + // With span streaming, span names have to be low cardinality, so we can't fall back to the URL. + name: parameterizedPathname ?? (hasSpanStreamingEnabled(client) ? PAGELOAD_SPAN_NAME_FALLBACK : pathname), + // pageload should always start at timeOrigin (and needs to be in s, not ms) + attributes: { + [SENTRY_OP]: PAGELOAD, + [SENTRY_ORIGIN]: 'auto.pageload.nextjs.app_router_instrumentation', + [SENTRY_SEGMENT_NAME_SOURCE]: parameterizedPathname ? 'route' : 'url', + [URL_PATH]: WINDOW.location.pathname, + [URL_FULL]: filterCollectedUrl(documentUrl, client), + ...(parameterizedPathname && { [URL_TEMPLATE]: parameterizedPathname }), + }, + }; + + // A tag that is already in the document was rendered for this request and can be continued. A + // Cache Components document that resumes a prerendered shell gets its tag with the resumed part, + // which usually arrives after the SDK started. Waiting for it lets the pageload continue the server + // trace instead of linking it; the span's start is backdated to the time origin either way. + const traceMetaTags = readTraceMetaTags(); + if (traceMetaTags || !canWaitForTraceMetaTag()) { + startPageloadSpan(client, spanOptions, documentUrl, traceMetaTags); + return; + } + + pendingPageloadWait = waitForTraceMetaTag( + values => { + pendingPageloadWait = undefined; + startPageloadSpan(client, spanOptions, documentUrl, values); + }, + () => { + pendingPageloadWait = undefined; + const span = startPageloadSpan(client, spanOptions, documentUrl); + // Only reachable through the cap or a navigation while the document still streams: a tag that + // shows up after this can no longer be continued, but the server request can still be linked. + if (span) { + waitForTraceMetaTag( + values => span.isRecording() && addTraceMetaTagLink(span, values), + () => undefined, + ); + } + }, + PAGELOAD_TRACE_META_TAG_TIMEOUT_MS, + ); +} diff --git a/packages/nextjs/src/client/routing/appRouterRoutingInstrumentation.ts b/packages/nextjs/src/client/routing/appRouterRoutingInstrumentation.ts index f5df240cd46c..8dd47257c3b6 100644 --- a/packages/nextjs/src/client/routing/appRouterRoutingInstrumentation.ts +++ b/packages/nextjs/src/client/routing/appRouterRoutingInstrumentation.ts @@ -1,25 +1,20 @@ -import type { Client, Span, StartSpanOptions } from '@sentry/core'; +import type { Client, Span } from '@sentry/core'; import { - getCurrentScope, GLOBAL_OBJ, hasSpanStreamingEnabled, isObjectLike, NAVIGATION_SPAN_NAME_FALLBACK, - PAGELOAD_SPAN_NAME_FALLBACK, filterCollectedUrl, timestampInSeconds, } from '@sentry/core'; import { startBrowserTracingNavigationSpan, - startBrowserTracingPageLoadSpan, WINDOW, getAbsoluteUrl, resolveCurrentRoute, resolveRoute, } from '@sentry/react'; import { stripTrailingSlash } from './parameterization'; -import type { TraceMetaTagValues, TraceMetaTagWait } from './traceMetaTags'; -import { addTraceMetaTagLink, canWaitForTraceMetaTag, readTraceMetaTags, waitForTraceMetaTag } from './traceMetaTags'; import { SENTRY_OP, SENTRY_SEGMENT_NAME_SOURCE, @@ -28,7 +23,7 @@ import { URL_TEMPLATE, SENTRY_ORIGIN, } from '@sentry/conventions/attributes'; -import { NAVIGATION, PAGELOAD } from '@sentry/conventions/op'; +import { NAVIGATION } from '@sentry/conventions/op'; function setNavigationSpanUrlAttributes(span: Span, urlPath: string, urlOrPath: string): void { span.setAttributes({ @@ -98,92 +93,6 @@ let navigationRoutingMode: 'router-patch' | 'transition-start-hook' = 'router-pa const currentRouterPatchingNavigationSpanRef: NavigationSpanRef = { current: undefined }; -/** - * How long the pageload start may wait for the resumed part of a Cache Components document. The wait - * normally ends with `DOMContentLoaded`; the cap only matters for a response the server keeps open. - */ -const PAGELOAD_TRACE_META_TAG_TIMEOUT_MS = 10_000; - -let pendingPageloadWait: TraceMetaTagWait | undefined; - -/** - * Starts a pageload that is still waiting for its trace meta tag with a fresh trace. Called when a - * navigation starts, before the navigation span is created, so the navigation ends the pageload like - * any pageload it interrupts, and the pageload is still recorded. - */ -export function settlePendingPageloadWait(): void { - pendingPageloadWait?.giveUp(); -} - -/** - * Starts the pageload span with the request data of the document. The span may start after a - * navigation already changed the location, so the browser SDK would otherwise read the new one. - */ -function startPageloadSpan( - client: Client, - spanOptions: StartSpanOptions, - documentUrl: string, - traceMetaTags?: TraceMetaTagValues, -): Span | undefined { - const span = startBrowserTracingPageLoadSpan(client, spanOptions, traceMetaTags); - const scope = getCurrentScope(); - const { normalizedRequest } = scope.getScopeData().sdkProcessingMetadata; - if (normalizedRequest) { - scope.setSDKProcessingMetadata({ normalizedRequest: { ...normalizedRequest, url: documentUrl } }); - } - return span; -} - -/** Instruments the Next.js app router for pageloads. */ -export function appRouterInstrumentPageLoad(client: Client): void { - const documentUrl = WINDOW.location.href; - const pathname = stripTrailingSlash(WINDOW.location.pathname); - const parameterizedPathname = resolveCurrentRoute(client); - const spanOptions: StartSpanOptions = { - // With span streaming, span names have to be low cardinality, so we can't fall back to the URL. - name: parameterizedPathname ?? (hasSpanStreamingEnabled(client) ? PAGELOAD_SPAN_NAME_FALLBACK : pathname), - // pageload should always start at timeOrigin (and needs to be in s, not ms) - attributes: { - [SENTRY_OP]: PAGELOAD, - [SENTRY_ORIGIN]: 'auto.pageload.nextjs.app_router_instrumentation', - [SENTRY_SEGMENT_NAME_SOURCE]: parameterizedPathname ? 'route' : 'url', - [URL_PATH]: WINDOW.location.pathname, - [URL_FULL]: filterCollectedUrl(documentUrl, client), - ...(parameterizedPathname && { [URL_TEMPLATE]: parameterizedPathname }), - }, - }; - - // A tag that is already in the document was rendered for this request and can be continued. A - // Cache Components document that resumes a prerendered shell gets its tag with the resumed part, - // which usually arrives after the SDK started. Waiting for it lets the pageload continue the server - // trace instead of linking it; the span's start is backdated to the time origin either way. - const traceMetaTags = readTraceMetaTags(); - if (traceMetaTags || !canWaitForTraceMetaTag()) { - startPageloadSpan(client, spanOptions, documentUrl, traceMetaTags); - return; - } - - pendingPageloadWait = waitForTraceMetaTag( - values => { - pendingPageloadWait = undefined; - startPageloadSpan(client, spanOptions, documentUrl, values); - }, - () => { - pendingPageloadWait = undefined; - const span = startPageloadSpan(client, spanOptions, documentUrl); - // Only reachable through the cap or a navigation while the document still streams: a tag that - // shows up after this can no longer be continued, but the server request can still be linked. - if (span) { - waitForTraceMetaTag( - values => span.isRecording() && addTraceMetaTagLink(span, values), - () => undefined, - ); - } - }, - PAGELOAD_TRACE_META_TAG_TIMEOUT_MS, - ); -} - interface NavigationSpanRef { current: Span | undefined; } diff --git a/packages/nextjs/src/client/routing/nextRoutingInstrumentation.ts b/packages/nextjs/src/client/routing/nextRoutingInstrumentation.ts index f9a31f3f0e79..e453ac6e6871 100644 --- a/packages/nextjs/src/client/routing/nextRoutingInstrumentation.ts +++ b/packages/nextjs/src/client/routing/nextRoutingInstrumentation.ts @@ -1,6 +1,7 @@ import type { Client } from '@sentry/core'; import { WINDOW } from '@sentry/react'; -import { appRouterInstrumentNavigation, appRouterInstrumentPageLoad } from './appRouterRoutingInstrumentation'; +import { appRouterInstrumentNavigation } from './appRouterRoutingInstrumentation'; +import { appRouterInstrumentPageLoad } from './appRouterPageloadInstrumentation'; import { pagesRouterInstrumentNavigation } from './pagesRouterNavigationInstrumentation'; import { pagesRouterInstrumentPageLoad } from './pagesRouterRoutingInstrumentation'; diff --git a/packages/nextjs/src/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 11b4573a2474ef8c10a3b68b3d0772706d4e4e9f Mon Sep 17 00:00:00 2001 From: Charly Gomez Date: Tue, 13 Oct 2026 13:27:31 +0200 Subject: [PATCH 2/4] 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 | 39 +++++++++++++++++-- .../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, 184 insertions(+), 38 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f6108e1d9a7c..eda01d52f2fe 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,10 @@ Pageloads of apps with `cacheComponents` enabled are connected to their server request again. Pages that Next.js prerenders no longer carry trace information, so a cached page can no longer connect visitors to a request that was not theirs. This also applies to ISR pages. The pageload waits for the trace information when it arrives with the part of the page that streams in after a cached shell. +- **feat(nextjs): Mark pageloads that were served from a cached shell** + + A pageload whose document 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. + ## 11.7.0 ### Important Changes diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/tests/cacheOrigin-pageloadConnection.spec.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/tests/cacheOrigin-pageloadConnection.spec.ts index 900ce43679e9..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 7ebf36478add..1ae6a0a056f9 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,8 +115,10 @@ test('starts a fresh trace on a fully static page', async ({ page }) => { const pageloadSpan = await pageloadSpanPromise; - // A build-time shell carries no trace context at all. + // A build-time shell carries no trace context and no marker, the SDK does not run during the build. expect(await page.locator('meta[name="sentry-trace"]').count()).toBe(0); + expect(await page.locator('meta[name="sentry-shell"]').count()).toBe(0); expect(pageloadSpan.trace_id).toBeTruthy(); expect(pageloadSpan.links).toBeUndefined(); + expect(pageloadSpan.attributes['sentry.nextjs.cached_shell']).toBeUndefined(); }); diff --git a/packages/nextjs/src/client/routing/traceMetaTags.ts b/packages/nextjs/src/client/routing/traceMetaTags.ts index 75eeca09ec9e..d5cd8740e9d7 100644 --- a/packages/nextjs/src/client/routing/traceMetaTags.ts +++ b/packages/nextjs/src/client/routing/traceMetaTags.ts @@ -1,6 +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. @@ -12,6 +13,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; } @@ -132,7 +136,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; @@ -144,5 +148,22 @@ export function addTraceMetaTagLink(span: Span, { sentryTrace }: TraceMetaTagVal spanId: traceparent.parentSpanId, traceFlags: traceparent.parentSampled ? TRACE_FLAG_SAMPLED : TRACE_FLAG_NONE, }, + ...(linkType && { attributes: { [SENTRY_LINK_TYPE]: linkType } }), }); } + +/** 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 9c684a6acc02..f716703cd687 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'; @@ -148,6 +149,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 7a96e4ab9afc..23bcadd7c36b 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(); @@ -315,4 +320,30 @@ describe('appRouterInstrumentPageLoad', () => { 'http://localhost:3000/navigation?from=document', ); }); + + it('marks a pageload whose document is a cached shell and links the prerender', async () => { + const { core, pageload } = await setup('static'); + Object.defineProperty(document, 'readyState', { value: 'complete', configurable: true }); + const meta = document.createElement('meta'); + meta.name = 'sentry-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 e05bca779da6..7d9e4020783c 100644 --- a/packages/nextjs/test/client/traceMetaTags.test.ts +++ b/packages/nextjs/test/client/traceMetaTags.test.ts @@ -3,6 +3,8 @@ import type { Span } from '@sentry/core'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { addTraceMetaTagLink, + markCachedShell, + readShellMarker, canWaitForTraceMetaTag, readTraceMetaTags, waitForTraceMetaTag, @@ -216,3 +218,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 5d2d542992bf4b480191e5a364d5d5f7118f4ca4 Mon Sep 17 00:00:00 2001 From: Charly Gomez Date: Tue, 13 Oct 2026 13:42:40 +0200 Subject: [PATCH 3/4] 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 | 36 +++++++++++-------- .../appRouterRoutingInstrumentation.test.ts | 4 +-- .../nextjs/test/client/traceMetaTags.test.ts | 32 ++++++++--------- 4 files changed, 52 insertions(+), 40 deletions(-) diff --git a/packages/nextjs/src/client/routing/appRouterPageloadInstrumentation.ts b/packages/nextjs/src/client/routing/appRouterPageloadInstrumentation.ts index c39f597f1999..7c4e34e12c13 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 d5cd8740e9d7..87eb9da7b056 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'; @@ -132,24 +132,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, }, ...(linkType && { attributes: { [SENTRY_LINK_TYPE]: linkType } }), - }); + }; +} + +/** + * Adds a span link to the span described by a `sentry-trace` value. Used when the tag arrives after + * the pageload span exists, which can only link the server request, not continue it. + */ +export function addTraceMetaTagLink(span: Span, { sentryTrace }: TraceMetaTagValues): void { + const link = traceMetaTagLink(sentryTrace); + if (link) { + span.addLink(link); + } } /** Reads the marker the server writes into a shell while it prerenders it, see `nextSentryPropagator.ts`. */ @@ -159,11 +166,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 23bcadd7c36b..8f21e888cb0f 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'; @@ -341,7 +341,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 7d9e4020783c..02f4c11a9790 100644 --- a/packages/nextjs/test/client/traceMetaTags.test.ts +++ b/packages/nextjs/test/client/traceMetaTags.test.ts @@ -1,9 +1,10 @@ // @vitest-environment jsdom +import { SENTRY_LINK_TYPE } from '@sentry/conventions/attributes'; import type { Span } from '@sentry/core'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { addTraceMetaTagLink, - markCachedShell, + cachedShellSpanOptions, readShellMarker, canWaitForTraceMetaTag, readTraceMetaTags, @@ -232,25 +233,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 26177faab1fdc16f3f3fe9a58876a6fbf87fe25c Mon Sep 17 00:00:00 2001 From: Charly Gomez Date: Tue, 13 Oct 2026 13:56:09 +0200 Subject: [PATCH 4/4] 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 eda01d52f2fe..34238db2d80c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,7 +10,7 @@ - **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. ## 11.7.0 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 1ae6a0a056f9..6bb500aaf613 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/tests/cacheOrigin-pageloadConnection.spec.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/tests/cacheOrigin-pageloadConnection.spec.ts @@ -7,7 +7,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,8 +117,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(pageloadSpan.trace_id).toBeTruthy(); expect(pageloadSpan.links).toBeUndefined(); - expect(pageloadSpan.attributes['sentry.nextjs.cached_shell']).toBeUndefined(); + expect(pageloadSpan.attributes['document.cached']).toBeUndefined(); }); diff --git a/packages/nextjs/src/client/routing/appRouterPageloadInstrumentation.ts b/packages/nextjs/src/client/routing/appRouterPageloadInstrumentation.ts index 7c4e34e12c13..1849040426d4 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 87eb9da7b056..0cec03935c82 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. @@ -13,8 +13,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; @@ -159,19 +159,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 f716703cd687..c0791a981500 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'; @@ -149,7 +149,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 8f21e888cb0f..a9fa6fe586b5 100644 --- a/packages/nextjs/test/client/appRouterRoutingInstrumentation.test.ts +++ b/packages/nextjs/test/client/appRouterRoutingInstrumentation.test.ts @@ -325,7 +325,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); @@ -335,7 +335,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 02f4c11a9790..34e8a5d03f35 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, @@ -220,23 +220,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 }, @@ -247,6 +247,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(); }); });