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/browserpnpm add @maple-dev/browserbun add @maple-dev/browsersrc/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
loadthat throwsredirect()stays in one span, named after the destination, with both loaders under it. - A click during a pending navigation: SvelteKit doesn’t call
beforeNavigateagain, so one span covers both clicks. It’s named after the route the user ends on, withurl.pathfrom the first click. cancel()in abeforeNavigateguard: the span ends at once with the bare namenavigate.- Back, forward and query-string changes are navigations. Hash links and shallow routing (
pushState) start no span, and loads rerun byinvalidate()orinvalidateAll()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
loadthat throws in the browser: on itsloaderspan. - A component that throws while rendering in the browser:
handleErrorreports it assveltekit.client_error. - An event handler that throws: the SDK’s global handler reports it as
browser.uncaught_error. - A
loadthat throws during server rendering: SvelteKit records it on its serversveltekit.loadspan. - 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-httppnpm add @opentelemetry/api @opentelemetry/sdk-node @opentelemetry/exporter-trace-otlp-httpbun 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/: sends them to Maple.exporter- trace- otlp- http @opentelemetry/api:hooks.server.tsbelow 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
- Frontend tracing overview: every framework guide.
- Browser SDK reference: sampling, logs, Web Vitals, consent and URL redaction.
- Errors and issues: how reported errors are grouped into issues.
- Instrument your application: backend guides, so browser traces continue into your services.