cronwatch 0.13.1 → 0.14.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 2550285307bd17b80e7aace7fe3ed098853b83258f1e9b9030d666739b415020
4
- data.tar.gz: 4392543773b56b11d1be70af9b67f0077ead1fff1efd0477b4a33733daf7337b
3
+ metadata.gz: d0a9fcfe8eb1b18e8afd7b80e2b41e62569bf606db798252d2305f9f353235ff
4
+ data.tar.gz: 12626c9fc256348658a3843ac1704b97ef7cd4fde62f289e0c570c174dd29704
5
5
  SHA512:
6
- metadata.gz: 9d9eb6e74fd3c89fbe884c6c8d0801a2b82a47860fb00d88e181a5e91846d67cb1c072cbbbe8c16dfda467c6d09b7d382f71fee0072638130003552c6c6f0596
7
- data.tar.gz: 1971c1a964d90823ce29c32d8661fa12ec19460557ff3650c842479cdf166556ca550ca24deab7aeaf613aa7c51ab510a5c76a8883353123ed56657849ea69af
6
+ metadata.gz: 4d9274543edac3d3400762e5863b08a2b9cf8a818b134996e28a292cfe802552e6ecffbc68b18a7a4a9258499e476e7e7982a546b4b204ebc5f146829e14cebc
7
+ data.tar.gz: ab1c1ef624f8e94f56eb73c2be364ebf9b799e1b7bceef3a69e4398604e72c1eb63252f894aec82bdb4dfc61d9b9803048a02564dd60d807a273cfde62b0b364
data/README.md CHANGED
@@ -214,7 +214,7 @@ Cronwatch::Alerts::Custom.new("pagerduty") do |alert|
214
214
  end
215
215
  ```
216
216
 
217
- Every alert goes to every channel. A channel that raises, or takes longer than 15 seconds, goes to `on_error` and never blocks the others. Each condition alerts once when it opens and once more when a clean run closes it; there are no repeat alerts. The webhook's body is the alert's JSON with `"schema": 1` first (its [JSON Schema](https://cronwatch.dev/schemas/webhook/1.json)); parse the fields, not `title` and `message`, whose wording may change. It signs the body with `X-CronWatch-Signature: sha256=<hex>`, the HMAC-SHA256 of the raw body, which a receiver checks with `Rack::Utils.secure_compare("sha256=#{Cronwatch::Alerts::Webhook.signature(secret, raw_body)}", header)`.
217
+ Every alert goes to every channel. A channel that raises, or takes longer than 15 seconds, goes to `on_error` and never blocks the others, and the alert is kept for that channel alone: each check retries it there, and only there, until it takes it. Each condition alerts once when it opens and once more when a clean run closes it; there are no repeat alerts. The webhook's body is the alert's JSON with `"schema": 1` first (its [JSON Schema](https://cronwatch.dev/schemas/webhook/1.json)); parse the fields, not `title` and `message`, whose wording may change. It signs the body with `X-CronWatch-Signature: sha256=<hex>`, the HMAC-SHA256 of the raw body, and sends `X-CronWatch-Timestamp` (unix seconds) with `X-CronWatch-Timestamp-Signature: sha256=<hex>`, the HMAC-SHA256 of `<timestamp>.<raw body>`. A receiver checks the second with `Rack::Utils.secure_compare("sha256=#{Cronwatch::Alerts::Webhook.signature(secret, "#{timestamp}.#{raw_body}")}", header)` and refuses a timestamp more than five minutes from its own clock, so a captured alert cannot be sent again later.
218
218
 
219
219
  ## Triage
220
220
 
@@ -10,7 +10,7 @@ module Cronwatch
10
10
  # from: the sender, "alerts@example.com" or "CronWatch <alerts@example.com>"
11
11
  # to: one address or several
12
12
  # subject_prefix: put in front of the title in the subject, "[prod]" say
13
- # link: ->(alert) { "https://app.example.com/cronwatch/jobs/#{alert.job}" }
13
+ # link: ->(alert) { "https://app.example.com/cronwatch/jobs/#{ERB::Util.url_encode(alert.job)}" }
14
14
  #
15
15
  # @api private
16
16
  module Email
@@ -5,7 +5,9 @@ module Cronwatch
5
5
  # Sends alerts to a Slack channel through an incoming webhook.
6
6
  #
7
7
  # Cronwatch::Alerts::Slack.new(webhook_url: ENV.fetch("SLACK_WEBHOOK_URL"),
8
- # link: ->(alert) { "https://app.example.com/cronwatch/jobs/#{alert.job}" })
8
+ # link: ->(alert) { "https://app.example.com/cronwatch/jobs/#{ERB::Util.url_encode(alert.job)}" })
9
+ #
10
+ # Any `<`, `>`, or `|` in the link is percent-encoded, so it cannot end Slack's link early.
9
11
  class Slack
10
12
  EMOJI = {
11
13
  missed: ":hourglass_flowing_sand:", failed: ":x:", stuck: ":no_entry:", slow: ":turtle:",
@@ -26,7 +28,7 @@ module Cronwatch
26
28
 
27
29
  def call(alert)
28
30
  url = @link&.call(alert)
29
- title = "#{EMOJI[alert.type]} *#{Slack.escape(alert.title)}*#{url && !url.empty? ? " (<#{url}|open>)" : ""}"
31
+ title = "#{EMOJI[alert.type]} *#{Slack.escape(alert.title)}*#{url && !url.empty? ? " (<#{Slack.link_target(url)}|open>)" : ""}"
30
32
  body = JS.head16(Slack.code_block_safe(Slack.escape(alert.message)), 2900)
31
33
  blocks = [
32
34
  { "type" => "section", "text" => { "type" => "mrkdwn", "text" => title } },
@@ -50,6 +52,12 @@ module Cronwatch
50
52
  text.gsub("&", "&amp;").gsub("<", "&lt;").gsub(">", "&gt;")
51
53
  end
52
54
 
55
+ # A link's URL as Slack's <url|text> can hold it: `<`, `>`, and `|`
56
+ # percent-encoded, which ends neither the link nor its text.
57
+ def self.link_target(url)
58
+ url.gsub("<", "%3C").gsub(">", "%3E").gsub("|", "%7C")
59
+ end
60
+
53
61
  # Breaks up ``` so text inside a code block cannot close it.
54
62
  def self.code_block_safe(text)
55
63
  text.gsub("```", "`\u200b`\u200b`")
@@ -7,21 +7,35 @@ module Cronwatch
7
7
  # POSTs the alert as JSON to any URL. The body is `"schema": 1` and then
8
8
  # the alert's JSON: { schema, type, run, details, job, definition, title,
9
9
  # message, at, triage }, described by https://cronwatch.dev/schemas/webhook/1.json.
10
- # With a secret, each request carries `X-CronWatch-Signature: sha256=<hex>`,
11
- # the HMAC-SHA256 of the raw body (Webhook.signature), so the receiver can verify it. Header
12
- # values are trimmed. A redirect is an error, not followed (the headers
13
- # and the signature would go with it): point the url at where the
14
- # receiver really is.
10
+ # With a secret, each request is signed with it (HMAC-SHA256, as
11
+ # lowercase hex; Webhook.signature), so the receiver can verify it:
12
+ #
13
+ # - `X-CronWatch-Signature: sha256=<hex>` signs the raw body alone, as it
14
+ # always has.
15
+ # - `X-CronWatch-Timestamp: <unix seconds>` is when it was sent, and
16
+ # `X-CronWatch-Timestamp-Signature: sha256=<hex>` signs
17
+ # `<timestamp>.<raw body>`, so a receiver that checks this one (in
18
+ # constant time) and refuses a timestamp more than five minutes from its
19
+ # own clock cannot be sent a captured alert again later.
20
+ #
21
+ # Header values are trimmed, and a name is matched without regard to
22
+ # case, so a Content-Type given in `headers` replaces the default one
23
+ # rather than going beside it; the signature headers are always the
24
+ # channel's own. A redirect is an error, not followed (the headers and the
25
+ # signature would go with it): point the url at where the receiver really
26
+ # is. `now` is the clock the timestamp is read from, a callable returning
27
+ # epoch milliseconds. For tests.
15
28
  class Webhook
16
29
  attr_reader :name
17
30
 
18
- def initialize(url:, headers: {}, secret: nil, http: HTTP.default)
31
+ def initialize(url:, headers: {}, secret: nil, now: nil, http: HTTP.default)
19
32
  raise ArgumentError, "Cronwatch::Alerts::Webhook needs a url" if url.nil? || url.to_s.empty?
20
33
 
21
34
  @name = "webhook"
22
35
  @url = url
23
36
  @headers = headers || {}
24
37
  @secret = secret
38
+ @now = now || -> { Process.clock_gettime(Process::CLOCK_REALTIME, :millisecond) }
25
39
  @http = http
26
40
  end
27
41
 
@@ -32,7 +46,9 @@ module Cronwatch
32
46
 
33
47
  # The HMAC-SHA256 of `body` with `secret`, as lowercase hex, without the
34
48
  # `sha256=` the header puts before it. A receiver compares it with the
35
- # header in constant time (Rack::Utils.secure_compare).
49
+ # header in constant time (Rack::Utils.secure_compare). The timestamped
50
+ # one is the signature of `<timestamp>.<body>`, with the
51
+ # X-CronWatch-Timestamp value as sent.
36
52
  def self.signature(secret, body)
37
53
  OpenSSL::HMAC.hexdigest("SHA256", secret.to_s, body.to_s)
38
54
  end
@@ -48,15 +64,25 @@ module Cronwatch
48
64
  end
49
65
  headers = { "content-type" => "application/json", "user-agent" => "cronwatch" }
50
66
  # A pasted Authorization value often carries a stray space or newline, which a header would refuse.
51
- @headers.each { |name, value| headers[name.to_s] = value.is_a?(String) ? JS.trim(value) : value }
67
+ @headers.each { |name, value| Webhook.set_header(headers, name.to_s, value.is_a?(String) ? JS.trim(value) : value) }
52
68
  if @secret && !@secret.empty?
53
- headers["x-cronwatch-signature"] = "sha256=#{Webhook.signature(@secret, body)}"
69
+ timestamp = @now.call.div(1000).to_s
70
+ Webhook.set_header(headers, "x-cronwatch-signature", "sha256=#{Webhook.signature(@secret, body)}")
71
+ Webhook.set_header(headers, "x-cronwatch-timestamp", timestamp)
72
+ Webhook.set_header(headers, "x-cronwatch-timestamp-signature", "sha256=#{Webhook.signature(@secret, "#{timestamp}.#{body}")}")
54
73
  end
55
74
  response = @http.post(@url, body, headers)
56
75
  # Only the origin: a webhook URL's path or query often is the credential.
57
76
  raise "Webhook #{Webhook.origin(@url)} answered #{response.status}" unless response.ok?
58
77
  end
59
78
 
79
+ # Sets a header, first taking out any of the same name in another case.
80
+ def self.set_header(headers, name, value)
81
+ lower = name.downcase
82
+ headers.delete_if { |key, _| key.downcase == lower }
83
+ headers[name] = value
84
+ end
85
+
60
86
  def self.origin(url)
61
87
  uri = URI(HTTP.clean_url(url))
62
88
  raise URI::InvalidURIError unless uri.scheme && uri.host
@@ -251,7 +251,7 @@ module Cronwatch
251
251
  # just before the block runs. The result is the same.
252
252
  if recorded
253
253
  begin
254
- update_state(name) { |before| [Evaluate.hold_run(Evaluate.on_run_start(before), run.id), nil] }
254
+ update_state(name) { |before| [Evaluate.hold_run(Evaluate.on_run_start(definition, run, before, now), run.id), nil] }
255
255
  rescue StandardError => e
256
256
  report(e, "starting #{name}")
257
257
  end
@@ -425,10 +425,10 @@ module Cronwatch
425
425
  end
426
426
  return [] unless evaluate
427
427
 
428
- update_state(run.job) { |before| [Evaluate.hold_run(Evaluate.on_run_start(before), run.id), nil] }
428
+ update_state(run.job) { |before| [Evaluate.hold_run(Evaluate.on_run_start(definition, run, before, now), run.id), nil] }
429
429
  return [] if run.status == :running
430
430
 
431
- finish_run(definition, run, now)
431
+ finish_run(definition, run, now, pending_only: true)
432
432
  end
433
433
 
434
434
  # Hands an error to on_error, as a source reports what went wrong. See #report.
@@ -437,9 +437,9 @@ module Cronwatch
437
437
  end
438
438
 
439
439
  # Look for missed and stuck runs across every job, send alerts, retry
440
- # alerts no channel accepted, and prune old runs. Call it from start_checking, a
441
- # scheduled job (Cronwatch::CheckJob), or by hand. Concurrent calls share
442
- # one check.
440
+ # alerts to the channels that refused them, and prune old runs. Call it
441
+ # from start_checking, a scheduled job (Cronwatch::CheckJob), or by hand.
442
+ # Concurrent calls share one check.
443
443
  def check
444
444
  after_fork_check
445
445
  flight = nil
@@ -763,10 +763,12 @@ module Cronwatch
763
763
  def ensure_ready
764
764
  return if @ready
765
765
 
766
+ missing = nil
766
767
  @ready_lock.synchronize do
767
768
  next if @ready
768
769
 
769
770
  @store.init if @store.respond_to?(:init)
771
+ missing = %i[compare_and_set_state update_run_if].reject { |method| @store.respond_to?(method) }
770
772
  if @using_default_store && Environment.production?
771
773
  warn "[cronwatch] using the in-memory store: runs and state are lost on restart. " \
772
774
  "Pass a store such as Cronwatch::Stores::ActiveRecord."
@@ -774,6 +776,12 @@ module Cronwatch
774
776
  # Only once init has gone through: a failure is tried again on the next call.
775
777
  @ready = true
776
778
  end
779
+ return if missing.nil? || missing.empty?
780
+
781
+ # Once per client, outside the lock: it still works, but only as long
782
+ # as one process at a time uses the store.
783
+ report(RuntimeError.new("the store has no #{missing.join(" or ")}, so two processes sharing it can overwrite each other's " \
784
+ "state or both evaluate the same run; it is safe only while one process at a time uses it"), "store")
777
785
  end
778
786
 
779
787
  # Writes the declaration of `definition`'s name as it stands, unless the
@@ -911,6 +919,7 @@ module Cronwatch
911
919
  end
912
920
  return [] if !%i[running timeout].include?(stored.status) || run.status == :running
913
921
 
922
+ tracked = (read_state(run.job).pending_runs || []).include?(run.id)
914
923
  late, ignored = claim_finish(run)
915
924
  if ignored
916
925
  report(RuntimeError.new("run #{run.id} of #{run.job} #{ignored}; ignored"), "recording #{run.job}")
@@ -923,7 +932,7 @@ module Cronwatch
923
932
  end
924
933
  return [] if late && run.status != :ok
925
934
 
926
- finish_run(definition, run, now)
935
+ finish_run(definition, run, now, pending_only: tracked && !late)
927
936
  end
928
937
 
929
938
  # Takes a run stored without judging out of the job's pending runs, so a
@@ -1020,13 +1029,14 @@ module Cronwatch
1020
1029
  return "belongs to job \"#{stored.job}\"" if stored.job != run.job
1021
1030
  end
1022
1031
  end
1032
+ tracked = (read_state(run.job).pending_runs || []).include?(run.id)
1023
1033
  late, ignored = claim_finish(run)
1024
1034
  return ignored if ignored
1025
1035
 
1026
1036
  if !judge
1027
1037
  release_unjudged(run)
1028
1038
  elsif !late || run.status == :ok
1029
- finish_run(Serialize.to_stored(definition), run, finished_at)
1039
+ finish_run(Serialize.to_stored(definition), run, finished_at, pending_only: tracked && !late)
1030
1040
  end
1031
1041
  nil
1032
1042
  end
@@ -1074,7 +1084,7 @@ module Cronwatch
1074
1084
  end
1075
1085
  if recorded
1076
1086
  begin
1077
- update_state(name) { |before| [Evaluate.hold_run(Evaluate.on_run_start(before), run.id), nil] }
1087
+ update_state(name) { |before| [Evaluate.hold_run(Evaluate.on_run_start(definition, run, before, now), run.id), nil] }
1078
1088
  rescue StandardError => e
1079
1089
  report(e, "starting #{name}")
1080
1090
  end
@@ -1242,7 +1252,7 @@ module Cronwatch
1242
1252
  end
1243
1253
  next if run.status == :running || !run.finished_at.is_a?(Numeric) || run.finished_at > at - Evaluate::SEND_LEASE_MS
1244
1254
 
1245
- alerts.concat(finish_run(stored.definition, run, at))
1255
+ alerts.concat(finish_run(stored.definition, run, at, pending_only: true))
1246
1256
  rescue StandardError => e
1247
1257
  report(e, "evaluating run #{id} of #{stored.name}")
1248
1258
  end
@@ -1252,11 +1262,12 @@ module Cronwatch
1252
1262
  # Evaluate a finished run (ok, failed, or timed out by a check), already
1253
1263
  # written, against the job's state and send what that produces. The
1254
1264
  # alerts are written with that state (see outbox). Never raises.
1255
- def finish_run(definition, run, at)
1265
+ def finish_run(definition, run, at, pending_only: false)
1256
1266
  held = nil
1257
1267
  begin
1258
1268
  past = nil
1259
1269
  _, held = update_state(run.job) do |previous|
1270
+ next [previous, Outbox.new([], 0)] if pending_only && !(previous.pending_runs || []).include?(run.id)
1260
1271
  past ||= history(run)
1261
1272
  before = Evaluate.release_run(previous, run.id)
1262
1273
  outbox(Evaluate.apply_silence(before, Evaluate.on_run_finish(definition, run, before, past, at), at), definition, at)
@@ -1451,11 +1462,15 @@ module Cronwatch
1451
1462
 
1452
1463
  delivered = []
1453
1464
  failed = []
1465
+ reached = false
1454
1466
  alerts.each do |alert|
1455
1467
  add_triage(alert, @triage_timeout_ms) if @triage && alert.type != :recovered
1456
- (deliver(alert) ? delivered : failed) << alert
1468
+ # The alert as composed, full run and all; only a retry reads the trimmed copy held in state.
1469
+ left, took = deliver(alert)
1470
+ reached ||= took
1471
+ left ? failed << left : delivered << alert
1457
1472
  end
1458
- record_delivery(name, delivered, failed, [], at)
1473
+ record_delivery(name, delivered, failed, [], at, reached)
1459
1474
  alerts
1460
1475
  end
1461
1476
 
@@ -1464,14 +1479,16 @@ module Cronwatch
1464
1479
  # @api private
1465
1480
  RetryBudget = Struct.new(:spent_ms)
1466
1481
 
1467
- # Send the alerts that no channel accepted last time, once each, oldest
1468
- # first. They are claimed first (Evaluate.claim_undelivered): moved into
1469
- # `sending` with a lease, in one conditional write, so when several
1470
- # processes check at once only the one whose write lands sends them. An
1471
- # alert that no longer describes the job (Evaluate.stale_alert?) is
1472
- # dropped instead. Retries across a check share RETRY_BUDGET_MS of
1473
- # wall-clock time; claimed alerts it has no time left for go back to the
1474
- # queue for the next check.
1482
+ # Send the alerts some channel refused last time, once each, oldest
1483
+ # first, to the channels that still owe them (Alert#owed) and no others,
1484
+ # from the copy held in state, its run trimmed (Evaluate.held_run). They
1485
+ # are claimed first (Evaluate.claim_undelivered): moved into `sending`
1486
+ # with a lease, in one conditional write, so when several processes check
1487
+ # at once only the one whose write lands sends them. An alert that no
1488
+ # longer describes the job (Evaluate.stale_alert?) is dropped instead.
1489
+ # Retries across a check share RETRY_BUDGET_MS of wall-clock time;
1490
+ # claimed alerts it has no time left for go back to the queue for the
1491
+ # next check. Returns the alerts some channel took on this attempt.
1475
1492
  def retry_undelivered(name, state, at, budget)
1476
1493
  return [] if (state.undelivered || []).empty? || @defer_delivery
1477
1494
 
@@ -1488,6 +1505,7 @@ module Cronwatch
1488
1505
 
1489
1506
  delivered = []
1490
1507
  failed = []
1508
+ sent = []
1491
1509
  claimed.each do |alert|
1492
1510
  left = @retry_budget_ms - budget.spent_ms
1493
1511
  if left <= 0
@@ -1500,21 +1518,25 @@ module Cronwatch
1500
1518
  # An alert queued by a process that delivers at check time was never
1501
1519
  # triaged. One that was tried (triage: null) is not tried again.
1502
1520
  add_triage(alert, [@triage_timeout_ms, left].min) if @triage && alert.type != :recovered && !alert.triage_tried?
1503
- (deliver(alert) ? delivered : failed) << alert
1521
+ left, took = deliver(alert)
1522
+ sent << Evaluate.without_owed(alert) if took
1523
+ left ? failed << left : delivered << alert
1504
1524
  budget.spent_ms += [0, ((AbortSignal.monotonic - started) * 1000).round].max
1505
1525
  end
1506
- record_delivery(name, delivered, failed, [], at)
1507
- delivered
1526
+ record_delivery(name, delivered, failed, [], at, !sent.empty?)
1527
+ sent
1508
1528
  end
1509
1529
 
1510
1530
  # Mark delivered alerts done, drop stale ones, and keep failed ones for the
1511
1531
  # next check, taking them all out of `sending` (Evaluate.record_sent). A
1512
1532
  # failed alert replaces its stored copy, so a triage made on this attempt
1513
- # is kept. last_alert_at moves only on a delivery. When this write fails,
1514
- # alerts still in `sending` are retried once their lease runs out.
1515
- def record_delivery(name, delivered, failed, dropped, at)
1533
+ # and the channels that still owe it are kept. last_alert_at moves when
1534
+ # `reached`, some channel accepted an alert on this attempt. When this
1535
+ # write fails, alerts still in `sending` are retried once their lease
1536
+ # runs out.
1537
+ def record_delivery(name, delivered, failed, dropped, at, reached)
1516
1538
  _, trimmed = update_state(name) do |previous|
1517
- held = Evaluate.record_sent(Evaluate.normalize_state(previous, name), delivered, failed, dropped, at)
1539
+ held = Evaluate.record_sent(Evaluate.normalize_state(previous, name), delivered, failed, dropped, at, reached)
1518
1540
  [held.state, held.dropped]
1519
1541
  end
1520
1542
  report_dropped(name, trimmed)
@@ -1522,17 +1544,28 @@ module Cronwatch
1522
1544
  report(e, "recording alert delivery for #{name}")
1523
1545
  end
1524
1546
 
1525
- # Send to every channel at once, each in its own thread with its own
1526
- # timeout. True when at least one accepted it, or there are none. A
1527
- # channel that times out is left to finish on its own, and nothing more
1528
- # is sent to it until it has: meanwhile its alerts count as not
1529
- # delivered there, to be retried by a later check. So a hung channel
1530
- # holds one thread, not one per alert.
1547
+ # Send to every channel that still owes the alert (Evaluate.owed_channels:
1548
+ # all of them for an alert not sent before), at once, each in its own
1549
+ # thread with its own timeout, each failure reported under its channel's
1550
+ # name. Returns [left, reached]: `left` is the alert with `owed` cut to
1551
+ # the channels that refused it, or nil when none did (or none is left to
1552
+ # owe it); `reached` is whether any channel accepted it. With no
1553
+ # channels at all it counts as delivered. A channel that times out is
1554
+ # left to finish on its own, and nothing more is sent to it until it
1555
+ # has: meanwhile its alerts count as not delivered there, to be retried
1556
+ # by a later check. So a hung channel holds one thread, not one per alert.
1531
1557
  def deliver(alert)
1532
- return true if @alerts.empty?
1558
+ return [nil, true] if @alerts.empty?
1559
+
1560
+ keys = Evaluate.channel_keys(@alerts.map { |channel| channel_name(channel).to_s })
1561
+ owed = Evaluate.owed_channels(alert, keys)
1562
+ return [nil, false] if owed.empty?
1533
1563
 
1564
+ plain = Evaluate.without_owed(alert)
1534
1565
  outcomes = Array.new(@alerts.length)
1535
1566
  threads = @alerts.each_with_index.map do |channel, i|
1567
+ next nil unless owed.include?(keys[i])
1568
+
1536
1569
  if @sending_lock.synchronize { @abandoned[i]&.alive? }
1537
1570
  outcomes[i] = TimeoutError.new("skipped: an earlier alert timed out and is still being sent")
1538
1571
  next nil
@@ -1540,7 +1573,7 @@ module Cronwatch
1540
1573
 
1541
1574
  Thread.new do
1542
1575
  Thread.current.report_on_exception = false
1543
- send_to(channel, alert)
1576
+ send_to(channel, plain)
1544
1577
  outcomes[i] = true
1545
1578
  # Anything the channel raised, SystemStackError and exit included, is
1546
1579
  # a failed send: raised again through join, it would replace the
@@ -1551,6 +1584,7 @@ module Cronwatch
1551
1584
  end
1552
1585
  deadline = AbortSignal.monotonic + (@channel_timeout_ms / 1000.0)
1553
1586
  results = threads.each_with_index.map do |thread, i|
1587
+ next nil unless owed.include?(keys[i])
1554
1588
  next outcomes[i] if thread.nil?
1555
1589
  if thread.join([deadline - AbortSignal.monotonic, 0].max)
1556
1590
  # A thread killed from outside leaves no outcome: not sent.
@@ -1560,10 +1594,14 @@ module Cronwatch
1560
1594
  @sending_lock.synchronize { @abandoned[i] = thread }
1561
1595
  TimeoutError.new("timed out after #{@channel_timeout_ms}ms")
1562
1596
  end
1597
+ failed = []
1563
1598
  results.each_with_index do |result, i|
1564
- report(result, "alert channel #{channel_name(@alerts[i])}") unless result == true
1599
+ next if result.nil? || result == true
1600
+
1601
+ failed << keys[i]
1602
+ report(result, "alert channel #{channel_name(@alerts[i])}")
1565
1603
  end
1566
- results.include?(true)
1604
+ [Evaluate.after_send(alert, owed, failed), failed.length < owed.length]
1567
1605
  end
1568
1606
 
1569
1607
  # channel.call(alert, context), the context's on_error reporting a problem
@@ -120,7 +120,7 @@ module Cronwatch
120
120
  # environment, so under one of them, in production too, RACK_ENV alone
121
121
  # reads "development". There it takes CRONWATCH_ENV, APP_ENV, Rails.env,
122
122
  # or RAILS_ENV to say development; any other value of RACK_ENV that reads
123
- # as development ("test", "dev") still counts, as no server sets it.
123
+ # as development ("dev", "local") still counts, as no server sets it.
124
124
  def stated_development?
125
125
  return false unless development?
126
126