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', () => {