cronwatch 0.3.1 → 0.4.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 +19 -3
- data/lib/cronwatch/alerts/bugsnag.rb +69 -0
- data/lib/cronwatch/alerts/custom.rb +9 -2
- data/lib/cronwatch/alerts/datadog.rb +54 -0
- data/lib/cronwatch/alerts/email.rb +78 -0
- data/lib/cronwatch/alerts/honeybadger.rb +59 -0
- data/lib/cronwatch/alerts/mailgun.rb +36 -0
- data/lib/cronwatch/alerts/newrelic.rb +52 -0
- data/lib/cronwatch/alerts/postmark.rb +41 -0
- data/lib/cronwatch/alerts/provider.rb +159 -0
- data/lib/cronwatch/alerts/resend.rb +39 -0
- data/lib/cronwatch/alerts/rollbar.rb +53 -0
- data/lib/cronwatch/alerts/sendgrid.rb +41 -0
- data/lib/cronwatch/alerts/sentry.rb +94 -0
- data/lib/cronwatch/alerts/ses.rb +72 -0
- data/lib/cronwatch/alerts/sigv4.rb +84 -0
- data/lib/cronwatch/alerts/twilio.rb +206 -0
- data/lib/cronwatch/alerts/webhook.rb +7 -2
- data/lib/cronwatch/client.rb +473 -52
- data/lib/cronwatch/evaluate.rb +14 -1
- data/lib/cronwatch/format.rb +10 -4
- data/lib/cronwatch/http.rb +67 -6
- data/lib/cronwatch/job.rb +17 -0
- data/lib/cronwatch/pg_cron.rb +524 -0
- data/lib/cronwatch/run_handle.rb +203 -0
- data/lib/cronwatch/stores/active_record.rb +23 -0
- data/lib/cronwatch/stores/memory.rb +33 -10
- data/lib/cronwatch/types.rb +1 -0
- data/lib/cronwatch/version.rb +1 -1
- data/lib/cronwatch/web/app.rb +38 -11
- data/lib/cronwatch/web/origin.rb +133 -0
- data/lib/cronwatch/web.rb +1 -0
- data/lib/cronwatch.rb +17 -1
- metadata +19 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: e0e24280879ad52eeeaf138342e4266225aa702893875d07c1ae9e3f7728b1b5
|
|
4
|
+
data.tar.gz: e828a6fb7fd737df7100480129d986232f59e2adef2f41eed50a42ef7e71913a
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: af2ec018d78e9277c24a81392e071a26aa31554c7fb3d60967e96761dc615b6aa0e3a501c97415fdf73b8db57320e9d1ecc2e2840e6e843690705424c18e2d04
|
|
7
|
+
data.tar.gz: 2ccf44bdaedd6d130d4f77daabd6030ffdaf690ef484dded57e74cf68c8b2f01f8587acda1bfd54732d9ada59f5a2e7e90ad2f92bc0ec33be0c08f3437779dc6
|
data/README.md
CHANGED
|
@@ -17,7 +17,8 @@ Ruby 3.2 or newer; the Rails integration is tested on Rails 7.2, 8.0 and 8.1. Th
|
|
|
17
17
|
|
|
18
18
|
| Require | For | Needs |
|
|
19
19
|
|---|---|---|
|
|
20
|
-
| `cronwatch` | the client, the memory store, and the Slack, Discord, webhook and
|
|
20
|
+
| `cronwatch` | the client, the memory store, and the Slack, Discord, webhook, console, email (Resend, Postmark, SendGrid, Mailgun, SES), Twilio and error tracker (Sentry, Honeybadger, Datadog, Rollbar, Bugsnag, New Relic) channels | |
|
|
21
|
+
| `cronwatch/pg_cron` | `Cronwatch::Sources::PgCron`, which watches pg_cron jobs through an ActiveRecord or pg connection | |
|
|
21
22
|
| `cronwatch/active_record` | the ActiveRecord store | `activerecord` |
|
|
22
23
|
| `cronwatch/rails` | the Railtie, `Cronwatch::ActiveJob`, `Cronwatch::CheckJob`, the `cronwatch:check` task, the install generator | `railties`, `activejob` |
|
|
23
24
|
| `cronwatch/sidekiq` | `Cronwatch::Sidekiq` for `Sidekiq::Job` classes, its server middleware, `Cronwatch::Sidekiq::CheckWorker` | `sidekiq` 7 or newer |
|
|
@@ -51,7 +52,19 @@ CW.start # checks for missed and stuck runs every minute, in a background thread
|
|
|
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
54
|
|
|
54
|
-
|
|
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
|
+
|
|
57
|
+
```ruby
|
|
58
|
+
run = NIGHTLY.start(id: batch_id) # records a running run; a second start with this id finds it
|
|
59
|
+
# later, perhaps in another process
|
|
60
|
+
run = NIGHTLY.resume(batch_id)
|
|
61
|
+
run.log("Report written:", path)
|
|
62
|
+
run.finish # or run.fail(error); finish("text") and finish(result: x) work like run's return value
|
|
63
|
+
```
|
|
64
|
+
|
|
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
|
+
|
|
67
|
+
Client options: `store`, `alerts`, `triage`, `cron_secret`, `retention` (default `"30d"`), `defaults`, `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`, `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.
|
|
55
68
|
|
|
56
69
|
## Rails
|
|
57
70
|
|
|
@@ -155,7 +168,7 @@ Rails.application.routes.draw do
|
|
|
155
168
|
end
|
|
156
169
|
```
|
|
157
170
|
|
|
158
|
-
Set `CRONWATCH_TOKEN` to a long random string and open `/cronwatch?token=<it>` once; the browser keeps a cookie. Without a token, while `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); 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. `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 `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); 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.
|
|
159
172
|
|
|
160
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.
|
|
161
174
|
|
|
@@ -181,6 +194,9 @@ Cronwatch::Alerts::Slack.new(webhook_url: ENV.fetch("SLACK_WEBHOOK_URL"))
|
|
|
181
194
|
Cronwatch::Alerts::Discord.new(webhook_url: ENV.fetch("DISCORD_WEBHOOK_URL"))
|
|
182
195
|
Cronwatch::Alerts::Webhook.new(url: "https://hooks.example.com/cronwatch", secret: ENV["CRONWATCH_WEBHOOK_SECRET"])
|
|
183
196
|
Cronwatch::Alerts::Console.new # the default
|
|
197
|
+
Cronwatch::Alerts::Resend.new(api_key: ENV.fetch("RESEND_API_KEY"), from: "alerts@example.com", to: "ops@example.com")
|
|
198
|
+
Cronwatch::Alerts::Sentry.new(dsn: ENV.fetch("SENTRY_DSN"))
|
|
199
|
+
# and Postmark, Sendgrid, Mailgun, Ses, Twilio, Honeybadger, Datadog, Rollbar, Bugsnag, NewRelic
|
|
184
200
|
|
|
185
201
|
Cronwatch::Alerts::Custom.new("pagerduty") do |alert|
|
|
186
202
|
next if alert.type == :recovered
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Cronwatch
|
|
4
|
+
module Alerts
|
|
5
|
+
# Reports alerts to Bugsnag (Error Reporting API, payload version 5),
|
|
6
|
+
# grouped per job and alert type
|
|
7
|
+
# (https://developer.smartbear.com/bugsnag/docs/reporting-events-and-sessions).
|
|
8
|
+
# Recoveries are not sent unless `recovered: true`, since each one is an
|
|
9
|
+
# event on an error.
|
|
10
|
+
class Bugsnag
|
|
11
|
+
attr_reader :name
|
|
12
|
+
|
|
13
|
+
# endpoint: another notify endpoint, for on-premise installs.
|
|
14
|
+
# now: the clock for the Bugsnag-Sent-At header, a callable returning epoch milliseconds. For tests.
|
|
15
|
+
def initialize(api_key:, release_stage: nil, endpoint: nil, recovered: false, now: nil, link: nil, http: HTTP.default)
|
|
16
|
+
@api_key = Provider.require_credential(api_key, "Cronwatch::Alerts::Bugsnag needs an api_key")
|
|
17
|
+
@url = endpoint.nil? ? "https://notify.bugsnag.com/" : endpoint.to_s
|
|
18
|
+
@release_stage = release_stage
|
|
19
|
+
@recovered = recovered
|
|
20
|
+
@now = now || -> { Process.clock_gettime(Process::CLOCK_REALTIME, :millisecond) }
|
|
21
|
+
@link = link
|
|
22
|
+
@http = http
|
|
23
|
+
@name = "bugsnag"
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def call(alert)
|
|
27
|
+
return if alert.type == :recovered && !@recovered
|
|
28
|
+
|
|
29
|
+
link = Provider.link_for(@link, alert)
|
|
30
|
+
type = alert.type.to_s
|
|
31
|
+
meta = { "job" => alert.job, "type" => type }
|
|
32
|
+
meta["triage"] = alert.triage if Provider.present?(alert.triage)
|
|
33
|
+
meta["link"] = link if link
|
|
34
|
+
meta["details"] = Naming.to_json_value(alert.details)
|
|
35
|
+
meta["run"] = Provider.run_summary(alert)
|
|
36
|
+
payload = {
|
|
37
|
+
"apiKey" => @api_key,
|
|
38
|
+
"payloadVersion" => "5",
|
|
39
|
+
# The notifier's own version, not the gem's; Bugsnag asks for one.
|
|
40
|
+
"notifier" => { "name" => "cronwatch", "version" => "1.0.0", "url" => "https://cronwatch.dev" },
|
|
41
|
+
"events" => [
|
|
42
|
+
{
|
|
43
|
+
"exceptions" => [{
|
|
44
|
+
"errorClass" => "CronWatch #{type}", "message" => Provider.cut("#{alert.title}\n#{alert.message}", 8000),
|
|
45
|
+
"stacktrace" => [], "type" => "nodejs",
|
|
46
|
+
}],
|
|
47
|
+
"severity" => Provider.severity(alert.type),
|
|
48
|
+
"unhandled" => false,
|
|
49
|
+
"severityReason" => { "type" => "handledException" },
|
|
50
|
+
"context" => alert.job,
|
|
51
|
+
"groupingHash" => "cronwatch:#{alert.job}:#{type}",
|
|
52
|
+
"metaData" => { "cronwatch" => meta },
|
|
53
|
+
"app" => { "releaseStage" => @release_stage.nil? ? "production" : @release_stage },
|
|
54
|
+
"device" => { "time" => JS.iso(alert.at) },
|
|
55
|
+
},
|
|
56
|
+
],
|
|
57
|
+
}
|
|
58
|
+
headers = {
|
|
59
|
+
"content-type" => "application/json",
|
|
60
|
+
"bugsnag-api-key" => @api_key,
|
|
61
|
+
"bugsnag-payload-version" => "5",
|
|
62
|
+
"bugsnag-sent-at" => JS.iso(@now.call),
|
|
63
|
+
}
|
|
64
|
+
Provider.post(@http, "Bugsnag", @url, headers, JS.json(payload), [@api_key])
|
|
65
|
+
nil
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
@@ -17,8 +17,15 @@ module Cronwatch
|
|
|
17
17
|
raise ArgumentError, "Cronwatch::Alerts::Custom needs a block" unless @send
|
|
18
18
|
end
|
|
19
19
|
|
|
20
|
-
|
|
21
|
-
|
|
20
|
+
# `context` is the client's Client::ChannelContext: a block taking a
|
|
21
|
+
# second argument gets it, to report a problem that did not stop the
|
|
22
|
+
# alert going out (context.on_error(error)).
|
|
23
|
+
def call(alert, context = nil)
|
|
24
|
+
if Client.takes_context?(@send)
|
|
25
|
+
@send.call(alert, context)
|
|
26
|
+
else
|
|
27
|
+
@send.call(alert)
|
|
28
|
+
end
|
|
22
29
|
nil
|
|
23
30
|
end
|
|
24
31
|
end
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Cronwatch
|
|
4
|
+
module Alerts
|
|
5
|
+
# Posts alerts to the Datadog event stream (Events API v1), aggregated per
|
|
6
|
+
# job and alert type. `site` is your Datadog site: "datadoghq.com" (the
|
|
7
|
+
# default), "datadoghq.eu", "us3.datadoghq.com", "us5.datadoghq.com",
|
|
8
|
+
# "ap1.datadoghq.com", "ddog-gov.com". Every event is tagged cronwatch,
|
|
9
|
+
# job:<name> and alert:<type>, then `tags`.
|
|
10
|
+
class Datadog
|
|
11
|
+
ALERT_TYPE = {
|
|
12
|
+
missed: "error", failed: "error", stuck: "error", slow: "warning", over_budget: "warning", recovered: "success",
|
|
13
|
+
}.freeze
|
|
14
|
+
|
|
15
|
+
attr_reader :name
|
|
16
|
+
|
|
17
|
+
def initialize(api_key:, site: nil, tags: nil, host: nil, link: nil, http: HTTP.default)
|
|
18
|
+
@api_key = Provider.require_credential(api_key, "Cronwatch::Alerts::Datadog needs an api_key")
|
|
19
|
+
site = (site.nil? ? "datadoghq.com" : site.to_s).sub(%r{\Ahttps?://}, "").sub(/\A(api|app)\./, "").sub(%r{/+\z}, "")
|
|
20
|
+
raise ArgumentError, "Cronwatch::Alerts::Datadog needs a site like datadoghq.com" unless /\A[a-z0-9.-]+\z/i.match?(site)
|
|
21
|
+
|
|
22
|
+
@url = "https://api.#{site}/api/v1/events"
|
|
23
|
+
@tags = tags || []
|
|
24
|
+
@host = host
|
|
25
|
+
@link = link
|
|
26
|
+
@http = http
|
|
27
|
+
@name = "datadog"
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def call(alert)
|
|
31
|
+
link = Provider.link_for(@link, alert)
|
|
32
|
+
event = {
|
|
33
|
+
"title" => Provider.cut(alert.title, 500),
|
|
34
|
+
"text" => Provider.cut(Provider.plain_text(alert, link), 4000),
|
|
35
|
+
"alert_type" => ALERT_TYPE[alert.type.to_sym],
|
|
36
|
+
"aggregation_key" => Datadog.aggregation_key(alert),
|
|
37
|
+
"date_happened" => alert.at.div(1000),
|
|
38
|
+
"priority" => "normal",
|
|
39
|
+
"tags" => ["cronwatch", "job:#{alert.job}", "alert:#{alert.type}", *@tags],
|
|
40
|
+
}
|
|
41
|
+
event["host"] = @host if Provider.present?(@host)
|
|
42
|
+
headers = { "content-type" => "application/json", "accept" => "application/json", "dd-api-key" => @api_key }
|
|
43
|
+
Provider.post(@http, "Datadog", @url, headers, JS.json(event), [@api_key])
|
|
44
|
+
nil
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# "cronwatch:<job>:<type>", or a hash of it when that passes Datadog's 100 characters.
|
|
48
|
+
def self.aggregation_key(alert)
|
|
49
|
+
key = "cronwatch:#{alert.job}:#{alert.type}"
|
|
50
|
+
JS.length16(key) <= 100 ? key : "cronwatch:#{Provider.sha256_hex(key)[0, 40]}"
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Cronwatch
|
|
4
|
+
module Alerts
|
|
5
|
+
# What every email channel sends: one subject, a plain text body and a
|
|
6
|
+
# small HTML body, so an alert reads the same whichever provider carries
|
|
7
|
+
# it (the SDK's alerts/email.ts). Resend, Postmark, SendGrid, Mailgun and
|
|
8
|
+
# SES each take the same options:
|
|
9
|
+
#
|
|
10
|
+
# from: the sender, "alerts@example.com" or "CronWatch <alerts@example.com>"
|
|
11
|
+
# to: one address or several
|
|
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}" }
|
|
14
|
+
module Email
|
|
15
|
+
Message = Struct.new(:from, :to, :subject, :text, :html, keyword_init: true)
|
|
16
|
+
|
|
17
|
+
# Not a line terminator, as JavaScript's "." reads it.
|
|
18
|
+
LINE = "[^\\n\\r\\u2028\\u2029]"
|
|
19
|
+
ADDRESS = Regexp.new("\\A[#{JS::WHITESPACE}]*(#{LINE}*?)[#{JS::WHITESPACE}]*<([^<>]+)>[#{JS::WHITESPACE}]*\\z")
|
|
20
|
+
QUOTED = Regexp.new("\\A\"(#{LINE}*)\"\\z")
|
|
21
|
+
|
|
22
|
+
module_function
|
|
23
|
+
|
|
24
|
+
# Checks the shared options once, when the channel is made. Returns the recipients.
|
|
25
|
+
def recipients(name, from, to)
|
|
26
|
+
raise ArgumentError, "Cronwatch::Alerts::#{name} needs a from address" if from.nil? || from.to_s.empty?
|
|
27
|
+
|
|
28
|
+
list = (to.is_a?(Array) ? to : [to]).select { |a| a.is_a?(String) && JS.trim(a) != "" }
|
|
29
|
+
raise ArgumentError, "Cronwatch::Alerts::#{name} needs at least one to address" if list.empty?
|
|
30
|
+
|
|
31
|
+
list.map { |a| JS.trim(a) }
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def compose(alert, from:, to:, subject_prefix: nil, link: nil)
|
|
35
|
+
url = safe_link(link&.call(alert))
|
|
36
|
+
# One line: a newline in a subject is a header injection or a rejected send.
|
|
37
|
+
prefix = Provider.present?(subject_prefix) ? "#{subject_prefix} " : ""
|
|
38
|
+
subject = JS.head16("#{prefix}#{alert.title}".gsub(/[\r\n]+/, " "), 250)
|
|
39
|
+
Message.new(from: from.to_s, to: to, subject: subject, text: Provider.plain_text(alert, url), html: html(alert, url))
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Escapes text for HTML content and double quoted attributes.
|
|
43
|
+
def escape_html(text)
|
|
44
|
+
text.to_s.gsub("&", "&").gsub("<", "<").gsub(">", ">").gsub('"', """).gsub("'", "'")
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Only http and https links are put in a mail; anything else is dropped.
|
|
48
|
+
def safe_link(link)
|
|
49
|
+
return nil unless Provider.present?(link)
|
|
50
|
+
|
|
51
|
+
%r{\Ahttps?://}i.match?(link.to_s) ? link.to_s : nil
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def html(alert, link)
|
|
55
|
+
parts = [
|
|
56
|
+
"<!doctype html>",
|
|
57
|
+
'<html><body style="margin:0;padding:16px;font-family:Georgia,serif;color:#1d1b16;background:#ffffff">',
|
|
58
|
+
"<p style=\"margin:0 0 12px;font-size:18px\"><strong>#{escape_html(alert.title)}</strong></p>",
|
|
59
|
+
'<pre style="margin:0 0 12px;padding:12px;background:#f6f3ec;white-space:pre-wrap;word-break:break-word;' \
|
|
60
|
+
"font:13px/1.45 Menlo,Consolas,monospace\">#{escape_html(alert.message)}</pre>",
|
|
61
|
+
]
|
|
62
|
+
parts << "<p style=\"margin:0 0 12px\"><em>Triage:</em> #{escape_html(alert.triage)}</p>" if Provider.present?(alert.triage)
|
|
63
|
+
parts << "<p style=\"margin:0\"><a href=\"#{escape_html(link)}\">Open #{escape_html(alert.job)}</a></p>" if link
|
|
64
|
+
parts << "</body></html>"
|
|
65
|
+
parts.join("\n")
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Splits "Name <a@b.c>" into its parts, as JSON-ready hashes; a bare address has no name.
|
|
69
|
+
def parse_address(address)
|
|
70
|
+
match = ADDRESS.match(address)
|
|
71
|
+
return { "email" => JS.trim(address) } unless match
|
|
72
|
+
|
|
73
|
+
name = match[1].sub(QUOTED, '\1')
|
|
74
|
+
name.empty? ? { "email" => JS.trim(match[2]) } : { "email" => JS.trim(match[2]), "name" => name }
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Cronwatch
|
|
4
|
+
module Alerts
|
|
5
|
+
# Reports alerts to Honeybadger as error notices, one error per job and
|
|
6
|
+
# alert type (https://docs.honeybadger.io/api/reporting-exceptions/). Not
|
|
7
|
+
# Check-ins, a separate product. Recoveries are not sent unless
|
|
8
|
+
# `recovered: true`: Honeybadger has no levels, so one would read as an error.
|
|
9
|
+
class Honeybadger
|
|
10
|
+
CLASS = {
|
|
11
|
+
missed: "CronWatch::Missed", failed: "CronWatch::Failed", stuck: "CronWatch::Stuck", slow: "CronWatch::Slow",
|
|
12
|
+
over_budget: "CronWatch::OverBudget", recovered: "CronWatch::Recovered",
|
|
13
|
+
}.freeze
|
|
14
|
+
|
|
15
|
+
attr_reader :name
|
|
16
|
+
|
|
17
|
+
# endpoint: another API host, "https://eu-api.honeybadger.io" say.
|
|
18
|
+
def initialize(api_key:, environment: nil, endpoint: nil, recovered: false, link: nil, http: HTTP.default)
|
|
19
|
+
@api_key = Provider.require_credential(api_key, "Cronwatch::Alerts::Honeybadger needs an api_key")
|
|
20
|
+
@url = "#{(endpoint.nil? ? "https://api.honeybadger.io" : endpoint.to_s).sub(%r{/+\z}, "")}/v1/notices"
|
|
21
|
+
@environment = environment
|
|
22
|
+
@recovered = recovered
|
|
23
|
+
@link = link
|
|
24
|
+
@http = http
|
|
25
|
+
@name = "honeybadger"
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def call(alert)
|
|
29
|
+
return if alert.type == :recovered && !@recovered
|
|
30
|
+
|
|
31
|
+
link = Provider.link_for(@link, alert)
|
|
32
|
+
type = alert.type.to_s
|
|
33
|
+
request = { "component" => "cronwatch", "action" => alert.job }
|
|
34
|
+
request["url"] = link if link
|
|
35
|
+
context = { "job" => alert.job, "type" => type }
|
|
36
|
+
context["triage"] = alert.triage if Provider.present?(alert.triage)
|
|
37
|
+
context["details"] = Naming.to_json_value(alert.details)
|
|
38
|
+
context["run"] = Provider.run_summary(alert)
|
|
39
|
+
request["context"] = context
|
|
40
|
+
notice = {
|
|
41
|
+
"notifier" => { "name" => "cronwatch", "url" => "https://cronwatch.dev" },
|
|
42
|
+
"error" => {
|
|
43
|
+
"class" => CLASS[alert.type.to_sym],
|
|
44
|
+
"message" => Provider.cut("#{alert.title}\n#{alert.message}", 8000),
|
|
45
|
+
# No code ran here; one frame naming the job keeps the notice well formed.
|
|
46
|
+
"backtrace" => [{ "number" => "0", "file" => "cronwatch/#{alert.job}", "method" => type }],
|
|
47
|
+
"fingerprint" => "cronwatch:#{alert.job}:#{type}",
|
|
48
|
+
"tags" => ["cronwatch", type],
|
|
49
|
+
},
|
|
50
|
+
"request" => request,
|
|
51
|
+
"server" => { "environment_name" => @environment.nil? ? "production" : @environment },
|
|
52
|
+
}
|
|
53
|
+
headers = { "content-type" => "application/json", "accept" => "application/json", "x-api-key" => @api_key }
|
|
54
|
+
Provider.post(@http, "Honeybadger", @url, headers, JS.json(notice), [@api_key])
|
|
55
|
+
nil
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Cronwatch
|
|
4
|
+
module Alerts
|
|
5
|
+
# Sends alerts as email through Mailgun
|
|
6
|
+
# (https://documentation.mailgun.com/docs/mailgun/api-reference/send/mailgun/messages),
|
|
7
|
+
# form encoded with basic auth. `domain` is the sending domain,
|
|
8
|
+
# "mg.example.com"; `region: "eu"` for a domain in the EU region.
|
|
9
|
+
class Mailgun
|
|
10
|
+
attr_reader :name
|
|
11
|
+
|
|
12
|
+
def initialize(api_key:, domain:, from:, to:, region: nil, subject_prefix: nil, link: nil, http: HTTP.default)
|
|
13
|
+
@api_key = Provider.require_credential(api_key, "Cronwatch::Alerts::Mailgun needs an api_key")
|
|
14
|
+
domain = Provider.require_option(domain, "Cronwatch::Alerts::Mailgun needs a domain")
|
|
15
|
+
@to = Email.recipients("Mailgun", from, to)
|
|
16
|
+
@from = from
|
|
17
|
+
host = region.to_s == "eu" ? "https://api.eu.mailgun.net" : "https://api.mailgun.net"
|
|
18
|
+
@url = "#{host}/v3/#{Provider.encode_uri_component(domain)}/messages"
|
|
19
|
+
@subject_prefix = subject_prefix
|
|
20
|
+
@link = link
|
|
21
|
+
@http = http
|
|
22
|
+
@name = "mailgun"
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def call(alert)
|
|
26
|
+
email = Email.compose(alert, from: @from, to: @to, subject_prefix: @subject_prefix, link: @link)
|
|
27
|
+
pairs = [["from", email.from]]
|
|
28
|
+
email.to.each { |address| pairs << ["to", address] }
|
|
29
|
+
pairs.push(["subject", email.subject], ["text", email.text], ["html", email.html], ["o:tag", "cronwatch"])
|
|
30
|
+
headers = { "content-type" => "application/x-www-form-urlencoded", "authorization" => Provider.basic_auth("api", @api_key) }
|
|
31
|
+
Provider.post(@http, "Mailgun", @url, headers, Provider.form(pairs), [@api_key])
|
|
32
|
+
nil
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Cronwatch
|
|
4
|
+
module Alerts
|
|
5
|
+
# Records alerts as New Relic custom events through the Event API
|
|
6
|
+
# (https://docs.newrelic.com/docs/data-apis/ingest-apis/event-api/introduction-event-api/).
|
|
7
|
+
# Each alert is one event of type CronWatchAlert (or `event_type`),
|
|
8
|
+
# queryable with NRQL: SELECT * FROM CronWatchAlert WHERE job = 'nightly'.
|
|
9
|
+
# `api_key` is a license key; `region: "eu"` for an EU account.
|
|
10
|
+
class NewRelic
|
|
11
|
+
attr_reader :name
|
|
12
|
+
|
|
13
|
+
def initialize(account_id:, api_key:, region: nil, event_type: nil, link: nil, http: HTTP.default)
|
|
14
|
+
@api_key = Provider.require_credential(api_key, "Cronwatch::Alerts::NewRelic needs an api_key")
|
|
15
|
+
account = account_id.nil? ? "" : account_id.to_s
|
|
16
|
+
raise ArgumentError, "Cronwatch::Alerts::NewRelic needs a numeric account_id" unless /\A\d+\z/.match?(account)
|
|
17
|
+
|
|
18
|
+
host = region.to_s == "eu" ? "https://insights-collector.eu01.nr-data.net" : "https://insights-collector.newrelic.com"
|
|
19
|
+
@url = "#{host}/v1/accounts/#{account}/events"
|
|
20
|
+
@event_type = event_type.nil? ? "CronWatchAlert" : event_type.to_s
|
|
21
|
+
@link = link
|
|
22
|
+
@http = http
|
|
23
|
+
@name = "newrelic"
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def call(alert)
|
|
27
|
+
link = Provider.link_for(@link, alert)
|
|
28
|
+
run = alert.run
|
|
29
|
+
# Flat attributes only, strings under 4096 characters.
|
|
30
|
+
event = {
|
|
31
|
+
"eventType" => @event_type,
|
|
32
|
+
"timestamp" => alert.at,
|
|
33
|
+
"job" => Provider.cut(alert.job, 4095),
|
|
34
|
+
"alertType" => alert.type.to_s,
|
|
35
|
+
"severity" => Provider.severity(alert.type),
|
|
36
|
+
"title" => Provider.cut(alert.title, 4095),
|
|
37
|
+
"message" => Provider.cut(alert.message, 4095),
|
|
38
|
+
}
|
|
39
|
+
event["triage"] = Provider.cut(alert.triage, 4095) if Provider.present?(alert.triage)
|
|
40
|
+
event["link"] = Provider.cut(link, 4095) if link
|
|
41
|
+
if run
|
|
42
|
+
event["runId"] = run.id
|
|
43
|
+
event["runStatus"] = run.status.to_s
|
|
44
|
+
event["durationMs"] = run.duration_ms unless run.duration_ms.nil?
|
|
45
|
+
end
|
|
46
|
+
headers = { "content-type" => "application/json", "api-key" => @api_key }
|
|
47
|
+
Provider.post(@http, "New Relic", @url, headers, JS.json([event]), [@api_key])
|
|
48
|
+
nil
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Cronwatch
|
|
4
|
+
module Alerts
|
|
5
|
+
# Sends alerts as email through Postmark
|
|
6
|
+
# (https://postmarkapp.com/developer/api/email-api). `message_stream`
|
|
7
|
+
# defaults to "outbound", the transactional stream.
|
|
8
|
+
class Postmark
|
|
9
|
+
ENDPOINT = "https://api.postmarkapp.com/email"
|
|
10
|
+
|
|
11
|
+
attr_reader :name
|
|
12
|
+
|
|
13
|
+
def initialize(server_token:, from:, to:, message_stream: nil, subject_prefix: nil, link: nil, http: HTTP.default)
|
|
14
|
+
@server_token = Provider.require_credential(server_token, "Cronwatch::Alerts::Postmark needs a server_token")
|
|
15
|
+
@to = Email.recipients("Postmark", from, to)
|
|
16
|
+
@from = from
|
|
17
|
+
@message_stream = message_stream
|
|
18
|
+
@subject_prefix = subject_prefix
|
|
19
|
+
@link = link
|
|
20
|
+
@http = http
|
|
21
|
+
@name = "postmark"
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def call(alert)
|
|
25
|
+
email = Email.compose(alert, from: @from, to: @to, subject_prefix: @subject_prefix, link: @link)
|
|
26
|
+
headers = { "content-type" => "application/json", "accept" => "application/json", "x-postmark-server-token" => @server_token }
|
|
27
|
+
body = JS.json({
|
|
28
|
+
"From" => email.from,
|
|
29
|
+
"To" => email.to.join(", "),
|
|
30
|
+
"Subject" => email.subject,
|
|
31
|
+
"TextBody" => email.text,
|
|
32
|
+
"HtmlBody" => email.html,
|
|
33
|
+
"MessageStream" => @message_stream.nil? ? "outbound" : @message_stream,
|
|
34
|
+
"Tag" => "cronwatch",
|
|
35
|
+
})
|
|
36
|
+
Provider.post(@http, "Postmark", ENDPOINT, headers, body, [@server_token])
|
|
37
|
+
nil
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "digest"
|
|
4
|
+
require "openssl"
|
|
5
|
+
require "uri"
|
|
6
|
+
|
|
7
|
+
module Cronwatch
|
|
8
|
+
module Alerts
|
|
9
|
+
# What the provider channels (email, SMS, error trackers) share, as the
|
|
10
|
+
# SDK's alerts/shared.ts: the POST that names the provider and the URL's
|
|
11
|
+
# origin on failure with every secret cut out, the stable alert id, and
|
|
12
|
+
# the run summary trackers attach. Standard library only.
|
|
13
|
+
module Provider
|
|
14
|
+
module_function
|
|
15
|
+
|
|
16
|
+
# Severity for trackers that have levels. Recovered is informational.
|
|
17
|
+
def severity(type)
|
|
18
|
+
case type.to_sym
|
|
19
|
+
when :recovered then "info"
|
|
20
|
+
when :slow, :over_budget then "warning"
|
|
21
|
+
else "error"
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# The scheme, host and port only. A URL's path or query can hold a credential.
|
|
26
|
+
def origin(url)
|
|
27
|
+
Webhook.origin(url)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# How much of a provider's error body goes into the error message.
|
|
31
|
+
ERROR_BODY_MAX = 200
|
|
32
|
+
|
|
33
|
+
# POSTs and raises on a non-2xx answer. The error names the provider and
|
|
34
|
+
# the URL's origin, plus the start of the response body with every
|
|
35
|
+
# secret the channel holds cut out, in case a provider echoes one back
|
|
36
|
+
# (see error_body). A redirect is not followed (Net::HTTP never follows
|
|
37
|
+
# one): its 3xx is an error like any other answer outside 2xx, so the
|
|
38
|
+
# credential headers never go where it points.
|
|
39
|
+
def post(http, provider, url, headers, body, secrets = [])
|
|
40
|
+
response = http.post(url, body, headers)
|
|
41
|
+
return response if response.ok?
|
|
42
|
+
|
|
43
|
+
# response.text() drops a leading byte order mark.
|
|
44
|
+
text = Output.utf8(response.body.to_s).delete_prefix("\u{FEFF}")
|
|
45
|
+
raise "#{provider} #{origin(url)} answered #{response.status}#{text.empty? ? "" : ": #{error_body(text, secrets)}"}"
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# The start of an error body: secrets are cut out of a prefix long
|
|
49
|
+
# enough to hold one that starts inside the first ERROR_BODY_MAX
|
|
50
|
+
# characters, and only then is it cut to that length, on a code point,
|
|
51
|
+
# so no part of a secret survives at the edge.
|
|
52
|
+
def error_body(text, secrets = [])
|
|
53
|
+
kept = secrets.select { |s| s.is_a?(String) && JS.length16(s) >= 4 }
|
|
54
|
+
longest = kept.map { |s| JS.length16(s) }.max || 0
|
|
55
|
+
head = JS.head16(text, ERROR_BODY_MAX + longest)
|
|
56
|
+
kept.each { |secret| head = head.split(secret, -1).join("[redacted]") }
|
|
57
|
+
JS.head16(head, ERROR_BODY_MAX)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# A credential with the spaces and newlines a paste leaves around it
|
|
61
|
+
# taken off, as it goes in a header. Anything not a String is "".
|
|
62
|
+
def trimmed(value)
|
|
63
|
+
value.is_a?(String) ? JS.trim(value) : ""
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Base64 of UTF-8, on one line.
|
|
67
|
+
def base64(text)
|
|
68
|
+
[text.to_s.b].pack("m0")
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def basic_auth(user, password)
|
|
72
|
+
"Basic #{base64("#{user}:#{password}")}"
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def sha256_hex(text)
|
|
76
|
+
Digest::SHA256.hexdigest(text.to_s)
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# A stable 32 hex character id for one alert: the same job, type and
|
|
80
|
+
# time always give the same id, so a provider that deduplicates on it
|
|
81
|
+
# drops a resend of an alert it already took.
|
|
82
|
+
def alert_id(alert)
|
|
83
|
+
sha256_hex("#{alert.job}\n#{alert.type}\n#{JS.number(alert.at)}")[0, 32]
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# The same id laid out as a UUID, for APIs that ask for one.
|
|
87
|
+
def as_uuid(id)
|
|
88
|
+
"#{id[0, 8]}-#{id[8, 4]}-#{id[12, 4]}-#{id[16, 4]}-#{id[20, 12]}"
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# At most `max` UTF-16 units, without splitting a surrogate pair.
|
|
92
|
+
def cut(text, max)
|
|
93
|
+
JS.head16(text, max)
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# The run fields worth attaching to a tracker event.
|
|
97
|
+
def run_summary(alert)
|
|
98
|
+
run = alert.run
|
|
99
|
+
return nil unless run
|
|
100
|
+
|
|
101
|
+
{
|
|
102
|
+
"id" => run.id, "status" => run.status.to_s, "startedAt" => JS.iso(run.started_at),
|
|
103
|
+
"durationMs" => run.duration_ms, "trigger" => run.trigger,
|
|
104
|
+
}
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# Title, message, triage and link as one plain text block, the way every channel reads.
|
|
108
|
+
def plain_text(alert, link)
|
|
109
|
+
lines = [alert.title, "", alert.message]
|
|
110
|
+
lines.push("", "Triage: #{alert.triage}") if present?(alert.triage)
|
|
111
|
+
lines.push("", "Open: #{link}") if present?(link)
|
|
112
|
+
lines.join("\n")
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# JavaScript's truthiness for an optional string: nil and "" are absent.
|
|
116
|
+
def present?(text)
|
|
117
|
+
!text.nil? && text != ""
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# The link option's answer for this alert, or nil.
|
|
121
|
+
def link_for(link, alert)
|
|
122
|
+
value = link&.call(alert)
|
|
123
|
+
present?(value) ? value.to_s : nil
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
# encodeURIComponent.
|
|
127
|
+
def encode_uri_component(text)
|
|
128
|
+
text.to_s.b.gsub(/[^A-Za-z0-9\-_.!~*'()]/n) { |c| format("%%%02X", c.ord) }.force_encoding(Encoding::UTF_8)
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
# URLSearchParams#toString for these pairs: application/x-www-form-urlencoded.
|
|
132
|
+
def form(pairs)
|
|
133
|
+
pairs.map { |k, v| "#{URI.encode_www_form_component(k)}=#{URI.encode_www_form_component(v.to_s)}" }.join("&")
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
# A required option, as a string: raises when it is missing or empty.
|
|
137
|
+
def require_option(value, message)
|
|
138
|
+
raise ArgumentError, message if value.nil? || value.to_s.empty?
|
|
139
|
+
|
|
140
|
+
value.to_s
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
# A required credential, trimmed (see trimmed): raises when it is missing,
|
|
144
|
+
# not a String, or only whitespace. A pasted credential often carries a
|
|
145
|
+
# stray space or newline, which a header would refuse or send.
|
|
146
|
+
def require_credential(value, message)
|
|
147
|
+
credential = trimmed(value)
|
|
148
|
+
raise ArgumentError, message if credential.empty?
|
|
149
|
+
|
|
150
|
+
credential
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
# The recovered option. `default` is what the SDK does when it is not given.
|
|
154
|
+
def recovered?(option, default)
|
|
155
|
+
option.nil? ? default : option != false
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
end
|