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.
- checksums.yaml +7 -0
- data/LICENSE +21 -0
- data/README.md +268 -0
- data/lib/cronwatch/abort_signal.rb +45 -0
- data/lib/cronwatch/active_record.rb +11 -0
- data/lib/cronwatch/alerts/console.rb +22 -0
- data/lib/cronwatch/alerts/custom.rb +26 -0
- data/lib/cronwatch/alerts/discord.rb +52 -0
- data/lib/cronwatch/alerts/slack.rb +58 -0
- data/lib/cronwatch/alerts/webhook.rb +46 -0
- data/lib/cronwatch/client.rb +925 -0
- data/lib/cronwatch/cron_pattern.rb +277 -0
- data/lib/cronwatch/duration.rb +77 -0
- data/lib/cronwatch/environment.rb +29 -0
- data/lib/cronwatch/evaluate.rb +345 -0
- data/lib/cronwatch/flight.rb +42 -0
- data/lib/cronwatch/format.rb +89 -0
- data/lib/cronwatch/http.rb +52 -0
- data/lib/cronwatch/job.rb +144 -0
- data/lib/cronwatch/js.rb +188 -0
- data/lib/cronwatch/monitored.rb +259 -0
- data/lib/cronwatch/output.rb +199 -0
- data/lib/cronwatch/rails/active_job.rb +55 -0
- data/lib/cronwatch/rails/check_job.rb +32 -0
- data/lib/cronwatch/rails/railtie.rb +37 -0
- data/lib/cronwatch/rails/tasks.rb +12 -0
- data/lib/cronwatch/rails.rb +35 -0
- data/lib/cronwatch/schedule.rb +191 -0
- data/lib/cronwatch/scheduler.rb +763 -0
- data/lib/cronwatch/serialize.rb +51 -0
- data/lib/cronwatch/sidekiq.rb +129 -0
- data/lib/cronwatch/stats.rb +23 -0
- data/lib/cronwatch/stores/active_record.rb +397 -0
- data/lib/cronwatch/stores/memory.rb +163 -0
- data/lib/cronwatch/ticker.rb +59 -0
- data/lib/cronwatch/triage/anthropic.rb +134 -0
- data/lib/cronwatch/types.rb +341 -0
- data/lib/cronwatch/version.rb +6 -0
- data/lib/cronwatch/walker.rb +137 -0
- data/lib/cronwatch/web/app.rb +484 -0
- data/lib/cronwatch/web/html.rb +314 -0
- data/lib/cronwatch/web.rb +17 -0
- data/lib/cronwatch/zone.rb +72 -0
- data/lib/cronwatch.rb +96 -0
- data/lib/generators/cronwatch/install/install_generator.rb +176 -0
- metadata +104 -0
data/lib/cronwatch/js.rb
ADDED
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Cronwatch
|
|
4
|
+
# The few places JavaScript and Ruby disagree about text and numbers,
|
|
5
|
+
# settled the JavaScript way. The SDK writes the stored rows, alert text and
|
|
6
|
+
# webhook bodies, so the gem reproduces them byte for byte: Math.round,
|
|
7
|
+
# String(number), JSON.stringify, String.prototype.trim and lengths counted
|
|
8
|
+
# in UTF-16 code units.
|
|
9
|
+
module JS
|
|
10
|
+
# What JavaScript's \s and trim() treat as whitespace.
|
|
11
|
+
WHITESPACE = "\\t\\n\\v\\f\\r \\u00a0\\u1680\\u2000-\\u200a\\u2028\\u2029\\u202f\\u205f\\u3000\\ufeff"
|
|
12
|
+
SPACE = Regexp.new("[#{WHITESPACE}]")
|
|
13
|
+
SPACES = Regexp.new("[#{WHITESPACE}]+")
|
|
14
|
+
LEADING = Regexp.new("\\A[#{WHITESPACE}]+")
|
|
15
|
+
TRAILING = Regexp.new("[#{WHITESPACE}]+\\z")
|
|
16
|
+
NOT_SPACE = Regexp.new("[^#{WHITESPACE}]+")
|
|
17
|
+
|
|
18
|
+
ESCAPES = { '"' => '\\"', "\\" => "\\\\", "\b" => "\\b", "\f" => "\\f", "\n" => "\\n", "\r" => "\\r", "\t" => "\\t" }.freeze
|
|
19
|
+
# An array index is a canonical integer below 2**32 - 1; JavaScript lists those keys first.
|
|
20
|
+
INDEX_KEY = /\A(?:0|[1-9]\d{0,9})\z/
|
|
21
|
+
# Number.MAX_SAFE_INTEGER. Past it JavaScript holds an integer as the nearest double.
|
|
22
|
+
MAX_SAFE_INTEGER = (2**53) - 1
|
|
23
|
+
|
|
24
|
+
module_function
|
|
25
|
+
|
|
26
|
+
def trim(text)
|
|
27
|
+
text.sub(LEADING, "").sub(TRAILING, "")
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def trim_end(text)
|
|
31
|
+
text.sub(TRAILING, "")
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Math.round: halves go up, toward positive infinity.
|
|
35
|
+
def round(value)
|
|
36
|
+
return value if value.is_a?(Integer)
|
|
37
|
+
return value unless value.finite?
|
|
38
|
+
|
|
39
|
+
floor = value.floor
|
|
40
|
+
value - floor >= 0.5 ? floor + 1 : floor
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def finite?(value)
|
|
44
|
+
value.is_a?(Integer) || (value.is_a?(Numeric) && value.real? && value.to_f.finite?)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Number.isInteger.
|
|
48
|
+
def integer?(value)
|
|
49
|
+
value.is_a?(Integer) || (value.is_a?(Float) && value.finite? && value == value.floor)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# String(number), the text a template literal or JSON.stringify gives a number.
|
|
53
|
+
def number(value)
|
|
54
|
+
return value.to_s if value.is_a?(Integer) && value.abs <= MAX_SAFE_INTEGER
|
|
55
|
+
|
|
56
|
+
value = value.to_f
|
|
57
|
+
return "NaN" if value.nan?
|
|
58
|
+
return (value.positive? ? "Infinity" : "-Infinity") if value.infinite?
|
|
59
|
+
return "0" if value.zero?
|
|
60
|
+
|
|
61
|
+
digits, point = decimal(value.abs)
|
|
62
|
+
k = digits.length
|
|
63
|
+
text =
|
|
64
|
+
if k <= point && point <= 21 then digits + ("0" * (point - k))
|
|
65
|
+
elsif point.positive? && point <= 21 then "#{digits[0, point]}.#{digits[point..]}"
|
|
66
|
+
elsif point > -6 && point <= 0 then "0.#{"0" * -point}#{digits}"
|
|
67
|
+
else
|
|
68
|
+
exponent = point - 1
|
|
69
|
+
mantissa = k == 1 ? digits : "#{digits[0]}.#{digits[1..]}"
|
|
70
|
+
"#{mantissa}e#{exponent.negative? ? "-" : "+"}#{exponent.abs}"
|
|
71
|
+
end
|
|
72
|
+
value.negative? ? "-#{text}" : text
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# The shortest digits that round-trip, and where the decimal point goes:
|
|
76
|
+
# value = 0.DIGITS * 10**point. Ruby's Float#to_s already finds the digits.
|
|
77
|
+
def decimal(value)
|
|
78
|
+
text = value.to_s
|
|
79
|
+
if text.include?("e")
|
|
80
|
+
mantissa, exponent = text.split("e")
|
|
81
|
+
digits = mantissa.delete(".")
|
|
82
|
+
point = exponent.to_i + 1
|
|
83
|
+
else
|
|
84
|
+
whole, fraction = text.split(".")
|
|
85
|
+
if whole == "0"
|
|
86
|
+
stripped = fraction.sub(/\A0+/, "")
|
|
87
|
+
point = -(fraction.length - stripped.length)
|
|
88
|
+
digits = stripped
|
|
89
|
+
else
|
|
90
|
+
digits = whole + fraction.to_s
|
|
91
|
+
point = whole.length
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
digits = digits.sub(/0+\z/, "")
|
|
95
|
+
[digits.empty? ? "0" : digits, point]
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# Length in UTF-16 code units, which is what String#length is in JavaScript.
|
|
99
|
+
def length16(text)
|
|
100
|
+
return text.length if text.ascii_only?
|
|
101
|
+
|
|
102
|
+
text.each_char.sum { |c| c.ord > 0xFFFF ? 2 : 1 }
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# The first `units` UTF-16 code units. A character that would be cut in
|
|
106
|
+
# half is left out; JavaScript would keep a lone surrogate, Ruby cannot.
|
|
107
|
+
def head16(text, units)
|
|
108
|
+
return text[0, units] if text.ascii_only?
|
|
109
|
+
|
|
110
|
+
out = +""
|
|
111
|
+
used = 0
|
|
112
|
+
text.each_char do |c|
|
|
113
|
+
size = c.ord > 0xFFFF ? 2 : 1
|
|
114
|
+
break if used + size > units
|
|
115
|
+
|
|
116
|
+
out << c
|
|
117
|
+
used += size
|
|
118
|
+
end
|
|
119
|
+
out
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# The last `units` UTF-16 code units, the same way.
|
|
123
|
+
def tail16(text, units)
|
|
124
|
+
return text[[text.length - units, 0].max..] if text.ascii_only?
|
|
125
|
+
|
|
126
|
+
chars = []
|
|
127
|
+
used = 0
|
|
128
|
+
text.each_char.reverse_each do |c|
|
|
129
|
+
size = c.ord > 0xFFFF ? 2 : 1
|
|
130
|
+
break if used + size > units
|
|
131
|
+
|
|
132
|
+
chars << c
|
|
133
|
+
used += size
|
|
134
|
+
end
|
|
135
|
+
chars.reverse.join
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
# JSON.stringify for plain data: hashes, arrays, strings, numbers, true,
|
|
139
|
+
# false and nil, and anything with a to_h (the gem's Structs) or as_json.
|
|
140
|
+
def json(value)
|
|
141
|
+
case value
|
|
142
|
+
when nil then "null"
|
|
143
|
+
when true then "true"
|
|
144
|
+
when false then "false"
|
|
145
|
+
when String then quote(value)
|
|
146
|
+
when Symbol then quote(value.to_s)
|
|
147
|
+
when Integer then number(value)
|
|
148
|
+
when Float then value.finite? ? number(value) : "null"
|
|
149
|
+
when Hash then "{#{object_keys(value).map { |k| "#{quote(k.to_s)}:#{json(value[k])}" }.join(",")}}"
|
|
150
|
+
when Array then "[#{value.map { |v| json(v) }.join(",")}]"
|
|
151
|
+
when Time then quote(iso(value))
|
|
152
|
+
when Numeric then json(value.to_f)
|
|
153
|
+
else
|
|
154
|
+
if value.respond_to?(:as_json) && !value.is_a?(Struct) then json(value.as_json)
|
|
155
|
+
elsif value.respond_to?(:to_h) then json(value.to_h)
|
|
156
|
+
else quote(value.to_s)
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
def quote(text)
|
|
162
|
+
text = text.encode(Encoding::UTF_8, invalid: :replace, undef: :replace) unless text.encoding == Encoding::UTF_8 && text.valid_encoding?
|
|
163
|
+
text = text.scrub unless text.valid_encoding?
|
|
164
|
+
escaped = text.gsub(/["\\\u0000-\u001f]/) { |c| ESCAPES[c] || format("\\u%04x", c.ord) }
|
|
165
|
+
"\"#{escaped}\""
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
# Property order: array-index keys ascending, then the rest as inserted.
|
|
169
|
+
def object_keys(hash)
|
|
170
|
+
keys = hash.keys
|
|
171
|
+
indexes = keys.select { |k| INDEX_KEY.match?(k.to_s) && k.to_s.to_i < 4_294_967_295 }
|
|
172
|
+
return keys if indexes.empty?
|
|
173
|
+
|
|
174
|
+
indexes.sort_by { |k| k.to_s.to_i } + (keys - indexes)
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
# Date#toISOString for epoch milliseconds or a Time.
|
|
178
|
+
def iso(at)
|
|
179
|
+
time = at.is_a?(Time) ? at.utc : Time.at(at.div(1000), at % 1000, :millisecond).utc
|
|
180
|
+
time.strftime("%Y-%m-%dT%H:%M:%S.%LZ")
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
# JSON.parse. Ruby's parser keeps key order, as JavaScript does.
|
|
184
|
+
def parse(text)
|
|
185
|
+
::JSON.parse(text)
|
|
186
|
+
end
|
|
187
|
+
end
|
|
188
|
+
end
|
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Cronwatch
|
|
4
|
+
# What Cronwatch::ActiveJob and Cronwatch::Sidekiq share: the `cronwatch`
|
|
5
|
+
# class macro, the list of classes that called it, and when their jobs are
|
|
6
|
+
# declared on Cronwatch.client. Needs nothing beyond the core, so Sidekiq
|
|
7
|
+
# without Rails can use it. Loaded by cronwatch/scheduler, which schedule:
|
|
8
|
+
# :from_scheduler and declare_from_scheduler! need.
|
|
9
|
+
module Monitored
|
|
10
|
+
# What `cronwatch` is outside a monitored run: it takes log and metric
|
|
11
|
+
# calls and drops them, so the job's code runs the same either way.
|
|
12
|
+
class NullContext
|
|
13
|
+
def name = nil
|
|
14
|
+
def run_id = nil
|
|
15
|
+
def started_at = nil
|
|
16
|
+
def signal = nil
|
|
17
|
+
def log(*) = nil
|
|
18
|
+
def metric(_name, _value) = nil
|
|
19
|
+
def metrics(_values = nil, **) = nil
|
|
20
|
+
def aborted? = false
|
|
21
|
+
end
|
|
22
|
+
NULL_CONTEXT = NullContext.new.freeze
|
|
23
|
+
|
|
24
|
+
# Where the context goes for a job that has no `cronwatch` to hold it (one
|
|
25
|
+
# declared from the scheduler's config): nowhere.
|
|
26
|
+
module Discard
|
|
27
|
+
def self.cronwatch_with(_context) = yield
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# One job's options and its handle on the current Cronwatch.client. The
|
|
31
|
+
# job is declared the first time it is needed and again whenever the
|
|
32
|
+
# client is replaced. `resolve`, when given, turns the options into the
|
|
33
|
+
# ones to declare (schedule: :from_scheduler reads the scheduler's config)
|
|
34
|
+
# the first time they are needed; its answer is kept.
|
|
35
|
+
class Declaration
|
|
36
|
+
attr_reader :name, :options, :where
|
|
37
|
+
|
|
38
|
+
def initialize(name, options, where:, resolve: nil)
|
|
39
|
+
@name = name
|
|
40
|
+
@options = options.freeze
|
|
41
|
+
@where = where
|
|
42
|
+
@resolve = resolve
|
|
43
|
+
@resolved = nil
|
|
44
|
+
@registration = nil
|
|
45
|
+
@lock = Mutex.new
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# [client, handle] on Cronwatch.client. With strict: false a bad
|
|
49
|
+
# declaration goes to the client's on_error and the answer is nil.
|
|
50
|
+
def registration(strict: true)
|
|
51
|
+
client = Cronwatch.client
|
|
52
|
+
@lock.synchronize do
|
|
53
|
+
current = @registration
|
|
54
|
+
return current if current && current[0].equal?(client)
|
|
55
|
+
|
|
56
|
+
handle = client.job(@name, **resolve_locked)
|
|
57
|
+
@registration = [client, handle].freeze
|
|
58
|
+
end
|
|
59
|
+
rescue StandardError => e
|
|
60
|
+
raise if strict
|
|
61
|
+
|
|
62
|
+
client.report(e, "declaring #{@where}")
|
|
63
|
+
nil
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
private
|
|
67
|
+
|
|
68
|
+
def resolve_locked
|
|
69
|
+
@resolved ||= (@resolve ? @resolve.call(@options) : @options).freeze
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
@monitored = {}
|
|
74
|
+
@lock = Mutex.new
|
|
75
|
+
@ready = false
|
|
76
|
+
|
|
77
|
+
class << self
|
|
78
|
+
# The names of the classes that declared `cronwatch`, in the order they did.
|
|
79
|
+
def monitored
|
|
80
|
+
@lock.synchronize { @monitored.keys }
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def track(klass)
|
|
84
|
+
@lock.synchronize { @monitored[klass.name] = true } if klass.name
|
|
85
|
+
nil
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# Called once the app has booted (by the Railtie in Rails). From then on
|
|
89
|
+
# a class declares its job as soon as it calls `cronwatch`.
|
|
90
|
+
def ready!
|
|
91
|
+
@ready = true
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def ready?
|
|
95
|
+
@ready
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# Once the app has booted: from now on classes declare as they load,
|
|
99
|
+
# every class loaded so far declares now, and so does what
|
|
100
|
+
# Cronwatch.declare_from_scheduler! asked for. A bad declaration raises.
|
|
101
|
+
def boot!
|
|
102
|
+
ready!
|
|
103
|
+
register_all(strict: true)
|
|
104
|
+
Cronwatch::Scheduler.declare_pending! if defined?(Cronwatch::Scheduler)
|
|
105
|
+
nil
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# Declares every monitored class's job on Cronwatch.client, and every
|
|
109
|
+
# job Cronwatch.declare_from_scheduler! took from the scheduler's
|
|
110
|
+
# config, so a check knows about jobs that have not run in this
|
|
111
|
+
# process. A class that no longer loads is skipped. With strict: false a
|
|
112
|
+
# bad declaration goes to the client's on_error instead of raising.
|
|
113
|
+
def register_all(strict: true)
|
|
114
|
+
monitored.each do |name|
|
|
115
|
+
klass = begin
|
|
116
|
+
Object.const_get(name)
|
|
117
|
+
rescue NameError
|
|
118
|
+
next
|
|
119
|
+
end
|
|
120
|
+
klass.cronwatch_registration(strict: strict) if klass.respond_to?(:cronwatch_registration)
|
|
121
|
+
end
|
|
122
|
+
Cronwatch::Scheduler.register_declared(strict: strict) if defined?(Cronwatch::Scheduler)
|
|
123
|
+
nil
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
# Loads app/jobs (and app/workers and app/sidekiq, where Sidekiq jobs
|
|
127
|
+
# often live) when the app does not eager load (development), so a check
|
|
128
|
+
# sees every monitored class, not only those used since boot.
|
|
129
|
+
def load_app_jobs
|
|
130
|
+
return unless defined?(::Rails) && ::Rails.respond_to?(:application) && (app = ::Rails.application)
|
|
131
|
+
return if app.config.eager_load
|
|
132
|
+
|
|
133
|
+
loader = ::Rails.respond_to?(:autoloaders) && ::Rails.autoloaders.main
|
|
134
|
+
return unless loader.respond_to?(:eager_load_dir)
|
|
135
|
+
|
|
136
|
+
dirs = Array(app.paths["app/jobs"]&.existent)
|
|
137
|
+
%w[app/workers app/sidekiq].each do |dir|
|
|
138
|
+
path = app.root.join(dir).to_s
|
|
139
|
+
dirs << path if File.directory?(path) && loader.dirs.include?(path)
|
|
140
|
+
end
|
|
141
|
+
dirs.uniq.each { |dir| loader.eager_load_dir(dir) }
|
|
142
|
+
nil
|
|
143
|
+
rescue StandardError, ScriptError => e
|
|
144
|
+
Cronwatch.client.report(e, "loading app/jobs")
|
|
145
|
+
nil
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
# NightlyReportJob is "nightly-report"; Reports::NightlyJob is "reports:nightly".
|
|
149
|
+
def default_name(klass)
|
|
150
|
+
raise ArgumentError, "cronwatch: an anonymous job class needs a name: option" if klass.name.nil?
|
|
151
|
+
|
|
152
|
+
dasherize(underscore(klass.name.delete_suffix("Job"))).tr("/", ":")
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
# Runs the block as a recorded run of the declaration's job, with the
|
|
156
|
+
# run's context as `cronwatch` on `job` (when it has one). A declaration that is broken
|
|
157
|
+
# runs the block unrecorded, with the error sent to on_error. The
|
|
158
|
+
# block's error is raised again after the run is recorded.
|
|
159
|
+
def record(declaration, trigger, job)
|
|
160
|
+
client, handle = declaration&.registration(strict: false)
|
|
161
|
+
return yield unless handle
|
|
162
|
+
|
|
163
|
+
holder = job.respond_to?(:cronwatch_with, true) ? job : Discard
|
|
164
|
+
outcome = client.execute(handle.definition, trigger) do |context|
|
|
165
|
+
holder.__send__(:cronwatch_with, context) { yield }
|
|
166
|
+
end
|
|
167
|
+
raise outcome.error if outcome.threw
|
|
168
|
+
|
|
169
|
+
outcome.result
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
private
|
|
173
|
+
|
|
174
|
+
# ActiveSupport's, when it is loaded, so the app's acronyms apply; the
|
|
175
|
+
# same rules without them otherwise.
|
|
176
|
+
def underscore(text)
|
|
177
|
+
return ::ActiveSupport::Inflector.underscore(text) if defined?(::ActiveSupport::Inflector)
|
|
178
|
+
|
|
179
|
+
text.gsub("::", "/").gsub(/(?<=[A-Z])(?=[A-Z][a-z])|(?<=[a-z\d])(?=[A-Z])/, "_").tr("-", "_").downcase
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
def dasherize(text)
|
|
183
|
+
text.tr("_", "-")
|
|
184
|
+
end
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
# The class side: `cronwatch` and what it declares.
|
|
188
|
+
module ClassMethods
|
|
189
|
+
# Monitor this job. Takes the options of Cronwatch::Client#job (schedule,
|
|
190
|
+
# timezone, grace, timeout, max_duration, budget, expect,
|
|
191
|
+
# failures_before_alert, description, tags) and name:, which defaults to
|
|
192
|
+
# the class name without "Job", dasherized. schedule: :from_scheduler
|
|
193
|
+
# takes the schedule (and its timezone) from the class's entry in Solid
|
|
194
|
+
# Queue's config/recurring.yml or sidekiq-cron's schedule.
|
|
195
|
+
def cronwatch(name: nil, **options)
|
|
196
|
+
name ||= Cronwatch::Monitored.default_name(self)
|
|
197
|
+
resolve = nil
|
|
198
|
+
if options[:schedule] == :from_scheduler
|
|
199
|
+
if options.key?(:timezone)
|
|
200
|
+
raise ArgumentError, "cronwatch: #{self.name || name} takes its timezone from the scheduler with schedule: :from_scheduler; " \
|
|
201
|
+
"drop timezone:"
|
|
202
|
+
end
|
|
203
|
+
klass = self
|
|
204
|
+
resolve = ->(given) { given.merge(Cronwatch::Scheduler.schedule_for(klass)) }
|
|
205
|
+
elsif options[:schedule].is_a?(Symbol)
|
|
206
|
+
raise ArgumentError, "cronwatch: schedule #{options[:schedule].inspect} is not a schedule; did you mean :from_scheduler?"
|
|
207
|
+
end
|
|
208
|
+
declaration = Declaration.new(name, options, where: self.name || name, resolve: resolve)
|
|
209
|
+
declaration.registration if Cronwatch::Monitored.ready? # a bad one raises here, leaving the class unmonitored
|
|
210
|
+
@cronwatch_declaration = declaration
|
|
211
|
+
Cronwatch::Monitored.track(self)
|
|
212
|
+
nil
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
# The options given to `cronwatch`, with the name. Nil when this class is not monitored.
|
|
216
|
+
def cronwatch_options
|
|
217
|
+
declaration = @cronwatch_declaration
|
|
218
|
+
declaration && declaration.options.merge(name: declaration.name).freeze
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
def cronwatch_name
|
|
222
|
+
@cronwatch_declaration&.name
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
def cronwatch_declaration
|
|
226
|
+
@cronwatch_declaration
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
# This class's job handle on the current Cronwatch.client.
|
|
230
|
+
def cronwatch_handle(strict: true)
|
|
231
|
+
cronwatch_registration(strict: strict)&.last
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
# [client, handle]: the job declared on Cronwatch.client, the first time
|
|
235
|
+
# and again whenever the client is replaced.
|
|
236
|
+
def cronwatch_registration(strict: true)
|
|
237
|
+
@cronwatch_declaration&.registration(strict: strict)
|
|
238
|
+
end
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
# The run's context during a monitored run: log, metric, metrics,
|
|
242
|
+
# aborted?, signal. Outside one, a stand-in that drops what it is given.
|
|
243
|
+
def cronwatch
|
|
244
|
+
@cronwatch || NULL_CONTEXT
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
private
|
|
248
|
+
|
|
249
|
+
def cronwatch_with(context)
|
|
250
|
+
previous = @cronwatch
|
|
251
|
+
@cronwatch = context
|
|
252
|
+
begin
|
|
253
|
+
yield
|
|
254
|
+
ensure
|
|
255
|
+
@cronwatch = previous
|
|
256
|
+
end
|
|
257
|
+
end
|
|
258
|
+
end
|
|
259
|
+
end
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Cronwatch
|
|
4
|
+
module Output
|
|
5
|
+
# Output is capped so a chatty job cannot fill the store. The tail is kept.
|
|
6
|
+
# Counted in UTF-16 code units, as the SDK counts it.
|
|
7
|
+
CAP = 16 * 1024
|
|
8
|
+
|
|
9
|
+
REDACTED = "[redacted]"
|
|
10
|
+
|
|
11
|
+
# The SDK's SECRET_PATTERNS, written so Onigmo matches exactly what
|
|
12
|
+
# JavaScript matches: (?a) keeps \b to ASCII word characters, \s is spelled
|
|
13
|
+
# out as JavaScript's whitespace, and the case-insensitive parts are
|
|
14
|
+
# spelled as [Ss][Ee]... because Ruby's /i also folds "ß" to "ss" and the
|
|
15
|
+
# Kelvin sign to "k", which JavaScript's /i does not. Bounded quantifiers
|
|
16
|
+
# throughout, so a long line cannot make these backtrack.
|
|
17
|
+
module Secrets
|
|
18
|
+
WS = JS::WHITESPACE
|
|
19
|
+
|
|
20
|
+
# "secret" as [Ss][Ee][Cc][Rr][Ee][Tt]: ASCII-only case folding.
|
|
21
|
+
def self.ci(word)
|
|
22
|
+
word.chars.map { |c| c.match?(/[a-z]/) ? "[#{c.upcase}#{c}]" : Regexp.escape(c) }.join
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
NAMES = [
|
|
26
|
+
ci("secret"), ci("token"), "#{ci("passw")}(?:#{ci("or")})?#{ci("d")}", ci("pwd"),
|
|
27
|
+
"#{ci("api")}[_-]?#{ci("key")}", "#{ci("access")}[_-]?#{ci("key")}", "#{ci("private")}[_-]?#{ci("key")}",
|
|
28
|
+
ci("credential"),
|
|
29
|
+
].join("|").freeze
|
|
30
|
+
|
|
31
|
+
# "=", ":" or a hash rocket, between a name and its value.
|
|
32
|
+
ASSIGN = "(?:=>|[=:])"
|
|
33
|
+
|
|
34
|
+
# They apply in this order, each to the text the ones before it left.
|
|
35
|
+
PATTERNS = [
|
|
36
|
+
# A PEM private key, header to footer. Without a footer (the output was
|
|
37
|
+
# trimmed) it runs to the end of the base64 body. A "-" that starts five
|
|
38
|
+
# dashes ends the body, so the footer is never swallowed into it.
|
|
39
|
+
[Regexp.new("-----BEGIN (?:[A-Z0-9]{1,20} ){0,3}PRIVATE KEY-----(?:[A-Za-z0-9+/=#{WS},:]|-(?!----)){0,16384}" \
|
|
40
|
+
"(?:-----END (?:[A-Z0-9]{1,20} ){0,3}PRIVATE KEY-----)?"), false],
|
|
41
|
+
# password=..., API_KEY: ..., "client_secret": "...", TOKEN='...', token=...,
|
|
42
|
+
# :password=>"..." (but not max_tokens: 800). A quoted value is blanked to
|
|
43
|
+
# its closing quote, spaces and all, and keeps its quotes.
|
|
44
|
+
[Regexp.new("(?a)\\b([A-Za-z0-9_-]{0,40}(?:#{NAMES})[A-Za-z0-9_-]{0,40}(?<![Tt][Oo][Kk][Ee][Nn][Ss])\"?[#{WS}]{0,3}#{ASSIGN}[#{WS}]{0,3})(?:(\")[^\"\\n]{1,4096}\"|(')[^'\\n]{1,4096}'|[\"']?[^#{WS}\"',;&]{1,4096})"), :quoted],
|
|
45
|
+
# Authorization: Basic <base64> and Authorization: Token <token>, also as a JSON or hash entry.
|
|
46
|
+
[Regexp.new("(?a)\\b((?:#{ci("proxy-")})?#{ci("authorization")}[\"']?[#{WS}]{0,3}#{ASSIGN}[#{WS}]{0,3}[\"']?[#{WS}]{0,3}" \
|
|
47
|
+
"(?:#{ci("basic")}|#{ci("token")})[#{WS}]{1,3})[A-Za-z0-9._~+/=:-]{1,4096}"), true],
|
|
48
|
+
# Credentials inside a URL: postgres://user:password@host. The password
|
|
49
|
+
# runs to the last "@" before a "/" or a space, so one that contains "@"
|
|
50
|
+
# is blanked whole.
|
|
51
|
+
[Regexp.new("(?a)(\\b[A-Za-z][A-Za-z0-9+.-]{0,30}://[^#{WS}/:@]{0,256}:)[^#{WS}/]{1,256}@"), :url],
|
|
52
|
+
# Authorization: Bearer <token>
|
|
53
|
+
[Regexp.new("(?a)\\b(Bearer[#{WS}]{1,3})[A-Za-z0-9._~+/=-]{8,4096}"), true],
|
|
54
|
+
# A bare JWT: three base64url segments, the first starting eyJ.
|
|
55
|
+
[/(?a)\beyJ[A-Za-z0-9_-]{4,4096}\.[A-Za-z0-9_-]{4,4096}\.[A-Za-z0-9_-]{0,4096}/, false],
|
|
56
|
+
# Incoming webhook URLs carry their secret in the path.
|
|
57
|
+
[Regexp.new("(?a)(\\b#{ci("hooks.slack.com")}/(?:#{ci("services")}|#{ci("workflows")}|#{ci("triggers")})/)[A-Za-z0-9/_-]{1,255}"), true],
|
|
58
|
+
[Regexp.new("(?a)(\\b#{ci("discord")}(?:#{ci("app")})?#{ci(".com/api/")}(?:[Vv][0-9]{1,2}/)?#{ci("webhooks/")})[A-Za-z0-9/_-]{1,255}"), true],
|
|
59
|
+
# Well-known token shapes: AWS, GitHub, Slack, Stripe, Anthropic, OpenAI and Google style keys.
|
|
60
|
+
[/(?a)\b(?:AKIA|ASIA)[0-9A-Z]{16}\b/, false],
|
|
61
|
+
[/(?a)\b(?:gh[pousr]_[A-Za-z0-9]{30,255}|github_pat_[A-Za-z0-9_]{20,255})\b/, false],
|
|
62
|
+
[/(?a)\bxox[abposr]-[A-Za-z0-9-]{10,255}/, false],
|
|
63
|
+
[/(?a)\b[rsp]k_(?:live|test)_[A-Za-z0-9]{10,255}\b/, false],
|
|
64
|
+
[%r{(?a)\bwhsec_[A-Za-z0-9+/=]{16,255}}, false],
|
|
65
|
+
[/(?a)\bsk-[A-Za-z0-9_-]{20,255}/, false],
|
|
66
|
+
[/(?a)\bAIza[0-9A-Za-z_-]{35}(?![0-9A-Za-z_-])/, false],
|
|
67
|
+
].freeze
|
|
68
|
+
|
|
69
|
+
# Characters outside the Basic Multilingual Plane.
|
|
70
|
+
ASTRAL = /[\u{10000}-\u{10FFFF}]/
|
|
71
|
+
# Where a UTF-16 surrogate stands while the patterns run: U+10D800 to
|
|
72
|
+
# U+10DFFF, which cannot otherwise appear once every astral character
|
|
73
|
+
# has been split into its two surrogates.
|
|
74
|
+
SURROGATE_BASE = 0x100000
|
|
75
|
+
STANDIN = /[\u{10D800}-\u{10DFFF}]+/
|
|
76
|
+
|
|
77
|
+
# JavaScript's patterns (no u flag) see UTF-16 code units, so a
|
|
78
|
+
# character outside the BMP is two characters to a negated class and to
|
|
79
|
+
# a bounded quantifier. Text with such characters is matched with each
|
|
80
|
+
# one written as two stand-ins, then put back together.
|
|
81
|
+
def self.to_units(text)
|
|
82
|
+
text.encode(Encoding::UTF_16LE).unpack("v*").map { |u| (u >= 0xD800 && u <= 0xDFFF ? SURROGATE_BASE + u : u).chr(Encoding::UTF_8) }.join
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# A surrogate a replacement cut from its partner becomes U+FFFD:
|
|
86
|
+
# JavaScript would keep it alone, Ruby's UTF-8 cannot.
|
|
87
|
+
def self.from_units(text)
|
|
88
|
+
text.gsub(STANDIN) do |run|
|
|
89
|
+
units = run.each_char.map { |c| c.ord - SURROGATE_BASE }
|
|
90
|
+
out = +""
|
|
91
|
+
i = 0
|
|
92
|
+
while i < units.length
|
|
93
|
+
high = units[i]
|
|
94
|
+
low = units[i + 1]
|
|
95
|
+
if high <= 0xDBFF && low && low >= 0xDC00
|
|
96
|
+
out << (0x10000 + ((high - 0xD800) << 10) + (low - 0xDC00)).chr(Encoding::UTF_8)
|
|
97
|
+
i += 2
|
|
98
|
+
else
|
|
99
|
+
out << 0xFFFD.chr(Encoding::UTF_8)
|
|
100
|
+
i += 1
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
out
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
module_function
|
|
109
|
+
|
|
110
|
+
# Text as valid UTF-8, whatever it was read as: bytes that are not UTF-8
|
|
111
|
+
# (binary output, a C extension's message) become U+FFFD, and text in
|
|
112
|
+
# another encoding is converted. JavaScript strings cannot hold anything
|
|
113
|
+
# else, and the store, the alerts and the redaction all expect UTF-8.
|
|
114
|
+
def utf8(text)
|
|
115
|
+
text = text.to_s
|
|
116
|
+
return text if text.encoding == Encoding::UTF_8 && text.valid_encoding?
|
|
117
|
+
|
|
118
|
+
if [Encoding::UTF_8, Encoding::BINARY, Encoding::US_ASCII].include?(text.encoding)
|
|
119
|
+
text.dup.force_encoding(Encoding::UTF_8).scrub
|
|
120
|
+
else
|
|
121
|
+
text.encode(Encoding::UTF_8, invalid: :replace, undef: :replace)
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
# NUL characters are removed first, since Postgres refuses them in TEXT
|
|
126
|
+
# and JSONB and the whole run row would be lost. The cap then applies to
|
|
127
|
+
# what is left.
|
|
128
|
+
def cap(text)
|
|
129
|
+
text = strip_nul(utf8(text))
|
|
130
|
+
return text if JS.length16(text) <= CAP
|
|
131
|
+
|
|
132
|
+
"[earlier output trimmed]\n#{JS.tail16(text, CAP)}"
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
# Removes every U+0000.
|
|
136
|
+
def strip_nul(text)
|
|
137
|
+
text.include?("\0") ? text.delete("\0") : text
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
# "Name: message" and the first five backtrace lines, each written as
|
|
141
|
+
# " at <line>" like the frames of a JavaScript stack, capped like output.
|
|
142
|
+
# An exception that stops the thread rather than reporting a problem
|
|
143
|
+
# (Interrupt, SystemExit, Sidekiq::Shutdown, a Timeout) is written
|
|
144
|
+
# "Interrupted: <class>", with its message when it says more.
|
|
145
|
+
def error_message(error)
|
|
146
|
+
cap(describe_error(error))
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
def describe_error(error)
|
|
150
|
+
if error.is_a?(Exception)
|
|
151
|
+
frames = (error.backtrace || []).first(5).map { |line| " at #{utf8(line)}" }
|
|
152
|
+
name = error.class.name || error.class.to_s
|
|
153
|
+
message = utf8(error.message)
|
|
154
|
+
header =
|
|
155
|
+
if interruption?(error)
|
|
156
|
+
message.empty? || message == name ? "Interrupted: #{name}" : "Interrupted: #{name}: #{message}"
|
|
157
|
+
else
|
|
158
|
+
"#{name}: #{message}"
|
|
159
|
+
end
|
|
160
|
+
return frames.empty? ? header : "#{header}\n#{frames.join("\n")}"
|
|
161
|
+
end
|
|
162
|
+
return utf8(error) if error.is_a?(String)
|
|
163
|
+
|
|
164
|
+
begin
|
|
165
|
+
JS.json(error)
|
|
166
|
+
rescue StandardError
|
|
167
|
+
utf8(error)
|
|
168
|
+
end
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
# Outside StandardError, and not a ScriptError (NotImplementedError,
|
|
172
|
+
# LoadError), which is a problem in the code rather than a stop.
|
|
173
|
+
def interruption?(error)
|
|
174
|
+
!error.is_a?(StandardError) && !error.is_a?(ScriptError)
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
# The default `redact`: blanks values that look like secrets (key=value
|
|
178
|
+
# pairs with secret-ish names, Authorization headers, URL credentials,
|
|
179
|
+
# bearer tokens, JWTs, PEM private keys, webhook URLs and well-known token
|
|
180
|
+
# formats) before output or an error is stored, shown or sent anywhere. Matches exactly what the SDK's redactSecrets matches.
|
|
181
|
+
def redact_secrets(text)
|
|
182
|
+
astral = !text.ascii_only? && Secrets::ASTRAL.match?(text)
|
|
183
|
+
out = astral ? Secrets.to_units(text) : text
|
|
184
|
+
Secrets::PATTERNS.each do |pattern, keep|
|
|
185
|
+
out = out.gsub(pattern) do
|
|
186
|
+
case keep
|
|
187
|
+
when :url then "#{Regexp.last_match(1)}#{REDACTED}@"
|
|
188
|
+
when :quoted
|
|
189
|
+
quote = Regexp.last_match(2) || Regexp.last_match(3) || ""
|
|
190
|
+
"#{Regexp.last_match(1)}#{quote}#{REDACTED}#{quote}"
|
|
191
|
+
when true then "#{Regexp.last_match(1)}#{REDACTED}"
|
|
192
|
+
else REDACTED
|
|
193
|
+
end
|
|
194
|
+
end
|
|
195
|
+
end
|
|
196
|
+
astral ? Secrets.from_units(out) : out
|
|
197
|
+
end
|
|
198
|
+
end
|
|
199
|
+
end
|