Skip to content

Web

Observability

Every response from the Worker carries an x-request-id header, and every server fault is logged as one line under that id. With monitoring on, the same faults also go to Sentry or Better Stack. GET /api/health answers uptime checks. You work here when you set up error tracking, report an error you caught, or change the error page.

How it works

  1. server/middleware/request-id.ts(Licensees only) sets x-request-id before the handler runs: Cloudflare's cf-ray when the request has one, otherwise a random id.
  2. server/plugins/error-capture.ts(Licensees only) listens on Nitro's error hook. A 5xx, or an error with no status, is written with console.error as one JSON line. A 4xx is neither logged nor reported.
  3. The same hook passes the fault to every provider in monitoring.providers that has a DSN. For a request this runs in waitUntil, after the log line, so a slow tracker never delays the error page.
  4. Dead-lettered jobs and errors Nitro catches outside a request go through the same hook. Scheduled tasks report through defineReportedTask.

Find a failed request

The log line holds the id, method, path, status and message, followed by the error and its stack:

{"tag":"error","requestId":"8f2a61c4bd9e3a10-FRA","method":"POST","path":"/api/billing","status":500,"message":"..."}

The path never includes the query string, because a query can carry a magic-link or password-reset token. observability is on in wrangler.jsonc, so the lines land in Workers Logs in the dashboard. To follow them live:

pnpm exec wrangler tail <worker>

A 5xx error page shows the same id as "Reference 8f2a61c4bd9e3a10-FRA", so a screenshot from a user leads straight to the log line and the tracker event.

Send faults to Sentry or Better Stack

Turn monitoring on in starter.config.ts(Licensees only). It ships off:

monitoring: {
  enabled: true,
  providers: ['sentry', 'betterstack'],
},

Then give each provider its DSN as a Worker secret:

pnpm exec wrangler secret put NUXT_MONITORING_SENTRY_DSN --name <worker>
  • Sentry: Project Settings > Client Keys (DSN). A self-hosted Sentry or a GlitchTip DSN works as sentry too.
  • Better Stack: Errors > Applications > your application > Ingest shows the token and host for https://<application-token>@<ingesting-host>/<application-id>.

A listed provider without a DSN is skipped, and the Worker's [env] line at startup names it. There is no SDK: the kit posts Sentry's envelope format with fetch, which Better Stack accepts as well, and an unlisted provider adds nothing to the bundle.

Each event carries the error's type, message and stack, its causes oldest first, METHOD /path as the transaction, and three tags: request_id, status, and capture (such as request, queue,dead-letter or cron,<task>). It carries no query string, headers, cookies, body, IP address or user, and asks Sentry not to take the Worker's own address for a user's, so a report counts no users. When a Drizzle query fails, the values it bound are replaced with params: [redacted] before anything logs or sends the error.

NUXT_MONITORING_ENVIRONMENT names the environment. Unset, it is production on a deploy and development under pnpm dev.

Report an error you caught

An error your code catches never reaches the hook. Report it with reportFault:

import { reportFault } from '#server/monitoring'

try {
  await syncContact(user)
} catch (error) {
  event.waitUntil(
    reportFault(
      {
        error: error instanceof Error ? error : new Error(String(error)),
        tags: ['crm-sync'],
        at: new Date(),
      },
      event,
    ),
  )
}

reportFault never rejects and does nothing while monitoring is off. To log a caught query error yourself, pass it through redactBoundValues(error) from #server/monitoring/bound-values first, or the log line prints the values the query bound.

Nitro runs no error hook for scheduled tasks. Define them with defineReportedTask from #server/monitoring/task instead of defineTask: a throw is reported, tagged cron and the task's name, then rethrown (Background jobs).

Add a tracker of your own

Drop a file in server/monitoring/adapters/custom/. Its name is the provider id, one lowercase word. server/monitoring/adapters/custom/slack.ts posts each fault to a Slack incoming webhook:

import type { MonitoringAdapter } from '#server/monitoring/types'

export default {
  id: 'slack',

  async send(fault, { dsn, environment }) {
    try {
      const response = await fetch(dsn, {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({
          text: `[${environment}] ${fault.error.name}: ${fault.error.message} ${fault.request?.requestId ?? ''}`,
        }),
      })

      return response.ok
        ? { outcome: 'sent' }
        : { outcome: 'refused', reason: `answered ${response.status}` }
    } catch (error) {
      return { outcome: 'refused', reason: String(error) }
    }
  },
} satisfies MonitoringAdapter

List it and declare its credential, then set NUXT_MONITORING_SLACK_DSN to the webhook URL:

monitoring: {
  enabled: true,
  providers: ['sentry', 'slack'],
  adapters: { slack: { credentials: ['dsn'] } },
},

An adapter receives only dsn and environment, must not throw, and should not retry. A refusal is logged as a [monitoring] warning.

Check health

curl -i https://example.com/api/health
{
  "ok": true,
  "checks": { "database": true },
  "requestId": "8f2a61c4bd9e3a10-FRA",
  "features": { "auth": true, "payments": true, "email": true }
}

It answers 200 when Postgres returns a one-row select and 503 when it doesn't, with cache-control: no-store, and needs no sign-in. An uptime monitor on it tells a Worker that is down from a Worker whose database is down.

Change the error page

app/error.vue(Licensees only) renders every failure, and the status picks the copy. errorCopy() in app/utils/error-copy.ts(Licensees only) gives 400, 401, 403, 404, 410 and 429 their own copy, reads any other 4xx as client and everything else as server. To give another status its own page, add it to COPY_FOR_STATUSES_WITH_THEIR_OWN_MESSAGE, give it an icon in ERROR_ICONS, and add errors.status.<name>.title and .description to every locale. Only the server copy shows the request id.

Configuration

Key or variableWhereDefaultWhat it does
monitoring.enabledstarter.config.tsfalseForwards faults to trackers. Off, they are only logged
monitoring.providersstarter.config.ts['sentry']Every fault goes to each of these that has a DSN
monitoring.adaptersstarter.config.tsnoneCredentials of an adapter you added
NUXT_MONITORING_SENTRY_DSNWorker secretnoneSentry's DSN
NUXT_MONITORING_BETTERSTACK_DSNWorker secretnoneBetter Stack's DSN
NUXT_MONITORING_ENVIRONMENTWorker variableproduction, or development in pnpm devThe environment every event is tagged with
observability.enabledwrangler.jsonctrueKeeps Worker logs in the dashboard
upload_source_mapswrangler.jsonctrueMaps stack traces in Cloudflare's logs back to source

Good to know

  • Stack frames in Sentry and Better Stack point at the minified Worker bundle, not your TypeScript. Only Cloudflare's own logs are source-mapped. Triage by type, message, route and request id.
  • Nothing is retried. A 429 or an X-Sentry-Rate-Limits header pauses that DSN for as long as it asks, or a minute when it names no time. The pause lives in the isolate, and events dropped during it aren't logged.
  • A tracker that hasn't answered within three seconds is given up on, and its event is dropped with a warning in the log. Some faults are reported while a request waits, such as Polar refusing the kit's API version during a checkout, and a stalled tracker must not hold that request.
  • Each forward spends one subrequest per provider from the failed request's own budget. A request that failed by running out of subrequests reaches only the log.
  • Errors thrown in the browser never reach the server, so no tracker sees them.
  • A route's statusMessage is English for logs and API clients, never for people. The browser words a refusal from the reason in data (Languages).

A link with a lock opens the kit’s private repository, which every licensee can read.