docs

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/node traces 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.

  1. Add the packages

    npm install @unhandled/browser
    npm install -D @unhandled/cli

    @unhandled/cli declares the deployment from Workers Builds.

  2. Start the SDK in the layout

    src/layouts/base.astro
    <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 without PUBLIC_UNHANDLED_DSN sends 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 />.

  3. Add the Vite plugin

    astro.config.mjs
    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 from dist/client once the build ends, and deletes them so they never ship. You need no public/.assetsignore and no separate upload script. Without UNHANDLED_API_KEY it prints a warning and still deletes the maps. A failed upload never fails the build.

  4. Export from the Worker

    In the Cloudflare dashboard, open Observability, Destinations, Add destination, and create one Traces and one Logs destination for the <site>-worker service. The Cloudflare Workers setup shows both with your endpoint and headers filled in. Then name them in the Wrangler config:

    wrangler.jsonc
    {
      "observability": {
        "enabled": true,
        "traces": { "enabled": true, "destinations": ["unhandled-mysite-worker-traces"] },
        "logs": { "enabled": true, "invocation_logs": false, "destinations": ["unhandled-mysite-worker-logs"] }
      }
    }

    invocation_logs: false stops Cloudflare from logging every request a second time, query string included. The root span already has the method, path and status.

  5. Set the Workers Builds variables

    Under the Worker's Settings, Build, Variables and secrets:

    VariableValue
    PUBLIC_UNHANDLED_DSNThe DSN of <site>-browser.
    UNHANDLED_API_KEYA project deploy key (dk_…), as a secret. It uploads source maps and declares deployments.
  6. Set the deploy command

    Keep the build command (npm run build). Replace the deploy command with:

    Terminal
    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. With WRANGLER_OUTPUT_FILE_PATH set, Wrangler appends one JSON line per command, and the deploy line holds that ID as version_id. Declaring the deployment with it as --external-id lets Unhandled map the version ID to the commit, which the CLI reads from WORKERS_CI_COMMIT_SHA. Worker spans and browser errors then share one release, and deployments mark both.

    An empty --external-id is 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:

src/middleware.ts
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:

Terminal
UNHANDLED_DSN=… APP_ENV=production node --import @unhandled/node/register ./dist/server/entry.mjs

Astro sets no http.route. The same middleware shape names the request span and reports errors Astro would otherwise turn into a 500:

src/middleware.ts
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.