Skip to content

Commit

Permalink
Add new Sentry.capture_check_in API for Cron monitoring (#2117)
Browse files Browse the repository at this point in the history
* New `CheckInEvent` class for the envelope payload
* New `Cron::MonitorConfig` class that holds the monitor configuration
* New `Cron::MonitorSchedule` module that holds two types of schedules
  `Crontab` and `Interval`
  • Loading branch information
sl0thentr0py authored Oct 5, 2023
1 parent 096e6c3 commit 286135c
Show file tree
Hide file tree
Showing 14 changed files with 507 additions and 9 deletions.
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,15 @@

- Record client reports for profiles [#2107](https://github.com/getsentry/sentry-ruby/pull/2107)
- Adopt Rails 7.1's new BroadcastLogger [#2120](https://github.com/getsentry/sentry-ruby/pull/2120)
- 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

Expand Down
19 changes: 19 additions & 0 deletions sentry-ruby/lib/sentry-ruby.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -430,6 +431,24 @@ 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 {CheckInEvent::VALID_STATUSES}
#
# @param [Hash] options extra check-in options
# @option options [String] check_in_id for updating the status of an existing monitor
# @option options [Integer] duration seconds elapsed since this monitor started
# @option options [Cron::MonitorConfig] monitor_config configuration for this monitor
#
# @yieldparam scope [Scope]
#
# @return [String, nil] The {CheckInEvent#check_in_id} to use for later updates on the same slug
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]
Expand Down
60 changes: 60 additions & 0 deletions sentry-ruby/lib/sentry/check_in_event.rb
Original file line number Diff line number Diff line change
@@ -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
31 changes: 31 additions & 0 deletions sentry-ruby/lib/sentry/client.rb
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,37 @@ 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 {CheckInEvent::VALID_STATUSES}
# @param hint [Hash] the hint data that'll be passed to `before_send` callback and the scope's event processors.
# @param duration [Integer, nil] seconds elapsed since this monitor started
# @param monitor_config [Cron::MonitorConfig, nil] configuration for this monitor
# @param check_in_id [String, nil] for updating the status of an existing monitor
#
# @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]
Expand Down
53 changes: 53 additions & 0 deletions sentry-ruby/lib/sentry/cron/monitor_config.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
# 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 self.from_crontab(crontab, **options)
new(MonitorSchedule::Crontab.new(crontab), **options)
end

def self.from_interval(num, unit, **options)
return nil unless MonitorSchedule::Interval::VALID_UNITS.include?(unit)

new(MonitorSchedule::Interval.new(num, unit), **options)
end

def to_hash
{
schedule: schedule.to_hash,
checkin_margin: checkin_margin,
max_runtime: max_runtime,
timezone: timezone
}.compact
end
end
end
end
42 changes: 42 additions & 0 deletions sentry-ruby/lib/sentry/cron/monitor_schedule.rb
Original file line number Diff line number Diff line change
@@ -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
26 changes: 25 additions & 1 deletion sentry-ruby/lib/sentry/hub.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand All @@ -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

Expand Down
6 changes: 6 additions & 0 deletions sentry-ruby/lib/sentry/integrable.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
6 changes: 6 additions & 0 deletions sentry-ruby/lib/sentry/utils/argument_checking_helper.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
53 changes: 53 additions & 0 deletions sentry-ruby/spec/sentry/client_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -510,6 +510,59 @@ module ExcTag; end
end
end

describe "#event_from_check_in" do
let(:slug) { "test_slug" }
let(:status) { :ok }

it 'returns an event' do
event = subject.event_from_check_in(slug, status)
expect(event).to be_a(Sentry::CheckInEvent)

hash = event.to_hash
expect(hash[:monitor_slug]).to eq(slug)
expect(hash[:status]).to eq(status)
expect(hash[:check_in_id].length).to eq(32)
end

it 'returns an event with correct optional attributes from crontab config' do
event = subject.event_from_check_in(
slug,
status,
duration: 30,
check_in_id: "xxx-yyy",
monitor_config: Sentry::Cron::MonitorConfig.from_crontab("* * * * *")
)

expect(event).to be_a(Sentry::CheckInEvent)

hash = event.to_hash
expect(hash[:monitor_slug]).to eq(slug)
expect(hash[:status]).to eq(status)
expect(hash[:check_in_id]).to eq("xxx-yyy")
expect(hash[:duration]).to eq(30)
expect(hash[:monitor_config]).to eq({ schedule: { type: :crontab, value: "* * * * *" } })
end

it 'returns an event with correct optional attributes from interval config' do
event = subject.event_from_check_in(
slug,
status,
duration: 30,
check_in_id: "xxx-yyy",
monitor_config: Sentry::Cron::MonitorConfig.from_interval(30, :minute)
)

expect(event).to be_a(Sentry::CheckInEvent)

hash = event.to_hash
expect(hash[:monitor_slug]).to eq(slug)
expect(hash[:status]).to eq(status)
expect(hash[:check_in_id]).to eq("xxx-yyy")
expect(hash[:duration]).to eq(30)
expect(hash[:monitor_config]).to eq({ schedule: { type: :interval, value: 30, unit: :minute } })
end
end

describe "#generate_sentry_trace" do
let(:string_io) { StringIO.new }
let(:logger) do
Expand Down
Loading

0 comments on commit 286135c

Please sign in to comment.