How-to
Automating Reporting WorkflowsDeep dive

Detect a Scheduled Report That Did Not Run

Catch the failure that error handling cannot: a report job that never started. Check each report's output against its schedule — the last time it should have run plus a grace period — and send heartbeat pings to an external monitor that alerts when a ping is late, with start, success and failure signals and a freshness sheet for humans.

Most error handling assumes the job runs. Retries cover a flaky database, alerts cover an exception, logs cover everything in between — and none of it fires when the job never starts. The server was rebuilt and the crontab was not restored. The Kubernetes CronJob was suspended during an incident and never resumed. The laptop that ran the report was replaced. The Monday report simply does not appear, and the first alert is a colleague asking where it is. Detecting that needs a check that lives outside the job: either something that looks at the job's output and compares it with the schedule, or an external monitor the job has to check in with. This page, part of Error Handling and Logging in Excel Automation, builds both.

Prerequisites

Bash
pip install croniter pandas openpyxl requests

The share/ folder holds the output of three scheduled reports: a weekly sales report due every Monday at 07:00, a daily stock report due at 06:30 on weekdays, and a month-end pack due at 08:00 on the 1st of each month. The checks run at 10:00 on Tuesday 29 September 2026. For the heartbeat examples, a tiny local HTTP server stands in for the external monitoring service, so the requests the job sends can be inspected.

Two ways to notice a missing run Two panels. A freshness check looks at each report's output and compares its age with the schedule, and needs no change to the jobs. A heartbeat has each job ping an external monitor, which alerts when a ping is late, and catches runs that start but hang. Freshness check no change to the jobs checks what readers actually get needs one monitoring job of its own Heartbeat ping monitor lives outside your servers start and fail signals too each job must send the pings

Step 1 — When should each report have last run?

The key calculation is the previous scheduled time: at 10:00 on Tuesday, the weekly report was last due at 07:00 on Monday. croniter computes it from the same cron expression the scheduler uses:

Python
from datetime import datetime, timedelta
from croniter import croniter

REPORTS = {
    "weekly_sales.xlsx":   {"cron": "0 7 * * 1",   "grace": timedelta(hours=2)},
    "daily_stock.xlsx":    {"cron": "30 6 * * 1-5", "grace": timedelta(hours=1)},
    "month_end_pack.xlsx": {"cron": "0 8 1 * *",   "grace": timedelta(days=3)},
}
check_time = datetime(2026, 9, 29, 10, 0)

for name, spec in REPORTS.items():
    due = croniter(spec["cron"], check_time).get_prev(datetime)
    print(f"{name:20} last due {due:%a %d %b %H:%M}, overdue after {due + spec['grace']:%a %d %b %H:%M}")

The grace period is how late a report may be before it counts as missing: the weekly report normally lands within minutes, so two hours is generous; the month-end pack depends on people closing the books, so it gets three days. Keeping the schedule in the same form as the scheduler's — ideally read from the same configuration — avoids the check and the job drifting apart.

Step 2 — Compare each output with its schedule

A report is fresh if its output was written after it was last due. If it is older and the grace period has passed, the run was missed:

Python
from pathlib import Path

def freshness(folder, reports, at):
    rows = []
    for name, spec in reports.items():
        due = croniter(spec["cron"], at).get_prev(datetime)
        path = Path(folder) / name
        written = datetime.fromtimestamp(path.stat().st_mtime) if path.exists() else None
        if written and written >= due:
            status = "fresh"
        elif at < due + spec["grace"]:
            status = "due — within grace"
        else:
            status = "MISSED"
        rows.append({"report": name, "last_due": due, "written": written, "status": status})
    return pd.DataFrame(rows)

status = freshness("share", REPORTS, check_time)
print(status.to_string(index=False))

The weekly report was written on Monday at 07:04, after it was due — fresh. The daily stock report was last written on Friday; it was due again at 06:30 today and the hour of grace has passed, so today's run was missed, and yesterday's must have been too. The month-end pack from 1 September is fine until its next due date on 1 October. Using the file's modification time works for files on a share; for cloud storage, use the object's update time, and for emailed reports, the send time the job logs.

The daily stock report, as the check sees it A timeline. The last output was written on Friday 25 September at 06:33. The report was due on Monday at 06:30 and on Tuesday at 06:30; the grace period for Tuesday ended at 07:30, so at 10:00 the check reports it as missed. Fri 06:33 last output written Mon 06:30 due — nothing arrived Tue 07:30 grace period ends Tue 10:00 check: MISSED

Step 3 — Run the check somewhere else, and alert

The check must not run on the same machine, in the same crontab or the same cluster namespace as the reports — otherwise the event that stops the reports stops the check too. Run it from a different scheduler, and send an alert only for missed reports:

Python
def alert_text(status_table):
    missed = status_table[status_table["status"] == "MISSED"]
    if missed.empty:
        return None
    lines = [f"- {r.report}: last due {r.last_due:%a %d %b %H:%M}, last written "
             f"{r.written:%a %d %b %H:%M}" if r.written else f"- {r.report}: never written"
             for r in missed.itertuples()]
    return "Scheduled reports missing:\n" + "\n".join(lines)

print(alert_text(status))

The message names each missing report, when it was due and when it last appeared — enough for whoever receives it to know where to look. Sending it uses whatever channel the team watches; Send an Alert When an Excel Report Job Fails covers email, Slack and Teams.

Step 4 — Heartbeats to an external monitor

The second approach inverts the check: each job pings a monitoring service when it starts and when it finishes, and the service — configured with the job's schedule and grace — alerts when a ping does not arrive. Hosted services such as Healthchecks.io, Cronitor and Better Stack work this way, and Healthchecks is also open source for self-hosting. The job side is a few HTTP calls, sent in a way that can never break the report itself:

Python
import requests

def ping(base_url, signal="", timeout=5):
    try:
        requests.get(f"{base_url}{'/' + signal if signal else ''}", timeout=timeout)
    except requests.RequestException:
        pass                                # monitoring must never fail the job

def run_with_heartbeat(base_url, job):
    ping(base_url, "start")
    try:
        job()
    except Exception:
        ping(base_url, "fail")
        raise
    ping(base_url)

The start signal lets the monitor measure run time and notice a job that started but hung; the bare ping marks success; fail reports a failure at once instead of waiting for the grace period. Each report gets its own check URL, which acts as a secret — anyone who has it can report success — so keep it in the job's configuration, not in the code.

Python
run_with_heartbeat(MONITOR, lambda: None)
try:
    run_with_heartbeat(MONITOR, lambda: 1 / 0)
except ZeroDivisionError:
    pass
ping("http://127.0.0.1:9/unreachable")       # monitor down: the job carries on
print(received)

A successful run sends start and then the success ping; a failing one sends start and fail, and the exception still propagates, so the scheduler records the failure too. With the monitoring service unreachable, ping gives up quietly after its timeout — a report must not fail because the thing watching it is down.

Heartbeats from one run A sequence diagram. The report job pings the monitor with start, builds the workbook, and pings success; if no success ping arrives within the schedule plus grace, the monitor alerts the team. report job monitor team 1. GET …/start 2. GET … (success) 3. alert only if a ping is late or fail arrives

Step 5 — A freshness sheet for humans

The same table is worth publishing where people look: a small workbook or a sheet in the reporting folder listing every scheduled report, when it last arrived and whether it is current. It answers "is Monday's report the latest?" without asking anyone:

Python
out = status.assign(last_due=status["last_due"].dt.strftime("%a %d %b %H:%M"),
                    written=status["written"].dt.strftime("%a %d %b %H:%M"))
out.columns = ["Report", "Last due", "Last written", "Status"]
with pd.ExcelWriter("share/report_freshness.xlsx", engine="xlsxwriter") as xw:
    out.to_excel(xw, sheet_name="Freshness", index=False)
    red = xw.book.add_format({"bg_color": "#FCE9E9", "font_color": "#9C0006", "bold": True})
    xw.sheets["Freshness"].conditional_format(1, 3, len(out), 3, {"type": "text", "criteria": "containing",
                                                                   "value": "MISSED", "format": red})
    xw.sheets["Freshness"].set_column("A:D", 22)
print(out.to_string(index=False))

Common pitfalls

  • Monitoring from the same place as the job. Whatever stops the job stops the check; run it elsewhere.
  • No grace period. Reports finish a few minutes after their start time; alerting at the scheduled minute produces noise.
  • Heartbeat failures that fail the job. Wrap pings so an unreachable monitor is ignored.
  • Only checking for errors. A job that never starts raises no error; check for presence.
  • Schedules defined twice. Read the cron expression from the same configuration as the scheduler, so a changed schedule does not trigger false alarms.

Conclusion

For every scheduled report, compute when it was last due with croniter(...).get_prev(), compare that with when its output was last written, and flag it as missed once a grace period has passed — from a check that runs somewhere other than the reports. Add heartbeat pings to an external monitor, with start, success and fail signals sent so that a monitoring outage can never break the report, and publish the freshness table where the reports' readers can see it.

Frequently asked questions

How do I know if a cron job did not run? Error handling inside the job cannot tell you, because a job that never starts never reports. Check its output against the schedule from a separate process, or have the job ping an external monitor that alerts when a ping is late.

What is a dead man's switch for scheduled jobs? A monitor that expects a regular signal from a job and raises an alert when the signal does not arrive in time. The job pings on each successful run; silence means something is wrong.

How do I compute when a cron job last should have run in Python? Use croniter with the job's cron expression and the current time and call get_prev(datetime). Add a grace period before treating the run as missed.

Should heartbeat pings be able to fail my report? No. Wrap them in a try/except with a short timeout so that an unreachable monitoring service is ignored and the report carries on.

More on logging, testing & packaging

More on logging, testing & packaging →