# Add monitors with the Belvador SDK

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

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.

| Function | Use 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

```ts title="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

```ts title="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:

```ts
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):

```ts
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:

| Option | Type | Default |
|---|---|---|
| `schedule` | `{ type: "crontab", value: "0 2 * * *" }` or `{ type: "interval", value: 15, unit: "minute" }` | — (without it nothing is created from code) |
| `timezone` | IANA name, e.g. `"Europe/Berlin"` | `"UTC"` |
| `checkinMargin` | minutes | 5 |
| `maxRuntime` | minutes, at most 10080 | 30 |
| `failureIssueThreshold` | runs in a row | 1 |
| `recoveryThreshold` | runs in a row | 1 |
| `environment` | the environment this run belongs to | the 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:

```ts
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](https://belvador.dev/en/docs/troubleshooting).
