cronwatch 0.14.2 → 0.14.3
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/lib/cronwatch/alerts/slack.rb +10 -2
- data/lib/cronwatch/client.rb +47 -9
- data/lib/cronwatch/evaluate.rb +9 -0
- data/lib/cronwatch/http.rb +4 -1
- data/lib/cronwatch/job.rb +18 -6
- data/lib/cronwatch/version.rb +1 -1
- data/lib/cronwatch/web/app.rb +17 -9
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: d78436fcdc58bdb265650a95d09bfc6300133aa0d24434cfb575fe3f726bb846
|
|
4
|
+
data.tar.gz: 1c52f21a7b4a2c724c8cfa75932e38b56eade9198f53602718c4d5be78eebe24
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 554b274bd8e3058755b2459778d4addcc6de0d2ab39fef01d47c15837d3dee796837333df8c019eaba223b63505cfe6f7c9ef331d0d0fd8d8bcae0ed1c5d46c1
|
|
7
|
+
data.tar.gz: d199b491b1260aea57e40852d36827455c69f3aec205fec3b33b0df631b432dcabce27e75b5a3d605e72f1c3e63531af2620e71e50726e524467f63664e4cdd3
|
|
@@ -29,14 +29,14 @@ module Cronwatch
|
|
|
29
29
|
def call(alert)
|
|
30
30
|
url = @link&.call(alert)
|
|
31
31
|
title = "#{EMOJI[alert.type]} *#{Slack.escape(alert.title)}*#{url && !url.empty? ? " (<#{Slack.link_target(url)}|open>)" : ""}"
|
|
32
|
-
body =
|
|
32
|
+
body = Slack.cut_escaped(Slack.code_block_safe(Slack.escape(alert.message)), 2900)
|
|
33
33
|
blocks = [
|
|
34
34
|
{ "type" => "section", "text" => { "type" => "mrkdwn", "text" => title } },
|
|
35
35
|
{ "type" => "section", "text" => { "type" => "mrkdwn", "text" => "```#{body}```" } },
|
|
36
36
|
]
|
|
37
37
|
if alert.triage && !alert.triage.empty?
|
|
38
38
|
# Its own block, so a long diagnosis cannot push a block past Slack's 3000 character limit.
|
|
39
|
-
blocks << { "type" => "section", "text" => { "type" => "mrkdwn", "text" =>
|
|
39
|
+
blocks << { "type" => "section", "text" => { "type" => "mrkdwn", "text" => Slack.cut_escaped("_Triage:_ #{Slack.escape(alert.triage)}", 3000) } }
|
|
40
40
|
end
|
|
41
41
|
payload = {
|
|
42
42
|
# The notification fallback is parsed as mrkdwn too, so it is escaped like the blocks.
|
|
@@ -52,6 +52,14 @@ module Cronwatch
|
|
|
52
52
|
text.gsub("&", "&").gsub("<", "<").gsub(">", ">")
|
|
53
53
|
end
|
|
54
54
|
|
|
55
|
+
# Escaped text cut to at most `max` UTF-16 units, splitting neither an
|
|
56
|
+
# entity escape wrote nor a surrogate pair.
|
|
57
|
+
def self.cut_escaped(text, max)
|
|
58
|
+
kept = JS.head16(text, max)
|
|
59
|
+
amp = kept.rindex("&")
|
|
60
|
+
amp && !kept.index(";", amp) ? kept[0, amp] : kept
|
|
61
|
+
end
|
|
62
|
+
|
|
55
63
|
# A link's URL as Slack's <url|text> can hold it: `<`, `>`, and `|`
|
|
56
64
|
# percent-encoded, which ends neither the link nor its text.
|
|
57
65
|
def self.link_target(url)
|
data/lib/cronwatch/client.rb
CHANGED
|
@@ -45,9 +45,13 @@ module Cronwatch
|
|
|
45
45
|
# The longest run id, in UTF-16 code units (JavaScript's string length):
|
|
46
46
|
# what start, resume, and record_run take, and every store holds.
|
|
47
47
|
MAX_RUN_ID = 200
|
|
48
|
+
# The statuses record_run takes.
|
|
49
|
+
RUN_STATUSES = %w[running ok failed timeout].freeze
|
|
50
|
+
# The error a check gives an import whose finish never landed (Evaluate.stranded?).
|
|
51
|
+
STRANDED_ERROR = "Imported, but its finish was never written; marked as timed out"
|
|
48
52
|
private_constant :CHANNEL_TIMEOUT_MS, :DEFAULT_OPTIONS, :FORK_LOCK, :HISTORY_MAX, :HISTORY_PAGE, :MAX_RUN_ID,
|
|
49
|
-
:PRUNE_INTERVAL_MS, :RESERVED_RUN_ID_PREFIX, :RETRY_BUDGET_MS, :
|
|
50
|
-
:TRIAGE_TIMEOUT_MS
|
|
53
|
+
:PRUNE_INTERVAL_MS, :RESERVED_RUN_ID_PREFIX, :RETRY_BUDGET_MS, :RUN_STATUSES, :SILENCE_OPTIONS,
|
|
54
|
+
:STATE_ATTEMPTS, :STRANDED_ERROR, :TRIAGE_TIMEOUT_MS
|
|
51
55
|
|
|
52
56
|
# What execute returns: the recorded run, and the block's own outcome.
|
|
53
57
|
#
|
|
@@ -362,7 +366,8 @@ module Cronwatch
|
|
|
362
366
|
# redacted the same way; one finishing after a check marked it timeout is
|
|
363
367
|
# judged only when it succeeded, as RunHandle#finish does. A metric that
|
|
364
368
|
# is not a finite number raises ArgumentError before anything is written,
|
|
365
|
-
# as JobContext#metric does
|
|
369
|
+
# as JobContext#metric does, and so does a status, start, finish,
|
|
370
|
+
# duration, error, output, or trigger evaluation cannot read. `evaluate: false` stores it without judging
|
|
366
371
|
# it, for history imported on first sight. Returns the alerts it sent.
|
|
367
372
|
#
|
|
368
373
|
# `run` is a Cronwatch::Run, or a hash of its fields (camelCase or snake_case keys).
|
|
@@ -379,6 +384,7 @@ module Cronwatch
|
|
|
379
384
|
if input.id.include?("\0")
|
|
380
385
|
raise ArgumentError, "record_run: run ids cannot contain a NUL character (job \"#{input.job}\")"
|
|
381
386
|
end
|
|
387
|
+
check_record_fields(input)
|
|
382
388
|
# Refused as JobContext#metric refuses them: a store keeps NaN and Infinity as null.
|
|
383
389
|
unless input.metrics.nil? || input.metrics.is_a?(Hash)
|
|
384
390
|
raise ArgumentError, "record_run: metrics must be a Hash of numbers (job \"#{input.job}\", run \"#{input.id}\")"
|
|
@@ -410,8 +416,16 @@ module Cronwatch
|
|
|
410
416
|
stored = @store.get_run(run.id)
|
|
411
417
|
return record_over(definition, stored, run, evaluate) if stored
|
|
412
418
|
|
|
419
|
+
# An evaluated import stays retryable until its durable evaluation marker
|
|
420
|
+
# is written. A finished one's placeholder has finished_at set to when it
|
|
421
|
+
# was written, so a check can tell one whose finish never landed
|
|
422
|
+
# (Evaluate.stranded?); the finish writes the run's own. One still
|
|
423
|
+
# running keeps finished_at nil, so it times out as any running run does.
|
|
413
424
|
inserted = run.dup
|
|
414
|
-
|
|
425
|
+
if evaluate
|
|
426
|
+
inserted.finished_at = now unless inserted.status == :running
|
|
427
|
+
inserted.status = :running
|
|
428
|
+
end
|
|
415
429
|
begin
|
|
416
430
|
@store.insert_run(inserted)
|
|
417
431
|
rescue StandardError
|
|
@@ -910,6 +924,23 @@ module Cronwatch
|
|
|
910
924
|
end
|
|
911
925
|
end
|
|
912
926
|
|
|
927
|
+
# The fields evaluation reads, checked before record_run writes anything.
|
|
928
|
+
def check_record_fields(input)
|
|
929
|
+
where = "job \"#{input.job}\", run \"#{input.id}\""
|
|
930
|
+
status = input.status
|
|
931
|
+
unless (status.is_a?(Symbol) || status.is_a?(String)) && RUN_STATUSES.include?(status.to_s)
|
|
932
|
+
raise ArgumentError, "record_run: status must be \"running\", \"ok\", \"failed\", or \"timeout\" (#{where})"
|
|
933
|
+
end
|
|
934
|
+
raise ArgumentError, "record_run: started_at must be a finite number (#{where})" unless JS.finite?(input.started_at)
|
|
935
|
+
raise ArgumentError, "record_run: finished_at must be a finite number or nil (#{where})" unless input.finished_at.nil? || JS.finite?(input.finished_at)
|
|
936
|
+
raise ArgumentError, "record_run: duration_ms must be a finite number or nil (#{where})" unless input.duration_ms.nil? || JS.finite?(input.duration_ms)
|
|
937
|
+
|
|
938
|
+
{ "error" => input.error, "output" => input.output }.each do |field, value|
|
|
939
|
+
raise ArgumentError, "record_run: #{field} must be a String or nil (#{where})" unless value.nil? || value.is_a?(String)
|
|
940
|
+
end
|
|
941
|
+
raise ArgumentError, "record_run: trigger must be a String (#{where})" unless input.trigger.is_a?(String)
|
|
942
|
+
end
|
|
943
|
+
|
|
913
944
|
# record_run for a run already stored.
|
|
914
945
|
def record_over(definition, stored, run, evaluate)
|
|
915
946
|
if stored.job != run.job
|
|
@@ -1348,18 +1379,25 @@ module Cronwatch
|
|
|
1348
1379
|
next if definition.nil?
|
|
1349
1380
|
|
|
1350
1381
|
evaluable(listed.job, definition)
|
|
1351
|
-
|
|
1382
|
+
stranded = Evaluate.stranded?(listed, at)
|
|
1383
|
+
next unless stranded || Evaluate.stuck?(definition, listed, at)
|
|
1352
1384
|
|
|
1353
1385
|
# Read again just before the write: lines and metrics flushed since the
|
|
1354
1386
|
# list was read (while earlier stuck runs were sent, say) are kept.
|
|
1355
1387
|
run = @store.get_run(listed.id)
|
|
1356
1388
|
next if run.nil? || run.status != :running || run.job != listed.job
|
|
1357
1389
|
|
|
1358
|
-
timeout = Evaluate.timeout_ms(definition)
|
|
1359
1390
|
run.status = :timeout
|
|
1360
|
-
|
|
1361
|
-
|
|
1362
|
-
|
|
1391
|
+
if stranded
|
|
1392
|
+
# Its own status was never written; a late finish of it is judged
|
|
1393
|
+
# as one after a timeout, so a success still recovers.
|
|
1394
|
+
run.duration_ms = Evaluate.run_duration(run.started_at, run.finished_at) if run.duration_ms.nil?
|
|
1395
|
+
run.error = run.error.nil? ? STRANDED_ERROR : "#{STRANDED_ERROR}\n#{run.error}"
|
|
1396
|
+
else
|
|
1397
|
+
run.finished_at = at
|
|
1398
|
+
run.duration_ms = Evaluate.run_duration(run.started_at, at)
|
|
1399
|
+
run.error = "Still running after #{Duration.format(Evaluate.timeout_ms(definition))}; marked as timed out"
|
|
1400
|
+
end
|
|
1363
1401
|
# Only over a row still running: a finish that landed meanwhile wins.
|
|
1364
1402
|
next unless write_run_if(run, [:running])
|
|
1365
1403
|
|
data/lib/cronwatch/evaluate.rb
CHANGED
|
@@ -691,6 +691,15 @@ module Cronwatch
|
|
|
691
691
|
run.status == :running && run.finished_at.nil? && now - run.started_at > timeout_ms(definition)
|
|
692
692
|
end
|
|
693
693
|
|
|
694
|
+
# Whether a finished import was left as its placeholder: record_run writes
|
|
695
|
+
# one as running, with finished_at set to when it was written, and the
|
|
696
|
+
# finish that follows never landed (the importer stopped, or a store write
|
|
697
|
+
# failed and it was not retried). A check closes one SEND_LEASE_MS after
|
|
698
|
+
# it was written, as it closes a stuck run.
|
|
699
|
+
def stranded?(run, now)
|
|
700
|
+
run.status == :running && !run.finished_at.nil? && now - run.finished_at > SEND_LEASE_MS
|
|
701
|
+
end
|
|
702
|
+
|
|
694
703
|
# While a job is silenced nothing new is recorded as an incident: conditions
|
|
695
704
|
# may close (so a job that recovered during the silence shows as healthy) but
|
|
696
705
|
# none may open, so the first problem after the silence ends alerts normally.
|
data/lib/cronwatch/http.rb
CHANGED
|
@@ -85,8 +85,11 @@ module Cronwatch
|
|
|
85
85
|
end
|
|
86
86
|
|
|
87
87
|
# A connection that gives up after ten seconds, as the SDK's requests do.
|
|
88
|
+
# It goes direct: nil as the proxy address keeps Net::HTTP from taking
|
|
89
|
+
# one from http_proxy or https_proxy, which fetch never reads, so an
|
|
90
|
+
# alert (and the credentials in it) reaches only the channel's own host.
|
|
88
91
|
def connection(uri)
|
|
89
|
-
http = Net::HTTP.new(uri.host, uri.port)
|
|
92
|
+
http = Net::HTTP.new(uri.host, uri.port, nil)
|
|
90
93
|
http.use_ssl = uri.scheme == "https"
|
|
91
94
|
http.open_timeout = @timeout
|
|
92
95
|
http.read_timeout = @timeout
|
data/lib/cronwatch/job.rb
CHANGED
|
@@ -111,8 +111,14 @@ module Cronwatch
|
|
|
111
111
|
# dropped from @lines: what expect_text needs once the output runs long.
|
|
112
112
|
@head = []
|
|
113
113
|
@head_size = 0
|
|
114
|
-
|
|
115
|
-
|
|
114
|
+
# Every line logged while they come to no more than twice the cap
|
|
115
|
+
# (nil after), joined only when read, so a log is not a copy of them all.
|
|
116
|
+
@whole = []
|
|
117
|
+
@whole_size = 0
|
|
118
|
+
# The raw lines behind expect_text's tail, each with its UTF-16 length,
|
|
119
|
+
# joined only when it is read.
|
|
120
|
+
@raw = []
|
|
121
|
+
@raw_size = 0
|
|
116
122
|
@logged = false
|
|
117
123
|
@metrics = {}
|
|
118
124
|
@lock = Mutex.new
|
|
@@ -126,9 +132,15 @@ module Cronwatch
|
|
|
126
132
|
separator = @logged ? "\n" : ""
|
|
127
133
|
@logged = true
|
|
128
134
|
if @whole
|
|
129
|
-
@
|
|
135
|
+
@whole_size += separator.length + length
|
|
136
|
+
@whole = @whole_size <= 2 * Output::CAP ? @whole << line.dup : nil
|
|
130
137
|
end
|
|
131
|
-
|
|
138
|
+
raw = length > Output::CAP ? JS.tail16(line, Output::CAP).dup : line.dup
|
|
139
|
+
raw_length = length > Output::CAP ? JS.length16(raw) : length
|
|
140
|
+
@raw << [raw, raw_length]
|
|
141
|
+
@raw_size += raw_length + 1
|
|
142
|
+
# Drops the oldest only while what is left still holds the cap.
|
|
143
|
+
@raw_size -= @raw.shift[1] + 1 while @raw.length > 1 && @raw_size - (@raw[0][1] + 1) > Output::CAP
|
|
132
144
|
if @head_size < Output::CAP
|
|
133
145
|
@head << JS.head16(line, Output::CAP - @head_size).dup
|
|
134
146
|
@head_size += length + 1
|
|
@@ -165,9 +177,9 @@ module Cronwatch
|
|
|
165
177
|
@lock.synchronize do
|
|
166
178
|
next nil if @lines.empty?
|
|
167
179
|
|
|
168
|
-
next @whole if @whole
|
|
180
|
+
next @whole.join("\n") if @whole
|
|
169
181
|
|
|
170
|
-
"#{JS.head16(@head.join("\n"), Output::CAP)}\n#{@
|
|
182
|
+
"#{JS.head16(@head.join("\n"), Output::CAP)}\n#{JS.tail16(@raw.map(&:first).join("\n"), Output::CAP)}"
|
|
171
183
|
end
|
|
172
184
|
end
|
|
173
185
|
|
data/lib/cronwatch/version.rb
CHANGED
data/lib/cronwatch/web/app.rb
CHANGED
|
@@ -204,6 +204,14 @@ module Cronwatch
|
|
|
204
204
|
end
|
|
205
205
|
|
|
206
206
|
def call(env)
|
|
207
|
+
status, headers, body = answer(env)
|
|
208
|
+
# A HEAD reads what a GET would, and is answered without the body.
|
|
209
|
+
env["REQUEST_METHOD"].to_s.upcase == "HEAD" ? [status, headers, []] : [status, headers, body]
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
private
|
|
213
|
+
|
|
214
|
+
def answer(env)
|
|
207
215
|
wants_html = true
|
|
208
216
|
base = @base_path || env["SCRIPT_NAME"].to_s.sub(%r{/+\z}, "")
|
|
209
217
|
begin
|
|
@@ -228,8 +236,6 @@ module Cronwatch
|
|
|
228
236
|
end
|
|
229
237
|
end
|
|
230
238
|
|
|
231
|
-
private
|
|
232
|
-
|
|
233
239
|
def serve(request, path, wants_html, base)
|
|
234
240
|
cw = client
|
|
235
241
|
method = request.verb
|
|
@@ -308,14 +314,15 @@ module Cronwatch
|
|
|
308
314
|
return wants_html ? html(HTML.message_page("Bad request", "The path is not valid.", base), 400) : api({ ok: false, error: "Bad path" }, 400)
|
|
309
315
|
end
|
|
310
316
|
|
|
311
|
-
# HTML
|
|
312
|
-
|
|
317
|
+
# HTML. A HEAD reads what a GET would; call sends it without the body.
|
|
318
|
+
reads = %w[GET HEAD].include?(method)
|
|
319
|
+
if reads && path == "/"
|
|
313
320
|
entries = cw.jobs_with_runs(BOARD_PAGE_RUNS)
|
|
314
321
|
now = cw.now
|
|
315
322
|
runs_by_job = entries.to_h { |entry| [entry.job.name, entry.runs] }
|
|
316
323
|
return html(HTML.dashboard_page(entries.map(&:job), runs_by_job, now, base, nil, board_lanes(cw, entries, now)))
|
|
317
324
|
end
|
|
318
|
-
if
|
|
325
|
+
if reads && parts[0] == "jobs" && parts.length == 2
|
|
319
326
|
job = cw.job_summary(parts[1])
|
|
320
327
|
return html(HTML.message_page("No such job", "#{parts[1]} is not in the store.", base), 404) unless job
|
|
321
328
|
|
|
@@ -363,14 +370,15 @@ module Cronwatch
|
|
|
363
370
|
|
|
364
371
|
def serve_api(request, method, rest, bearer)
|
|
365
372
|
cw = client
|
|
366
|
-
|
|
373
|
+
reads = %w[GET HEAD].include?(method)
|
|
374
|
+
if reads && rest.empty?
|
|
367
375
|
return api({ ok: true, library: LIBRARY, language: LANGUAGE, version: VERSION, api: API_VERSION })
|
|
368
376
|
end
|
|
369
|
-
return api({ ok: true, jobs: cw.jobs }) if
|
|
377
|
+
return api({ ok: true, jobs: cw.jobs }) if reads && rest[0] == "jobs" && rest.length == 1
|
|
370
378
|
|
|
371
379
|
if rest[0] == "jobs" && rest.length == 2
|
|
372
380
|
name = rest[1]
|
|
373
|
-
if
|
|
381
|
+
if reads
|
|
374
382
|
job = cw.job_summary(name)
|
|
375
383
|
return api({ ok: false, error: "No such job" }, 404) unless job
|
|
376
384
|
|
|
@@ -413,7 +421,7 @@ module Cronwatch
|
|
|
413
421
|
end
|
|
414
422
|
return api({ "ok" => true }.merge(cw.check.to_h)) if %w[GET POST].include?(method)
|
|
415
423
|
end
|
|
416
|
-
if
|
|
424
|
+
if reads && rest[0] == "runs" && rest.length == 2
|
|
417
425
|
run = cw.get_run(rest[1])
|
|
418
426
|
return run ? api({ ok: true, run: run }) : api({ ok: false, error: "No such run" }, 404)
|
|
419
427
|
end
|