cronwatch 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +21 -0
  3. data/README.md +268 -0
  4. data/lib/cronwatch/abort_signal.rb +45 -0
  5. data/lib/cronwatch/active_record.rb +11 -0
  6. data/lib/cronwatch/alerts/console.rb +22 -0
  7. data/lib/cronwatch/alerts/custom.rb +26 -0
  8. data/lib/cronwatch/alerts/discord.rb +52 -0
  9. data/lib/cronwatch/alerts/slack.rb +58 -0
  10. data/lib/cronwatch/alerts/webhook.rb +46 -0
  11. data/lib/cronwatch/client.rb +925 -0
  12. data/lib/cronwatch/cron_pattern.rb +277 -0
  13. data/lib/cronwatch/duration.rb +77 -0
  14. data/lib/cronwatch/environment.rb +29 -0
  15. data/lib/cronwatch/evaluate.rb +345 -0
  16. data/lib/cronwatch/flight.rb +42 -0
  17. data/lib/cronwatch/format.rb +89 -0
  18. data/lib/cronwatch/http.rb +52 -0
  19. data/lib/cronwatch/job.rb +144 -0
  20. data/lib/cronwatch/js.rb +188 -0
  21. data/lib/cronwatch/monitored.rb +259 -0
  22. data/lib/cronwatch/output.rb +199 -0
  23. data/lib/cronwatch/rails/active_job.rb +55 -0
  24. data/lib/cronwatch/rails/check_job.rb +32 -0
  25. data/lib/cronwatch/rails/railtie.rb +37 -0
  26. data/lib/cronwatch/rails/tasks.rb +12 -0
  27. data/lib/cronwatch/rails.rb +35 -0
  28. data/lib/cronwatch/schedule.rb +191 -0
  29. data/lib/cronwatch/scheduler.rb +763 -0
  30. data/lib/cronwatch/serialize.rb +51 -0
  31. data/lib/cronwatch/sidekiq.rb +129 -0
  32. data/lib/cronwatch/stats.rb +23 -0
  33. data/lib/cronwatch/stores/active_record.rb +397 -0
  34. data/lib/cronwatch/stores/memory.rb +163 -0
  35. data/lib/cronwatch/ticker.rb +59 -0
  36. data/lib/cronwatch/triage/anthropic.rb +134 -0
  37. data/lib/cronwatch/types.rb +341 -0
  38. data/lib/cronwatch/version.rb +6 -0
  39. data/lib/cronwatch/walker.rb +137 -0
  40. data/lib/cronwatch/web/app.rb +484 -0
  41. data/lib/cronwatch/web/html.rb +314 -0
  42. data/lib/cronwatch/web.rb +17 -0
  43. data/lib/cronwatch/zone.rb +72 -0
  44. data/lib/cronwatch.rb +96 -0
  45. data/lib/generators/cronwatch/install/install_generator.rb +176 -0
  46. metadata +104 -0
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_support/concern"
4
+ require "active_support/inflector"
5
+ require "active_job"
6
+ require_relative "../scheduler"
7
+
8
+ module Cronwatch
9
+ # Monitors an ActiveJob class. Each perform is a recorded run with the
10
+ # trigger "active_job"; `cronwatch` in the job is the run's context, for
11
+ # log and metric. A perform that raises is recorded as failed and then
12
+ # raises as before, so retry_on, discard_on and error reporters see it
13
+ # unchanged.
14
+ #
15
+ # class NightlyReportJob < ApplicationJob
16
+ # include Cronwatch::ActiveJob
17
+ # cronwatch schedule: "0 2 * * *", grace: "15m", expect: "Report written" # name: "nightly-report"
18
+ #
19
+ # def perform
20
+ # cronwatch.log("Report written")
21
+ # cronwatch.metric(:cost, 1.2)
22
+ # end
23
+ # end
24
+ #
25
+ # `cronwatch schedule: :from_scheduler` takes the schedule from the class's
26
+ # entry in config/recurring.yml (Solid Queue) or sidekiq-cron's schedule,
27
+ # so the cron expression is written once.
28
+ #
29
+ # The job is declared on Cronwatch.client once the app has booted (after
30
+ # config/initializers/cronwatch.rb ran), and again if the client is
31
+ # replaced. Only classes that call `cronwatch` are monitored; subclasses
32
+ # declare their own.
33
+ module ActiveJob
34
+ extend ActiveSupport::Concern
35
+ include Cronwatch::Monitored
36
+
37
+ TRIGGER = "active_job"
38
+
39
+ included do
40
+ around_perform :cronwatch_perform
41
+ end
42
+
43
+ class_methods do
44
+ include Cronwatch::Monitored::ClassMethods
45
+ end
46
+
47
+ private
48
+
49
+ # Runs the perform as a recorded run. A job whose declaration is broken
50
+ # still performs, unrecorded, with the error sent to on_error.
51
+ def cronwatch_perform(&block)
52
+ Cronwatch::Monitored.record(self.class.cronwatch_declaration, TRIGGER, self, &block)
53
+ end
54
+ end
55
+ end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_job"
4
+
5
+ module Cronwatch
6
+ # Looks for missed and stuck runs across every job, sends alerts, retries
7
+ # undelivered ones and prunes old runs: Cronwatch.client.check, as a job.
8
+ # Schedule it every few minutes; nothing else notices a job that never ran.
9
+ #
10
+ # # config/recurring.yml (Solid Queue)
11
+ # production:
12
+ # cronwatch_check:
13
+ # class: Cronwatch::CheckJob
14
+ # schedule: every 5 minutes
15
+ #
16
+ # # config/schedule.yml (sidekiq-cron)
17
+ # cronwatch_check:
18
+ # cron: "*/5 * * * *"
19
+ # class: "Cronwatch::CheckJob"
20
+ #
21
+ # With Sidekiq but no ActiveJob adapter for it, Cronwatch::Sidekiq::CheckWorker
22
+ # does the same. Returns the CheckResult.
23
+ class CheckJob < ::ActiveJob::Base
24
+ queue_as :default
25
+
26
+ def perform
27
+ Cronwatch::Monitored.load_app_jobs
28
+ Cronwatch::Monitored.register_all(strict: false)
29
+ Cronwatch.client.check
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/railtie"
4
+
5
+ module Cronwatch
6
+ # Hooks the gem into a Rails app. There is nothing to start: runs are
7
+ # recorded as monitored jobs perform, and checks come from
8
+ # Cronwatch::CheckJob (or Cronwatch::Sidekiq::CheckWorker) on your
9
+ # scheduler, so no interval thread runs inside web or worker processes.
10
+ # Errors outside jobs (the store, a channel) go to Rails.logger unless the
11
+ # initializer sets on_error.
12
+ class Railtie < ::Rails::Railtie
13
+ # With Sidekiq in the bundle, its server records the runs of
14
+ # Cronwatch::Sidekiq jobs. Checked here rather than when the gem loads,
15
+ # so the order of the Gemfile does not matter.
16
+ initializer "cronwatch.sidekiq" do
17
+ if defined?(::Sidekiq) && ::Sidekiq.respond_to?(:configure_server)
18
+ require "cronwatch/sidekiq"
19
+ Cronwatch::Sidekiq.install
20
+ end
21
+ end
22
+
23
+ # Once config/initializers/cronwatch.rb has run and, in production, app/jobs
24
+ # is loaded: declare every monitored job, and what
25
+ # Cronwatch.declare_from_scheduler! asked for, so a check knows each
26
+ # schedule before the job first runs. A bad declaration (or a schedule
27
+ # that cannot be read from the scheduler's config) stops the boot, as it
28
+ # would in plain Ruby.
29
+ config.after_initialize do
30
+ Cronwatch::Monitored.boot!
31
+ end
32
+
33
+ rake_tasks do
34
+ require_relative "tasks"
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ # `bin/rails cronwatch:check`: one check, for a plain crontab instead of a job scheduler.
4
+ namespace :cronwatch do
5
+ desc "Check for missed and stuck runs and send alerts (what Cronwatch::CheckJob does)"
6
+ task check: :environment do
7
+ result = Cronwatch::CheckJob.perform_now
8
+ jobs = result.jobs.length
9
+ alerts = result.alerts.length
10
+ puts "cronwatch: checked #{jobs} job#{jobs == 1 ? "" : "s"}, sent #{alerts} alert#{alerts == 1 ? "" : "s"}"
11
+ end
12
+ end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ # The Rails integration: a Railtie, the Cronwatch::ActiveJob concern,
4
+ # Cronwatch::CheckJob, Cronwatch::Web and the `cronwatch:install` generator.
5
+ # Needs railties and activejob; the ActiveRecord store loads on first use,
6
+ # and so does Cronwatch::Sidekiq when the app has Sidekiq (the Railtie loads
7
+ # it at boot).
8
+ #
9
+ # `gem "cronwatch"` loads this file on its own when Rails is already loaded,
10
+ # as it is under Bundler.require; require it by hand only otherwise.
11
+ require "cronwatch" unless defined?(Cronwatch::Client) # loaded from cronwatch.rb when Rails is already up
12
+ begin
13
+ require "rails"
14
+ require "active_job"
15
+ rescue LoadError => e
16
+ raise LoadError, "cronwatch/rails needs the railties and activejob gems, which a Rails app has (#{e.message})"
17
+ end
18
+
19
+ # Rails always brings Rack, so the dashboard loads here and the mount line in
20
+ # config/routes.rb needs no require of its own.
21
+ require_relative "web"
22
+
23
+ module Cronwatch
24
+ autoload :Sidekiq, File.expand_path("sidekiq", __dir__) unless const_defined?(:Sidekiq, false)
25
+
26
+ module Stores
27
+ autoload :ActiveRecord, File.expand_path("stores/active_record", __dir__) unless const_defined?(:ActiveRecord, false)
28
+ end
29
+ end
30
+
31
+ require_relative "sidekiq" if defined?(::Sidekiq::Job) || defined?(::Sidekiq::Worker)
32
+
33
+ require_relative "rails/active_job"
34
+ require_relative "rails/check_job"
35
+ require_relative "rails/railtie"
@@ -0,0 +1,191 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fugit"
4
+ require_relative "zone"
5
+ require_relative "cron_pattern"
6
+
7
+ module Cronwatch
8
+ # Schedules: "0 2 * * *" (cron, five or six fields), "@hourly", or
9
+ # "every 5m". The SDK computes fire times with croner; this module gives
10
+ # the same times. Fugit parses the cron fields. Croner's rules decide what
11
+ # is accepted and how a fire lands on a clock that jumps (daylight saving),
12
+ # so both are ported here and a Node and a Ruby process sharing one store
13
+ # agree on every due time.
14
+ module Schedule
15
+ # How early a run may start and still count for the fire it was meant for.
16
+ EARLY_SLACK_MS = 60_000
17
+
18
+ Parsed = Struct.new(:kind, :source, :timezone, :every_ms, keyword_init: true) do
19
+ include Serializable
20
+
21
+ # The compiled cron fields, kept out of the public shape.
22
+ attr_accessor :pattern
23
+
24
+ def cron? = kind == :cron
25
+ def interval? = kind == :interval
26
+
27
+ def to_h
28
+ out = { "kind" => kind.to_s, "source" => source }
29
+ out["timezone"] = timezone if timezone
30
+ out["everyMs"] = every_ms if every_ms
31
+ out
32
+ end
33
+ end
34
+
35
+ Expectation = Struct.new(:due_at, :deadline, keyword_init: true) do
36
+ include Serializable
37
+
38
+ def to_h = { "dueAt" => due_at, "deadline" => deadline }
39
+ end
40
+
41
+ @cache = {}
42
+ @lock = Mutex.new
43
+
44
+ module_function
45
+
46
+ # Parsed once per (schedule, timezone) pair and cached. Without a timezone
47
+ # the expression is read in the process timezone, like crontab. Vercel and
48
+ # GitHub Actions run their crons in UTC, so pass timezone: "UTC" for those.
49
+ def parse(schedule, timezone = nil)
50
+ key = "#{timezone}|#{schedule}"
51
+ hit = @lock.synchronize { @cache[key] }
52
+ return hit if hit
53
+
54
+ text = JS.trim(schedule.to_s)
55
+ every = Regexp.new("\\Aevery[#{JS::WHITESPACE}]+(.+)\\z", Regexp::IGNORECASE).match(text)
56
+ if every
57
+ every_ms = Duration.parse(every[1], "schedule interval")
58
+ raise ArgumentError, "schedule \"#{schedule}\" is shorter than one second" if every_ms < 1000
59
+
60
+ parsed = Parsed.new(kind: :interval, source: text, every_ms: every_ms)
61
+ else
62
+ begin
63
+ pattern = CronPattern.compile(text)
64
+ rescue ArgumentError => e
65
+ raise ArgumentError, "schedule \"#{schedule}\" is not a cron expression or \"every <duration>\": #{e.message}"
66
+ end
67
+ parsed = Parsed.new(kind: :cron, source: text, timezone: timezone.nil? || timezone == "" ? nil : timezone)
68
+ parsed.pattern = pattern
69
+ end
70
+ parsed.freeze
71
+ @lock.synchronize { @cache[key] = parsed }
72
+ end
73
+
74
+ # The first fire strictly after `from`, or nil when the cron never fires
75
+ # again. The walker works on the wall clock and Zone.to_utc takes the
76
+ # earlier of a repeated time, so, like croner, asked from inside the hour
77
+ # that repeats when clocks go back it can answer with times in the past.
78
+ # Its answers are filtered, and a stretch of nothing but past times is
79
+ # stepped over an hour at a time. Ported from fireAfter in schedule.ts.
80
+ def fire_after(parsed, from)
81
+ raise ArgumentError, "schedule \"#{parsed.source}\" was not made by Schedule.parse" unless parsed.pattern
82
+
83
+ probe = from
84
+ 4.times do
85
+ runs = next_runs(parsed, 8, probe)
86
+ return nil if runs.empty?
87
+
88
+ found = runs.find { |t| t > from }
89
+ return found if found
90
+
91
+ probe += 3_600_000
92
+ end
93
+ nil
94
+ end
95
+
96
+ # Up to `count` fires, each found from the one before, as croner's nextRuns.
97
+ def next_runs(parsed, count, from)
98
+ runs = []
99
+ at = from
100
+ count.times do
101
+ at = parsed.pattern.next_after(at, parsed.timezone)
102
+ break if at.nil?
103
+
104
+ runs << at
105
+ end
106
+ runs
107
+ end
108
+
109
+ # The next time the schedule fires strictly after `from`. For an interval, counted from the last run when there is one.
110
+ def next_fire(parsed, from, last_run_at)
111
+ return (last_run_at || from) + parsed.every_ms if parsed.interval?
112
+
113
+ fire_after(parsed, from)
114
+ end
115
+
116
+ # When the schedule next wants a run, given the last one. For a cron that is
117
+ # the first fire the last run does not already cover; with no run yet, the
118
+ # first fire at or after registration. For an interval it is the last run's
119
+ # start (or registration) plus the interval. Nil for a cron that never
120
+ # fires again.
121
+ #
122
+ # Counting forward from the last run, rather than back from now, is what
123
+ # lets a job whose period is shorter than its grace be missed at all, and it
124
+ # works for a cron that fires once a year or less.
125
+ def expectation(parsed, last_run_at, registered_at, grace_ms)
126
+ due_at =
127
+ if parsed.interval? then (last_run_at || registered_at) + parsed.every_ms
128
+ elsif last_run_at.nil? then fire_after(parsed, registered_at - 1)
129
+ else due_after_run(parsed, last_run_at)
130
+ end
131
+ due_at.nil? ? nil : Expectation.new(due_at: due_at, deadline: due_at + grace_ms)
132
+ end
133
+
134
+ # The first fire that a run starting at `started_at` does not cover.
135
+ def due_after_run(parsed, started_at)
136
+ # A fire at or before the start is covered by the run itself.
137
+ following_fire = fire_after(parsed, started_at)
138
+ return nil if following_fire.nil?
139
+
140
+ following = fire_after(parsed, following_fire)
141
+ covers = run_covers?(started_at, following_fire, following) || in_spring_forward_gap?(parsed, started_at, following_fire)
142
+ covers ? following : following_fire
143
+ end
144
+
145
+ # Whether a run starting at `started_at` covers the fire at `due_at`. A minute
146
+ # of slack before the tick absorbs schedulers that fire a touch early. When
147
+ # the fire after `due_at` is known, the slack is at most half the gap between
148
+ # the two, so one run of an every-minute cron never covers two fires.
149
+ def run_covers?(started_at, due_at, following_at = nil)
150
+ slack = following_at.nil? ? EARLY_SLACK_MS : [EARLY_SLACK_MS, (following_at - due_at).div(2)].min
151
+ started_at >= due_at - slack
152
+ end
153
+
154
+ # On the night clocks spring forward, a fire whose local time does not exist
155
+ # (02:30 when 02:00 jumps to 03:00) is moved by croner to the same distance
156
+ # past the jump (03:30), while vixie cron runs it at the jump itself (03:00).
157
+ # A run that starts at or after the jump, and before the first fire after
158
+ # it when that fire lies within one gap of it, is taken to cover that fire,
159
+ # so neither scheduler's run is reported as missed. A cron that really fires
160
+ # at 03:30 that night is treated the same way, which only matters if it also
161
+ # ran early by up to an hour.
162
+ def in_spring_forward_gap?(parsed, started_at, fire_at)
163
+ lookback = 3 * 3_600_000
164
+ after = utc_offset(fire_at, parsed.timezone)
165
+ before = utc_offset(fire_at - lookback, parsed.timezone)
166
+ gap = after - before
167
+ return false if gap <= 0
168
+
169
+ # Find the jump: the first minute in the window with the later offset.
170
+ lo = fire_at - lookback
171
+ hi = fire_at
172
+ while hi - lo > 60_000
173
+ mid = lo + (hi - lo).div(2)
174
+ if utc_offset(mid, parsed.timezone) == after then hi = mid
175
+ else lo = mid
176
+ end
177
+ end
178
+ jump_at = hi.div(60_000) * 60_000
179
+ return false if fire_at - jump_at >= gap || started_at < jump_at - EARLY_SLACK_MS || started_at >= fire_at
180
+
181
+ # Only the first fire after the jump can be a moved one; a cron that also
182
+ # fires at the jump (every 10 minutes, say) was not moved at all.
183
+ fire_after(parsed, jump_at - 1) == fire_at
184
+ end
185
+
186
+ # Milliseconds the zone's wall clock is ahead of UTC at `at`.
187
+ def utc_offset(at, timezone)
188
+ Zone.offset(at.div(1000), timezone) * 1000
189
+ end
190
+ end
191
+ end