# Cron monitoring

A cron monitor tells you when a scheduled job did not run, ran and failed, or started and never
finished. The job reports in with **check-ins**; Bucker watches the clock. Every failure becomes an
ordinary issue, with the same short id, assignment and alert routing as a crash.

There are four ways to send check-ins. They all land on the same service, so the same rules hold:
a check-in with a schedule creates the monitor on first sight and keeps its schedule current after
that.

| You have | Use |
| --- | --- |
| A Node job | `withMonitor` from `@bucker/node` |
| `node-cron` tasks, BullMQ repeatable jobs | `instrumentNodeCron`, `instrumentBullMQ` (automatic, below) |
| Celery beat tasks | `install(app, monitor_beat_tasks=True)` (automatic, below) |
| A Python job | `with_monitor` from `bucker-sdk` |
| Any command (shell, Go, Ruby, a binary) | `bucker-cron run` |
| A crontab line and nothing else | the heartbeat URL |

## Node

```ts
import { withMonitor, flush } from '@bucker/node'

await withMonitor(
  'nightly-export',
  () => exportEverything(),
  { schedule: { type: 'crontab', value: '0 2 * * *' }, checkinMargin: 5, maxRuntime: 30 },
)
await flush() // a job that exits right away must flush, or its last check-in is a false "missed"
```

`withMonitor` sends `in_progress`, then `ok` (or `error` if the callback throws; the error still
propagates), under one check-in id and with the run's duration.

### `node-cron` and BullMQ, without wrapping each job

```ts
import cron from 'node-cron'
import { instrumentNodeCron } from '@bucker/node/integrations/crons'

instrumentNodeCron(cron).schedule('0 2 * * *', nightlyExport, { name: 'nightly-export' })
```

`instrumentNodeCron` wraps `schedule()`: a task with a `name` option is bracketed like
`withMonitor` and its crontab becomes the monitor's schedule. `instrumentBullMQ(bullmq)` returns the
module with a `Worker` that does the same for jobs made by a repeat rule (`pattern`, `every`,
`upsertJobScheduler`). Neither library is a dependency of the SDK. Details and limits are in the
[Node SDK](sdk-node.md#cron-check-ins-for-node-cron-and-bullmq).

## Python

```python
from bucker_sdk import with_monitor

@with_monitor("nightly-export", monitor_config={
    "schedule": {"type": "crontab", "value": "0 2 * * *"},
    "checkin_margin": 5, "max_runtime": 30, "timezone": "UTC",
})
def nightly_export(): ...
```

It is also a context manager (`with with_monitor("one-off"): ...`).

For Celery beat, `bucker_sdk.celery.install(app, monitor_beat_tasks=True)` brackets every task in
`beat_schedule` with check-ins and derives the monitor schedule from the entry (a `crontab`, or an
interval of a minute or more). See the [Python SDK](sdk-python.md).

## Any command: `bucker-cron`

`@bucker/node` ships a zero-dependency wrapper. It needs only Node 20 and a DSN:

```bash
export BUCKER_DSN="https://<public-key>@us.ingest.bucker.io/<project-id>"

# in your crontab, around the command you already run
0 2 * * * npx -y -p @bucker/node bucker-cron run nightly-backup --schedule "0 2 * * *" -- /opt/backup.sh --full
```

It sends `in_progress`, runs the command with its output untouched, then sends `ok` or `error` with
the duration, and **exits with the command's own exit code**. If Bucker is unreachable it prints a
warning and the command is unaffected: the monitor never becomes the outage.

| Option | Meaning |
| --- | --- |
| `--dsn` | The project DSN (default `$BUCKER_DSN`) |
| `--schedule "<cron>"` | Cron expression. Creates or updates the monitor |
| `--interval-minutes <n>` | Expect a run every `n` minutes instead |
| `--timezone <iana>` | Timezone for `--schedule` (default `UTC`) |
| `--grace <minutes>` | Lateness tolerated before a run counts as missed |
| `--max-runtime <minutes>` | How long an in-progress run may last before it times out |
| `--environment <name>` | Environment tag |

Without `--schedule` or `--interval-minutes` the monitor must already exist. A command that cannot
start exits 127 and is reported as a failed run.

## Plain HTTP

```bash
# the whole integration, in a crontab
0 2 * * * /opt/backup.sh && curl -fsS "https://us.ingest.bucker.io/api/<project-id>/monitors/nightly-backup/heartbeat?sentry_key=<public-key>"
```

`POST /api/<project-id>/monitors/<slug>/checkins` takes `{ status, checkInId?, durationMs?,
environment?, monitorConfig? }`; `PUT .../checkins/<checkInId>` closes a run. `status` is
`in_progress`, `ok` or `error`. The heartbeat URL means "I just completed" (`&status=error` reports
a failure).

## The config in a check-in is an upsert

When a check-in carries a schedule (`monitor_config` in the SDKs, `monitorConfig` over HTTP) the
monitor is **created if it does not exist and updated if it does**: the job's code is where the
schedule is written down, so a changed crontab in your deploy reaches the monitor on the next ping
instead of leaving it expecting the old time.

What an upsert changes: the schedule (cron expression or interval, timezone), and the grace period
and max runtime when the config states them. A config that changes nothing writes nothing.

What it never changes: the monitor's **name**, whether it is **enabled** (a paused monitor stays
paused), its **owner**, its **thresholds** and its **mute**. Those belong to people, in the console.

New monitors from a check-in are held to the console's rules: slugs are lowercase letters, digits,
`-` and `_` (up to 64), and a project has a ceiling on how many monitors it can hold.

## What alerts, and when

A monitor opens an issue for a failed run (`error`), a missed run (nothing arrived within the grace
period after the expected time) and a timeout (started, never finished within the max runtime). Each
kind is its own issue. A successful run resolves them.

Three policy fields keep a flaky job from paging you, and tell Bucker who to tell:

- **Failure threshold** (default 1): consecutive failed runs before an issue opens. One flaky run
  does not page when you set 2 or 3.
- **Recovery threshold** (default 1): consecutive successful runs before the issue resolves.
- **Owner**: a person or a team. An alert rule with a `notify_owner` action routes a monitor's
  issue to the owner you set here, and says so in the message.
- **Mute**: silence a monitor until a time, up to 30 days. A muted monitor still records runs and
  opens issues; its alerts are written to the alert history as *suppressed*, naming the mute, so
  nothing is silently lost. Muting is never billed differently and never deactivates the monitor.

Set them on the monitor in the console, or `PATCH` the monitor
(`failureThreshold`, `recoveryThreshold`, `ownerPrincipalId`, `ownerTeamId`, `mutedUntil`).
Planned downtime is better expressed as a [maintenance window](alert-rules.md), which silences
everything it covers.

## Sentry compatibility

An unmodified Sentry SDK's `captureCheckIn` and `withMonitor` already work: pointed at a Bucker DSN
they arrive as `check_in` envelope items and go through the same service, including the upsert.
Monthly and yearly intervals have no fixed length in minutes and are refused rather than guessed.
