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,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