How to Monitor Cron Jobs in Python
A try/finally and an explicit exit code cover most of it. The virtualenv is what breaks first.
Monitoring a Python cron script is mostly a matter of putting the ping where it cannot be skipped and making the environment under cron match the environment you tested in.
The second part causes more incidents than the first.
A decorator keeps it out of the way
If you have more than two or three scripts, wrapping the entry point is cleaner than repeating try/except in each one.
Catching BaseException rather than Exception is deliberate here. It includes KeyboardInterrupt and SystemExit, which is what you get when a supervisor sends SIGINT during a deploy. Those are real interruptions and you want them reported.
Raising on a zero-row result turns an empty success into a loud failure. This is the single highest-value line in most Python cron scripts, because a job that runs perfectly and does nothing is the hardest failure to notice.
The crontab entry
Call the interpreter inside the virtualenv directly. Do not try to activate it: activation is a shell function, and cron runs the command with /bin/sh and no profile loaded.
Set the working directory with cd, or use -m with the package path. Relative imports and relative file paths both resolve against the working directory, and cron's default is the user's home.
If your job reads configuration from environment variables, cron will not have them. Either set them at the top of the crontab file, source an env file explicitly in the command, or read from a config file with an absolute path.
Timeouts, because a hung job pings nothing and alerts nothing
A script blocked on a network call with no timeout will sit there indefinitely. It never completes, so it never pings; it never crashes, so it never reports failure. Your monitor eventually alerts on the missing heartbeat, which is correct but slow.
With the decorator above, the SIGTERM raises SystemExit inside Python, the except branch catches it, and you get an explicit failure ping saying the job was killed rather than a silent gap. Always set the interpreter's own socket timeouts as well; requests defaults to no timeout, which surprises people.
The order matters: timeout sends SIGTERM first and waits 30 seconds before SIGKILL. That window is what gives your except branch time to fire the failure ping before the process is destroyed outright.
APScheduler is a different problem
If you use APScheduler rather than system cron, you are running an in-process scheduler and it shares the fate of the process. A crash takes every job with it and there is no daemon to restart them.
APScheduler also has misfire_grace_time, which defaults to one second. A job whose scheduled moment passes while the process is busy is skipped entirely and logged as a misfire, which most people never see.
Related guides
Django projects should see monitoring cron jobs in Django, which covers management commands and Celery Beat.