Back to Blog
December 24, 2024

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.

Ready to monitor your cron jobs?

$9/month for unlimited checks

Get Started