Astro
Send browser errors, page views and web vitals from an Astro site, plus Worker traces from Cloudflare, with releases and source maps set in Workers Builds.
Astro needs no Unhandled package. The browser SDK goes in your layout, its Vite plugin sets the release and uploads source maps, and the server half depends on where the site runs:
- Static on Cloudflare Workers. Workers Observability exports the Worker's traces and logs. This is the common case and the one most of this page covers.
- On-demand routes on Workers. The same export, plus a middleware that names the routes.
- Node standalone.
@unhandled/nodetraces the server.
Two services
Create two services in one project: <site>-browser for the browser SDK and <site>-worker for the Worker (or <site>-server on Node). Each gets its own DSN. The browser service can then be restricted to your origins, and browser fetch spans stay out of the Worker's requests.
Static site on Cloudflare Workers
With static output, Cloudflare serves the prerendered HTML from static assets and never runs the Worker for it. Only the browser sees those page views. The Worker runs for the routes in assets.run_worker_first, on-demand endpoints and 404s, and those requests are the only Worker traces you get.
Add the packages
npm install @unhandled/browser npm install -D @unhandled/clipnpm add @unhandled/browser pnpm add -D @unhandled/cli@unhandled/clideclares the deployment from Workers Builds.Start the SDK in the layout
<html lang="en"> <head> <meta name="unhandled-route" content={Astro.routePattern} /> </head> <body> <slot /> <script> import { analytics, errors, initUnhandled, network, webVitals } from '@unhandled/browser' const dsn = import.meta.env.PUBLIC_UNHANDLED_DSN if (dsn) { initUnhandled({ dsn, environment: 'production', integrations: [network(), errors(), analytics(), webVitals()], }) } </script> </body> </html>Astro bundles the
<script>, so it runs once per page load. A build withoutPUBLIC_UNHANDLED_DSNsends nothing.The meta tag carries the route pattern,
/blog/[slug], written at build time for prerendered pages. Page views and web vitals group under it instead of one row per URL. The SDK reads it again on every navigation, so it follows<ClientRouter />.Add the Vite plugin
import cloudflare from '@astrojs/cloudflare' import { unhandled } from '@unhandled/browser/vite' import { defineConfig } from 'astro/config' export default defineConfig({ adapter: cloudflare(), vite: { plugins: [unhandled({ sourcemaps: { upload: true } })], }, })In Workers Builds the plugin takes the release from
WORKERS_CI_COMMIT_SHA. It builds the client with hidden source maps, uploads them fromdist/clientonce the build ends, and deletes them so they never ship. You need nopublic/.assetsignoreand no separate upload script. WithoutUNHANDLED_API_KEYit prints a warning and still deletes the maps. A failed upload never fails the build.Export from the Worker
In the Cloudflare dashboard, open Observability, Destinations, Add destination, and create one Traces and one Logs destination for the
<site>-workerservice. The Cloudflare Workers setup shows both with your endpoint and headers filled in. Then name them in the Wrangler config:{ "observability": { "enabled": true, "traces": { "enabled": true, "destinations": ["unhandled-mysite-worker-traces"] }, "logs": { "enabled": true, "invocation_logs": false, "destinations": ["unhandled-mysite-worker-logs"] } } }invocation_logs: falsestops Cloudflare from logging every request a second time, query string included. The root span already has the method, path and status.Set the Workers Builds variables
Under the Worker's Settings, Build, Variables and secrets:
Variable Value PUBLIC_UNHANDLED_DSNThe DSN of <site>-browser.UNHANDLED_API_KEYA project deploy key ( dk_…), as a secret. It uploads source maps and declares deployments.Set the deploy command
Keep the build command (
npm run build). Replace the deploy command with:WRANGLER_OUTPUT_FILE_PATH=/tmp/wrangler-output.ndjson npx wrangler deploy && npx unhandled deploy --service mysite-worker --environment production --external-id "$(grep '"type":"deploy"' /tmp/wrangler-output.ndjson | grep -o '"version_id":"[^"]*"' | cut -d'"' -f4)"Worker spans report the Worker version ID as
faas.version, not the commit. WithWRANGLER_OUTPUT_FILE_PATHset, Wrangler appends one JSON line per command, and thedeployline holds that ID asversion_id. Declaring the deployment with it as--external-idlets Unhandled map the version ID to the commit, which the CLI reads fromWORKERS_CI_COMMIT_SHA. Worker spans and browser errors then share one release, and deployments mark both.An empty
--external-idis a usage error, so if the lookup finds nothing the build fails after the Worker is already live. The build log shows why.
On-demand routes on Workers
A Worker root span is named GET and has url.path but no http.route. Unhandled groups the 404s as GET (unmatched) and every other request by its path. For on-demand pages and endpoints, set the route on the root span from a middleware:
import { defineMiddleware } from 'astro:middleware'
import { tracing } from 'cloudflare:workers'
export const onRequest = defineMiddleware(async (context, next) => {
if (context.isPrerendered) {
return next()
}
tracing.getActiveSpan()?.setAttribute('http.route', context.routePattern)
try {
return await next()
} catch (error) {
console.error(error)
throw error
}
})Astro renders a thrown error as a 500 without reaching the Worker's own error handling, so the console.error is what puts the stack in your logs, linked to the request's trace.
Node standalone
With @astrojs/node in standalone mode, run the server through @unhandled/node with the <site>-server DSN:
UNHANDLED_DSN=… APP_ENV=production node --import @unhandled/node/register ./dist/server/entry.mjsAstro sets no http.route. The same middleware shape names the request span and reports errors Astro would otherwise turn into a 500:
import { reportException, setRoute } from '@unhandled/node/framework'
import { defineMiddleware } from 'astro:middleware'
export const onRequest = defineMiddleware(async (context, next) => {
if (context.isPrerendered) {
return next()
}
setRoute(context.routePattern)
try {
return await next()
} catch (error) {
reportException(error, { mechanism: 'astro.middleware', handled: false })
throw error
}
})The layout and the Vite plugin stay the same. The plugin uploads dist/client. On a plain static build with no adapter, it uploads dist.
Content Security Policy
If the site sets a CSP, add the ingest origin from the browser DSN to connect-src, for example connect-src 'self' https://ingest.example.com. Astro's security.csp hashes the bundled <script> above. An is:inline script is not hashed, so keep the SDK in a bundled one.
Check the data
Open a page, then the <site>-browser service: a page view, network spans and, after you leave the page, web vitals. Request a Worker route, wait a few minutes for Cloudflare's export, and check <site>-worker. Its service badge reads JavaScript · Cloudflare Workers.