railwatch 0.1.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.
- checksums.yaml +7 -0
- data/AGENTS.md +122 -0
- data/CHANGELOG.md +462 -0
- data/MIT-LICENSE +20 -0
- data/README.md +226 -0
- data/app/controllers/railwatch/beacon_controller.rb +254 -0
- data/config/routes.rb +5 -0
- data/docs/ai-and-mcp.md +227 -0
- data/docs/configuration.md +931 -0
- data/docs/faq.md +230 -0
- data/docs/getting-started.md +279 -0
- data/docs/records.md +834 -0
- data/docs/replacing-nightwatch.md +216 -0
- data/docs/replacing-sentry.md +573 -0
- data/docs/security.md +94 -0
- data/docs/self-hosting.md +60 -0
- data/docs/source-maps.md +60 -0
- data/docs/testing.md +175 -0
- data/docs/troubleshooting.md +319 -0
- data/lib/generators/railwatch/install/install_generator.rb +280 -0
- data/lib/generators/railwatch/install/templates/initializer.rb +54 -0
- data/lib/generators/railwatch/install/templates/post-deploy +98 -0
- data/lib/generators/railwatch/install/templates/railwatch.ts +658 -0
- data/lib/railwatch/attachments.rb +83 -0
- data/lib/railwatch/backtrace.rb +158 -0
- data/lib/railwatch/buffer.rb +122 -0
- data/lib/railwatch/clock.rb +25 -0
- data/lib/railwatch/configuration.rb +334 -0
- data/lib/railwatch/console.rb +48 -0
- data/lib/railwatch/context.rb +125 -0
- data/lib/railwatch/controller_helpers.rb +21 -0
- data/lib/railwatch/current.rb +32 -0
- data/lib/railwatch/engine.rb +144 -0
- data/lib/railwatch/execution.rb +367 -0
- data/lib/railwatch/faraday.rb +73 -0
- data/lib/railwatch/health.rb +188 -0
- data/lib/railwatch/job_tracing.rb +49 -0
- data/lib/railwatch/middleware/request.rb +289 -0
- data/lib/railwatch/minitest.rb +43 -0
- data/lib/railwatch/patches/inertia.rb +34 -0
- data/lib/railwatch/patches/net_http.rb +102 -0
- data/lib/railwatch/patches/rake_task.rb +88 -0
- data/lib/railwatch/patches/runner_command.rb +120 -0
- data/lib/railwatch/patches.rb +43 -0
- data/lib/railwatch/profiler.rb +270 -0
- data/lib/railwatch/record.rb +119 -0
- data/lib/railwatch/redactor.rb +67 -0
- data/lib/railwatch/release_detector.rb +97 -0
- data/lib/railwatch/reporter.rb +539 -0
- data/lib/railwatch/rspec.rb +139 -0
- data/lib/railwatch/sampler.rb +17 -0
- data/lib/railwatch/secret_safety.rb +62 -0
- data/lib/railwatch/sessions.rb +162 -0
- data/lib/railwatch/source_maps.rb +59 -0
- data/lib/railwatch/spec_helper.rb +147 -0
- data/lib/railwatch/sql_normalizer.rb +398 -0
- data/lib/railwatch/subscribers/base.rb +54 -0
- data/lib/railwatch/subscribers/broadcasts.rb +107 -0
- data/lib/railwatch/subscribers/cache.rb +107 -0
- data/lib/railwatch/subscribers/deprecations.rb +26 -0
- data/lib/railwatch/subscribers/exceptions.rb +304 -0
- data/lib/railwatch/subscribers/jobs.rb +282 -0
- data/lib/railwatch/subscribers/logs.rb +137 -0
- data/lib/railwatch/subscribers/mail.rb +42 -0
- data/lib/railwatch/subscribers/notifications.rb +36 -0
- data/lib/railwatch/subscribers/process_info.rb +98 -0
- data/lib/railwatch/subscribers/queries.rb +183 -0
- data/lib/railwatch/subscribers/requests.rb +94 -0
- data/lib/railwatch/subscribers/storage.rb +35 -0
- data/lib/railwatch/subscribers/users.rb +159 -0
- data/lib/railwatch/subscribers/views.rb +54 -0
- data/lib/railwatch/subscribers.rb +34 -0
- data/lib/railwatch/transport/http.rb +208 -0
- data/lib/railwatch/version.rb +5 -0
- data/lib/railwatch.rb +550 -0
- data/lib/tasks/railwatch_tasks.rake +289 -0
- data/llms.txt +38 -0
- metadata +157 -0
|
@@ -0,0 +1,304 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Railwatch
|
|
4
|
+
module Subscribers
|
|
5
|
+
# Everything that flows through Rails.error (handled and unhandled, with
|
|
6
|
+
# severity, source, and context) plus what the Rack middleware catches.
|
|
7
|
+
# Unhandled exceptions are shipped immediately so a crashing process still
|
|
8
|
+
# reports; that also makes them survive sampled-out executions.
|
|
9
|
+
module Exceptions
|
|
10
|
+
extend Base
|
|
11
|
+
|
|
12
|
+
class ErrorSubscriber
|
|
13
|
+
def report(error, handled:, severity:, context:, source: nil)
|
|
14
|
+
Exceptions.capture(error, handled: handled, severity: severity, context: context, source: source)
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
module_function
|
|
19
|
+
|
|
20
|
+
def install!(_app)
|
|
21
|
+
Rails.error.subscribe(ErrorSubscriber.new) if defined?(Rails) && Rails.respond_to?(:error)
|
|
22
|
+
Locals.install! if Railwatch.config.capture_exception_locals
|
|
23
|
+
|
|
24
|
+
# An exception a controller swallows with `rescue_from` never reaches
|
|
25
|
+
# Rails.error or the middleware, so without this it is invisible.
|
|
26
|
+
# Rails instruments the moment a matching handler is found, which is
|
|
27
|
+
# exactly Sentry's report_rescued_exceptions. Active Job's equivalents
|
|
28
|
+
# (retry_on / discard_on) are already covered by the
|
|
29
|
+
# retry_stopped/discard subscriptions in Subscribers::Jobs.
|
|
30
|
+
subscribe("rescue_from_callback.action_controller") do |event|
|
|
31
|
+
next unless Railwatch.config.capture_rescued_exceptions
|
|
32
|
+
capture(event.payload[:exception], handled: true, severity: :warning,
|
|
33
|
+
source: "action_controller.rescue_from")
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Local variables at the raise site, like Sentry's "locals" panel.
|
|
38
|
+
# Opt-in (RAILWATCH_CAPTURE_EXCEPTION_LOCALS): a TracePoint on :raise
|
|
39
|
+
# snapshots the raising frame's binding onto the exception object,
|
|
40
|
+
# already stringified, truncated, and run through the param filter so
|
|
41
|
+
# a `password` local ships as [FILTERED]. Costs one binding walk per
|
|
42
|
+
# raise, nothing on the happy path.
|
|
43
|
+
module Locals
|
|
44
|
+
MAX_LOCALS = 25
|
|
45
|
+
MAX_VALUE = 200
|
|
46
|
+
|
|
47
|
+
module_function
|
|
48
|
+
|
|
49
|
+
def install!
|
|
50
|
+
return if @trace
|
|
51
|
+
@trace = TracePoint.new(:raise) do |tp|
|
|
52
|
+
error = tp.raised_exception
|
|
53
|
+
next if error.instance_variable_defined?(:@__railwatch_locals)
|
|
54
|
+
error.instance_variable_set(:@__railwatch_locals, snapshot(tp.binding))
|
|
55
|
+
rescue StandardError
|
|
56
|
+
nil
|
|
57
|
+
end
|
|
58
|
+
@trace.enable
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def uninstall!
|
|
62
|
+
@trace&.disable
|
|
63
|
+
@trace = nil
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def snapshot(binding)
|
|
67
|
+
return nil unless binding
|
|
68
|
+
names = binding.local_variables.first(MAX_LOCALS)
|
|
69
|
+
raw = names.to_h { |n| [ n.to_s, inspect_value(binding.local_variable_get(n)) ] }
|
|
70
|
+
Railwatch.redactor.params(raw)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def inspect_value(value)
|
|
74
|
+
s = value.inspect
|
|
75
|
+
s.length > MAX_VALUE ? s[0, MAX_VALUE] + "…" : s
|
|
76
|
+
rescue StandardError
|
|
77
|
+
"#<#{value.class}>"
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def for(error)
|
|
81
|
+
error.instance_variable_get(:@__railwatch_locals)
|
|
82
|
+
rescue StandardError
|
|
83
|
+
nil
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def capture(error, handled:, severity:, context: {}, source: nil, fingerprint: nil)
|
|
88
|
+
return unless Railwatch.enabled?
|
|
89
|
+
return if ignored?(error)
|
|
90
|
+
|
|
91
|
+
exe = execution
|
|
92
|
+
if exe&.first_exception_observation?(error, handled)
|
|
93
|
+
exe.count(:exceptions)
|
|
94
|
+
exe.exception_preview ||= "#{error.class}: #{error.message}"[0, 255]
|
|
95
|
+
end
|
|
96
|
+
# An interactive `rails runner` -- typed, piped, or a script in /tmp --
|
|
97
|
+
# is an engineer at a shell, and their typo is not an issue. Their
|
|
98
|
+
# command record still ships, carrying the exit code and the preview
|
|
99
|
+
# set just above, so the run is visible without opening one. Checked
|
|
100
|
+
# here rather than only where the patch rescues because the Rails
|
|
101
|
+
# executor reports the error to Rails.error first (source
|
|
102
|
+
# "application.runner.railties"), inside the runner's own call.
|
|
103
|
+
return if exe&.interactive
|
|
104
|
+
# Release health: an unhandled exception ends this request's session
|
|
105
|
+
# crashed. Flagged rather than written straight into the session map
|
|
106
|
+
# because the key is only resolved once the request finishes (a
|
|
107
|
+
# user-keyed session has no cookie to read up front).
|
|
108
|
+
exe.session_crashed = true if exe && !handled && Railwatch.config.track_sessions
|
|
109
|
+
|
|
110
|
+
# Sampled-out executions still report an unhandled error, governed by
|
|
111
|
+
# the exceptions sample rate. Decided once per execution and memoized,
|
|
112
|
+
# so a burst of errors doesn't re-roll the dice each time.
|
|
113
|
+
return if exe && !exe.sampled? && (handled || !exception_sampled?(exe))
|
|
114
|
+
return if exe&.paused?
|
|
115
|
+
return if exe && !exe.first_exception_report?(error, handled)
|
|
116
|
+
|
|
117
|
+
cause = error.cause
|
|
118
|
+
frames = Backtrace.frames(error, with_source: Railwatch.config.capture_exception_source)
|
|
119
|
+
top = top_frame(frames)
|
|
120
|
+
parts, fingerprint_source = fingerprint_for(error, top, override: fingerprint)
|
|
121
|
+
# So an attachment filed against this same error object later
|
|
122
|
+
# (Railwatch.attach(exception:)) lands on the issue this call chose.
|
|
123
|
+
remember_fingerprint(error, parts) if fingerprint
|
|
124
|
+
rec = {
|
|
125
|
+
class: error.class.name,
|
|
126
|
+
message: error.message.to_s[0, 4096],
|
|
127
|
+
handled: handled,
|
|
128
|
+
severity: severity.to_s,
|
|
129
|
+
source: source.to_s,
|
|
130
|
+
file: top[:file],
|
|
131
|
+
line: top[:line],
|
|
132
|
+
frames: frames,
|
|
133
|
+
cause: cause && { class: cause.class.name, message: cause.message.to_s[0, 1024] },
|
|
134
|
+
context: Context.serialized_with(context),
|
|
135
|
+
code: error_code(error),
|
|
136
|
+
sql_state: sql_state_for(error),
|
|
137
|
+
locals: Railwatch.config.capture_exception_locals ? Locals.for(error) : nil,
|
|
138
|
+
fingerprint: parts,
|
|
139
|
+
fingerprint_source: fingerprint_source,
|
|
140
|
+
ruby_version: RUBY_VERSION,
|
|
141
|
+
rails_version: (Rails.version rescue nil)
|
|
142
|
+
}
|
|
143
|
+
group = Record.group_hash(*parts)
|
|
144
|
+
if handled
|
|
145
|
+
Railwatch.record(:exception, group: group, **rec)
|
|
146
|
+
else
|
|
147
|
+
# The one signal that promotes a failure-context ring, set here --
|
|
148
|
+
# where the exception is actually written -- rather than where the
|
|
149
|
+
# exceptions sample was rolled above, so an exception dropped on
|
|
150
|
+
# the way to this line never ships a sampled-out execution's
|
|
151
|
+
# children (Railwatch.tail_keep?).
|
|
152
|
+
exe.exception_reported = true if exe
|
|
153
|
+
Railwatch.record_now(:exception, group: group, **rec)
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
# The group hash `capture` would assign this error. Public so
|
|
158
|
+
# Railwatch.attach can file an attachment against the same issue without
|
|
159
|
+
# having to re-derive the bucketing rule (source snippets are skipped:
|
|
160
|
+
# they cost I/O and don't take part in the hash).
|
|
161
|
+
def group_for(error)
|
|
162
|
+
parts = error.instance_variable_get(:@__railwatch_fingerprint) ||
|
|
163
|
+
fingerprint_for(error, top_frame(Backtrace.frames(error, with_source: false))).first
|
|
164
|
+
Record.group_hash(*parts)
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
# The frame an occurrence is filed under: the first application frame,
|
|
168
|
+
# falling back to the top of the backtrace for an error raised entirely
|
|
169
|
+
# inside a gem.
|
|
170
|
+
def top_frame(frames)
|
|
171
|
+
frames.find { |f| f[:in_app] } || frames.first || {}
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
MAX_FINGERPRINT_PARTS = 10
|
|
175
|
+
MAX_FINGERPRINT_PART = 200
|
|
176
|
+
|
|
177
|
+
# The parts this occurrence is hashed on, and where they came from:
|
|
178
|
+
# an explicit `Railwatch.report(error, fingerprint: [...])` ("report"),
|
|
179
|
+
# the error object's own #railwatch_fingerprint ("error"), the
|
|
180
|
+
# `Railwatch.fingerprint { }` resolver ("resolver"), or Railwatch's own
|
|
181
|
+
# class/frame/message parts ("default"). Anything that comes back
|
|
182
|
+
# empty -- or raises -- falls back to the default, so a bad resolver
|
|
183
|
+
# can never lose an exception.
|
|
184
|
+
def fingerprint_for(error, top, override: nil)
|
|
185
|
+
default = default_fingerprint(error, top)
|
|
186
|
+
custom, source =
|
|
187
|
+
if override then [ override, "report" ]
|
|
188
|
+
elsif error.respond_to?(:railwatch_fingerprint) then [ error.railwatch_fingerprint, "error" ]
|
|
189
|
+
elsif (resolver = Railwatch.config.fingerprint_resolver) then [ resolver.call(error, default), "resolver" ]
|
|
190
|
+
end
|
|
191
|
+
parts = custom && expand_fingerprint(custom, default)
|
|
192
|
+
parts ? [ parts, source ] : [ cap_fingerprint(default), "default" ]
|
|
193
|
+
rescue StandardError => e
|
|
194
|
+
Railwatch.debug { "fingerprint for #{error.class} raised #{e.class}: #{e.message}; using the default" }
|
|
195
|
+
[ cap_fingerprint(default || [ error.class.name ]), "default" ]
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
def default_fingerprint(error, top)
|
|
199
|
+
[ error.class.name, top[:file], top[:line], normalize_message(message_key(error)) ]
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
# A custom fingerprint: `:default` splices in the parts Railwatch would
|
|
203
|
+
# have used (Sentry's "{{ default }}"), everything else is stringified.
|
|
204
|
+
# nil when nothing usable is left, so the caller can fall back.
|
|
205
|
+
def expand_fingerprint(custom, default)
|
|
206
|
+
parts = Array(custom).flat_map { |part| part == :default ? default : part }
|
|
207
|
+
parts = cap_fingerprint(parts.reject { |part| part.nil? || part.to_s.empty? })
|
|
208
|
+
parts.empty? ? nil : parts
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
def cap_fingerprint(parts)
|
|
212
|
+
parts.first(MAX_FINGERPRINT_PARTS).map { |part| part.to_s[0, MAX_FINGERPRINT_PART] }
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
def remember_fingerprint(error, parts)
|
|
216
|
+
error.instance_variable_set(:@__railwatch_fingerprint, parts)
|
|
217
|
+
rescue StandardError
|
|
218
|
+
nil
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
# config.ignored_exceptions, matched against the error's own class name
|
|
222
|
+
# and every named ancestor, so an app's subclass of an ignored error is
|
|
223
|
+
# ignored too. Sentry's excluded_exceptions equivalent, and it applies to
|
|
224
|
+
# handled and unhandled errors alike.
|
|
225
|
+
def ignored?(error)
|
|
226
|
+
ignored = Railwatch.config.ignored_exceptions
|
|
227
|
+
return false if ignored.empty?
|
|
228
|
+
error.class.ancestors.any? { |ancestor| (name = ancestor.name) && ignored.include?(name) }
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
# Variable data that would otherwise split one issue into thousands of
|
|
232
|
+
# them. Applied in this order: a URL before the numbers inside it, a
|
|
233
|
+
# quoted string before the id it quotes, hex before plain digits.
|
|
234
|
+
MESSAGE_NOISE = [
|
|
235
|
+
%r{\bhttps?://\S+}, # URLs
|
|
236
|
+
/\b[^\s@]+@[^\s@]+\.[^\s@]+\b/, # email addresses
|
|
237
|
+
/\h{8}-\h{4}-\h{4}-\h{4}-\h{12}/, # UUIDs
|
|
238
|
+
/\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}:\d{2}\S*/, # ISO timestamps
|
|
239
|
+
/\b\d{1,3}(?:\.\d{1,3}){3}\b/, # IPv4 addresses
|
|
240
|
+
/(?<!\w)'[^']*'|"[^"]*"/, # quoted strings ("won't" is not one)
|
|
241
|
+
/\b(?:0x)?\h{6,}\b/, # hex: digests, object addresses
|
|
242
|
+
/\b\d+\b/ # plain integers
|
|
243
|
+
].freeze
|
|
244
|
+
|
|
245
|
+
# After the rules above every value in a SQL bind list is "?", so
|
|
246
|
+
# collapse "(?, ?, ?)" to "(?)": an IN (...) groups the same at any length.
|
|
247
|
+
BIND_LIST = /\(\s*\?(?:\s*,\s*\?)*\s*\)/
|
|
248
|
+
|
|
249
|
+
# Classes whose message is mostly the data that varied -- the record
|
|
250
|
+
# that wasn't found, the key that was missing, the receiver that had no
|
|
251
|
+
# method. For those the default key keeps only the message prefix, up
|
|
252
|
+
# to the first ":" (or " for ", for the NameError family), and lets the
|
|
253
|
+
# class and the frame do the rest of the bucketing. Matched on the
|
|
254
|
+
# exact class name, so an app's own subclass keeps its whole message.
|
|
255
|
+
MESSAGE_PREFIXES = {
|
|
256
|
+
"ActiveRecord::RecordNotFound" => ":", "ActiveRecord::RecordInvalid" => ":",
|
|
257
|
+
"KeyError" => ":", "ArgumentError" => ":", "TypeError" => ":",
|
|
258
|
+
"NoMethodError" => " for ", "NameError" => " for "
|
|
259
|
+
}.freeze
|
|
260
|
+
|
|
261
|
+
def normalize_message(message)
|
|
262
|
+
text = MESSAGE_NOISE.inject(message.to_s) { |m, pattern| m.gsub(pattern, "?") }
|
|
263
|
+
text.gsub(BIND_LIST, "(?)").gsub(/\s+/, " ").strip[0, 200]
|
|
264
|
+
end
|
|
265
|
+
|
|
266
|
+
def message_key(error)
|
|
267
|
+
message = error.message.to_s
|
|
268
|
+
separator = MESSAGE_PREFIXES[error.class.name] or return message
|
|
269
|
+
message.split(separator, 2).first.to_s
|
|
270
|
+
end
|
|
271
|
+
|
|
272
|
+
# Decided once per execution and memoized on exception_sampled, so the
|
|
273
|
+
# sampling roll happens exactly once even across many exceptions.
|
|
274
|
+
def exception_sampled?(exe)
|
|
275
|
+
return exe.exception_sampled unless exe.exception_sampled.nil?
|
|
276
|
+
exe.exception_sampled = Sampler.decide(:exceptions)
|
|
277
|
+
end
|
|
278
|
+
|
|
279
|
+
# errno-style code: SystemCallError subclasses (Errno::ECONNREFUSED etc)
|
|
280
|
+
# define an Errno class constant; some drivers expose #errno or #code.
|
|
281
|
+
def error_code(error)
|
|
282
|
+
if error.class.const_defined?(:Errno)
|
|
283
|
+
error.class.const_get(:Errno)
|
|
284
|
+
elsif error.respond_to?(:errno)
|
|
285
|
+
error.errno
|
|
286
|
+
elsif error.respond_to?(:code)
|
|
287
|
+
error.code
|
|
288
|
+
end
|
|
289
|
+
rescue StandardError
|
|
290
|
+
nil
|
|
291
|
+
end
|
|
292
|
+
|
|
293
|
+
# Database SQLSTATE for ActiveRecord::StatementInvalid, when the
|
|
294
|
+
# underlying driver error exposes one (e.g. pg; sqlite3 does not).
|
|
295
|
+
def sql_state_for(error)
|
|
296
|
+
return nil unless defined?(ActiveRecord::StatementInvalid) && error.is_a?(ActiveRecord::StatementInvalid)
|
|
297
|
+
cause = error.cause
|
|
298
|
+
cause.respond_to?(:sql_state) ? cause.sql_state : nil
|
|
299
|
+
rescue StandardError
|
|
300
|
+
nil
|
|
301
|
+
end
|
|
302
|
+
end
|
|
303
|
+
end
|
|
304
|
+
end
|
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Railwatch
|
|
4
|
+
module Subscribers
|
|
5
|
+
# Active Job enqueue and perform, Solid Queue recurring tasks and
|
|
6
|
+
# pruned-process failures. A job attempt is its own execution context,
|
|
7
|
+
# linked to the enqueuing request through JobTracing.
|
|
8
|
+
module Jobs
|
|
9
|
+
extend Base
|
|
10
|
+
|
|
11
|
+
module_function
|
|
12
|
+
|
|
13
|
+
def install!(_app)
|
|
14
|
+
%w[enqueue enqueue_at enqueue_all].each do |name|
|
|
15
|
+
subscribe("#{name}.active_job") do |event|
|
|
16
|
+
exe = execution
|
|
17
|
+
jobs = name == "enqueue_all" ? Array(event.payload[:jobs]) : [ event.payload[:job] ]
|
|
18
|
+
exe&.count(:jobs_enqueued, jobs.size)
|
|
19
|
+
next unless recording?
|
|
20
|
+
jobs.each do |job|
|
|
21
|
+
Railwatch.record(:enqueued_job,
|
|
22
|
+
group: Record.group_hash(job.class.name),
|
|
23
|
+
timestamp: started_at(event),
|
|
24
|
+
job_id: job.job_id,
|
|
25
|
+
name: job.class.name,
|
|
26
|
+
queue: job.queue_name.to_s,
|
|
27
|
+
adapter: adapter_name(event.payload[:adapter]),
|
|
28
|
+
priority: job.priority,
|
|
29
|
+
scheduled_at: job.scheduled_at&.to_f,
|
|
30
|
+
duration: micros(event),
|
|
31
|
+
failed: event.payload[:exception].present? || (job.respond_to?(:successfully_enqueued?) && job.successfully_enqueued? == false))
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
subscribe("perform_start.active_job") do |event|
|
|
37
|
+
job = event.payload[:job]
|
|
38
|
+
key, run_at = recurring_task_key(job)
|
|
39
|
+
exe = Railwatch.start_execution(
|
|
40
|
+
source: key ? :scheduled_task : :job,
|
|
41
|
+
sample_kind: key ? :scheduled_tasks : :jobs,
|
|
42
|
+
trace_id: job.respond_to?(:railwatch_trace_id) ? job.railwatch_trace_id : nil,
|
|
43
|
+
parent_id: job.respond_to?(:railwatch_parent_id) ? job.railwatch_parent_id : nil,
|
|
44
|
+
preview: job.class.name)
|
|
45
|
+
exe.enter_stage(:action)
|
|
46
|
+
# The enqueuing execution's identity, restored from the payload
|
|
47
|
+
# before anything is recorded, so the job_attempt parent and every
|
|
48
|
+
# child record under it carry the same user and tenant as the
|
|
49
|
+
# request that enqueued the job. Local resolution stays the
|
|
50
|
+
# fallback: an older payload, or a job nobody enqueued on a user's
|
|
51
|
+
# behalf, still resolves whatever this process can see. A tenant
|
|
52
|
+
# that was not propagated is left nil so Execution#envelope can
|
|
53
|
+
# still late-bind one the job binds itself (with_tenant).
|
|
54
|
+
propagated_user = job.railwatch_user if job.respond_to?(:railwatch_user)
|
|
55
|
+
propagated_tenant = job.railwatch_tenant if job.respond_to?(:railwatch_tenant)
|
|
56
|
+
exe.tenant = propagated_tenant if propagated_tenant
|
|
57
|
+
exe.user_id = propagated_user || Users.resolve_from_current
|
|
58
|
+
exe.queue_latency = queue_latency_micros(job)
|
|
59
|
+
exe.drift = drift_micros(run_at) if key
|
|
60
|
+
job.instance_variable_set(:@__railwatch_execution, exe)
|
|
61
|
+
job.instance_variable_set(:@__railwatch_recurring_key, key)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
subscribe("perform.active_job") do |event|
|
|
65
|
+
job = event.payload[:job]
|
|
66
|
+
exe = job.instance_variable_get(:@__railwatch_execution) or next
|
|
67
|
+
Railwatch::Current.execution = exe
|
|
68
|
+
exe.finish_stages
|
|
69
|
+
p = event.payload
|
|
70
|
+
released = job.instance_variable_get(:@__railwatch_released)
|
|
71
|
+
status = if released then "released"
|
|
72
|
+
elsif p[:exception_object] then "failed"
|
|
73
|
+
elsif p[:aborted] then "aborted"
|
|
74
|
+
else "processed"
|
|
75
|
+
end
|
|
76
|
+
if p[:exception_object]
|
|
77
|
+
Exceptions.capture(p[:exception_object], handled: false, severity: :error, source: "application.active_job")
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
key = job.instance_variable_get(:@__railwatch_recurring_key)
|
|
81
|
+
fields = {
|
|
82
|
+
job_id: job.job_id,
|
|
83
|
+
provider_job_id: job.provider_job_id&.to_s,
|
|
84
|
+
attempt_id: exe.id,
|
|
85
|
+
attempt: job.executions,
|
|
86
|
+
name: job.class.name,
|
|
87
|
+
queue: job.queue_name.to_s,
|
|
88
|
+
adapter: adapter_name(p[:adapter]),
|
|
89
|
+
connection: adapter_name(p[:adapter]),
|
|
90
|
+
concurrency_key: job.respond_to?(:concurrency_key) ? job.concurrency_key : nil,
|
|
91
|
+
priority: job.priority,
|
|
92
|
+
status: status,
|
|
93
|
+
queue_latency: exe.queue_latency,
|
|
94
|
+
db_runtime: p[:db_runtime]&.round(2),
|
|
95
|
+
arguments_preview: arguments_preview(job),
|
|
96
|
+
**captured_arguments(job)
|
|
97
|
+
}
|
|
98
|
+
if key
|
|
99
|
+
Railwatch.finish_execution(:scheduled_task, group: Record.group_hash(key), task_key: key,
|
|
100
|
+
schedule: schedule_for(key), drift: exe.drift, **fields)
|
|
101
|
+
else
|
|
102
|
+
Railwatch.finish_execution(:job_attempt, group: Record.group_hash(job.class.name), **fields)
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
subscribe("enqueue_retry.active_job") do |event|
|
|
107
|
+
p = event.payload
|
|
108
|
+
# Flag the job so perform.active_job reports "released" instead of
|
|
109
|
+
# "failed" -- the exception was handled internally by retry_on and
|
|
110
|
+
# never escaped perform_now, so this is the only signal we get.
|
|
111
|
+
p[:job]&.instance_variable_set(:@__railwatch_released, true)
|
|
112
|
+
next unless recording?
|
|
113
|
+
if Railwatch.config.capture_job_retry_errors && p[:error]
|
|
114
|
+
Exceptions.capture(p[:error], handled: true, severity: :warning,
|
|
115
|
+
context: { attempt: p[:job]&.executions, wait: p[:wait] },
|
|
116
|
+
source: "application.active_job.enqueue_retry")
|
|
117
|
+
end
|
|
118
|
+
Railwatch.record(:log, level: "warn", message: "Retrying #{p[:job].class.name} in #{p[:wait]}s: #{p[:error]&.class}",
|
|
119
|
+
tags: [ "active_job", "retry" ], context: "{}")
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
subscribe("retry_stopped.active_job") do |event|
|
|
123
|
+
p = event.payload
|
|
124
|
+
Exceptions.capture(p[:error], handled: false, severity: :error, source: "application.active_job.retry_stopped") if p[:error]
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
subscribe("discard.active_job") do |event|
|
|
128
|
+
p = event.payload
|
|
129
|
+
Exceptions.capture(p[:error], handled: true, severity: :warning, source: "application.active_job.discard") if p[:error]
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
# Solid Queue: jobs whose worker was killed or pruned never fire perform.active_job.
|
|
133
|
+
# Each pruned job gets its own throwaway execution so its job_attempt
|
|
134
|
+
# record carries a fresh execution_id/trace_id instead of borrowing
|
|
135
|
+
# whatever happens to be Current at the time the sweep runs.
|
|
136
|
+
subscribe("fail_many_claimed.solid_queue") do |event|
|
|
137
|
+
p = event.payload
|
|
138
|
+
Array(p[:job_ids]).each do |job_id|
|
|
139
|
+
Railwatch::Current.with(Railwatch::Execution.new(source: :job, sampled: true)) do
|
|
140
|
+
Railwatch.record_now(:job_attempt, group: Record.group_hash("SolidQueue::Pruned"),
|
|
141
|
+
job_id: nil, provider_job_id: job_id.to_s, name: "(pruned)", status: "failed",
|
|
142
|
+
queue: nil, duration: 0, attempt: nil, stages: {}, counters: {},
|
|
143
|
+
exception_preview: p[:error].to_s[0, 255])
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
subscribe("enqueue_recurring_task.solid_queue") do |event|
|
|
149
|
+
p = event.payload
|
|
150
|
+
next if p[:skipped]
|
|
151
|
+
Railwatch.record(:log, level: p[:enqueue_error] ? "error" : "info",
|
|
152
|
+
message: "Scheduled #{p[:task]} for #{p[:at]}#{p[:enqueue_error] && ": #{p[:enqueue_error]}"}",
|
|
153
|
+
tags: [ "solid_queue", "recurring" ], context: JSON.generate(task: p[:task], active_job_id: p[:active_job_id]))
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
def adapter_name(adapter)
|
|
158
|
+
adapter.class.name.to_s.demodulize.delete_suffix("Adapter")
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
# Measured at perform-start (stored on the execution), not at
|
|
162
|
+
# completion -- otherwise a slow perform inflates its own queue latency.
|
|
163
|
+
def queue_latency_micros(job)
|
|
164
|
+
started = job.scheduled_at || job.enqueued_at
|
|
165
|
+
return nil unless started
|
|
166
|
+
((Clock.now - started.to_time.utc.to_f) * 1_000_000).round
|
|
167
|
+
rescue StandardError
|
|
168
|
+
nil
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
# Difference between the recurring task's scheduled run_at and when
|
|
172
|
+
# this perform actually started, in the same units and at the same
|
|
173
|
+
# point in the lifecycle as queue_latency_micros.
|
|
174
|
+
def drift_micros(run_at)
|
|
175
|
+
return nil unless run_at
|
|
176
|
+
((Clock.now - run_at.to_f) * 1_000_000).round
|
|
177
|
+
rescue StandardError
|
|
178
|
+
nil
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
def arguments_preview(job)
|
|
182
|
+
job.arguments.map { |a| a.respond_to?(:to_global_id) ? a.to_global_id.to_s : a.class.name }.first(10)
|
|
183
|
+
rescue StandardError
|
|
184
|
+
[]
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
ARGUMENTS_MAX_BYTES = 8 * 1024
|
|
188
|
+
|
|
189
|
+
# The job's real arguments, off by default (capture_job_arguments)
|
|
190
|
+
# because they routinely carry PII -- arguments_preview above ships
|
|
191
|
+
# only their shape and is always on.
|
|
192
|
+
#
|
|
193
|
+
# job.serialize["arguments"] is Active Job's own JSON-safe form, so an
|
|
194
|
+
# Active Record argument is already a GlobalID string rather than a
|
|
195
|
+
# hydrated model.
|
|
196
|
+
def captured_arguments(job)
|
|
197
|
+
return {} unless Railwatch.config.capture_job_arguments
|
|
198
|
+
|
|
199
|
+
kept, truncated = fit_arguments(redact_arguments(job.serialize["arguments"]))
|
|
200
|
+
truncated ? { arguments: kept, arguments_truncated: true } : { arguments: kept }
|
|
201
|
+
rescue StandardError
|
|
202
|
+
{}
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
# Hash arguments (including hashes nested in an array argument) go
|
|
206
|
+
# through the same parameter filter as request params, so a
|
|
207
|
+
# `password:` keyword ships as [FILTERED].
|
|
208
|
+
def redact_arguments(arguments)
|
|
209
|
+
Array(arguments).map do |argument|
|
|
210
|
+
case argument
|
|
211
|
+
when Hash then Railwatch.redactor.params(argument)
|
|
212
|
+
when Array then redact_arguments(argument)
|
|
213
|
+
else argument
|
|
214
|
+
end
|
|
215
|
+
end
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
# Drops trailing arguments until the JSON fits, rather than truncating
|
|
219
|
+
# the JSON itself into something the platform can't parse.
|
|
220
|
+
def fit_arguments(arguments)
|
|
221
|
+
return [ arguments, false ] if JSON.generate(arguments).bytesize <= ARGUMENTS_MAX_BYTES
|
|
222
|
+
|
|
223
|
+
kept = arguments.dup
|
|
224
|
+
kept.pop while kept.any? && JSON.generate(kept).bytesize > ARGUMENTS_MAX_BYTES
|
|
225
|
+
[ kept, true ]
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
# A job is a scheduled task when Solid Queue recorded a RecurringExecution
|
|
229
|
+
# for it. Cheap lookup by job_id, memoised per job, only when Solid Queue
|
|
230
|
+
# is the adapter and recurring tasks are configured. Returns [task_key,
|
|
231
|
+
# run_at], or nil when there is no matching RecurringExecution.
|
|
232
|
+
def recurring_task_key(job)
|
|
233
|
+
return nil unless defined?(::SolidQueue::RecurringExecution)
|
|
234
|
+
return nil if recurring_keys.empty?
|
|
235
|
+
return [ job.class.name, nil ] if job.is_a?(::SolidQueue::RecurringJob)
|
|
236
|
+
return nil unless recurring_job_classes.include?(job.class.name)
|
|
237
|
+
Railwatch.ignore do
|
|
238
|
+
::SolidQueue::RecurringExecution.joins(:job).where(solid_queue_jobs: { active_job_id: job.job_id }).pick(:task_key, :run_at)
|
|
239
|
+
end
|
|
240
|
+
rescue StandardError
|
|
241
|
+
nil
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
# Solid Queue's recurring task table changes when config/recurring.yml
|
|
245
|
+
# is reloaded or dynamic tasks are scheduled, so the lookup tables are
|
|
246
|
+
# re-read every RECURRING_TTL seconds instead of once per process.
|
|
247
|
+
RECURRING_TTL = 60
|
|
248
|
+
|
|
249
|
+
def recurring_tasks
|
|
250
|
+
now = Clock.monotonic
|
|
251
|
+
if @recurring_tasks.nil? || now - @recurring_read_at > RECURRING_TTL
|
|
252
|
+
@recurring_tasks = Railwatch.ignore { load_recurring_tasks }
|
|
253
|
+
@recurring_read_at = now
|
|
254
|
+
end
|
|
255
|
+
@recurring_tasks
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
def load_recurring_tasks
|
|
259
|
+
rows = ::SolidQueue::RecurringTask.pluck(:key, :class_name, :schedule)
|
|
260
|
+
{ keys: rows.map(&:first), classes: rows.filter_map { |_k, c, _s| c }.uniq, schedules: rows.to_h { |k, _c, sch| [ k, sch ] } }
|
|
261
|
+
rescue StandardError
|
|
262
|
+
{ keys: [], classes: [], schedules: {} }
|
|
263
|
+
end
|
|
264
|
+
|
|
265
|
+
def refresh_recurring_tasks!
|
|
266
|
+
@recurring_tasks = nil
|
|
267
|
+
end
|
|
268
|
+
|
|
269
|
+
def recurring_keys
|
|
270
|
+
recurring_tasks[:keys]
|
|
271
|
+
end
|
|
272
|
+
|
|
273
|
+
def recurring_job_classes
|
|
274
|
+
recurring_tasks[:classes]
|
|
275
|
+
end
|
|
276
|
+
|
|
277
|
+
def schedule_for(key)
|
|
278
|
+
recurring_tasks[:schedules][key]
|
|
279
|
+
end
|
|
280
|
+
end
|
|
281
|
+
end
|
|
282
|
+
end
|