Skip to content

Commit

Permalink
Add new Sentry.capture_check_in API for Cron monitoring
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 committed Oct 5, 2023
1 parent ba29bb8 commit fb9bc77
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)

Check warning on line 449 in sentry-ruby/lib/sentry-ruby.rb

View check run for this annotation

Codecov / codecov/patch

sentry-ruby/lib/sentry-ruby.rb#L448-L449

Added lines #L448 - L449 were not covered by tests
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)

Check warning on line 40 in sentry-ruby/lib/sentry/check_in_event.rb

View check run for this annotation

Codecov / codecov/patch

sentry-ruby/lib/sentry/check_in_event.rb#L40

Added line #L40 was not covered by tests

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('-')

Check warning on line 46 in sentry-ruby/lib/sentry/check_in_event.rb

View check run for this annotation

Codecov / codecov/patch

sentry-ruby/lib/sentry/check_in_event.rb#L42-L46

Added lines #L42 - L46 were not covered by tests
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

Check warning on line 57 in sentry-ruby/lib/sentry/check_in_event.rb

View check run for this annotation

Codecov / codecov/patch

sentry-ruby/lib/sentry/check_in_event.rb#L51-L57

Added lines #L51 - L57 were not covered by tests
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?

Check warning on line 125 in sentry-ruby/lib/sentry/client.rb

View check run for this annotation

Codecov / codecov/patch

sentry-ruby/lib/sentry/client.rb#L125

Added line #L125 was not covered by tests

CheckInEvent.new(

Check warning on line 127 in sentry-ruby/lib/sentry/client.rb

View check run for this annotation

Codecov / codecov/patch

sentry-ruby/lib/sentry/client.rb#L127

Added line #L127 was not covered by tests
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

Check warning on line 30 in sentry-ruby/lib/sentry/cron/monitor_config.rb

View check run for this annotation

Codecov / codecov/patch

sentry-ruby/lib/sentry/cron/monitor_config.rb#L27-L30

Added lines #L27 - L30 were not covered by tests
end

def self.from_crontab(crontab, **options)
new(MonitorSchedule::Crontab.new(crontab), **options)

Check warning on line 34 in sentry-ruby/lib/sentry/cron/monitor_config.rb

View check run for this annotation

Codecov / codecov/patch

sentry-ruby/lib/sentry/cron/monitor_config.rb#L34

Added line #L34 was not covered by tests
end

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

Check warning on line 38 in sentry-ruby/lib/sentry/cron/monitor_config.rb

View check run for this annotation

Codecov / codecov/patch

sentry-ruby/lib/sentry/cron/monitor_config.rb#L38

Added line #L38 was not covered by tests

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

Check warning on line 40 in sentry-ruby/lib/sentry/cron/monitor_config.rb

View check run for this annotation

Codecov / codecov/patch

sentry-ruby/lib/sentry/cron/monitor_config.rb#L40

Added line #L40 was not covered by tests
end

def to_hash
{
schedule: schedule.to_hash,

Check warning on line 45 in sentry-ruby/lib/sentry/cron/monitor_config.rb

View check run for this annotation

Codecov / codecov/patch

sentry-ruby/lib/sentry/cron/monitor_config.rb#L45

Added line #L45 was not covered by tests
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

Check warning on line 12 in sentry-ruby/lib/sentry/cron/monitor_schedule.rb

View check run for this annotation

Codecov / codecov/patch

sentry-ruby/lib/sentry/cron/monitor_schedule.rb#L12

Added line #L12 was not covered by tests
end

def to_hash
{ type: :crontab, value: value }

Check warning on line 16 in sentry-ruby/lib/sentry/cron/monitor_schedule.rb

View check run for this annotation

Codecov / codecov/patch

sentry-ruby/lib/sentry/cron/monitor_schedule.rb#L16

Added line #L16 was not covered by tests
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

Check warning on line 33 in sentry-ruby/lib/sentry/cron/monitor_schedule.rb

View check run for this annotation

Codecov / codecov/patch

sentry-ruby/lib/sentry/cron/monitor_schedule.rb#L32-L33

Added lines #L32 - L33 were not covered by tests
end

def to_hash
{ type: :interval, value: value, unit: unit }

Check warning on line 37 in sentry-ruby/lib/sentry/cron/monitor_schedule.rb

View check run for this annotation

Codecov / codecov/patch

sentry-ruby/lib/sentry/cron/monitor_schedule.rb#L37

Added line #L37 was not covered by tests
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)

Check warning on line 161 in sentry-ruby/lib/sentry/hub.rb

View check run for this annotation

Codecov / codecov/patch

sentry-ruby/lib/sentry/hub.rb#L160-L161

Added lines #L160 - L161 were not covered by tests

return unless current_client

Check warning on line 163 in sentry-ruby/lib/sentry/hub.rb

View check run for this annotation

Codecov / codecov/patch

sentry-ruby/lib/sentry/hub.rb#L163

Added line #L163 was not covered by tests

options[:hint] ||= {}
options[:hint][:slug] = slug

Check warning on line 166 in sentry-ruby/lib/sentry/hub.rb

View check run for this annotation

Codecov / codecov/patch

sentry-ruby/lib/sentry/hub.rb#L165-L166

Added lines #L165 - L166 were not covered by tests

event = current_client.event_from_check_in(

Check warning on line 168 in sentry-ruby/lib/sentry/hub.rb

View check run for this annotation

Codecov / codecov/patch

sentry-ruby/lib/sentry/hub.rb#L168

Added line #L168 was not covered by tests
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

Check warning on line 177 in sentry-ruby/lib/sentry/hub.rb

View check run for this annotation

Codecov / codecov/patch

sentry-ruby/lib/sentry/hub.rb#L177

Added line #L177 was not covered by tests

capture_event(event, **options, &block)
event.check_in_id

Check warning on line 180 in sentry-ruby/lib/sentry/hub.rb

View check run for this annotation

Codecov / codecov/patch

sentry-ruby/lib/sentry/hub.rb#L179-L180

Added lines #L179 - L180 were not covered by tests
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)

Check warning on line 29 in sentry-ruby/lib/sentry/integrable.rb

View check run for this annotation

Codecov / codecov/patch

sentry-ruby/lib/sentry/integrable.rb#L27-L29

Added lines #L27 - L29 were not covered by tests
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}"

Check warning on line 15 in sentry-ruby/lib/sentry/utils/argument_checking_helper.rb

View check run for this annotation

Codecov / codecov/patch

sentry-ruby/lib/sentry/utils/argument_checking_helper.rb#L14-L15

Added lines #L14 - L15 were not covered by tests
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 fb9bc77

Please sign in to comment.