Skip to content

Commit 7793e97

Browse files
Crons: add sidekiq-scheduler zero-config monitor check-ins (#2172)
* Crons: add sidekiq-scheduler zero config support. - Adds support for `sidekiq-scheduler` instrumentation without any configuration from the user. - Based on #2170. * Crons: support intervals for sidekiq-scheduler jobs - AppliesApply review feedback. - Adds support for interval and every interval_types for sidekiq-scheduler-schedule - Adds specs for the above. * Crons: changelog for sidekiq-scheduler support. * Crons: fix tests on Sidekiq 6 * Require sidekiq-scheduler to ensure it's there * sidekiq-scheduler mock config without delegation * Make version checks consistent * Fix some stuff * need int for interval * constantize doesn't exist outside rails * cleanup specs --------- Co-authored-by: Neel Shah <neelshah.sa@gmail.com>
1 parent 78fb37a commit 7793e97

File tree

9 files changed

+189
-12
lines changed

9 files changed

+189
-12
lines changed

CHANGELOG.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,13 @@
1212
```rb
1313
config.enabled_patches += [:sidekiq_cron]
1414
```
15+
- Add support for [`sidekiq-scheduler`](https://github.com/sidekiq-scheduler/sidekiq-scheduler) [#2172](https://github.com/getsentry/sentry-ruby/pull/2172)
16+
17+
You can opt in to the `sidekiq-scheduler` patch and we will automatically monitor check-ins for all repeating jobs (i.e. `cron`, `every`, and `interval`) specified in the config.
18+
19+
```rb
20+
config.enabled_patches += [:sidekiq_scheduler]
21+
```
1522

1623
### Bug Fixes
1724

sentry-sidekiq/Gemfile

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,11 @@ sidekiq_version = "7.0" if sidekiq_version.nil?
2323
sidekiq_version = Gem::Version.new(sidekiq_version)
2424

2525
gem "sidekiq", "~> #{sidekiq_version}"
26-
gem "sidekiq-cron" if sidekiq_version >= Gem::Version.new("6.0")
26+
27+
if RUBY_VERSION.to_f >= 2.7 && sidekiq_version >= Gem::Version.new("6.0")
28+
gem "sidekiq-cron"
29+
gem "sidekiq-scheduler"
30+
end
2731

2832
gem "rails", "> 5.0.0", "< 7.1.0"
2933

sentry-sidekiq/lib/sentry-sidekiq.rb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,3 +42,4 @@ class Railtie < ::Rails::Railtie
4242

4343
# patches
4444
require "sentry/sidekiq/cron/job"
45+
require "sentry/sidekiq-scheduler/scheduler"
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
# frozen_string_literal: true
2+
3+
# Try to require sidekiq-scheduler to make sure it's loaded before the integration.
4+
begin
5+
require "sidekiq-scheduler"
6+
rescue LoadError
7+
return
8+
end
9+
10+
# If we've loaded sidekiq-scheduler, but the API changed,
11+
# and the Scheduler class is not there, fail gracefully.
12+
return unless defined?(::SidekiqScheduler::Scheduler)
13+
14+
module Sentry
15+
module SidekiqScheduler
16+
module Scheduler
17+
def new_job(name, interval_type, config, schedule, options)
18+
# Schedule the job upstream first
19+
# SidekiqScheduler does not validate schedules
20+
# It will fail with an error if the schedule in the config is invalid.
21+
# If this errors out, let it fall through.
22+
rufus_job = super
23+
24+
klass = config.fetch("class")
25+
return rufus_job unless klass
26+
27+
# Constantize the job class, and fail gracefully if it could not be found
28+
klass_const =
29+
begin
30+
Object.const_get(klass)
31+
rescue NameError
32+
return rufus_job
33+
end
34+
35+
# For cron, every, or interval jobs — grab their schedule.
36+
# Rufus::Scheduler::EveryJob stores it's frequency in seconds,
37+
# so we convert it to minutes before passing in to the monitor.
38+
monitor_config = case interval_type
39+
when "cron"
40+
Sentry::Cron::MonitorConfig.from_crontab(schedule)
41+
when "every", "interval"
42+
Sentry::Cron::MonitorConfig.from_interval(rufus_job.frequency.to_i / 60, :minute)
43+
end
44+
45+
# If we couldn't build a monitor config, it's either an error, or
46+
# it's a one-time job (interval_type is in, or at), in which case
47+
# we should not make a monitof for it automaticaly.
48+
return rufus_job if monitor_config.nil?
49+
50+
# only patch if not explicitly included in job by user
51+
unless klass_const.send(:ancestors).include?(Sentry::Cron::MonitorCheckIns)
52+
klass_const.send(:include, Sentry::Cron::MonitorCheckIns)
53+
klass_const.send(:sentry_monitor_check_ins,
54+
slug: name,
55+
monitor_config: monitor_config)
56+
57+
::Sidekiq.logger.info "Injected Sentry Crons monitor checkins into #{klass}"
58+
end
59+
60+
rufus_job
61+
end
62+
end
63+
end
64+
end
65+
66+
Sentry.register_patch(:sidekiq_scheduler, Sentry::SidekiqScheduler::Scheduler, ::SidekiqScheduler::Scheduler)

sentry-sidekiq/spec/fixtures/schedule.yml renamed to sentry-sidekiq/spec/fixtures/sidekiq-cron-schedule.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
happy:
22
cron: "* * * * *"
3-
class: "HappyWorkerDup"
3+
class: "HappyWorkerForCron"
44

55
manual:
66
cron: "* * * * *"
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
:schedule:
2+
happy:
3+
cron: "* * * * *"
4+
class: "HappyWorkerForScheduler"
5+
manual:
6+
cron: "* * * * *"
7+
class: "SadWorkerWithCron"
8+
regularly_happy:
9+
every: "10 minutes"
10+
class: "EveryHappyWorker"
11+
reports:
12+
in: "2 hours"
13+
class: "ReportingWorker"
14+
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
require 'spec_helper'
2+
3+
return unless defined?(SidekiqScheduler::Scheduler)
4+
5+
RSpec.describe Sentry::SidekiqScheduler::Scheduler do
6+
before do
7+
perform_basic_setup { |c| c.enabled_patches += [:sidekiq_scheduler] }
8+
end
9+
10+
before do
11+
schedule_file = 'spec/fixtures/sidekiq-scheduler-schedule.yml'
12+
config_options = { scheduler: YAML.load_file(schedule_file) }
13+
14+
# Sidekiq::Scheduler merges it's config with Sidekiq.
15+
# To grab a config for it to start, we need to pass sidekiq configuration
16+
# (defaults should be fine though).
17+
scheduler_config = SidekiqScheduler::Config.new(sidekiq_config: sidekiq_config(config_options))
18+
19+
# Making and starting a Manager instance will load the jobs
20+
schedule_manager = SidekiqScheduler::Manager.new(scheduler_config)
21+
schedule_manager.start
22+
end
23+
24+
it 'patches class' do
25+
expect(SidekiqScheduler::Scheduler.ancestors).to include(described_class)
26+
end
27+
28+
it 'patches HappyWorkerForScheduler' do
29+
expect(HappyWorkerForScheduler.ancestors).to include(Sentry::Cron::MonitorCheckIns)
30+
expect(HappyWorkerForScheduler.sentry_monitor_slug).to eq('happy')
31+
expect(HappyWorkerForScheduler.sentry_monitor_config).to be_a(Sentry::Cron::MonitorConfig)
32+
expect(HappyWorkerForScheduler.sentry_monitor_config.schedule).to be_a(Sentry::Cron::MonitorSchedule::Crontab)
33+
expect(HappyWorkerForScheduler.sentry_monitor_config.schedule.value).to eq('* * * * *')
34+
end
35+
36+
it 'does not override SadWorkerWithCron manually set values' do
37+
expect(SadWorkerWithCron.ancestors).to include(Sentry::Cron::MonitorCheckIns)
38+
expect(SadWorkerWithCron.sentry_monitor_slug).to eq('failed_job')
39+
expect(SadWorkerWithCron.sentry_monitor_config).to be_a(Sentry::Cron::MonitorConfig)
40+
expect(SadWorkerWithCron.sentry_monitor_config.schedule).to be_a(Sentry::Cron::MonitorSchedule::Crontab)
41+
expect(SadWorkerWithCron.sentry_monitor_config.schedule.value).to eq('5 * * * *')
42+
end
43+
44+
it "sets correct monitor config based on `every` schedule" do
45+
expect(EveryHappyWorker.ancestors).to include(Sentry::Cron::MonitorCheckIns)
46+
expect(EveryHappyWorker.sentry_monitor_slug).to eq('regularly_happy')
47+
expect(EveryHappyWorker.sentry_monitor_config).to be_a(Sentry::Cron::MonitorConfig)
48+
expect(EveryHappyWorker.sentry_monitor_config.schedule).to be_a(Sentry::Cron::MonitorSchedule::Interval)
49+
expect(EveryHappyWorker.sentry_monitor_config.schedule.to_hash).to eq({value: 10, type: :interval, unit: :minute})
50+
end
51+
52+
it "does not add monitors for a one-off job" do
53+
expect(ReportingWorker.ancestors).not_to include(Sentry::Cron::MonitorCheckIns)
54+
end
55+
end
56+

sentry-sidekiq/spec/sentry/sidekiq/cron/job_spec.rb

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
end
99

1010
before do
11-
schedule_file = 'spec/fixtures/schedule.yml'
11+
schedule_file = 'spec/fixtures/sidekiq-cron-schedule.yml'
1212
schedule = Sidekiq::Cron::Support.load_yaml(ERB.new(IO.read(schedule_file)).result)
1313
# sidekiq-cron 2.0+ accepts second argument to `load_from_hash!` with options,
1414
# such as {source: 'schedule'}, but sidekiq-cron 1.9.1 (last version to support Ruby 2.6) does not.
@@ -40,12 +40,12 @@
4040
end.not_to raise_error
4141
end
4242

43-
it 'patches HappyWorker' do
44-
expect(HappyWorkerDup.ancestors).to include(Sentry::Cron::MonitorCheckIns)
45-
expect(HappyWorkerDup.sentry_monitor_slug).to eq('happy')
46-
expect(HappyWorkerDup.sentry_monitor_config).to be_a(Sentry::Cron::MonitorConfig)
47-
expect(HappyWorkerDup.sentry_monitor_config.schedule).to be_a(Sentry::Cron::MonitorSchedule::Crontab)
48-
expect(HappyWorkerDup.sentry_monitor_config.schedule.value).to eq('* * * * *')
43+
it 'patches HappyWorkerForCron' do
44+
expect(HappyWorkerForCron.ancestors).to include(Sentry::Cron::MonitorCheckIns)
45+
expect(HappyWorkerForCron.sentry_monitor_slug).to eq('happy')
46+
expect(HappyWorkerForCron.sentry_monitor_config).to be_a(Sentry::Cron::MonitorConfig)
47+
expect(HappyWorkerForCron.sentry_monitor_config.schedule).to be_a(Sentry::Cron::MonitorSchedule::Crontab)
48+
expect(HappyWorkerForCron.sentry_monitor_config.schedule.value).to eq('* * * * *')
4949
end
5050

5151
it 'does not override SadWorkerWithCron manually set values' do

sentry-sidekiq/spec/spec_helper.rb

Lines changed: 32 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,11 +5,16 @@
55
# this enables sidekiq's server mode
66
require "sidekiq/cli"
77

8+
MIN_SIDEKIQ_6 = Gem::Version.new(Sidekiq::VERSION) >= Gem::Version.new("6.0")
89
WITH_SIDEKIQ_7 = Gem::Version.new(Sidekiq::VERSION) >= Gem::Version.new("7.0")
9-
WITH_SIDEKIQ_6 = Gem::Version.new(Sidekiq::VERSION) >= Gem::Version.new("6.0") && !WITH_SIDEKIQ_7
10+
WITH_SIDEKIQ_6 = MIN_SIDEKIQ_6 && !WITH_SIDEKIQ_7
1011

1112
require "sidekiq/embedded" if WITH_SIDEKIQ_7
12-
require 'sidekiq-cron' if RUBY_VERSION.to_f >= 2.7 && WITH_SIDEKIQ_6 || WITH_SIDEKIQ_7
13+
14+
if RUBY_VERSION.to_f >= 2.7 && MIN_SIDEKIQ_6
15+
require 'sidekiq-cron'
16+
require 'sidekiq-scheduler'
17+
end
1318

1419
require "sentry-ruby"
1520

@@ -128,7 +133,9 @@ def perform
128133
end
129134
end
130135

131-
class HappyWorkerDup < HappyWorker; end
136+
class HappyWorkerForCron < HappyWorker; end
137+
class HappyWorkerForScheduler < HappyWorker; end
138+
class EveryHappyWorker < HappyWorker; end
132139

133140
class HappyWorkerWithCron < HappyWorker
134141
include Sentry::Cron::MonitorCheckIns
@@ -184,6 +191,28 @@ def new_processor
184191
manager.workers.first
185192
end
186193

194+
class SidekiqConfigMock
195+
include ::Sidekiq
196+
attr_accessor :options
197+
198+
def initialize(options = {})
199+
@options = DEFAULTS.merge(options)
200+
end
201+
202+
def fetch(key, default = nil)
203+
options.fetch(key, default)
204+
end
205+
206+
def [](key)
207+
options[key]
208+
end
209+
end
210+
211+
# Sidekiq 7 has a Config class, but for Sidekiq 6, we'll mock it.
212+
def sidekiq_config(opts)
213+
WITH_SIDEKIQ_7 ? ::Sidekiq::Config.new(opts) : SidekiqConfigMock.new(opts)
214+
end
215+
187216
def execute_worker(processor, klass, **options)
188217
klass_options = klass.sidekiq_options_hash || {}
189218

0 commit comments

Comments
 (0)