cronwatch 0.3.1

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.
Files changed (46) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +21 -0
  3. data/README.md +268 -0
  4. data/lib/cronwatch/abort_signal.rb +45 -0
  5. data/lib/cronwatch/active_record.rb +11 -0
  6. data/lib/cronwatch/alerts/console.rb +22 -0
  7. data/lib/cronwatch/alerts/custom.rb +26 -0
  8. data/lib/cronwatch/alerts/discord.rb +52 -0
  9. data/lib/cronwatch/alerts/slack.rb +58 -0
  10. data/lib/cronwatch/alerts/webhook.rb +46 -0
  11. data/lib/cronwatch/client.rb +925 -0
  12. data/lib/cronwatch/cron_pattern.rb +277 -0
  13. data/lib/cronwatch/duration.rb +77 -0
  14. data/lib/cronwatch/environment.rb +29 -0
  15. data/lib/cronwatch/evaluate.rb +345 -0
  16. data/lib/cronwatch/flight.rb +42 -0
  17. data/lib/cronwatch/format.rb +89 -0
  18. data/lib/cronwatch/http.rb +52 -0
  19. data/lib/cronwatch/job.rb +144 -0
  20. data/lib/cronwatch/js.rb +188 -0
  21. data/lib/cronwatch/monitored.rb +259 -0
  22. data/lib/cronwatch/output.rb +199 -0
  23. data/lib/cronwatch/rails/active_job.rb +55 -0
  24. data/lib/cronwatch/rails/check_job.rb +32 -0
  25. data/lib/cronwatch/rails/railtie.rb +37 -0
  26. data/lib/cronwatch/rails/tasks.rb +12 -0
  27. data/lib/cronwatch/rails.rb +35 -0
  28. data/lib/cronwatch/schedule.rb +191 -0
  29. data/lib/cronwatch/scheduler.rb +763 -0
  30. data/lib/cronwatch/serialize.rb +51 -0
  31. data/lib/cronwatch/sidekiq.rb +129 -0
  32. data/lib/cronwatch/stats.rb +23 -0
  33. data/lib/cronwatch/stores/active_record.rb +397 -0
  34. data/lib/cronwatch/stores/memory.rb +163 -0
  35. data/lib/cronwatch/ticker.rb +59 -0
  36. data/lib/cronwatch/triage/anthropic.rb +134 -0
  37. data/lib/cronwatch/types.rb +341 -0
  38. data/lib/cronwatch/version.rb +6 -0
  39. data/lib/cronwatch/walker.rb +137 -0
  40. data/lib/cronwatch/web/app.rb +484 -0
  41. data/lib/cronwatch/web/html.rb +314 -0
  42. data/lib/cronwatch/web.rb +17 -0
  43. data/lib/cronwatch/zone.rb +72 -0
  44. data/lib/cronwatch.rb +96 -0
  45. data/lib/generators/cronwatch/install/install_generator.rb +176 -0
  46. metadata +104 -0
@@ -0,0 +1,314 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Cronwatch
4
+ class Web
5
+ # The dashboard's pages, markup for markup the SDK's routes/html.ts. No
6
+ # script anywhere: the pages refresh themselves and the forget button
7
+ # confirms with a <details>.
8
+ module HTML
9
+ CSS = "\n" + <<~'CSS'
10
+ :root{--bg:#fbfbf9;--fg:#1b1b18;--muted:#6b6b64;--line:#e6e5df;--card:#fff;--ok:#1f8a4c;--warn:#b7791f;--bad:#c62828;--info:#2b5fb3;--pill:#f1f0ea;--mono:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;--sans:-apple-system,BlinkMacSystemFont,"Segoe UI",Inter,Roboto,sans-serif}
11
+ @media(prefers-color-scheme:dark){:root{--bg:#121311;--fg:#ecece6;--muted:#9a9a91;--line:#2a2b27;--card:#1a1b18;--pill:#24251f}}
12
+ *{box-sizing:border-box}html{-webkit-text-size-adjust:100%}
13
+ body{margin:0;background:var(--bg);color:var(--fg);font:15px/1.5 var(--sans)}
14
+ a{color:inherit}main{max-width:1080px;margin:0 auto;padding:24px 16px 64px}
15
+ header{display:flex;align-items:baseline;justify-content:space-between;gap:16px;flex-wrap:wrap;margin-bottom:20px}
16
+ header h1{font-size:18px;margin:0;letter-spacing:-.01em}header h1 a{text-decoration:none}
17
+ header .meta{color:var(--muted);font-size:13px}
18
+ .card{background:var(--card);border:1px solid var(--line);border-radius:10px;overflow:hidden}
19
+ table{width:100%;border-collapse:collapse;font-size:14px}
20
+ th{text-align:left;font-weight:600;color:var(--muted);font-size:12px;text-transform:uppercase;letter-spacing:.04em;padding:10px 12px;border-bottom:1px solid var(--line);white-space:nowrap}
21
+ td{padding:10px 12px;border-bottom:1px solid var(--line);vertical-align:top}
22
+ tr:last-child td{border-bottom:0}
23
+ .name{font-weight:600;white-space:nowrap}.name a{text-decoration:none}.name a:hover{text-decoration:underline}
24
+ .mono{font-family:var(--mono);font-size:13px}.muted{color:var(--muted)}.nowrap{white-space:nowrap}
25
+ .pill{display:inline-flex;align-items:center;gap:6px;padding:2px 9px;border-radius:999px;background:var(--pill);font-size:12px;font-weight:600;white-space:nowrap}
26
+ .pill::before{content:"";width:7px;height:7px;border-radius:50%;background:currentColor}
27
+ .ok{color:var(--ok)}.warn{color:var(--warn)}.bad{color:var(--bad)}.info{color:var(--info)}.mutedpill{color:var(--muted)}
28
+ .spark{display:block}
29
+ form.inline{display:inline}
30
+ button,select{font:inherit;font-size:13px;padding:5px 10px;border:1px solid var(--line);border-radius:7px;background:var(--card);color:var(--fg);cursor:pointer}
31
+ button:hover{border-color:var(--muted)}
32
+ .actions{display:flex;gap:8px;align-items:center;flex-wrap:wrap}
33
+ .grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(180px,1fr));gap:12px;margin:0 0 20px}
34
+ .stat{padding:12px 14px}.stat .k{font-size:12px;color:var(--muted);text-transform:uppercase;letter-spacing:.04em}.stat .v{font-size:20px;font-weight:600;margin-top:2px}
35
+ pre{margin:0;padding:10px 12px;background:var(--pill);border-radius:8px;font:12.5px/1.45 var(--mono);white-space:pre-wrap;word-break:break-word;max-height:320px;overflow:auto}
36
+ details summary{cursor:pointer;color:var(--muted);font-size:13px}details{margin-top:6px}
37
+ details.confirm{margin:0}details.confirm summary{list-style:none;display:inline-block;font-size:13px;padding:5px 10px;border:1px solid var(--line);border-radius:7px;background:var(--card);color:var(--fg)}
38
+ details.confirm summary::-webkit-details-marker{display:none}details.confirm[open] summary{border-color:var(--muted)}details.confirm form{margin-left:8px;font-size:13px}
39
+ .empty{padding:40px 16px;text-align:center;color:var(--muted)}
40
+ dl{display:grid;grid-template-columns:max-content 1fr;gap:6px 16px;margin:0;padding:14px 16px;font-size:14px}dt{color:var(--muted)}dd{margin:0}
41
+ footer{margin-top:28px;color:var(--muted);font-size:12px}
42
+ @media(max-width:720px){.hide-sm{display:none}main{padding:16px 16px 48px}}
43
+ CSS
44
+
45
+ HEALTH = {
46
+ healthy: %w[ok healthy], late: %w[warn late], failing: %w[bad failing], stuck: %w[bad stuck],
47
+ silenced: %w[mutedpill silenced], never_ran: ["info", "never ran"],
48
+ }.freeze
49
+ # Conditions the health pill already says; the others get a pill of their own.
50
+ SHOWN_BY_HEALTH = %i[missed failed stuck].freeze
51
+ # What encodeURIComponent leaves alone.
52
+ URI_UNRESERVED = /[A-Za-z0-9\-_.!~*'()]/
53
+
54
+ module_function
55
+
56
+ # escapeHtml: String(value ?? "") with & < > " ' escaped.
57
+ def h(value)
58
+ text(value).gsub("&", "&amp;").gsub("<", "&lt;").gsub(">", "&gt;").gsub('"', "&quot;").gsub("'", "&#39;")
59
+ end
60
+
61
+ # String(value), the way a template literal writes it.
62
+ def text(value)
63
+ case value
64
+ when nil then ""
65
+ when String then value
66
+ when Symbol then value.to_s
67
+ when Numeric then JS.number(value)
68
+ when Array then value.map { |v| v.nil? ? "" : text(v) }.join(",")
69
+ when Hash then "[object Object]"
70
+ else value.to_s
71
+ end
72
+ end
73
+
74
+ # JavaScript truthiness, for the template's `x ? a : b`.
75
+ def truthy?(value)
76
+ return false if value.nil? || value == false || value == ""
77
+ return false if value.is_a?(Numeric) && (value.zero? || (value.is_a?(Float) && value.nan?))
78
+
79
+ true
80
+ end
81
+
82
+ # encodeURIComponent.
83
+ def encode_uri_component(value)
84
+ text(value).each_char.map do |c|
85
+ URI_UNRESERVED.match?(c) ? c : c.bytes.map { |b| format("%%%02X", b) }.join
86
+ end.join
87
+ end
88
+
89
+ # Number.prototype.toFixed: the nearest `digits`-place decimal to the
90
+ # exact value of the double, halves away from zero.
91
+ def to_fixed(value, digits)
92
+ return JS.number(value) if !JS.finite?(value) || value.abs >= 1e21
93
+
94
+ scaled = (Rational(value.abs) * (10**digits)).round(half: :up).to_s
95
+ scaled = scaled.rjust(digits + 1, "0") if digits.positive?
96
+ out = digits.positive? ? "#{scaled[0...-digits]}.#{scaled[-digits..]}" : scaled
97
+ value.negative? ? "-#{out}" : out
98
+ end
99
+
100
+ # Object.entries: integer-like keys first, ascending, then the rest in insertion order.
101
+ def entries(hash)
102
+ return [] if hash.nil?
103
+
104
+ JS.object_keys(hash).map { |k| [k.to_s, hash[k]] }
105
+ end
106
+
107
+ def layout(title, body, refresh: nil)
108
+ <<~HTML.chomp
109
+ <!doctype html>
110
+ <html lang="en">
111
+ <head>
112
+ <meta charset="utf-8">
113
+ <meta name="viewport" content="width=device-width,initial-scale=1">
114
+ <meta name="robots" content="noindex,nofollow">
115
+ #{truthy?(refresh) ? "<meta http-equiv=\"refresh\" content=\"#{text(refresh)}\">" : ""}
116
+ <title>#{h(title)}</title>
117
+ <style>#{CSS}</style>
118
+ </head>
119
+ <body><main>#{body}</main></body>
120
+ </html>
121
+ HTML
122
+ end
123
+
124
+ def health_pill(job)
125
+ cls, label = HEALTH.fetch(job.health.to_sym)
126
+ extras = job.open.map(&:to_sym).reject { |c| SHOWN_BY_HEALTH.include?(c) }.map { |c| c.to_s.sub("_", " ") }
127
+ extra = extras.empty? ? "" : %( <span class="pill warn">#{h(extras.join(", "))}</span>)
128
+ %(<span class="pill #{cls}">#{label}</span>#{extra})
129
+ end
130
+
131
+ def run_pill(run)
132
+ status = run.status.to_s
133
+ cls = if status == "ok" then "ok"
134
+ elsif status == "running" then "info"
135
+ else "bad"
136
+ end
137
+ %(<span class="pill #{cls}">#{h(status)}</span>)
138
+ end
139
+
140
+ def sparkline(runs)
141
+ points = runs.reverse.reject { |r| r.duration_ms.nil? }.last(20)
142
+ return "" if points.length < 2
143
+
144
+ w = 96
145
+ hgt = 22
146
+ max = [*points.map(&:duration_ms), 1].max
147
+ step = w.fdiv(points.length - 1)
148
+ y = ->(r) { to_fixed(hgt - 2 - (r.duration_ms.fdiv(max) * (hgt - 4)), 1) }
149
+ path = points.each_with_index.map { |r, i| "#{i.zero? ? "M" : "L"}#{to_fixed(i * step, 1)},#{y.call(r)}" }.join(" ")
150
+ dots = points.each_with_index.map do |r, i|
151
+ r.status.to_s == "ok" ? "" : %(<circle cx="#{to_fixed(i * step, 1)}" cy="#{y.call(r)}" r="2.2" fill="var(--bad)"/>)
152
+ end.join
153
+ %(<svg class="spark" width="#{w}" height="#{hgt}" viewBox="0 0 #{w} #{hgt}" aria-hidden="true"><path d="#{path}" fill="none" stroke="var(--muted)" stroke-width="1.5"/>#{dots}</svg>)
154
+ end
155
+
156
+ def stamp(at, now)
157
+ return %(<span class="muted">never</span>) if at.nil?
158
+
159
+ iso = JS.iso(at.to_i).sub("T", " ")[0, 19]
160
+ %(<span class="nowrap" title="#{iso} UTC">#{h(Duration.relative(at, now))}</span>)
161
+ end
162
+
163
+ def dashboard_page(jobs, runs_by_job, now, base, checked_at)
164
+ rows = jobs.map do |job|
165
+ last = job.last_run
166
+ d = job.definition
167
+ description = truthy?(d.description) ? %(<div class="muted" style="font-weight:400;font-size:13px;white-space:normal">#{h(d.description)}</div>) : ""
168
+ last_cell =
169
+ if last
170
+ took = last.duration_ms.nil? ? "" : %( <span class="muted">#{h(Duration.format(last.duration_ms))}</span>)
171
+ "#{run_pill(last)} #{stamp(last.started_at, now)}#{took}"
172
+ else
173
+ %(<span class="muted">never</span>)
174
+ end
175
+ <<~ROW.chomp
176
+ <tr>
177
+ <td class="name"><a href="#{h(base)}/jobs/#{encode_uri_component(job.name)}">#{h(job.name)}</a>#{description}</td>
178
+ <td>#{health_pill(job)}</td>
179
+ <td class="mono nowrap">#{h(d.schedule.nil? ? "" : d.schedule)}<span class="muted">#{truthy?(d.schedule) ? "" : "no schedule"}</span></td>
180
+ <td class="nowrap">#{last_cell}</td>
181
+ <td class="nowrap hide-sm">#{stamp(job.next_expected_at, now)}</td>
182
+ <td class="hide-sm">#{sparkline(runs_by_job[job.name] || [])}</td>
183
+ </tr>
184
+ ROW
185
+ end.join("\n")
186
+
187
+ checked = truthy?(checked_at) ? ", checked #{h(Duration.relative(checked_at, now))}" : ""
188
+ table =
189
+ if jobs.empty?
190
+ %(<div class="empty">No jobs yet. Declare one with <code class="mono">CW.job("name", schedule: "0 2 * * *")</code> and run it once.</div>)
191
+ else
192
+ <<~TABLE.chomp
193
+ <table>
194
+ <thead><tr><th>Job</th><th>Health</th><th>Schedule</th><th>Last run</th><th class="hide-sm">Next due</th><th class="hide-sm">Durations</th></tr></thead>
195
+ <tbody>#{rows}</tbody></table>
196
+ TABLE
197
+ end
198
+ body = <<~BODY.chomp
199
+
200
+ <header>
201
+ <h1><a href="#{h(base)}/">CronWatch</a></h1>
202
+ <div class="actions">
203
+ <span class="meta">#{jobs.length} job#{jobs.length == 1 ? "" : "s"}#{checked}</span>
204
+ <form class="inline" method="post" action="#{h(base)}/check"><button type="submit">Run check now</button></form>
205
+ </div>
206
+ </header>
207
+ <div class="card">
208
+ #{table}
209
+ </div>
210
+ <footer>Refreshes every minute. <a href="#{h(base)}/api/jobs">JSON</a></footer>
211
+ BODY
212
+ layout("CronWatch", body, refresh: 60)
213
+ end
214
+
215
+ def job_page(job, runs, now, base)
216
+ d = job.definition
217
+ ok_rate = "#{JS.number(JS.round(job.stats.ok_rate * 100))}%"
218
+ run_rows = runs.map do |run|
219
+ detail = [
220
+ truthy?(run.error) ? %(<details open><summary>error</summary><pre>#{h(run.error)}</pre></details>) : "",
221
+ truthy?(run.output) ? %(<details#{run.status.to_s == "ok" ? "" : " open"}><summary>output</summary><pre>#{h(run.output)}</pre></details>) : "",
222
+ ].join
223
+ metrics = entries(run.metrics).map do |k, v|
224
+ %(<span class="pill mutedpill">#{h(k)} #{h(JS.integer?(v) ? v : to_fixed(v, 4))}</span>)
225
+ end.join(" ")
226
+ took = run.duration_ms.nil? ? %(<span class="muted">running</span>) : h(Duration.format(run.duration_ms))
227
+ detail_row = detail.empty? ? "" : %(<tr><td colspan="5" style="padding-top:0">#{detail}</td></tr>)
228
+ <<~ROW.chomp
229
+ <tr>
230
+ <td class="nowrap">#{run_pill(run)}</td>
231
+ <td class="nowrap">#{stamp(run.started_at, now)}</td>
232
+ <td class="nowrap">#{took}</td>
233
+ <td class="hide-sm">#{metrics}</td>
234
+ <td class="mono hide-sm muted">#{h(run.trigger)}</td>
235
+ </tr>#{detail_row}
236
+ ROW
237
+ end.join("\n")
238
+
239
+ name = encode_uri_component(job.name)
240
+ silenced = !job.silenced_until.nil? && job.silenced_until > now
241
+ silence_form =
242
+ if silenced
243
+ %(<form class="inline" method="post" action="#{h(base)}/jobs/#{name}/unsilence"><button type="submit">Unsilence (until #{h(Duration.relative(job.silenced_until, now))})</button></form>)
244
+ else
245
+ %(<form class="inline" method="post" action="#{h(base)}/jobs/#{name}/silence"><select name="for"><option value="1h">1 hour</option><option value="4h">4 hours</option><option value="1d">1 day</option><option value="7d">1 week</option></select> <button type="submit">Silence</button></form>)
246
+ end
247
+ stats = job.stats
248
+ p50 = stats.p50_ms.nil? ? "?" : h(Duration.format(stats.p50_ms))
249
+ p95 = stats.p95_ms.nil? ? "?" : h(Duration.format(stats.p95_ms))
250
+ schedule =
251
+ if truthy?(d.schedule)
252
+ h(d.schedule) + (truthy?(d.timezone) ? %( <span class="muted">#{h(d.timezone)}</span>) : "")
253
+ else
254
+ %(<span class="muted">none</span>)
255
+ end
256
+ budget = truthy?(d.budget) ? entries(d.budget).map { |k, v| "#{k} ≤ #{text(v)}" }.join(", ") : nil
257
+ tags = d.tags
258
+ failures = d.failures_before_alert
259
+ runs_table =
260
+ if runs.empty?
261
+ %(<div class="empty">No runs yet.</div>)
262
+ else
263
+ <<~TABLE.chomp
264
+ <table>
265
+ <thead><tr><th>Status</th><th>Started</th><th>Duration</th><th class="hide-sm">Metrics</th><th class="hide-sm">Trigger</th></tr></thead>
266
+ <tbody>#{run_rows}</tbody></table>
267
+ TABLE
268
+ end
269
+
270
+ body = <<~BODY.chomp
271
+
272
+ <header>
273
+ <h1><a href="#{h(base)}/">CronWatch</a> <span class="muted">/</span> #{h(job.name)}</h1>
274
+ <div class="actions">
275
+ #{health_pill(job)}
276
+ #{silence_form}
277
+ <details class="confirm"><summary>Forget</summary><form class="inline" method="post" action="#{h(base)}/jobs/#{name}/forget"><span class="muted">Remove this job and its runs from the store?</span> <button type="submit">Forget</button></form></details>
278
+ </div>
279
+ </header>
280
+ <div class="grid">
281
+ <div class="card stat"><div class="k">Last run</div><div class="v">#{job.last_run ? h(Duration.relative(job.last_run.started_at, now)) : "never"}</div></div>
282
+ <div class="card stat"><div class="k">Next due</div><div class="v">#{truthy?(job.next_expected_at) ? h(Duration.relative(job.next_expected_at, now)) : "no schedule"}</div></div>
283
+ <div class="card stat"><div class="k">Success, last #{h(stats.runs)}</div><div class="v">#{h(ok_rate)}</div></div>
284
+ <div class="card stat"><div class="k">p50 / p95</div><div class="v">#{p50} <span class="muted">/</span> #{p95}</div></div>
285
+ </div>
286
+ <div class="card" style="margin-bottom:20px">
287
+ <dl>
288
+ <dt>Schedule</dt><dd class="mono">#{schedule}</dd>
289
+ <dt>Grace</dt><dd class="mono">#{h(d.grace.nil? ? "10m" : d.grace)}</dd>
290
+ <dt>Timeout</dt><dd class="mono">#{h(d.timeout.nil? ? "1h" : d.timeout)}</dd>
291
+ #{truthy?(d.max_duration) ? %(<dt>Max duration</dt><dd class="mono">#{h(d.max_duration)}</dd>) : ""}
292
+ #{budget ? %(<dt>Budget</dt><dd class="mono">#{h(budget)}</dd>) : ""}
293
+ #{truthy?(d.expect) ? %(<dt>Expect</dt><dd class="mono">#{h(d.expect)}</dd>) : ""}
294
+ #{truthy?(failures) && failures > 1 ? %(<dt>Alert after</dt><dd>#{h(failures)} consecutive failures</dd>) : ""}
295
+ #{truthy?(d.description) ? %(<dt>Description</dt><dd>#{h(d.description)}</dd>) : ""}
296
+ #{tags.is_a?(Array) && tags.any? ? %(<dt>Tags</dt><dd>#{tags.map { |t| %(<span class="pill mutedpill">#{h(t)}</span>) }.join(" ")}</dd>) : ""}
297
+ #{job.open.any? ? %(<dt>Open</dt><dd>#{job.open.map { |c| %(<span class="pill warn">#{h(c.to_s.sub("_", " "))}</span>) }.join(" ")}</dd>) : ""}
298
+ #{job.consecutive_failures.positive? ? %(<dt>Consecutive failures</dt><dd>#{h(job.consecutive_failures)}</dd>) : ""}
299
+ </dl>
300
+ </div>
301
+ <div class="card">
302
+ #{runs_table}
303
+ </div>
304
+ <footer><a href="#{h(base)}/api/jobs/#{name}">JSON</a></footer>
305
+ BODY
306
+ layout("#{job.name}: CronWatch", body, refresh: 60)
307
+ end
308
+
309
+ def message_page(title, message, base)
310
+ layout(title, %(<header><h1><a href="#{h(base)}/">CronWatch</a></h1></header><div class="card"><div class="empty"><strong>#{h(title)}</strong><br>#{h(message)}</div></div>))
311
+ end
312
+ end
313
+ end
314
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ # The dashboard and JSON API, as a Rack app. Needs the rack gem; the Rails
4
+ # integration loads this file itself.
5
+ #
6
+ # require "cronwatch/web"
7
+ # run Cronwatch::Web.new(CW)
8
+ begin
9
+ require "rack"
10
+ rescue LoadError => e
11
+ raise LoadError, "cronwatch/web needs the rack gem: add `gem \"rack\"` to your Gemfile " \
12
+ "(Rails apps already have it) (#{e.message})"
13
+ end
14
+
15
+ require "cronwatch" unless defined?(Cronwatch::Client)
16
+ require_relative "web/html"
17
+ require_relative "web/app"
@@ -0,0 +1,72 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fugit"
4
+
5
+ module Cronwatch
6
+ # IANA zones through TZInfo (which Fugit brings, by way of et-orbi), or the
7
+ # process's own zone when none is named, as JavaScript's Date does.
8
+ module Zone
9
+ @zones = {}
10
+ @names = nil
11
+ @lock = Mutex.new
12
+
13
+ module_function
14
+
15
+ # A TZInfo zone. Names are matched without regard to case, as Intl does.
16
+ def get(name)
17
+ @lock.synchronize do
18
+ @zones[name] ||= begin
19
+ TZInfo::Timezone.get(name)
20
+ rescue TZInfo::InvalidTimezoneIdentifier
21
+ @names ||= TZInfo::Timezone.all_identifiers.to_h { |id| [id.downcase, id] }
22
+ canonical = @names[name.to_s.downcase]
23
+ raise ArgumentError, "timezone \"#{name}\" is not an IANA timezone" unless canonical
24
+
25
+ TZInfo::Timezone.get(canonical)
26
+ end
27
+ end
28
+ end
29
+
30
+ def valid?(name)
31
+ get(name)
32
+ true
33
+ rescue ArgumentError, TZInfo::InvalidTimezoneIdentifier, TZInfo::InvalidDataSource
34
+ false
35
+ end
36
+
37
+ # Seconds the wall clock is ahead of UTC at epoch second `sec`.
38
+ def offset(sec, timezone)
39
+ return Time.at(sec).utc_offset if timezone.nil?
40
+
41
+ get(timezone).period_for_utc(Time.at(sec).utc).utc_total_offset
42
+ end
43
+
44
+ # The wall clock at epoch second `sec`: [year, month, day, hour, minute, second].
45
+ def wall(sec, timezone)
46
+ t = Time.at(sec + offset(sec, timezone)).utc
47
+ [t.year, t.month, t.day, t.hour, t.min, t.sec]
48
+ end
49
+
50
+ def civil_seconds(wall)
51
+ Time.utc(*wall).to_i
52
+ end
53
+
54
+ # Croner's fromTZ: the instant a wall-clock time names. A time that falls in
55
+ # a spring-forward gap is moved forward by the gap; a time that occurs
56
+ # twice (fall back) is the earlier of the two. Returns epoch seconds.
57
+ def to_utc(wall, timezone)
58
+ target = civil_seconds(wall)
59
+ guess = target + (target - civil_seconds(wall(target, timezone)))
60
+ seen = wall(guess, timezone)
61
+ if seen == wall
62
+ earlier = guess - 3600
63
+ return wall(earlier, timezone) == wall ? earlier : guess
64
+ end
65
+
66
+ shifted = guess + target - civil_seconds(seen)
67
+ return shifted if wall(shifted, timezone) == wall
68
+
69
+ [guess, shifted].max
70
+ end
71
+ end
72
+ end
data/lib/cronwatch.rb ADDED
@@ -0,0 +1,96 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ require_relative "cronwatch/version"
6
+ require_relative "cronwatch/js"
7
+ require_relative "cronwatch/types"
8
+ require_relative "cronwatch/duration"
9
+ require_relative "cronwatch/stats"
10
+ require_relative "cronwatch/output"
11
+ require_relative "cronwatch/schedule"
12
+ require_relative "cronwatch/evaluate"
13
+ require_relative "cronwatch/format"
14
+ require_relative "cronwatch/serialize"
15
+ require_relative "cronwatch/job"
16
+ require_relative "cronwatch/http"
17
+ require_relative "cronwatch/stores/memory"
18
+ require_relative "cronwatch/alerts/console"
19
+ require_relative "cronwatch/alerts/custom"
20
+ require_relative "cronwatch/alerts/slack"
21
+ require_relative "cronwatch/alerts/discord"
22
+ require_relative "cronwatch/alerts/webhook"
23
+ require_relative "cronwatch/client"
24
+
25
+ # Cron and scheduled-job monitoring that lives inside your app.
26
+ #
27
+ # CW = Cronwatch.new(alerts: [Cronwatch::Alerts::Slack.new(webhook_url: ENV.fetch("SLACK_WEBHOOK_URL"))])
28
+ # NIGHTLY = CW.job("nightly-report", schedule: "0 2 * * *", grace: "15m")
29
+ # NIGHTLY.run { |job| job.log("Report written") }
30
+ #
31
+ # Or configure one client for the whole app and reach it as Cronwatch.client.
32
+ module Cronwatch
33
+ # The options Cronwatch.configure sets. Anything left unset takes the client's default.
34
+ class Configuration
35
+ OPTIONS = %i[store alerts triage cron_secret retention defaults redact deliver now on_error].freeze
36
+
37
+ attr_accessor(*OPTIONS)
38
+
39
+ def initialize
40
+ @cron_secret = Client::UNSET
41
+ end
42
+
43
+ def to_options
44
+ OPTIONS.each_with_object({}) do |option, out|
45
+ value = public_send(option)
46
+ next if value.nil? && option != :cron_secret
47
+
48
+ out[option] = value
49
+ end
50
+ end
51
+ end
52
+
53
+ @lock = Mutex.new
54
+ @client = nil
55
+
56
+ class << self
57
+ # A new client. See Client#initialize for the options.
58
+ def new(**options)
59
+ Client.new(**options)
60
+ end
61
+
62
+ # Builds the app's client:
63
+ #
64
+ # Cronwatch.configure do |c|
65
+ # c.store = Cronwatch::Stores::ActiveRecord.new
66
+ # c.alerts = [Cronwatch::Alerts::Slack.new(webhook_url: ENV["SLACK_WEBHOOK_URL"])]
67
+ # end
68
+ #
69
+ # Configuring again replaces the client and stops the old one's interval.
70
+ def configure
71
+ config = Configuration.new
72
+ yield config if block_given?
73
+ client = Client.new(**config.to_options)
74
+ previous = @lock.synchronize do
75
+ old = @client
76
+ @client = client
77
+ old
78
+ end
79
+ previous&.stop
80
+ client
81
+ end
82
+
83
+ # The configured client, or one with the defaults if configure was never called.
84
+ def client
85
+ @lock.synchronize { @client ||= Client.new }
86
+ end
87
+
88
+ def client=(client)
89
+ @lock.synchronize { @client = client }
90
+ end
91
+ end
92
+ end
93
+
94
+ # In a Rails app Bundler requires gems after Rails itself, so `gem "cronwatch"`
95
+ # alone brings in the Railtie, the ActiveJob concern and the generator.
96
+ require_relative "cronwatch/rails" if defined?(::Rails::Railtie)
@@ -0,0 +1,176 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/generators"
4
+ require "rails/generators/active_record"
5
+ require "cronwatch/active_record"
6
+
7
+ module Cronwatch
8
+ module Generators
9
+ # bin/rails generate cronwatch:install
10
+ #
11
+ # Writes a migration for the three tables the ActiveRecord store uses
12
+ # (created with the SDK's own DDL, so a Node process can share them) and
13
+ # config/initializers/cronwatch.rb, then prints how to schedule
14
+ # Cronwatch::CheckJob and mount the dashboard. The templates live in this
15
+ # file so the gem ships nothing but Ruby.
16
+ class InstallGenerator < ::Rails::Generators::Base
17
+ include ::ActiveRecord::Generators::Migration
18
+
19
+ desc "Creates the CronWatch migration and initializer."
20
+
21
+ class_option :prefix, type: :string, default: Cronwatch::Stores::ActiveRecord::DEFAULT_PREFIX,
22
+ desc: "Table name prefix: lowercase letters, digits and underscores"
23
+ class_option :database, type: :string, aliases: %i[--db],
24
+ desc: "The database for the migration, in an app with several"
25
+
26
+ def check_prefix
27
+ Cronwatch::Stores::ActiveRecord.table_prefix(options[:prefix])
28
+ rescue ArgumentError => e
29
+ raise ::Thor::Error, e.message
30
+ end
31
+
32
+ def create_migration_file
33
+ dir = db_migrate_path
34
+ absolute = File.join(destination_root, dir)
35
+ if (existing = self.class.migration_exists?(absolute, migration_name))
36
+ say_status :exist, existing.delete_prefix("#{destination_root}/"), :blue
37
+ return
38
+ end
39
+
40
+ number = self.class.next_migration_number(absolute)
41
+ create_file File.join(dir, "#{number}_#{migration_name}.rb"), migration
42
+ end
43
+
44
+ def create_initializer
45
+ create_file "config/initializers/cronwatch.rb", initializer
46
+ end
47
+
48
+ def show_next_steps
49
+ say next_steps
50
+ end
51
+
52
+ private
53
+
54
+ # create_cronwatch_tables, or with a prefix create_cronwatch_ops_tables,
55
+ # so each prefix gets a migration of its own.
56
+ def migration_name
57
+ prefix = options[:prefix]
58
+ return "create_cronwatch_tables" if prefix == Cronwatch::Stores::ActiveRecord::DEFAULT_PREFIX
59
+
60
+ "create_cronwatch_#{prefix.squeeze("_").delete_prefix("_").delete_suffix("_")}_tables"
61
+ end
62
+
63
+ def store_args
64
+ prefix = options[:prefix]
65
+ prefix == Cronwatch::Stores::ActiveRecord::DEFAULT_PREFIX ? "" : "(prefix: #{prefix.inspect})"
66
+ end
67
+
68
+ def migration
69
+ <<~RUBY
70
+ # frozen_string_literal: true
71
+
72
+ require "cronwatch/active_record"
73
+
74
+ # The tables CronWatch keeps jobs, runs and alert state in. They are
75
+ # created with the SDK's own statements, so a Node process using
76
+ # @cronwatch/sdk can share them.
77
+ class #{migration_name.camelize} < ActiveRecord::Migration[#{::ActiveRecord::Migration.current_version}]
78
+ def up
79
+ Cronwatch::Stores::ActiveRecord.create_tables!(connection, prefix: #{options[:prefix].inspect})
80
+ end
81
+
82
+ def down
83
+ Cronwatch::Stores::ActiveRecord.drop_tables!(connection, prefix: #{options[:prefix].inspect})
84
+ end
85
+ end
86
+ RUBY
87
+ end
88
+
89
+ def initializer
90
+ <<~RUBY
91
+ # frozen_string_literal: true
92
+
93
+ # CronWatch: told when a scheduled job is missed, failed, stuck, slow or
94
+ # over budget. https://cronwatch.dev/docs/
95
+ #
96
+ # Monitor a job by including Cronwatch::ActiveJob and declaring its schedule:
97
+ #
98
+ # class NightlyReportJob < ApplicationJob
99
+ # include Cronwatch::ActiveJob
100
+ # cronwatch schedule: "0 2 * * *", grace: "15m"
101
+ # end
102
+ #
103
+ # and run Cronwatch::CheckJob every few minutes to catch the runs that never happen.
104
+ Cronwatch.configure do |c|
105
+ # Jobs, runs and alert state, in this app's database.
106
+ c.store = Cronwatch::Stores::ActiveRecord.new#{store_args}
107
+
108
+ # Where alerts go. With none set, they are written to standard error.
109
+ c.alerts = [
110
+ (Cronwatch::Alerts::Slack.new(webhook_url: ENV["SLACK_WEBHOOK_URL"]) if ENV["SLACK_WEBHOOK_URL"].present?),
111
+ # Cronwatch::Alerts::Discord.new(webhook_url: ENV["DISCORD_WEBHOOK_URL"]),
112
+ # Cronwatch::Alerts::Webhook.new(url: ENV["CRONWATCH_WEBHOOK_URL"], secret: ENV["CRONWATCH_WEBHOOK_SECRET"]),
113
+ ].compact.presence
114
+
115
+ # How long finished runs are kept. Each job's newest run is always kept.
116
+ # c.retention = "30d"
117
+
118
+ # Applied to every job that does not set its own.
119
+ # c.defaults = { grace: "10m", timezone: "Europe/London", failures_before_alert: 1 }
120
+
121
+ # Called with (error, where) when the store, a channel or triage fails. Default: Rails.logger.
122
+ # c.on_error = ->(error, where) { Rails.error.report(error, handled: true, context: { cronwatch: where }) }
123
+
124
+ # The bearer secret the dashboard's check endpoint takes. Default: ENV["CRON_SECRET"].
125
+ # c.cron_secret = Rails.application.credentials.cron_secret
126
+ end
127
+ RUBY
128
+ end
129
+
130
+ def next_steps
131
+ <<~TEXT
132
+
133
+ CronWatch is installed. Next:
134
+
135
+ 1. Create the tables:
136
+
137
+ bin/rails db:migrate
138
+
139
+ 2. Monitor a job:
140
+
141
+ class NightlyReportJob < ApplicationJob
142
+ include Cronwatch::ActiveJob
143
+ cronwatch schedule: "0 2 * * *", grace: "15m" # name: "nightly-report"
144
+ end
145
+
146
+ 3. Run Cronwatch::CheckJob every 5 minutes, from one scheduler only. It notices
147
+ the runs that never happen; two checkers would send each alert twice.
148
+
149
+ Solid Queue, in config/recurring.yml:
150
+
151
+ production:
152
+ cronwatch_check:
153
+ class: Cronwatch::CheckJob
154
+ schedule: every 5 minutes
155
+
156
+ sidekiq-cron, in config/schedule.yml:
157
+
158
+ cronwatch_check:
159
+ cron: "*/5 * * * *"
160
+ class: "Cronwatch::CheckJob"
161
+
162
+ Or from a crontab: bin/rails cronwatch:check
163
+
164
+ 4. Mount the dashboard in config/routes.rb:
165
+
166
+ mount Cronwatch::Web.new(Cronwatch.client) => "/cronwatch"
167
+
168
+ Outside development it needs CRONWATCH_TOKEN set to sign in. In
169
+ development, without one, the server prints a sign-in link on
170
+ the dashboard's first request.
171
+
172
+ TEXT
173
+ end
174
+ end
175
+ end
176
+ end