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
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 9f5d4d303ae53fd20968b2f416e805a3f840b07b152004878147c3d3c3e4e899
|
|
4
|
+
data.tar.gz: 184ac01a139983fdabc2344586a4bc6c6669b987864a8c84d7f7b8f8bd90909d
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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.
|
|
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 `
|
|
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`, `
|
|
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"
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
|
@@ -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
|
-
|
|
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
|
|
|
@@ -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
|
|
|
@@ -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
|
|
|
@@ -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
|
|
8
|
-
# { type, run, details, job, definition, title,
|
|
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=#{
|
|
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.
|