Cuckoo

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/start when the job starts
  • https://cuckoo.metastic.ch/ping/YOUR-UUID on success
  • https://cuckoo.metastic.ch/ping/YOUR-UUID/fail on failure, or /ping/YOUR-UUID/<exit code>, where 0 means 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 /start ping makes this possible.

Troubleshooting

  • No pings after a deploy: check that the deploy runs whenever --update-crontab (Capistrano: require "whenever/capistrano"), and run crontab -l as the deploy user.
  • Template not substituted: option names must be plain words (ping, not ping-url), because whenever replaces :word tokens.
  • Every run reported as failed: the task exits non-zero even on success. Run the generated command by hand and check echo $?.
  • curl not found: cron's PATH is 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

All guides · Compare Cuckoo with other tools

Send feedback

Ideas, bugs, questions: we read every message. Or write to hello@metastic.ch.