Skip to content

Commit 286135c

Browse files
authored
Add new Sentry.capture_check_in API for Cron monitoring (#2117)
* 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`
1 parent 096e6c3 commit 286135c

14 files changed

+507
-9
lines changed

CHANGELOG.md

+9
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,15 @@
44

55
- Record client reports for profiles [#2107](https://github.com/getsentry/sentry-ruby/pull/2107)
66
- Adopt Rails 7.1's new BroadcastLogger [#2120](https://github.com/getsentry/sentry-ruby/pull/2120)
7+
- Add `Sentry.capture_check_in` API for Cron Monitoring [#2117](https://github.com/getsentry/sentry-ruby/pull/2117)
8+
9+
You can now track progress of long running scheduled jobs.
10+
11+
```rb
12+
check_in_id = Sentry.capture_check_in('job_name', :in_progress)
13+
# do job stuff
14+
Sentry.capture_check_in('job_name', :ok, check_in_id: check_in_id)
15+
```
716

817
### Bug Fixes
918

sentry-ruby/lib/sentry-ruby.rb

+19
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
require "sentry/event"
1616
require "sentry/error_event"
1717
require "sentry/transaction_event"
18+
require "sentry/check_in_event"
1819
require "sentry/span"
1920
require "sentry/transaction"
2021
require "sentry/hub"
@@ -430,6 +431,24 @@ def capture_event(event)
430431
get_current_hub.capture_event(event)
431432
end
432433

434+
# Captures a check-in and sends it to Sentry via the currently active hub.
435+
#
436+
# @param slug [String] identifier of this monitor
437+
# @param status [Symbol] status of this check-in, one of {CheckInEvent::VALID_STATUSES}
438+
#
439+
# @param [Hash] options extra check-in options
440+
# @option options [String] check_in_id for updating the status of an existing monitor
441+
# @option options [Integer] duration seconds elapsed since this monitor started
442+
# @option options [Cron::MonitorConfig] monitor_config configuration for this monitor
443+
#
444+
# @yieldparam scope [Scope]
445+
#
446+
# @return [String, nil] The {CheckInEvent#check_in_id} to use for later updates on the same slug
447+
def capture_check_in(slug, status, **options, &block)
448+
return unless initialized?
449+
get_current_hub.capture_check_in(slug, status, **options, &block)
450+
end
451+
433452
# Takes or initializes a new Sentry::Transaction and makes a sampling decision for it.
434453
#
435454
# @return [Transaction, nil]
+60
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
# frozen_string_literal
2+
3+
require 'securerandom'
4+
require 'sentry/cron/monitor_config'
5+
6+
module Sentry
7+
class CheckInEvent < Event
8+
TYPE = 'check_in'
9+
10+
# uuid to identify this check-in.
11+
# @return [String]
12+
attr_accessor :check_in_id
13+
14+
# Identifier of the monitor for this check-in.
15+
# @return [String]
16+
attr_accessor :monitor_slug
17+
18+
# Duration of this check since it has started in seconds.
19+
# @return [Integer, nil]
20+
attr_accessor :duration
21+
22+
# Monitor configuration to support upserts.
23+
# @return [Cron::MonitorConfig, nil]
24+
attr_accessor :monitor_config
25+
26+
# Status of this check-in.
27+
# @return [Symbol]
28+
attr_accessor :status
29+
30+
VALID_STATUSES = %i(ok in_progress error)
31+
32+
def initialize(
33+
slug:,
34+
status:,
35+
duration: nil,
36+
monitor_config: nil,
37+
check_in_id: nil,
38+
**options
39+
)
40+
super(**options)
41+
42+
self.monitor_slug = slug
43+
self.status = status
44+
self.duration = duration
45+
self.monitor_config = monitor_config
46+
self.check_in_id = check_in_id || SecureRandom.uuid.delete('-')
47+
end
48+
49+
# @return [Hash]
50+
def to_hash
51+
data = super
52+
data[:check_in_id] = check_in_id
53+
data[:monitor_slug] = monitor_slug
54+
data[:status] = status
55+
data[:duration] = duration if duration
56+
data[:monitor_config] = monitor_config.to_hash if monitor_config
57+
data
58+
end
59+
end
60+
end

sentry-ruby/lib/sentry/client.rb

+31
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,37 @@ def event_from_message(message, hint = {}, backtrace: nil)
104104
event
105105
end
106106

107+
# Initializes a CheckInEvent object with the given options.
108+
#
109+
# @param slug [String] identifier of this monitor
110+
# @param status [Symbol] status of this check-in, one of {CheckInEvent::VALID_STATUSES}
111+
# @param hint [Hash] the hint data that'll be passed to `before_send` callback and the scope's event processors.
112+
# @param duration [Integer, nil] seconds elapsed since this monitor started
113+
# @param monitor_config [Cron::MonitorConfig, nil] configuration for this monitor
114+
# @param check_in_id [String, nil] for updating the status of an existing monitor
115+
#
116+
# @return [Event]
117+
def event_from_check_in(
118+
slug,
119+
status,
120+
hint = {},
121+
duration: nil,
122+
monitor_config: nil,
123+
check_in_id: nil
124+
)
125+
return unless configuration.sending_allowed?
126+
127+
CheckInEvent.new(
128+
configuration: configuration,
129+
integration_meta: Sentry.integrations[hint[:integration]],
130+
slug: slug,
131+
status: status,
132+
duration: duration,
133+
monitor_config: monitor_config,
134+
check_in_id: check_in_id
135+
)
136+
end
137+
107138
# Initializes an Event object with the given Transaction object.
108139
# @param transaction [Transaction] the transaction to be recorded.
109140
# @return [TransactionEvent]
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
# frozen_string_literal
2+
3+
require 'sentry/cron/monitor_schedule'
4+
5+
module Sentry
6+
module Cron
7+
class MonitorConfig
8+
# The monitor schedule configuration
9+
# @return [MonitorSchedule::Crontab, MonitorSchedule::Interval]
10+
attr_accessor :schedule
11+
12+
# How long (in minutes) after the expected checkin time will we wait
13+
# until we consider the checkin to have been missed.
14+
# @return [Integer, nil]
15+
attr_accessor :checkin_margin
16+
17+
# How long (in minutes) is the checkin allowed to run for in in_progress
18+
# before it is considered failed.
19+
# @return [Integer, nil]
20+
attr_accessor :max_runtime
21+
22+
# tz database style timezone string
23+
# @return [String, nil]
24+
attr_accessor :timezone
25+
26+
def initialize(schedule, checkin_margin: nil, max_runtime: nil, timezone: nil)
27+
@schedule = schedule
28+
@checkin_margin = checkin_margin
29+
@max_runtime = max_runtime
30+
@timezone = timezone
31+
end
32+
33+
def self.from_crontab(crontab, **options)
34+
new(MonitorSchedule::Crontab.new(crontab), **options)
35+
end
36+
37+
def self.from_interval(num, unit, **options)
38+
return nil unless MonitorSchedule::Interval::VALID_UNITS.include?(unit)
39+
40+
new(MonitorSchedule::Interval.new(num, unit), **options)
41+
end
42+
43+
def to_hash
44+
{
45+
schedule: schedule.to_hash,
46+
checkin_margin: checkin_margin,
47+
max_runtime: max_runtime,
48+
timezone: timezone
49+
}.compact
50+
end
51+
end
52+
end
53+
end
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
# frozen_string_literal
2+
3+
module Sentry
4+
module Cron
5+
module MonitorSchedule
6+
class Crontab
7+
# A crontab formatted string such as "0 * * * *".
8+
# @return [String]
9+
attr_accessor :value
10+
11+
def initialize(value)
12+
@value = value
13+
end
14+
15+
def to_hash
16+
{ type: :crontab, value: value }
17+
end
18+
end
19+
20+
class Interval
21+
# The number representing duration of the interval.
22+
# @return [Integer]
23+
attr_accessor :value
24+
25+
# The unit representing duration of the interval.
26+
# @return [Symbol]
27+
attr_accessor :unit
28+
29+
VALID_UNITS = %i(year month week day hour minute)
30+
31+
def initialize(value, unit)
32+
@value = value
33+
@unit = unit
34+
end
35+
36+
def to_hash
37+
{ type: :interval, value: value, unit: unit }
38+
end
39+
end
40+
end
41+
end
42+
end

sentry-ruby/lib/sentry/hub.rb

+25-1
Original file line numberDiff line numberDiff line change
@@ -156,6 +156,30 @@ def capture_message(message, **options, &block)
156156
capture_event(event, **options, &block)
157157
end
158158

159+
def capture_check_in(slug, status, **options, &block)
160+
check_argument_type!(slug, ::String)
161+
check_argument_includes!(status, Sentry::CheckInEvent::VALID_STATUSES)
162+
163+
return unless current_client
164+
165+
options[:hint] ||= {}
166+
options[:hint][:slug] = slug
167+
168+
event = current_client.event_from_check_in(
169+
slug,
170+
status,
171+
options[:hint],
172+
duration: options.delete(:duration),
173+
monitor_config: options.delete(:monitor_config),
174+
check_in_id: options.delete(:check_in_id)
175+
)
176+
177+
return unless event
178+
179+
capture_event(event, **options, &block)
180+
event.check_in_id
181+
end
182+
159183
def capture_event(event, **options, &block)
160184
check_argument_type!(event, Sentry::Event)
161185

@@ -178,7 +202,7 @@ def capture_event(event, **options, &block)
178202
configuration.log_debug(event.to_json_compatible)
179203
end
180204

181-
@last_event_id = event&.event_id unless event.is_a?(Sentry::TransactionEvent)
205+
@last_event_id = event&.event_id if event.is_a?(Sentry::ErrorEvent)
182206
event
183207
end
184208

sentry-ruby/lib/sentry/integrable.rb

+6
Original file line numberDiff line numberDiff line change
@@ -22,5 +22,11 @@ def capture_message(message, **options, &block)
2222
options[:hint][:integration] = integration_name
2323
Sentry.capture_message(message, **options, &block)
2424
end
25+
26+
def capture_check_in(slug, status, **options, &block)
27+
options[:hint] ||= {}
28+
options[:hint][:integration] = integration_name
29+
Sentry.capture_check_in(slug, status, **options, &block)
30+
end
2531
end
2632
end

sentry-ruby/lib/sentry/utils/argument_checking_helper.rb

+6
Original file line numberDiff line numberDiff line change
@@ -9,5 +9,11 @@ def check_argument_type!(argument, *expected_types)
99
raise ArgumentError, "expect the argument to be a #{expected_types.join(' or ')}, got #{argument.class} (#{argument.inspect})"
1010
end
1111
end
12+
13+
def check_argument_includes!(argument, values)
14+
unless values.include?(argument)
15+
raise ArgumentError, "expect the argument to be one of #{values.map(&:inspect).join(' or ')}, got #{argument.inspect}"
16+
end
17+
end
1218
end
1319
end

sentry-ruby/spec/sentry/client_spec.rb

+53
Original file line numberDiff line numberDiff line change
@@ -510,6 +510,59 @@ module ExcTag; end
510510
end
511511
end
512512

513+
describe "#event_from_check_in" do
514+
let(:slug) { "test_slug" }
515+
let(:status) { :ok }
516+
517+
it 'returns an event' do
518+
event = subject.event_from_check_in(slug, status)
519+
expect(event).to be_a(Sentry::CheckInEvent)
520+
521+
hash = event.to_hash
522+
expect(hash[:monitor_slug]).to eq(slug)
523+
expect(hash[:status]).to eq(status)
524+
expect(hash[:check_in_id].length).to eq(32)
525+
end
526+
527+
it 'returns an event with correct optional attributes from crontab config' do
528+
event = subject.event_from_check_in(
529+
slug,
530+
status,
531+
duration: 30,
532+
check_in_id: "xxx-yyy",
533+
monitor_config: Sentry::Cron::MonitorConfig.from_crontab("* * * * *")
534+
)
535+
536+
expect(event).to be_a(Sentry::CheckInEvent)
537+
538+
hash = event.to_hash
539+
expect(hash[:monitor_slug]).to eq(slug)
540+
expect(hash[:status]).to eq(status)
541+
expect(hash[:check_in_id]).to eq("xxx-yyy")
542+
expect(hash[:duration]).to eq(30)
543+
expect(hash[:monitor_config]).to eq({ schedule: { type: :crontab, value: "* * * * *" } })
544+
end
545+
546+
it 'returns an event with correct optional attributes from interval config' do
547+
event = subject.event_from_check_in(
548+
slug,
549+
status,
550+
duration: 30,
551+
check_in_id: "xxx-yyy",
552+
monitor_config: Sentry::Cron::MonitorConfig.from_interval(30, :minute)
553+
)
554+
555+
expect(event).to be_a(Sentry::CheckInEvent)
556+
557+
hash = event.to_hash
558+
expect(hash[:monitor_slug]).to eq(slug)
559+
expect(hash[:status]).to eq(status)
560+
expect(hash[:check_in_id]).to eq("xxx-yyy")
561+
expect(hash[:duration]).to eq(30)
562+
expect(hash[:monitor_config]).to eq({ schedule: { type: :interval, value: 30, unit: :minute } })
563+
end
564+
end
565+
513566
describe "#generate_sentry_trace" do
514567
let(:string_io) { StringIO.new }
515568
let(:logger) do

0 commit comments

Comments
 (0)