How to Monitor Rails Cron Jobs (With Alerts)
Rails apps typically run scheduled tasks through the Whenever gem, Sidekiq-cron, Good Job, or plain rake tasks called from crontab. All of these fail silently. Your nightly invoice job stopped working two weeks ago? You won't know until accounting asks where the invoices are.
This guide covers adding heartbeat monitoring to Rails scheduled tasks so you find out about failures in minutes, not weeks.
This guide covers Rails 6.1+ and Ruby 3.x. Sidekiq examples use Sidekiq 6.3+.
The Problem With Rescue Blocks
Most Rails background jobs have error handling like this:
class BackupJob < ApplicationJob
def perform
BackupService.run!
rescue => e
Rails.logger.error("Backup failed: #{e.message}")
Sentry.capture_exception(e) # or whatever error tracker
end
end
This catches exceptions. It doesn't catch jobs that never run. If your crontab entry gets deleted, or the server reboots without starting Sidekiq, or Good Job's scheduler stalls, there's no exception to rescue. Nothing happens.
The Solution: Heartbeat Monitoring
Alert on the absence of success instead of the presence of failure.
After your job completes successfully, ping an external URL. If that ping doesn't arrive on schedule, you get alerted. This catches everything: exceptions, crashed processes, misconfigured cron, OOM kills.
Monitoring Whenever Gem Tasks
Whenever is the most common way to manage crontab in Rails apps.
Basic Setup
Add the ping directly in your schedule.rb:
# config/schedule.rb
# Set environment for all jobs
set :environment, ENV['RAILS_ENV'] || 'production'
every 1.day, at: '2:00 am' do
rake 'db:backup'
command 'curl -fsS https://api.cronsignal.io/ping/YOUR_CHECK_ID'
end
The command runs after the rake task. If the rake task fails (non-zero exit), subsequent commands in that block still run. For strict success-only pinging:
every 1.day, at: '2:00 am' do
command 'cd /path/to/app && bundle exec rake db:backup && curl -fsS https://api.cronsignal.io/ping/YOUR_CHECK_ID'
end
The && ensures curl only runs if the rake task succeeds.
Monitoring Multiple Jobs
every :hour do
command 'cd /path/to/app && bundle exec rake reports:generate && curl -fsS https://api.cronsignal.io/ping/REPORTS_CHECK'
end
every 1.day, at: '3:00 am' do
command 'cd /path/to/app && bundle exec rake cleanup:old_sessions && curl -fsS https://api.cronsignal.io/ping/CLEANUP_CHECK'
end
every :monday, at: '6:00 am' do
command 'cd /path/to/app && bundle exec rake invoices:generate && curl -fsS https://api.cronsignal.io/ping/INVOICES_CHECK'
end
Run whenever --update-crontab after changes.
Monitoring Inside Rake Tasks
For more control, add monitoring inside the rake task itself:
# lib/tasks/backup.rake
require 'net/http' # stdlib, no gem needed
namespace :db do
desc 'Backup database'
task backup: :environment do
BackupService.run!
# Ping on success (stdlib)
uri = URI('https://api.cronsignal.io/ping/YOUR_CHECK_ID')
Net::HTTP.get(uri)
# Or with http.rb gem (cleaner API):
# HTTP.get('https://api.cronsignal.io/ping/YOUR_CHECK_ID')
puts 'Backup completed'
end
end
If BackupService.run! raises an exception, the ping never fires.
Reusable Helper
For multiple monitored tasks:
# lib/tasks/monitoring.rake
def with_monitoring(check_id)
yield
uri = URI("https://api.cronsignal.io/ping/#{check_id}")
Net::HTTP.get(uri)
rescue => e
Rails.logger.error("Task failed: #{e.message}")
raise
end
# Usage in other tasks
namespace :reports do
task generate: :environment do
with_monitoring('REPORTS_CHECK') do
ReportGenerator.run!
end
end
end
Monitoring Sidekiq-cron Jobs
Sidekiq-cron runs recurring jobs through Sidekiq. Add monitoring in the job itself:
# app/jobs/backup_job.rb
class BackupJob
include Sidekiq::Job # Sidekiq 6.3+
# For Sidekiq < 6.3, use: include Sidekiq::Worker
def perform
BackupService.run!
# Ping on success
uri = URI('https://api.cronsignal.io/ping/YOUR_CHECK_ID')
Net::HTTP.get(uri)
end
end
Configure the schedule as normal:
# config/initializers/sidekiq_cron.rb
Sidekiq::Cron::Job.create(
name: 'Backup Job - daily at 2am',
cron: '0 2 * * *',
class: 'BackupJob'
)
Or in YAML:
# config/schedule.yml
backup_job:
cron: '0 2 * * *'
class: 'BackupJob'
Monitoring the Sidekiq Process
Also monitor that Sidekiq itself is running. Create a heartbeat job:
class SidekiqHeartbeatJob
include Sidekiq::Job
def perform
uri = URI('https://api.cronsignal.io/ping/SIDEKIQ_HEARTBEAT')
Net::HTTP.get(uri)
end
end
# Schedule to run every 5 minutes
Sidekiq::Cron::Job.create(
name: 'Sidekiq Heartbeat',
cron: '*/5 * * * *',
class: 'SidekiqHeartbeatJob'
)
If Sidekiq crashes or runs out of memory, this heartbeat stops and you get alerted.
Monitoring Good Job
Good Job is a Postgres-backed alternative to Sidekiq:
# app/jobs/sync_job.rb
class SyncJob < ApplicationJob
def perform
SyncService.run!
# Ping on success
uri = URI('https://api.cronsignal.io/ping/YOUR_CHECK_ID')
Net::HTTP.get(uri)
end
end
Configure recurring execution:
# config/initializers/good_job.rb
#
# Note: Good Job 3.0+ can also be configured in config/application.rb
# or config/environments/production.rb
Rails.application.configure do
config.good_job.enable_cron = true
config.good_job.cron = {
sync_job: {
cron: '0 * * * *', # Every hour
class: 'SyncJob'
}
}
end
Monitoring Delayed Job
For Delayed Job recurring tasks:
class RecurringBackupJob
def perform
BackupService.run!
uri = URI('https://api.cronsignal.io/ping/YOUR_CHECK_ID')
Net::HTTP.get(uri)
# Reschedule for tomorrow
Delayed::Job.enqueue(RecurringBackupJob.new, run_at: 1.day.from_now)
end
end
Monitoring ActiveJob
For vanilla ActiveJob with any backend:
class MonitoredJob < ApplicationJob
class_attribute :check_id
after_perform do
if self.class.check_id
uri = URI("https://api.cronsignal.io/ping/#{self.class.check_id}")
Net::HTTP.get(uri)
end
end
end
class BackupJob < MonitoredJob
self.check_id = 'BACKUP_CHECK'
def perform
BackupService.run!
end
end
Monitoring Heroku Scheduler
Heroku Scheduler runs rake tasks but has no built-in monitoring:
# lib/tasks/scheduled.rake
require 'net/http'
task daily_backup: :environment do
BackupService.run!
uri = URI('https://api.cronsignal.io/ping/YOUR_CHECK_ID')
Net::HTTP.get(uri)
end
Heroku Scheduler is notoriously unreliable (it's best-effort, not guaranteed). Heartbeat monitoring catches when it skips runs.
Long-Running Jobs
For jobs that take significant time, ping at start and end:
class ETLJob < ApplicationJob
def perform
# Ping start
uri = URI('https://api.cronsignal.io/ping/YOUR_CHECK_ID/start')
Net::HTTP.get(uri)
# Long-running task
ETLPipeline.run!
# Ping completion
uri = URI('https://api.cronsignal.io/ping/YOUR_CHECK_ID')
Net::HTTP.get(uri)
end
end
If you get a start ping but no completion, the job is hanging or crashed mid-execution.
Testing Your Setup
Verify monitoring works before relying on it.
Run your job manually:
bundle exec rake db:backup
# Or for ActiveJob:
BackupJob.perform_now
Check CronSignal to confirm the ping arrived.
Then test failure detection. Make the job raise an error:
def perform
raise 'Test failure'
uri = URI('https://api.cronsignal.io/ping/YOUR_CHECK_ID')
Net::HTTP.get(uri)
end
Run the job. Verify no ping arrives and you get an alert within your grace period.
Getting Started
CronSignal handles the monitoring side for $9/month. Create a check, grab your ping URL, add it to your Rails jobs. Takes five minutes. Start with 3 checks free.
Whether you're using Whenever, Sidekiq-cron, Good Job, or rake tasks from crontab, the pattern is the same: ping on success, get alerted on absence.
For more on heartbeat monitoring and why it beats rescue blocks and error trackers, see our guide on how to monitor cron jobs.