Blog Monitoring and alerts

Cron job monitoring: alert when a job doesn't run

A cron job that crashes loudly is easy to catch. A cron job that simply stops running is not. Nothing errors, nothing logs, and you find out weeks later when someone asks for last month's backup.

The usual fix is a heartbeat, also called a check-in or a dead man's switch. The job tells a monitoring service "I ran" every time it runs. If the service stops hearing from it, it alerts you. The alert comes from silence, not from an error.

This post shows how that works in ZipLogger's heartbeats, with samples you can paste into a crontab or a .NET service.

Why logs and uptime checks don't catch this

There are two things people usually try first.

  • An alert on missing logs. It fires when a log query matches nothing for a while. It has no idea what a "run" is. A job that fails and logs an error still produces logs, so the alert stays quiet unless you also alert on that error.
  • An uptime check. Something polls a URL you expose. ZipLogger has uptime monitors for that, and they are the right tool when the question is "does the server answer?". They cannot tell you whether a job inside it ran: the server can be perfectly healthy while the nightly job hasn't run for a week.

A heartbeat turns the direction around. The job calls the monitor, so the monitor knows about each run: when it started, whether it finished, and how it ended.

Create a heartbeat

In ZipLogger, go to Alerts → Detection → Heartbeats and click + New heartbeat. Heartbeats used to be called job monitors, and the page address (/alerts/job-monitors) and the API (/api/v1/job-monitors) still use that name. The form asks for:

  • Name, plus optional Service and Environment labels. The Environment label is free text; the heartbeat itself belongs to the environment you are viewing when you create it.
  • Schedule: Interval or Cron. More on both below.
  • Grace period (minutes): how long after the expected time a run may still arrive before it counts as missed.
  • Max runtime (minutes): optional. Catches a job that started but is taking too long.
  • Severity, Escalation policy and On-call team: who gets told when it goes wrong.
  • Sentry monitor slug: optional, for Sentry cron check-ins (below).

When you click Create, ZipLogger shows the heartbeat's ping URL. It is also on the heartbeat's page, for Editors. Treat it as a secret: anyone with it can report runs. If it leaks, use Rotate token on the heartbeat's page. The old URL stops working at once.

The simplest version: one line in crontab

Call the ping URL when the job succeeds. No body, no header, no SDK:

0 2 * * * /opt/backup/run.sh && curl -fsS -m 10 --retry 3 https://app.ziplogger.ai/api/v1/heartbeat/YOUR_TOKEN

If run.sh exits non-zero, && skips the curl, no ping arrives, and the run counts as missed once the grace period has passed.

To report the failure straight away instead of waiting, send the exit code. 0 is a success and anything else is a failure:

0 2 * * * /opt/backup/run.sh; curl -fsS -m 10 --retry 3 https://app.ziplogger.ai/api/v1/heartbeat/YOUR_TOKEN/$?

The ping URL accepts GET, HEAD and POST, so most HTTP clients and schedulers can call it.

The fuller version: start, then the outcome

A success-only ping can't catch a job that hangs. For that, report the start too, and set Max runtime:

#!/usr/bin/env bash
# /opt/backup/run-with-heartbeat.sh
URL="https://app.ziplogger.ai/api/v1/heartbeat/$ZIPLOGGER_HEARTBEAT_TOKEN"
RID="$(hostname)-$$-$(date +%s)"

curl -fsS -m 10 --retry 3 "$URL/start?rid=$RID" > /dev/null || true

/opt/backup/run.sh
code=$?

curl -fsS -m 10 --retry 3 "$URL/$code?rid=$RID" > /dev/null || true
exit $code
PathReports
/api/v1/heartbeat/<token>A successful run
/api/v1/heartbeat/<token>/startA run started
/api/v1/heartbeat/<token>/failA failed run. A POST body (plain text, first 2,000 characters) is kept as its message.
/api/v1/heartbeat/<token>/<exit code>0 is a success; anything else is a failure

?rid= names the run, so the start and the outcome belong together. Without it, a start begins a new run and a success or failure closes the open one.

The || true is on purpose. If ZipLogger can't be reached, your backup should still run.

Then point cron at the wrapper:

0 2 * * * ZIPLOGGER_HEARTBEAT_TOKEN=YOUR_TOKEN /opt/backup/run-with-heartbeat.sh

Interval or cron schedule

Interval means a run is expected that long after the last successful run, from one minute to 30 days. It suits a job that runs "every hour" with no fixed clock time.

Cron means a run is expected at each time a cron expression names, in the Time zone you pick. Use it for jobs with fixed times, such as 0 2 * * * for 02:00 every day or 0 9 * * 1-5 for 09:00 on weekdays. A weekday job then expects nothing on Saturday and Sunday. The form shows the next runs as you type, so you can check the expression before you save.

Daylight saving is handled in the time zone you chose. A 02:30 run on the night the clocks skip from 02:00 to 03:00 is expected at 03:00. On the night an hour repeats, it is expected once.

After each expected time, a heartbeat goes through two stages:

  • Late: the expected time has passed, but the grace period has not. Nothing is opened yet.
  • Missed: the grace period has passed with no successful run. An incident opens.

For a cron schedule, a ping that arrives a little early counts for the coming run, so a server whose clock runs a minute fast does not cause a missed run. The tolerance is the grace period, at most 10 minutes.

Sentry cron check-ins

If your jobs already use Sentry cron monitoring, point the Sentry SDK at ZipLogger (the Sentry SDK post shows how) and keep the calls you have: capture_checkin and @monitor in Python, Sentry.withMonitor in Node.js. Set the heartbeat's Sentry monitor slug to the SDK's monitor_slug. The heartbeat must be in the same environment as the DSN's key.

  • in_progress starts a run.
  • ok ends it as a success.
  • error ends it as a failure and opens an incident.
  • A missed run is found from the schedule, as for any heartbeat.

You don't have to create the heartbeat first. A check-in that carries a monitor_config creates one the first time, named after the slug, and keeps its schedule (crontab or interval, margin, maximum runtime and time zone) in step after that. In Python:

from sentry_sdk.crons import monitor

@monitor(monitor_slug="nightly-report", monitor_config={
    "schedule": {"type": "crontab", "value": "0 2 * * *"},
    "timezone": "Europe/Berlin",
    "checkin_margin": 10,
    "max_runtime": 60,
})
def nightly_report():
    ...

A heartbeat you created yourself keeps its own schedule; the SDK only reports its runs. Check-ins are also stored as log lines, as before.

From code: a .NET BackgroundService

Scheduled work often lives inside a service rather than in cron. This worker runs an export every hour, so its heartbeat would be an Interval of 1 hour. It calls the ping URL with a plain HttpClient:

public sealed class HourlyExportWorker(ILogger<HourlyExportWorker> logger, IConfiguration config)
    : BackgroundService
{
    private static readonly HttpClient Http = new() { Timeout = TimeSpan.FromSeconds(10) };

    protected override async Task ExecuteAsync(CancellationToken ct)
    {
        using var timer = new PeriodicTimer(TimeSpan.FromHours(1));
        do
        {
            var runId = Guid.NewGuid().ToString("n");
            await PingAsync("/start", runId, ct);
            try
            {
                await RunExportAsync(ct);
                await PingAsync("", runId, ct);
            }
            catch (Exception ex) when (ex is not OperationCanceledException)
            {
                logger.LogError(ex, "Hourly export failed");
                await PingAsync("/fail", runId, ct);
            }
        }
        while (await timer.WaitForNextTickAsync(ct));
    }

    private async Task PingAsync(string suffix, string runId, CancellationToken ct)
    {
        try
        {
            // ZipLogger:HeartbeatUrl is the ping URL from the heartbeat's page.
            var url = $"{config["ZipLogger:HeartbeatUrl"]}{suffix}?rid={runId}";
            using var response = await Http.PostAsync(url, content: null, ct);
            if (!response.IsSuccessStatusCode)
                logger.LogWarning("Heartbeat ping {Suffix} returned {Status}", suffix, (int)response.StatusCode);
        }
        catch (Exception ex) when (ex is HttpRequestException or TaskCanceledException && !ct.IsCancellationRequested)
        {
            // A monitoring outage must never stop the job itself.
            logger.LogWarning(ex, "Heartbeat ping {Suffix} could not be sent", suffix);
        }
    }

    private static Task RunExportAsync(CancellationToken ct) => Task.Delay(1000, ct); // your job here
}

Register it with builder.Services.AddHostedService<HourlyExportWorker>(); and put the ping URL in configuration as ZipLogger:HeartbeatUrl, for example as the environment variable ZipLogger__HeartbeatUrl. If you already send logs to ZipLogger, the LogError above ends up next to the incident. The Serilog post covers that setup.

What triggers an alert

A heartbeat can open an incident for three reasons. The description on the incident says which one.

What happenedWhen ZipLogger notices
The job reported a failure (/fail, a non-zero exit code, or Sentry error)Straight away, when the ping arrives
No successful run by the expected time plus the grace periodOn the next evaluation, which runs every 30 seconds
A start with no outcome inside Max runtimeOn the next evaluation

The next successful run resolves the incident.

The incident goes through the same pipeline as every other alert: maintenance windows, escalation, on-call routing. One thing to check: if the heartbeat has neither an escalation policy nor an on-call team, the incident is recorded on the Alerts page but nobody is notified. The incident management page explains policies and teams.

A status page component can also follow a heartbeat: healthy shows Operational, a missed or failed run shows Major outage.

Retries, duplicates and overlapping runs

Networks fail and cron jobs get retried, so pings are designed to be sent more than once.

  • A retried ping for the same run id is recorded as a duplicate and changes nothing.
  • The first outcome for a run wins. A late success can't overwrite a failure that was already recorded for the same run.
  • An old run's result can't close an incident that a newer run opened.
  • Only one run can be open at a time. A start for a new run while another is still open is rejected with 409. If the job can overlap itself, give each copy its own heartbeat.

The heartbeat's page lists every ping under Run history, with its outcome and where it came from (ping URL, API or Sentry), so you can see a duplicate or a rejected start instead of guessing.

The JSON endpoints

If you want a JSON body and a bearer token instead of a URL, the original check-in endpoints still work. The token is the last part of the ping URL, starting with jmk_:

curl -fsS -X POST https://app.ziplogger.ai/api/v1/job-monitors/checkins/success \
  -H "Authorization: Bearer jmk_..." -H "Content-Type: application/json" \
  -d '{"runId": "backup-2026-10-11"}'

There are start, success and failure endpoints, each with an optional runId (up to 200 characters) and message (kept up to 2,000 characters).

Limits worth knowing

  • Nothing is judged until the first ping. A new heartbeat is awaiting and cannot be missed until the job has reported once. Run the job by hand after setting it up.
  • Pausing is clean. Pause resolves the open incident and stops evaluation. Resume starts a fresh expectation, so the paused time doesn't count as missed. Changing the schedule does the same.
  • No holiday calendar. A cron expression can skip weekends, but not a list of holidays. Pause the heartbeat for those.
  • Sentry thresholds. Sentry's failure_issue_threshold and recovery_threshold are not used: ZipLogger opens an incident on the first missed or failed run.
  • Rate limit. Pings are limited to 120 per minute per heartbeat.
  • Heartbeats created before ping URLs existed ask you to rotate the token to get a ping URL. The JSON endpoints keep working with the old token until then.

Heartbeats vs service health checks

ZipLogger also has health checks, and they solve a different problem. On the Dashboards page, each service card has a ⚙ where you add a Health check URL. ZipLogger's server requests that URL on a schedule you pick, from 1 to 60 minutes. Any response below HTTP 400 counts as up. An error status, or no answer within 10 seconds, marks the service down. Tick Alert when this service goes down to get an alert rule for it.

HeartbeatHealth check
Who calls whomYour job calls ZipLoggerZipLogger calls your URL
AnswersDid this job run, finish and succeed?Is this service reachable?
Knows about runsYesNo
Needs a public URLNo, only outbound HTTPSYes

Use a health check for the API. Use a heartbeat for the backup that runs inside it.

To set one up, see the heartbeat docs, or create a free workspace and add your first heartbeat under Alerts → Detection → Heartbeats.

Metric monitors and anomaly alerts

Alert when any metric crosses a line or looks unusual for the time of day, with CPU per host from the OpenTelemetry Collector as a worked example.

Uptime monitoring: HTTP, TCP, DNS and ping checks

Check a URL, a page's text, a port, a DNS record or a ping on a schedule, page on-call only when locations agree, and get warned before a certificate or domain expires.

Alert on error logs and quiet services

Set up log-based alert rules that fire on error spikes or when a service goes quiet, send them to email or Slack, and keep them from paging for nothing.

Try it on your own logs

There is a free plan and no sales call. Install an SDK, send a few log lines, and search them.