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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: cc6a450f111f1a0f65f0e92656784a215a37af11da63a723d76074f59fe21719
4
- data.tar.gz: 7fadec6fdfa02a2b19bc39f32ee3a0e2942ed8077cc1cff4d8058ca0c9a1519f
3
+ metadata.gz: e0e24280879ad52eeeaf138342e4266225aa702893875d07c1ae9e3f7728b1b5
4
+ data.tar.gz: e828a6fb7fd737df7100480129d986232f59e2adef2f41eed50a42ef7e71913a
5
5
  SHA512:
6
- metadata.gz: 5c02045a364c8d77dfaa6a7861d1e0c0ed9d9f33726f40b8dbc058f5ec0057f3eac073bce211f398f27751ecfff37b67023c8e5d1e07f04f5f3aee039a60fbd6
7
- data.tar.gz: 8988406bf83d5685d3764f73b9a1af90999a8e1cfe2e967b0628705ba0c3d1b87cbf038733ad36279c1e3b65f1a98ba572ce69a9159be46bf28386584fc014e0
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 console channels | |
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
- 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`, `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
+ 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
- def call(alert)
21
- @send.call(alert)
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("&", "&amp;").gsub("<", "&lt;").gsub(">", "&gt;").gsub('"', "&quot;").gsub("'", "&#39;")
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