Monitor Rails Cron Jobs Built with Whenever
Updated
The whenever gem turns config/schedule.rb into crontab entries. That works well until a deploy forgets to run
whenever --update-crontab, a server is rebuilt without the crontab, or a rake task starts raising and its output
goes to a log nobody reads. Cron doesn't tell you any of this.
A heartbeat check fixes that. Each job pings Cuckoo when it finishes. If the ping doesn't arrive on time, or the job reports a failure, you get an alert.
How Cuckoo sees a whenever job
Create one check per job. Each check has its own URL:
https://cuckoo.metastic.ch/ping/YOUR-UUID/startwhen the job startshttps://cuckoo.metastic.ch/ping/YOUR-UUIDon successhttps://cuckoo.metastic.ch/ping/YOUR-UUID/failon failure, or/ping/YOUR-UUID/<exit code>, where0means success and anything else means failure
Set the check's schedule to the same cron expression that whenever writes. whenever (without arguments) prints
the generated crontab so you can copy it.
Option 1: A monitored job_type with curl
job_type defines a command template. Any :word in the template is replaced with an option passed to the job,
so you can pass the ping URL per job. This template sends /start, runs the rake task, and reports its exit code:
# config/schedule.rb
set :output, "log/cron.log"
job_type :monitored_rake,
"cd :path && curl -fsS -m 10 --retry 5 -o /dev/null :ping/start; " \
":environment_variable=:environment :bundle_command rake :task --silent :output; " \
"curl -fsS -m 10 --retry 5 -o /dev/null :ping/$?"
every 1.day, at: "3:00 am" do
monitored_rake "reports:daily", ping: "https://cuckoo.metastic.ch/ping/YOUR-UUID"
end
every :hour do
monitored_rake "feeds:import", ping: "https://cuckoo.metastic.ch/ping/YOUR-UUID"
end
The resulting crontab line runs through whenever's default /bin/bash -l -c wrapper, so $? holds the rake exit
code at the moment the last curl runs. The ; separators make sure a failed start ping never prevents the
task from running. A rake task that raises exits non-zero, which Cuckoo records as a failure.
For runner jobs, define a second template the same way:
job_type :monitored_runner,
"cd :path && curl -fsS -m 10 --retry 5 -o /dev/null :ping/start; " \
"bin/rails runner -e :environment ':task' :output; " \
"curl -fsS -m 10 --retry 5 -o /dev/null :ping/$?"
every :sunday, at: "4:30 am" do
monitored_runner "Cleanup.run!", ping: "https://cuckoo.metastic.ch/ping/YOUR-UUID"
end
Option 2: Ping from Ruby with Net::HTTP
If you prefer to keep the crontab simple, ping from inside the task with the standard library. The helper has short timeouts and swallows network errors:
# lib/heartbeat.rb
require "net/http"
module Cuckoo
def self.ping(url, suffix = "", body: nil)
uri = URI(url + suffix)
Net::HTTP.start(uri.host, uri.port, use_ssl: true, open_timeout: 5, read_timeout: 10) do |http|
body ? http.post(uri.path, body.byteslice(0, 10_000)) : http.get(uri.path)
end
rescue StandardError => e
warn "heartbeat ping failed: #{e.class}: #{e.message}"
end
def self.monitor(url)
ping(url, "/start")
result = yield
ping(url)
result
rescue Exception => e
ping(url, "/fail", body: "#{e.class}: #{e.message}\n#{e.backtrace&.first(20)&.join("\n")}")
raise
end
end
# lib/tasks/reports.rake
require Rails.root.join("lib/heartbeat")
namespace :reports do
task daily: :environment do
Cuckoo.monitor(ENV.fetch("CUCKOO_REPORTS_DAILY")) do
DailyReport.generate!
end
end
end
CUCKOO_REPORTS_DAILY holds https://cuckoo.metastic.ch/ping/YOUR-UUID. Keeping it in the environment
means staging and production report to different checks. Re-raising keeps the non-zero exit code and your error
tracker's behaviour unchanged.
Schedule and grace advice
- whenever writes times in the server's time zone unless you configure otherwise. In Cuckoo, use the same cron
expression and the server's IANA time zone (for example
Europe/Zurich). - Rails boot time counts. A rake task that takes 2 minutes plus 20 seconds of boot needs more grace than you think. Start with 15 to 30 minutes for daily jobs.
- Set max runtime for jobs that might hang on a lock or an external API. The
/startping makes this possible.
Troubleshooting
- No pings after a deploy: check that the deploy runs
whenever --update-crontab(Capistrano:require "whenever/capistrano"), and runcrontab -las the deploy user. - Template not substituted: option names must be plain words (
ping, notping-url), because whenever replaces:wordtokens. - Every run reported as failed: the task exits non-zero even on success. Run the generated command by hand and
check
echo $?. curlnot found: cron'sPATHis minimal. The login shell wrapper usually fixes this, otherwise use/usr/bin/curl.
FAQ
Is curl or Net::HTTP the better choice?
curl in the template also catches failures before Ruby starts, such as a broken bundle or a missing gem, because the exit code is reported from the shell. Net::HTTP keeps the crontab clean and lets you attach exception details.
Will a Cuckoo outage break my rake task?
No. With curl the commands are joined with ; rather than &&, and the Ruby helper rescues all network errors.
In both cases the task runs whether or not the ping gets through.
Can I monitor several jobs with one check?
You can, but you shouldn't. One check per job gives each job its own deadline and tells you which job failed.
Related guides
- Monitor Django and Celery Beat Periodic TasksMonitor Celery beat periodic tasks and Django management commands with start, success and fail pings, and get alerted when beat or the worker stops.
- Monitor GoodJob Cron Jobs with CuckooMonitor GoodJob cron jobs in Rails: configure enable_cron and cron entries, then ping Cuckoo with an ActiveJob around_perform callback on each run.
- Monitor sidekiq-cron Jobs with CuckooMonitor sidekiq-cron scheduled jobs: ping Cuckoo from perform, report failures with rescue, and decide how Sidekiq retries map to fail alerts.
- Monitor the Laravel Scheduler with CuckooMonitor Laravel scheduled tasks with pingBefore, pingOnSuccess and pingOnFailure, plus a check for schedule:run itself. Works on Laravel 10 to 13.