cronwatch 0.12.4 → 0.13.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.
@@ -3,11 +3,12 @@
3
3
  require "base64"
4
4
  require "digest"
5
5
  require "securerandom"
6
+ require "stringio"
6
7
  require "uri"
7
8
 
8
9
  module Cronwatch
9
10
  # The dashboard and the small JSON API, as a Rack app: the SDK's routes
10
- # (routes/index.ts) with the same URLs, JSON, auth, CSRF rules and headers,
11
+ # (routes/index.ts) with the same URLs, JSON, auth, CSRF rules, and headers,
11
12
  # so @cronwatch/mcp works against a Ruby app as it does against a Node one.
12
13
  #
13
14
  # Made by Client#routes, the one way to mount it:
@@ -24,12 +25,13 @@ module Cronwatch
24
25
  # token: required to reach anything. Send it as `Authorization: Bearer <token>`,
25
26
  # or open the dashboard once with `?token=<token>` (or sign in with the
26
27
  # form, which posts it to <base>/signin) and a cookie is set.
27
- # Defaults to ENV["CRONWATCH_TOKEN"]; an empty string or one of only
28
+ # Defaults to ENV["CRONWATCH_TOKEN"]. It is trimmed of the whitespace
29
+ # around it; an empty string or one of only
28
30
  # whitespace, given here or in the variable, counts as unset, and
29
31
  # anything but a String or nil (false, a number, a Symbol) raises TypeError.
30
- # With no token while Cronwatch::Environment is development or
31
- # test (Environment.stated_development?: under Puma, Unicorn,
32
- # Thin or rackup, which set RACK_ENV=development by default,
32
+ # With no token while Cronwatch::Environment is development
33
+ # (Environment.stated_development?: under Puma, Unicorn,
34
+ # Thin, or rackup, which set RACK_ENV=development by default,
33
35
  # RACK_ENV alone does not count), the app makes a random one and
34
36
  # prints a sign-in link to stdout on its first request; with no
35
37
  # token otherwise it answers 503. Pass `token: nil` to opt out and serve it open everywhere,
@@ -39,18 +41,24 @@ module Cronwatch
39
41
  # mount point (SCRIPT_NAME), which is right under Rails' `mount`.
40
42
  # origin: the public origin the dashboard is served from, such as
41
43
  # "https://app.example.com". By default the request's own origin
42
- # is used, as Rack reads it (Rack::Request#base_url, which follows
43
- # X-Forwarded-Proto and X-Forwarded-Host the way Rails does). Set
44
- # this to pin it, for an app behind a proxy that does not set
45
- # those headers or one that should not trust them. Used in place
46
- # of the request's origin for the cross-site check on writes, the
47
- # sign-in cookie's Secure flag, the Referer the redirect back after
48
- # a form follows, and the development sign-in line. Read as
44
+ # is used: rack.url_scheme and the Host header, or the forwarded
45
+ # ones under trust_proxy. Set this to pin it. Used in place of the
46
+ # request's origin for the cross-site check on writes, the sign-in
47
+ # cookie's Secure flag, the Referer the redirect back after a form
48
+ # follows, and the development sign-in line. Read as
49
49
  # `new URL(value).origin` reads it (Web::Origin) and normalised to
50
50
  # scheme://host[:port]; anything that is not an absolute http or
51
51
  # https URL, or has a port outside 1 to 65535, raises
52
- # ArgumentError here. "" counts as unset. Behind more than one
53
- # proxy set it: Rack takes the last of several forwarded values.
52
+ # ArgumentError here. "" counts as unset. Takes precedence over
53
+ # trust_proxy.
54
+ # trust_proxy: take the public origin from X-Forwarded-Proto and
55
+ # X-Forwarded-Host (the first value of each, falling back to the
56
+ # request's own scheme or host for whichever is missing) when a
57
+ # request carries either, as the SDK's trustProxy does. Only for
58
+ # an app whose proxy sets or overwrites both headers: a client can
59
+ # send them too. Defaults to true when Rails is loaded, since Rails
60
+ # itself trusts them (ActionDispatch::Request#base_url), and to
61
+ # false otherwise, as in the SDK.
54
62
  class Web
55
63
  # Tells "token not given" (read CRONWATCH_TOKEN) from "token: nil" (open on purpose).
56
64
  UNSET = Object.new.freeze
@@ -62,7 +70,7 @@ module Cronwatch
62
70
  COOKIE_MAX_AGE = 60 * 60 * 24 * 30
63
71
 
64
72
  # 'self' only for what the app shell needs: app.js (which registers the
65
- # service worker and nothing else), the manifest, the worker and the icons.
73
+ # service worker and nothing else), the manifest, the worker, and the icons.
66
74
  # No inline script, and the pages work without any.
67
75
  CSP = "default-src 'none'; script-src 'self'; style-src 'unsafe-inline'; img-src 'self' data:; " \
68
76
  "manifest-src 'self'; worker-src 'self'; form-action 'self'; frame-ancestors 'none'; base-uri 'none'"
@@ -103,24 +111,41 @@ module Cronwatch
103
111
  app
104
112
  end
105
113
 
106
- def initialize(client = nil, token: UNSET, base_path: nil, origin: nil)
114
+ def initialize(client = nil, token: UNSET, base_path: nil, origin: nil, trust_proxy: UNSET)
107
115
  @client = client
108
116
  @origin = Web.configured_origin(origin)
117
+ @trust_proxy = trust_proxy.equal?(UNSET) ? Web.rails? : trust_proxy == true
109
118
  @opted_out = token.nil?
110
119
  # A blank token, given or read, counts as unset; one given that is not a String raises.
111
120
  given = token.equal?(UNSET) ? nil : Environment.secret_option(token, "routes: token")
112
121
  @token = @opted_out ? nil : given || Environment.secret("CRONWATCH_TOKEN")
113
122
  @base_path = base_path&.to_s&.sub(%r{/+\z}, "")
114
123
  # A Rack app cannot reliably tell a local caller from a remote one
115
- # (proxies, tunnels and a server bound to every interface all look
124
+ # (proxies, tunnels, and a server bound to every interface all look
116
125
  # alike), so development gets a token too: made here, and shown only in
117
126
  # the server log.
118
127
  @generated = @token.nil? && !@opted_out && Environment.stated_development?
119
128
  @token = Web.development_token if @generated
129
+ Web.warn_short_token(@token) unless @generated
120
130
  @announced = false
121
131
  @announce_lock = Mutex.new
122
132
  end
123
133
 
134
+ # Under this many characters a token could be guessed one request at a
135
+ # time, since nothing slows down wrong guesses.
136
+ SHORT_TOKEN = 32
137
+ private_constant :SHORT_TOKEN
138
+
139
+ # Warns, once per app, when the dashboard's token is short enough to guess.
140
+ #
141
+ # @api private
142
+ def self.warn_short_token(token)
143
+ return if token.nil? || token.length >= SHORT_TOKEN
144
+
145
+ warn "[cronwatch] the dashboard token is #{token.length} characters; use #{SHORT_TOKEN} or more random characters, " \
146
+ "such as the output of `ruby -rsecurerandom -e 'puts SecureRandom.urlsafe_base64(32)'`"
147
+ end
148
+
124
149
  # A token for one app in development, when none is configured: 32 random
125
150
  # bytes, base64url (43 characters).
126
151
  def self.development_token
@@ -129,7 +154,7 @@ module Cronwatch
129
154
 
130
155
  # The line a development token is announced with, printed once to stdout
131
156
  # on the app's first request. `origin` is the `origin:` option when set,
132
- # otherwise that request's origin (scheme, host and any port) when its
157
+ # otherwise that request's origin (scheme, host, and any port) when its
133
158
  # host is loopback, and nil for any other host: the request's host is
134
159
  # the client's to choose, so the line then leaves it out rather than
135
160
  # point the link, token and all, somewhere else. `base` is the base path
@@ -168,6 +193,11 @@ module Cronwatch
168
193
  Origin.parse(value)
169
194
  end
170
195
 
196
+ # Whether the app runs under Rails, which trusts forwarded headers by default.
197
+ def self.rails?
198
+ defined?(::Rails::Railtie) ? true : false
199
+ end
200
+
171
201
  # The client given, or Cronwatch.client when none was.
172
202
  def client
173
203
  @client || Cronwatch.client
@@ -203,7 +233,7 @@ module Cronwatch
203
233
 
204
234
  announce(request, base) if @generated && !@announced
205
235
 
206
- # The app shell: the manifest, icons, service worker, app.js and the
236
+ # The app shell: the manifest, icons, service worker, app.js, and the
207
237
  # offline page. Served to anyone, since a browser fetches some of it
208
238
  # without cookies and none of it says anything about the jobs.
209
239
  if %w[GET HEAD].include?(method)
@@ -230,7 +260,9 @@ module Cronwatch
230
260
  # The sign-in form posts the token here, in the body, so it stays out of
231
261
  # the URL and every access log. Cross-site posts were refused above.
232
262
  if @token && method == "POST" && path == "/signin"
233
- sent = read_body(request)["token"]
263
+ body = read_body(request)
264
+ # A body too large is no token at all.
265
+ sent = body.equal?(TOO_LARGE) ? nil : body["token"]
234
266
  return sign_in_page(base) if sent.nil? || !HTTP.constant_time_equal?(sent, @token)
235
267
 
236
268
  return redirect(sign_in_return(request.header("referer"), public_origin(request), base),
@@ -260,7 +292,10 @@ module Cronwatch
260
292
  # Move the token from the URL into a cookie, so it is not left in the
261
293
  # browser's history. The request line that carried it may still be in
262
294
  # an access log, which is why the sign-in form posts instead.
263
- return redirect(request.pathname + request.search_without("token"), "set-cookie" => sign_in_cookie(request, base))
295
+ # One leading slash: "//host/..." would be a protocol-relative Location,
296
+ # off this site, and a browser reads a backslash there as a slash.
297
+ return redirect(request.pathname.sub(%r{\A[/\\]+}, "/") + request.search_without("token"),
298
+ "set-cookie" => sign_in_cookie(request, base))
264
299
  end
265
300
  end
266
301
 
@@ -301,8 +336,11 @@ module Cronwatch
301
336
  return html(HTML.message_page("No such job", "#{name} is not in the store.", base), 404) unless cw.job_summary(name)
302
337
 
303
338
  if action == "silence"
339
+ body = read_body(request)
340
+ return html(HTML.message_page("Not silenced", "The request was too large.", base), 413) if body.equal?(TOO_LARGE)
341
+
304
342
  begin
305
- duration = silence_duration(read_body(request)["for"])
343
+ duration = silence_duration(body["for"])
306
344
  rescue ArgumentError => e
307
345
  return html(HTML.message_page("Not silenced", e.message, base), 400)
308
346
  end
@@ -346,6 +384,8 @@ module Cronwatch
346
384
 
347
385
  if rest[2] == "silence"
348
386
  body = read_body(request)
387
+ return api({ ok: false, error: "Request body too large" }, 413) if body.equal?(TOO_LARGE)
388
+
349
389
  begin
350
390
  duration = silence_duration(body.key?("for") ? body["for"] : request.query("for"))
351
391
  rescue ArgumentError => e
@@ -399,7 +439,8 @@ module Cronwatch
399
439
  end
400
440
  return unless first
401
441
 
402
- shown = @origin || (Web.loopback_origin?(request.origin) ? request.origin : nil)
442
+ own = request_origin(request)
443
+ shown = @origin || (Web.loopback_origin?(own) ? own : nil)
403
444
  $stdout.puts(Web.development_sign_in_line(shown, base, @token))
404
445
  $stdout.flush
405
446
  end
@@ -477,17 +518,26 @@ module Cronwatch
477
518
  !site.nil? && site != "same-origin" && site != "none"
478
519
  end
479
520
 
521
+ # A request body past Request::MAX_BODY, which read_body answers with.
522
+ TOO_LARGE = Object.new.freeze
523
+ private_constant :TOO_LARGE
524
+
480
525
  # The form fields or JSON object of a request, each value as String(value)
481
- # gives it in JavaScript. The body is read as the SDK's request.json()
482
- # reads it: bytes that are not UTF-8 become U+FFFD, and a leading byte
483
- # order mark is dropped.
526
+ # gives it in JavaScript, or TOO_LARGE for a body past Request::MAX_BODY.
527
+ # The body is read as the SDK's request.json() reads it: bytes that are
528
+ # not UTF-8 become U+FFFD, and a leading byte order mark is dropped.
484
529
  def read_body(request)
485
530
  type = request.header("content-type") || ""
486
- if type.include?("application/json")
531
+ json = type.include?("application/json")
532
+ form = type.include?("application/x-www-form-urlencoded") || type.include?("multipart/form-data")
533
+ return {} unless json || form
534
+ return TOO_LARGE if request.too_large?
535
+
536
+ if json
487
537
  data = JSON.parse(Output.utf8(request.body).delete_prefix(""))
488
538
  return data.to_h { |k, v| [k.to_s, js_string(v)] } if data.is_a?(Hash)
489
539
  return data.each_with_index.to_h { |v, i| [i.to_s, js_string(v)] } if data.is_a?(Array)
490
- elsif type.include?("application/x-www-form-urlencoded") || type.include?("multipart/form-data")
540
+ else
491
541
  return request.form.transform_values { |v| form_value(v) }
492
542
  end
493
543
  {}
@@ -547,7 +597,12 @@ module Cronwatch
547
597
 
548
598
  # The origin a browser sees: the `origin` option when set, otherwise the request's own.
549
599
  def public_origin(request)
550
- @origin || request.origin
600
+ @origin || request_origin(request)
601
+ end
602
+
603
+ # The request's origin: the forwarded one under trust_proxy, else its own.
604
+ def request_origin(request)
605
+ @trust_proxy ? request.forwarded_origin : request.origin
551
606
  end
552
607
 
553
608
  def api(body, status = 200, headers = {})
@@ -607,11 +662,35 @@ module Cronwatch
607
662
  @env[key]
608
663
  end
609
664
 
610
- # scheme://host[:port], the page's origin, lowercased as a browser
611
- # writes it in Origin and Referer (Rack's base_url keeps the case of
612
- # the Host or X-Forwarded-Host it read).
665
+ # scheme://host[:port], the page's origin as the request URL gives it
666
+ # (rack.url_scheme and the Host header, never a forwarded header),
667
+ # normalised as a browser writes it in Origin and Referer.
613
668
  def origin
614
- @rack.base_url.downcase
669
+ scheme = @env["rack.url_scheme"].to_s.downcase
670
+ scheme = "http" unless %w[http https].include?(scheme)
671
+ host = @env["HTTP_HOST"].to_s
672
+ host = [@env["SERVER_NAME"], @env["SERVER_PORT"]].compact.join(":") if host.empty?
673
+ Origin.bare("#{scheme}://#{host}") || "#{scheme}://#{host.downcase}"
674
+ end
675
+
676
+ # With trust_proxy: the first X-Forwarded-Proto and X-Forwarded-Host,
677
+ # each falling back to the request's own, when either is present and
678
+ # well formed; otherwise the request's own origin. The SDK's forwardedOrigin.
679
+ def forwarded_origin
680
+ own = origin
681
+ proto = Request.first_value(header("x-forwarded-proto"))&.downcase
682
+ host = Request.first_value(header("x-forwarded-host"))
683
+ return own if proto.nil? && host.nil?
684
+ return own unless proto.nil? || %w[http https].include?(proto)
685
+
686
+ scheme, authority = own.split("://", 2)
687
+ Origin.bare("#{proto || scheme}://#{host || authority}") || own
688
+ end
689
+
690
+ # The first of a header's comma-separated values, trimmed, or nil.
691
+ def self.first_value(value)
692
+ first = value&.split(",", 2)&.first&.strip
693
+ first.nil? || first.empty? ? nil : first
615
694
  end
616
695
 
617
696
  # URLSearchParams#get: the first value, or nil.
@@ -627,12 +706,24 @@ module Cronwatch
627
706
  pairs.empty? ? "" : "?#{URI.encode_www_form(pairs)}"
628
707
  end
629
708
 
709
+ # The most of a request body the routes read (1 MiB), as every port's.
710
+ MAX_BODY = 1 << 20
711
+
712
+ # Whether the body is past MAX_BODY: by its Content-Length when that
713
+ # says more, else by reading one byte past the cap at most, so a client
714
+ # cannot make the server hold a body of any size. The forms and JSON the
715
+ # routes take are a few bytes. What was read is left to be read again.
716
+ def too_large?
717
+ @too_large = measure_too_large if @too_large.nil?
718
+ @too_large
719
+ end
720
+
630
721
  def body
631
722
  input = @env["rack.input"]
632
723
  return "" unless input
633
724
 
634
725
  input.rewind if input.respond_to?(:rewind)
635
- text = input.read.to_s
726
+ text = input.read(MAX_BODY + 1).to_s
636
727
  input.rewind if input.respond_to?(:rewind)
637
728
  text.force_encoding(Encoding::UTF_8)
638
729
  end
@@ -645,6 +736,30 @@ module Cronwatch
645
736
  fields.each_with_object({}) { |(k, v), out| out[Output.utf8(k)] = v }
646
737
  end
647
738
 
739
+ private
740
+
741
+ def measure_too_large
742
+ length = @env["CONTENT_LENGTH"].to_s
743
+ return true if length.match?(/\A\d+\z/) && length.to_i > MAX_BODY
744
+
745
+ input = @env["rack.input"]
746
+ return false unless input
747
+
748
+ input.rewind if input.respond_to?(:rewind)
749
+ read = input.read(MAX_BODY + 1).to_s
750
+ return true if read.bytesize > MAX_BODY
751
+
752
+ # An input that cannot be rewound is handed on as what was read.
753
+ if input.respond_to?(:rewind)
754
+ input.rewind
755
+ else
756
+ @env["rack.input"] = StringIO.new(read)
757
+ end
758
+ false
759
+ end
760
+
761
+ public
762
+
648
763
  # application/x-www-form-urlencoded parsing as URLSearchParams does it:
649
764
  # "+" is a space, and a bad escape is kept as written rather than refused.
650
765
  def self.parse_query(text)
@@ -10,7 +10,7 @@ module Cronwatch
10
10
  # person reads, a mono for what a machine printed, neutral greys, and
11
11
  # colour only for the states CronWatch reports. The page loads nothing but
12
12
  # its own app shell (its CSP is default-src 'none' plus 'self' for the
13
- # script, the manifest, the worker and images), so the fonts are system
13
+ # script, the manifest, the worker, and images), so the fonts are system
14
14
  # stacks that echo the site's Newsreader and IBM Plex Mono, and use them
15
15
  # when they are installed.
16
16
  #
@@ -251,7 +251,7 @@ module Cronwatch
251
251
  end
252
252
 
253
253
  # A page. `base` is where the dashboard is mounted ("" at the root): the
254
- # head links the web app manifest, the icons and app.js, the one script,
254
+ # head links the web app manifest, the icons, and app.js, the one script,
255
255
  # which registers the service worker and toggles the theme (Web::PWA). Everything works
256
256
  # without it.
257
257
  def layout(title, body, base, refresh: nil)
@@ -42,7 +42,7 @@ module Cronwatch
42
42
  end
43
43
 
44
44
  # scheme://host[:port] for text that is a scheme and a bare host, or nil
45
- # when it carries credentials, a path, a query or a fragment, or is not
45
+ # when it carries credentials, a path, a query, or a fragment, or is not
46
46
  # an http or https URL.
47
47
  def bare(value)
48
48
  return nil unless value.is_a?(String)
@@ -61,7 +61,7 @@ module Cronwatch
61
61
  end
62
62
 
63
63
  # Whether what follows the host would show in the URL: a path other
64
- # than "/", a query or a fragment.
64
+ # than "/", a query, or a fragment.
65
65
  def past_host?(after)
66
66
  path, hash, fragment = after.partition("#")
67
67
  path, _, query = path.partition("?")
@@ -4,7 +4,7 @@ module Cronwatch
4
4
  class Web
5
5
  # What makes the dashboard an installable web app, as the SDK's
6
6
  # routes/pwa.ts has it: a manifest, icons, a service worker, the script
7
- # that registers it and a page to show offline. None of it says anything
7
+ # that registers it, and a page to show offline. None of it says anything
8
8
  # about the jobs, so it is served without the token (a browser fetches the
9
9
  # manifest and icons without cookies in some flows).
10
10
  #
@@ -46,7 +46,7 @@ module Cronwatch
46
46
  JS
47
47
 
48
48
  # The service worker. It caches the app shell (the offline page, the
49
- # manifest, the icons and app.js) and nothing else: every other request
49
+ # manifest, the icons, and app.js) and nothing else: every other request
50
50
  # goes to the network as the page made it, and its answer is never
51
51
  # stored, since the pages and the JSON carry job data. When a page
52
52
  # cannot be reached it shows the offline page. Its scope gives the base,
@@ -95,7 +95,7 @@ module Cronwatch
95
95
  });
96
96
  JS
97
97
 
98
- # One app shell file: its type, body and Cache-Control, and whether it
98
+ # One app shell file: its type, body, and Cache-Control, and whether it
99
99
  # is the service worker (which may control everything under the base).
100
100
  Asset = Struct.new(:type, :body, :cache, :worker, keyword_init: true)
101
101
 
@@ -120,7 +120,7 @@ module Cronwatch
120
120
  id: "#{base}/",
121
121
  name: "CronWatch",
122
122
  short_name: "CronWatch",
123
- description: "The scheduled jobs of this app: their health, their last day and their runs.",
123
+ description: "The scheduled jobs of this app: their health, their last day, and their runs.",
124
124
  start_url: "#{base}/",
125
125
  scope: "#{base}/",
126
126
  display: "standalone",
@@ -239,11 +239,11 @@ module Cronwatch
239
239
 
240
240
  if open?(job, :over_budget) && last
241
241
  over = over_ceilings(job)
242
- return "went over budget#{over.empty? ? "" : " on #{over.join(" and ")}"} at #{when_at(last.started_at, now)}"
242
+ return "went over budget#{over.empty? ? "" : " on #{Format.and_list(over)}"} at #{when_at(last.started_at, now)}"
243
243
  end
244
244
  if open?(job, :under_floor) && last
245
245
  under = under_floors(job)
246
- return "fell short#{under.empty? ? "" : " on #{under.join(" and ")}"} at #{when_at(last.started_at, now)}"
246
+ return "fell short#{under.empty? ? "" : " on #{Format.and_list(under)}"} at #{when_at(last.started_at, now)}"
247
247
  end
248
248
  return "slow: took #{Duration.format(last.duration_ms)}" if open?(job, :slow) && !last&.duration_ms.nil?
249
249
  return "failing" if open?(job, :failed)
@@ -405,7 +405,7 @@ module Cronwatch
405
405
  [box.call("run ok"), "ran"],
406
406
  [box.call("run bad"), "failed"],
407
407
  [box.call("run timeout"), "timed out"],
408
- [box.call("run warn"), "over budget, under floor or slow"],
408
+ [box.call("run warn"), "over budget, under floor, or slow"],
409
409
  [box.call("run running"), "running"],
410
410
  [box.call("missed"), "missed"],
411
411
  ]
data/lib/cronwatch.rb CHANGED
@@ -110,5 +110,5 @@ module Cronwatch
110
110
  end
111
111
 
112
112
  # In a Rails app Bundler requires gems after Rails itself, so `gem "cronwatch"`
113
- # alone brings in the Railtie, the ActiveJob concern and the generator.
113
+ # alone brings in the Railtie, the ActiveJob concern, and the generator.
114
114
  require_relative "cronwatch/rails" if defined?(::Rails::Railtie)
@@ -22,7 +22,7 @@ module Cronwatch
22
22
  desc "Creates the CronWatch migration and initializer."
23
23
 
24
24
  class_option :prefix, type: :string, default: Cronwatch::Stores::ActiveRecord::DEFAULT_PREFIX,
25
- desc: "Table name prefix: lowercase letters, digits and underscores"
25
+ desc: "Table name prefix: lowercase letters, digits, and underscores"
26
26
  class_option :database, type: :string, aliases: %i[--db],
27
27
  desc: "The database for the tables, in an app with several (from config/database.yml)"
28
28
 
@@ -107,7 +107,7 @@ module Cronwatch
107
107
 
108
108
  require "cronwatch/active_record"
109
109
 
110
- # The tables CronWatch keeps jobs, runs and alert state in. They are
110
+ # The tables CronWatch keeps jobs, runs, and alert state in. They are
111
111
  # created with the SDK's own statements, so a Node process using
112
112
  # @cronwatch/sdk can share them.
113
113
  class #{migration_name.camelize} < ActiveRecord::Migration[#{::ActiveRecord::Migration.current_version}]
@@ -127,7 +127,7 @@ module Cronwatch
127
127
  # frozen_string_literal: true
128
128
 
129
129
  # CronWatch: told when a scheduled job is missed, failed, stuck, slow,
130
- # over budget or under its floor. https://cronwatch.dev/docs/
130
+ # over budget, or under its floor. https://cronwatch.dev/docs/
131
131
  #
132
132
  # Monitor a job by including Cronwatch::ActiveJob and declaring its schedule:
133
133
  #
@@ -138,7 +138,7 @@ module Cronwatch
138
138
  #
139
139
  # and run Cronwatch::CheckJob every few minutes to catch the runs that never happen.
140
140
  Cronwatch.configure do |c|
141
- # Jobs, runs and alert state, in #{database_name ? "the #{database_name} database (see app/models/cronwatch_record.rb)" : "this app's database"}.
141
+ # Jobs, runs, and alert state, in #{database_name ? "the #{database_name} database (see app/models/cronwatch_record.rb)" : "this app's database"}.
142
142
  c.store = Cronwatch::Stores::ActiveRecord.new#{store_args}
143
143
 
144
144
  # Where alerts go. With none set, they are written to standard error.
@@ -154,7 +154,7 @@ module Cronwatch
154
154
  # Applied to every job that does not set its own.
155
155
  # c.defaults = { grace: "10m", timezone: "Europe/London", failures_before_alert: 1 }
156
156
 
157
- # Called with (error, where) when the store, a channel or triage fails. Default: Rails.logger.
157
+ # Called with (error, where) when the store, a channel, or triage fails. Default: Rails.logger.
158
158
  # c.on_error = ->(error, where) { Rails.error.report(error, handled: true, context: { cronwatch: where }) }
159
159
 
160
160
  # The bearer secret the dashboard's check endpoint takes. Default: ENV["CRON_SECRET"].
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: cronwatch
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.12.4
4
+ version: 0.13.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Jon C. Phillips
@@ -24,8 +24,8 @@ dependencies:
24
24
  - !ruby/object:Gem::Version
25
25
  version: '1.11'
26
26
  description: 'Wrap a job, run a check, get told when it is missed, failed, stuck,
27
- slow or over budget. The Ruby port of @cronwatch/sdk: same rules, same alerts, same
28
- stored rows.'
27
+ slow, or over budget. The Ruby port of @cronwatch/sdk: same rules, same alerts,
28
+ same stored rows.'
29
29
  executables: []
30
30
  extensions: []
31
31
  extra_rdoc_files: []