Frontend tracing for Astro
Trace Astro page loads, ClientRouter navigations and island errors with @maple-dev/browser, and join the first page load to the server render of on-demand pages.
This page sets up @maple-dev/browser in an Astro site. Every document load becomes a pageload span and, with <ClientRouter />, every client-side navigation a navigate span, both named after the page’s route (/projects/[id]). For pages rendered on demand, the page load joins the server render’s trace.
It covers the browser setup, connecting your API, island errors, and the server half with the Node adapter. Checked against Astro 7.4 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 Astro.
My Maple public ingest key is maple_pk_... and my organization is in the US region.
Use your public key from Settings → Ingestion. EU organizations should say EU region.
Install
npm install @maple-dev/browserpnpm add @maple-dev/browserbun add @maple-dev/browserTrace page loads and navigations
Astro has no browser router hooks of its own, so a few lines of your code call the SDK’s navigation API. Create src/maple.ts:
// src/maple.ts
import { MapleBrowser } from "@maple-dev/browser"
MapleBrowser.init({
ingestKey: import.meta.env.PUBLIC_MAPLE_INGEST_KEY, // public key, maple_pk_...
serviceName: "acme-web",
environment: import.meta.env.MODE,
tracing: {
propagateTraceHeaderCorsUrls: [/^https:\/\/api\.acme\.com\//],
},
})
const endNavigation = () => MapleBrowser.endNavigation(document.documentElement.dataset.route)
// Every document load is a page load, named when the window load event fires
MapleBrowser.startNavigation(location.pathname)
addEventListener("load", endNavigation)
// <ClientRouter /> navigations, with the page fetch inside them
document.addEventListener("astro:before-preparation", (event) => {
MapleBrowser.startNavigation(event.to.pathname)
const loader = event.loader
event.loader = () => MapleBrowser.traced("load page", loader)
})
document.addEventListener("astro:page-load", endNavigation)
Astro only exposes PUBLIC_-prefixed environment variables to browser code. The public key can only write telemetry, so it is safe to ship. For an EU organization, add region: "eu".
Same-origin requests always carry the traceparent header, so your server’s spans join the browser’s trace. propagateTraceHeaderCorsUrls adds your APIs on other origins. Each must allow the header in its CORS preflight (Access-), or the browser blocks the request, and needs OpenTelemetry to read it (Instrument your application). List only your own APIs.
Import it from the base layout every page renders, and write the route template onto <html>:
---
// src/layouts/Layout.astro
import { ClientRouter } from "astro:transitions"
---
<html lang="en" data-route={Astro.routePattern}>
<head>
<meta charset="utf-8" />
<ClientRouter />
<script>
import "../maple"
</script>
</head>
<body>
<slot />
</body>
</html>
<ClientRouter /> is optional; drop it and the same script traces a multi-page site. Keep the <script> without is:inline so Astro bundles it once per page. A page that doesn’t use the layout needs the same <script> and data-route.
Requests islands make are their own traces, not children of the page load or navigation span. They still join your backend’s trace through traceparent.
Report island errors
Uncaught errors and unhandled rejections from scripts and islands are reported without extra code, as browser.uncaught_error and browser.unhandled_rejection. Errors an island framework catches never reach those handlers, so report them where the framework catches them.
For React islands, wrap the island’s content in MapleErrorBoundary. It reports a render error once, as react.render_error, and renders fallback:
// src/components/Projects.tsx
import { MapleErrorBoundary } from "@maple-dev/browser/react"
export default function Projects() {
return (
<MapleErrorBoundary fallback={<p>Something went wrong.</p>}>
<ProjectList />
</MapleErrorBoundary>
)
}
For Vue islands, set app.config.errorHandler in an app entrypoint:
// astro.config.mjs
vue({ appEntrypoint: "/src/vue-app" })
// src/vue-app.ts
import { MapleBrowser } from "@maple-dev/browser"
import type { App } from "vue"
export default (app: App) => {
app.config.errorHandler = (error, _instance, info) => {
console.error(error) // a handler replaces Vue's own logging
MapleBrowser.captureException(error, { name: "vue.error", attributes: { "vue.error.info": info } })
}
}
For other island frameworks, call MapleBrowser. from their error hook. Each error object is recorded once.
Join the page load to the server render
For pages rendered on demand (an adapter, and output: "server" or export const prerender = false), the server can hand its trace context to the browser in a Server-Timing header. The SDK reads it and makes the pageload span a child of the server’s span. Prerendered pages are plain files, so their page loads start their own traces.
This step is optional: without it, each page load is still traced, as a trace of its own.
Install OpenTelemetry for the server:
npm install @opentelemetry/api @opentelemetry/sdk-node @opentelemetry/auto-instrumentations-node @opentelemetry/exporter-trace-otlp-http import-in-the-middlepnpm add @opentelemetry/api @opentelemetry/sdk-node @opentelemetry/auto-instrumentations-node @opentelemetry/exporter-trace-otlp-http import-in-the-middlebun add @opentelemetry/api @opentelemetry/sdk-node @opentelemetry/auto-instrumentations-node @opentelemetry/exporter-trace-otlp-http import-in-the-middle@opentelemetry/sdk-node: the OpenTelemetry SDK for Node.js.@opentelemetry/: creates a span for each request the server handles, which the page load joins, plus spans for the server’sauto- instrumentations- node fetchand database calls.@opentelemetry/: sends the spans to Maple.exporter- trace- otlp- http import-in-the-middle: lets the instrumentations patch ES modules, which Astro’s server build uses. Without it there is no request span.@opentelemetry/api: the middleware below uses it to write the header.
Start the SDK in a file the server loads first:
// tracing.mjs
import { register } from "import-in-the-middle/register-hooks.mjs"
import { getNodeAutoInstrumentations } from "@opentelemetry/auto-instrumentations-node"
import { OTLPTraceExporter } from "@opentelemetry/exporter-trace-otlp-http"
import { NodeSDK } from "@opentelemetry/sdk-node"
// Lets the instrumentations patch ES modules
register()
const sdk = new NodeSDK({
serviceName: "acme-astro",
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}` }, // private key, maple_sk_...
}),
instrumentations: [
getNodeAutoInstrumentations({
// Skip Astro's hashed JS and CSS, or every asset gets a trace of its own
"@opentelemetry/instrumentation-http": {
ignoreIncomingRequestHook: (request) => request.url?.startsWith("/_astro/") ?? false,
},
}),
],
})
sdk.start()
Load it before the server (@astrojs/node):
astro build
node --import ./tracing.mjs ./dist/server/entry.mjs
Then add a middleware that hands the trace context to the browser:
// src/middleware.ts
import { context, propagation, trace } from "@opentelemetry/api"
import { defineMiddleware } from "astro:middleware"
export const onRequest = defineMiddleware(async ({ request, routePattern }, next) => {
// Name the server's request span after the route (GET /projects/[id]), like the browser spans
trace.getActiveSpan()?.setAttribute("http.route", routePattern).updateName(`${request.method} ${routePattern}`)
const response = await next()
// The request span's trace context, as a traceparent string
const carrier: Record<string, string> = {}
propagation.inject(context.active(), carrier)
// Only on HTML pages: the browser SDK reads it from the page's Server-Timing header
if (carrier.traceparent && response.headers.get("content-type")?.startsWith("text/html")) {
response.headers.append("server-timing", `traceparent;desc="${carrier.traceparent}"`)
}
return response
})
A direct load of /projects/8f2a is then one trace: the server’s GET /projects/[id] span with pageload /projects/[id] under it. A <ClientRouter /> click to that page puts the server’s span under the click’s page fetch, in the navigate trace.
- The middleware writes nothing where no OpenTelemetry SDK runs in the server process, and those page loads start their own traces.
- Don’t send the header on responses a CDN or shared cache stores, or every visitor joins one trace.
- Back and forward can load the document from the browser’s cache, header included, so that
pageloadjoins the earlier visit’s trace.Cache-Control: no-storeon the page avoids it. <ClientRouter />prefetches links on hover. The prefetch is a server trace of its own; the click still fetches and renders the page under itsnavigatespan.
Verify
Build and start the server (the commands above), load a page and click around, then check Traces for your serviceName:
- a direct load of an on-demand page:
pageload <route>under the server’sGET <route>span; - a
<ClientRouter />click:navigate <route>>load page>fetch, with the server’s span under thefetch; - a request to your API: the API’s span under the
fetchspan.
Next steps
- Browser SDK reference: every option, including sampling, logs, Web Vitals, privacy and consent.
- Frontend tracing: the other framework pages.
- Errors and issues: how reported errors are grouped.