cronwatch 0.9.0 → 0.11.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 +4 -4
- data/README.md +20 -11
- data/lib/cronwatch/alerts/datadog.rb +1 -0
- data/lib/cronwatch/alerts/discord.rb +34 -2
- data/lib/cronwatch/alerts/email.rb +2 -0
- data/lib/cronwatch/alerts/honeybadger.rb +1 -0
- data/lib/cronwatch/alerts/postmark.rb +1 -0
- data/lib/cronwatch/alerts/provider.rb +2 -0
- data/lib/cronwatch/alerts/resend.rb +1 -0
- data/lib/cronwatch/alerts/rollbar.rb +1 -0
- data/lib/cronwatch/alerts/sentry.rb +1 -0
- data/lib/cronwatch/alerts/sigv4.rb +2 -0
- data/lib/cronwatch/alerts/slack.rb +1 -0
- data/lib/cronwatch/alerts/twilio.rb +1 -0
- data/lib/cronwatch/alerts/webhook.rb +18 -5
- data/lib/cronwatch/client.rb +275 -110
- data/lib/cronwatch/cron_pattern.rb +17 -8
- data/lib/cronwatch/deprecation.rb +18 -0
- data/lib/cronwatch/duration.rb +2 -0
- data/lib/cronwatch/environment.rb +109 -7
- data/lib/cronwatch/evaluate.rb +145 -10
- data/lib/cronwatch/flight.rb +6 -0
- data/lib/cronwatch/format.rb +1 -0
- data/lib/cronwatch/http.rb +21 -5
- data/lib/cronwatch/job.rb +7 -2
- data/lib/cronwatch/js.rb +74 -1
- data/lib/cronwatch/monitored.rb +6 -1
- data/lib/cronwatch/output.rb +28 -1
- data/lib/cronwatch/pg_cron.rb +29 -6
- data/lib/cronwatch/rails/active_job.rb +2 -2
- data/lib/cronwatch/schedule.rb +25 -1
- data/lib/cronwatch/scheduler.rb +79 -11
- data/lib/cronwatch/serialize.rb +1 -0
- data/lib/cronwatch/stats.rb +1 -0
- data/lib/cronwatch/stores/active_record.rb +37 -11
- data/lib/cronwatch/ticker.rb +14 -0
- data/lib/cronwatch/triage/anthropic.rb +2 -0
- data/lib/cronwatch/types.rb +199 -27
- data/lib/cronwatch/version.rb +1 -1
- data/lib/cronwatch/walker.rb +2 -0
- data/lib/cronwatch/web/app.rb +114 -37
- data/lib/cronwatch/web/html.rb +9 -5
- data/lib/cronwatch/web/origin.rb +2 -0
- data/lib/cronwatch/web/pwa.rb +2 -0
- data/lib/cronwatch/web/timeline.rb +2 -0
- data/lib/cronwatch/web.rb +1 -2
- data/lib/cronwatch/zone.rb +2 -0
- data/lib/cronwatch.rb +2 -0
- data/lib/generators/cronwatch/install/install_generator.rb +52 -8
- metadata +2 -1
data/lib/cronwatch/client.rb
CHANGED
|
@@ -25,7 +25,7 @@ module Cronwatch
|
|
|
25
25
|
CHANNEL_TIMEOUT_MS = 15_000
|
|
26
26
|
PRUNE_INTERVAL_MS = 60 * 60_000
|
|
27
27
|
# Undelivered alerts kept per job for retry; the oldest go first.
|
|
28
|
-
MAX_UNDELIVERED =
|
|
28
|
+
MAX_UNDELIVERED = Evaluate::MAX_UNDELIVERED
|
|
29
29
|
# Wall-clock time one check spends retrying undelivered alerts, across
|
|
30
30
|
# every job. Once it is spent the rest wait for the next check.
|
|
31
31
|
RETRY_BUDGET_MS = 20_000
|
|
@@ -42,8 +42,16 @@ module Cronwatch
|
|
|
42
42
|
SILENCE_OPTIONS = %i[for].freeze
|
|
43
43
|
# Run ids that start with this belong to the pg_cron source (Sources::PgCron).
|
|
44
44
|
RESERVED_RUN_ID_PREFIX = "pgcron:"
|
|
45
|
+
# The longest run id, in UTF-16 code units (JavaScript's string length):
|
|
46
|
+
# what start, resume and record_run take, and every store holds.
|
|
47
|
+
MAX_RUN_ID = 200
|
|
48
|
+
private_constant :CHANNEL_TIMEOUT_MS, :DEFAULT_OPTIONS, :FORK_LOCK, :HISTORY_MAX, :HISTORY_PAGE, :MAX_RUN_ID,
|
|
49
|
+
:PRUNE_INTERVAL_MS, :RESERVED_RUN_ID_PREFIX, :RETRY_BUDGET_MS, :SILENCE_OPTIONS, :STATE_ATTEMPTS,
|
|
50
|
+
:TRIAGE_TIMEOUT_MS
|
|
45
51
|
|
|
46
52
|
# What execute returns: the recorded run, and the block's own outcome.
|
|
53
|
+
#
|
|
54
|
+
# @api private
|
|
47
55
|
ExecuteResult = Struct.new(:run, :result, :error, :threw, keyword_init: true)
|
|
48
56
|
# What a channel's call receives with each alert (the SDK's ChannelContext).
|
|
49
57
|
# on_error(error) reports a problem that did not stop the alert going
|
|
@@ -73,7 +81,9 @@ module Cronwatch
|
|
|
73
81
|
# alerts: where alerts go: objects with #call(alert) and #name. Defaults to the console.
|
|
74
82
|
# triage: a callable taking a TriageContext and returning a short diagnosis, added to every alert but recoveries.
|
|
75
83
|
# cron_secret: a second bearer Cronwatch::Web accepts for /api/check, for an outside cron. Defaults to
|
|
76
|
-
# ENV["CRON_SECRET"]; an empty string
|
|
84
|
+
# ENV["CRON_SECRET"]; an empty string or one of only whitespace, given here or in the
|
|
85
|
+
# variable, counts as unset. Pass nil for none. Anything but a String or nil (false, a
|
|
86
|
+
# number, a Symbol) raises TypeError.
|
|
77
87
|
# retention: how long finished runs are kept. Default "30d".
|
|
78
88
|
# defaults: grace, timeout, timezone and failures_before_alert applied to every job unless it sets its own.
|
|
79
89
|
# redact: applied to every run's output and error before it is stored, shown or sent to an alert
|
|
@@ -98,8 +108,11 @@ module Cronwatch
|
|
|
98
108
|
raise ArgumentError, "a source must respond to sync(host)" unless source.respond_to?(:sync)
|
|
99
109
|
end
|
|
100
110
|
@triage = triage
|
|
101
|
-
secret
|
|
102
|
-
@cron_secret =
|
|
111
|
+
# A blank secret, given or read, counts as unset; one given that is not a String raises.
|
|
112
|
+
@cron_secret =
|
|
113
|
+
if cron_secret.equal?(UNSET) then Environment.secret("CRON_SECRET")
|
|
114
|
+
else Environment.secret_option(cron_secret, "cron_secret")
|
|
115
|
+
end
|
|
103
116
|
@retention_ms = Duration.parse(retention || "30d", "retention")
|
|
104
117
|
@defaults = (defaults || {}).transform_keys(&:to_sym)
|
|
105
118
|
unknown = @defaults.keys - DEFAULT_OPTIONS
|
|
@@ -128,7 +141,7 @@ module Cronwatch
|
|
|
128
141
|
@registry = Mutex.new
|
|
129
142
|
@ready = false
|
|
130
143
|
@last_prune_at = 0
|
|
131
|
-
# Seconds before
|
|
144
|
+
# Seconds before start_checking's first check, and how long a channel or triage may take. Tests shorten them.
|
|
132
145
|
@first_tick_s = 1.0
|
|
133
146
|
@channel_timeout_ms = CHANNEL_TIMEOUT_MS
|
|
134
147
|
@triage_timeout_ms = TRIAGE_TIMEOUT_MS
|
|
@@ -158,13 +171,16 @@ module Cronwatch
|
|
|
158
171
|
JobHandle.new(self, definition)
|
|
159
172
|
end
|
|
160
173
|
|
|
161
|
-
#
|
|
162
|
-
#
|
|
163
|
-
#
|
|
174
|
+
# Run a job by name without keeping a handle, declaring it on first use
|
|
175
|
+
# (or again, when options are given).
|
|
176
|
+
#
|
|
177
|
+
# Without a block it reads the run with this id, as get_run does: that
|
|
178
|
+
# form is deprecated (use get_run), still works through 1.x and goes in 2.0.
|
|
164
179
|
def run(name_or_id, **options, &block)
|
|
165
180
|
unless block
|
|
166
181
|
raise ArgumentError, "run(#{name_or_id.inspect}, ...) needs a block; without one, run(id) reads a run" if options.any?
|
|
167
182
|
|
|
183
|
+
Deprecation.warn("run(id) without a block", "get_run(id)")
|
|
168
184
|
return get_run(name_or_id)
|
|
169
185
|
end
|
|
170
186
|
|
|
@@ -198,7 +214,7 @@ module Cronwatch
|
|
|
198
214
|
duration_ms: nil, error: nil, output: nil, metrics: {}, trigger: trigger)
|
|
199
215
|
recorded = false
|
|
200
216
|
begin
|
|
201
|
-
sync(definition)
|
|
217
|
+
sync(definition, confirm: true)
|
|
202
218
|
@store.insert_run(run.dup)
|
|
203
219
|
recorded = true
|
|
204
220
|
rescue StandardError => e
|
|
@@ -232,7 +248,7 @@ module Cronwatch
|
|
|
232
248
|
run.duration_ms = Evaluate.run_duration(started_at, finished_at)
|
|
233
249
|
run.metrics = recorder.metrics
|
|
234
250
|
returned = result.is_a?(String) ? Output.utf8(result) : nil
|
|
235
|
-
run.output = recorder.output ||
|
|
251
|
+
run.output = recorder.output || returned
|
|
236
252
|
|
|
237
253
|
conclude(definition, run, result, error, threw, recorder.expect_text || returned, failure: failure)
|
|
238
254
|
begin
|
|
@@ -316,9 +332,10 @@ module Cronwatch
|
|
|
316
332
|
# alone and reported. A finished run is judged as if it had been wrapped
|
|
317
333
|
# here (expect, failures, duration, budgets) and its output and error are
|
|
318
334
|
# redacted the same way; one finishing after a check marked it timeout is
|
|
319
|
-
# judged only when it succeeded, as RunHandle#finish does.
|
|
320
|
-
#
|
|
321
|
-
#
|
|
335
|
+
# judged only when it succeeded, as RunHandle#finish does. A metric that
|
|
336
|
+
# is not a finite number raises ArgumentError before anything is written,
|
|
337
|
+
# as JobContext#metric does. `evaluate: false` stores it without judging
|
|
338
|
+
# it, for history imported on first sight. Returns the alerts it sent.
|
|
322
339
|
#
|
|
323
340
|
# `run` is a Cronwatch::Run, or a hash of its fields (camelCase or snake_case keys).
|
|
324
341
|
def record_run(run, evaluate: true)
|
|
@@ -326,9 +343,24 @@ module Cronwatch
|
|
|
326
343
|
input = run.is_a?(Run) ? run : Run.from_h(run.is_a?(Hash) ? run.to_h { |k, v| [Naming.camel(k), v] } : run)
|
|
327
344
|
declared = @registry.synchronize { @definitions[input.job] }
|
|
328
345
|
raise ArgumentError, "record_run: job \"#{input.job}\" is not declared; call job first" unless declared
|
|
329
|
-
|
|
346
|
+
# The longest id start takes, and every store holds.
|
|
347
|
+
unless input.id.is_a?(String) && !input.id.empty? && JS.length16(input.id) <= MAX_RUN_ID
|
|
348
|
+
got = input.id.is_a?(String) ? "#{JS.length16(input.id)} characters" : input.id.class.to_s
|
|
349
|
+
raise ArgumentError, "record_run: run ids must be 1 to #{MAX_RUN_ID} characters (got #{got}; job \"#{input.job}\")"
|
|
350
|
+
end
|
|
351
|
+
if input.id.include?("\0")
|
|
330
352
|
raise ArgumentError, "record_run: run ids cannot contain a NUL character (job \"#{input.job}\")"
|
|
331
353
|
end
|
|
354
|
+
# Refused as JobContext#metric refuses them: a store keeps NaN and Infinity as null.
|
|
355
|
+
unless input.metrics.nil? || input.metrics.is_a?(Hash)
|
|
356
|
+
raise ArgumentError, "record_run: metrics must be a Hash of numbers (job \"#{input.job}\", run \"#{input.id}\")"
|
|
357
|
+
end
|
|
358
|
+
|
|
359
|
+
(input.metrics || {}).each do |metric, value|
|
|
360
|
+
next if value.is_a?(Numeric) && value.real? && JS.finite?(value)
|
|
361
|
+
|
|
362
|
+
raise ArgumentError, "record_run: metric \"#{metric}\" must be a finite number (job \"#{input.job}\", run \"#{input.id}\")"
|
|
363
|
+
end
|
|
332
364
|
|
|
333
365
|
sync(declared)
|
|
334
366
|
run = input.dup
|
|
@@ -343,8 +375,8 @@ module Cronwatch
|
|
|
343
375
|
run.error = unmet
|
|
344
376
|
end
|
|
345
377
|
end
|
|
346
|
-
run.output = Output.
|
|
347
|
-
run.error = Output.
|
|
378
|
+
run.output = Output.redact_and_cap(run.output, @redact) unless run.output.nil?
|
|
379
|
+
run.error = Output.redact_and_cap(run.error, @redact) unless run.error.nil?
|
|
348
380
|
definition = Serialize.to_stored(declared)
|
|
349
381
|
|
|
350
382
|
stored = @store.get_run(run.id)
|
|
@@ -377,7 +409,7 @@ module Cronwatch
|
|
|
377
409
|
end
|
|
378
410
|
|
|
379
411
|
# Look for missed and stuck runs across every job, send alerts, retry
|
|
380
|
-
# alerts no channel accepted, and prune old runs. Call it from
|
|
412
|
+
# alerts no channel accepted, and prune old runs. Call it from start_checking, a
|
|
381
413
|
# scheduled job (Cronwatch::CheckJob), or by hand. Concurrent calls share
|
|
382
414
|
# one check.
|
|
383
415
|
def check
|
|
@@ -414,16 +446,18 @@ module Cronwatch
|
|
|
414
446
|
def jobs_with_runs(limit = 20)
|
|
415
447
|
after_fork_check
|
|
416
448
|
ensure_ready
|
|
417
|
-
|
|
449
|
+
jobs = stored_jobs
|
|
418
450
|
at = now
|
|
419
451
|
count = clamp_limit(limit, 20, 0)
|
|
420
|
-
|
|
452
|
+
jobs.map { |stored| snapshot(stored, at, count) }
|
|
421
453
|
end
|
|
422
454
|
|
|
455
|
+
# A job's summary, or nil for one the store does not have. One declared
|
|
456
|
+
# here and forgotten elsewhere is written again, as stored_jobs does.
|
|
423
457
|
def job_summary(name)
|
|
424
458
|
ensure_ready
|
|
425
459
|
definition = @registry.synchronize { @definitions[name] }
|
|
426
|
-
sync(definition) if definition
|
|
460
|
+
sync(definition, confirm: true) if definition
|
|
427
461
|
stored = @store.get_job(name)
|
|
428
462
|
return nil unless stored
|
|
429
463
|
|
|
@@ -441,23 +475,30 @@ module Cronwatch
|
|
|
441
475
|
@store.get_run(id)
|
|
442
476
|
end
|
|
443
477
|
|
|
444
|
-
# Stop alerts for a job for a while. State keeps updating underneath.
|
|
445
|
-
#
|
|
478
|
+
# Stop alerts for a job for a while. State keeps updating underneath. The
|
|
479
|
+
# end is a whole millisecond, held at 2**53 - 1 (see Evaluate.silence_end).
|
|
480
|
+
# silence("nightly-report", for: "2h")
|
|
481
|
+
# The duration as a second argument, silence("nightly-report", "2h"), is
|
|
482
|
+
# deprecated: it still works through 1.x and goes in 2.0.
|
|
446
483
|
def silence(name, duration = nil, **options)
|
|
447
484
|
unknown = options.keys - SILENCE_OPTIONS
|
|
448
485
|
raise ArgumentError, "silence takes for:, not #{unknown.map(&:inspect).join(", ")}" if unknown.any?
|
|
449
486
|
raise ArgumentError, "silence takes a duration or for:, not both" if !duration.nil? && options.key?(:for)
|
|
450
487
|
|
|
488
|
+
Deprecation.warn("silence(name, duration)", "silence(name, for: duration)") unless duration.nil?
|
|
451
489
|
duration = options[:for] if duration.nil?
|
|
452
490
|
ms = Duration.parse(duration, "silence duration")
|
|
453
|
-
patch_state(name) { |state| state.silenced_until = now
|
|
491
|
+
patch_state(name) { |state| state.silenced_until = Evaluate.silence_end(now, ms) }
|
|
454
492
|
end
|
|
455
493
|
|
|
456
494
|
def unsilence(name)
|
|
457
495
|
patch_state(name) { |state| state.silenced_until = nil }
|
|
458
496
|
end
|
|
459
497
|
|
|
460
|
-
# Remove a job and its runs from the store. A job still declared in code
|
|
498
|
+
# Remove a job and its runs from the store. A job still declared in code
|
|
499
|
+
# comes back: here on its next run, and in any other process that
|
|
500
|
+
# declares it on its next run there, or at that process's next check or
|
|
501
|
+
# dashboard read.
|
|
461
502
|
def forget(name)
|
|
462
503
|
ensure_ready
|
|
463
504
|
@registry.synchronize do
|
|
@@ -472,15 +513,18 @@ module Cronwatch
|
|
|
472
513
|
# processes. Default every minute; the first check comes after a second.
|
|
473
514
|
# Calling it again while it runs does nothing, and a different interval
|
|
474
515
|
# is reported to on_error and ignored: stop first to change it. A forked
|
|
475
|
-
# child (Puma, Unicorn, Sidekiq) has no thread, so call
|
|
476
|
-
|
|
516
|
+
# child (Puma, Unicorn, Sidekiq) has no thread, so call start_checking
|
|
517
|
+
# there. (A job's start opens a run; this starts the checks.)
|
|
518
|
+
def start_checking(every = "1m")
|
|
477
519
|
after_fork_check
|
|
478
520
|
ms = [5_000, Duration.parse(every, "check interval")].max
|
|
479
521
|
@ticker_lock.synchronize do
|
|
522
|
+
# One whose thread ended (killed from outside) is replaced.
|
|
523
|
+
@ticker = nil if @ticker && !@ticker.alive?
|
|
480
524
|
if @ticker
|
|
481
525
|
unless @ticker_ms == ms
|
|
482
|
-
report(ArgumentError.new("
|
|
483
|
-
"call stop first to change it"), "
|
|
526
|
+
report(ArgumentError.new("start_checking(#{every.inspect}) ignored: already checking every #{Duration.format(@ticker_ms)}; " \
|
|
527
|
+
"call stop first to change it"), "start_checking")
|
|
484
528
|
end
|
|
485
529
|
return nil
|
|
486
530
|
end
|
|
@@ -488,31 +532,59 @@ module Cronwatch
|
|
|
488
532
|
@ticker_ms = ms
|
|
489
533
|
if @defer_delivery && !@warned_deferred_start
|
|
490
534
|
@warned_deferred_start = true
|
|
491
|
-
warn '[cronwatch]
|
|
535
|
+
warn '[cronwatch] start_checking was called with deliver: "check", so these checks send no alerts. ' \
|
|
492
536
|
'Another process must run checks with deliver: "now" (the default) to send them.'
|
|
493
537
|
end
|
|
494
538
|
@ticker = Ticker.new(ms / 1000.0, @first_tick_s) do
|
|
495
539
|
check
|
|
496
|
-
|
|
540
|
+
# Anything, a source's LoadError or a SystemStackError included, is
|
|
541
|
+
# reported and the next tick comes as usual: the thread must not end
|
|
542
|
+
# silently and leave missed and stuck unchecked in this process.
|
|
543
|
+
rescue Exception => e # rubocop:disable Lint/RescueException
|
|
497
544
|
report(e, "check")
|
|
498
545
|
end
|
|
499
546
|
end
|
|
500
547
|
nil
|
|
501
548
|
end
|
|
502
549
|
|
|
550
|
+
# start_checking, under its former name. Deprecated, since a job's start
|
|
551
|
+
# opens a run: it still works through 1.x and goes in 2.0.
|
|
552
|
+
def start(every = "1m")
|
|
553
|
+
Deprecation.warn("client.start(every)", "client.start_checking(every)")
|
|
554
|
+
start_checking(every)
|
|
555
|
+
end
|
|
556
|
+
|
|
557
|
+
# The dashboard and JSON API for this client, as a Rack app to mount:
|
|
558
|
+
#
|
|
559
|
+
# mount Cronwatch.client.routes => "/cronwatch" # config/routes.rb
|
|
560
|
+
# run CW.routes # config.ru
|
|
561
|
+
#
|
|
562
|
+
# Options: token:, base_path: and origin: (see Cronwatch::Web). Needs the
|
|
563
|
+
# rack gem, which every Rails app has.
|
|
564
|
+
def routes(**options)
|
|
565
|
+
require_relative "web"
|
|
566
|
+
Web.build(self, **options)
|
|
567
|
+
end
|
|
568
|
+
|
|
569
|
+
# Stop the checks start_checking began.
|
|
503
570
|
def stop
|
|
504
|
-
|
|
505
|
-
ticker = @ticker_lock.synchronize do
|
|
506
|
-
current = @ticker
|
|
507
|
-
@ticker = nil
|
|
508
|
-
current
|
|
509
|
-
end
|
|
510
|
-
ticker&.stop
|
|
571
|
+
stop_ticker
|
|
511
572
|
nil
|
|
512
573
|
end
|
|
513
574
|
|
|
575
|
+
# Stop the interval, wait for a check already under way (a failed one is
|
|
576
|
+
# reported by whoever started it), then close the store. Runs being
|
|
577
|
+
# recorded are not waited for.
|
|
514
578
|
def close
|
|
515
|
-
|
|
579
|
+
stop_ticker&.join
|
|
580
|
+
flight = @check_lock.synchronize { @checking }
|
|
581
|
+
if flight && !flight.owner.equal?(Thread.current)
|
|
582
|
+
begin
|
|
583
|
+
flight.value
|
|
584
|
+
rescue StandardError
|
|
585
|
+
nil
|
|
586
|
+
end
|
|
587
|
+
end
|
|
516
588
|
@store.close if @store.respond_to?(:close)
|
|
517
589
|
nil
|
|
518
590
|
end
|
|
@@ -534,6 +606,18 @@ module Cronwatch
|
|
|
534
606
|
|
|
535
607
|
private
|
|
536
608
|
|
|
609
|
+
# Stops the interval thread and returns it, or nil when there was none.
|
|
610
|
+
def stop_ticker
|
|
611
|
+
after_fork_check
|
|
612
|
+
ticker = @ticker_lock.synchronize do
|
|
613
|
+
current = @ticker
|
|
614
|
+
@ticker = nil
|
|
615
|
+
current
|
|
616
|
+
end
|
|
617
|
+
ticker&.stop
|
|
618
|
+
ticker
|
|
619
|
+
end
|
|
620
|
+
|
|
537
621
|
# Locks, the check in flight, the interval thread and the channel and
|
|
538
622
|
# triage threads belong to one process. A forked child (Puma, Unicorn,
|
|
539
623
|
# Sidekiq) starts with fresh ones, so start and check work there.
|
|
@@ -653,11 +737,22 @@ module Cronwatch
|
|
|
653
737
|
# its own. The writes of one name take turns (the name's lock in
|
|
654
738
|
# @syncing, held across the write), so one still under way cannot land
|
|
655
739
|
# after a later one; and a name declared again while its write was under
|
|
656
|
-
# way is still to be written.
|
|
657
|
-
|
|
740
|
+
# way is still to be written. A name is marked as written only while
|
|
741
|
+
# that same declaration stands, so a forget that lands during the write
|
|
742
|
+
# (deleting the row after it) leaves the name to be written again, as
|
|
743
|
+
# does one forgotten before it.
|
|
744
|
+
#
|
|
745
|
+
# With `confirm`, as a run starts, a name already written is read back:
|
|
746
|
+
# another process may have forgotten the job since, and a job still
|
|
747
|
+
# declared here comes back on its next run.
|
|
748
|
+
def sync(definition, confirm: false)
|
|
658
749
|
ensure_ready
|
|
659
750
|
name = definition.name
|
|
660
|
-
|
|
751
|
+
if @registry.synchronize { @synced.include?(name) }
|
|
752
|
+
return if !confirm || @store.get_job(name)
|
|
753
|
+
|
|
754
|
+
@registry.synchronize { @synced.delete(name) }
|
|
755
|
+
end
|
|
661
756
|
|
|
662
757
|
after_fork_check
|
|
663
758
|
turn = @registry.synchronize { @syncing[name] ||= Mutex.new }
|
|
@@ -666,8 +761,28 @@ module Cronwatch
|
|
|
666
761
|
next if standing.nil?
|
|
667
762
|
|
|
668
763
|
@store.upsert_job(Serialize.to_stored(standing), now)
|
|
669
|
-
@registry.synchronize { @synced << name if @definitions
|
|
764
|
+
@registry.synchronize { @synced << name if @definitions[name].equal?(standing) }
|
|
765
|
+
end
|
|
766
|
+
end
|
|
767
|
+
|
|
768
|
+
# Every stored job, once each declaration has been written. A job
|
|
769
|
+
# declared here that the store no longer has was forgotten by another
|
|
770
|
+
# process after this one wrote it: it is written again, as its next run
|
|
771
|
+
# would, so it is checked and shown while any process still declares it.
|
|
772
|
+
def stored_jobs
|
|
773
|
+
defined_jobs.each { |definition| sync(definition) }
|
|
774
|
+
jobs = @store.list_jobs
|
|
775
|
+
listed = jobs.to_set(&:name)
|
|
776
|
+
missing = defined_jobs.reject { |definition| listed.include?(definition.name) }
|
|
777
|
+
return jobs if missing.empty?
|
|
778
|
+
|
|
779
|
+
missing.each do |definition|
|
|
780
|
+
name = definition.name
|
|
781
|
+
# Not one forgotten here meanwhile.
|
|
782
|
+
standing = @registry.synchronize { @definitions[name].equal?(definition) && @synced.delete(name) }
|
|
783
|
+
sync(definition) if standing
|
|
670
784
|
end
|
|
785
|
+
@store.list_jobs
|
|
671
786
|
end
|
|
672
787
|
|
|
673
788
|
# Runs the block while holding the job's lock, so two runs (or a run and a
|
|
@@ -743,11 +858,6 @@ module Cronwatch
|
|
|
743
858
|
end
|
|
744
859
|
end
|
|
745
860
|
|
|
746
|
-
# Identifies an alert across retries.
|
|
747
|
-
def alert_key(alert)
|
|
748
|
-
"#{alert.type}|#{alert.at}|#{alert.run&.id}"
|
|
749
|
-
end
|
|
750
|
-
|
|
751
861
|
# record_run for a run already stored.
|
|
752
862
|
def record_over(definition, stored, run, evaluate)
|
|
753
863
|
if stored.job != run.job
|
|
@@ -795,11 +905,12 @@ module Cronwatch
|
|
|
795
905
|
end
|
|
796
906
|
|
|
797
907
|
# Sets a finished run's status and error from how it ended, then redacts
|
|
798
|
-
# its output and error. Shared by execute
|
|
908
|
+
# its output and error and caps them, in that order. Shared by execute
|
|
909
|
+
# and RunHandle#finish.
|
|
799
910
|
def conclude(definition, run, result, error, threw, expect_text, failure: nil)
|
|
800
911
|
if threw
|
|
801
912
|
run.status = :failed
|
|
802
|
-
run.error = Output.
|
|
913
|
+
run.error = Output.describe_error(error)
|
|
803
914
|
elsif (problem = failure&.call(result))
|
|
804
915
|
run.status = :failed
|
|
805
916
|
run.error = Output.utf8(problem.to_s)
|
|
@@ -813,9 +924,10 @@ module Cronwatch
|
|
|
813
924
|
end
|
|
814
925
|
end
|
|
815
926
|
# Redacted after the expect check, so a rule can still match what was
|
|
816
|
-
# logged
|
|
817
|
-
|
|
818
|
-
run.
|
|
927
|
+
# logged, and before the cap, so the cut cannot keep half a secret. NULs
|
|
928
|
+
# go last, so not even a custom redact can store one.
|
|
929
|
+
run.output = Output.redact_and_cap(run.output, @redact) unless run.output.nil?
|
|
930
|
+
run.error = Output.redact_and_cap(run.error, @redact) unless run.error.nil?
|
|
819
931
|
end
|
|
820
932
|
|
|
821
933
|
# Writes a finished run and evaluates it. `recorded` says whether its
|
|
@@ -868,7 +980,7 @@ module Cronwatch
|
|
|
868
980
|
duration_ms: nil, error: nil, output: nil, metrics: {}, trigger: trigger)
|
|
869
981
|
recorded = false
|
|
870
982
|
begin
|
|
871
|
-
sync(definition)
|
|
983
|
+
sync(definition, confirm: true)
|
|
872
984
|
@store.insert_run(run.dup)
|
|
873
985
|
recorded = true
|
|
874
986
|
rescue StandardError => e
|
|
@@ -955,13 +1067,14 @@ module Cronwatch
|
|
|
955
1067
|
failed, result, error = RunHandle.read_outcome(outcome)
|
|
956
1068
|
finished_at = now
|
|
957
1069
|
returned = result.is_a?(String) ? Output.utf8(result) : nil
|
|
958
|
-
added = recorder.output ||
|
|
1070
|
+
added = recorder.output || returned
|
|
959
1071
|
run = from.dup
|
|
960
1072
|
run.status = :running
|
|
961
1073
|
run.finished_at = finished_at
|
|
962
1074
|
run.duration_ms = Evaluate.run_duration(from.started_at, finished_at)
|
|
963
1075
|
run.error = nil
|
|
964
|
-
|
|
1076
|
+
# Capped by conclude, after it is redacted.
|
|
1077
|
+
run.output = join_lines(from.output, added)
|
|
965
1078
|
run.metrics = (from.metrics || {}).merge(recorder.metrics)
|
|
966
1079
|
expect_text = join_lines(head, join_lines(from.output, recorder.expect_text || returned))
|
|
967
1080
|
conclude(definition, run, result, error, failed, expect_text)
|
|
@@ -998,7 +1111,7 @@ module Cronwatch
|
|
|
998
1111
|
end
|
|
999
1112
|
|
|
1000
1113
|
updated = stored.dup
|
|
1001
|
-
updated.output = join_output(stored.output, Output.
|
|
1114
|
+
updated.output = join_output(stored.output, Output.redact_and_cap(lines, @redact)) unless lines.nil?
|
|
1002
1115
|
updated.metrics = (stored.metrics || {}).merge(metrics)
|
|
1003
1116
|
write_run_if(updated, [:running])
|
|
1004
1117
|
rescue StandardError => e
|
|
@@ -1008,9 +1121,9 @@ module Cronwatch
|
|
|
1008
1121
|
|
|
1009
1122
|
# Raises for a run id no store could hold, or one reserved for the pg_cron source.
|
|
1010
1123
|
def check_run_id(job, id, method)
|
|
1011
|
-
unless id.is_a?(String) && !id.empty? && JS.length16(id) <=
|
|
1124
|
+
unless id.is_a?(String) && !id.empty? && JS.length16(id) <= MAX_RUN_ID
|
|
1012
1125
|
got = id.is_a?(String) ? "#{JS.length16(id)} characters" : id.class.to_s
|
|
1013
|
-
raise ArgumentError, "job \"#{job}\": #{method}() needs a run id of 1 to
|
|
1126
|
+
raise ArgumentError, "job \"#{job}\": #{method}() needs a run id of 1 to #{MAX_RUN_ID} characters (got #{got})"
|
|
1014
1127
|
end
|
|
1015
1128
|
# Postgres refuses NUL in text, so no store could hold such an id.
|
|
1016
1129
|
raise ArgumentError, "job \"#{job}\": #{method}() cannot take a run id containing a NUL character" if id.include?("\0")
|
|
@@ -1035,21 +1148,47 @@ module Cronwatch
|
|
|
1035
1148
|
end
|
|
1036
1149
|
|
|
1037
1150
|
# Evaluate a finished run (ok, failed, or timed out by a check), already
|
|
1038
|
-
# written, against the job's state and send what that produces.
|
|
1151
|
+
# written, against the job's state and send what that produces. The
|
|
1152
|
+
# alerts are written with that state (see outbox). Never raises.
|
|
1039
1153
|
def finish_run(definition, run, at)
|
|
1040
|
-
|
|
1154
|
+
held = nil
|
|
1041
1155
|
begin
|
|
1042
1156
|
past = nil
|
|
1043
|
-
_,
|
|
1157
|
+
_, held = update_state(run.job) do |previous|
|
|
1044
1158
|
past ||= history(run)
|
|
1045
|
-
|
|
1046
|
-
[settled.state, settled.alerts]
|
|
1159
|
+
outbox(Evaluate.apply_silence(previous, Evaluate.on_run_finish(definition, run, previous, past, at), at), definition, at)
|
|
1047
1160
|
end
|
|
1048
1161
|
rescue StandardError => e
|
|
1049
1162
|
report(e, "evaluating #{run.job}")
|
|
1050
1163
|
return []
|
|
1051
1164
|
end
|
|
1052
|
-
|
|
1165
|
+
report_dropped(run.job, held.dropped)
|
|
1166
|
+
dispatch(run.job, held.alerts, at)
|
|
1167
|
+
end
|
|
1168
|
+
|
|
1169
|
+
# Alerts written with the state that opened their conditions, and how
|
|
1170
|
+
# many older ones the queue let go.
|
|
1171
|
+
#
|
|
1172
|
+
# @api private
|
|
1173
|
+
Outbox = Struct.new(:alerts, :dropped)
|
|
1174
|
+
|
|
1175
|
+
# An evaluation as it is written: its drafts composed into alerts and held
|
|
1176
|
+
# in the same state (Evaluate.hold_alerts), so the write that opens a
|
|
1177
|
+
# condition also keeps its alerts, and a process that stops before
|
|
1178
|
+
# sending them does not lose them. Called inside update_state, so it only
|
|
1179
|
+
# computes. Returns `[state, Outbox]`.
|
|
1180
|
+
def outbox(settled, definition, at)
|
|
1181
|
+
alerts = settled.alerts.map { |draft| Format.compose_alert(draft, definition, at) }
|
|
1182
|
+
held = Evaluate.hold_alerts(settled.state, alerts, now + Evaluate::SEND_LEASE_MS, @defer_delivery)
|
|
1183
|
+
[held.state, Outbox.new(alerts, held.dropped)]
|
|
1184
|
+
end
|
|
1185
|
+
|
|
1186
|
+
# Reports alerts let go because a job's queue was full.
|
|
1187
|
+
def report_dropped(name, dropped)
|
|
1188
|
+
return unless dropped.positive?
|
|
1189
|
+
|
|
1190
|
+
report(RuntimeError.new("#{dropped} undelivered alert#{dropped == 1 ? "" : "s"} for #{name} dropped: " \
|
|
1191
|
+
"only the newest #{Evaluate::MAX_UNDELIVERED} are kept for retry"), "alert queue for #{name}")
|
|
1053
1192
|
end
|
|
1054
1193
|
|
|
1055
1194
|
# The runs before `run`, newest first, with up to BASELINE_WINDOW
|
|
@@ -1078,10 +1217,18 @@ module Cronwatch
|
|
|
1078
1217
|
|
|
1079
1218
|
# Runs that never reported back. One that cannot be judged (its job's
|
|
1080
1219
|
# stored timeout no longer parses, say) is reported and skipped.
|
|
1081
|
-
@store.running_runs.each do |
|
|
1082
|
-
declared = @registry.synchronize { @definitions[
|
|
1083
|
-
definition = declared ? Serialize.to_stored(declared) : @store.get_job(
|
|
1084
|
-
next if definition.nil?
|
|
1220
|
+
@store.running_runs.each do |listed|
|
|
1221
|
+
declared = @registry.synchronize { @definitions[listed.job] }
|
|
1222
|
+
definition = declared ? Serialize.to_stored(declared) : @store.get_job(listed.job)&.definition
|
|
1223
|
+
next if definition.nil?
|
|
1224
|
+
|
|
1225
|
+
evaluable(listed.job, definition)
|
|
1226
|
+
next unless Evaluate.stuck?(definition, listed, at)
|
|
1227
|
+
|
|
1228
|
+
# Read again just before the write: lines and metrics flushed since the
|
|
1229
|
+
# list was read (while earlier stuck runs were sent, say) are kept.
|
|
1230
|
+
run = @store.get_run(listed.id)
|
|
1231
|
+
next if run.nil? || run.status != :running || run.job != listed.job
|
|
1085
1232
|
|
|
1086
1233
|
timeout = Evaluate.timeout_ms(definition)
|
|
1087
1234
|
run.status = :timeout
|
|
@@ -1093,7 +1240,7 @@ module Cronwatch
|
|
|
1093
1240
|
|
|
1094
1241
|
alerts.concat(finish_run(definition, run, at))
|
|
1095
1242
|
rescue StandardError => e
|
|
1096
|
-
report(e, "checking #{
|
|
1243
|
+
report(e, "checking #{listed.job}")
|
|
1097
1244
|
end
|
|
1098
1245
|
|
|
1099
1246
|
# Each job on its own: one that cannot be evaluated (a stored schedule
|
|
@@ -1102,17 +1249,22 @@ module Cronwatch
|
|
|
1102
1249
|
# the others.
|
|
1103
1250
|
jobs = []
|
|
1104
1251
|
retries = RetryBudget.new(0)
|
|
1105
|
-
|
|
1252
|
+
stored_jobs.each do |stored|
|
|
1253
|
+
evaluable(stored.name, stored.definition)
|
|
1106
1254
|
recent = @store.list_runs(stored.name, Evaluate::BASELINE_WINDOW)
|
|
1107
1255
|
next_expected_at = nil
|
|
1108
|
-
state,
|
|
1256
|
+
state, held = update_state(stored.name) do |previous|
|
|
1109
1257
|
evaluation = Evaluate.on_check(stored.definition, stored, recent.first, previous, at)
|
|
1110
1258
|
next_expected_at = evaluation.next_expected_at
|
|
1111
1259
|
settled = Evaluate.apply_silence(previous, evaluation, at)
|
|
1112
|
-
|
|
1260
|
+
# Alerts a process stopped sending part way go back to the retry queue.
|
|
1261
|
+
released = Evaluate.release_sending(settled.state, now)
|
|
1262
|
+
following, out = outbox(Evaluate::Evaluation.new(state: released.state, alerts: settled.alerts), stored.definition, at)
|
|
1263
|
+
[following, Outbox.new(out.alerts, released.dropped + out.dropped)]
|
|
1113
1264
|
end
|
|
1265
|
+
report_dropped(stored.name, held.dropped)
|
|
1114
1266
|
alerts.concat(retry_undelivered(stored.name, state, at, retries))
|
|
1115
|
-
alerts.concat(dispatch(
|
|
1267
|
+
alerts.concat(dispatch(stored.name, held.alerts, at))
|
|
1116
1268
|
jobs << Evaluate.summarize(stored, recent, state, next_expected_at, at)
|
|
1117
1269
|
rescue StandardError => e
|
|
1118
1270
|
report(e, "checking #{stored.name}")
|
|
@@ -1139,6 +1291,7 @@ module Cronwatch
|
|
|
1139
1291
|
recent = []
|
|
1140
1292
|
begin
|
|
1141
1293
|
recent = @store.list_runs(stored.name, [count, Evaluate::BASELINE_WINDOW].max)
|
|
1294
|
+
evaluable(stored.name, stored.definition)
|
|
1142
1295
|
state = read_state(stored.name)
|
|
1143
1296
|
next_expected_at = Evaluate.on_check(stored.definition, stored, recent.first, state, at).next_expected_at
|
|
1144
1297
|
JobWithRuns.new(job: Evaluate.summarize(stored, recent, state, next_expected_at, at), runs: recent.first(count))
|
|
@@ -1148,6 +1301,13 @@ module Cronwatch
|
|
|
1148
1301
|
end
|
|
1149
1302
|
end
|
|
1150
1303
|
|
|
1304
|
+
# Raises for a stored definition that was not a JSON object (see
|
|
1305
|
+
# JobDefinition.from_h), so that one job is reported and shown as
|
|
1306
|
+
# failing, and the others are checked as usual.
|
|
1307
|
+
def evaluable(name, definition)
|
|
1308
|
+
raise ArgumentError, "job \"#{name}\": its stored definition is not a JSON object" if definition.unreadable?
|
|
1309
|
+
end
|
|
1310
|
+
|
|
1151
1311
|
# The summary of a job whose evaluation failed, from whatever can still be read.
|
|
1152
1312
|
def unevaluable(stored, at)
|
|
1153
1313
|
recent = begin
|
|
@@ -1174,30 +1334,30 @@ module Cronwatch
|
|
|
1174
1334
|
state
|
|
1175
1335
|
end
|
|
1176
1336
|
|
|
1177
|
-
#
|
|
1178
|
-
#
|
|
1179
|
-
# the delivery fields are written back,
|
|
1180
|
-
|
|
1181
|
-
|
|
1337
|
+
# Triage and send each alert the outbox holds (see outbox). The state,
|
|
1338
|
+
# with the alerts in it, was saved before this, so a slow channel holds up
|
|
1339
|
+
# nothing else; afterwards only the delivery fields are written back,
|
|
1340
|
+
# onto a fresh read of the state, and the alerts leave `sending`. Triage
|
|
1341
|
+
# is made here, never stored with the held alert: the write that opens a
|
|
1342
|
+
# condition cannot wait for it, and a retry triages an alert that has
|
|
1343
|
+
# none. With deliver: :check the alerts were queued for a check elsewhere
|
|
1344
|
+
# instead.
|
|
1345
|
+
def dispatch(name, alerts, at)
|
|
1346
|
+
return alerts if alerts.empty? || @defer_delivery
|
|
1182
1347
|
|
|
1183
|
-
composed = []
|
|
1184
1348
|
delivered = []
|
|
1185
1349
|
failed = []
|
|
1186
|
-
|
|
1187
|
-
alert
|
|
1188
|
-
|
|
1189
|
-
failed << alert
|
|
1190
|
-
else
|
|
1191
|
-
add_triage(alert, @triage_timeout_ms) if @triage && alert.type != :recovered
|
|
1192
|
-
(deliver(alert) ? delivered : failed) << alert
|
|
1193
|
-
end
|
|
1194
|
-
composed << alert
|
|
1350
|
+
alerts.each do |alert|
|
|
1351
|
+
add_triage(alert, @triage_timeout_ms) if @triage && alert.type != :recovered
|
|
1352
|
+
(deliver(alert) ? delivered : failed) << alert
|
|
1195
1353
|
end
|
|
1196
|
-
record_delivery(
|
|
1197
|
-
|
|
1354
|
+
record_delivery(name, delivered, failed, [], at)
|
|
1355
|
+
alerts
|
|
1198
1356
|
end
|
|
1199
1357
|
|
|
1200
1358
|
# The wall-clock milliseconds one check has spent retrying, across its jobs.
|
|
1359
|
+
#
|
|
1360
|
+
# @api private
|
|
1201
1361
|
RetryBudget = Struct.new(:spent_ms)
|
|
1202
1362
|
|
|
1203
1363
|
# Send the alerts that no channel accepted last time, once each, oldest
|
|
@@ -1230,24 +1390,16 @@ module Cronwatch
|
|
|
1230
1390
|
end
|
|
1231
1391
|
|
|
1232
1392
|
# Mark delivered alerts done, drop stale ones, and keep failed ones for the
|
|
1233
|
-
# next check
|
|
1234
|
-
#
|
|
1393
|
+
# next check, taking them all out of `sending` (Evaluate.record_sent). A
|
|
1394
|
+
# failed alert replaces its stored copy, so a triage made on this attempt
|
|
1395
|
+
# is kept. last_alert_at moves only on a delivery. When this write fails,
|
|
1396
|
+
# alerts still in `sending` are retried once their lease runs out.
|
|
1235
1397
|
def record_delivery(name, delivered, failed, dropped, at)
|
|
1236
1398
|
_, trimmed = update_state(name) do |previous|
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
retried = failed.to_h { |a| [alert_key(a), a] }
|
|
1240
|
-
kept = state.undelivered.reject { |a| done.include?(alert_key(a)) }.map { |a| retried.fetch(alert_key(a), a) }
|
|
1241
|
-
known = kept.map { |a| alert_key(a) }.to_set
|
|
1242
|
-
kept.concat(failed.reject { |a| known.include?(alert_key(a)) })
|
|
1243
|
-
state.undelivered = kept.last(MAX_UNDELIVERED)
|
|
1244
|
-
state.last_alert_at = at if delivered.any?
|
|
1245
|
-
[state, [0, kept.length - MAX_UNDELIVERED].max]
|
|
1246
|
-
end
|
|
1247
|
-
if trimmed.positive?
|
|
1248
|
-
report(RuntimeError.new("#{trimmed} undelivered alert#{trimmed == 1 ? "" : "s"} for #{name} dropped: " \
|
|
1249
|
-
"only the newest #{MAX_UNDELIVERED} are kept for retry"), "alert queue for #{name}")
|
|
1399
|
+
held = Evaluate.record_sent(Evaluate.normalize_state(previous, name), delivered, failed, dropped, at)
|
|
1400
|
+
[held.state, held.dropped]
|
|
1250
1401
|
end
|
|
1402
|
+
report_dropped(name, trimmed)
|
|
1251
1403
|
rescue StandardError => e
|
|
1252
1404
|
report(e, "recording alert delivery for #{name}")
|
|
1253
1405
|
end
|
|
@@ -1272,14 +1424,20 @@ module Cronwatch
|
|
|
1272
1424
|
Thread.current.report_on_exception = false
|
|
1273
1425
|
send_to(channel, alert)
|
|
1274
1426
|
outcomes[i] = true
|
|
1275
|
-
|
|
1427
|
+
# Anything the channel raised, SystemStackError and exit included, is
|
|
1428
|
+
# a failed send: raised again through join, it would replace the
|
|
1429
|
+
# job's own error and lose the alert.
|
|
1430
|
+
rescue Exception => e # rubocop:disable Lint/RescueException
|
|
1276
1431
|
outcomes[i] = e
|
|
1277
1432
|
end
|
|
1278
1433
|
end
|
|
1279
1434
|
deadline = AbortSignal.monotonic + (@channel_timeout_ms / 1000.0)
|
|
1280
1435
|
results = threads.each_with_index.map do |thread, i|
|
|
1281
1436
|
next outcomes[i] if thread.nil?
|
|
1282
|
-
|
|
1437
|
+
if thread.join([deadline - AbortSignal.monotonic, 0].max)
|
|
1438
|
+
# A thread killed from outside leaves no outcome: not sent.
|
|
1439
|
+
next outcomes[i] || RuntimeError.new("the channel's thread ended without sending")
|
|
1440
|
+
end
|
|
1283
1441
|
|
|
1284
1442
|
@sending_lock.synchronize { @abandoned[i] = thread }
|
|
1285
1443
|
TimeoutError.new("timed out after #{@channel_timeout_ms}ms")
|
|
@@ -1334,21 +1492,28 @@ module Cronwatch
|
|
|
1334
1492
|
thread = Thread.new do
|
|
1335
1493
|
Thread.current.report_on_exception = false
|
|
1336
1494
|
outcome = [:ok, @triage.call(context)]
|
|
1337
|
-
|
|
1495
|
+
# Anything triage raised counts as no diagnosis, as a channel's does.
|
|
1496
|
+
rescue Exception => e # rubocop:disable Lint/RescueException
|
|
1338
1497
|
outcome = [:error, e]
|
|
1339
1498
|
end
|
|
1340
1499
|
unless thread.join(timeout_ms / 1000.0)
|
|
1341
1500
|
@sending_lock.synchronize { @abandoned[:triage] = thread }
|
|
1342
1501
|
raise TimeoutError, "timed out after #{timeout_ms}ms"
|
|
1343
1502
|
end
|
|
1344
|
-
|
|
1503
|
+
outcome ||= [:error, RuntimeError.new("the triage thread ended without an answer")]
|
|
1504
|
+
return no_triage(alert, signal, outcome[1]) if outcome[0] == :error
|
|
1345
1505
|
|
|
1346
1506
|
diagnosis = outcome[1]
|
|
1347
1507
|
alert.triage_result = diagnosis.is_a?(String) && !diagnosis.empty? ? Output.utf8(diagnosis) : nil
|
|
1348
1508
|
rescue StandardError => e
|
|
1509
|
+
no_triage(alert, signal, e)
|
|
1510
|
+
end
|
|
1511
|
+
|
|
1512
|
+
# A triage that gave nothing: the alert goes without one, and why is reported.
|
|
1513
|
+
def no_triage(alert, signal, error)
|
|
1349
1514
|
signal&.abort!
|
|
1350
1515
|
alert.triage_result = nil
|
|
1351
|
-
report(
|
|
1516
|
+
report(error, "triage for #{alert.job}")
|
|
1352
1517
|
end
|
|
1353
1518
|
|
|
1354
1519
|
# A whole number in range, or the fallback for anything that is not a number.
|