Heartbeats

How to Monitor Cron Jobs Properly (And Why Exit Codes Are Not Enough)

Monty17 Mar 2025 5 dk okuma 2

Cron has a design property that quietly ruins weekends: when a cron job does not run, nothing happens. No error, no log line, no alert. Most monitoring answers "did this job fail?" The more important question is "did this job run at all?"

The three ways a cron job fails

The job ran and failed. Exited non-zero. The easy case; exit codes catch it.

The job ran and lied. Exited zero but did not do the work. A backup script that produced a zero-byte file. Every signal says success.

The job never ran. The daemon was not running, the container was evicted, someone fat-fingered the crontab. There is no exit code because no process started, and this is the one that goes unnoticed for weeks.

The inversion that fixes it

Heartbeat monitoring flips the direction. Instead of your monitoring reaching out to inspect your job, your job reports in. You create a heartbeat with an expected interval; the job pings a URL when it finishes; if the ping does not arrive within the interval plus a grace period, an incident opens.

0 2 * * * /opt/scripts/backup.sh && curl -fsS --retry 3 https://your-host/api/ping/YOUR_TOKEN

The && means the ping only fires if the script exited zero. The --retry 3 matters because a ping that fails to send is indistinguishable from a job that did not run.

Choosing an interval and a grace period

Set the interval to the job's actual schedule. Set the grace period to at least twice the job's normal runtime. Zero grace is a trap: the job's own duration will eventually push a ping past the deadline.

Verifying the work, not just the run

Push a small amount of verification into the script and make the ping conditional on it.

#!/bin/bash
set -euo pipefail
/usr/bin/pg_dump mydb | gzip > /backups/db-$(date +%F).sql.gz
SIZE=$(stat -c%s /backups/db-$(date +%F).sql.gz)
if [ "$SIZE" -lt 1000000 ]; then echo "Backup too small" >&2; exit 1; fi
curl -fsS --retry 3 https://your-host/api/ping/YOUR_TOKEN

Now a zero-byte backup fails the size check, skips the ping, and alerts through the same path as a job that never ran.

The setup step everyone skips

Create the heartbeat, wire the URL, and run the job once by hand before you walk away. A heartbeat that has never received a ping has no reference point to measure lateness from. One manual run confirms the token is right and the clock has started.

The honest summary

The only reliable way to detect a job that never started is to expect a signal and alert on its absence, which is exactly what a heartbeat does. It takes about two minutes to set up per job.

Monty