diff --git a/CHANGELOG.md b/CHANGELOG.md index c18ccce7fe07..193f6a328043 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,12 @@ Work in this release was contributed by @kunal649 and @bardrop. Thank you for yo ### Important Changes +- **feat(sveltekit): Mark pageloads of prerendered and Vercel ISR pages ([#26301](https://github.com/getsentry/sentry-javascript/pull/26301), [#26307](https://github.com/getsentry/sentry-javascript/pull/26307))** + + When SvelteKit serves a page that it rendered earlier, the pageload now has the `page.server_rendering.type: static` attribute. This covers pages prerendered during `vite build` and pages that Vercel caches with ISR (`config.isr`). For an ISR page, the pageload also links to the request that rendered the page, if that request was sampled. + + Before, every visitor of a cached ISR page continued the trace of that one request. Now each pageload starts its own trace. Pages with `ssr = false` get no attribute, because the server rendered none of their content. + - **fix(core): Set `url.scheme` without colon and `url.port` as a number ([#26284](https://github.com/getsentry/sentry-javascript/pull/26284))** HTTP spans that the SDK builds from a `URL` now send `url.scheme` without the trailing colon (`https` instead of `https:`) and `url.port` as a number instead of a string, as the Sentry conventions define them and as Node `http.server` spans already do. This changes `http.server` spans from `@sentry/cloudflare`, `Bun.serve`, Deno and Nitro, and outgoing `node:http` client spans. Update saved queries, alerts and dashboards that filter on `url.scheme:"https:"` or on `url.port` as a string. diff --git a/dev-packages/e2e-tests/test-applications/sveltekit-2-static/package.json b/dev-packages/e2e-tests/test-applications/sveltekit-2-static/package.json index c4686c849dd6..4f66f0712160 100644 --- a/dev-packages/e2e-tests/test-applications/sveltekit-2-static/package.json +++ b/dev-packages/e2e-tests/test-applications/sveltekit-2-static/package.json @@ -27,6 +27,7 @@ "devDependencies": { "@playwright/test": "1.64.1", "@sentry-internal/test-utils": "file:../../packed/sentry-internal-test-utils-packed.tgz", + "@sentry/conventions": "0.32.0", "@sveltejs/adapter-node": "^2.0.0", "@sveltejs/kit": "2.70.2", "@sveltejs/vite-plugin-svelte": "^3.0.0", diff --git a/dev-packages/e2e-tests/test-applications/sveltekit-2-static/tests/sdk.test.ts b/dev-packages/e2e-tests/test-applications/sveltekit-2-static/tests/sdk.test.ts index a8d4a59d790c..431b64457609 100644 --- a/dev-packages/e2e-tests/test-applications/sveltekit-2-static/tests/sdk.test.ts +++ b/dev-packages/e2e-tests/test-applications/sveltekit-2-static/tests/sdk.test.ts @@ -1,5 +1,7 @@ import { expect, test } from '@playwright/test'; import { waitForTransaction } from '@sentry-internal/test-utils'; +import { PAGE_SERVER_RENDERING_TYPE } from '@sentry/conventions/attributes'; +import { PAGELOAD } from '@sentry/conventions/op'; import { waitForInitialPageload } from './utils'; function sentryMetaTagNames(html: string): string[] { @@ -33,26 +35,26 @@ test.describe('SDK-internal behavior', () => { test('Marks the pageload of a prerendered page as statically rendered, without a link', async ({ page }) => { const pageloadTxnPromise = waitForTransaction('sveltekit-2-static', txnEvent => { - return txnEvent?.transaction === '/prerendered' && txnEvent.contexts?.trace?.op === 'pageload'; + return txnEvent?.transaction === '/prerendered' && txnEvent.contexts?.trace?.op === PAGELOAD; }); await page.goto('/prerendered'); const pageloadTxn = await pageloadTxnPromise; - expect(pageloadTxn.contexts?.trace?.data?.['page.server_rendering.type']).toBe('static'); + expect(pageloadTxn.contexts?.trace?.data?.[PAGE_SERVER_RENDERING_TYPE]).toBe('static'); expect(pageloadTxn.contexts?.trace?.links).toBeUndefined(); expect(pageloadTxn.contexts?.trace?.parent_span_id).toBeUndefined(); }); test("Doesn't mark the pageload of a prerendered client-side shell", async ({ page }) => { const pageloadTxnPromise = waitForTransaction('sveltekit-2-static', txnEvent => { - return txnEvent?.transaction === '/prerendered-shell' && txnEvent.contexts?.trace?.op === 'pageload'; + return txnEvent?.transaction === '/prerendered-shell' && txnEvent.contexts?.trace?.op === PAGELOAD; }); await page.goto('/prerendered-shell'); const pageloadTxn = await pageloadTxnPromise; - expect(pageloadTxn.contexts?.trace?.data?.['page.server_rendering.type']).toBeUndefined(); + expect(pageloadTxn.contexts?.trace?.data?.[PAGE_SERVER_RENDERING_TYPE]).toBeUndefined(); expect(pageloadTxn.contexts?.trace?.parent_span_id).toBeUndefined(); }); }); diff --git a/dev-packages/e2e-tests/test-applications/sveltekit-3/package.json b/dev-packages/e2e-tests/test-applications/sveltekit-3/package.json index 6d6dd79531a7..2bdb4261a836 100644 --- a/dev-packages/e2e-tests/test-applications/sveltekit-3/package.json +++ b/dev-packages/e2e-tests/test-applications/sveltekit-3/package.json @@ -24,6 +24,7 @@ "devDependencies": { "@playwright/test": "1.64.1", "@sentry-internal/test-utils": "file:../../packed/sentry-internal-test-utils-packed.tgz", + "@sentry/conventions": "0.32.0", "@sveltejs/adapter-node": "^6.0.0", "@sveltejs/kit": "^3.0.0", "@sveltejs/vite-plugin-svelte": "^7.3.1", diff --git a/dev-packages/e2e-tests/test-applications/sveltekit-3/tests/prerender.test.ts b/dev-packages/e2e-tests/test-applications/sveltekit-3/tests/prerender.test.ts index d030334c2188..504190ed9465 100644 --- a/dev-packages/e2e-tests/test-applications/sveltekit-3/tests/prerender.test.ts +++ b/dev-packages/e2e-tests/test-applications/sveltekit-3/tests/prerender.test.ts @@ -1,17 +1,22 @@ import { expect, test } from '@playwright/test'; import { getSpanOp, waitForStreamedSpan } from '@sentry-internal/test-utils'; +import { PAGE_SERVER_RENDERING_TYPE, SENTRY_LINK_TYPE } from '@sentry/conventions/attributes'; +import { HTTP_SERVER, PAGELOAD } from '@sentry/conventions/op'; function sentryMetaTagNames(html: string): string[] { return [...html.matchAll(/ match[1] ?? ''); } +const VERCEL_ISR_HEADERS = { 'x-vercel-isr': '1' }; + [ { path: '/prerendered', kind: 'a prerendered page', expected: ['sentry-cache-origin'] }, { path: '/prerendered-shell', kind: 'a prerendered client-side shell', expected: [] }, { path: '/', kind: 'a server-rendered page', expected: ['sentry-trace', 'baggage'] }, -].forEach(({ path, kind, expected }) => { + { path: '/', headers: VERCEL_ISR_HEADERS, kind: 'a Vercel ISR render', expected: ['sentry-cache-origin'] }, +].forEach(({ path, headers, kind, expected }) => { test(`writes the expected Sentry meta tags into ${kind} (${path})`, async ({ request }) => { - const html = await (await request.get(path)).text(); + const html = await (await request.get(path, { headers })).text(); expect(sentryMetaTagNames(html)).toEqual(expected); }); @@ -19,37 +24,75 @@ function sentryMetaTagNames(html: string): string[] { test('marks the pageload of a prerendered page as statically rendered, without a link', async ({ page }) => { const pageloadSpanPromise = waitForStreamedSpan('sveltekit-3', span => { - return span.name === '/prerendered' && getSpanOp(span) === 'pageload' && span.is_segment; + return span.name === '/prerendered' && getSpanOp(span) === PAGELOAD && span.is_segment; }); await page.goto('/prerendered'); const pageloadSpan = await pageloadSpanPromise; - expect(pageloadSpan.attributes?.['page.server_rendering.type']).toEqual({ type: 'string', value: 'static' }); + expect(pageloadSpan.attributes?.[PAGE_SERVER_RENDERING_TYPE]).toEqual({ type: 'string', value: 'static' }); expect(pageloadSpan.links).toBeUndefined(); expect(pageloadSpan.parent_span_id).toBeUndefined(); }); test("doesn't mark the pageload of a prerendered client-side shell", async ({ page }) => { const pageloadSpanPromise = waitForStreamedSpan('sveltekit-3', span => { - return span.name === '/prerendered-shell' && getSpanOp(span) === 'pageload' && span.is_segment; + return span.name === '/prerendered-shell' && getSpanOp(span) === PAGELOAD && span.is_segment; }); await page.goto('/prerendered-shell'); const pageloadSpan = await pageloadSpanPromise; - expect(pageloadSpan.attributes?.['page.server_rendering.type']).toBeUndefined(); + expect(pageloadSpan.attributes?.[PAGE_SERVER_RENDERING_TYPE]).toBeUndefined(); expect(pageloadSpan.parent_span_id).toBeUndefined(); }); test("doesn't mark the pageload of a server-rendered page, which continues the server trace", async ({ page }) => { const pageloadSpanPromise = waitForStreamedSpan('sveltekit-3', span => { - return span.name === '/' && getSpanOp(span) === 'pageload' && span.is_segment; + return span.name === '/' && getSpanOp(span) === PAGELOAD && span.is_segment; }); await page.goto('/'); const pageloadSpan = await pageloadSpanPromise; - expect(pageloadSpan.attributes?.['page.server_rendering.type']).toBeUndefined(); + expect(pageloadSpan.attributes?.[PAGE_SERVER_RENDERING_TYPE]).toBeUndefined(); expect(pageloadSpan.parent_span_id).toMatch(/^[a-f0-9]{16}$/); }); + +test('marks the pageload of a Vercel ISR page as statically rendered and links the render', async ({ page }) => { + // The event proxy replays spans from this time on, so the server span of the render can be awaited + // after its trace id is known from the HTML. Same clock as the proxy (`process.hrtime`). + const [seconds, nanoseconds] = process.hrtime(); + const requestStart = seconds * 1e9 + nanoseconds; + const pageloadSpanPromise = waitForStreamedSpan('sveltekit-3', span => { + return span.name === '/' && getSpanOp(span) === PAGELOAD && span.is_segment; + }); + + // Only on the document: on the SDK's cross-origin envelope requests, the header would need a preflight. + await page.route( + url => url.pathname === '/', + route => route.continue({ headers: { ...route.request().headers(), ...VERCEL_ISR_HEADERS } }), + ); + await page.goto('/'); + const cacheOrigin = await page.locator('meta[name="sentry-cache-origin"]').getAttribute('content'); + const [originTraceId, originSpanId] = cacheOrigin?.split('-') ?? []; + const [originSpan, pageloadSpan] = await Promise.all([ + waitForStreamedSpan('sveltekit-3', span => span.span_id === originSpanId, requestStart), + pageloadSpanPromise, + ]); + + expect(originSpan.trace_id).toBe(originTraceId); + expect(getSpanOp(originSpan)).toBe(HTTP_SERVER); + expect(originSpan.is_segment).toBe(true); + expect(pageloadSpan.attributes?.[PAGE_SERVER_RENDERING_TYPE]).toEqual({ type: 'string', value: 'static' }); + expect(pageloadSpan.parent_span_id).toBeUndefined(); + expect(pageloadSpan.trace_id).not.toBe(originTraceId); + expect(pageloadSpan.links).toEqual([ + { + trace_id: originTraceId, + span_id: originSpanId, + sampled: true, + attributes: { [SENTRY_LINK_TYPE]: { type: 'string', value: 'cache_origin' } }, + }, + ]); +}); diff --git a/packages/sveltekit/src/client/svelte4BrowserTracing.ts b/packages/sveltekit/src/client/svelte4BrowserTracing.ts index 6ecafdc5cb1f..06a1c24b03b2 100644 --- a/packages/sveltekit/src/client/svelte4BrowserTracing.ts +++ b/packages/sveltekit/src/client/svelte4BrowserTracing.ts @@ -26,7 +26,7 @@ import type { Navigation, Page } from '@sveltejs/kit'; // eslint-disable-next-line typescript/no-deprecated import { navigating, page } from '$app/stores'; import type { Readable } from 'svelte/store'; -import { getServerRenderingAttributes } from './traceMetaTags'; +import { withCacheOrigin } from './traceMetaTags'; /** * SvelteKit 2 / Svelte 4 browser tracing (`$app/stores`). Selected at build time, so it's only @@ -51,17 +51,19 @@ export function instrumentSvelteKitTracing( function _instrumentPageload(client: Client, pageStore: Readable): void { const initialPath = WINDOW.location?.pathname; - const pageloadSpan = startBrowserTracingPageLoadSpan(client, { - // With span streaming, span names have to be low cardinality. The route id is only available - // asynchronously, which updates the span name then. - name: hasSpanStreamingEnabled(client) ? PAGELOAD_SPAN_NAME_FALLBACK : initialPath, - attributes: { - [SENTRY_OP]: PAGELOAD, - [SENTRY_ORIGIN]: 'auto.pageload.sveltekit', - [SENTRY_SEGMENT_NAME_SOURCE]: 'url', - ...getServerRenderingAttributes(), - }, - }); + const pageloadSpan = startBrowserTracingPageLoadSpan( + client, + withCacheOrigin({ + // With span streaming, span names have to be low cardinality. The route id is only available + // asynchronously, which updates the span name then. + name: hasSpanStreamingEnabled(client) ? PAGELOAD_SPAN_NAME_FALLBACK : initialPath, + attributes: { + [SENTRY_OP]: PAGELOAD, + [SENTRY_ORIGIN]: 'auto.pageload.sveltekit', + [SENTRY_SEGMENT_NAME_SOURCE]: 'url', + }, + }), + ); if (!pageloadSpan) { return; } diff --git a/packages/sveltekit/src/client/svelte5BrowserTracing.ts b/packages/sveltekit/src/client/svelte5BrowserTracing.ts index 8930775c56b4..135928af8c7e 100644 --- a/packages/sveltekit/src/client/svelte5BrowserTracing.ts +++ b/packages/sveltekit/src/client/svelte5BrowserTracing.ts @@ -24,7 +24,7 @@ import { import { NAVIGATION, PAGELOAD, ROUTER } from '@sentry/conventions/op'; import type { Navigation } from '@sveltejs/kit'; import { getCurrentNavigation, onNavigationChange, onPageRouteChange } from './navigationState.svelte'; -import { getServerRenderingAttributes } from './traceMetaTags'; +import { withCacheOrigin } from './traceMetaTags'; /** * SvelteKit 3 / Svelte 5 browser tracing (`$app/state` runes). Selected at build time, so it's only @@ -47,17 +47,19 @@ export function instrumentSvelteKitTracing( function _instrumentPageLoad(client: Client): void { const initialPath = WINDOW.location?.pathname; - const pageLoadSpan = startBrowserTracingPageLoadSpan(client, { - // With span streaming, span names have to be low cardinality. The route id is only available - // asynchronously, which updates the span name then. - name: hasSpanStreamingEnabled(client) ? PAGELOAD_SPAN_NAME_FALLBACK : initialPath, - attributes: { - [SENTRY_OP]: PAGELOAD, - [SENTRY_ORIGIN]: 'auto.pageload.sveltekit', - [SENTRY_SEGMENT_NAME_SOURCE]: 'url', - ...getServerRenderingAttributes(), - }, - }); + const pageLoadSpan = startBrowserTracingPageLoadSpan( + client, + withCacheOrigin({ + // With span streaming, span names have to be low cardinality. The route id is only available + // asynchronously, which updates the span name then. + name: hasSpanStreamingEnabled(client) ? PAGELOAD_SPAN_NAME_FALLBACK : initialPath, + attributes: { + [SENTRY_OP]: PAGELOAD, + [SENTRY_ORIGIN]: 'auto.pageload.sveltekit', + [SENTRY_SEGMENT_NAME_SOURCE]: 'url', + }, + }), + ); if (!pageLoadSpan) { return; diff --git a/packages/sveltekit/src/client/traceMetaTags.ts b/packages/sveltekit/src/client/traceMetaTags.ts index b8e09933acce..c21dbdc73077 100644 --- a/packages/sveltekit/src/client/traceMetaTags.ts +++ b/packages/sveltekit/src/client/traceMetaTags.ts @@ -1,10 +1,39 @@ -import { PAGE_SERVER_RENDERING_TYPE } from '@sentry/conventions/attributes'; -import type { SpanAttributes } from '@sentry/core'; +import { PAGE_SERVER_RENDERING_TYPE, SENTRY_LINK_TYPE } from '@sentry/conventions/attributes'; +import type { SpanLink, StartSpanOptions } from '@sentry/core'; +import { extractTraceparentData } from '@sentry/core'; import { WINDOW } from '@sentry/svelte'; import { CACHE_ORIGIN_META_TAG_NAME } from '../common/traceMetaTags'; -/** Set at span start, so `tracesSampler` sees it. */ -export function getServerRenderingAttributes(): SpanAttributes { - const isPrerendered = !!WINDOW.document?.querySelector(`meta[name="${CACHE_ORIGIN_META_TAG_NAME}"]`); - return isPrerendered ? { [PAGE_SERVER_RENDERING_TYPE]: 'static' } : {}; +// `@opentelemetry/api` `TraceFlags.SAMPLED`, inlined because core does not export its copy. +const TRACE_FLAG_SAMPLED = 0x1; + +/** + * Marks the pageload of a reused page as `static` at span start, so `tracesSampler` sees it. Links the + * render only if it was sampled, like every `cache_origin` link. + */ +export function withCacheOrigin(spanOptions: StartSpanOptions): StartSpanOptions { + const cacheOrigin = WINDOW.document + ?.querySelector(`meta[name="${CACHE_ORIGIN_META_TAG_NAME}"]`) + ?.getAttribute('content'); + if (cacheOrigin == null) { + return spanOptions; + } + + const link = getSampledCacheOriginLink(cacheOrigin); + return { + ...spanOptions, + attributes: { ...spanOptions.attributes, [PAGE_SERVER_RENDERING_TYPE]: 'static' }, + ...(link && { links: [link] }), + }; +} + +function getSampledCacheOriginLink(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' }, + }; } diff --git a/packages/sveltekit/src/common/traceMetaTags.ts b/packages/sveltekit/src/common/traceMetaTags.ts index d6618a156999..16292b31274c 100644 --- a/packages/sveltekit/src/common/traceMetaTags.ts +++ b/packages/sveltekit/src/common/traceMetaTags.ts @@ -1,5 +1,5 @@ /** - * Replaces the trace meta tags of pages prerendered during the build. Empty, unlike in Next.js: spans - * from the build are not reliably sent, so there is no origin to link. + * Replaces `sentry-trace` and `baggage` in a page that many visitors get. Holds the `sentry-trace` of the + * render to link, or is empty. The client links it but never continues it. */ export const CACHE_ORIGIN_META_TAG_NAME = 'sentry-cache-origin'; diff --git a/packages/sveltekit/src/server-common/handle.ts b/packages/sveltekit/src/server-common/handle.ts index bb996f16f7ca..e70cf289c09f 100644 --- a/packages/sveltekit/src/server-common/handle.ts +++ b/packages/sveltekit/src/server-common/handle.ts @@ -2,10 +2,13 @@ import type { Span } from '@sentry/core'; import { continueTrace, debug, + getActiveSpan, getClient, getCurrentScope, getDefaultIsolationScope, getIsolationScope, + getRootSpan, + getTraceData, getTraceMetaTags, hasSpanStreamingEnabled, httpHeadersToSpanAttributes, @@ -82,9 +85,10 @@ export const FETCH_PROXY_SCRIPT = ` */ export function addSentryCodeToPage(options: { injectFetchProxyScript: boolean; + isVercelIsrRender?: boolean; }): NonNullable { return ({ html }) => { - const metaTags = getPageMetaTags(html); + const metaTags = getPageMetaTags(html, !!options.isVercelIsrRender); const headWithMetaTags = metaTags ? `\n${metaTags}` : ''; const headWithFetchScript = options.injectFetchProxyScript ? `\n` : ''; @@ -95,15 +99,35 @@ export function addSentryCodeToPage(options: { }; } -/** Trace meta tags written during the build would put one trace into the HTML of every visitor. */ -function getPageMetaTags(html: string): string { - if (!isSvelteKitBuilding()) { +/** + * Returns the meta tags for the pageload. A page rendered for this request gets the trace meta tags. A page + * that many visitors get, such as a prerendered or ISR page, gets `sentry-cache-origin` instead, so each + * pageload starts its own trace. + */ +function getPageMetaTags(html: string, isVercelIsrRender: boolean): string { + if (!isVercelIsrRender && !isSvelteKitBuilding()) { return getTraceMetaTags(); } - return isServerRenderedPage(html) ? `` : ''; + + // An `ssr = false` page has no server-rendered content, so `page.server_rendering.type` does not apply. + // Many visitors get the same HTML, so it gets no trace tags either. + if (!isServerRenderedPage(html)) { + return ''; + } + + // The build does not reliably send spans, so a prerendered page has no trace to link. + // The empty tag still marks the pageload as static. + const originTrace = isVercelIsrRender ? getRootSpanTrace() : undefined; + return ``; +} + +/** With Kit tracing, the active span while the page renders is Kit's `sveltekit.resolve`, not the root span. */ +function getRootSpanTrace(): string | undefined { + const activeSpan = getActiveSpan(); + return getTraceData({ span: activeSpan && getRootSpan(activeSpan) })['sentry-trace']; } -/** Kit writes `node_ids` only for server-rendered pages, not for shells (`ssr = false`, SPA fallback). */ +/** Kit writes `node_ids` only if the server rendered content, not for `ssr = false` or the SPA fallback. */ function isServerRenderedPage(html: string): boolean { return html.includes('node_ids:'); } @@ -240,6 +264,9 @@ async function instrumentHandle( const res = await resolve(event, { transformPageChunk: addSentryCodeToPage({ injectFetchProxyScript: options.injectFetchProxyScript ?? true, + // Vercel sets this on every ISR invocation, also on revalidation. + // A client that sends it changes only its own response. + isVercelIsrRender: event.request.headers.has('x-vercel-isr'), }), }); diff --git a/packages/sveltekit/test/client/browserTracingIntegration.test.ts b/packages/sveltekit/test/client/browserTracingIntegration.test.ts index 89210d628788..a2b17a736c70 100644 --- a/packages/sveltekit/test/client/browserTracingIntegration.test.ts +++ b/packages/sveltekit/test/client/browserTracingIntegration.test.ts @@ -17,6 +17,7 @@ import { URL_TEMPLATE, SENTRY_ORIGIN, } from '@sentry/conventions/attributes'; +import { PAGELOAD } from '@sentry/conventions/op'; // we have to overwrite the global mock from `vitest.setup.ts` here to reset the // `navigating` store for each test. @@ -193,7 +194,7 @@ describe('browserTracingIntegration', () => { expect(startBrowserTracingPageLoadSpanSpy).toHaveBeenCalledWith(fakeClient, { name: '/', attributes: { - [SENTRY_OP]: 'pageload', + [SENTRY_OP]: PAGELOAD, [SENTRY_ORIGIN]: 'auto.pageload.sveltekit', [SENTRY_SEGMENT_NAME_SOURCE]: 'url', [PAGE_SERVER_RENDERING_TYPE]: 'static', diff --git a/packages/sveltekit/test/client/svelte5BrowserTracing.test.ts b/packages/sveltekit/test/client/svelte5BrowserTracing.test.ts index a2975a0bafa5..176f7bf5c50a 100644 --- a/packages/sveltekit/test/client/svelte5BrowserTracing.test.ts +++ b/packages/sveltekit/test/client/svelte5BrowserTracing.test.ts @@ -12,6 +12,7 @@ import { URL_TEMPLATE, SENTRY_ORIGIN, } from '@sentry/conventions/attributes'; +import { PAGELOAD } from '@sentry/conventions/op'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { instrumentSvelteKitTracing } from '../../src/client/svelte5BrowserTracing'; import { CACHE_ORIGIN_META_TAG_NAME } from '../../src/common/traceMetaTags'; @@ -116,7 +117,7 @@ describe('svelte5 browser tracing', () => { expect(startPageLoadSpanSpy).toHaveBeenCalledWith(client, { name: '/', attributes: { - [SENTRY_OP]: 'pageload', + [SENTRY_OP]: PAGELOAD, [SENTRY_ORIGIN]: 'auto.pageload.sveltekit', [SENTRY_SEGMENT_NAME_SOURCE]: 'url', [PAGE_SERVER_RENDERING_TYPE]: 'static', diff --git a/packages/sveltekit/test/client/traceMetaTags.test.ts b/packages/sveltekit/test/client/traceMetaTags.test.ts new file mode 100644 index 000000000000..b491fff60f62 --- /dev/null +++ b/packages/sveltekit/test/client/traceMetaTags.test.ts @@ -0,0 +1,56 @@ +/** + * @vitest-environment jsdom + */ + +import { PAGE_SERVER_RENDERING_TYPE, SENTRY_LINK_TYPE, SENTRY_OP } from '@sentry/conventions/attributes'; +import { PAGELOAD } from '@sentry/conventions/op'; +import { beforeEach, describe, expect, it } from 'vitest'; +import { withCacheOrigin } from '../../src/client/traceMetaTags'; + +const spanOptions = { name: '/pricing', attributes: { [SENTRY_OP]: PAGELOAD } }; + +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('returns the span options unchanged for a page without a cache origin tag', () => { + expect(withCacheOrigin(spanOptions)).toBe(spanOptions); + }); + + it('marks the pageload as static and links a sampled render', () => { + addCacheOriginTag('0b37aaf4f0d74c0e80875099e7d7ca68-bc3a52360214dcf3-1'); + + expect(withCacheOrigin(spanOptions)).toEqual({ + name: '/pricing', + attributes: { [SENTRY_OP]: PAGELOAD, [PAGE_SERVER_RENDERING_TYPE]: 'static' }, + links: [ + { + context: { traceId: '0b37aaf4f0d74c0e80875099e7d7ca68', spanId: 'bc3a52360214dcf3', traceFlags: 1 }, + attributes: { [SENTRY_LINK_TYPE]: 'cache_origin' }, + }, + ], + }); + }); + + it.each([ + { kind: 'an empty tag (prerendered during the build)', content: '' }, + { kind: 'an unsampled render', content: '0b37aaf4f0d74c0e80875099e7d7ca68-bc3a52360214dcf3-0' }, + { kind: 'a render without a sampling decision', content: '0b37aaf4f0d74c0e80875099e7d7ca68-bc3a52360214dcf3' }, + { kind: 'a malformed value', content: 'not-a-trace' }, + ])('marks the pageload as static without a link for $kind', ({ content }) => { + addCacheOriginTag(content); + + expect(withCacheOrigin(spanOptions)).toEqual({ + name: '/pricing', + attributes: { [SENTRY_OP]: PAGELOAD, [PAGE_SERVER_RENDERING_TYPE]: 'static' }, + }); + }); +}); diff --git a/packages/sveltekit/test/server-common/handle.test.ts b/packages/sveltekit/test/server-common/handle.test.ts index e7b703f08c44..c40ef465919e 100644 --- a/packages/sveltekit/test/server-common/handle.test.ts +++ b/packages/sveltekit/test/server-common/handle.test.ts @@ -540,6 +540,36 @@ describe('sentryHandle', () => { }); }); +describe('sentryHandle on Vercel ISR', () => { + it('writes the trace of the request span as the cache origin of a request that Vercel marks as an ISR render', async () => { + const event = mockEvent({ + request: new Request('https://shop.example.com/pricing', { headers: { 'x-vercel-isr': '1' } }), + url: new URL('https://shop.example.com/pricing'), + route: { id: '/pricing' }, + }); + let html: string | undefined; + let requestSpan: Span | undefined; + + await sentryHandle({ injectFetchProxyScript: false })({ + event, + resolve: async (_event, opts) => { + requestSpan = SentryCore.getActiveSpan(); + html = + (await opts?.transformPageChunk?.({ + html: '', + done: true, + })) ?? undefined; + return mockResponse; + }, + }); + + const { traceId, spanId } = requestSpan!.spanContext(); + expect(html).toBe( + `\n`, + ); + }); +}); + describe('addSentryCodeToPage', () => { const html = ` @@ -650,6 +680,50 @@ describe('addSentryCodeToPage', () => { expect(transformed).toContain(``); }); }); + + describe('while Vercel renders a page for its ISR cache', () => { + it('replaces the trace meta tags of a server-rendered page with the cache origin of this render', () => { + const transformPageChunk = addSentryCodeToPage({ injectFetchProxyScript: false, isVercelIsrRender: true }); + + const transformed = SentryCore.startSpan({ name: 'GET /pricing' }, span => { + const { traceId, spanId } = span.spanContext(); + return { html: transformPageChunk({ html: serverRenderedHtml, done: true }), traceId, spanId }; + }); + + expect(transformed.html).toBe( + serverRenderedHtml.replace( + '', + `\n`, + ), + ); + }); + + it('uses the root span of the request as the cache origin while a child span is active', () => { + const transformPageChunk = addSentryCodeToPage({ injectFetchProxyScript: false, isVercelIsrRender: true }); + + const transformed = SentryCore.startSpan({ name: 'GET /pricing' }, rootSpan => + SentryCore.startSpan({ name: 'sveltekit.resolve' }, () => { + const { traceId, spanId } = rootSpan.spanContext(); + return { html: transformPageChunk({ html: serverRenderedHtml, done: true }), traceId, spanId }; + }), + ); + + expect(transformed.html).toBe( + serverRenderedHtml.replace( + '', + `\n`, + ), + ); + }); + + it('adds no meta tags to a client-side shell', () => { + const transformPageChunk = addSentryCodeToPage({ injectFetchProxyScript: false, isVercelIsrRender: true }); + + const transformed = transformPageChunk({ html: clientShellHtml, done: true }); + + expect(transformed).toBe(clientShellHtml); + }); + }); }); describe('isFetchProxyRequired', () => {