diff --git a/CHANGELOG.md b/CHANGELOG.md index 1388a7da2..37b3c8d37 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,15 @@ ### Features - Record client reports for profiles [#2107](https://github.com/getsentry/sentry-ruby/pull/2107) +- Add `Sentry.capture_check_in` API for Cron Monitoring [#2117](https://github.com/getsentry/sentry-ruby/pull/2117) + + You can now track progress of long running scheduled jobs. + + ```rb + check_in_id = Sentry.capture_check_in('job_name', :in_progress) + # do job stuff + Sentry.capture_check_in('job_name', :ok, check_in_id: check_in_id) + ``` ### Bug Fixes diff --git a/sentry-ruby/lib/sentry-ruby.rb b/sentry-ruby/lib/sentry-ruby.rb index daed11803..8d0ce7c18 100644 --- a/sentry-ruby/lib/sentry-ruby.rb +++ b/sentry-ruby/lib/sentry-ruby.rb @@ -15,6 +15,7 @@ require "sentry/event" require "sentry/error_event" require "sentry/transaction_event" +require "sentry/check_in_event" require "sentry/span" require "sentry/transaction" require "sentry/hub" @@ -430,6 +431,19 @@ def capture_event(event) get_current_hub.capture_event(event) end + # Captures a check-in and sends it to Sentry via the currently active hub. + # + # @param slug [String] identifier of this monitor + # @param status [Symbol] status of this check-in, one of Sentry::CheckInEvent::VALID_STATUSES + # @yieldparam scope [Scope] + # TODO-neel-crons yard @option + # + # @return [String, nil] + def capture_check_in(slug, status, **options, &block) + return unless initialized? + get_current_hub.capture_check_in(slug, status, **options, &block) + end + # Takes or initializes a new Sentry::Transaction and makes a sampling decision for it. # # @return [Transaction, nil] diff --git a/sentry-ruby/lib/sentry/check_in_event.rb b/sentry-ruby/lib/sentry/check_in_event.rb new file mode 100644 index 000000000..d2b266aa3 --- /dev/null +++ b/sentry-ruby/lib/sentry/check_in_event.rb @@ -0,0 +1,60 @@ +# frozen_string_literal + +require 'securerandom' +require 'sentry/cron/monitor_config' + +module Sentry + class CheckInEvent < Event + TYPE = 'check_in' + + # uuid to identify this check-in. + # @return [String] + attr_accessor :check_in_id + + # Identifier of the monitor for this check-in. + # @return [String] + attr_accessor :monitor_slug + + # Duration of this check since it has started in seconds. + # @return [Integer, nil] + attr_accessor :duration + + # Monitor configuration to support upserts. + # @return [Cron::MonitorConfig, nil] + attr_accessor :monitor_config + + # Status of this check-in. + # @return [Symbol] + attr_accessor :status + + VALID_STATUSES = %i(ok in_progress error) + + def initialize( + slug:, + status:, + duration: nil, + monitor_config: nil, + check_in_id: nil, + **options + ) + super(**options) + + self.monitor_slug = slug + self.status = status + self.duration = duration + self.monitor_config = monitor_config + self.check_in_id = check_in_id || SecureRandom.uuid.delete('-') + end + + # @return [Hash] + def to_hash + data = super + data[:check_in_id] = check_in_id + data[:monitor_slug] = monitor_slug + data[:status] = status + data[:duration] = duration if duration + data[:monitor_config] = monitor_config.to_hash if monitor_config + data + end + end +end diff --git a/sentry-ruby/lib/sentry/client.rb b/sentry-ruby/lib/sentry/client.rb index 80f1165c8..67320133b 100644 --- a/sentry-ruby/lib/sentry/client.rb +++ b/sentry-ruby/lib/sentry/client.rb @@ -104,6 +104,33 @@ def event_from_message(message, hint = {}, backtrace: nil) event end + # Initializes a CheckInEvent object with the given options. + # @param slug [String] identifier of this monitor + # @param status [Symbol] status of this check-in, one of Sentry::CheckInEvent::VALID_STATUSES + # @param hint [Hash] the hint data that'll be passed to `before_send` callback and the scope's event processors. + # TODO-neel-crons yard opts + # @return [Event] + def event_from_check_in( + slug, + status, + hint = {}, + duration: nil, + monitor_config: nil, + check_in_id: nil + ) + return unless configuration.sending_allowed? + + CheckInEvent.new( + configuration: configuration, + integration_meta: Sentry.integrations[hint[:integration]], + slug: slug, + status: status, + duration: duration, + monitor_config: monitor_config, + check_in_id: check_in_id + ) + end + # Initializes an Event object with the given Transaction object. # @param transaction [Transaction] the transaction to be recorded. # @return [TransactionEvent] diff --git a/sentry-ruby/lib/sentry/cron/monitor_config.rb b/sentry-ruby/lib/sentry/cron/monitor_config.rb new file mode 100644 index 000000000..791631e32 --- /dev/null +++ b/sentry-ruby/lib/sentry/cron/monitor_config.rb @@ -0,0 +1,43 @@ +# frozen_string_literal + +require 'sentry/cron/monitor_schedule' + +module Sentry + module Cron + class MonitorConfig + # The monitor schedule configuration + # @return [MonitorSchedule::Crontab, MonitorSchedule::Interval] + attr_accessor :schedule + + # How long (in minutes) after the expected checkin time will we wait + # until we consider the checkin to have been missed. + # @return [Integer, nil] + attr_accessor :checkin_margin + + # How long (in minutes) is the checkin allowed to run for in in_progress + # before it is considered failed. + # @return [Integer, nil] + attr_accessor :max_runtime + + # tz database style timezone string + # @return [String, nil] + attr_accessor :timezone + + def initialize(schedule, checkin_margin: nil, max_runtime: nil, timezone: nil) + @schedule = schedule + @checkin_margin = checkin_margin + @max_runtime = max_runtime + @timezone = timezone + end + + def to_hash + { + schedule: schedule.to_hash, + checkin_margin: checkin_margin, + max_runtime: max_runtime, + timezone: timezone + }.compact + end + end + end +end diff --git a/sentry-ruby/lib/sentry/cron/monitor_schedule.rb b/sentry-ruby/lib/sentry/cron/monitor_schedule.rb new file mode 100644 index 000000000..e23615b59 --- /dev/null +++ b/sentry-ruby/lib/sentry/cron/monitor_schedule.rb @@ -0,0 +1,42 @@ +# frozen_string_literal + +module Sentry + module Cron + module MonitorSchedule + class Crontab + # A crontab formatted string such as "0 * * * *". + # @return [String] + attr_accessor :value + + def initialize(value) + @value = value + end + + def to_hash + { type: :crontab, value: value } + end + end + + class Interval + # The number representing duration of the interval. + # @return [Integer] + attr_accessor :value + + # The unit representing duration of the interval. + # @return [Symbol] + attr_accessor :unit + + VALID_UNITS = %i(year month week day hour minute) + + def initialize(value, unit) + @value = value + @unit = unit + end + + def to_hash + { type: :interval, value: value, unit: unit } + end + end + end + end +end diff --git a/sentry-ruby/lib/sentry/hub.rb b/sentry-ruby/lib/sentry/hub.rb index 7d9dca4c7..36258eb33 100644 --- a/sentry-ruby/lib/sentry/hub.rb +++ b/sentry-ruby/lib/sentry/hub.rb @@ -156,6 +156,30 @@ def capture_message(message, **options, &block) capture_event(event, **options, &block) end + def capture_check_in(slug, status, **options, &block) + check_argument_type!(slug, ::String) + check_argument_includes!(status, Sentry::CheckInEvent::VALID_STATUSES) + + return unless current_client + + options[:hint] ||= {} + options[:hint][:slug] = slug + + event = current_client.event_from_check_in( + slug, + status, + options[:hint], + duration: options.delete(:duration), + monitor_config: options.delete(:monitor_config), + check_in_id: options.delete(:check_in_id) + ) + + return unless event + + capture_event(event, **options, &block) + event.check_in_id + end + def capture_event(event, **options, &block) check_argument_type!(event, Sentry::Event) @@ -178,7 +202,7 @@ def capture_event(event, **options, &block) configuration.log_debug(event.to_json_compatible) end - @last_event_id = event&.event_id unless event.is_a?(Sentry::TransactionEvent) + @last_event_id = event&.event_id if event.is_a?(Sentry::ErrorEvent) event end diff --git a/sentry-ruby/lib/sentry/integrable.rb b/sentry-ruby/lib/sentry/integrable.rb index daa3669f7..a1edeadb6 100644 --- a/sentry-ruby/lib/sentry/integrable.rb +++ b/sentry-ruby/lib/sentry/integrable.rb @@ -22,5 +22,11 @@ def capture_message(message, **options, &block) options[:hint][:integration] = integration_name Sentry.capture_message(message, **options, &block) end + + def capture_check_in(slug, status, **options, &block) + options[:hint] ||= {} + options[:hint][:integration] = integration_name + Sentry.capture_check_in(slug, status, **options, &block) + end end end diff --git a/sentry-ruby/lib/sentry/utils/argument_checking_helper.rb b/sentry-ruby/lib/sentry/utils/argument_checking_helper.rb index 5b161b872..e00eb5fc2 100644 --- a/sentry-ruby/lib/sentry/utils/argument_checking_helper.rb +++ b/sentry-ruby/lib/sentry/utils/argument_checking_helper.rb @@ -9,5 +9,11 @@ def check_argument_type!(argument, *expected_types) raise ArgumentError, "expect the argument to be a #{expected_types.join(' or ')}, got #{argument.class} (#{argument.inspect})" end end + + def check_argument_includes!(argument, values) + unless values.include?(argument) + raise ArgumentError, "expect the argument to be one of #{values.map(&:inspect).join(' or ')}, got #{argument.inspect}" + end + end end end