From 3927a11ea3b6c4f365161318f93be235678adb94 Mon Sep 17 00:00:00 2001
From: s1gr1d <32902192+s1gr1d@users.noreply.github.com>
Date: Wed, 11 Nov 2026 15:12:52 +0100
Subject: [PATCH] feat(sveltekit): Mark pageloads of prerendered pages
---
.../src/routes/prerendered-shell/+page.svelte | 1 +
.../src/routes/prerendered-shell/+page.ts | 2 +
.../sveltekit-2-static/tests/sdk.test.ts | 47 ++++++++++++---
.../src/routes/prerendered-shell/+page.svelte | 1 +
.../src/routes/prerendered-shell/+page.ts | 2 +
.../sveltekit-3/tests/prerender.test.ts | 59 +++++++++++++++---
.../src/client/svelte4BrowserTracing.ts | 2 +
.../src/client/svelte5BrowserTracing.ts | 2 +
.../sveltekit/src/client/traceMetaTags.ts | 10 ++++
.../sveltekit/src/common/traceMetaTags.ts | 5 ++
.../sveltekit/src/server-common/handle.ts | 18 +++++-
.../client/browserTracingIntegration.test.ts | 32 +++++++++-
.../test/client/svelte5BrowserTracing.test.ts | 29 ++++++++-
.../test/server-common/handle.test.ts | 60 +++++++++++++++++--
14 files changed, 242 insertions(+), 28 deletions(-)
create mode 100644 dev-packages/e2e-tests/test-applications/sveltekit-2-static/src/routes/prerendered-shell/+page.svelte
create mode 100644 dev-packages/e2e-tests/test-applications/sveltekit-2-static/src/routes/prerendered-shell/+page.ts
create mode 100644 dev-packages/e2e-tests/test-applications/sveltekit-3/src/routes/prerendered-shell/+page.svelte
create mode 100644 dev-packages/e2e-tests/test-applications/sveltekit-3/src/routes/prerendered-shell/+page.ts
create mode 100644 packages/sveltekit/src/client/traceMetaTags.ts
create mode 100644 packages/sveltekit/src/common/traceMetaTags.ts
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}`);
});
});