diff --git a/CHANGELOG.md b/CHANGELOG.md index 94cb6dcefa9b..5b6587069a3c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,10 @@ Work in this release was contributed by @lazerg, @rjmcgee53192, @adityankale1908 ### Important Changes +- **feat(nuxt): Mark pageloads of cached pages ([#26305](https://github.com/getsentry/sentry-javascript/pull/26305))** + + When Nuxt serves a cached or prerendered page, for example with a `swr` or `isr` route rule, the pageload now has the `page.server_rendering.type: static` attribute and starts its own trace. If the request that rendered the cached page was sampled, the pageload also links to it. So visitors of a cached page no longer continue the trace of the request that rendered it. + - **feat(bundler-plugins): Enable hidden source maps and remove them after upload ([#25637](https://github.com/getsentry/sentry-javascript/pull/25637), [#25645](https://github.com/getsentry/sentry-javascript/pull/25645), [#25646](https://github.com/getsentry/sentry-javascript/pull/25646), [#25647](https://github.com/getsentry/sentry-javascript/pull/25647), [#25648](https://github.com/getsentry/sentry-javascript/pull/25648))** The Sentry Rollup, Vite, webpack and esbuild plugins now turn on hidden source maps when your bundler has no source maps enabled and the plugin can upload them. They upload the maps to Sentry and remove them from the build output. You no longer need to set `sourcemap: 'hidden'` or `filesToDeleteAfterUpload` globs: diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4/package.json b/dev-packages/e2e-tests/test-applications/nuxt-4/package.json index 35f5a25b7a42..a1cd9f3a8596 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-4/package.json +++ b/dev-packages/e2e-tests/test-applications/nuxt-4/package.json @@ -32,6 +32,7 @@ }, "devDependencies": { "@playwright/test": "1.64.1", + "@sentry/conventions": "0.32.0", "@sentry-internal/test-utils": "file:../../packed/sentry-internal-test-utils-packed.tgz" }, "sentryTest": { diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4/tests/tracing.cached-html.test.ts b/dev-packages/e2e-tests/test-applications/nuxt-4/tests/tracing.cached-html.test.ts index 42ed5115020c..f990f2bd3c3e 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-4/tests/tracing.cached-html.test.ts +++ b/dev-packages/e2e-tests/test-applications/nuxt-4/tests/tracing.cached-html.test.ts @@ -1,4 +1,5 @@ import { expect, test, type Page } from '@playwright/test'; +import { PAGE_SERVER_RENDERING_TYPE, SENTRY_LINK_TYPE } from '@sentry/conventions/attributes'; import { getSpanOp, waitForStreamedSpan } from '@sentry-internal/test-utils'; import { isViteServer, NO_META_TAGS } from './isViteServer'; @@ -18,16 +19,19 @@ test.describe('Rendering Modes with Cached HTML', () => { await testChangingTracingMetaTagsOnISRPage(page, '/rendering-modes/isr-1h-cached-page', 'ISR 1h Cached Page'); }); - test('exclude tracing meta tags on SWR-cached page', async ({ page }) => { - await testExcludeTracingMetaTagsOnCachedPage(page, '/rendering-modes/swr-cached-page', 'SWR Cached Page'); + test('links the render as cache origin on SWR-cached page', async ({ page }) => { + test.fail(isViteServer, NO_META_TAGS); + await testCacheOriginOnRuntimeCachedPage(page, '/rendering-modes/swr-cached-page', 'SWR Cached Page'); }); - test('exclude tracing meta tags on SWR 1h cached page', async ({ page }) => { - await testExcludeTracingMetaTagsOnCachedPage(page, '/rendering-modes/swr-1h-cached-page', 'SWR 1h Cached Page'); + test('links the render as cache origin on SWR 1h cached page', async ({ page }) => { + test.fail(isViteServer, NO_META_TAGS); + await testCacheOriginOnRuntimeCachedPage(page, '/rendering-modes/swr-1h-cached-page', 'SWR 1h Cached Page'); }); - test('exclude tracing meta tags on pre-rendered page', async ({ page }) => { - await testExcludeTracingMetaTagsOnCachedPage(page, '/rendering-modes/pre-rendered-page', 'Pre-Rendered Page'); + test('marks pageloads of pre-rendered page as static without a cache origin link', async ({ page }) => { + test.fail(isViteServer, NO_META_TAGS); + await testEmptyCacheOriginOnPrerenderedPage(page, '/rendering-modes/pre-rendered-page', 'Pre-Rendered Page'); }); }); @@ -122,26 +126,25 @@ export async function testChangingTracingMetaTagsOnISRPage( }); } +async function getCacheOriginMetaTag(page: Page): Promise { + await expect(page.locator('meta[name="sentry-cache-origin"]')).toHaveCount(1); + expect(await page.locator('meta[name="sentry-trace"]').count()).toBe(0); + expect(await page.locator('meta[name="baggage"]').count()).toBe(0); + return page.locator('meta[name="sentry-cache-origin"]').getAttribute('content'); +} + /** - * Tests that tracing meta-tags are excluded on cached pages (SWR, pre-rendered, etc.) - * This utility handles the common pattern of: - * 1. Making two requests to a cached page - * 2. Verifying no tracing meta-tags are present - * 3. Verifying traces are not distributed (each pageload starts its own trace) - * - * @param page - Playwright page object - * @param routePath - The route path to test (e.g., '/rendering-modes/swr-cached-page') - * @param expectedPageText - The text to verify is visible on the page (e.g., 'SWR Cached Page') + * Tests a page that a route rule caches at runtime. The cached HTML holds the `sentry-trace` value of + * the render in a `sentry-cache-origin` tag. Every pageload starts its own trace, is static, and links + * the render. */ -export async function testExcludeTracingMetaTagsOnCachedPage( +export async function testCacheOriginOnRuntimeCachedPage( page: Page, routePath: string, expectedPageText: string, ): Promise { - // === 1. Request === + // === 1. Request (renders and fills the cache) === const clientSpanPromise1 = waitForPageloadSegment(routePath); - - // Only the 1. request creates a server root span const serverSpanPromise1 = waitForServerSegment(routePath); const [_1, clientSpan1, serverSpan1] = await Promise.all([ @@ -150,13 +153,9 @@ export async function testExcludeTracingMetaTagsOnCachedPage( serverSpanPromise1, expect(page.getByText(expectedPageText, { exact: true })).toBeVisible(), ]); + const cacheOrigin1 = await getCacheOriginMetaTag(page); - // Verify no baggage and sentry-trace meta-tags are present on first request - expect(await page.locator('meta[name="baggage"]').count()).toBe(0); - expect(await page.locator('meta[name="sentry-trace"]').count()).toBe(0); - - // === 2. Request === - + // === 2. Request (served from the cache) === const clientSpanPromise2 = waitForPageloadSegment(routePath); const [_2, clientSpan2] = await Promise.all([ @@ -164,30 +163,71 @@ export async function testExcludeTracingMetaTagsOnCachedPage( clientSpanPromise2, expect(page.getByText(expectedPageText, { exact: true })).toBeVisible(), ]); - - const clientSpan1TraceId = clientSpan1.trace_id; - const clientSpan2TraceId = clientSpan2.trace_id; - - const serverSpan1TraceId = serverSpan1.trace_id; - - await test.step('No baggage and sentry-trace meta-tags are present on second request', async () => { - expect(await page.locator('meta[name="baggage"]').count()).toBe(0); - expect(await page.locator('meta[name="sentry-trace"]').count()).toBe(0); - }); - - await test.step('1. server root span and all client root spans are defined', () => { - expect(serverSpan1TraceId).toBeDefined(); - expect(clientSpan1TraceId).toBeDefined(); - expect(clientSpan2TraceId).toBeDefined(); + const cacheOrigin2 = await getCacheOriginMetaTag(page); + + const [originTraceId, originSpanId, originSampled] = cacheOrigin1?.split('-') ?? []; + const cacheOriginLink = { + trace_id: serverSpan1.trace_id, + span_id: originSpanId, + sampled: true, + attributes: { [SENTRY_LINK_TYPE]: { type: 'string', value: 'cache_origin' } }, + }; + + await test.step('The cache origin tag holds the sampled render of the 1. request', () => { + expect(originTraceId).toBe(serverSpan1.trace_id); + expect(originSpanId).toMatch(/^[a-f0-9]{16}$/); + expect(originSampled).toBe('1'); + expect(cacheOrigin2).toBe(cacheOrigin1); }); - await test.step('Trace is not distributed', () => { - // Cannot create distributed trace as HTML Meta Tags are not added (caching leads to multiple usages of the same server trace id) - expect(clientSpan1TraceId).not.toBe(clientSpan2TraceId); - expect(clientSpan1TraceId).not.toBe(serverSpan1TraceId); - expect(clientSpan2TraceId).not.toBe(serverSpan1TraceId); - // Without meta tags the pageloads have no parent and start their own traces + await test.step('Every pageload starts its own trace', () => { expect(clientSpan1.parent_span_id).toBeUndefined(); expect(clientSpan2.parent_span_id).toBeUndefined(); + expect(clientSpan1.trace_id).not.toBe(serverSpan1.trace_id); + expect(clientSpan2.trace_id).not.toBe(serverSpan1.trace_id); + expect(clientSpan1.trace_id).not.toBe(clientSpan2.trace_id); + }); + + await test.step('Every pageload is static and links the render', () => { + expect(clientSpan1.attributes?.[PAGE_SERVER_RENDERING_TYPE]).toEqual({ type: 'string', value: 'static' }); + expect(clientSpan2.attributes?.[PAGE_SERVER_RENDERING_TYPE]).toEqual({ type: 'string', value: 'static' }); + expect(clientSpan1.links).toEqual([cacheOriginLink]); + expect(clientSpan2.links).toEqual([cacheOriginLink]); }); } + +/** + * Tests a page that Nitro prerendered during the build. The SDK does not run in the build, so the + * `sentry-cache-origin` tag is empty: every pageload starts its own trace and is static, without a link. + */ +export async function testEmptyCacheOriginOnPrerenderedPage( + page: Page, + routePath: string, + expectedPageText: string, +): Promise { + const clientSpanPromise1 = waitForPageloadSegment(routePath); + const [_1, clientSpan1] = await Promise.all([ + page.goto(routePath), + clientSpanPromise1, + expect(page.getByText(expectedPageText, { exact: true })).toBeVisible(), + ]); + const cacheOrigin1 = await getCacheOriginMetaTag(page); + + const clientSpanPromise2 = waitForPageloadSegment(routePath); + const [_2, clientSpan2] = await Promise.all([ + page.goto(routePath), + clientSpanPromise2, + expect(page.getByText(expectedPageText, { exact: true })).toBeVisible(), + ]); + const cacheOrigin2 = await getCacheOriginMetaTag(page); + + expect(cacheOrigin1).toBe(''); + expect(cacheOrigin2).toBe(''); + expect(clientSpan1.parent_span_id).toBeUndefined(); + expect(clientSpan2.parent_span_id).toBeUndefined(); + expect(clientSpan1.trace_id).not.toBe(clientSpan2.trace_id); + expect(clientSpan1.attributes?.[PAGE_SERVER_RENDERING_TYPE]).toEqual({ type: 'string', value: 'static' }); + expect(clientSpan2.attributes?.[PAGE_SERVER_RENDERING_TYPE]).toEqual({ type: 'string', value: 'static' }); + expect(clientSpan1.links).toBeUndefined(); + expect(clientSpan2.links).toBeUndefined(); +} diff --git a/packages/nuxt/src/runtime/plugins/sentry-cloudflare.server.ts b/packages/nuxt/src/runtime/plugins/sentry-cloudflare.server.ts index 45dfa9a87a55..ae8e8c68ed2d 100644 --- a/packages/nuxt/src/runtime/plugins/sentry-cloudflare.server.ts +++ b/packages/nuxt/src/runtime/plugins/sentry-cloudflare.server.ts @@ -9,8 +9,8 @@ import type { NitroApp, NitroAppPlugin } from 'nitropack'; import type { NuxtRenderHTMLContext } from 'nuxt/app'; import { sentryCaptureErrorHook } from '../hooks/captureErrorHook'; import { updateRouteBeforeResponse } from '../hooks/updateRouteBeforeResponse'; -import { addSentryTracingMetaTags } from '../utils'; import { getCfProperties, getCloudflareProperties, hasCfProperty, isEventType } from '../utils/event-type-check'; +import { addSentryPageMetaTags } from '../utils/pageMetaTags'; /** * Sentry Cloudflare Nitro plugin for when using the "cloudflare-pages" preset in Nuxt. @@ -162,9 +162,9 @@ export const sentryCloudflareNitroPlugin = if (storedTraceData && Object.keys(storedTraceData).length > 0) { debug.log('Using stored trace data for HTML meta-tags: ', storedTraceData); - addSentryTracingMetaTags(html.head, storedTraceData); + addSentryPageMetaTags(html.head, event, storedTraceData); } else { - addSentryTracingMetaTags(html.head); + addSentryPageMetaTags(html.head, event); } }); diff --git a/packages/nuxt/src/runtime/plugins/sentry.client.ts b/packages/nuxt/src/runtime/plugins/sentry.client.ts index 423e8c457492..2df161d56a01 100644 --- a/packages/nuxt/src/runtime/plugins/sentry.client.ts +++ b/packages/nuxt/src/runtime/plugins/sentry.client.ts @@ -9,6 +9,7 @@ import { import { defineNuxtPlugin, isNuxtError } from 'nuxt/app'; import type { GlobalObjWithIntegrationOptions } from '../../client/vueIntegration'; import { reportNuxtError } from '../utils'; +import { withCacheOrigin } from '../utils/cacheOriginPageload'; // --- Types are copied from @sentry/vue (so it does not need to be exported) --- // The following type is an intersection of the Route type from VueRouter v2, v3, and v4. @@ -73,7 +74,11 @@ export default defineNuxtPlugin({ if (sentryClient && '$router' in nuxtApp) { sentryClient.addIntegration( - browserTracingIntegration({ router: nuxtApp.$router as VueRouter, routeLabel: 'path' }), + browserTracingIntegration({ + router: nuxtApp.$router as VueRouter, + routeLabel: 'path', + beforeStartSpan: options => withCacheOrigin(options, nuxtApp.payload.serverRendered === true), + }), ); } } diff --git a/packages/nuxt/src/runtime/plugins/sentry.server.ts b/packages/nuxt/src/runtime/plugins/sentry.server.ts index fd35d035c077..d9045578c341 100644 --- a/packages/nuxt/src/runtime/plugins/sentry.server.ts +++ b/packages/nuxt/src/runtime/plugins/sentry.server.ts @@ -1,36 +1,13 @@ -import { debug } from '@sentry/core'; import type { H3Event } from 'h3'; import type { NitroAppPlugin } from 'nitropack'; import type { NuxtRenderHTMLContext } from 'nuxt/app'; import { sentryCaptureErrorHook } from '../hooks/captureErrorHook'; -import { addSentryTracingMetaTags } from '../utils'; +import { addSentryPageMetaTags } from '../utils/pageMetaTags'; export default (nitroApp => { nitroApp.hooks.hook('error', sentryCaptureErrorHook); nitroApp.hooks.hook('render:html', (html: NuxtRenderHTMLContext, { event }: { event: H3Event }) => { - // h3 v1 (Nuxt 4): event.node.res.getHeaders(); h3 v2 (Nuxt 5): event.node is undefined - const nodeResHeadersH3v1 = event.node?.res?.getHeaders() || {}; - - // h3 v2 (Nuxt 5): response headers are on event.res.headers - const isPreRenderedPage = - Object.keys(nodeResHeadersH3v1).includes('x-nitro-prerender') || - // fix × typescript-eslint(no-unsafe-member-access): Unsafe member access .res on an `any` value. - // oxlint-disable-next-line typescript/no-explicit-any,typescript-oxlint/no-unsafe-member-access - !!(event as any).res?.headers?.has?.('x-nitro-prerender'); - - // oxlint-disable-next-line typescript-oxlint/no-unsafe-member-access - const isSWRCachedPage = /* Nitro v2 */ (event?.context?.cache?.options?.swr || - // oxlint-disable-next-line typescript-oxlint/no-unsafe-member-access - /* Nitro v3 */ event?.context?.routeRules?.swr) as boolean; - - if (!isPreRenderedPage && !isSWRCachedPage) { - addSentryTracingMetaTags(html.head); - } else { - const reason = isPreRenderedPage ? 'the page was pre-rendered' : 'SWR caching is enabled for the route'; - debug.log( - `Not adding Sentry tracing meta tags to HTML for ${event.path} because ${reason}. This will disable distributed tracing and prevent connecting multiple client page loads to the same server request.`, - ); - } + addSentryPageMetaTags(html.head, event); }); }) satisfies NitroAppPlugin; diff --git a/packages/nuxt/src/runtime/utils/cacheOriginMetaTag.ts b/packages/nuxt/src/runtime/utils/cacheOriginMetaTag.ts new file mode 100644 index 000000000000..860a747209d9 --- /dev/null +++ b/packages/nuxt/src/runtime/utils/cacheOriginMetaTag.ts @@ -0,0 +1,5 @@ +/** + * Replaces `sentry-trace` and `baggage` in a page that other visitors get too. Holds the `sentry-trace` + * value of the render, or nothing for a build prerender. The client links it and never continues it. + */ +export const CACHE_ORIGIN_META_TAG_NAME = 'sentry-cache-origin'; diff --git a/packages/nuxt/src/runtime/utils/cacheOriginPageload.ts b/packages/nuxt/src/runtime/utils/cacheOriginPageload.ts new file mode 100644 index 000000000000..3ef0ddb3a28a --- /dev/null +++ b/packages/nuxt/src/runtime/utils/cacheOriginPageload.ts @@ -0,0 +1,46 @@ +import { PAGE_SERVER_RENDERING_TYPE, SENTRY_LINK_TYPE } from '@sentry/conventions/attributes'; +import { PAGELOAD } from '@sentry/conventions/op'; +import type { SpanLink, StartSpanOptions } from '@sentry/core'; +import { extractTraceparentData } from '@sentry/core'; +import { WINDOW } from '@sentry/vue'; +import { CACHE_ORIGIN_META_TAG_NAME } from './cacheOriginMetaTag'; + +// `@opentelemetry/api` `TraceFlags`, inlined because core does not export its copies. +const TRACE_FLAG_SAMPLED = 0x1; + +/** The `cache_origin` link to the sampled render that a `sentry-trace` value describes. */ +function getCacheOriginLink(sentryTrace: string): SpanLink | undefined { + const traceparent = extractTraceparentData(sentryTrace); + if (!traceparent?.traceId || !traceparent.parentSpanId || !traceparent.parentSampled) { + return undefined; + } + return { + context: { traceId: traceparent.traceId, spanId: traceparent.parentSpanId, traceFlags: TRACE_FLAG_SAMPLED }, + attributes: { [SENTRY_LINK_TYPE]: 'cache_origin' }, + }; +} + +/** + * Marks the pageload of a reused page as `static` and links the render if it was sampled (as in Next.js). + * Runs at span start, so `tracesSampler` and `ignoreSpans` see the attribute. A client-side shell + * (`ssr: false`) stays unmarked, because the server rendered none of its content. + */ +export function withCacheOrigin(spanOptions: StartSpanOptions, isServerRendered: boolean): StartSpanOptions { + if (spanOptions.op !== PAGELOAD || !isServerRendered) { + return spanOptions; + } + + const cacheOrigin = WINDOW.document + ?.querySelector(`meta[name="${CACHE_ORIGIN_META_TAG_NAME}"]`) + ?.getAttribute('content'); + if (typeof cacheOrigin !== 'string') { + return spanOptions; + } + + const link = getCacheOriginLink(cacheOrigin); + return { + ...spanOptions, + attributes: { ...spanOptions.attributes, [PAGE_SERVER_RENDERING_TYPE]: 'static' }, + links: link ? [...(spanOptions.links ?? []), link] : spanOptions.links, + }; +} diff --git a/packages/nuxt/src/runtime/utils/pageMetaTags.ts b/packages/nuxt/src/runtime/utils/pageMetaTags.ts new file mode 100644 index 000000000000..760c34a0168d --- /dev/null +++ b/packages/nuxt/src/runtime/utils/pageMetaTags.ts @@ -0,0 +1,74 @@ +import type { SerializedTraceData } from '@sentry/core'; +import { debug, getTraceData, GLOBAL_OBJ } from '@sentry/core'; +import type { H3Event } from 'h3'; +import type { NuxtRenderHTMLContext } from 'nuxt/app'; +import { addSentryTracingMetaTags, getEventRequestInfo } from '../utils'; +import { CACHE_ORIGIN_META_TAG_NAME } from './cacheOriginMetaTag'; + +/** Global flag set by the generated runtime-flags module to the Nitro preset of the server build. */ +export const NUXT_NITRO_PRESET_FLAG = '__SENTRY_NUXT_NITRO_PRESET__'; + +// Presets on which a platform cache serves `isr` pages. On all other presets (also `vercel-edge` and +// `netlify-edge`), Nitro ignores `isr` and renders the page for each request. +const PLATFORM_ISR_PRESETS = new Set(['vercel', 'netlify', 'netlify-legacy', 'netlify-builder']); + +interface RouteRulesContext { + // Nitro v2: the Nuxt renderer writes the merged rules here before `render:html`. + _nitro?: { routeRules?: RouteRules }; + // Nitro v3: the rules by name, without the rules set to `false`. + routeRules?: RouteRules; +} + +interface RouteRules { + cache?: unknown; + isr?: unknown; +} + +/** + * Adds the Sentry meta tags to a rendered page. A page that other visitors get too has a + * `sentry-cache-origin` tag instead of `sentry-trace` and `baggage`, so that no visitor continues the + * trace of the render. The tag is empty for build prerenders, because the SDK does not run in the build. + */ +export function addSentryPageMetaTags( + head: NuxtRenderHTMLContext['head'], + event: H3Event, + traceData?: SerializedTraceData, +): void { + const cacheOrigin = getCacheOrigin(event, traceData); + if (cacheOrigin === undefined) { + addSentryTracingMetaTags(head, traceData); + return; + } + + debug.log(`Adding the Sentry cache origin meta tag to the reused page ${getEventRequestInfo(event).path}.`); + head.push(``); +} + +/** The `sentry-trace` value of the render of a reused page, or `undefined` for a page rendered per request. */ +function getCacheOrigin(event: H3Event, traceData: SerializedTraceData | undefined): string | undefined { + if (isPrerenderRequest(event)) { + return ''; + } + if (!isCachedRoute(event)) { + return undefined; + } + return (traceData ?? getTraceData())['sentry-trace'] ?? ''; +} + +// The Nitro prerenderer sends this header with each route it renders (Nitro v2 and v3). `event.headers` +// exists in h3 v1 (1.8+) and h3 v2, where it is a deprecated alias of `event.req.headers`. +function isPrerenderRequest(event: H3Event): boolean { + return event.headers.has('x-nitro-prerender'); +} + +// Nitro changes `swr` rules into `cache` rules, so `cache` covers both. +function isCachedRoute(event: H3Event): boolean { + const context = event.context as RouteRulesContext | undefined; + const routeRules = context?._nitro?.routeRules ?? context?.routeRules; + return !!routeRules?.cache || (!!routeRules?.isr && hasPlatformIsr()); +} + +function hasPlatformIsr(): boolean { + const preset = (GLOBAL_OBJ as typeof GLOBAL_OBJ & { [NUXT_NITRO_PRESET_FLAG]?: unknown })[NUXT_NITRO_PRESET_FLAG]; + return typeof preset === 'string' && PLATFORM_ISR_PRESETS.has(preset); +} diff --git a/packages/nuxt/src/vite/utils.ts b/packages/nuxt/src/vite/utils.ts index e5b81e2802ac..ee00b95a95c1 100644 --- a/packages/nuxt/src/vite/utils.ts +++ b/packages/nuxt/src/vite/utils.ts @@ -6,6 +6,7 @@ import { fileURLToPath } from 'node:url'; import type { SentryNuxtModuleOptions } from '../common/types'; import { addTemplate, resolvePath, resolveServerVariant } from '@nuxt/kit'; import { NUXT_DEV_MODE_FLAG, NUXT_PRERENDER_FLAG } from '../common/devMode'; +import { NUXT_NITRO_PRESET_FLAG } from '../runtime/utils/pageMetaTags'; const SERVER_API_VARIANTS = { nitro2: 'nitro2', nitro3: 'nitro3', nuxt: 'nuxt' } as const; @@ -243,8 +244,8 @@ export function addRuntimeInjectionTraceDep(nuxt: Nuxt, isNitroV3: boolean): voi } /** - * Generates the module that sets the runtime flags `Sentry.init` reads. Import it before the - * Sentry server config: a statement above the config import would not survive import hoisting. + * Generates the module that sets the runtime flags `Sentry.init` and the server plugins read. Import it + * before the Sentry server config: a statement above the config import would not survive import hoisting. */ export function addRuntimeFlagsTemplate(): ResolvedNuxtTemplate { return addTemplate({ @@ -255,6 +256,7 @@ export function addRuntimeFlagsTemplate(): ResolvedNuxtTemplate { '// Generated by @sentry/nuxt. Sets runtime flags before the Sentry server config evaluates.', `globalThis.${NUXT_DEV_MODE_FLAG} = import.meta.dev === true;`, `globalThis.${NUXT_PRERENDER_FLAG} = import.meta.prerender === true;`, + `globalThis.${NUXT_NITRO_PRESET_FLAG} = import.meta.preset;`, '', ].join('\n'), }); diff --git a/packages/nuxt/test/runtime/plugins/client.test.ts b/packages/nuxt/test/runtime/plugins/client.test.ts index 16890e7be620..cbc8b35aa117 100644 --- a/packages/nuxt/test/runtime/plugins/client.test.ts +++ b/packages/nuxt/test/runtime/plugins/client.test.ts @@ -1,8 +1,9 @@ -import type { Client } from '@sentry/core'; +import type { Client, StartSpanOptions } from '@sentry/core'; import type { isNuxtError as nuxtIsNuxtError, NuxtApp } from 'nuxt/app'; import { afterEach, describe, expect, it, vi } from 'vitest'; import clientPlugin from '../../../src/runtime/plugins/sentry.client'; import * as utils from '../../../src/runtime/utils'; +import { withCacheOrigin } from '../../../src/runtime/utils/cacheOriginPageload'; const getClient = vi.hoisted(() => vi.fn<() => Client | undefined>()); const browserTracingIntegration = vi.hoisted(() => vi.fn()); @@ -40,6 +41,10 @@ vi.mock(import('@sentry/vue'), async importOriginal => { }; }); +vi.mock(import('../../../src/runtime/utils/cacheOriginPageload'), () => ({ + withCacheOrigin: vi.fn(), +})); + vi.mock(import('nuxt/app'), () => ({ defineNuxtPlugin: vi.fn(plugin => plugin), isNuxtError: vi.fn(() => false) as unknown as typeof nuxtIsNuxtError, @@ -126,10 +131,34 @@ describe('Sentry Nuxt client plugin', () => { await (clientPlugin as NuxtPluginShape).setup(makeNuxtApp(router)); - expect(browserTracingIntegration).toHaveBeenCalledWith({ router, routeLabel: 'path' }); + expect(browserTracingIntegration).toHaveBeenCalledWith({ + router, + routeLabel: 'path', + beforeStartSpan: expect.any(Function), + }); expect(client.addIntegration).toHaveBeenCalledWith(integration); }); + it.each([true, false])( + 'passes `serverRendered: %s` from the Nuxt payload to the cache origin check of the pageload', + async serverRendered => { + let beforeStartSpan: ((options: StartSpanOptions) => StartSpanOptions) | undefined; + browserTracingIntegration.mockImplementation(options => { + beforeStartSpan = options.beforeStartSpan; + return { name: 'BrowserTracing' }; + }); + getClient.mockReturnValue(makeClient()); + const nuxtApp = makeNuxtApp({ beforeEach: vi.fn(), onError: vi.fn() }); + nuxtApp.payload = { serverRendered } as NuxtApp['payload']; + const pageloadOptions = { name: '/rendering-modes/swr-cached-page', op: 'pageload' }; + + await (clientPlugin as NuxtPluginShape).setup(nuxtApp); + beforeStartSpan?.(pageloadOptions); + + expect(withCacheOrigin).toHaveBeenCalledWith(pageloadOptions, serverRendered); + }, + ); + it('does not add browserTracingIntegration without an initialized client', async () => { getClient.mockReturnValue(undefined); diff --git a/packages/nuxt/test/runtime/utils/cacheOriginPageload.test.ts b/packages/nuxt/test/runtime/utils/cacheOriginPageload.test.ts new file mode 100644 index 000000000000..f90c87afac01 --- /dev/null +++ b/packages/nuxt/test/runtime/utils/cacheOriginPageload.test.ts @@ -0,0 +1,100 @@ +import { PAGE_SERVER_RENDERING_TYPE, SENTRY_LINK_TYPE, SENTRY_OP, SENTRY_ORIGIN } from '@sentry/conventions/attributes'; +import { NAVIGATION, PAGELOAD } from '@sentry/conventions/op'; +import type { StartSpanOptions } from '@sentry/core'; +import { beforeEach, describe, expect, it } from 'vitest'; +import { withCacheOrigin } from '../../../src/runtime/utils/cacheOriginPageload'; + +const TRACE_ID = '4c79f60c11214eb38604f4ae0781bfb2'; +const SPAN_ID = 'fa90fdead5f74052'; + +const pageloadOptions: StartSpanOptions = { + name: '/rendering-modes/swr-cached-page', + op: PAGELOAD, + attributes: { [SENTRY_OP]: PAGELOAD, [SENTRY_ORIGIN]: 'auto.pageload.vue' }, +}; + +function addCacheOriginTag(content: string): void { + const tag = document.createElement('meta'); + tag.name = 'sentry-cache-origin'; + tag.content = content; + document.head.appendChild(tag); +} + +describe('withCacheOrigin', () => { + beforeEach(() => { + document.head.innerHTML = ''; + }); + + it('marks the pageload as static and links the sampled render', () => { + addCacheOriginTag(`${TRACE_ID}-${SPAN_ID}-1`); + + const options = withCacheOrigin(pageloadOptions, true); + + expect(options).toEqual({ + ...pageloadOptions, + attributes: { ...pageloadOptions.attributes, [PAGE_SERVER_RENDERING_TYPE]: 'static' }, + links: [ + { + context: { traceId: TRACE_ID, spanId: SPAN_ID, traceFlags: 1 }, + attributes: { [SENTRY_LINK_TYPE]: 'cache_origin' }, + }, + ], + }); + }); + + it.each([ + { origin: 'an unsampled render', content: `${TRACE_ID}-${SPAN_ID}-0` }, + { origin: 'a render without a sampling decision', content: `${TRACE_ID}-${SPAN_ID}` }, + { origin: 'a build prerender', content: '' }, + ])('marks the pageload as static without a link for $origin', ({ content }) => { + addCacheOriginTag(content); + + const options = withCacheOrigin(pageloadOptions, true); + + expect(options).toEqual({ + ...pageloadOptions, + attributes: { ...pageloadOptions.attributes, [PAGE_SERVER_RENDERING_TYPE]: 'static' }, + }); + }); + + it('keeps the links that the pageload already has', () => { + addCacheOriginTag(`${TRACE_ID}-${SPAN_ID}-1`); + const previousTraceLink = { + context: { traceId: '0d5c7c7a6f0b4a57a3f1e9c2b8d4e6f1', spanId: 'b1a2c3d4e5f60718', traceFlags: 1 }, + attributes: { [SENTRY_LINK_TYPE]: 'previous_trace' }, + }; + + const options = withCacheOrigin({ ...pageloadOptions, links: [previousTraceLink] }, true); + + expect(options.links).toEqual([ + previousTraceLink, + { + context: { traceId: TRACE_ID, spanId: SPAN_ID, traceFlags: 1 }, + attributes: { [SENTRY_LINK_TYPE]: 'cache_origin' }, + }, + ]); + }); + + it('does not change a pageload without a cache origin tag', () => { + const options = withCacheOrigin(pageloadOptions, true); + + expect(options).toBe(pageloadOptions); + }); + + it('does not change the pageload of a client-side shell', () => { + addCacheOriginTag(`${TRACE_ID}-${SPAN_ID}-1`); + + const options = withCacheOrigin(pageloadOptions, false); + + expect(options).toBe(pageloadOptions); + }); + + it('does not change a navigation', () => { + addCacheOriginTag(`${TRACE_ID}-${SPAN_ID}-1`); + const navigationOptions: StartSpanOptions = { name: '/checkout', op: NAVIGATION }; + + const options = withCacheOrigin(navigationOptions, true); + + expect(options).toBe(navigationOptions); + }); +}); diff --git a/packages/nuxt/test/runtime/utils/pageMetaTags.test.ts b/packages/nuxt/test/runtime/utils/pageMetaTags.test.ts new file mode 100644 index 000000000000..21ef3828956f --- /dev/null +++ b/packages/nuxt/test/runtime/utils/pageMetaTags.test.ts @@ -0,0 +1,146 @@ +import { getTraceData, getTraceMetaTags } from '@sentry/core'; +import type { H3Event } from 'h3'; +import { afterEach, describe, expect, it, type Mock, vi } from 'vitest'; +import { addSentryPageMetaTags, NUXT_NITRO_PRESET_FLAG } from '../../../src/runtime/utils/pageMetaTags'; + +vi.mock(import('@sentry/core'), async importOriginal => { + const mod = await importOriginal(); + return { + ...mod, + getTraceData: vi.fn(), + getTraceMetaTags: vi.fn(), + }; +}); + +const SENTRY_TRACE = '4c79f60c11214eb38604f4ae0781bfb2-fa90fdead5f74052-1'; +const LIVE_TRACE_META_TAGS = [ + ``, + '', +].join('\n'); +const CACHE_ORIGIN_META_TAG = ``; +const EMPTY_CACHE_ORIGIN_META_TAG = ''; + +const PATH = '/rendering-modes/swr-cached-page'; + +// h3 v1 (Nitro v2): Node request, with web `Headers` built from it. The Nuxt renderer resolves the +// route rules. +function h3v1Event(routeRules: Record = {}, headers: Record = {}): H3Event { + const nodeReq = { url: PATH, headers }; + return { + path: PATH, + node: { req: nodeReq }, + get headers() { + return new Headers(nodeReq.headers); + }, + context: { _nitro: { routeRules } }, + } as unknown as H3Event; +} + +// h3 v2 (Nitro v3): web `Request`, route rules stored by name. +function h3v2Event(routeRules: Record = {}, headers: Record = {}): H3Event { + const url = `https://shop.example.com${PATH}`; + const req = new Request(url, { headers }); + return { + req, + url: new URL(url), + get headers() { + return req.headers; + }, + context: { routeRules }, + } as unknown as H3Event; +} + +function nitroV3Rule(name: string, options: unknown): Record { + return { [name]: { name, route: PATH, options, params: {} } }; +} + +describe('addSentryPageMetaTags', () => { + afterEach(() => { + vi.resetAllMocks(); + vi.unstubAllGlobals(); + }); + + it('adds the tracing meta tags to a page rendered for one request', () => { + (getTraceMetaTags as Mock).mockReturnValue(LIVE_TRACE_META_TAGS); + const head: string[] = []; + + addSentryPageMetaTags(head, h3v1Event()); + + expect(head).toEqual([LIVE_TRACE_META_TAGS]); + }); + + it.each([ + { nitro: 'Nitro v2', event: h3v1Event({ cache: { swr: true, maxAge: 3600 } }) }, + { nitro: 'Nitro v3', event: h3v2Event(nitroV3Rule('cache', { swr: true, maxAge: 3600 })) }, + ])('replaces the tracing meta tags with the render as cache origin on a cached route ($nitro)', ({ event }) => { + (getTraceData as Mock).mockReturnValue({ 'sentry-trace': SENTRY_TRACE, baggage: 'sentry-environment=production' }); + const head: string[] = []; + + addSentryPageMetaTags(head, event); + + expect(head).toEqual([CACHE_ORIGIN_META_TAG]); + }); + + it('adds the tracing meta tags when the route opts out of caching with `cache: false`', () => { + (getTraceMetaTags as Mock).mockReturnValue(LIVE_TRACE_META_TAGS); + const head: string[] = []; + + addSentryPageMetaTags(head, h3v1Event({ cache: false })); + + expect(head).toEqual([LIVE_TRACE_META_TAGS]); + }); + + it('uses the given trace data of the request for the cache origin', () => { + const storedSentryTrace = '0d5c7c7a6f0b4a57a3f1e9c2b8d4e6f1-b1a2c3d4e5f60718-1'; + const head: string[] = []; + + addSentryPageMetaTags(head, h3v1Event({ cache: {} }), { 'sentry-trace': storedSentryTrace }); + + expect(head).toEqual([``]); + expect(getTraceData).not.toHaveBeenCalled(); + }); + + it('adds an empty cache origin tag to a cached route when the server has no trace data', () => { + (getTraceData as Mock).mockReturnValue({}); + const head: string[] = []; + + addSentryPageMetaTags(head, h3v1Event({ cache: {} })); + + expect(head).toEqual([EMPTY_CACHE_ORIGIN_META_TAG]); + }); + + it.each([ + { nitro: 'Nitro v2', event: h3v1Event({}, { 'x-nitro-prerender': PATH }) }, + { nitro: 'Nitro v3', event: h3v2Event({}, { 'x-nitro-prerender': PATH }) }, + ])('adds an empty cache origin tag to a page prerendered during the build ($nitro)', ({ event }) => { + const head: string[] = []; + + addSentryPageMetaTags(head, event, { 'sentry-trace': SENTRY_TRACE }); + + expect(head).toEqual([EMPTY_CACHE_ORIGIN_META_TAG]); + }); + + it.each(['vercel', 'netlify'])('replaces the tracing meta tags on an `isr` route on the %s preset', preset => { + vi.stubGlobal(NUXT_NITRO_PRESET_FLAG, preset); + (getTraceData as Mock).mockReturnValue({ 'sentry-trace': SENTRY_TRACE }); + const head: string[] = []; + + addSentryPageMetaTags(head, h3v1Event({ isr: 3600 })); + + expect(head).toEqual([CACHE_ORIGIN_META_TAG]); + }); + + it.each([ + { preset: 'node-server', description: 'a preset without a platform cache' }, + { preset: 'vercel-edge', description: 'the Vercel edge preset, which has no ISR functions' }, + { preset: undefined, description: 'an unknown preset' }, + ])('adds the tracing meta tags to an `isr` route on $description', ({ preset }) => { + vi.stubGlobal(NUXT_NITRO_PRESET_FLAG, preset); + (getTraceMetaTags as Mock).mockReturnValue(LIVE_TRACE_META_TAGS); + const head: string[] = []; + + addSentryPageMetaTags(head, h3v2Event(nitroV3Rule('isr', true))); + + expect(head).toEqual([LIVE_TRACE_META_TAGS]); + }); +}); diff --git a/packages/nuxt/test/vite/addServerConfig.test.ts b/packages/nuxt/test/vite/addServerConfig.test.ts index d73e66af2530..5ac940f88463 100644 --- a/packages/nuxt/test/vite/addServerConfig.test.ts +++ b/packages/nuxt/test/vite/addServerConfig.test.ts @@ -178,6 +178,7 @@ describe('addServerConfigPlugin', () => { const contents = templateContents('sentry-runtime-flags.mjs'); expect(contents).toContain('globalThis.__SENTRY_NUXT_DEV_MODE__ = import.meta.dev === true;'); expect(contents).toContain('globalThis.__SENTRY_NUXT_PRERENDER__ = import.meta.prerender === true;'); + expect(contents).toContain('globalThis.__SENTRY_NUXT_NITRO_PRESET__ = import.meta.preset;'); }); it('marks the config and the flags as side-effectful so tree shaking cannot drop them', () => {