Skip to content
Maple Docs
Open app

Frontend tracing for SvelteKit

Trace SvelteKit navigations, universal load functions and client errors with @maple-dev/browser, and join the first page load to SvelteKit's server-side OpenTelemetry spans.

This page sets up frontend tracing for SvelteKit: a span per navigation, a span per universal load function with its fetch requests and backend spans underneath, a report for every unexpected error, and a page load that joins the server render’s trace. The SDK has no SvelteKit-specific entry, so a few lines in SvelteKit’s hooks and root layout call the SDK’s navigation API.

Tested with SvelteKit 3.1, Svelte 5.57, adapter-node 6 and @maple-dev/browser 0.14.

Quick setup with a coding agent

Copy this prompt into Claude Code, Codex, Cursor or another agent that can run shell commands. It installs the maple-frontend-tracing skill, which contains every step of this page.

Set up Maple frontend tracing in this project.

Install the skill with `npx skills add MapleTechLabs/maple/skills --skill maple-frontend-tracing -y`, then follow it. This app uses SvelteKit.

My Maple public ingest key is maple_pk_... and my organization is in the US region.

Use your public key from Settings → Ingestion. Without one, the agent uses a placeholder you can replace later. EU organizations should say EU region.

Install and initialize

npm install @maple-dev/browser
pnpm add @maple-dev/browser
bun add @maple-dev/browser

src/hooks.client.ts runs only in the browser, before the app hydrates. Initialize the SDK there, open the page load span in the init hook, and report unexpected errors from handleError:

// src/hooks.client.ts
import { MapleBrowser } from "@maple-dev/browser"
import type { ClientInit, HandleClientError } from "@sveltejs/kit/hooks"

MapleBrowser.init({
	ingestKey: "maple_pk_...", // public key from Settings → Ingestion
	serviceName: "acme-web",
	region: "us", // "eu" for EU organizations
	tracing: {
		// Your own APIs on another origin; same-origin requests carry traceparent already
		propagateTraceHeaderCorsUrls: [/^https:\/\/api\.acme\.com\//],
	},
})

// Runs once, before hydration and the first page's load functions
export const init: ClientInit = () => MapleBrowser.startNavigation(location.pathname)

export const handleError: HandleClientError = ({ kind, error }) => {
	// "app" is error(), "framework" is SvelteKit's own (404s): only "unknown" is a bug
	if (kind === "unknown") MapleBrowser.captureException(error, { name: "sveltekit.client_error" })
}

The public key can only write telemetry, so it’s safe in browser code. A cross-origin API must allow the traceparent header in its CORS preflight (Access-Control-Allow-Headers). Every other option, including session replay, consent and URL redaction, is in the Browser SDK reference.

Trace navigations

beforeNavigate and afterNavigate from $app/navigation only work during component initialization, so register them in the root layout, which stays mounted for the whole visit:

<!-- src/routes/+layout.svelte -->
<script lang="ts">
	import { MapleBrowser } from "@maple-dev/browser"
	import { afterNavigate, beforeNavigate } from "$app/navigation"
	import { navigating, page } from "$app/state"

	let { children } = $props()

	beforeNavigate(({ to, willUnload, complete }) => {
		if (willUnload || !to) return // leaving the app, or a link no route matches
		MapleBrowser.startNavigation(to.url.pathname)
		// A beforeNavigate guard called cancel(): end the span instead of leaving it open
		complete.catch(() => {
			if (!navigating.type) MapleBrowser.endNavigation()
		})
	})

	// page.route.id, not to.route.id: SvelteKit 3.1 passes a null route id on the first load
	afterNavigate(() => MapleBrowser.endNavigation(page.route.id ?? undefined))
</script>

{@render children()}

Spans are named after the route id, such as navigate /projects/[id], with the concrete path in url.path. SvelteKit’s server spans carry the same string in http.route.

How SvelteKit’s navigations map to spans:

  • Redirects: a load that throws redirect() stays in one span, named after the destination, with both loaders under it.
  • A click during a pending navigation: SvelteKit doesn’t call beforeNavigate again, so one span covers both clicks. It’s named after the route the user ends on, with url.path from the first click.
  • cancel() in a beforeNavigate guard: the span ends at once with the bare name navigate.
  • Back, forward and query-string changes are navigations. Hash links and shallow routing (pushState) start no span, and loads rerun by invalidate() or invalidateAll() are their own traces.
  • A link to a path no route matches loads a new document. Its span is a bare pageload, since there’s no route id.

Trace load functions

Universal load functions in +page.ts and +layout.ts run in the browser on every client-side navigation. Wrap them in MapleBrowser.traced so each gets a span under the navigation. A small helper keeps redirect() and 4xx error() from marking the span failed:

// src/lib/trace-load.ts
import { MapleBrowser } from "@maple-dev/browser"
import { isHttpError, isRedirect } from "@sveltejs/kit"

/** Runs a universal load in a `loader <route id>` span under the current navigation. */
export const traceLoad = <T>(routeId: string | null, fn: () => Promise<T>) =>
	MapleBrowser.traced(`loader ${routeId}`, fn, {
		// redirect() and error() below 500 are control flow, not failures
		isFailure: (e) => !isRedirect(e) && !(isHttpError(e) && e.status < 500),
	})
// src/routes/projects/[id]/+page.ts
import { traceLoad } from "#lib/trace-load.js"
import { error } from "@sveltejs/kit"
import type { PageLoad } from "./$types"

export const load: PageLoad = ({ fetch, params, route }) =>
	traceLoad(route.id, async () => {
		// Both requests start before the first await, so both nest under the span
		const [project, members] = await Promise.all([
			fetch(`/api/projects/${params.id}`),
			fetch(`https://api.acme.com/projects/${params.id}/members`),
		])
		if (project.status === 404) error(404, "Project not found")
		return { project: await project.json(), members: await members.json() }
	})

On the server, traced only runs the function; SvelteKit’s own tracing spans server-side loads.

The browser has no async context: only requests started before the function’s first await nest under its span. Start independent requests together with Promise.all, as above. For a request that needs an earlier result, carry the context across the await (npm install @opentelemetry/api):

import { context } from "@opentelemetry/api"

export const load: PageLoad = ({ fetch, params, route }) =>
	traceLoad(route.id, async () => {
		const ctx = context.active() // before the first await
		const project = await (await fetch(`/api/projects/${params.id}`)).json()
		const members = await context.with(ctx, () => fetch(`https://api.acme.com/teams/${project.teamId}/members`))
		return { project, members: await members.json() }
	})

Errors

Where each kind of error is recorded. None is recorded twice:

  • A universal load that throws in the browser: on its loader span.
  • A component that throws while rendering in the browser: handleError reports it as sveltekit.client_error.
  • An event handler that throws: the SDK’s global handler reports it as browser.uncaught_error.
  • A load that throws during server rendering: SvelteKit records it on its server sveltekit.load span.
  • A component that throws during server rendering: the response is a 500, but no span records the exception.

Join the page load to the server render

SvelteKit can emit OpenTelemetry spans for the handle hook, load functions, form actions and +server endpoints, and reads an incoming traceparent. Turn it on in the options you pass to the sveltekit() plugin:

// vite.config.ts
import adapter from "@sveltejs/adapter-node"
import { sveltekit } from "@sveltejs/kit/vite"
import { defineConfig } from "vite"

export default defineConfig({
	plugins: [sveltekit({ adapter: adapter(), tracing: { server: true } })],
})

Start the OpenTelemetry Node SDK in src/instrumentation.server.ts, which SvelteKit loads before your app code when the adapter supports it (adapter-node does):

npm install @opentelemetry/api @opentelemetry/sdk-node @opentelemetry/exporter-trace-otlp-http
pnpm add @opentelemetry/api @opentelemetry/sdk-node @opentelemetry/exporter-trace-otlp-http
bun add @opentelemetry/api @opentelemetry/sdk-node @opentelemetry/exporter-trace-otlp-http
  • @opentelemetry/sdk-node: records the spans SvelteKit creates. Without an SDK, SvelteKit’s tracing does nothing.
  • @opentelemetry/exporter-trace-otlp-http: sends them to Maple.
  • @opentelemetry/api: hooks.server.ts below uses it to write the trace context into the page.
// src/instrumentation.server.ts
import { OTLPTraceExporter } from "@opentelemetry/exporter-trace-otlp-http"
import { NodeSDK } from "@opentelemetry/sdk-node"

new NodeSDK({
	serviceName: "acme-web-server",
	traceExporter: new OTLPTraceExporter({
		url: "https://ingest.maple.dev/v1/traces", // EU: https://ingest.eu.maple.dev/v1/traces
		headers: { authorization: `Bearer ${process.env.MAPLE_INGEST_KEY}` },
	}),
}).start()

Each request then gets a sveltekit.handle.root span, with sveltekit.resolve and a sveltekit.load per load under it. A same-origin fetch from the browser joins the server’s trace through its traceparent. A load’s server-side fetch to another origin carries no traceparent unless you add HTTP instrumentation; see the Node.js guide.

The browser joins the server’s trace when the page has a <meta name="traceparent"> tag. Add a placeholder to src/app.html:

<meta name="traceparent" content="%maple.traceparent%" />

Then fill it with the root span’s trace context in src/hooks.server.ts:

// src/hooks.server.ts
import { context, propagation, trace } from "@opentelemetry/api"
import type { Handle } from "@sveltejs/kit/hooks"

export const handle: Handle = ({ event, resolve }) => {
	// The trace context of SvelteKit's root span for this request
	const carrier: { traceparent?: string } = {}
	propagation.inject(trace.setSpan(context.active(), event.tracing.root), carrier)

	return resolve(event, {
		transformPageChunk: ({ html }) => html.replace("%maple.traceparent%", carrier.traceparent ?? ""),
	})
}

The pageload span becomes a child of sveltekit.handle.root, and every reload gets a new trace, because SvelteKit computes the ETag after transformPageChunk. Don’t add the tag to pages a CDN caches, or every visitor joins the same trace. Without server tracing, the placeholder becomes an empty string and the page load starts a trace of its own.

Next steps