Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions sentry-rails/lib/sentry/rails.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 6 additions & 0 deletions sentry-rails/lib/sentry/rails/configuration.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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<String, Regexp>]
attr_accessor :solid_queue_exclude_recurring_tasks

# Configuration for structured logging feature
# @return [StructuredLoggingConfiguration]
attr_reader :structured_logging
Expand All @@ -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
Expand Down
132 changes: 132 additions & 0 deletions sentry-rails/lib/sentry/rails/solid_queue_recurring.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
# 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 unless Sentry::Rails::SolidQueueRecurring.excluded?(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
def excluded?(task)
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.
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
204 changes: 204 additions & 0 deletions sentry-rails/spec/active_job/solid_queue_recurring_spec.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,204 @@
# 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(: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
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 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)
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 "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

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

expect(check_ins).to be_empty
end
end
end
Loading