Late Run Detection - ptweezy/cronstable GitHub Wiki
Service level agreement (SLA) monitoring detects jobs that have gone too long without success, missed their scheduled start, or exceeded an expected runtime. Set per-job thresholds in an sla: block. Each breach triggers onLate once, using the same six reporters as failure reporting. The Windows Event Log reporter writes event 1003 at Warning level.
The monitor evaluates checks once per wall-clock minute. It needs no state store, though a store preserves the last successful run across restarts. Breaches appear in the HTTP API (sla on GET /jobs), the web dashboard and terminal dashboard (OVERDUE), Prometheus, and MCP observe tools.
The historical "SLA aggregates" in GET /jobs/{name}/trends summarize completed runs. The sla: checks described here monitor current thresholds.
jobs:
- name: nightly-etl
command: python -m etl.run
schedule: "0 4 * * *"
sla:
maxTimeSinceSuccessSeconds: 129600 # page when no success for 36h
lateAfterSeconds: 900 # page when a due slot has not started within 15min
maxRuntimeSeconds: 7200 # page when a run exceeds 2h
onLate:
report:
webhook:
url:
fromEnvVar: SLACK_WEBHOOK_URL| Option | Type | Default | Description |
|---|---|---|---|
sla.maxTimeSinceSuccessSeconds |
int or null |
null (off) |
Breach when this many seconds pass without a successful finish. Must be > 0 when set. |
sla.lateAfterSeconds |
int or null |
null (off) |
Breach when a due scheduled slot has not started a run within this many seconds. Must be > 0 when set. |
sla.maxRuntimeSeconds |
int or null |
null (off) |
Breach while any running instance has been running longer than this. Observes only; this check never stops the run (to enforce a limit, use executionTimeout). Must be > 0 when set. |
onLate.report |
report block | reporter defaults | The reporters fired once per breach: mail, sentry, shell, webhook, push, and eventlog. The schema matches onFailure.report, with overdue-specific default templates. |
The three thresholds are independent; set any subset. Configuring an onLate reporter with no thresholds raises a load-time ConfigError (onLate requires sla). Both keys merge under a defaults: block and are excluded from the job-set ID fingerprint.
Check names are the config keys minus their Seconds suffix: maxTimeSinceSuccess, lateAfter, maxRuntime. The same vocabulary appears everywhere a check is named: the metric check label, the payload's check field, and the {{sla_check}} template variable.
-
maxTimeSinceSuccess: breached whennow - last successful finishexceeds the threshold. With no recorded success, the check measures from daemon startup. A durable run ledger restores the last success at boot, so an already-overdue job may trigger a report soon after restart. -
lateAfter: a scheduled slot falls due, and no run of the job has started since it. Breached whennow - dueexceeds the threshold. Any start (scheduled, catch-up, retry, or manual) clears it. Slots skipped because the job was paused do not count as late, and a restart baselines on the next due slot. -
maxRuntime: breached while any running instance has been running longer than the threshold, measured from the run's launch instant. Clears when the run ends. It never stops anything.
A disabled or paused job is not evaluated at all. Under leader election, only the node that owns the job evaluates it, so one breach pages once, not once per node.
Each (job, check) pair carries a latch. On the transition into breach, cronstable fires the onLate reporters once, sets cronstable_job_late{job_name, check} to 1, increments cronstable_job_sla_breaches_total, and logs a warning naming the observed and threshold seconds. While the breach persists, nothing re-fires. On recovery, the gauge clears, an info line is logged, and no report fires. The latch is in-memory, so after a daemon restart a still-breached check fires its report once more.
Reports are dispatched off the scheduler loop and ordered after the same job's in-flight completion reports, so a slow SMTP server can never stall scheduling.
onLate.report takes the exact schema of the other reporting hooks, with defaults reworded for a breach (there is no run outcome to describe). The default mail subject is:
Cron job '{{name}}' is overdue ({{sla_check}})
The default body names the check, the threshold, the observed value, and the last success (or (none recorded)). The default webhook body wraps the same text in the Slack-compatible {"text": ...} shape. The default sentry fingerprint is ["cronstable", "sla", "{{ name }}"], so breaches group as their own Sentry issue per job instead of folding into run failures.
Templates receive the full standard template variable set, with the run-shaped fields empty: success is false, fail_reason is sla: <check> breached, and stdout/stderr/exit_code are null. They also receive four breach variables, which the shell reporter additionally receives as environment variables:
| Template variable | Environment variable | Value |
|---|---|---|
sla_check |
CRONSTABLE_SLA_CHECK |
The check name: maxTimeSinceSuccess, lateAfter, or maxRuntime. |
threshold_seconds |
CRONSTABLE_SLA_THRESHOLD_SECONDS |
The configured threshold. |
observed_seconds |
CRONSTABLE_SLA_OBSERVED_SECONDS |
The measured value that breached it. |
last_success_at |
CRONSTABLE_LAST_SUCCESS_AT |
ISO-8601 instant of the last known success, or null (empty string in the environment). |
-
GET /jobscarries anslaobject for every job with a configured check (and only those):thresholds(the non-null keys),state("ok"or"late"), andbreaches, a list of{check, since, observed_seconds, threshold_seconds}, wheresinceis when the monitor latched the breach. Theobserved_secondsvalue is re-measured at payload time, so dashboards show a moving number. See HTTP API. -
Prometheus:
cronstable_job_late{job_name, check}(0/1 per check) andcronstable_job_sla_breaches_total{job_name, check}, both emitted after the monitor first evaluates the job's checks. - The web dashboard shows an OVERDUE badge on late jobs (row chip, drawer, wallboard). The terminal dashboard paints the same suffix.
-
MCP observe tools (
cron_list_jobs,cron_get_job) return the sameslaobject.
onLate runs inside the daemon, so it cannot report when the daemon or host stops. Pair it with the external alerts in metrics with Prometheus: time() - cronstable_job_last_success_timestamp_seconds detects stale successes, and up == 0 detects failed scrapes. These checks can alert when the daemon cannot send notifications.
- Pausing Jobs: pausing suppresses a job's SLA checks.
-
Reporting (Mail, Sentry, Shell, Webhook, Push, Event Log): the reporter options
onLate.reportaccepts. - Metrics with Prometheus: the metric families and the external staleness alert to pair with.
- Failure Detection and Retries: the hooks for runs that happened and failed.
-
Hashed Schedules: stable
Hslots keep "was this run late?" answerable. - Configuration Reference: the schema and load-time validation.