From 47d6800d7d1c98555b513f0f3ee38f5eb431916d Mon Sep 17 00:00:00 2001 From: Dan Fuller Date: Fri, 16 Oct 2026 10:02:31 -0700 Subject: [PATCH 1/4] feat(crons): Add Solid Queue recurring task monitoring Opt-in via `config.enabled_patches << :solid_queue_recurring`. Each recurring task run sends in_progress/ok/error check-ins with the task key as the slug and its schedule as the monitor config, built with Sentry::Cron::FugitSchedule. Jobs whose class already configured sentry_monitor_check_ins are skipped, matched by the task's job class. Co-Authored-By: Claude --- sentry-rails/lib/sentry/rails.rb | 1 + .../lib/sentry/rails/solid_queue_recurring.rb | 123 ++++++++++++++ .../active_job/solid_queue_recurring_spec.rb | 152 ++++++++++++++++++ 3 files changed, 276 insertions(+) create mode 100644 sentry-rails/lib/sentry/rails/solid_queue_recurring.rb create mode 100644 sentry-rails/spec/active_job/solid_queue_recurring_spec.rb diff --git a/sentry-rails/lib/sentry/rails.rb b/sentry-rails/lib/sentry/rails.rb index 8af39f372..203709b40 100644 --- a/sentry-rails/lib/sentry/rails.rb +++ b/sentry-rails/lib/sentry/rails.rb @@ -8,6 +8,7 @@ require "sentry/rails/structured_logging" require "sentry/rails/engine" require "sentry/rails/railtie" +require "sentry/rails/solid_queue_recurring" module Sentry module Rails diff --git a/sentry-rails/lib/sentry/rails/solid_queue_recurring.rb b/sentry-rails/lib/sentry/rails/solid_queue_recurring.rb new file mode 100644 index 000000000..70acb39e8 --- /dev/null +++ b/sentry-rails/lib/sentry/rails/solid_queue_recurring.rb @@ -0,0 +1,123 @@ +# frozen_string_literal: true + +module Sentry + module Rails + # Sends Crons check-ins for Solid Queue recurring tasks. + # Enable with `config.enabled_patches << :solid_queue_recurring`. + module SolidQueueRecurring + PAYLOAD_KEY = "_sentry_recurring_task" + CURRENT_TASK = :sentry_solid_queue_recurring_task + + module RecurringTask + def enqueue(at:) + Thread.current[CURRENT_TASK] = self + super + ensure + Thread.current[CURRENT_TASK] = nil + end + end + + module Job + def serialize + @_sentry_recurring_task ||= Sentry::Rails::SolidQueueRecurring.monitor_for(Thread.current[CURRENT_TASK], self) + payload = super + payload[PAYLOAD_KEY] = @_sentry_recurring_task if @_sentry_recurring_task + payload + end + + def deserialize(job_data) + super + @_sentry_recurring_task = job_data[PAYLOAD_KEY] + end + end + + class << self + # Runs in the scheduler, which is where Solid Queue evaluates the schedule. + def monitor_for(task, job) + return unless task && Sentry.initialized? + job_class = task.class_name.present? ? task.class_name.safe_constantize : task.class.default_job_class + return unless job.instance_of?(job_class) + return if job_class < Sentry::Cron::MonitorCheckIns::Patch + + slug = task.key.to_s.downcase.gsub(/[^a-z0-9_-]+/, "-").gsub(/\A-+|-+\z/, "") + slug = slug[0, Sentry::Cron::MonitorCheckIns::MAX_SLUG_LENGTH].delete_suffix("-") + return if slug.empty? + + config = Sentry::Cron::FugitSchedule.from_cron(cron_for(task)) + { "slug" => slug, "crontab" => config&.schedule&.value, "timezone" => config&.timezone }.compact + rescue StandardError => e + Sentry.sdk_logger.error(Sentry::LOGGER_PROGNAME) { "Not monitoring Solid Queue recurring task #{task.key}: #{e.class}: #{e.message}" } + nil + end + + def perform(job) + data = job.instance_variable_get(:@_sentry_recurring_task) + return yield unless data && Sentry.initialized? + + slug = data["slug"] + if data["crontab"] + cron_config = Sentry.configuration.cron + monitor_config = Sentry::Cron::MonitorConfig.from_crontab( + data["crontab"], + timezone: data["timezone"], + checkin_margin: cron_config.default_checkin_margin, + max_runtime: cron_config.default_max_runtime + ) + end + + check_in_id = Sentry.capture_check_in(slug, :in_progress, monitor_config: monitor_config) + start = Process.clock_gettime(Process::CLOCK_MONOTONIC) + + begin + ret = yield + rescue Exception + duration = Process.clock_gettime(Process::CLOCK_MONOTONIC) - start + Sentry.capture_check_in(slug, :error, check_in_id: check_in_id, duration: duration, monitor_config: monitor_config) + raise + end + + duration = Process.clock_gettime(Process::CLOCK_MONOTONIC) - start + Sentry.capture_check_in(slug, :ok, check_in_id: check_in_id, duration: duration, monitor_config: monitor_config) + ret + end + + def patch + return if @patched + + @patched = true + ActiveSupport.on_load(:active_job) do + prepend Sentry::Rails::SolidQueueRecurring::Job + around_perform { |job, block| Sentry::Rails::SolidQueueRecurring.perform(job, &block) } + end + + apply = proc do + if defined?(::SolidQueue::RecurringTask) && !(::SolidQueue::RecurringTask < Sentry::Rails::SolidQueueRecurring::RecurringTask) + ::SolidQueue::RecurringTask.prepend(Sentry::Rails::SolidQueueRecurring::RecurringTask) + end + end + ::Rails.application.reloader.to_prepare(&apply) + apply.call if ::Rails.application.initialized? + end + + private + + # Mirrors RecurringTask, which applies SolidQueue.time_zone to zoneless schedules. + def cron_for(task) + cron = ::Fugit.parse(task.schedule) + zone = ::SolidQueue.time_zone if ::SolidQueue.respond_to?(:time_zone) + return cron unless cron.is_a?(::Fugit::Cron) && cron.zone.nil? && zone.present? + + ::Fugit.parse("#{cron.to_cron_s} #{zone}") + end + end + end + end +end + +Sentry.register_patch(:solid_queue_recurring) do |config| + if defined?(::SolidQueue) && defined?(::Rails.application) && ::Rails.application + Sentry::Rails::SolidQueueRecurring.patch + else + config.sdk_logger.warn(Sentry::LOGGER_PROGNAME) { "You tried to enable the Solid Queue recurring task integration but the `solid_queue` gem was not detected." } + end +end diff --git a/sentry-rails/spec/active_job/solid_queue_recurring_spec.rb b/sentry-rails/spec/active_job/solid_queue_recurring_spec.rb new file mode 100644 index 000000000..115c90ac6 --- /dev/null +++ b/sentry-rails/spec/active_job/solid_queue_recurring_spec.rb @@ -0,0 +1,152 @@ +# frozen_string_literal: true + +require "spec_helper" + +if RAILS_VERSION >= 7.1 && RUBY_VERSION >= "3.1" + require "solid_queue" + + SOLID_QUEUE_RECURRING_ADAPTER = ::ActiveJob::QueueAdapters::SolidQueueAdapter.new + + RSpec.describe Sentry::Rails::SolidQueueRecurring, type: :job do + include_context "active_job backend harness", adapter: :solid_queue + + let(:configure_sentry) { proc { |config| config.enabled_patches << :solid_queue_recurring } } + + def queue_adapter_for_test + SOLID_QUEUE_RECURRING_ADAPTER + end + + def boot_adapter(_adapter) + Sentry::Rails::Test::Application.load_queue_schema + end + + def reset_adapter(_adapter) + [ + SolidQueue::ReadyExecution, + SolidQueue::ClaimedExecution, + SolidQueue::FailedExecution, + SolidQueue::ScheduledExecution, + SolidQueue::RecurringExecution, + SolidQueue::Process, + SolidQueue::Job + ].each(&:delete_all) + end + + def drain + process = SolidQueue::Process.register(kind: "Worker", pid: ::Process.pid, name: "spec-#{SecureRandom.hex(4)}") + SolidQueue::ReadyExecution.claim("*", 100, process.id).each(&:perform) + end + + def run_task(key, **options) + SolidQueue::RecurringTask.from_configuration(key, **options).enqueue(at: Time.now.change(usec: 0)) + drain + end + + def check_ins + sentry_events.select { |event| event.is_a?(Sentry::CheckInEvent) } + end + + let(:report_job) do + job_fixture("ReportJob") do + def perform(*); end + end + end + + before { report_job } + + it "sends in_progress and ok check-ins with the task key and schedule" do + run_task("nightly_report", class: "ReportJob", schedule: "0 9 * * 1-5 Europe/Vienna") + + in_progress, ok = check_ins + expect(check_ins.size).to eq(2) + expect(in_progress.monitor_slug).to eq("nightly_report") + expect(in_progress.status).to eq(:in_progress) + expect(in_progress.monitor_config.to_h).to eq( + schedule: { type: :crontab, value: "0 9 * * 1-5" }, + timezone: "Europe/Vienna" + ) + expect(ok.status).to eq(:ok) + expect(ok.check_in_id).to eq(in_progress.check_in_id) + end + + it "sends an error check-in when the job fails" do + job_fixture("FailingReportJob") do + def perform + raise "boom" + end + end + + expect { run_task("failing_report", class: "FailingReportJob", schedule: "every hour") }.to raise_error("boom") + expect(check_ins.map(&:status)).to eq([:in_progress, :error]) + expect(check_ins.first.monitor_config.to_h[:schedule]).to eq(type: :crontab, value: "0 * * * *") + end + + it "uses each task's key and schedule when tasks share a job class" do + run_task("report_eu", class: "ReportJob", args: ["eu"], schedule: "0 9 * * * Europe/Berlin") + run_task("report_us", class: "ReportJob", args: ["us"], schedule: "0 9 * * * America/New_York") + + in_progress = check_ins.select { |check_in| check_in.status == :in_progress } + expect(in_progress.map { |check_in| [check_in.monitor_slug, check_in.monitor_config.timezone] }).to eq( + [["report_eu", "Europe/Berlin"], ["report_us", "America/New_York"]] + ) + end + + it "monitors command tasks" do + run_task("Cleanup Cache", command: "1 + 1", schedule: "*/15 * * * * UTC") + + expect(check_ins.map(&:monitor_slug)).to eq(["cleanup-cache", "cleanup-cache"]) + expect(check_ins.first.monitor_config.to_h[:schedule]).to eq(type: :crontab, value: "*/15 * * * *") + end + + it "sends check-ins without a config for schedules Sentry would read differently" do + run_task("month_end", class: "ReportJob", schedule: "0 12 L * *") + + expect(check_ins.map(&:status)).to eq([:in_progress, :ok]) + expect(check_ins.map(&:monitor_config)).to all(be_nil) + end + + it "applies SolidQueue.time_zone to schedules without a zone" do + skip "SolidQueue.time_zone is not supported" unless SolidQueue.respond_to?(:time_zone=) + + original = SolidQueue.time_zone + SolidQueue.time_zone = "Tokyo" + run_task("tokyo_report", class: "ReportJob", schedule: "0 9 * * *") + + expect(check_ins.first.monitor_config.timezone).to eq("Asia/Tokyo") + ensure + SolidQueue.time_zone = original if SolidQueue.respond_to?(:time_zone=) + end + + it "leaves jobs that include Sentry::Cron::MonitorCheckIns alone" do + job_fixture("MonitoredJob") do + include Sentry::Cron::MonitorCheckIns + sentry_monitor_check_ins slug: "own-slug" + + def perform; end + end + + run_task("monitored", class: "MonitoredJob", schedule: "0 9 * * *") + + expect(check_ins.map(&:monitor_slug)).to eq(["own-slug", "own-slug"]) + end + + it "monitors jobs that include Sentry::Cron::MonitorCheckIns without configuring it" do + job_fixture("UnconfiguredJob") do + include Sentry::Cron::MonitorCheckIns + + def perform; end + end + + run_task("unconfigured", class: "::UnconfiguredJob", schedule: "0 9 * * *") + + expect(check_ins.map(&:monitor_slug)).to eq(["unconfigured", "unconfigured"]) + end + + it "does not monitor jobs enqueued outside a recurring task" do + report_job.perform_later + drain + + expect(check_ins).to be_empty + end + end +end From 82a001df4e8b04f560108f01284a6400dcdba7d6 Mon Sep 17 00:00:00 2001 From: Dan Fuller Date: Fri, 16 Oct 2026 16:05:27 -0700 Subject: [PATCH 2/4] feat(crons): Add an exclude list for Solid Queue recurring tasks Co-Authored-By: Claude --- .../lib/sentry/rails/configuration.rb | 6 ++++ .../lib/sentry/rails/solid_queue_recurring.rb | 6 +++- .../active_job/solid_queue_recurring_spec.rb | 34 ++++++++++++++++++- 3 files changed, 44 insertions(+), 2 deletions(-) diff --git a/sentry-rails/lib/sentry/rails/configuration.rb b/sentry-rails/lib/sentry/rails/configuration.rb index d5589ab0f..ecf899352 100644 --- a/sentry-rails/lib/sentry/rails/configuration.rb +++ b/sentry-rails/lib/sentry/rails/configuration.rb @@ -183,6 +183,11 @@ class Configuration # Defaults to true. Set to false to opt out. attr_accessor :active_job_propagate_traces + # Solid Queue recurring task keys to skip when the :solid_queue_recurring patch is enabled. + # Strings match the key exactly; Regexps are matched against it. + # @return [Array] + attr_accessor :solid_queue_exclude_recurring_tasks + # Configuration for structured logging feature # @return [StructuredLoggingConfiguration] attr_reader :structured_logging @@ -205,6 +210,7 @@ def initialize @active_support_logger_subscription_items = Sentry::Rails::ACTIVE_SUPPORT_LOGGER_SUBSCRIPTION_ITEMS_DEFAULT.dup @active_job_report_on_retry_error = false @active_job_propagate_traces = true + @solid_queue_exclude_recurring_tasks = [] @structured_logging = StructuredLoggingConfiguration.new end end diff --git a/sentry-rails/lib/sentry/rails/solid_queue_recurring.rb b/sentry-rails/lib/sentry/rails/solid_queue_recurring.rb index 70acb39e8..f8c17f14e 100644 --- a/sentry-rails/lib/sentry/rails/solid_queue_recurring.rb +++ b/sentry-rails/lib/sentry/rails/solid_queue_recurring.rb @@ -10,7 +10,7 @@ module SolidQueueRecurring module RecurringTask def enqueue(at:) - Thread.current[CURRENT_TASK] = self + Thread.current[CURRENT_TASK] = self unless Sentry::Rails::SolidQueueRecurring.excluded?(self) super ensure Thread.current[CURRENT_TASK] = nil @@ -32,6 +32,10 @@ def deserialize(job_data) end class << self + def excluded?(task) + Sentry.initialized? && Sentry.configuration.rails.solid_queue_exclude_recurring_tasks.any? { |pattern| pattern === task.key.to_s } + end + # Runs in the scheduler, which is where Solid Queue evaluates the schedule. def monitor_for(task, job) return unless task && Sentry.initialized? diff --git a/sentry-rails/spec/active_job/solid_queue_recurring_spec.rb b/sentry-rails/spec/active_job/solid_queue_recurring_spec.rb index 115c90ac6..dda05531b 100644 --- a/sentry-rails/spec/active_job/solid_queue_recurring_spec.rb +++ b/sentry-rails/spec/active_job/solid_queue_recurring_spec.rb @@ -10,7 +10,13 @@ RSpec.describe Sentry::Rails::SolidQueueRecurring, type: :job do include_context "active_job backend harness", adapter: :solid_queue - let(:configure_sentry) { proc { |config| config.enabled_patches << :solid_queue_recurring } } + let(:excluded_tasks) { [] } + let(:configure_sentry) do + proc do |config| + config.enabled_patches << :solid_queue_recurring + config.rails.solid_queue_exclude_recurring_tasks = excluded_tasks + end + end def queue_adapter_for_test SOLID_QUEUE_RECURRING_ADAPTER @@ -142,6 +148,32 @@ def perform; end expect(check_ins.map(&:monitor_slug)).to eq(["unconfigured", "unconfigured"]) end + it "excludes nothing by default" do + expect(Sentry.configuration.rails.solid_queue_exclude_recurring_tasks).to eq([]) + end + + context "with excluded tasks" do + let(:excluded_tasks) { ["clear_cache", /\Atmp_/] } + + it "skips tasks whose key matches a string" do + run_task("clear_cache", class: "ReportJob", schedule: "0 9 * * *") + + expect(check_ins).to be_empty + end + + it "skips tasks whose key matches a regexp" do + run_task("tmp_cleanup", class: "ReportJob", schedule: "0 9 * * *") + + expect(check_ins).to be_empty + end + + it "monitors tasks that do not match" do + run_task("clear_cache_daily", class: "ReportJob", schedule: "0 9 * * *") + + expect(check_ins.map(&:monitor_slug)).to eq(["clear_cache_daily", "clear_cache_daily"]) + end + end + it "does not monitor jobs enqueued outside a recurring task" do report_job.perform_later drain From a5c3fb57f0b2ccd3f8c6f21abaf3bd9be4806035 Mon Sep 17 00:00:00 2001 From: Dan Fuller Date: Fri, 16 Oct 2026 16:17:53 -0700 Subject: [PATCH 3/4] test(crons): Use a sub-minute schedule for the no-config Solid Queue case L schedules now convert to Crons schedules on the base branch. Co-Authored-By: Claude --- sentry-rails/spec/active_job/solid_queue_recurring_spec.rb | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/sentry-rails/spec/active_job/solid_queue_recurring_spec.rb b/sentry-rails/spec/active_job/solid_queue_recurring_spec.rb index dda05531b..4ef68c3a2 100644 --- a/sentry-rails/spec/active_job/solid_queue_recurring_spec.rb +++ b/sentry-rails/spec/active_job/solid_queue_recurring_spec.rb @@ -104,8 +104,8 @@ def perform expect(check_ins.first.monitor_config.to_h[:schedule]).to eq(type: :crontab, value: "*/15 * * * *") end - it "sends check-ins without a config for schedules Sentry would read differently" do - run_task("month_end", class: "ReportJob", schedule: "0 12 L * *") + it "sends check-ins without a config for schedules Crons cannot express" do + run_task("every_ten_seconds", class: "ReportJob", schedule: "every 10 seconds") expect(check_ins.map(&:status)).to eq([:in_progress, :ok]) expect(check_ins.map(&:monitor_config)).to all(be_nil) From 75317f011bb0a34655027d324a5b7f80be13e623 Mon Sep 17 00:00:00 2001 From: Dan Fuller Date: Fri, 16 Oct 2026 16:47:31 -0700 Subject: [PATCH 4/4] fix(rails): Don't let a bad Solid Queue exclusion list block enqueues excluded? runs inside RecurringTask#enqueue, so a nil or broken solid_queue_exclude_recurring_tasks raised and stopped every recurring task from enqueuing. Wrap the value in Array and log any error instead. Co-Authored-By: Claude --- .../lib/sentry/rails/solid_queue_recurring.rb | 7 ++++++- .../active_job/solid_queue_recurring_spec.rb | 20 +++++++++++++++++++ 2 files changed, 26 insertions(+), 1 deletion(-) diff --git a/sentry-rails/lib/sentry/rails/solid_queue_recurring.rb b/sentry-rails/lib/sentry/rails/solid_queue_recurring.rb index f8c17f14e..248cc2c48 100644 --- a/sentry-rails/lib/sentry/rails/solid_queue_recurring.rb +++ b/sentry-rails/lib/sentry/rails/solid_queue_recurring.rb @@ -33,7 +33,12 @@ def deserialize(job_data) class << self def excluded?(task) - Sentry.initialized? && Sentry.configuration.rails.solid_queue_exclude_recurring_tasks.any? { |pattern| pattern === task.key.to_s } + return false unless Sentry.initialized? + + Array(Sentry.configuration.rails.solid_queue_exclude_recurring_tasks).any? { |pattern| pattern === task.key.to_s } + rescue StandardError => e + Sentry.sdk_logger.error(Sentry::LOGGER_PROGNAME) { "Could not check solid_queue_exclude_recurring_tasks for #{task.key}: #{e.class}: #{e.message}" } + false end # Runs in the scheduler, which is where Solid Queue evaluates the schedule. diff --git a/sentry-rails/spec/active_job/solid_queue_recurring_spec.rb b/sentry-rails/spec/active_job/solid_queue_recurring_spec.rb index 4ef68c3a2..7fe119c20 100644 --- a/sentry-rails/spec/active_job/solid_queue_recurring_spec.rb +++ b/sentry-rails/spec/active_job/solid_queue_recurring_spec.rb @@ -174,6 +174,26 @@ def perform; end end end + context "with no excluded tasks set" do + let(:excluded_tasks) { nil } + + it "monitors the task" do + run_task("daily_report", class: "ReportJob", schedule: "0 9 * * *") + + expect(check_ins.map(&:monitor_slug)).to eq(["daily_report", "daily_report"]) + end + end + + context "with an exclusion pattern that raises" do + let(:excluded_tasks) { [->(_key) { raise "boom" }] } + + it "still enqueues and monitors the task" do + run_task("daily_report", class: "ReportJob", schedule: "0 9 * * *") + + expect(check_ins.map(&:monitor_slug)).to eq(["daily_report", "daily_report"]) + end + end + it "does not monitor jobs enqueued outside a recurring task" do report_job.perform_later drain