Skip to content

Add monitors with the Belvador SDK

withMonitor, captureCheckIn and heartbeat for TanStack Start and Cloudflare Workers; create monitors in Belvador or from code.

Markdown
On this page

From @belvador/* 0.3.0, the server packages report background work with three functions. They never throw, never change what your job returns, and do nothing when the SDK is off.

FunctionUse it for
withMonitor(slug, fn, options?)Wrap one job run: start, finish, errors and duration are reported for you
captureCheckIn(checkIn, config?)Report a start or finish yourself, when the work is not one function call
heartbeat(slug)Say "still alive" for an every-N-minutes monitor

They exist in @belvador/tanstack-start/server and @belvador/cloudflare. Browser packages do not have them: check-ins from a browser are refused.

Wrap a job with withMonitor#

TanStack Start#

src/routes/api/jobs/nightly-invoices.ts
import { createFileRoute } from "@tanstack/react-router";import { withMonitor } from "@belvador/tanstack-start/server";// Call this route from your scheduler; the run is reported even if it throws.export const Route = createFileRoute("/api/jobs/nightly-invoices")({  server: {    handlers: {      POST: async () => {        const result = await withMonitor("nightly-invoices", async (run) => {          // Your job. Nothing to do? run.skip("nothing_due")          return { done: true };        }, { schedule: { type: "crontab", value: "0 2 * * *" }, timezone: "UTC" });        return Response.json(result);      },    },  },});

Call the route from your scheduler (a cron service, a Workers cron trigger, GitHub Actions). Protect it like any internal endpoint.

Cloudflare Workers#

src/index.ts
import { withBelvador, withMonitor } from "@belvador/cloudflare";export default withBelvador((env: Env) => ({ release: env.BELVADOR_RELEASE }), {  async scheduled(controller, env, ctx) {    await withMonitor("nightly-invoices", async (run) => {      // Your job. Nothing to do? run.skip("nothing_due")    }, { schedule: { type: "crontab", value: "0 2 * * *" }, timezone: "UTC" });  },});

What happens on each run:

  1. in_progress is sent when fn starts.

  2. When fn returns (or its promise resolves), ok is sent with the duration.

  3. When fn throws, the error is captured as a normal issue — linked to this run — and error is sent. The same error is then thrown to your code, unchanged.

Tell Belvador what the run did#

fn receives a run handle:

  • run.skip("nothing_due") — the job ran but had nothing to do. The run is Skipped, which is healthy.

  • run.unknown("provider_timeout") — the job ended but cannot tell whether its work took effect. The run becomes Outcome unknown and the monitor Needs review.

Reasons are short codes: lowercase letters, digits, ., _, -, up to 64 characters. Never put customer data in them.

Retries#

Queues retry for you; Belvador only needs to know which attempts belong together:

TypeScript
await withMonitor("sync-orders", async () => syncOrders(message), {  runId: message.id,        // the same id on every attempt  attempt: message.attempts, // 1 for the first try  willRetry: (error) => message.attempts < 5,});

An attempt that fails with willRetry true is shown as Will retry and does not open an incident; the run's final result decides. Belvador never retries your work.

The error of every failed attempt is also reported as an issue event, so the run shows what each attempt hit. Three attempts of one failure are one issue with three events, and a message that recovers on its third try still leaves two. When the event count matters more (event-based archive conditions, the escalating threshold, your quota), report only the final failure (SDK 0.5.3):

TypeScript
await withMonitor("sync-orders", async () => syncOrders(message), {  runId: message.id,  attempt: message.attempts,  willRetry: (error) => message.attempts < 5,  reportRetries: false, // attempts that will retry send their check-in, not an issue event});

The reported error carries the attempt number in its monitor context.

Create the monitor#

There are two ways; both end in the same monitor.

In Belvador. Open Monitors → Create monitor, pick the project, name, schedule and limits, and use the slug in your code. Schedule and limits are then edited in Belvador.

From code. Pass the schedule in the options, as in the examples above. The first check-in creates the monitor, marked Defined in code; later check-ins update it. Schedule and limits are then locked in Belvador — change them in code. The name and watched environments stay editable in Belvador.

Options that define a monitor from code:

OptionTypeDefault
schedule{ type: "crontab", value: "0 2 * * *" } or { type: "interval", value: 15, unit: "minute" }— (without it nothing is created from code)
timezoneIANA name, e.g. "Europe/Berlin""UTC"
checkinMarginminutes5
maxRuntimeminutes, at most 1008030
failureIssueThresholdruns in a row1
recoveryThresholdruns in a row1
environmentthe environment this run belongs tothe SDK's environment

Found in code#

A project can turn off Allow monitors from code (Project settings → Observability). New slugs then appear under Monitors → Found in code instead; Review turns one into a monitor with the settings your code sent, Ignore hides it for 30 days. The same list catches slugs beyond 20 new monitors per project per hour. A deleted monitor is not re-created from code for 30 days.

captureCheckIn and heartbeat#

When start and finish happen in different places — a job split across two functions, or a process that runs for days — report them yourself:

TypeScript
const checkInId = captureCheckIn({ monitorSlug: "import-catalog", status: "in_progress" });// … later, maybe in another function …captureCheckIn({ monitorSlug: "import-catalog", status: "ok", checkInId, duration: 42 });

duration is in seconds. For an every-N-minutes monitor that only needs a sign of life, call heartbeat("queue-worker") on each loop.

Check it works#

  1. Run the job once.

  2. Open Monitors in Belvador: the run appears within a few seconds with its result and duration.

  3. Make it throw once on purpose: the run shows Failed and links to the issue.

Nothing appears? The SDK writes a diagnostic to the console with debug: true; BLV_MONITOR_INVALID_SLUG means the slug does not match ^[a-z0-9][a-z0-9_-]{0,63}$. See Troubleshooting.

Posts on this topic

All posts

Spotted something wrong or missing? Tell us: hello@belvador.dev