Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Original file line number Diff line number Diff line change
@@ -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[] {
Expand Down Expand Up @@ -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();
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Original file line number Diff line number Diff line change
@@ -1,55 +1,98 @@
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(/<meta name="(sentry-[\w-]+|baggage)"/g)].map(match => 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);
});
});

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);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could we additionally check that expect(getSpanOp(originSpan)).toBe('http.server') here? SInce we do not control the spans from sveltekit this would verify that actually attach it to the segment/root span

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' } },
},
]);
});
26 changes: 14 additions & 12 deletions packages/sveltekit/src/client/svelte4BrowserTracing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -51,17 +51,19 @@ export function instrumentSvelteKitTracing(
function _instrumentPageload(client: Client, pageStore: Readable<Page>): 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;
}
Expand Down
26 changes: 14 additions & 12 deletions packages/sveltekit/src/client/svelte5BrowserTracing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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;
Expand Down
41 changes: 35 additions & 6 deletions packages/sveltekit/src/client/traceMetaTags.ts
Original file line number Diff line number Diff line change
@@ -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' },
};
}
4 changes: 2 additions & 2 deletions packages/sveltekit/src/common/traceMetaTags.ts
Original file line number Diff line number Diff line change
@@ -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';
39 changes: 33 additions & 6 deletions packages/sveltekit/src/server-common/handle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,13 @@ import type { Span } from '@sentry/core';
import {
continueTrace,
debug,
getActiveSpan,
getClient,
getCurrentScope,
getDefaultIsolationScope,
getIsolationScope,
getRootSpan,
getTraceData,
getTraceMetaTags,
hasSpanStreamingEnabled,
httpHeadersToSpanAttributes,
Expand Down Expand Up @@ -82,9 +85,10 @@ export const FETCH_PROXY_SCRIPT = `
*/
export function addSentryCodeToPage(options: {
injectFetchProxyScript: boolean;
isVercelIsrRender?: boolean;
}): NonNullable<ResolveOptions['transformPageChunk']> {
return ({ html }) => {
const metaTags = getPageMetaTags(html);
const metaTags = getPageMetaTags(html, !!options.isVercelIsrRender);
const headWithMetaTags = metaTags ? `<head>\n${metaTags}` : '<head>';

const headWithFetchScript = options.injectFetchProxyScript ? `\n<script>${FETCH_PROXY_SCRIPT}</script>` : '';
Expand All @@ -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) ? `<meta name="${CACHE_ORIGIN_META_TAG_NAME}" content=""/>` : '';

// 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 `<meta name="${CACHE_ORIGIN_META_TAG_NAME}" content="${originTrace ?? ''}"/>`;
}

/** 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:');
}
Expand Down Expand Up @@ -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'),
Comment thread
s1gr1d marked this conversation as resolved.
Resolved
}),
});

Expand Down
Loading
Loading