diff --git a/dev-packages/e2e-tests/test-applications/sveltekit-2-static/src/routes/prerendered-shell/+page.svelte b/dev-packages/e2e-tests/test-applications/sveltekit-2-static/src/routes/prerendered-shell/+page.svelte new file mode 100644 index 000000000000..d3c9d3fe79fc --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/sveltekit-2-static/src/routes/prerendered-shell/+page.svelte @@ -0,0 +1 @@ +

Prerendered shell

diff --git a/dev-packages/e2e-tests/test-applications/sveltekit-2-static/src/routes/prerendered-shell/+page.ts b/dev-packages/e2e-tests/test-applications/sveltekit-2-static/src/routes/prerendered-shell/+page.ts new file mode 100644 index 000000000000..ceccaaf67a21 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/sveltekit-2-static/src/routes/prerendered-shell/+page.ts @@ -0,0 +1,2 @@ +export const prerender = true; +export const ssr = false; 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 23bce25b7ec4..a8d4a59d790c 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,6 +1,11 @@ import { expect, test } from '@playwright/test'; +import { waitForTransaction } from '@sentry-internal/test-utils'; import { waitForInitialPageload } from './utils'; +function sentryMetaTagNames(html: string): string[] { + return [...html.matchAll(/ match[1] ?? ''); +} + test.describe('SDK-internal behavior', () => { test("Doesn't inject fetch proxy script for SvelteKit>=2.16.0", async ({ page }) => { await waitForInitialPageload(page, { route: '/' }); @@ -14,16 +19,40 @@ test.describe('SDK-internal behavior', () => { expect(proxyHandle).toBe('undefined'); }); - test("Doesn't add trace meta tags to prerendered pages", async ({ request }) => { - const prerenderedHtml = await (await request.get('/prerendered')).text(); - const ssrHtml = await (await request.get('/')).text(); + [ + { 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 }) => { + test(`Writes the expected Sentry meta tags into ${kind} (${path})`, async ({ request }) => { + const html = await (await request.get(path)).text(); + + expect(sentryMetaTagNames(html)).toEqual(expected); + }); + }); + + 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'; + }); + + await page.goto('/prerendered'); + const pageloadTxn = await pageloadTxnPromise; + + 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'; + }); - expect(prerenderedHtml).toContain('Prerendered page'); - expect(prerenderedHtml).not.toContain('Prerendered shell diff --git a/dev-packages/e2e-tests/test-applications/sveltekit-3/src/routes/prerendered-shell/+page.ts b/dev-packages/e2e-tests/test-applications/sveltekit-3/src/routes/prerendered-shell/+page.ts new file mode 100644 index 000000000000..ceccaaf67a21 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/sveltekit-3/src/routes/prerendered-shell/+page.ts @@ -0,0 +1,2 @@ +export const prerender = true; +export const ssr = false; 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 9e9a5bbf4be8..d030334c2188 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,14 +1,55 @@ import { expect, test } from '@playwright/test'; +import { getSpanOp, waitForStreamedSpan } from '@sentry-internal/test-utils'; -test("Doesn't add trace meta tags to prerendered pages", async ({ request }) => { - const prerenderedHtml = await (await request.get('/prerendered')).text(); - const ssrHtml = await (await request.get('/')).text(); +function sentryMetaTagNames(html: string): string[] { + return [...html.matchAll(/ match[1] ?? ''); +} - expect(prerenderedHtml).toContain('Prerendered page'); - expect(prerenderedHtml).not.toContain(' { + test(`writes the expected Sentry meta tags into ${kind} (${path})`, async ({ request }) => { + const html = await (await request.get(path)).text(); - // SSR pages still get the meta tags - expect(ssrHtml).toContain(' { + const pageloadSpanPromise = waitForStreamedSpan('sveltekit-3', span => { + 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.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; + }); + + await page.goto('/prerendered-shell'); + const pageloadSpan = await pageloadSpanPromise; + + 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; + }); + + await page.goto('/'); + const pageloadSpan = await pageloadSpanPromise; + + expect(pageloadSpan.attributes?.['page.server_rendering.type']).toBeUndefined(); + expect(pageloadSpan.parent_span_id).toMatch(/^[a-f0-9]{16}$/); }); diff --git a/packages/sveltekit/src/client/svelte4BrowserTracing.ts b/packages/sveltekit/src/client/svelte4BrowserTracing.ts index 9dbb21336021..6ecafdc5cb1f 100644 --- a/packages/sveltekit/src/client/svelte4BrowserTracing.ts +++ b/packages/sveltekit/src/client/svelte4BrowserTracing.ts @@ -26,6 +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'; /** * SvelteKit 2 / Svelte 4 browser tracing (`$app/stores`). Selected at build time, so it's only @@ -58,6 +59,7 @@ function _instrumentPageload(client: Client, pageStore: Readable): void { [SENTRY_OP]: PAGELOAD, [SENTRY_ORIGIN]: 'auto.pageload.sveltekit', [SENTRY_SEGMENT_NAME_SOURCE]: 'url', + ...getServerRenderingAttributes(), }, }); if (!pageloadSpan) { diff --git a/packages/sveltekit/src/client/svelte5BrowserTracing.ts b/packages/sveltekit/src/client/svelte5BrowserTracing.ts index b8388adc91b6..8930775c56b4 100644 --- a/packages/sveltekit/src/client/svelte5BrowserTracing.ts +++ b/packages/sveltekit/src/client/svelte5BrowserTracing.ts @@ -24,6 +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'; /** * SvelteKit 3 / Svelte 5 browser tracing (`$app/state` runes). Selected at build time, so it's only @@ -54,6 +55,7 @@ function _instrumentPageLoad(client: Client): void { [SENTRY_OP]: PAGELOAD, [SENTRY_ORIGIN]: 'auto.pageload.sveltekit', [SENTRY_SEGMENT_NAME_SOURCE]: 'url', + ...getServerRenderingAttributes(), }, }); diff --git a/packages/sveltekit/src/client/traceMetaTags.ts b/packages/sveltekit/src/client/traceMetaTags.ts new file mode 100644 index 000000000000..b8e09933acce --- /dev/null +++ b/packages/sveltekit/src/client/traceMetaTags.ts @@ -0,0 +1,10 @@ +import { PAGE_SERVER_RENDERING_TYPE } from '@sentry/conventions/attributes'; +import type { SpanAttributes } 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' } : {}; +} diff --git a/packages/sveltekit/src/common/traceMetaTags.ts b/packages/sveltekit/src/common/traceMetaTags.ts new file mode 100644 index 000000000000..d6618a156999 --- /dev/null +++ b/packages/sveltekit/src/common/traceMetaTags.ts @@ -0,0 +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. + */ +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 38291b226679..bb996f16f7ca 100644 --- a/packages/sveltekit/src/server-common/handle.ts +++ b/packages/sveltekit/src/server-common/handle.ts @@ -25,6 +25,7 @@ import { _INTERNAL_setTunnelDefaults as setTunnelDefaults, } from '@sentry/core/server'; import { getTunnelBasePath } from '../common/tunnel'; +import { CACHE_ORIGIN_META_TAG_NAME } from '../common/traceMetaTags'; import type { Handle, ResolveOptions } from './handleTypes'; import { DEBUG_BUILD } from '../common/debug-build'; import { getTracePropagationData, sendErrorToSentry } from './utils'; @@ -83,7 +84,7 @@ export function addSentryCodeToPage(options: { injectFetchProxyScript: boolean; }): NonNullable { return ({ html }) => { - const metaTags = isSvelteKitBuilding() ? '' : getTraceMetaTags(); + const metaTags = getPageMetaTags(html); const headWithMetaTags = metaTags ? `\n${metaTags}` : ''; const headWithFetchScript = options.injectFetchProxyScript ? `\n` : ''; @@ -94,9 +95,20 @@ 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()) { + return getTraceMetaTags(); + } + return isServerRenderedPage(html) ? `` : ''; +} + +/** Kit writes `node_ids` only for server-rendered pages, not for shells (`ssr = false`, SPA fallback). */ +function isServerRenderedPage(html: string): boolean { + return html.includes('node_ids:'); +} + /** - * Prerendered pages would bake one trace id and sampling decision into the HTML for every visitor. - * * `building` from `$app/environment` isn't available here (the SDK is loaded from node_modules at * runtime), so we rely on `_SENTRY_SVELTEKIT_BUILDING`, which our Vite plugin sets during `vite build` * and SvelteKit copies into the worker it prerenders in. diff --git a/packages/sveltekit/test/client/browserTracingIntegration.test.ts b/packages/sveltekit/test/client/browserTracingIntegration.test.ts index c27d9db8ddb6..89210d628788 100644 --- a/packages/sveltekit/test/client/browserTracingIntegration.test.ts +++ b/packages/sveltekit/test/client/browserTracingIntegration.test.ts @@ -9,7 +9,14 @@ import { writable } from 'svelte/store'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { navigating, page } from '$app/stores'; import { browserTracingIntegration } from '../../src/client'; -import { SENTRY_SEGMENT_NAME_SOURCE, SENTRY_OP, URL_TEMPLATE, SENTRY_ORIGIN } from '@sentry/conventions/attributes'; +import { CACHE_ORIGIN_META_TAG_NAME } from '../../src/common/traceMetaTags'; +import { + PAGE_SERVER_RENDERING_TYPE, + SENTRY_SEGMENT_NAME_SOURCE, + SENTRY_OP, + URL_TEMPLATE, + SENTRY_ORIGIN, +} from '@sentry/conventions/attributes'; // we have to overwrite the global mock from `vitest.setup.ts` here to reset the // `navigating` store for each test. @@ -72,6 +79,7 @@ describe('browserTracingIntegration', () => { beforeEach(() => { createdRootSpan = undefined; navigatingStore = writable(); + document.head.innerHTML = ''; vi.clearAllMocks(); }); @@ -171,6 +179,28 @@ describe('browserTracingIntegration', () => { expect(scopeSetTransactionNameSpy).toHaveBeenLastCalledWith('testRoute/:id'); }); + it('marks the pageload of a prerendered page as statically rendered, without a link', async () => { + const marker = document.createElement('meta'); + marker.name = CACHE_ORIGIN_META_TAG_NAME; + marker.content = ''; + document.head.appendChild(marker); + + const integration = browserTracingIntegration(); + // @ts-expect-error - the fakeClient doesn't satisfy Client but that's fine + integration.afterAllSetup(fakeClient); + await vi.dynamicImportSettled(); + + expect(startBrowserTracingPageLoadSpanSpy).toHaveBeenCalledWith(fakeClient, { + name: '/', + attributes: { + [SENTRY_OP]: 'pageload', + [SENTRY_ORIGIN]: 'auto.pageload.sveltekit', + [SENTRY_SEGMENT_NAME_SOURCE]: 'url', + [PAGE_SERVER_RENDERING_TYPE]: 'static', + }, + }); + }); + it("doesn't start a navigation span when `instrumentNavigation` is false", async () => { const integration = browserTracingIntegration({ instrumentNavigation: false, diff --git a/packages/sveltekit/test/client/svelte5BrowserTracing.test.ts b/packages/sveltekit/test/client/svelte5BrowserTracing.test.ts index eca41882dd76..a2975a0bafa5 100644 --- a/packages/sveltekit/test/client/svelte5BrowserTracing.test.ts +++ b/packages/sveltekit/test/client/svelte5BrowserTracing.test.ts @@ -5,9 +5,16 @@ /* eslint-disable @typescript-eslint/unbound-method */ import type { Span } from '@sentry/core'; import * as SentrySvelte from '@sentry/svelte'; -import { SENTRY_SEGMENT_NAME_SOURCE, URL_TEMPLATE, SENTRY_ORIGIN } from '@sentry/conventions/attributes'; +import { + PAGE_SERVER_RENDERING_TYPE, + SENTRY_OP, + SENTRY_SEGMENT_NAME_SOURCE, + URL_TEMPLATE, + SENTRY_ORIGIN, +} from '@sentry/conventions/attributes'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { instrumentSvelteKitTracing } from '../../src/client/svelte5BrowserTracing'; +import { CACHE_ORIGIN_META_TAG_NAME } from '../../src/common/traceMetaTags'; // The Svelte 5 variant reads navigation state from rune-backed helpers (`navigationState.svelte.ts`), // which need the Svelte compiler. We mock them here so the variant's orchestration logic (fetch @@ -70,6 +77,7 @@ describe('svelte5 browser tracing', () => { navigationChangeCb = undefined; pageRouteChangeCb = undefined; createdRootSpan = undefined; + document.head.innerHTML = ''; // The variant wraps `window.fetch`; give it a resolvable fetch to wrap and reset each test. window.fetch = vi.fn().mockResolvedValue(undefined); }); @@ -97,6 +105,25 @@ describe('svelte5 browser tracing', () => { expect(setTransactionNameSpy).toHaveBeenCalledWith('/users/[id]'); }); + it('marks the pageload of a prerendered page as statically rendered, without a link', () => { + const marker = document.createElement('meta'); + marker.name = CACHE_ORIGIN_META_TAG_NAME; + marker.content = ''; + document.head.appendChild(marker); + + instrumentSvelteKitTracing(client, {}); + + expect(startPageLoadSpanSpy).toHaveBeenCalledWith(client, { + name: '/', + attributes: { + [SENTRY_OP]: 'pageload', + [SENTRY_ORIGIN]: 'auto.pageload.sveltekit', + [SENTRY_SEGMENT_NAME_SOURCE]: 'url', + [PAGE_SERVER_RENDERING_TYPE]: 'static', + }, + }); + }); + it("doesn't upgrade the pageload span while the route id is still null", () => { instrumentSvelteKitTracing(client, {}); pageRouteChangeCb?.(null); diff --git a/packages/sveltekit/test/server-common/handle.test.ts b/packages/sveltekit/test/server-common/handle.test.ts index 9e55877cfdad..e7b703f08c44 100644 --- a/packages/sveltekit/test/server-common/handle.test.ts +++ b/packages/sveltekit/test/server-common/handle.test.ts @@ -553,6 +553,28 @@ describe('addSentryCodeToPage', () => { `; + const withBootScript = (startArgs: string): string => + html.replace( + '%sveltekit.body%', + `

Pricing

+ `, + ); + const serverRenderedHtml = withBootScript(`app, element, { + node_ids: [0, 4], + data: [null,{type:"data",data:{plans:3},uses:{}}], + form: null, + error: null + }`); + const clientShellHtml = withBootScript('app, element'); + it("Adds add meta tags and fetch proxy script if there's no active transaction", () => { const transformPageChunk = addSentryCodeToPage({ injectFetchProxyScript: true }); const transformed = transformPageChunk({ html, done: true }); @@ -575,6 +597,15 @@ describe('addSentryCodeToPage', () => { }); }); + it('does not add the cache origin marker to a page rendered for a request', () => { + const transformPageChunk = addSentryCodeToPage({ injectFetchProxyScript: false }); + + const transformed = transformPageChunk({ html: serverRenderedHtml, done: true }); + + expect(transformed).toContain(' { const transformPageChunk = addSentryCodeToPage({ injectFetchProxyScript: false }); const transformed = transformPageChunk({ html, done: true }) as string; @@ -585,18 +616,37 @@ describe('addSentryCodeToPage', () => { }); describe('while SvelteKit builds the app', () => { + beforeEach(() => { + vi.stubEnv('_SENTRY_SVELTEKIT_BUILDING', 'true'); + }); + afterEach(() => { vi.unstubAllEnvs(); }); - it("doesn't add meta tags but still adds the fetch proxy script if _SENTRY_SVELTEKIT_BUILDING is set", () => { - vi.stubEnv('_SENTRY_SVELTEKIT_BUILDING', 'true'); + it('replaces the trace meta tags of a server-rendered page with an empty cache origin marker', () => { + const transformPageChunk = addSentryCodeToPage({ injectFetchProxyScript: false }); + + const transformed = transformPageChunk({ html: serverRenderedHtml, done: true }); + + expect(transformed).toBe( + serverRenderedHtml.replace('', '\n'), + ); + }); + + it('adds no meta tags to a client-side shell, such as an `ssr = false` page or the fallback page', () => { + const transformPageChunk = addSentryCodeToPage({ injectFetchProxyScript: false }); + const transformed = transformPageChunk({ html: clientShellHtml, done: true }); + + expect(transformed).toBe(clientShellHtml); + }); + + it('still adds the fetch proxy script', () => { const transformPageChunk = addSentryCodeToPage({ injectFetchProxyScript: true }); - const transformed = transformPageChunk({ html, done: true }) as string; - expect(transformed).not.toContain('${FETCH_PROXY_SCRIPT}`); }); });