# Cron Job Alerting: What to Alert On Without Alert Fatigue

By Dan Ochoa · Published 2026-10-25 · Updated 2026-10-31
Canonical: https://cronradar.com/blog/cron-job-alerting

> Which cron job failures deserve an alert, how to route alerts by severity and environment, who should own each job, and how to prevent alert fatigue.

**Alert on a cron job when it misses its schedule, fails, hangs, or finishes with a bad result, and send each alert to the person or rotation that owns the job, through a channel as urgent as the job is.** Page only for production jobs whose failure can't wait, send the rest to chat or email, and don't alert on problems that fix themselves, such as a run that's late but inside its grace period or an attempt a retry recovers. Every alert should also end cleanly: someone acknowledges it, a successful run resolves it, and a recovery message says to stop looking. CronRadar is the worked example; the practice applies to any monitor.

## What Deserves an Alert

A scheduled job goes wrong in five ways. Four need someone to act; the fifth is a change to the job list that CronRadar records without alerting.

| Failure mode   | What happened                                  | Detected by                            | CronRadar's response                                        |
| -------------- | ---------------------------------------------- | -------------------------------------- | ----------------------------------------------------------- |
| Missed or late | No check-in by the expected time plus grace    | The monitor's copy of the schedule     | `warning` inside the grace, then `critical` and an incident |
| Failed         | The job ran and reported failure               | A `fail` check-in                      | An incident at once, with no grace                          |
| Stuck          | It started and never finished                  | A `start` with no `complete` or `fail` | `critical` once the next expected time plus grace passes    |
| Bad outcome    | It exited 0 but did the wrong amount of work   | Values reported with `complete`        | An incident when an expectation is missing or violated      |
| Disappeared    | A discovered job left the scheduler's registry | The integration's reconciliation       | Monitor paused, marked no longer discovered; no alert       |

- **Missed runs are why the monitor lives outside the job.** A job that never starts can't report its own failure, so the monitor alerts on a missing check-in, the [dead man's switch](https://cronradar.com/blog/cron-dead-mans-switch) pattern. CronRadar's overdue check runs once a minute, and a monitor turns `critical` only when it's past its grace period and at least two minutes late.
- **Stuck detection needs a `start` check-in, and its window belongs to the job, not the run.** Every check-in moves the expected time to the next scheduled occurrence, so a hang is caught only when the job then goes silent, and there's no per-job maximum runtime. A timeout inside the job turns a hang into a `fail`, which alerts at once.
- **A bad outcome is the run that exits 0 after importing zero rows.** Report up to five numbers or booleans with `complete` and configure what a good run looks like; [silent success](https://cronradar.com/blog/scheduled-job-silent-success) covers choosing the checks.
- **Disappeared doesn't alert.** When a framework integration's registry reconciliation (every 15 minutes by default in CronRadar.Hangfire) no longer contains a discovered job, CronRadar pauses its monitor and keeps its history. A job deleted by mistake shows up only as a paused monitor, so review paused monitors after deploys that change job registration.

One gap remains: a job that never runs even once. A monitor created by its first check-in can't flag it, because it doesn't exist yet; a monitor created ahead of the job can, as the last FAQ below explains.

### What Shouldn't Page Anyone

Google's SRE book suggests asking of each alert rule, among other questions, "Can I take action in response to this alert? Is that action urgent, or could it wait until morning?", and names the cost of failing it: "When pages occur too frequently, employees second-guess, skim, or even ignore incoming alerts, sometimes even ignoring a 'real' page that's masked by the noise" ([Monitoring Distributed Systems](https://sre.google/sre-book/monitoring-distributed-systems/)). For scheduled jobs, these shouldn't wake anyone:

- **A run that's late but inside its grace period.** CronRadar shows it as `warning`, a status with no alert.
- **A failed attempt that a retry fixed.** Nothing is broken now. Track how many runs need a retry instead.
- **Staging and development failures.** They matter to whoever works there, not to production's on-call.
- **A failure that can wait for working hours,** such as a weekly report, or a [certificate renewal](https://cronradar.com/blog/certbot-renewal-monitoring) that runs weeks before expiry.

## Route by Criticality and Environment

The SRE book sorts alerts meant for people into tickets, email alerts, and pages. Scheduled jobs fit the same tiers:

| Tier   | Example jobs                                                | Destination                                | Who acts, and when              |
| ------ | ----------------------------------------------------------- | ------------------------------------------ | ------------------------------- |
| Page   | Production backup, billing run, a feed customers see hourly | PagerDuty, plus the owning team's channel  | The on-call responder, now      |
| Notify | Daily reports, search reindexing, cache warming             | Slack, Discord, or email                   | The owner, the same working day |
| Ticket | Archive pruning, weekly cleanup                             | A webhook to a handler that opens a ticket | The owner, in normal planning   |

The [CronRadar Scheduled Job Methodology's priority classes](https://cronradar.com/methodology) give the rule for each tier: Page is P1, Notify is P2, and Ticket is P3. Classify by what the failure costs, not by the fact that it's a cron job. PagerDuty's published incident response guide lists "Cron failure (not impacting event & notification pipeline)" under SEV-4, a "Low-Urgency page to service team" ([severity levels](https://response.pagerduty.com/before/severity_levels/)). A job that bills customers is another matter. [Monitoring billing, renewal, and email jobs](https://cronradar.com/blog/business-critical-job-monitoring) covers what those jobs should report and who to page.

### PagerDuty Decides Who Wakes Up

CronRadar has no on-call schedules, escalation policies, quiet hours, or SMS; PagerDuty is the hand-off for on-call. It has no Opsgenie or Jira Service Management channel either; [moving heartbeat checks off Opsgenie](https://cronradar.com/blog/opsgenie-heartbeat-migration) covers what changes before Opsgenie shuts down on April 5, 2027. A PagerDuty service can set its urgency "Based on support hours", so responders "won't be woken up for low-urgency incidents that can be handled in the morning", and low-urgency incidents don't escalate automatically ([service settings](https://docs.pagerduty.com/incident-management/services/configurable-service-settings), checked October 25, 2026). Urgency rules belong to the service, so a job that must page at 3 a.m. and one that can wait until 9 a.m. usually belong on different PagerDuty services, or the second doesn't belong on PagerDuty.

CronRadar connects channels per application, in its **Alerts** tab, or once for the workspace when the workspace manages notifications. Jobs that need a different Slack channel or PagerDuty service from the rest of their application go in an application of their own.

### One Application per Environment, Defaults per Application

Use one CronRadar application and API key per environment. The key decides which application a check-in lands in, so staging alerts reach only the staging application's channels: connect PagerDuty to production only and nothing in staging can page anyone. If the workspace manages notifications for every application, they share its channels, so leave PagerDuty out of the staging application's default alert destinations.

Each monitor's settings hold **Owner or team**, **Severity**, **Environment**, **Alert destinations** (any of email, Slack, webhook, Discord, and PagerDuty), **Logs URL**, **Runbook URL**, and **Repository URL**. Owner, severity, and destinations left blank inherit the application's default, then the workspace's. Set the common case on the application, such as Slack and email for production, and override only the jobs that differ: the backup monitor's destinations become PagerDuty and Slack.

The owner appears in email, Slack, Discord, PagerDuty, and webhook alerts, and severity in all of them except email, so severity informs the responder; the destinations decide where an alert goes. For email, each member also chooses which applications reach their inbox under **Your alert emails**, so confirm the intended recipient has them on.

## Give Every Job an Owner

An owner is the person, or on-call rotation, who decides what happens when the job fails and can change its code or configuration: usually the team that wrote it, not whoever set up the monitoring. A [scheduled job inventory](https://cronradar.com/blog/scheduled-job-inventory) records each job's owner beside its schedule.

"The team" is not an owner. An alert addressed to a whole team asks everyone and nobody in particular, and each reader can reasonably assume someone closer to the job has it. Naming a person or rotation settles who acts before the alert fires. A team name works only when it maps to a rotation on call for the job, such as a PagerDuty schedule.

The SRE book's rule is "Every page should be actionable": the alert should name who acts and link to what they do. In CronRadar, **Owner or team** appears in alerts on every channel, and an application can set a default owner and runbook URL. Runbook, logs, and repository URLs are included in webhook payloads and PagerDuty custom details; CronRadar links to them without collecting their content.

## The Alert Lifecycle: Alert, Reminder, Acknowledgment, Recovery

For an unresolved incident, CronRadar sends:

1. **The first alert** when the incident opens: a missed or stuck run, a `fail` check-in, a bad outcome (an outcome-failure alert), or a first report that never came (`NeverExecuted`). Most are `InitialFailure` alerts.
2. **At most one reminder every 24 hours** (`ContinuedFailure`) while the incident is unresolved and nobody has acknowledged it.
3. **A recovery message** (`Recovered`) when a successful run resolves it.

The reminder limit applies per incident. After a recovery, the next failure opens a new incident with its own first alert.

### Acknowledging Is Not Fixing

Acknowledging says "I have this." In CronRadar, acknowledging the incident in the console stops further reminders, but it doesn't stop monitoring or prevent recovery from being recorded: the monitor stays `critical` until a successful run resolves the incident and sends the recovery message. PagerDuty draws the same line: "Resolving an incident closes the incident, while acknowledging only halts escalation" ([incidents](https://docs.pagerduty.com/incident-management/incidents/overview)).

So there are three actions for three situations:

- **Acknowledge** a failure you're working on. Detection keeps running, so the recovery is recorded when your fix works.
- **Pause** a monitor for planned downtime, when no runs are expected. It shows `paused` and isn't monitored.
- **Fix** the job. The next successful run resolves the incident.

CronRadar's reminders stop on an acknowledgment in its console; if the alert also went to PagerDuty, acknowledge it there too. Opening the read-only summary link in an incident email acknowledges nothing.

## Why Cron Alerts Turn Into Noise

Staging alerts in production's channels are one cause of noise, fixed by the separate applications above. Four more have fixes of their own.

### Grace Periods Shorter Than Normal Lateness

A grace period shorter than the latest a healthy run checks in turns normal lateness into incidents, each followed by a recovery when the late check-in arrives. Measure the start delay, plus run time if the job checks in only on completion, and set the grace above it; [choosing a grace period](https://cronradar.com/blog/cron-grace-period) works through the numbers. A `gracePeriod` sent on a check-in applies only when that check-in creates the monitor, so change an existing monitor's grace in its settings.

### A Failure Alert on Every Retry Attempt

Send `fail` only when the last attempt fails, and tag each check-in with its attempt number so retries stay visible. There's no CronRadar SDK for crontab and no auto-discovery, so each cron job reports with its own HTTP check-in. This wrapper gives an hourly sync up to three attempts:

```bash
#!/bin/sh
# /opt/sync/sync-monitored.sh: up to three attempts; alert only if the last one fails.
url="https://cron.life/api/v1/ping/partner-sync"
run_id="partner-sync-$(date +%s)-$$" # one ID shared by every attempt of this run
max_attempts=3

report() {
  event=$1
  shift
  curl --fail --silent --show-error --max-time 5 --output /dev/null \
    --request POST --get --user "$CRONRADAR_API_KEY:" \
    --data-urlencode "schedule=0 * * * *" \
    --data-urlencode "gracePeriod=600" \
    --data-urlencode "run_id=$run_id" \
    --data-urlencode "attempt=$attempt" \
    "$@" "$url/$event" || true
}

attempt=0 # zero-based, as CronRadar expects
while :; do
  report start
  timeout 10m /opt/sync/sync.sh
  rc=$?
  if [ "$rc" -eq 0 ]; then
    report complete
    exit 0
  fi
  # Retry only "try again" (75) and timeouts (124); any other status is final.
  if [ $((attempt + 1)) -ge "$max_attempts" ] || { [ "$rc" -ne 75 ] && [ "$rc" -ne 124 ]; }; then
    report fail --data-urlencode "message=sync.sh exited $rc on attempt $((attempt + 1)) of $max_attempts"
    exit "$rc"
  fi
  sleep $((60 * (attempt + 1)))
  attempt=$((attempt + 1))
done
```

Each report gives up after five seconds and ignores errors, and the script exits with the sync's own status. GNU `timeout` exits 124 when it stops a hung attempt ([timeout(1)](https://man7.org/linux/man-pages/man1/timeout.1.html), coreutils 9.11). Three 10-minute attempts plus three minutes of waiting end well inside the hour, before stuck detection could flag the run. Run it from cron with `CRONRADAR_API_KEY` in its environment; every report carries the schedule, so the first run creates the monitor. [Retrying failed cron jobs](https://cronradar.com/blog/cron-job-retries) adds jitter, a time budget, and the case for alerting on every attempt.

When the scheduler does the retrying, each attempt is a separate process that can't know another will follow, so each failed attempt reports `fail`; to alert only on the final failure, move the retries into the job. For .NET and Hangfire:

- **Hangfire:** the published `CronRadar.Hangfire` 0.1.0 (checked September 20, 2026) can report a failure before Hangfire finishes retrying, including cancellations, so an alert can arrive for a job that later succeeds. Reporting only after retries are exhausted requires the pending 0.2.1 release. [Hangfire automatic retry](https://cronradar.com/blog/hangfire-automatic-retry) covers the retry settings.
- **.NET with its own retry policy:** put `CronRadar.CronRadar.WrapAsync` (the `CronRadar` NuGet package, 1.0.13; the class shares its namespace's name, so qualify it fully) around the whole policy, not inside it. It then records `fail` only when the policy gives up, and rethrows the exception unchanged. Version 1.0.13 doesn't send attempt numbers.

### Cron Mail Next to a Monitor

cronie (Fedora, RHEL, Arch) and Debian's cron (Debian, Ubuntu) mail "any output" a job writes to the crontab's owner, or to `MAILTO` when set ([cronie cron(8)](https://man7.org/linux/man-pages/man8/cron.8.html), [Debian cron(8)](https://manpages.debian.org/trixie/cron/cron.8.en.html), 3.0pl1-197). The mail follows output, not failure, so next to a monitor it adds a second message for every failure that writes to stderr, and mail from successful runs that print progress. The SRE book asks: "Are other people getting paged for this issue, therefore rendering at least one of the pages unnecessary?"

Pick one path per job. Above monitored entries, `MAILTO=""` sends no mail in either cron ([cronie crontab(5)](https://man7.org/linux/man-pages/man5/crontab.5.html), [Debian crontab(5)](https://manpages.debian.org/trixie/cron/crontab.5.en.html)); append the output to a log for whoever investigates, and put a link to its log search in the monitor's **Logs URL**:

```text
MAILTO=""
0 * * * * /opt/sync/sync-monitored.sh >> /var/log/partner-sync.log 2>&1
```

### Every-Minute Jobs That Fail On and Off

A job that runs every minute and fails a few times a day produces an alert and a recovery each time it reports `fail`, because every failure after a recovery is a new incident. If one failed run doesn't matter but several minutes without a success does, drop the `fail` report and send a heartbeat only on success:

```text
* * * * * /opt/queue/drain.sh && curl -fsS -m 5 -o /dev/null --user "$CRONRADAR_API_KEY:" https://cron.life/api/v1/ping/queue-drain
```

This line doesn't carry the schedule, so create the monitor in the dashboard with `* * * * *` and a 300-second grace period. Each success moves the expected time to the next minute, so an incident opens only after no run has succeeded for that minute plus the grace, about six minutes. The cost: no failure message, and a heartbeat-only monitor can't measure duration or detect stuck runs.

### Muted Channels During Maintenance

Muting a Slack channel or disabling a PagerDuty integration for a migration silences every job on that route, including those the maintenance doesn't touch, until someone remembers to undo it. Pause the affected monitors instead: they show `paused` while the rest keep alerting, and a timed maintenance pause resumes monitoring on its own.

## Test the Path End to End

Two tests answer two questions:

- **A channel test** proves a destination receives CronRadar's messages, not that detection works; CronRadar's docs note that Slack's test "does not prove that a scheduled-job failure was detected."
- **A real alert** proves detection, delivery, and recovery. Use a harmless job in a separate test application with an agreed test destination, never a deliberately failed production job: send a `fail` check-in, confirm the alert, then send a successful check-in and confirm the recovery message.

Treat delivery as unverified until the intended recipient confirms it, and retest after changing routes or recipients. After the first real incident, confirm the alert reached the owner it names and that the next successful run recorded recovery and returned the monitor to `healthy`. The [verification guide](https://docs.cronradar.com/verify) has the full steps.

## Cron Alerting Checklist

- Every job reports to a monitor that knows its schedule; where a first run matters, the monitor exists before the job deploys.
- Grace periods come from measured lateness, and `fail` is sent only after the last retry.
- Jobs that can succeed while doing nothing report outcome values.
- Each environment has its own application; only production connects PagerDuty.
- Each monitor names an owner who can fix it, and paging jobs have a runbook URL.
- Only jobs that can't wait for morning route to PagerDuty.
- Monitored crontab entries set `MAILTO=""` and log their output.
- Maintenance pauses monitors, not channels, and paused monitors are reviewed after deploys.
- The recipient has confirmed a channel test and a test-application alert with its recovery.

## Frequently Asked Questions

### How do I get alerted when a cron job fails?

Have the job report to a monitor outside the server: `start` before the work, `complete` after it, and `fail` with the exit code when it fails, while keeping the job's own exit status. Give the monitor the schedule so it also alerts when a run never arrives. Cron itself only mails output, which misses a job that fails quietly or never starts.

### How do I avoid alert fatigue from cron monitoring?

Remove alerts nobody needs to act on: size grace periods from measured lateness, report `fail` only after the last retry, keep staging in its own application, stop cron mail for monitored jobs, and let frequent jobs alert on a stretch without success.

### Should cron failures page someone at night?

Only when waiting until morning costs something real, such as a failed production backup or billing run. PagerDuty's own incident response guide treats a cron failure that doesn't affect customers as a low-urgency page. Send the rest to Slack, Discord, or email, and let PagerDuty's urgency rules decide who wakes up for the jobs that do page.

### What is the difference between acknowledging and resolving a cron alert?

Acknowledging says someone is working on it; resolving means the job works again. In CronRadar, acknowledgment stops the daily reminders, and the incident stays open until a successful run resolves it and sends a recovery message. Monitoring continues the whole time.

### How do I alert on a cron job that never ran?

Create the monitor before the job's first run, so it waits in `pending`; if the first expected run plus the grace period passes without a report, CronRadar sends a never-executed alert. For HTTP check-ins, create it in the dashboard with the job's schedule before deploying. The Hangfire and Quartz.NET 3.x integrations create monitors when they discover jobs at startup. A monitor that a check-in creates can't catch this, since it doesn't exist until the job runs.
