cronwatch 0.11.1 → 0.12.0

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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 4a31800c33f9abb5092e104cc1572940190883006bab28dfc5c389d01ab3d5c8
4
- data.tar.gz: 2e85fbf87e5c1929124d105f54791eb36726f69eae05db77ff22b6b665672471
3
+ metadata.gz: 9d07fd66287ac18a9275761ad9f47f458677fb59a1a561127a41347489a56fcd
4
+ data.tar.gz: debcbaa164095d25d0362d477da3b3ce3a90ca4cf03a990a934c2cb3bacd6671
5
5
  SHA512:
6
- metadata.gz: dd728e8770453f91445d006f0ce7f15d709c04e4c130366a5adfb99f104570fca428e6363c91b69a55c0b58a6e4b90472ec13fd3588b85548b195161a4a2c0f8
7
- data.tar.gz: 951158243f2598f6d75fe9cb4095138a4480af3f1d9dc9de1d9f24af011d63ad4df5001bbc6e3227c70f9cdb3c1b3e031e4a4b175b570a12ab04786946484ad8
6
+ metadata.gz: f2ea67fe28637a9e9adf510927de1fd37a84d67376bbcffb1b6d8e64ed377ec3b0f6965a74bf57d4deb18c187e6a22b7c55460ed68198cd22ded70dcb59abd3c
7
+ data.tar.gz: 703d25d89a40768fd392f6564f7f8a686958847426dba51e56da19ff77a0ed8174a6cd1f68cbd15d3bcbde164f4fcf90c084dbd9eae8c6d8ecefbf7b406365e8
data/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # cronwatch
2
2
 
3
- Cron and scheduled-job monitoring that lives inside your Ruby or Rails app. Wrap a job once; every run is recorded in a database you already have, and you are told when a run is missed, fails, gets stuck, runs slow or goes over budget. No server to run, no account to make.
3
+ Cron and scheduled-job monitoring that lives inside your Ruby or Rails app. Wrap a job once; every run is recorded in a database you already have, and you are told when a run is missed, fails, gets stuck, runs slow, goes over budget or quietly does nothing. No server to run, no account to make.
4
4
 
5
5
  This is the Ruby port of [`@cronwatch/sdk`](https://www.npmjs.com/package/@cronwatch/sdk): the same rules, the same alert text and the same stored rows, so a Ruby process can share one database with a Node, Python, PHP, Go or Rust process, and [`@cronwatch/mcp`](https://www.npmjs.com/package/@cronwatch/mcp) works against any of them.
6
6
 
@@ -39,12 +39,13 @@ CW = Cronwatch.new(
39
39
 
40
40
  NIGHTLY = CW.job("nightly-report",
41
41
  schedule: "0 2 * * *", timezone: "UTC", grace: "15m", timeout: "30m",
42
- expect: "Report written", budget: { cost: 2 })
42
+ expect: "Report written", budget: { cost: 2 }, floor: { pages: 1 })
43
43
 
44
44
  NIGHTLY.run do |job|
45
45
  path = build_report
46
46
  job.log("Report written:", path) # kept with the run, shown in alerts
47
- job.metric(:cost, 1.2) # watched against budgets and baselines
47
+ job.metric(:cost, 1.2) # watched against budgets, floors and baselines
48
+ job.metric(:pages, 12) # fewer than 1 page is under the floor
48
49
  end
49
50
 
50
51
  CW.start_checking # checks for missed and stuck runs every minute, in a background thread
@@ -9,7 +9,8 @@ module Cronwatch
9
9
  # job:<name> and alert:<type>, then `tags`.
10
10
  class Datadog
11
11
  ALERT_TYPE = {
12
- missed: "error", failed: "error", stuck: "error", slow: "warning", over_budget: "warning", recovered: "success",
12
+ missed: "error", failed: "error", stuck: "error", slow: "warning", over_budget: "warning", under_floor: "warning",
13
+ recovered: "success",
13
14
  }.freeze
14
15
  private_constant :ALERT_TYPE
15
16
 
@@ -6,7 +6,8 @@ module Cronwatch
6
6
  # Integrations, Webhooks).
7
7
  class Discord
8
8
  COLOR = {
9
- missed: 0xb7791f, failed: 0xc62828, stuck: 0xc62828, slow: 0xb7791f, over_budget: 0xb7791f, recovered: 0x1f8a4c,
9
+ missed: 0xb7791f, failed: 0xc62828, stuck: 0xc62828, slow: 0xb7791f, over_budget: 0xb7791f, under_floor: 0xb7791f,
10
+ recovered: 0x1f8a4c,
10
11
  }.freeze
11
12
 
12
13
  # The longest embed description Discord takes. The title (under 256)
@@ -9,7 +9,7 @@ module Cronwatch
9
9
  class Honeybadger
10
10
  CLASS = {
11
11
  missed: "CronWatch::Missed", failed: "CronWatch::Failed", stuck: "CronWatch::Stuck", slow: "CronWatch::Slow",
12
- over_budget: "CronWatch::OverBudget", recovered: "CronWatch::Recovered",
12
+ over_budget: "CronWatch::OverBudget", under_floor: "CronWatch::UnderFloor", recovered: "CronWatch::Recovered",
13
13
  }.freeze
14
14
  private_constant :CLASS
15
15
 
@@ -19,7 +19,7 @@ module Cronwatch
19
19
  def severity(type)
20
20
  case type.to_sym
21
21
  when :recovered then "info"
22
- when :slow, :over_budget then "warning"
22
+ when :slow, :over_budget, :under_floor then "warning"
23
23
  else "error"
24
24
  end
25
25
  end
@@ -9,7 +9,7 @@ module Cronwatch
9
9
  class Slack
10
10
  EMOJI = {
11
11
  missed: ":hourglass_flowing_sand:", failed: ":x:", stuck: ":no_entry:", slow: ":turtle:",
12
- over_budget: ":moneybag:", recovered: ":white_check_mark:",
12
+ over_budget: ":moneybag:", under_floor: ":chart_with_downwards_trend:", recovered: ":white_check_mark:",
13
13
  }.freeze
14
14
  private_constant :EMOJI
15
15
 
@@ -659,7 +659,7 @@ module Cronwatch
659
659
  fields = {}
660
660
  @defaults.each { |k, v| fields[k] = v }
661
661
  options.each do |k, v|
662
- fields[k.to_sym] = v.is_a?(Hash) && k.to_sym == :budget ? v.transform_keys(&:to_s) : v
662
+ fields[k.to_sym] = v.is_a?(Hash) && %i[budget floor].include?(k.to_sym) ? v.transform_keys(&:to_s) : v
663
663
  end
664
664
  fields[:name] = name
665
665
  JobDefinition.new(fields)
@@ -700,6 +700,21 @@ module Cronwatch
700
700
  raise ArgumentError, "job \"#{name}\": budget.#{metric} must be a finite number, 0 or more (got #{js_string(ceiling)})"
701
701
  end
702
702
  end
703
+ unless definition.floor.nil?
704
+ raise ArgumentError, "job \"#{name}\": floor must be an object of { metric: floor }" unless definition.floor.is_a?(Hash)
705
+
706
+ definition.floor.each do |metric, floor|
707
+ unless floor.is_a?(Numeric) && floor.real? && JS.finite?(floor)
708
+ raise ArgumentError, "job \"#{name}\": floor.#{metric} must be a finite number (got #{js_string(floor)})"
709
+ end
710
+
711
+ ceiling = definition.budget.is_a?(Hash) ? definition.budget[metric.to_s] : nil
712
+ next if ceiling.nil? || floor <= ceiling
713
+
714
+ raise ArgumentError,
715
+ "job \"#{name}\": floor.#{metric} (#{js_string(floor)}) is above budget.#{metric} (#{js_string(ceiling)}), so every run would alert"
716
+ end
717
+ end
703
718
  expect = definition.expect
704
719
  return if expect.nil? || expect.is_a?(String) || expect.is_a?(Regexp) || expect.respond_to?(:call)
705
720
 
@@ -83,8 +83,8 @@ module Cronwatch
83
83
  end
84
84
 
85
85
  # A stored state with every field present, or a fresh one. State written
86
- # by an older version lacks the newer fields. `sending` is the exception:
87
- # it is there only while it holds an alert (see hold_alerts).
86
+ # by an older version lacks the newer fields. `sending` and `under_floor`
87
+ # are the exceptions: each is there only while it holds something.
88
88
  #
89
89
  # Read leniently, since a foreign, hand-edited or damaged row must affect
90
90
  # only its own job, and the next write puts it right: a state that is not
@@ -92,13 +92,15 @@ module Cronwatch
92
92
  # number (anything but an object reads as {}); `silenced_until` and
93
93
  # `last_alert_at` that are not numbers read as nil; `pending_recovery`
94
94
  # keeps only its conditions (strings as stored), and `undelivered` only
95
- # its entries that are objects (a list of neither shape reads as []).
96
- # Unknown fields are kept as written.
95
+ # its entries that are objects (a list of neither shape reads as []),
96
+ # and `under_floor` only its strings (absent when none). Unknown fields
97
+ # are kept as written.
97
98
  def normalize_state(state, job)
98
99
  state = JobState.from_h(state)
99
100
  return empty_state(job) if state.nil?
100
101
 
101
102
  sending = state.sending
103
+ under_floor = state.under_floor.is_a?(Array) ? state.under_floor.grep(String) : []
102
104
  open = state.open.is_a?(Hash) ? state.open.select { |_, at| json_number?(at) } : {}
103
105
  pending = state.pending_recovery.is_a?(Array) ? state.pending_recovery.grep(Symbol) : []
104
106
  undelivered = state.undelivered.is_a?(Array) ? state.undelivered.grep(Alert) : []
@@ -112,6 +114,7 @@ module Cronwatch
112
114
  undelivered: undelivered,
113
115
  version: state.version,
114
116
  sending: sending.is_a?(Array) && !sending.empty? ? sending.dup : nil,
117
+ under_floor: under_floor.empty? ? nil : under_floor,
115
118
  # Fields a newer release wrote, carried through every write.
116
119
  extra: state.extra,
117
120
  )
@@ -283,6 +286,42 @@ module Cronwatch
283
286
  breaches
284
287
  end
285
288
 
289
+ # The metrics of a successful run that fell below their floor. A metric
290
+ # with a floor breaches when it reports less than that floor. One without
291
+ # breaches when it reports 0 or less and either it did so on the job's
292
+ # last successful run too (`previous`, the metrics under their floor
293
+ # then), or the earlier successful runs that reported it (at least five,
294
+ # the newest twenty) all reported more than 0. So a job that keeps writing
295
+ # nothing stays under its floor however long it goes on, and a metric that
296
+ # is always 0 never alerts.
297
+ def floor_breaches(definition, run, history, previous = [])
298
+ breaches = []
299
+ floor = definition.floor
300
+ metrics = run.metrics || {}
301
+ JS.object_keys(metrics).each do |metric|
302
+ value = metrics[metric]
303
+ limit = floor&.[](metric.to_s)
304
+ unless limit.nil?
305
+ breaches << { metric: metric.to_s, value: value, limit: limit, basis: "floor" } if value < limit
306
+ next
307
+ end
308
+ next if value.positive?
309
+
310
+ if previous.include?(metric.to_s)
311
+ breaches << { metric: metric.to_s, value: value, limit: 0, basis: "0 or less on the run before too" }
312
+ next
313
+ end
314
+ past = history.select { |r| r.status == :ok && (r.metrics || {})[metric].is_a?(Numeric) }
315
+ .first(BASELINE_WINDOW).map { |r| r.metrics[metric] }
316
+ next if past.length < BASELINE_MIN_RUNS || !past.all?(&:positive?)
317
+
318
+ lowest = past.min
319
+ breaches << { metric: metric.to_s, value: value, limit: lowest,
320
+ basis: "the last #{past.length} runs all reported more than 0, the lowest #{format_number(lowest)}" }
321
+ end
322
+ breaches
323
+ end
324
+
286
325
  # Whether `history` (newest first) holds a full baseline window of successful runs.
287
326
  def full_baseline?(history)
288
327
  history.count { |r| r.status == :ok } >= BASELINE_WINDOW
@@ -364,6 +403,15 @@ module Cronwatch
364
403
  close_condition(next_state, :over_budget)
365
404
  end
366
405
 
406
+ short = floor_breaches(definition, run, history, next_state.under_floor || [])
407
+ if short.any?
408
+ next_state.under_floor = short.map { |b| b[:metric] }
409
+ alerts << AlertDraft.new(type: :under_floor, run: run, details: { breaches: short }) if open_condition(next_state, :under_floor, now)
410
+ else
411
+ next_state.under_floor = nil
412
+ close_condition(next_state, :under_floor)
413
+ end
414
+
367
415
  pending = next_state.pending_recovery || []
368
416
  if pending.any? && open_conditions(next_state).empty?
369
417
  alerts << AlertDraft.new(type: :recovered, run: run, details: { after: pending.dup })
@@ -77,6 +77,17 @@ module Cronwatch
77
77
  end
78
78
  lines << "Started #{at_time(run.started_at, now)}." if run
79
79
  "#{name} went over budget"
80
+ when :under_floor
81
+ details[:breaches].each do |b|
82
+ value = Evaluate.format_number(b[:value])
83
+ lines << if b[:basis] == "floor"
84
+ "#{b[:metric]}: #{value}, below the floor of #{Evaluate.format_number(b[:limit])}."
85
+ else
86
+ "#{b[:metric]}: #{value} (#{b[:basis]})."
87
+ end
88
+ end
89
+ lines << "Started #{at_time(run.started_at, now)}." if run
90
+ "#{name} fell short"
80
91
  when :recovered
81
92
  if details[:reason]&.to_sym == :unscheduled
82
93
  since = details[:since]
@@ -192,7 +192,7 @@ module Cronwatch
192
192
  # The class side: `cronwatch` and what it declares.
193
193
  module ClassMethods
194
194
  # Monitor this job. Takes the options of Cronwatch::Client#job (schedule,
195
- # timezone, grace, timeout, max_duration, budget, expect,
195
+ # timezone, grace, timeout, max_duration, budget, floor, expect,
196
196
  # failures_before_alert, description, tags) and name:, which defaults to
197
197
  # the class name without "Job", dasherized. schedule: :from_scheduler
198
198
  # takes the schedule (and its timezone) from the class's entry in Solid
@@ -86,7 +86,7 @@ module Cronwatch
86
86
  UTC = /\A(gmt|utc|z)\z/i
87
87
  SCHEDULE_ONLY = %i[schedule timezone].freeze
88
88
  # The options of a definition that are declared again, without its schedule, for a name no longer in use.
89
- UNSCHEDULED = %i[description tags grace timeout max_duration budget failures_before_alert].freeze
89
+ UNSCHEDULED = %i[description tags grace timeout max_duration budget floor failures_before_alert].freeze
90
90
  DESCRIBED = /\Apg_cron job (\d+) in /
91
91
  private_constant :BACKFILL, :COLUMNS, :DESCRIBED, :JOBS_SQL, :MAX_PAGES, :NEWEST_SQL, :PAGE, :REBOOT, :RUNS_SQL,
92
92
  :SCHEDULE_ONLY, :SECONDS, :SETTING_SQL, :UNSCHEDULED, :UTC
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Cronwatch
4
- CONDITIONS = %i[missed failed stuck slow over_budget].freeze
4
+ CONDITIONS = %i[missed failed stuck slow over_budget under_floor].freeze
5
5
  RUN_STATUSES = %i[running ok failed timeout].freeze
6
6
 
7
7
  # Ruby names are snake_case symbols; everything that leaves the process
@@ -74,7 +74,7 @@ module Cronwatch
74
74
 
75
75
  FIELDS = {
76
76
  name: "name", schedule: "schedule", timezone: "timezone", grace: "grace", timeout: "timeout",
77
- max_duration: "maxDuration", budget: "budget", expect: "expect",
77
+ max_duration: "maxDuration", budget: "budget", floor: "floor", expect: "expect",
78
78
  failures_before_alert: "failuresBeforeAlert", description: "description", tags: "tags",
79
79
  }.freeze
80
80
  OPTIONS = (FIELDS.keys - [:name]).freeze
@@ -386,7 +386,7 @@ module Cronwatch
386
386
  # when there are none. An open condition this version does not know stays
387
387
  # in `open` the same way, under its own name.
388
388
  JobState = Struct.new(:job, :open, :consecutive_failures, :silenced_until, :last_alert_at, :pending_recovery,
389
- :undelivered, :version, :sending, :extra, keyword_init: true) do
389
+ :undelivered, :version, :sending, :under_floor, :extra, keyword_init: true) do
390
390
  include Serializable
391
391
 
392
392
  def self.from_h(hash)
@@ -397,6 +397,7 @@ module Cronwatch
397
397
  pending = Naming.fetch(hash, "pendingRecovery")
398
398
  undelivered = Naming.fetch(hash, "undelivered")
399
399
  sending = Naming.fetch(hash, "sending")
400
+ under_floor = Naming.fetch(hash, "underFloor")
400
401
  extra = hash.each_with_object({}) { |(k, v), out| out[k.to_s] = v unless JobState::KNOWN.include?(k.to_s) }
401
402
  new(
402
403
  extra: extra.empty? ? nil : extra,
@@ -409,6 +410,7 @@ module Cronwatch
409
410
  undelivered: undelivered.is_a?(Array) ? undelivered.map { |a| a.is_a?(Hash) ? Alert.from_h(a) : a } : undelivered,
410
411
  version: Naming.fetch(hash, "version"),
411
412
  sending: sending.is_a?(Array) ? sending.map { |entry| JobState.sending_entry(entry) } : sending,
413
+ under_floor: under_floor,
412
414
  )
413
415
  end
414
416
 
@@ -434,8 +436,8 @@ module Cronwatch
434
436
 
435
437
  # pendingRecovery, undelivered and version are left out when unset, as in
436
438
  # state written before they existed. The version comes after them, where
437
- # the SDK's spread of a normalized state puts it, and `sending` last, and
438
- # only while it holds an entry.
439
+ # the SDK's spread of a normalized state puts it, then `sending` and
440
+ # `underFloor`, each only while it holds an entry.
439
441
  def to_h
440
442
  out = {
441
443
  "job" => job, "open" => open.is_a?(Hash) ? open.transform_keys(&:to_s) : (open || {}),
@@ -454,6 +456,7 @@ module Cronwatch
454
456
  entry.is_a?(Hash) ? entry.transform_values { |v| v.is_a?(Alert) ? v.to_h : v } : entry
455
457
  end
456
458
  end
459
+ out["underFloor"] = under_floor.dup if under_floor.is_a?(Array) && !under_floor.empty?
457
460
  extra&.each { |key, value| out[key] = value unless out.key?(key) || JobState::KNOWN.include?(key) }
458
461
  out
459
462
  end
@@ -461,7 +464,7 @@ module Cronwatch
461
464
 
462
465
  # The fields this version reads from a stored state; the rest are kept in `extra`.
463
466
  # @api private
464
- JobState::KNOWN = %w[job open consecutiveFailures silencedUntil lastAlertAt pendingRecovery undelivered version sending].freeze
467
+ JobState::KNOWN = %w[job open consecutiveFailures silencedUntil lastAlertAt pendingRecovery undelivered version sending underFloor].freeze
465
468
 
466
469
  JobStats = Struct.new(:runs, :ok_rate, :p50_ms, :p95_ms, keyword_init: true) do
467
470
  include Serializable
@@ -2,5 +2,5 @@
2
2
 
3
3
  module Cronwatch
4
4
  # Kept in step with @cronwatch/sdk.
5
- VERSION = "0.11.1"
5
+ VERSION = "0.12.0"
6
6
  end
@@ -290,7 +290,7 @@ module Cronwatch
290
290
  %(<p class="brand">#{home}<span class="slash" aria-hidden="true">/</span><span class="crumb">#{name_html(crumb)}</span></p>)
291
291
  end
292
292
 
293
- # The job's health, with any open condition it does not already say (over budget, slow) after it.
293
+ # The job's health, with any open condition it does not already say (over budget, under floor, slow) after it.
294
294
  def health_state(job)
295
295
  cls, label = HEALTH.fetch(job.health.to_sym)
296
296
  extras = job.open.map(&:to_sym).reject { |c| SHOWN_BY_HEALTH.include?(c) }
@@ -495,6 +495,7 @@ module Cronwatch
495
495
  %(<span class="muted">none</span>)
496
496
  end
497
497
  budget = truthy?(d.budget) ? entries(d.budget).map { |k, v| "#{k} ≤ #{text(v)}" }.join(", ") : nil
498
+ floor = truthy?(d.floor) ? entries(d.floor).map { |k, v| "#{k} ≥ #{text(v)}" }.join(", ") : nil
498
499
  tags = d.tags
499
500
  failures = d.failures_before_alert
500
501
  open = job.open.map do |c|
@@ -554,6 +555,7 @@ module Cronwatch
554
555
  <dt>Timeout</dt><dd>#{h(d.timeout.nil? ? "1h" : d.timeout)}</dd>
555
556
  #{truthy?(d.max_duration) ? %(<dt>Max duration</dt><dd>#{h(d.max_duration)}</dd>) : ""}
556
557
  #{budget ? %(<dt>Budget</dt><dd>#{h(budget)}</dd>) : ""}
558
+ #{floor ? %(<dt>Floor</dt><dd>#{h(floor)}</dd>) : ""}
557
559
  #{truthy?(d.expect) ? %(<dt>Expect</dt><dd>#{h(d.expect)}</dd>) : ""}
558
560
  #{truthy?(failures) && failures > 1 ? %(<dt>Alert after</dt><dd>#{h(failures)} consecutive failures</dd>) : ""}
559
561
  #{tags.is_a?(Array) && tags.any? ? %(<dt>Tags</dt><dd>#{tags.map { |t| h(t) }.join(", ")}</dd>) : ""}
@@ -178,7 +178,7 @@ module Cronwatch
178
178
  return "timeout" if status == "timeout"
179
179
 
180
180
  latest = !job.last_run.nil? && job.last_run.id == run.id
181
- latest && (open?(job, :over_budget) || open?(job, :slow)) ? "warn" : "ok"
181
+ latest && (open?(job, :over_budget) || open?(job, :under_floor) || open?(job, :slow)) ? "warn" : "ok"
182
182
  end
183
183
 
184
184
  def timeout_text(job)
@@ -192,7 +192,12 @@ module Cronwatch
192
192
  return "running since #{at}, past its #{timeout_text(job)} timeout" if tone == "stuck"
193
193
 
194
194
  took = run.duration_ms.nil? ? "" : ", took #{Duration.format(run.duration_ms)}"
195
- extra = tone == "warn" ? (open?(job, :over_budget) ? ", over budget" : ", slow") : ""
195
+ extra =
196
+ if tone != "warn" then ""
197
+ elsif open?(job, :over_budget) then ", over budget"
198
+ elsif open?(job, :under_floor) then ", under floor"
199
+ else ", slow"
200
+ end
196
201
  "#{run.status} at #{at}#{took}#{extra}"
197
202
  end
198
203
 
@@ -205,6 +210,16 @@ module Cronwatch
205
210
  end.map(&:first)
206
211
  end
207
212
 
213
+ # The metrics of the job's last run under their floors, or at 0 or less without one.
214
+ def under_floors(job)
215
+ floors = HTML.entries(job.definition.floor).to_h
216
+ metrics = job.last_run&.metrics || {}
217
+ JS.object_keys(metrics).select do |k|
218
+ value = metrics[k]
219
+ floors.key?(k.to_s) ? value < floors[k.to_s] : value <= 0
220
+ end.map(&:to_s)
221
+ end
222
+
208
223
  # What is worth saying about the job in a few words, or nil when all is well.
209
224
  def lane_note(job, missed, now)
210
225
  last = job.last_run
@@ -226,6 +241,10 @@ module Cronwatch
226
241
  over = over_ceilings(job)
227
242
  return "went over budget#{over.empty? ? "" : " on #{over.join(" and ")}"} at #{when_at(last.started_at, now)}"
228
243
  end
244
+ if open?(job, :under_floor) && last
245
+ under = under_floors(job)
246
+ return "fell short#{under.empty? ? "" : " on #{under.join(" and ")}"} at #{when_at(last.started_at, now)}"
247
+ end
229
248
  return "slow: took #{Duration.format(last.duration_ms)}" if open?(job, :slow) && !last&.duration_ms.nil?
230
249
  return "failing" if open?(job, :failed)
231
250
  return "no runs yet, first due #{when_at(job.next_expected_at, now)}" if last.nil? && !job.next_expected_at.nil?
@@ -386,7 +405,7 @@ module Cronwatch
386
405
  [box.call("run ok"), "ran"],
387
406
  [box.call("run bad"), "failed"],
388
407
  [box.call("run timeout"), "timed out"],
389
- [box.call("run warn"), "over budget or slow"],
408
+ [box.call("run warn"), "over budget, under floor or slow"],
390
409
  [box.call("run running"), "running"],
391
410
  [box.call("missed"), "missed"],
392
411
  ]
@@ -126,8 +126,8 @@ module Cronwatch
126
126
  <<~RUBY
127
127
  # frozen_string_literal: true
128
128
 
129
- # CronWatch: told when a scheduled job is missed, failed, stuck, slow or
130
- # over budget. https://cronwatch.dev/docs/
129
+ # CronWatch: told when a scheduled job is missed, failed, stuck, slow,
130
+ # over budget or under its floor. https://cronwatch.dev/docs/
131
131
  #
132
132
  # Monitor a job by including Cronwatch::ActiveJob and declaring its schedule:
133
133
  #
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: cronwatch
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.11.1
4
+ version: 0.12.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Jon C. Phillips