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.
Files changed (50) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +20 -11
  3. data/lib/cronwatch/alerts/datadog.rb +1 -0
  4. data/lib/cronwatch/alerts/discord.rb +34 -2
  5. data/lib/cronwatch/alerts/email.rb +2 -0
  6. data/lib/cronwatch/alerts/honeybadger.rb +1 -0
  7. data/lib/cronwatch/alerts/postmark.rb +1 -0
  8. data/lib/cronwatch/alerts/provider.rb +2 -0
  9. data/lib/cronwatch/alerts/resend.rb +1 -0
  10. data/lib/cronwatch/alerts/rollbar.rb +1 -0
  11. data/lib/cronwatch/alerts/sentry.rb +1 -0
  12. data/lib/cronwatch/alerts/sigv4.rb +2 -0
  13. data/lib/cronwatch/alerts/slack.rb +1 -0
  14. data/lib/cronwatch/alerts/twilio.rb +1 -0
  15. data/lib/cronwatch/alerts/webhook.rb +18 -5
  16. data/lib/cronwatch/client.rb +275 -110
  17. data/lib/cronwatch/cron_pattern.rb +17 -8
  18. data/lib/cronwatch/deprecation.rb +18 -0
  19. data/lib/cronwatch/duration.rb +2 -0
  20. data/lib/cronwatch/environment.rb +109 -7
  21. data/lib/cronwatch/evaluate.rb +145 -10
  22. data/lib/cronwatch/flight.rb +6 -0
  23. data/lib/cronwatch/format.rb +1 -0
  24. data/lib/cronwatch/http.rb +21 -5
  25. data/lib/cronwatch/job.rb +7 -2
  26. data/lib/cronwatch/js.rb +74 -1
  27. data/lib/cronwatch/monitored.rb +6 -1
  28. data/lib/cronwatch/output.rb +28 -1
  29. data/lib/cronwatch/pg_cron.rb +29 -6
  30. data/lib/cronwatch/rails/active_job.rb +2 -2
  31. data/lib/cronwatch/schedule.rb +25 -1
  32. data/lib/cronwatch/scheduler.rb +79 -11
  33. data/lib/cronwatch/serialize.rb +1 -0
  34. data/lib/cronwatch/stats.rb +1 -0
  35. data/lib/cronwatch/stores/active_record.rb +37 -11
  36. data/lib/cronwatch/ticker.rb +14 -0
  37. data/lib/cronwatch/triage/anthropic.rb +2 -0
  38. data/lib/cronwatch/types.rb +199 -27
  39. data/lib/cronwatch/version.rb +1 -1
  40. data/lib/cronwatch/walker.rb +2 -0
  41. data/lib/cronwatch/web/app.rb +114 -37
  42. data/lib/cronwatch/web/html.rb +9 -5
  43. data/lib/cronwatch/web/origin.rb +2 -0
  44. data/lib/cronwatch/web/pwa.rb +2 -0
  45. data/lib/cronwatch/web/timeline.rb +2 -0
  46. data/lib/cronwatch/web.rb +1 -2
  47. data/lib/cronwatch/zone.rb +2 -0
  48. data/lib/cronwatch.rb +2 -0
  49. data/lib/generators/cronwatch/install/install_generator.rb +52 -8
  50. metadata +2 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 6df945edfb266ca737de6343276860554a4d3d740331091e1167d00fe93888a4
4
- data.tar.gz: ec33c3140fb5f453de2cd0263b74a894b590408394f9f1aade76f1be2485e190
3
+ metadata.gz: 9f5d4d303ae53fd20968b2f416e805a3f840b07b152004878147c3d3c3e4e899
4
+ data.tar.gz: 184ac01a139983fdabc2344586a4bc6c6669b987864a8c84d7f7b8f8bd90909d
5
5
  SHA512:
6
- metadata.gz: ba15e440f49e390715b9668a24c0ee2b1c0cf35dac907a461555d18181fc95572b0ba3fee50b00237893733ca5b864b9f35b6a4e30b0f0bee64e6eefa1f3c5b0
7
- data.tar.gz: 71e579e3ebba4a279cfef5de79d611157dcc2fc8ad176fac77cc737e7e0750c9ecd3cfefed383b2ac51f2f7aef5a5a24a24a472b6b739e90c1bd007a5814f0f5
6
+ metadata.gz: 333767b7bcffda2b75c1349644c7fa870c371c9a5c1ee0491083effcdbb1030c54dfc561a36a883b8588411caa59b19ca4c36c06638c61c5652917c29f1252fa
7
+ data.tar.gz: 0ecbafab015195219f3dfb53e03dd202766342fc11195b7d97cdac4610821940a2e8d777898834aa8b015236b5b5a1a11eb0f1ecd424c42f52a4b84eddfb14b1
data/README.md CHANGED
@@ -47,10 +47,10 @@ NIGHTLY.run do |job|
47
47
  job.metric(:cost, 1.2) # watched against budgets and baselines
48
48
  end
49
49
 
50
- CW.start # checks for missed and stuck runs every minute, in a background thread
50
+ CW.start_checking # checks for missed and stuck runs every minute, in a background thread
51
51
  ```
52
52
 
53
- `run` returns what the block returns and raises what it raises, after the run is recorded. A script run from crontab exits when it is done, so instead of `start`, add a second crontab line that declares the jobs and calls `CW.check` every five minutes.
53
+ `run` returns what the block returns and raises what it raises, after the run is recorded. A script run from crontab exits when it is done, so instead of `start_checking`, add a second crontab line that declares the jobs and calls `CW.check` every five minutes.
54
54
 
55
55
  A run that starts in one call and ends in another (a job that hands work to a queue, a webhook that reports back later) is one run too:
56
56
 
@@ -64,7 +64,7 @@ run.finish # or run.fail(error); finish("text") and fin
64
64
 
65
65
  Neither raises for the store: failures go to `on_error`, and a finish of a run already finished, not found, or of another job records nothing and returns nil. A run is judged once, however many processes finish it at once, and a store that fails during `finish` leaves the handle active to finish again. A run never finished is marked stuck after the job's `timeout`.
66
66
 
67
- Client options: `store`, `alerts`, `triage`, `cron_secret`, `retention` (default `"30d"`), `defaults`, `sources` (where runs the process does not wrap come from, such as pg_cron), `redact` (default: blank values that look like secrets; `false` keeps output as logged, or pass a callable), `deliver` (`:now` by default; `:check` queues alerts for another process's check to send, for a worker that cannot reach Slack), `on_error` and `now`. Methods: `job`, `run`, `resume_run`, `record_run`, `check`, `start`/`stop`, `silence(name, for: "2h")`/`unsilence`, `forget`, `jobs`, `jobs_with_runs`, `job_summary`, `runs`, `get_run`, `defined_jobs`, `close`. [cronwatch.dev/docs/ruby](https://cronwatch.dev/docs/ruby/#api) has each one.
67
+ Client options: `store`, `alerts`, `triage`, `cron_secret`, `retention` (default `"30d"`), `defaults`, `sources` (where runs the process does not wrap come from, such as pg_cron), `redact` (default: blank values that look like secrets; `false` keeps output as logged, or pass a callable), `deliver` (`:now` by default; `:check` queues alerts for another process's check to send, for a worker that cannot reach Slack), `on_error` and `now`. Methods: `job`, `run`, `resume_run`, `record_run`, `check`, `start_checking`/`stop`, `silence(name, for: "2h")`/`unsilence`, `forget`, `jobs`, `jobs_with_runs`, `job_summary`, `runs`, `get_run`, `defined_jobs`, `close`. [cronwatch.dev/docs/ruby](https://cronwatch.dev/docs/ruby/#api) has each one.
68
68
 
69
69
  ## Rails
70
70
 
@@ -74,7 +74,7 @@ bin/rails generate cronwatch:install # a migration and config/initializers/cr
74
74
  bin/rails db:migrate
75
75
  ```
76
76
 
77
- The generator takes `--prefix` (table prefix, default `cronwatch_`) and `--database` (the database whose migrations directory gets the migration). The initializer it writes sets the ActiveRecord store, which Rails needs so web, worker and check processes see the same runs:
77
+ The generator takes `--prefix` (table prefix, default `cronwatch_`) and `--database` (the database, from `config/database.yml`, whose migrations directory gets the migration; it also writes `app/models/cronwatch_record.rb`, an abstract class that `connects_to` that database, and names it as the store's `connection_class`, so the store reads the tables where the migration made them). The initializer it writes sets the ActiveRecord store, which Rails needs so web, worker and check processes see the same runs:
78
78
 
79
79
  ```ruby
80
80
  # config/initializers/cronwatch.rb
@@ -100,7 +100,7 @@ class NightlyReportJob < ApplicationJob
100
100
  end
101
101
  ```
102
102
 
103
- Every `perform` is recorded as a run with the trigger `"active_job"`. The name defaults to the class name without `Job`, dasherized, with `::` as `:` (`Reports::NightlyJob` is `reports:nightly`); pass `name:` to choose another. Jobs are declared once the app has booted, so a check knows a job that has never run. A job that raises still raises after the run is recorded, so ActiveJob retries and your error reporter see it as before.
103
+ Every `perform` is recorded as a run with the trigger `"active-job"` (runs recorded before 1.0 carry `"active_job"`). The name defaults to the class name without `Job`, dasherized, with `::` as `:` (`Reports::NightlyJob` is `reports:nightly`); pass `name:` to choose another. Jobs are declared once the app has booted, so a check knows a job that has never run. A job that raises still raises after the run is recorded, so ActiveJob retries and your error reporter see it as before.
104
104
 
105
105
  ### Sidekiq
106
106
 
@@ -124,7 +124,7 @@ The same `cronwatch` as for ActiveJob, for classes that include `Sidekiq::Job` (
124
124
  cronwatch schedule: :from_scheduler, grace: "15m" # ActiveJob or Sidekiq
125
125
  ```
126
126
 
127
- reads the class's one entry in Solid Queue's `config/recurring.yml` (the section for `Rails.env`) or sidekiq-cron's `config/schedule.yml`, so the schedule is written once. Fugit phrases such as `every day at 3am` or `every hour at minute 12` become the cron expression Fugit makes of them (`0 3 * * *`, `12 * * * *`), in the zone the scheduler reads them in. Each conversion is checked against Fugit's own next run times; anything CronWatch would not expect at exactly those times is refused at boot rather than approximated: every other week (`%`), days counted back from the month's end other than the last, random times (`~`), offsets instead of IANA zones, and a time that daylight saving skips (Fugit skips that run; CronWatch would report it missed). A class with no entry, or with two, also stops the boot.
127
+ reads the class's one entry in Solid Queue's `config/recurring.yml` (the section for `Rails.env`) or sidekiq-cron's `config/schedule.yml`, so the schedule is written once. Fugit phrases such as `every day at 3am` or `every hour at minute 12` become the cron expression Fugit makes of them (`0 3 * * *`, `12 * * * *`), in the zone the scheduler reads them in. Each conversion is checked against Fugit's own next run times; anything CronWatch would not expect at exactly those times is refused at boot rather than approximated: every other week (`%`), days counted back from the month's end other than the last, random times (`~`), offsets instead of IANA zones, and a time that daylight saving skips (Fugit skips that run; CronWatch would report it missed). A class with no entry, or with two, also stops the boot; one scheduled only in another environment's section (Rails 8's `recurring.yml` has only `production:`) is declared without a schedule, and a process started with `SOLID_QUEUE_SKIP_RECURRING` still reads the entry.
128
128
 
129
129
  `Cronwatch.declare_from_scheduler!(grace: "10m")` in the initializer watches every enabled entry once the app has booted: classes that call `cronwatch` declare themselves, other classes are named as `cronwatch` would name them and their runs are recorded, and Solid Queue `command:` tasks are named after their key. `except:` leaves keys out. `Cronwatch::Scheduler.sources` sets where to read, when the defaults (Solid Queue's file when Solid Queue is loaded, sidekiq-cron's when sidekiq-cron is) are not right.
130
130
 
@@ -164,11 +164,11 @@ Or from a crontab: `bin/rails cronwatch:check`.
164
164
  ```ruby
165
165
  # config/routes.rb
166
166
  Rails.application.routes.draw do
167
- mount Cronwatch::Web.new(Cronwatch.client) => "/cronwatch"
167
+ mount Cronwatch.client.routes => "/cronwatch"
168
168
  end
169
169
  ```
170
170
 
171
- Set `CRONWATCH_TOKEN` to a long random string and open `/cronwatch?token=<it>` once; the browser keeps a cookie. Without a token, while the environment (`Rails.env`, else `RAILS_ENV` or `RACK_ENV`) is `development` or `test`, it makes a token of its own and prints a sign-in link to standard output on its first request (open it once; unless `origin:` is set or the request's host is loopback, the link leaves the host out, since a client chooses it); anywhere else it answers 503. To rely on the app's own sign in, mount it behind that (Devise's `authenticate` block, or a routing constraint) and pass `token: nil`. The URLs, JSON shapes, headers and CSRF rules are the SDK's, so the MCP server reads it unchanged. The request's origin, which the CSRF check compares against, is what Rack reports and so already follows `X-Forwarded-Host` and `X-Forwarded-Proto`; pass `origin: "https://app.example.com"` to pin it instead. `GET /cronwatch/api/check` with a bearer (the token or `CRON_SECRET`) runs the check, for an outside cron.
171
+ Set `CRONWATCH_TOKEN` to a long random string and open `/cronwatch?token=<it>` once; the browser keeps a cookie. Without a token, while the environment (the first of `CRONWATCH_ENV`, `APP_ENV`, then `Rails.env` (when Rails is loaded), `RAILS_ENV` or `RACK_ENV` that holds more than spaces, trimmed and lowercased) is `development`, `dev`, `local`, `test` or `testing`, it makes a token of its own and prints a sign-in link to standard output on its first request (open it once; unless `origin:` is set or the request's host is loopback, the link leaves the host out, since a client chooses it); anywhere else it answers 503. Puma, Unicorn, Thin and rackup set `RACK_ENV` to `development` when nothing names an environment, production included, so under one of them `RACK_ENV=development` alone does not count: `CRONWATCH_ENV`, `APP_ENV`, `Rails.env` or `RAILS_ENV` must say development (or `RACK_ENV` say `test`, `dev` or `local`). To rely on the app's own sign in, mount it behind that (Devise's `authenticate` block, or a routing constraint) and pass `token: nil`. The URLs, JSON shapes, headers and CSRF rules are the SDK's, so the MCP server reads it unchanged. The request's origin, which the CSRF check compares against, is what Rack reports and so already follows `X-Forwarded-Host` and `X-Forwarded-Proto`; pass `origin: "https://app.example.com"` to pin it instead. `GET /cronwatch/api/check` with a bearer (the token or `CRON_SECRET`) runs the check, for an outside cron.
172
172
 
173
173
  There is no `handler()` as in the TypeScript SDK: for a job triggered over HTTP, wrap the controller action's body in `CW.job(...).run` (declared once, at boot) and check the bearer in the controller.
174
174
 
@@ -176,10 +176,19 @@ In any other Rack app:
176
176
 
177
177
  ```ruby
178
178
  # config.ru
179
- require "cronwatch/web"
180
- map("/cronwatch") { run Cronwatch::Web.new(CW) }
179
+ map("/cronwatch") { run CW.routes }
181
180
  ```
182
181
 
182
+ `client.routes(**options)` (`token:`, `base_path:`, `origin:`) is the one way to build the dashboard, as `routes()` is in every language.
183
+
184
+ ### Public and internal
185
+
186
+ Public means what this README and the docs at cronwatch.dev name. Everything else is internal and may change in any release: anything marked `@api private` in the source (`Evaluate`, `Schedule`, `Format`, `JS`, `HTTP` and the dashboard's helpers among them), and the constants made private with `private_constant`.
187
+
188
+ ### Deprecated
189
+
190
+ These still work through every 1.x release and go in 2.0, each with a warning in Ruby's deprecation category (shown under `ruby -w` or `-W:deprecated`): `Cronwatch::Web.new(client, **options)` (use `client.routes(**options)`), `client.start(every)` (use `client.start_checking(every)`), `client.run(id)` without a block (use `client.get_run(id)`) and `client.silence(name, "2h")` (use `client.silence(name, for: "2h")`). The ActiveJob trigger is `active-job` from 1.0; runs recorded before keep `active_job`.
191
+
183
192
  ## Stores
184
193
 
185
194
  - `Cronwatch::Stores::Memory.new`: the default. Nothing survives a restart.
@@ -204,7 +213,7 @@ Cronwatch::Alerts::Custom.new("pagerduty") do |alert|
204
213
  end
205
214
  ```
206
215
 
207
- 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 signs its body with `X-CronWatch-Signature: sha256=<hex>`, the HMAC-SHA256 of the raw body.
216
+ 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)`.
208
217
 
209
218
  ## Triage
210
219
 
@@ -11,6 +11,7 @@ module Cronwatch
11
11
  ALERT_TYPE = {
12
12
  missed: "error", failed: "error", stuck: "error", slow: "warning", over_budget: "warning", recovered: "success",
13
13
  }.freeze
14
+ private_constant :ALERT_TYPE
14
15
 
15
16
  attr_reader :name
16
17
 
@@ -9,6 +9,11 @@ module Cronwatch
9
9
  missed: 0xb7791f, failed: 0xc62828, stuck: 0xc62828, slow: 0xb7791f, over_budget: 0xb7791f, recovered: 0x1f8a4c,
10
10
  }.freeze
11
11
 
12
+ # The longest embed description Discord takes. The title (under 256)
13
+ # and it stay well inside the embed's 6000.
14
+ DESCRIPTION_MAX = 4096
15
+ private_constant :COLOR, :DESCRIPTION_MAX
16
+
12
17
  attr_reader :name
13
18
 
14
19
  def initialize(webhook_url:, link: nil, http: HTTP.default)
@@ -24,8 +29,7 @@ module Cronwatch
24
29
  url = @link&.call(alert)
25
30
  embed = { "title" => alert.title }
26
31
  embed["url"] = url if url && !url.empty?
27
- triage = alert.triage && !alert.triage.empty? ? "\n**Triage:** #{Discord.escape_markdown(JS.head16(alert.triage, 1000))}" : ""
28
- embed["description"] = "```\n#{Discord.code_block_safe(JS.head16(alert.message, 3800))}\n```#{triage}"
32
+ embed["description"] = Discord.embed_description(alert)
29
33
  embed["color"] = COLOR[alert.type]
30
34
  embed["timestamp"] = JS.iso(alert.at)
31
35
  payload = {
@@ -38,6 +42,34 @@ module Cronwatch
38
42
  raise "Discord webhook answered #{response.status}: #{JS.head16(response.body.to_s, 200)}" unless response.ok?
39
43
  end
40
44
 
45
+ # The message in a code block, then the triage. Each part has its own
46
+ # cap, and escaping can grow both, so the whole is held to
47
+ # DESCRIPTION_MAX (in UTF-16 units) by cutting the message's block,
48
+ # never the triage: Discord refuses a longer one on every retry.
49
+ #
50
+ # Where a cap cuts a surrogate pair the high half is kept, as the SDK's
51
+ # slice keeps it, and written as JSON.stringify writes it (see
52
+ # JS.lone_surrogate). The cut of the block to the room left leaves out
53
+ # a pair or a lone half, as the SDK's cut does.
54
+ def self.embed_description(alert)
55
+ triage = ""
56
+ triage_units = 0
57
+ if alert.triage && !alert.triage.empty?
58
+ head, lone = JS.slice16(alert.triage, 1000)
59
+ triage = "\n**Triage:** #{escape_markdown(head)}"
60
+ triage_units = JS.length16(triage)
61
+ if lone
62
+ triage += JS.lone_surrogate(lone)
63
+ triage_units += 1
64
+ end
65
+ end
66
+ room = DESCRIPTION_MAX - "```\n".length - "\n```".length - triage_units
67
+ head, lone = JS.slice16(alert.message, 3800)
68
+ block = code_block_safe(head)
69
+ block = lone && JS.length16(block) + 1 <= room ? block + JS.lone_surrogate(lone) : JS.head16(block, room)
70
+ "```\n" + block + "\n```" + triage
71
+ end
72
+
41
73
  # Breaks up ``` so text inside a code block cannot close it.
42
74
  def self.code_block_safe(text)
43
75
  text.gsub("```", "`\u200b`\u200b`")
@@ -11,6 +11,8 @@ module Cronwatch
11
11
  # to: one address or several
12
12
  # subject_prefix: put in front of the title in the subject, "[prod]" say
13
13
  # link: ->(alert) { "https://app.example.com/cronwatch/jobs/#{alert.job}" }
14
+ #
15
+ # @api private
14
16
  module Email
15
17
  Message = Struct.new(:from, :to, :subject, :text, :html, keyword_init: true)
16
18
 
@@ -11,6 +11,7 @@ module Cronwatch
11
11
  missed: "CronWatch::Missed", failed: "CronWatch::Failed", stuck: "CronWatch::Stuck", slow: "CronWatch::Slow",
12
12
  over_budget: "CronWatch::OverBudget", recovered: "CronWatch::Recovered",
13
13
  }.freeze
14
+ private_constant :CLASS
14
15
 
15
16
  attr_reader :name
16
17
 
@@ -7,6 +7,7 @@ module Cronwatch
7
7
  # defaults to "outbound", the transactional stream.
8
8
  class Postmark
9
9
  ENDPOINT = "https://api.postmarkapp.com/email"
10
+ private_constant :ENDPOINT
10
11
 
11
12
  attr_reader :name
12
13
 
@@ -10,6 +10,8 @@ module Cronwatch
10
10
  # SDK's alerts/shared.ts: the POST that names the provider and the URL's
11
11
  # origin on failure with every secret cut out, the stable alert id, and
12
12
  # the run summary trackers attach. Standard library only.
13
+ #
14
+ # @api private
13
15
  module Provider
14
16
  module_function
15
17
 
@@ -9,6 +9,7 @@ module Cronwatch
9
9
  # from: "CronWatch <alerts@example.com>", to: "ops@example.com")
10
10
  class Resend
11
11
  ENDPOINT = "https://api.resend.com/emails"
12
+ private_constant :ENDPOINT
12
13
 
13
14
  attr_reader :name
14
15
 
@@ -8,6 +8,7 @@ module Cronwatch
8
8
  # `recovered: false`.
9
9
  class Rollbar
10
10
  ENDPOINT = "https://api.rollbar.com/api/1/item/"
11
+ private_constant :ENDPOINT
11
12
 
12
13
  attr_reader :name
13
14
 
@@ -13,6 +13,7 @@ module Cronwatch
13
13
  # Envelopes: https://develop.sentry.dev/sdk/data-model/envelopes/
14
14
  # Event payload: https://develop.sentry.dev/sdk/data-model/event-payloads/
15
15
  class Sentry
16
+ # @api private
16
17
  Dsn = Struct.new(:endpoint, :public_key, keyword_init: true)
17
18
 
18
19
  attr_reader :name
@@ -9,6 +9,8 @@ module Cronwatch
9
9
  # alerts/sigv4.ts. Spec:
10
10
  # https://docs.aws.amazon.com/IAM/latest/UserGuide/reference_sigv-create-signed-request.html
11
11
  # Checked against the AWS SigV4 test suite (test/provider_channels_test.rb).
12
+ #
13
+ # @api private
12
14
  module SigV4
13
15
  SPACES = Regexp.new("[#{JS::WHITESPACE}]+")
14
16
 
@@ -11,6 +11,7 @@ module Cronwatch
11
11
  missed: ":hourglass_flowing_sand:", failed: ":x:", stuck: ":no_entry:", slow: ":turtle:",
12
12
  over_budget: ":moneybag:", recovered: ":white_check_mark:",
13
13
  }.freeze
14
+ private_constant :EMOJI
14
15
 
15
16
  attr_reader :name
16
17
 
@@ -31,6 +31,7 @@ module Cronwatch
31
31
  MAX_SEGMENTS = 10
32
32
  # Twilio refuses a Body longer than this.
33
33
  MAX_BODY = 1600
34
+ private_constant :GSM, :GSM_EXTENDED, :MAX_BODY, :MAX_SEGMENTS
34
35
 
35
36
  attr_reader :name
36
37
 
@@ -4,10 +4,11 @@ require "openssl"
4
4
 
5
5
  module Cronwatch
6
6
  module Alerts
7
- # POSTs the alert as JSON to any URL. The body is the alert's JSON:
8
- # { type, run, details, job, definition, title, message, at, triage }.
7
+ # POSTs the alert as JSON to any URL. The body is `"schema": 1` and then
8
+ # the alert's JSON: { schema, type, run, details, job, definition, title,
9
+ # message, at, triage }, described by https://cronwatch.dev/schemas/webhook/1.json.
9
10
  # With a secret, each request carries `X-CronWatch-Signature: sha256=<hex>`,
10
- # the HMAC-SHA256 of the raw body, so the receiver can verify it. Header
11
+ # the HMAC-SHA256 of the raw body (Webhook.signature), so the receiver can verify it. Header
11
12
  # values are trimmed. A redirect is an error, not followed (the headers
12
13
  # and the signature would go with it): point the url at where the
13
14
  # receiver really is.
@@ -24,13 +25,25 @@ module Cronwatch
24
25
  @http = http
25
26
  end
26
27
 
28
+ # The version of the body's shape, its first key. It goes up only in a
29
+ # major release; fields are added within one.
30
+ SCHEMA = 1
31
+ private_constant :SCHEMA
32
+
33
+ # The HMAC-SHA256 of `body` with `secret`, as lowercase hex, without the
34
+ # `sha256=` the header puts before it. A receiver compares it with the
35
+ # header in constant time (Rack::Utils.secure_compare).
36
+ def self.signature(secret, body)
37
+ OpenSSL::HMAC.hexdigest("SHA256", secret.to_s, body.to_s)
38
+ end
39
+
27
40
  def call(alert)
28
- body = JS.json(alert.to_h)
41
+ body = JS.json({ "schema" => SCHEMA }.merge(alert.to_h))
29
42
  headers = { "content-type" => "application/json", "user-agent" => "cronwatch" }
30
43
  # A pasted Authorization value often carries a stray space or newline, which a header would refuse.
31
44
  @headers.each { |name, value| headers[name.to_s] = value.is_a?(String) ? JS.trim(value) : value }
32
45
  if @secret && !@secret.empty?
33
- headers["x-cronwatch-signature"] = "sha256=#{OpenSSL::HMAC.hexdigest("SHA256", @secret, body)}"
46
+ headers["x-cronwatch-signature"] = "sha256=#{Webhook.signature(@secret, body)}"
34
47
  end
35
48
  response = @http.post(@url, body, headers)
36
49
  # Only the origin: a webhook URL's path or query often is the credential.