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.
Files changed (78) hide show
  1. checksums.yaml +7 -0
  2. data/AGENTS.md +122 -0
  3. data/CHANGELOG.md +462 -0
  4. data/MIT-LICENSE +20 -0
  5. data/README.md +226 -0
  6. data/app/controllers/railwatch/beacon_controller.rb +254 -0
  7. data/config/routes.rb +5 -0
  8. data/docs/ai-and-mcp.md +227 -0
  9. data/docs/configuration.md +931 -0
  10. data/docs/faq.md +230 -0
  11. data/docs/getting-started.md +279 -0
  12. data/docs/records.md +834 -0
  13. data/docs/replacing-nightwatch.md +216 -0
  14. data/docs/replacing-sentry.md +573 -0
  15. data/docs/security.md +94 -0
  16. data/docs/self-hosting.md +60 -0
  17. data/docs/source-maps.md +60 -0
  18. data/docs/testing.md +175 -0
  19. data/docs/troubleshooting.md +319 -0
  20. data/lib/generators/railwatch/install/install_generator.rb +280 -0
  21. data/lib/generators/railwatch/install/templates/initializer.rb +54 -0
  22. data/lib/generators/railwatch/install/templates/post-deploy +98 -0
  23. data/lib/generators/railwatch/install/templates/railwatch.ts +658 -0
  24. data/lib/railwatch/attachments.rb +83 -0
  25. data/lib/railwatch/backtrace.rb +158 -0
  26. data/lib/railwatch/buffer.rb +122 -0
  27. data/lib/railwatch/clock.rb +25 -0
  28. data/lib/railwatch/configuration.rb +334 -0
  29. data/lib/railwatch/console.rb +48 -0
  30. data/lib/railwatch/context.rb +125 -0
  31. data/lib/railwatch/controller_helpers.rb +21 -0
  32. data/lib/railwatch/current.rb +32 -0
  33. data/lib/railwatch/engine.rb +144 -0
  34. data/lib/railwatch/execution.rb +367 -0
  35. data/lib/railwatch/faraday.rb +73 -0
  36. data/lib/railwatch/health.rb +188 -0
  37. data/lib/railwatch/job_tracing.rb +49 -0
  38. data/lib/railwatch/middleware/request.rb +289 -0
  39. data/lib/railwatch/minitest.rb +43 -0
  40. data/lib/railwatch/patches/inertia.rb +34 -0
  41. data/lib/railwatch/patches/net_http.rb +102 -0
  42. data/lib/railwatch/patches/rake_task.rb +88 -0
  43. data/lib/railwatch/patches/runner_command.rb +120 -0
  44. data/lib/railwatch/patches.rb +43 -0
  45. data/lib/railwatch/profiler.rb +270 -0
  46. data/lib/railwatch/record.rb +119 -0
  47. data/lib/railwatch/redactor.rb +67 -0
  48. data/lib/railwatch/release_detector.rb +97 -0
  49. data/lib/railwatch/reporter.rb +539 -0
  50. data/lib/railwatch/rspec.rb +139 -0
  51. data/lib/railwatch/sampler.rb +17 -0
  52. data/lib/railwatch/secret_safety.rb +62 -0
  53. data/lib/railwatch/sessions.rb +162 -0
  54. data/lib/railwatch/source_maps.rb +59 -0
  55. data/lib/railwatch/spec_helper.rb +147 -0
  56. data/lib/railwatch/sql_normalizer.rb +398 -0
  57. data/lib/railwatch/subscribers/base.rb +54 -0
  58. data/lib/railwatch/subscribers/broadcasts.rb +107 -0
  59. data/lib/railwatch/subscribers/cache.rb +107 -0
  60. data/lib/railwatch/subscribers/deprecations.rb +26 -0
  61. data/lib/railwatch/subscribers/exceptions.rb +304 -0
  62. data/lib/railwatch/subscribers/jobs.rb +282 -0
  63. data/lib/railwatch/subscribers/logs.rb +137 -0
  64. data/lib/railwatch/subscribers/mail.rb +42 -0
  65. data/lib/railwatch/subscribers/notifications.rb +36 -0
  66. data/lib/railwatch/subscribers/process_info.rb +98 -0
  67. data/lib/railwatch/subscribers/queries.rb +183 -0
  68. data/lib/railwatch/subscribers/requests.rb +94 -0
  69. data/lib/railwatch/subscribers/storage.rb +35 -0
  70. data/lib/railwatch/subscribers/users.rb +159 -0
  71. data/lib/railwatch/subscribers/views.rb +54 -0
  72. data/lib/railwatch/subscribers.rb +34 -0
  73. data/lib/railwatch/transport/http.rb +208 -0
  74. data/lib/railwatch/version.rb +5 -0
  75. data/lib/railwatch.rb +550 -0
  76. data/lib/tasks/railwatch_tasks.rake +289 -0
  77. data/llms.txt +38 -0
  78. 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