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.
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Cronwatch
4
+ module Alerts
5
+ # Sends alerts as email through Resend
6
+ # (https://resend.com/docs/api-reference/emails/send-email).
7
+ #
8
+ # Cronwatch::Alerts::Resend.new(api_key: ENV.fetch("RESEND_API_KEY"),
9
+ # from: "CronWatch <alerts@example.com>", to: "ops@example.com")
10
+ class Resend
11
+ ENDPOINT = "https://api.resend.com/emails"
12
+
13
+ attr_reader :name
14
+
15
+ def initialize(api_key:, from:, to:, subject_prefix: nil, link: nil, http: HTTP.default)
16
+ @api_key = Provider.require_credential(api_key, "Cronwatch::Alerts::Resend needs an api_key")
17
+ @to = Email.recipients("Resend", from, to)
18
+ @from = from
19
+ @subject_prefix = subject_prefix
20
+ @link = link
21
+ @http = http
22
+ @name = "resend"
23
+ end
24
+
25
+ def call(alert)
26
+ email = Email.compose(alert, from: @from, to: @to, subject_prefix: @subject_prefix, link: @link)
27
+ headers = {
28
+ "content-type" => "application/json",
29
+ "authorization" => "Bearer #{@api_key}",
30
+ # The same alert sent twice within 24 hours is delivered once.
31
+ "idempotency-key" => "cronwatch-#{Provider.alert_id(alert)}",
32
+ }
33
+ body = JS.json({ "from" => email.from, "to" => email.to, "subject" => email.subject, "text" => email.text, "html" => email.html })
34
+ Provider.post(@http, "Resend", ENDPOINT, headers, body, [@api_key])
35
+ nil
36
+ end
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Cronwatch
4
+ module Alerts
5
+ # Reports alerts to Rollbar, one item per job and alert type
6
+ # (https://docs.rollbar.com/reference/create-item). `access_token` needs
7
+ # the post_server_item scope. Recoveries go too, as info items, unless
8
+ # `recovered: false`.
9
+ class Rollbar
10
+ ENDPOINT = "https://api.rollbar.com/api/1/item/"
11
+
12
+ attr_reader :name
13
+
14
+ def initialize(access_token:, environment: nil, recovered: true, link: nil, http: HTTP.default)
15
+ @access_token = Provider.require_credential(access_token, "Cronwatch::Alerts::Rollbar needs an access_token")
16
+ @environment = environment
17
+ @recovered = recovered
18
+ @link = link
19
+ @http = http
20
+ @name = "rollbar"
21
+ end
22
+
23
+ def call(alert)
24
+ return if alert.type == :recovered && @recovered == false
25
+
26
+ link = Provider.link_for(@link, alert)
27
+ type = alert.type.to_s
28
+ custom = { "job" => alert.job, "type" => type }
29
+ custom["triage"] = alert.triage if Provider.present?(alert.triage)
30
+ custom["link"] = link if link
31
+ custom["details"] = Naming.to_json_value(alert.details)
32
+ custom["run"] = Provider.run_summary(alert)
33
+ item = {
34
+ "data" => {
35
+ "environment" => Provider.cut(@environment.nil? ? "production" : @environment.to_s, 255),
36
+ "level" => Provider.severity(alert.type),
37
+ "timestamp" => alert.at.div(1000),
38
+ "title" => Provider.cut(alert.title, 255),
39
+ # Rollbar hashes a fingerprint longer than 40 characters itself.
40
+ "fingerprint" => "cronwatch:#{alert.job}:#{type}",
41
+ "uuid" => Provider.as_uuid(Provider.alert_id(alert)),
42
+ "body" => { "message" => { "body" => alert.message } },
43
+ "custom" => custom,
44
+ "notifier" => { "name" => "cronwatch" },
45
+ },
46
+ }
47
+ headers = { "content-type" => "application/json", "x-rollbar-access-token" => @access_token }
48
+ Provider.post(@http, "Rollbar", ENDPOINT, headers, JS.json(item), [@access_token])
49
+ nil
50
+ end
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Cronwatch
4
+ module Alerts
5
+ # Sends alerts as email through SendGrid
6
+ # (https://www.twilio.com/docs/sendgrid/api-reference/mail-send/mail-send).
7
+ # `region: "eu"` for an EU regional subuser.
8
+ class Sendgrid
9
+ attr_reader :name
10
+
11
+ def initialize(api_key:, from:, to:, region: nil, subject_prefix: nil, link: nil, http: HTTP.default)
12
+ @api_key = Provider.require_credential(api_key, "Cronwatch::Alerts::Sendgrid needs an api_key")
13
+ @to = Email.recipients("Sendgrid", from, to)
14
+ @from = from
15
+ @url = region.to_s == "eu" ? "https://api.eu.sendgrid.com/v3/mail/send" : "https://api.sendgrid.com/v3/mail/send"
16
+ @subject_prefix = subject_prefix
17
+ @link = link
18
+ @http = http
19
+ @name = "sendgrid"
20
+ end
21
+
22
+ def call(alert)
23
+ email = Email.compose(alert, from: @from, to: @to, subject_prefix: @subject_prefix, link: @link)
24
+ body = JS.json({
25
+ "personalizations" => [{ "to" => email.to.map { |a| Email.parse_address(a) } }],
26
+ "from" => Email.parse_address(email.from),
27
+ "subject" => email.subject,
28
+ # text/plain must come before text/html.
29
+ "content" => [
30
+ { "type" => "text/plain", "value" => email.text },
31
+ { "type" => "text/html", "value" => email.html },
32
+ ],
33
+ "categories" => ["cronwatch"],
34
+ })
35
+ headers = { "content-type" => "application/json", "authorization" => "Bearer #{@api_key}" }
36
+ Provider.post(@http, "SendGrid", @url, headers, body, [@api_key])
37
+ nil
38
+ end
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,94 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "uri"
4
+
5
+ module Cronwatch
6
+ module Alerts
7
+ # Sends alerts to Sentry as events through the envelope endpoint, one
8
+ # issue per job and alert type. Recoveries go too, as info events, unless
9
+ # `recovered: false`.
10
+ #
11
+ # Cronwatch::Alerts::Sentry.new(dsn: ENV.fetch("SENTRY_DSN"), environment: "production")
12
+ #
13
+ # Envelopes: https://develop.sentry.dev/sdk/data-model/envelopes/
14
+ # Event payload: https://develop.sentry.dev/sdk/data-model/event-payloads/
15
+ class Sentry
16
+ Dsn = Struct.new(:endpoint, :public_key, keyword_init: true)
17
+
18
+ attr_reader :name
19
+
20
+ def initialize(dsn:, environment: nil, release: nil, recovered: true, link: nil, http: HTTP.default)
21
+ dsn = Provider.require_credential(dsn, "Cronwatch::Alerts::Sentry needs a dsn")
22
+ parsed = Sentry.parse_dsn(dsn)
23
+ @endpoint = parsed.endpoint
24
+ @public_key = parsed.public_key
25
+ @environment = environment
26
+ @release = release
27
+ @recovered = recovered
28
+ @link = link
29
+ @http = http
30
+ @name = "sentry"
31
+ end
32
+
33
+ # "https://<key>@<host>/<project>" as the envelope endpoint and the public key.
34
+ def self.parse_dsn(dsn)
35
+ uri = begin
36
+ URI.parse(dsn.to_s)
37
+ rescue URI::Error
38
+ nil
39
+ end
40
+ raise ArgumentError, "Cronwatch::Alerts::Sentry needs a valid dsn" unless uri&.scheme && uri.host
41
+
42
+ segments = uri.path.to_s.split("/").reject(&:empty?)
43
+ project = segments.pop
44
+ user = uri.user
45
+ if user.nil? || user.empty? || project.nil? || !/\A\d+\z/.match?(project)
46
+ raise ArgumentError, "Cronwatch::Alerts::Sentry needs a dsn like https://<key>@<host>/<project>"
47
+ end
48
+
49
+ prefix = segments.empty? ? "" : "/#{segments.join("/")}"
50
+ Dsn.new(endpoint: "#{uri.scheme.downcase}://#{SigV4.host(uri).downcase}#{prefix}/api/#{project}/envelope/",
51
+ public_key: URI.decode_uri_component(user))
52
+ end
53
+
54
+ def call(alert)
55
+ return if alert.type == :recovered && @recovered == false
56
+
57
+ event_id = Provider.alert_id(alert)
58
+ link = Provider.link_for(@link, alert)
59
+ event = {
60
+ "event_id" => event_id,
61
+ "timestamp" => alert.at / 1000.0,
62
+ "platform" => "other",
63
+ "level" => Provider.severity(alert.type),
64
+ "logger" => "cronwatch",
65
+ "transaction" => alert.job,
66
+ "environment" => @environment.nil? ? "production" : @environment,
67
+ }
68
+ event["release"] = @release if Provider.present?(@release)
69
+ # The first line is the issue title.
70
+ event["logentry"] = { "formatted" => Provider.cut("#{alert.title}\n\n#{alert.message}", 8192) }
71
+ event["fingerprint"] = ["cronwatch", alert.job, alert.type.to_s]
72
+ event["tags"] = { "job" => Provider.cut(alert.job, 199), "type" => alert.type.to_s }
73
+ extra = {}
74
+ extra["triage"] = alert.triage if Provider.present?(alert.triage)
75
+ extra["link"] = link if link
76
+ extra["details"] = Naming.to_json_value(alert.details)
77
+ extra["run"] = Provider.run_summary(alert)
78
+ event["extra"] = extra
79
+ payload = JS.json(event)
80
+ envelope = [
81
+ JS.json({ "event_id" => event_id }),
82
+ JS.json({ "type" => "event", "content_type" => "application/json", "length" => payload.bytesize }),
83
+ payload,
84
+ ].join("\n") + "\n"
85
+ headers = {
86
+ "content-type" => "application/x-sentry-envelope",
87
+ "x-sentry-auth" => "Sentry sentry_version=7, sentry_key=#{@public_key}, sentry_client=cronwatch",
88
+ }
89
+ Provider.post(@http, "Sentry", @endpoint, headers, envelope, [@public_key])
90
+ nil
91
+ end
92
+ end
93
+ end
94
+ end
@@ -0,0 +1,72 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Cronwatch
4
+ module Alerts
5
+ # Sends alerts as email through Amazon SES, API v2 SendEmail
6
+ # (https://docs.aws.amazon.com/ses/latest/APIReference-V2/API_SendEmail.html),
7
+ # signed with AWS Signature Version 4 (see SigV4), so no AWS SDK is
8
+ # needed. The from identity must be verified in `region`.
9
+ #
10
+ # Cronwatch::Alerts::Ses.new(region: "us-east-1", access_key_id: ENV.fetch("AWS_ACCESS_KEY_ID"),
11
+ # secret_access_key: ENV.fetch("AWS_SECRET_ACCESS_KEY"),
12
+ # from: "alerts@example.com", to: "ops@example.com")
13
+ class Ses
14
+ attr_reader :name
15
+
16
+ # now: the clock used to sign requests, a callable returning epoch milliseconds. For tests.
17
+ def initialize(region:, access_key_id:, secret_access_key:, from:, to:, session_token: nil, configuration_set_name: nil,
18
+ subject_prefix: nil, link: nil, now: nil, http: HTTP.default)
19
+ region = Provider.require_option(region, "Cronwatch::Alerts::Ses needs a region")
20
+ raise ArgumentError, "Cronwatch::Alerts::Ses needs a region like us-east-1" unless /\A[a-z0-9-]+\z/.match?(region)
21
+ # A pasted credential often carries a stray space or newline, which would spoil the signature.
22
+ access_key_id = Provider.trimmed(access_key_id)
23
+ secret_access_key = Provider.trimmed(secret_access_key)
24
+ if access_key_id.empty? || secret_access_key.empty?
25
+ raise ArgumentError, "Cronwatch::Alerts::Ses needs an access_key_id and secret_access_key"
26
+ end
27
+
28
+ @to = Email.recipients("Ses", from, to)
29
+ @region = region
30
+ @access_key_id = access_key_id
31
+ @secret_access_key = secret_access_key
32
+ session_token = Provider.trimmed(session_token)
33
+ @session_token = session_token.empty? ? nil : session_token
34
+ @configuration_set_name = configuration_set_name
35
+ @from = from
36
+ @subject_prefix = subject_prefix
37
+ @link = link
38
+ @now = now || -> { Process.clock_gettime(Process::CLOCK_REALTIME, :millisecond) }
39
+ @url = "https://email.#{region}.amazonaws.com/v2/email/outbound-emails"
40
+ @http = http
41
+ @name = "ses"
42
+ end
43
+
44
+ def call(alert)
45
+ email = Email.compose(alert, from: @from, to: @to, subject_prefix: @subject_prefix, link: @link)
46
+ payload = {
47
+ "FromEmailAddress" => email.from,
48
+ "Destination" => { "ToAddresses" => email.to },
49
+ "Content" => {
50
+ "Simple" => {
51
+ "Subject" => { "Data" => email.subject, "Charset" => "UTF-8" },
52
+ "Body" => {
53
+ "Text" => { "Data" => email.text, "Charset" => "UTF-8" },
54
+ "Html" => { "Data" => email.html, "Charset" => "UTF-8" },
55
+ },
56
+ },
57
+ },
58
+ }
59
+ payload["ConfigurationSetName"] = @configuration_set_name if Provider.present?(@configuration_set_name)
60
+ payload["EmailTags"] = [{ "Name" => "source", "Value" => "cronwatch" }]
61
+ body = JS.json(payload)
62
+ headers = SigV4.sign(
63
+ method: "POST", url: @url, headers: { "content-type" => "application/json" }, body: body, region: @region,
64
+ service: "ses", now: @now.call, access_key_id: @access_key_id, secret_access_key: @secret_access_key,
65
+ session_token: @session_token
66
+ )
67
+ Provider.post(@http, "SES", @url, headers, body, [@secret_access_key, @session_token])
68
+ nil
69
+ end
70
+ end
71
+ end
72
+ end
@@ -0,0 +1,84 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "openssl"
4
+ require "uri"
5
+
6
+ module Cronwatch
7
+ module Alerts
8
+ # AWS Signature Version 4 with OpenSSL, for the SES channel, as the SDK's
9
+ # alerts/sigv4.ts. Spec:
10
+ # https://docs.aws.amazon.com/IAM/latest/UserGuide/reference_sigv-create-signed-request.html
11
+ # Checked against the AWS SigV4 test suite (test/provider_channels_test.rb).
12
+ module SigV4
13
+ SPACES = Regexp.new("[#{JS::WHITESPACE}]+")
14
+
15
+ module_function
16
+
17
+ # Returns the headers to send: the given ones (lowercased) plus
18
+ # x-amz-date, the session token when there is one, and authorization.
19
+ # Host is signed but not returned; Net::HTTP sets it.
20
+ def sign(method:, url:, headers:, body:, region:, service:, now:, access_key_id:, secret_access_key:, session_token: nil)
21
+ uri = URI(url)
22
+ amz_date = JS.iso(now).delete("-:").sub(/\.\d{3}/, "")
23
+ day = amz_date[0, 8]
24
+ out = {}
25
+ headers.each { |name, value| out[name.to_s.downcase] = value.to_s }
26
+ out["x-amz-date"] = amz_date
27
+ out["x-amz-security-token"] = session_token if Provider.present?(session_token)
28
+
29
+ signed = out.merge("host" => host(uri))
30
+ names = signed.keys.sort
31
+ canonical_headers = names.map { |n| "#{n}:#{JS.trim(signed[n]).gsub(SPACES, " ")}\n" }.join
32
+ signed_headers = names.join(";")
33
+ canonical_request = [
34
+ method.to_s.upcase,
35
+ canonical_uri(uri.path),
36
+ canonical_query(uri.query),
37
+ canonical_headers,
38
+ signed_headers,
39
+ Provider.sha256_hex(body),
40
+ ].join("\n")
41
+ scope = "#{day}/#{region}/#{service}/aws4_request"
42
+ string_to_sign = ["AWS4-HMAC-SHA256", amz_date, scope, Provider.sha256_hex(canonical_request)].join("\n")
43
+
44
+ key = hmac("AWS4#{secret_access_key}", day)
45
+ key = hmac(key, region)
46
+ key = hmac(key, service)
47
+ key = hmac(key, "aws4_request")
48
+ signature = OpenSSL::HMAC.hexdigest("SHA256", key, string_to_sign)
49
+
50
+ out["authorization"] = "AWS4-HMAC-SHA256 Credential=#{access_key_id}/#{scope}, SignedHeaders=#{signed_headers}, Signature=#{signature}"
51
+ out
52
+ end
53
+
54
+ # URL#host: the host, and the port when it is not the scheme's default.
55
+ def host(uri)
56
+ uri.port && uri.port != uri.default_port ? "#{uri.host}:#{uri.port}" : uri.host
57
+ end
58
+
59
+ def hmac(key, data)
60
+ OpenSSL::HMAC.digest("SHA256", key, data)
61
+ end
62
+
63
+ # RFC 3986 encoding of every byte but the unreserved characters.
64
+ def uri_encode(text)
65
+ text.to_s.b.gsub(/[^A-Za-z0-9\-_.~]/n) { |c| format("%%%02X", c.ord) }.force_encoding(Encoding::UTF_8)
66
+ end
67
+
68
+ def canonical_uri(path)
69
+ return "/" if path.nil? || path.empty?
70
+
71
+ # The path is already encoded once; every AWS service but S3 expects each segment encoded again.
72
+ path.split("/", -1).map { |segment| uri_encode(segment) }.join("/")
73
+ end
74
+
75
+ # The query as URLSearchParams reads it, each name and value encoded, sorted.
76
+ def canonical_query(query)
77
+ return "" if query.nil? || query.empty?
78
+
79
+ pairs = URI.decode_www_form(query).map { |name, value| [uri_encode(name), uri_encode(value)] }
80
+ pairs.sort.map { |name, value| "#{name}=#{value}" }.join("&")
81
+ end
82
+ end
83
+ end
84
+ end
@@ -0,0 +1,206 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "set"
4
+
5
+ module Cronwatch
6
+ module Alerts
7
+ # Texts alerts through Twilio
8
+ # (https://www.twilio.com/docs/messaging/api/message-resource), form
9
+ # encoded with basic auth, one message per number, to every number at once.
10
+ #
11
+ # Cronwatch::Alerts::Twilio.new(account_sid: ENV.fetch("TWILIO_ACCOUNT_SID"),
12
+ # auth_token: ENV.fetch("TWILIO_AUTH_TOKEN"),
13
+ # from: "+15005550006", to: ["+15551110000"])
14
+ #
15
+ # Sign with auth_token, or with api_key_sid and api_key_secret (each
16
+ # trimmed of the spaces and newlines a paste leaves). Send from a number,
17
+ # or through messaging_service_sid. Recoveries are not texted unless
18
+ # `recovered: true`: a text is for what needs a person. A message fits in
19
+ # `segments` SMS segments (default 3, 1 to 10).
20
+ #
21
+ # The alert counts as delivered when any number took it; each number that
22
+ # refused it is reported through the client's channel context (on_error).
23
+ # It fails only when every number did.
24
+ class Twilio
25
+ # The GSM 03.38 alphabet: a message in it takes 153 characters a segment
26
+ # (when split), anything else is UCS-2 at 67. The extension table costs two.
27
+ GSM = Set.new("@£$¥èéùìòÇ\nØø\rÅåΔ_ΦΓΛΩΠΨΣΘΞÆæßÉ !\"\#¤%&'()*+,-./0123456789:;<=>?¡ABCDEFGHIJKLMNOPQRSTUVWXYZÄÖÑܧ¿" \
28
+ "abcdefghijklmnopqrstuvwxyzäöñüà".chars).freeze
29
+ GSM_EXTENDED = Set.new("^{}\\[~]|€\f".chars).freeze
30
+ # The most segments a message may use, which keeps it inside Twilio's 1600 character Body limit.
31
+ MAX_SEGMENTS = 10
32
+ # Twilio refuses a Body longer than this.
33
+ MAX_BODY = 1600
34
+
35
+ attr_reader :name
36
+
37
+ def initialize(account_sid:, to:, auth_token: nil, api_key_sid: nil, api_key_secret: nil, from: nil,
38
+ messaging_service_sid: nil, recovered: false, segments: 3, link: nil, http: HTTP.default)
39
+ # A pasted credential often carries a stray space or newline, which the Authorization header would refuse or send.
40
+ account_sid = Provider.require_credential(account_sid, "Cronwatch::Alerts::Twilio needs an account_sid")
41
+ api_key_sid = Provider.trimmed(api_key_sid)
42
+ user = api_key_sid.empty? ? account_sid : api_key_sid
43
+ password = Provider.trimmed(api_key_sid.empty? ? auth_token : api_key_secret)
44
+ if password.empty?
45
+ raise ArgumentError, "Cronwatch::Alerts::Twilio needs an auth_token, or an api_key_sid and api_key_secret"
46
+ end
47
+ unless Provider.present?(from) || Provider.present?(messaging_service_sid)
48
+ raise ArgumentError, "Cronwatch::Alerts::Twilio needs a from number or a messaging_service_sid"
49
+ end
50
+
51
+ @to = (to.is_a?(Array) ? to : [to]).select { |n| n.is_a?(String) && JS.trim(n) != "" }.map { |n| JS.trim(n) }
52
+ raise ArgumentError, "Cronwatch::Alerts::Twilio needs at least one to number" if @to.empty?
53
+
54
+ @url = "https://api.twilio.com/2010-04-01/Accounts/#{Provider.encode_uri_component(account_sid)}/Messages.json"
55
+ @password = password
56
+ @authorization = Provider.basic_auth(user, @password)
57
+ @from = from
58
+ @messaging_service_sid = messaging_service_sid
59
+ @recovered = recovered
60
+ @segments = Twilio.segment_budget(segments)
61
+ @link = link
62
+ @http = http
63
+ # How long to wait for the numbers' requests, each already bounded by its own HTTP deadline.
64
+ @deadline_s = HTTP::TIMEOUT + 1
65
+ @name = "twilio"
66
+ end
67
+
68
+ # `context` is the client's channel context: each number that refused
69
+ # the alert, when another took it, is reported through its on_error.
70
+ def call(alert, context = nil)
71
+ return if alert.type == :recovered && !@recovered
72
+
73
+ body = Twilio.sms_body(alert, Provider.link_for(@link, alert), @segments)
74
+ errors = send_all(body)
75
+ failed = @to.each_index.filter_map { |i| [@to[i], errors[i]] if errors[i] }
76
+ return if failed.empty?
77
+
78
+ if failed.length == @to.length
79
+ message = failed[0][1].message
80
+ raise(@to.length > 1 ? "#{message} (#{failed.length} of #{@to.length} numbers failed)" : message)
81
+ end
82
+
83
+ # Delivered to someone: counted as sent, so a retry never texts the numbers that took it again.
84
+ took = @to.length - failed.length
85
+ failed.each do |number, error|
86
+ report = RuntimeError.new("#{error.message} (to #{Twilio.mask_number(number)}; #{took} of #{@to.length} numbers took the alert)")
87
+ if context.respond_to?(:on_error)
88
+ context.on_error(report)
89
+ else
90
+ warn "[cronwatch] alert channel twilio: #{report.message}"
91
+ end
92
+ end
93
+ nil
94
+ end
95
+
96
+ # A number with all but its last four digits hidden, for an error message.
97
+ def self.mask_number(number)
98
+ length = JS.length16(number)
99
+ length <= 4 ? number : "#{"*" * [length - 4, 8].min}#{JS.tail16(number, 4)}"
100
+ end
101
+
102
+ # A segment count clamped to 1 to MAX_SEGMENTS; 3 for anything not a number.
103
+ def self.segment_budget(segments)
104
+ n = segments.is_a?(Numeric) && segments.real? && JS.finite?(segments) ? segments.floor.to_i : 3
105
+ [MAX_SEGMENTS, [1, n].max].min
106
+ end
107
+
108
+ # The segments `text` takes. A character is never split across two: an
109
+ # extension character (two septets) or a surrogate pair (two UCS-2
110
+ # units) that would straddle a boundary starts the next segment, as
111
+ # phones pack them.
112
+ def self.sms_segments(text)
113
+ units = []
114
+ gsm = true
115
+ text.each_char do |ch|
116
+ if GSM.include?(ch) then units << 1
117
+ elsif GSM_EXTENDED.include?(ch) then units << 2
118
+ else
119
+ gsm = false
120
+ break
121
+ end
122
+ end
123
+ single, per, sizes = gsm ? [160, 153, units] : [70, 67, text.each_char.map { |ch| ch.ord > 0xFFFF ? 2 : 1 }]
124
+ return 1 if sizes.sum <= single
125
+
126
+ count = 1
127
+ used = 0
128
+ sizes.each do |u|
129
+ if used + u > per
130
+ count += 1
131
+ used = 0
132
+ end
133
+ used += u
134
+ end
135
+ count
136
+ end
137
+
138
+ # Whether `text` fits in `segments` SMS segments and Twilio's Body limit.
139
+ def self.fits?(text, segments)
140
+ JS.length16(text) <= MAX_BODY && sms_segments(text) <= segments
141
+ end
142
+
143
+ # The title, then as many lines of the message (and the triage) as fit,
144
+ # then the link. The link is kept whole; the text before it is cut to
145
+ # make room. `segments` is clamped to 1 to 10.
146
+ def self.sms_body(alert, link, segments = 3)
147
+ budget = segment_budget(segments)
148
+ tail = Provider.present?(link) ? "\n#{link}" : ""
149
+ lines = [alert.title, *alert.message.split("\n", -1).reject { |l| JS.trim(l) == "" }]
150
+ lines << "Triage: #{alert.triage}" if Provider.present?(alert.triage)
151
+ text = +""
152
+ lines.each do |line|
153
+ following = text.empty? ? line : "#{text}\n#{line}"
154
+ if fits?(following + tail, budget)
155
+ text = following
156
+ next
157
+ end
158
+ # Part of this line, cut on a code point and marked.
159
+ chars = line.chars
160
+ lo = 0
161
+ hi = chars.length
162
+ while lo < hi
163
+ mid = (lo + hi + 1) / 2
164
+ candidate = (text.empty? ? "" : "#{text}\n") + chars[0, mid].join + "..."
165
+ if fits?(candidate + tail, budget)
166
+ lo = mid
167
+ else
168
+ hi = mid - 1
169
+ end
170
+ end
171
+ text = (text.empty? ? "" : "#{text}\n") + chars[0, lo].join + "..." if lo.positive?
172
+ break
173
+ end
174
+ # Only a link too long for any budget gets here too long; Twilio would refuse it whole.
175
+ Provider.cut(text + tail, MAX_BODY)
176
+ end
177
+
178
+ private
179
+
180
+ # Posts to every number at once, each in its own thread. Returns the
181
+ # error for each number, by index, or nil where it took the alert.
182
+ def send_all(body)
183
+ errors = Array.new(@to.length)
184
+ threads = @to.each_with_index.map do |number, i|
185
+ Thread.new do
186
+ Thread.current.report_on_exception = false
187
+ pairs = [["To", number]]
188
+ pairs << (Provider.present?(@messaging_service_sid) ? ["MessagingServiceSid", @messaging_service_sid] : ["From", @from])
189
+ pairs << ["Body", body]
190
+ headers = { "content-type" => "application/x-www-form-urlencoded", "authorization" => @authorization }
191
+ Provider.post(@http, "Twilio", @url, headers, Provider.form(pairs), [@password])
192
+ rescue StandardError => e
193
+ errors[i] = e
194
+ end
195
+ end
196
+ deadline = HTTP.monotonic + @deadline_s
197
+ threads.each_with_index do |thread, i|
198
+ next if thread.join([deadline - HTTP.monotonic, 0].max)
199
+
200
+ errors[i] = HTTP::TimeoutError.new
201
+ end
202
+ errors
203
+ end
204
+ end
205
+ end
206
+ end
@@ -7,7 +7,10 @@ module Cronwatch
7
7
  # POSTs the alert as JSON to any URL. The body is the alert's JSON:
8
8
  # { type, run, details, job, definition, title, message, at, triage }.
9
9
  # 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.
10
+ # the HMAC-SHA256 of the raw body, so the receiver can verify it. Header
11
+ # values are trimmed. A redirect is an error, not followed (the headers
12
+ # and the signature would go with it): point the url at where the
13
+ # receiver really is.
11
14
  class Webhook
12
15
  attr_reader :name
13
16
 
@@ -23,7 +26,9 @@ module Cronwatch
23
26
 
24
27
  def call(alert)
25
28
  body = JS.json(alert.to_h)
26
- headers = { "content-type" => "application/json", "user-agent" => "cronwatch" }.merge(@headers.transform_keys(&:to_s))
29
+ headers = { "content-type" => "application/json", "user-agent" => "cronwatch" }
30
+ # A pasted Authorization value often carries a stray space or newline, which a header would refuse.
31
+ @headers.each { |name, value| headers[name.to_s] = value.is_a?(String) ? JS.trim(value) : value }
27
32
  if @secret && !@secret.empty?
28
33
  headers["x-cronwatch-signature"] = "sha256=#{OpenSSL::HMAC.hexdigest("SHA256", @secret, body)}"
29
34
  end