Monitor GoodJob Cron Jobs with Cuckoo
Updated
GoodJob can enqueue recurring jobs itself, so you don't need a crontab. That also means the schedule only runs
while a GoodJob process with cron enabled is up. If enable_cron is off in production, the worker container is
scaled to zero, or the job class was renamed and the cron entry wasn't, nothing runs and nothing complains. The
GoodJob dashboard shows executions that happened. It can't show the ones that didn't.
A heartbeat check fills that gap. Each run reports to Cuckoo. A late or failed report triggers an alert.
How Cuckoo sees a GoodJob cron entry
Create one check per cron entry, with the same cron expression and time zone. Each check has a URL:
https://cuckoo.metastic.ch/ping/YOUR-UUID/startwhenperformstartshttps://cuckoo.metastic.ch/ping/YOUR-UUIDwhen it succeedshttps://cuckoo.metastic.ch/ping/YOUR-UUID/failwhen it raises (POST body up to 10 KB stored)
Step 1: Configure GoodJob cron
# config/application.rb or config/environments/production.rb
config.good_job.enable_cron = true
config.good_job.cron = {
nightly_export: {
cron: "0 3 * * * Europe/Zurich", # fugit syntax, time zone optional
class: "NightlyExportJob",
description: "Export orders to the warehouse"
},
hourly_sync: {
cron: "every hour",
class: "InventorySyncJob"
}
}
enable_cron defaults to false. Cron runs inside GoodJob processes: the good_job start CLI, or the web
process in :async mode. Enabling it on several processes is safe, because GoodJob uses a unique index so that
each run is enqueued only once. For that to work, GoodJob has to keep cron-created job records.
Step 2: A ping helper
# app/lib/heartbeat.rb
require "net/http"
module Cuckoo
def self.ping(url, suffix = "", body: nil)
return if url.blank?
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
Rails.logger.warn("heartbeat ping failed: #{e.message}")
end
end
Step 3: An around_perform concern
ActiveJob callbacks keep the monitoring out of each job's perform. A concern adds the pings to any job that
declares a ping URL:
# app/jobs/concerns/heartbeat_monitored.rb
module CuckooMonitored
extend ActiveSupport::Concern
included do
class_attribute :heartbeat_url, instance_writer: false
around_perform do |job, block|
url = job.class.heartbeat_url
Cuckoo.ping(url, "/start")
begin
block.call
Cuckoo.ping(url)
rescue Exception => e
Cuckoo.ping(url, "/fail", body: "#{e.class}: #{e.message}\n#{Array(e.backtrace).first(20).join("\n")}")
raise
end
end
end
end
class NightlyExportJob < ApplicationJob
include CuckooMonitored
self.heartbeat_url = ENV["CUCKOO_NIGHTLY_EXPORT"] # https://cuckoo.metastic.ch/ping/YOUR-UUID
def perform
WarehouseExport.run!
end
end
The exception is re-raised, so GoodJob records the error and applies your retry rules as usual.
Retries and discard rules
ActiveJob handles retry_on and discard_on outside the perform callbacks, so around_perform sees every
exception first. That gives you these defaults:
- Every failed attempt sends
/fail, including attempts thatretry_onwill retry. When a retry succeeds, the success ping follows and Cuckoo sends a recovery message. discard_onerrors also send/failbefore the job is dropped, so a discarded cron run is still visible.
If you only want an alert once retries are used up, take the rescue branch out of the concern and report from
the block that retry_on calls when attempts are exhausted:
class NightlyExportJob < ApplicationJob
retry_on Net::ReadTimeout, wait: 30.seconds, attempts: 3 do |job, error|
Cuckoo.ping(job.class.heartbeat_url, "/fail", body: "#{error.class}: #{error.message}")
end
end
Any other failure still shows up as a missing success ping once the grace time runs out.
Schedule and grace advice
- Use the same cron expression and time zone in Cuckoo as in
config.good_job.cron. For fugit shorthands likeevery hour, use a simple 1-hour period. - Grace time should cover queue wait plus runtime. Give important cron jobs a dedicated queue, or a higher
priority via the entry's
set:option. - Set max runtime for jobs that call slow external APIs. The
/startping makes this possible.
Troubleshooting
- Nothing runs: confirm
GoodJob.configuration.enable_cron?in a production console, and check that a GoodJob process is actually running (good_job startor:async). - Cron tab empty in the dashboard: the
cronhash didn't load in that environment. Configure it where all environments that run cron will read it. - Pings from development: leave the
CUCKOO_*variables unset locally. The helper skips blank URLs.
FAQ
Does this work with other ActiveJob backends?
Yes. The around_perform concern is plain ActiveJob, so it works the same with Solid Queue, Sidekiq or any other
adapter. Only the cron configuration in step 1 is specific to GoodJob.
Can I check that the GoodJob cron process itself is alive?
Yes. Add a cron entry every five minutes that enqueues a tiny monitored job, and give its check a 5-minute period and grace. If cron stops in every process, that check goes down first.
Why not just watch the GoodJob dashboard?
The dashboard shows jobs that exist. A heartbeat check also catches jobs that were never enqueued, and it alerts you without anyone having to look.
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 Rails Cron Jobs Built with WheneverAdd heartbeat monitoring to Rails cron jobs from the whenever gem: a custom job_type with curl, or Net::HTTP pings inside rake and runner tasks.
- 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.