rails_error_dashboard 0.12.1 → 0.14.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 +4 -4
- data/app/controllers/rails_error_dashboard/application_controller.rb +92 -11
- data/app/controllers/rails_error_dashboard/errors_controller.rb +111 -34
- data/app/controllers/rails_error_dashboard/webhooks_controller.rb +3 -0
- data/app/helpers/rails_error_dashboard/application_helper.rb +46 -1
- data/app/helpers/rails_error_dashboard/backtrace_helper.rb +8 -0
- data/app/jobs/rails_error_dashboard/async_error_logging_job.rb +10 -0
- data/app/jobs/rails_error_dashboard/concerns/plain_channel_message.rb +71 -0
- data/app/jobs/rails_error_dashboard/notification_burst_summary_job.rb +55 -0
- data/app/jobs/rails_error_dashboard/retention_cleanup_job.rb +122 -1
- data/app/jobs/rails_error_dashboard/storm_flush_job.rb +7 -4
- data/app/jobs/rails_error_dashboard/storm_notification_job.rb +8 -44
- data/app/models/rails_error_dashboard/error_baseline.rb +12 -9
- data/app/models/rails_error_dashboard/error_comment.rb +0 -5
- data/app/models/rails_error_dashboard/error_log.rb +19 -3
- data/app/models/rails_error_dashboard/error_logs_record.rb +34 -0
- data/app/models/rails_error_dashboard/error_occurrence.rb +9 -1
- data/app/models/rails_error_dashboard/event_count.rb +132 -0
- data/app/models/rails_error_dashboard/event_timing_gap.rb +55 -0
- data/app/views/layouts/rails_error_dashboard.html.erb +58 -5
- data/app/views/rails_error_dashboard/errors/_discussion.html.erb +18 -11
- data/app/views/rails_error_dashboard/errors/_issue_section.html.erb +22 -8
- data/app/views/rails_error_dashboard/errors/_request_context.html.erb +2 -0
- data/app/views/rails_error_dashboard/errors/_sidebar_metadata.html.erb +15 -6
- data/app/views/rails_error_dashboard/errors/analytics.html.erb +8 -8
- data/app/views/rails_error_dashboard/errors/diagnostic_dumps.html.erb +1 -1
- data/app/views/rails_error_dashboard/errors/index.html.erb +6 -1
- data/app/views/rails_error_dashboard/errors/overview.html.erb +12 -0
- data/app/views/rails_error_dashboard/errors/platform_comparison.html.erb +7 -7
- data/app/views/rails_error_dashboard/errors/releases.html.erb +2 -2
- data/app/views/rails_error_dashboard/errors/settings.html.erb +2 -0
- data/app/views/rails_error_dashboard/errors/show.html.erb +3 -3
- data/config/locales/de.yml +30 -0
- data/config/locales/en.yml +37 -0
- data/config/locales/es.yml +30 -0
- data/config/locales/fr.yml +30 -0
- data/config/locales/it.yml +30 -0
- data/config/locales/ja.yml +30 -0
- data/config/locales/pl.yml +30 -0
- data/config/locales/pt-BR.yml +30 -0
- data/config/locales/ru.yml +30 -0
- data/config/locales/uk.yml +30 -0
- data/config/locales/zh-CN.yml +30 -0
- data/db/migrate/20260917000001_add_last_notified_at_to_error_logs.rb +40 -0
- data/db/migrate/20260919000001_create_event_counts.rb +71 -0
- data/db/migrate/20260920000001_add_buckets_incomplete_to_storm_events.rb +25 -0
- data/db/migrate/20260920000002_create_event_timing_gaps.rb +55 -0
- data/lib/generators/rails_error_dashboard/install/templates/initializer.rb +12 -1
- data/lib/rails_error_dashboard/commands/assign_error.rb +12 -2
- data/lib/rails_error_dashboard/commands/backfill_environments.rb +2 -0
- data/lib/rails_error_dashboard/commands/backfill_resolved_at.rb +42 -0
- data/lib/rails_error_dashboard/commands/batch_delete_errors.rb +1 -0
- data/lib/rails_error_dashboard/commands/batch_mute_errors.rb +2 -0
- data/lib/rails_error_dashboard/commands/batch_resolve_errors.rb +2 -0
- data/lib/rails_error_dashboard/commands/batch_unmute_errors.rb +2 -0
- data/lib/rails_error_dashboard/commands/find_or_increment_error.rb +109 -11
- data/lib/rails_error_dashboard/commands/flush_rack_attack_events.rb +3 -1
- data/lib/rails_error_dashboard/commands/flush_storm_counts.rb +252 -12
- data/lib/rails_error_dashboard/commands/flush_swallowed_exceptions.rb +5 -2
- data/lib/rails_error_dashboard/commands/link_existing_issue.rb +1 -0
- data/lib/rails_error_dashboard/commands/log_error.rb +291 -40
- data/lib/rails_error_dashboard/commands/mute_error.rb +1 -0
- data/lib/rails_error_dashboard/commands/resolve_error.rb +2 -0
- data/lib/rails_error_dashboard/commands/scrub_invalid_encoding.rb +104 -0
- data/lib/rails_error_dashboard/commands/snooze_error.rb +34 -10
- data/lib/rails_error_dashboard/commands/unmute_error.rb +1 -0
- data/lib/rails_error_dashboard/commands/update_error_priority.rb +23 -2
- data/lib/rails_error_dashboard/commands/update_error_status.rb +33 -6
- data/lib/rails_error_dashboard/configuration.rb +39 -1
- data/lib/rails_error_dashboard/engine.rb +28 -0
- data/lib/rails_error_dashboard/manual_error_reporter.rb +16 -5
- data/lib/rails_error_dashboard/queries/analytics_stats.rb +89 -30
- data/lib/rails_error_dashboard/queries/baseline_stats.rb +107 -0
- data/lib/rails_error_dashboard/queries/dashboard_stats.rb +216 -74
- data/lib/rails_error_dashboard/queries/error_correlation.rb +6 -3
- data/lib/rails_error_dashboard/queries/errors_list.rb +15 -2
- data/lib/rails_error_dashboard/queries/event_volume.rb +503 -0
- data/lib/rails_error_dashboard/queries/similar_errors.rb +1 -1
- data/lib/rails_error_dashboard/services/analytics_cache_manager.rb +43 -18
- data/lib/rails_error_dashboard/services/backtrace_processor.rb +3 -1
- data/lib/rails_error_dashboard/services/breadcrumb_collector.rb +23 -0
- data/lib/rails_error_dashboard/services/cause_chain_extractor.rb +3 -1
- data/lib/rails_error_dashboard/services/codeberg_issue_client.rb +13 -2
- data/lib/rails_error_dashboard/services/diagnostic_dump_generator.rb +5 -3
- data/lib/rails_error_dashboard/services/encoding_sanitizer.rb +80 -0
- data/lib/rails_error_dashboard/services/error_broadcaster.rb +180 -32
- data/lib/rails_error_dashboard/services/error_hash_generator.rb +9 -3
- data/lib/rails_error_dashboard/services/error_notification_dispatcher.rb +13 -0
- data/lib/rails_error_dashboard/services/exception_filter.rb +55 -0
- data/lib/rails_error_dashboard/services/git_head_reader.rb +102 -0
- data/lib/rails_error_dashboard/services/notification_throttler.rb +172 -28
- data/lib/rails_error_dashboard/services/sensitive_data_filter.rb +47 -1
- data/lib/rails_error_dashboard/services/storm_protection/circuit_breaker.rb +59 -3
- data/lib/rails_error_dashboard/services/storm_protection/count_buffer.rb +64 -6
- data/lib/rails_error_dashboard/services/storm_protection/fingerprint_buckets.rb +25 -2
- data/lib/rails_error_dashboard/services/storm_protection/gate.rb +32 -5
- data/lib/rails_error_dashboard/services/swallowed_exception_tracker.rb +99 -23
- data/lib/rails_error_dashboard/services/url_safety.rb +40 -0
- data/lib/rails_error_dashboard/services/variable_serializer.rb +125 -10
- data/lib/rails_error_dashboard/subscribers/breadcrumb_subscriber.rb +111 -0
- data/lib/rails_error_dashboard/subscribers/issue_tracker_subscriber.rb +11 -2
- data/lib/rails_error_dashboard/value_objects/error_context.rb +40 -3
- data/lib/rails_error_dashboard/version.rb +1 -1
- data/lib/rails_error_dashboard.rb +34 -0
- data/lib/tasks/error_dashboard.rake +54 -4
- metadata +16 -2
|
@@ -25,6 +25,17 @@ module RailsErrorDashboard
|
|
|
25
25
|
RAISE_THREAD_KEY = :red_swallowed_raises
|
|
26
26
|
RESCUE_THREAD_KEY = :red_swallowed_rescues
|
|
27
27
|
FLUSH_THREAD_KEY = :red_swallowed_last_flush
|
|
28
|
+
# Monotonic time at which this thread's buffer stopped being empty. The
|
|
29
|
+
# flush is due one interval after THAT, whether or not anything else
|
|
30
|
+
# happens on the thread.
|
|
31
|
+
DEADLINE_THREAD_KEY = :red_swallowed_armed_at
|
|
32
|
+
|
|
33
|
+
# Where evicted counts go, so eviction bounds memory without losing the
|
|
34
|
+
# total. The flush command splits keys on "|" and "->", so these persist as
|
|
35
|
+
# ordinary rows under the class name "[overflow]".
|
|
36
|
+
OVERFLOW_LABEL = "[overflow]"
|
|
37
|
+
RAISE_OVERFLOW_KEY = "#{OVERFLOW_LABEL}|#{OVERFLOW_LABEL}".freeze
|
|
38
|
+
RESCUE_OVERFLOW_KEY = "#{OVERFLOW_LABEL}|#{OVERFLOW_LABEL}->#{OVERFLOW_LABEL}".freeze
|
|
28
39
|
RAISE_LOC_IVAR = :@_red_raise_loc
|
|
29
40
|
|
|
30
41
|
# Flow-control exceptions that are commonly raised/rescued in normal Rails operation.
|
|
@@ -95,7 +106,7 @@ module RailsErrorDashboard
|
|
|
95
106
|
end
|
|
96
107
|
|
|
97
108
|
# Force flush the current thread's counters (used by job and tests)
|
|
98
|
-
def flush!
|
|
109
|
+
def flush!(sync: false)
|
|
99
110
|
raises = Thread.current[RAISE_THREAD_KEY]
|
|
100
111
|
rescues = Thread.current[RESCUE_THREAD_KEY]
|
|
101
112
|
return if raises.nil? && rescues.nil?
|
|
@@ -107,14 +118,42 @@ module RailsErrorDashboard
|
|
|
107
118
|
raises&.clear
|
|
108
119
|
rescues&.clear
|
|
109
120
|
Thread.current[FLUSH_THREAD_KEY] = Time.now.to_f
|
|
121
|
+
Thread.current[DEADLINE_THREAD_KEY] = nil
|
|
110
122
|
|
|
111
|
-
dispatch_flush(raise_snapshot, rescue_snapshot)
|
|
123
|
+
dispatch_flush(raise_snapshot, rescue_snapshot, sync: sync)
|
|
112
124
|
rescue => e
|
|
113
125
|
RailsErrorDashboard::Logger.debug(
|
|
114
126
|
"[RailsErrorDashboard] SwallowedExceptionTracker.flush! failed: #{e.class} - #{e.message}"
|
|
115
127
|
)
|
|
116
128
|
end
|
|
117
129
|
|
|
130
|
+
# Drain this thread's buffer at the end of a unit of work (a request or a
|
|
131
|
+
# job) once it has been waiting for flush_interval.
|
|
132
|
+
#
|
|
133
|
+
# Without this the ONLY in-process drain was maybe_flush! inside the
|
|
134
|
+
# :rescue callback, so a buffer could only ever be flushed by a LATER
|
|
135
|
+
# rescue on the SAME thread: a swallowed exception that happened once
|
|
136
|
+
# stayed invisible until the process exited, and counts on a Puma thread
|
|
137
|
+
# that retired were lost. Same defect, same fix, as
|
|
138
|
+
# RackAttackTracker#flush_if_due!.
|
|
139
|
+
#
|
|
140
|
+
# Wired to Rails.application.executor.to_complete, which fires after the
|
|
141
|
+
# response body is closed, so this never delays a request. Deadline-gated:
|
|
142
|
+
# the usual cost is one thread-local read.
|
|
143
|
+
def flush_if_due!
|
|
144
|
+
return unless flush_due?
|
|
145
|
+
|
|
146
|
+
# sync: the response is already sent, and one upsert per interval is
|
|
147
|
+
# cheaper than enqueueing a job to do it.
|
|
148
|
+
flush!(sync: true)
|
|
149
|
+
nil
|
|
150
|
+
rescue => e
|
|
151
|
+
RailsErrorDashboard::Logger.debug(
|
|
152
|
+
"[RailsErrorDashboard] SwallowedExceptionTracker.flush_if_due! failed: #{e.class} - #{e.message}"
|
|
153
|
+
)
|
|
154
|
+
nil
|
|
155
|
+
end
|
|
156
|
+
|
|
118
157
|
# Read current thread's counters (for testing/inspection)
|
|
119
158
|
def current_raises
|
|
120
159
|
Thread.current[RAISE_THREAD_KEY] || {}
|
|
@@ -129,6 +168,7 @@ module RailsErrorDashboard
|
|
|
129
168
|
Thread.current[RAISE_THREAD_KEY] = nil
|
|
130
169
|
Thread.current[RESCUE_THREAD_KEY] = nil
|
|
131
170
|
Thread.current[FLUSH_THREAD_KEY] = nil
|
|
171
|
+
Thread.current[DEADLINE_THREAD_KEY] = nil
|
|
132
172
|
end
|
|
133
173
|
|
|
134
174
|
private
|
|
@@ -152,11 +192,9 @@ module RailsErrorDashboard
|
|
|
152
192
|
class_name = exception.class.name || exception.class.to_s
|
|
153
193
|
key = "#{class_name}|#{location}"
|
|
154
194
|
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
# 5. LRU eviction if over capacity
|
|
159
|
-
evict_oldest!(raises) if raises.size > max_cache_size
|
|
195
|
+
# 4b/5. Count it, most-recent-last, evicting into the overflow bucket
|
|
196
|
+
increment!((Thread.current[RAISE_THREAD_KEY] ||= {}), key, RAISE_OVERFLOW_KEY)
|
|
197
|
+
arm_deadline!
|
|
160
198
|
end
|
|
161
199
|
|
|
162
200
|
# TracePoint(:rescue) callback
|
|
@@ -181,11 +219,9 @@ module RailsErrorDashboard
|
|
|
181
219
|
class_name = exception.class.name || exception.class.to_s
|
|
182
220
|
key = "#{class_name}|#{raise_loc}->#{rescue_loc}"
|
|
183
221
|
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
# 5. LRU eviction if over capacity
|
|
188
|
-
evict_oldest!(rescues) if rescues.size > max_cache_size
|
|
222
|
+
# 4b/5. Count it, most-recent-last, evicting into the overflow bucket
|
|
223
|
+
increment!((Thread.current[RESCUE_THREAD_KEY] ||= {}), key, RESCUE_OVERFLOW_KEY)
|
|
224
|
+
arm_deadline!
|
|
189
225
|
|
|
190
226
|
# 6. Maybe flush
|
|
191
227
|
maybe_flush!
|
|
@@ -210,21 +246,60 @@ module RailsErrorDashboard
|
|
|
210
246
|
false
|
|
211
247
|
end
|
|
212
248
|
|
|
213
|
-
#
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
249
|
+
# Count one event. delete-and-reinsert moves the key to the END of the
|
|
250
|
+
# Hash, so insertion order IS recency order and "oldest" below means least
|
|
251
|
+
# recently UPDATED. A plain `hash[key] += 1` leaves a key where it was
|
|
252
|
+
# first inserted, which made the busiest key in the process the first one
|
|
253
|
+
# evicted.
|
|
254
|
+
def increment!(hash, key, overflow_key)
|
|
255
|
+
hash[key] = hash.delete(key).to_i + 1
|
|
256
|
+
|
|
257
|
+
# Loops because the overflow bucket takes a slot of its own once it
|
|
258
|
+
# exists. evict_oldest! returns false when only that bucket is left,
|
|
259
|
+
# which terminates the loop even with max_cache_size misconfigured to 0.
|
|
260
|
+
while hash.size > max_cache_size
|
|
261
|
+
break unless evict_oldest!(hash, overflow_key)
|
|
262
|
+
end
|
|
217
263
|
end
|
|
218
264
|
|
|
219
|
-
#
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
265
|
+
# Evict the least recently updated key, folding its count into the
|
|
266
|
+
# overflow bucket so the total is conserved. The overflow bucket itself is
|
|
267
|
+
# never the victim. Mirrors RackAttackTracker#evict_oldest!.
|
|
268
|
+
# @return [Boolean] false once only the overflow bucket remains
|
|
269
|
+
def evict_oldest!(hash, overflow_key)
|
|
270
|
+
oldest_key = hash.each_key.find { |k| k != overflow_key }
|
|
271
|
+
return false unless oldest_key
|
|
224
272
|
|
|
225
|
-
|
|
273
|
+
dropped = hash.delete(oldest_key).to_i
|
|
274
|
+
hash[overflow_key] = hash[overflow_key].to_i + dropped if dropped.positive?
|
|
275
|
+
true
|
|
276
|
+
end
|
|
226
277
|
|
|
227
|
-
|
|
278
|
+
# Start the flush clock when the buffer becomes non-empty; a later event
|
|
279
|
+
# never pushes it out. The old guard (`last_flush ||= now`) was first
|
|
280
|
+
# evaluated by the SECOND event on a thread, so a lone event had no
|
|
281
|
+
# deadline at all.
|
|
282
|
+
def arm_deadline!
|
|
283
|
+
Thread.current[DEADLINE_THREAD_KEY] ||= monotonic_now
|
|
284
|
+
end
|
|
285
|
+
|
|
286
|
+
# Monotonic: Time.now can jump backwards and would defer the flush.
|
|
287
|
+
def flush_due?
|
|
288
|
+
armed_at = Thread.current[DEADLINE_THREAD_KEY]
|
|
289
|
+
return false if armed_at.nil?
|
|
290
|
+
|
|
291
|
+
(monotonic_now - armed_at) >= RailsErrorDashboard.configuration.swallowed_exception_flush_interval.to_f
|
|
292
|
+
rescue => e
|
|
293
|
+
false
|
|
294
|
+
end
|
|
295
|
+
|
|
296
|
+
def monotonic_now
|
|
297
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
298
|
+
end
|
|
299
|
+
|
|
300
|
+
# Cheap periodic flush check on the :rescue path
|
|
301
|
+
def maybe_flush!
|
|
302
|
+
flush! if flush_due?
|
|
228
303
|
end
|
|
229
304
|
|
|
230
305
|
# Dispatch flush asynchronously via background job (zero I/O in request path).
|
|
@@ -263,6 +338,7 @@ module RailsErrorDashboard
|
|
|
263
338
|
thread[RAISE_THREAD_KEY] = nil
|
|
264
339
|
thread[RESCUE_THREAD_KEY] = nil
|
|
265
340
|
thread[FLUSH_THREAD_KEY] = nil
|
|
341
|
+
thread[DEADLINE_THREAD_KEY] = nil
|
|
266
342
|
|
|
267
343
|
dispatch_flush(raise_snapshot, rescue_snapshot, sync: true)
|
|
268
344
|
end
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "uri"
|
|
4
|
+
|
|
5
|
+
module RailsErrorDashboard
|
|
6
|
+
module Services
|
|
7
|
+
# Pure algorithm: decide whether a string is safe to use as a link target
|
|
8
|
+
#
|
|
9
|
+
# A URL that ends up in an `href` must be absolute http(s). Rails' `link_to`
|
|
10
|
+
# and ERB escaping stop attribute breakout, but neither rejects a scheme such
|
|
11
|
+
# as `javascript:` — that has to be an allowlist, and it has to be the same
|
|
12
|
+
# rule where the value is written and where it is rendered.
|
|
13
|
+
#
|
|
14
|
+
# The value is judged exactly as given (no stripping), so what passes here is
|
|
15
|
+
# byte-for-byte what the caller goes on to store or render.
|
|
16
|
+
#
|
|
17
|
+
# @example
|
|
18
|
+
# UrlSafety.http_url?("https://github.com/a/b/issues/1") # => true
|
|
19
|
+
# UrlSafety.http_url?("javascript:alert(1)") # => false
|
|
20
|
+
class UrlSafety
|
|
21
|
+
# Browsers strip TAB/CR/LF from inside a URL before resolving the scheme,
|
|
22
|
+
# so "java\tscript:" is live. No legitimate URL contains these or a space.
|
|
23
|
+
UNSAFE_CHARACTERS = /[[:cntrl:][:space:]]/
|
|
24
|
+
|
|
25
|
+
# @param value [String, nil]
|
|
26
|
+
# @return [Boolean] true only for an absolute http:// or https:// URL with a host
|
|
27
|
+
def self.http_url?(value)
|
|
28
|
+
return false unless value.is_a?(String)
|
|
29
|
+
return false if value.empty? || value.match?(UNSAFE_CHARACTERS)
|
|
30
|
+
|
|
31
|
+
uri = URI.parse(value)
|
|
32
|
+
# URI::HTTPS is a subclass of URI::HTTP
|
|
33
|
+
uri.is_a?(URI::HTTP) && uri.host.present?
|
|
34
|
+
rescue URI::InvalidURIError, ArgumentError, EncodingError
|
|
35
|
+
# ArgumentError/EncodingError: a string with invalid bytes cannot be matched
|
|
36
|
+
false
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
@@ -175,19 +175,109 @@ module RailsErrorDashboard
|
|
|
175
175
|
return { value: label, truncated: false }
|
|
176
176
|
end
|
|
177
177
|
|
|
178
|
-
#
|
|
178
|
+
# #inspect on an unknown object is arbitrary APPLICATION code, running
|
|
179
|
+
# on the failure path. Truncating its output bounds what is STORED,
|
|
180
|
+
# not what it COSTS: an inspect that sleeps or builds a megabyte pays
|
|
181
|
+
# that in full before a single character is discarded. So the default
|
|
182
|
+
# is a safe structural summary, and inspect runs only for types the
|
|
183
|
+
# host app opted in to -- under a wall-clock budget even then.
|
|
179
184
|
max_len = config.local_variable_max_string_length || 200
|
|
185
|
+
|
|
186
|
+
# A Struct is serialized MEMBER-WISE, never through its own #inspect.
|
|
187
|
+
#
|
|
188
|
+
# Struct was allowlisted because it prints its attributes cheaply --
|
|
189
|
+
# true of the container, false of what it holds. Struct#inspect calls
|
|
190
|
+
# each member's #inspect, so a Struct wrapping an unknown object ran
|
|
191
|
+
# that object's arbitrary code in full. Walking the members instead
|
|
192
|
+
# gives every one of them the same safe-summary default an unknown
|
|
193
|
+
# object already gets, so the guarantee holds by construction rather
|
|
194
|
+
# than by measuring afterwards.
|
|
195
|
+
return serialize_struct(value, config, depth, max_depth) if struct?(value)
|
|
196
|
+
|
|
197
|
+
return { value: safe_summary(value), truncated: false } unless inspectable?(value, config)
|
|
198
|
+
|
|
199
|
+
started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
180
200
|
inspected = value.inspect
|
|
201
|
+
elapsed_ms = (Process.clock_gettime(Process::CLOCK_MONOTONIC) - started) * 1000
|
|
202
|
+
|
|
203
|
+
# An OUTPUT-selection threshold, not an execution budget: the inspect
|
|
204
|
+
# above has already run to completion by the time this is measured.
|
|
205
|
+
# Only reachable for a type the host app explicitly opted in to, and
|
|
206
|
+
# that opt-in is documented as accepting unbounded execution -- the
|
|
207
|
+
# only way to interrupt arbitrary Ruby mid-call is Timeout, which is
|
|
208
|
+
# not safe on the capture path (safety rule 1).
|
|
209
|
+
budget = config.local_variable_inspect_budget_ms || 5
|
|
210
|
+
if elapsed_ms > budget
|
|
211
|
+
RailsErrorDashboard::Logger.debug(
|
|
212
|
+
"[RailsErrorDashboard] #{value.class}#inspect took #{elapsed_ms.round(1)}ms " \
|
|
213
|
+
"(budget #{budget}ms) — storing a summary instead"
|
|
214
|
+
)
|
|
215
|
+
return { value: safe_summary(value), truncated: true }
|
|
216
|
+
end
|
|
217
|
+
|
|
181
218
|
if inspected.length > max_len
|
|
182
219
|
{ value: inspected[0, max_len], truncated: true }
|
|
183
220
|
else
|
|
184
221
|
{ value: inspected, truncated: false }
|
|
185
222
|
end
|
|
186
223
|
rescue
|
|
187
|
-
{ value:
|
|
224
|
+
{ value: safe_summary(value), truncated: false }
|
|
188
225
|
end
|
|
189
226
|
private_class_method :serialize_object
|
|
190
227
|
|
|
228
|
+
def self.struct?(value)
|
|
229
|
+
value.is_a?(Struct)
|
|
230
|
+
rescue StandardError
|
|
231
|
+
false
|
|
232
|
+
end
|
|
233
|
+
private_class_method :struct?
|
|
234
|
+
|
|
235
|
+
# Serialize a Struct's members through the ordinary bounded path.
|
|
236
|
+
#
|
|
237
|
+
# Bounded twice over: member count is capped, and each member recurses
|
|
238
|
+
# with depth + 1, so a Struct of Structs cannot reintroduce unbounded
|
|
239
|
+
# work through recursion instead of through #inspect.
|
|
240
|
+
def self.serialize_struct(value, config, depth, max_depth)
|
|
241
|
+
max_members = config.local_variable_max_array_items || 10
|
|
242
|
+
members = value.members.first(max_members)
|
|
243
|
+
truncated = value.members.size > members.size
|
|
244
|
+
|
|
245
|
+
pairs = members.map do |member|
|
|
246
|
+
serialized = serialize_value(value[member], config, depth + 1, max_depth)
|
|
247
|
+
truncated ||= serialized[:truncated]
|
|
248
|
+
"#{member}=#{serialized[:value]}"
|
|
249
|
+
end
|
|
250
|
+
|
|
251
|
+
# An anonymous Struct has no class name; label it by shape rather than
|
|
252
|
+
# rendering "#<struct a=1>" with a hole in it.
|
|
253
|
+
label = value.class.name.presence || "struct"
|
|
254
|
+
{ value: "#<#{label} #{pairs.join(', ')}>", truncated: truncated }
|
|
255
|
+
rescue StandardError
|
|
256
|
+
{ value: safe_summary(value), truncated: false }
|
|
257
|
+
end
|
|
258
|
+
private_class_method :serialize_struct
|
|
259
|
+
|
|
260
|
+
# What an object is, without asking the object. Costs one class-name read.
|
|
261
|
+
def self.safe_summary(value)
|
|
262
|
+
"#<#{value.class.name}>"
|
|
263
|
+
rescue StandardError
|
|
264
|
+
"#<Object>"
|
|
265
|
+
end
|
|
266
|
+
private_class_method :safe_summary
|
|
267
|
+
|
|
268
|
+
# True when this object's class (or an ancestor) is on the allowlist, so
|
|
269
|
+
# the host app has accepted the cost of its #inspect.
|
|
270
|
+
def self.inspectable?(value, config)
|
|
271
|
+
allowlist = Array(config.local_variable_inspect_allowlist)
|
|
272
|
+
return false if allowlist.empty?
|
|
273
|
+
|
|
274
|
+
ancestors = value.class.ancestors.map { |mod| mod.name }.compact
|
|
275
|
+
(ancestors & allowlist).any?
|
|
276
|
+
rescue StandardError
|
|
277
|
+
false
|
|
278
|
+
end
|
|
279
|
+
private_class_method :inspectable?
|
|
280
|
+
|
|
191
281
|
# --- Sensitive data filtering (post-serialization) ---
|
|
192
282
|
# Reuses SensitiveDataFilter.parameter_filter — same pattern as BreadcrumbCollector.
|
|
193
283
|
# Applied AFTER serialization so ParameterFilter works on clean JSON-compatible values.
|
|
@@ -215,14 +305,39 @@ module RailsErrorDashboard
|
|
|
215
305
|
info[:value] = SensitiveDataFilter.send(:filter_message, filter, info[:value])
|
|
216
306
|
end
|
|
217
307
|
|
|
218
|
-
#
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
#
|
|
224
|
-
|
|
225
|
-
|
|
308
|
+
# Path-aware filtering, in ONE call for every container type.
|
|
309
|
+
#
|
|
310
|
+
# The value is wrapped back under its own variable name so the filter
|
|
311
|
+
# sees the SAME key path Rails sees for request params. A dotted
|
|
312
|
+
# pattern like "profile.private_note" is a path, not a name: dropping
|
|
313
|
+
# the "profile" segment means the value Rails redacts in params stays
|
|
314
|
+
# readable here. Unwrapping afterwards leaves the stored shape
|
|
315
|
+
# unchanged.
|
|
316
|
+
#
|
|
317
|
+
# This must NOT ask "what shape is this?" first. Wrapping only Hashes
|
|
318
|
+
# and sending Arrays straight to the recursive walker is exactly how
|
|
319
|
+
# `profile = [{ private_note: ... }]` leaked while the identical
|
|
320
|
+
# request params were redacted: ParameterFilter already traverses
|
|
321
|
+
# arbitrary nesting of Hash and Array, so one wrap covers every shape
|
|
322
|
+
# and a new container type cannot reintroduce the gap.
|
|
323
|
+
#
|
|
324
|
+
# Parity with Rails is the contract in both directions -- a pattern
|
|
325
|
+
# that Rails does NOT match (profile.list.private_note, or a bare
|
|
326
|
+
# scalar) must survive here too. Over-redaction silently destroys
|
|
327
|
+
# data a developer needs to debug.
|
|
328
|
+
if info[:value].is_a?(Hash) || info[:value].is_a?(Array)
|
|
329
|
+
scoped = filter.filter(var_name => info[:value])[var_name]
|
|
330
|
+
|
|
331
|
+
# The recursive pass stays, and runs AFTER the path-aware filter:
|
|
332
|
+
# it scrubs sensitive CONTENT inside strings (credit-card and
|
|
333
|
+
# key=value patterns), which ParameterFilter does not do -- it only
|
|
334
|
+
# matches keys.
|
|
335
|
+
info[:value] =
|
|
336
|
+
case scoped
|
|
337
|
+
when Hash then filter_hash_recursive(filter, scoped)
|
|
338
|
+
when Array then filter_array_recursive(filter, scoped)
|
|
339
|
+
else scoped
|
|
340
|
+
end
|
|
226
341
|
end
|
|
227
342
|
end
|
|
228
343
|
|
|
@@ -16,6 +16,18 @@ module RailsErrorDashboard
|
|
|
16
16
|
class BreadcrumbSubscriber
|
|
17
17
|
SQL_MESSAGE_MAX = 200
|
|
18
18
|
|
|
19
|
+
# Where a failing job's trail waits for the reporter.
|
|
20
|
+
#
|
|
21
|
+
# ActiveJob reports a job error OUTSIDE the frame that runs
|
|
22
|
+
# `run_callbacks :perform`: perform_now's `rescue Exception` is one frame
|
|
23
|
+
# out (activejob execution.rb), and the report itself fires two frames
|
|
24
|
+
# further out still, from the :execute around-callback that the railtie
|
|
25
|
+
# registers (ExecutionWrapper.wrap's `rescue Exception` ->
|
|
26
|
+
# error_reporter.report). By the time LogError runs, our ensure has
|
|
27
|
+
# already cleared the buffer. So we snapshot on the way out and leave the
|
|
28
|
+
# snapshot here for LogError to pick up.
|
|
29
|
+
JOB_TRAIL_KEY = :rails_error_dashboard_job_breadcrumb_trail
|
|
30
|
+
|
|
19
31
|
# Event subscriptions managed by this class
|
|
20
32
|
@subscriptions = []
|
|
21
33
|
|
|
@@ -38,6 +50,105 @@ module RailsErrorDashboard
|
|
|
38
50
|
@subscriptions
|
|
39
51
|
end
|
|
40
52
|
|
|
53
|
+
# Open a breadcrumb buffer around every Active Job perform, so a job
|
|
54
|
+
# that fails outside a request still has an activity trail.
|
|
55
|
+
#
|
|
56
|
+
# Idempotent: the callback list belongs to ActiveJob::Base, and a
|
|
57
|
+
# second registration would open and close the buffer twice per job.
|
|
58
|
+
#
|
|
59
|
+
# The config check is INSIDE the callback, not around this method.
|
|
60
|
+
# enable_breadcrumbs defaults to false and the callback list is fixed
|
|
61
|
+
# once the class loads, so gating registration would permanently
|
|
62
|
+
# disable job breadcrumbs for any host that enables the feature in an
|
|
63
|
+
# initializer running after the engine's.
|
|
64
|
+
# @return [Boolean] true when the callback was installed
|
|
65
|
+
def install_job_buffer!
|
|
66
|
+
return false if @job_buffer_installed
|
|
67
|
+
return false unless defined?(ActiveJob::Base)
|
|
68
|
+
|
|
69
|
+
@job_buffer_installed = true
|
|
70
|
+
ActiveSupport.on_load(:active_job) do
|
|
71
|
+
around_perform do |job, block|
|
|
72
|
+
collector = RailsErrorDashboard::Services::BreadcrumbCollector
|
|
73
|
+
subscriber = RailsErrorDashboard::Subscribers::BreadcrumbSubscriber
|
|
74
|
+
|
|
75
|
+
# Drop any snapshot a previous perform on this pooled thread left
|
|
76
|
+
# behind. This, not the ensure below, is what bounds the leak:
|
|
77
|
+
# discard_on (and a host rescue_from) can swallow an exception so
|
|
78
|
+
# that nothing is ever reported and nothing ever consumes the
|
|
79
|
+
# snapshot. At most one job's serialized trail survives, and only
|
|
80
|
+
# until this thread's very next perform.
|
|
81
|
+
Thread.current[subscriber::JOB_TRAIL_KEY] = nil
|
|
82
|
+
|
|
83
|
+
owned =
|
|
84
|
+
if RailsErrorDashboard.configuration.enable_breadcrumbs &&
|
|
85
|
+
!subscriber.capture_job?(job)
|
|
86
|
+
collector.init_buffer_unless_present
|
|
87
|
+
else
|
|
88
|
+
false
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
completed = false
|
|
92
|
+
begin
|
|
93
|
+
block.call
|
|
94
|
+
completed = true
|
|
95
|
+
ensure
|
|
96
|
+
# Snapshot BEFORE clearing, and only when the job did not
|
|
97
|
+
# finish normally -- see JOB_TRAIL_KEY: the error is reported
|
|
98
|
+
# two frames outside this one, long after the clear.
|
|
99
|
+
#
|
|
100
|
+
# `completed`, not a `rescue Exception`, because retry_on and
|
|
101
|
+
# discard_on with `report: true` report the error and then
|
|
102
|
+
# return normally: no exception passes through here, yet the
|
|
103
|
+
# capture still needs the trail.
|
|
104
|
+
#
|
|
105
|
+
# Gated on `owned`: a job running inline inside a request must
|
|
106
|
+
# not copy out -- or clear -- a buffer the request owns.
|
|
107
|
+
if owned && !completed
|
|
108
|
+
begin
|
|
109
|
+
trail = collector.current_breadcrumbs
|
|
110
|
+
Thread.current[subscriber::JOB_TRAIL_KEY] = trail if trail.is_a?(Array) && trail.any?
|
|
111
|
+
rescue StandardError
|
|
112
|
+
nil # never raise from the capture path
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# ensure, always: a worker pool reuses threads, and a
|
|
117
|
+
# thread-local left behind would leak one job's trail into the
|
|
118
|
+
# next (safety rule 4).
|
|
119
|
+
collector.clear_buffer_if_owned(owned)
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
true
|
|
124
|
+
rescue StandardError => e
|
|
125
|
+
RailsErrorDashboard::Logger.debug(
|
|
126
|
+
"[RailsErrorDashboard] install_job_buffer! failed: #{e.class} - #{e.message}"
|
|
127
|
+
)
|
|
128
|
+
false
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
# Is the job now performing one of the gem's OWN capture jobs?
|
|
132
|
+
#
|
|
133
|
+
# AsyncErrorLoggingJob descends from ActiveJob::Base, so it runs
|
|
134
|
+
# through the around_perform above like any host job. Opening a buffer
|
|
135
|
+
# for it collects the gem's own write traffic -- its cache reads for
|
|
136
|
+
# the application record, its SAVEPOINT/RELEASE SAVEPOINT pairs -- and
|
|
137
|
+
# the anti-recursion filter does not catch those (it is a substring
|
|
138
|
+
# test for "rails_error_dashboard_" against the SQL text, and
|
|
139
|
+
# transaction control carries no table name).
|
|
140
|
+
#
|
|
141
|
+
# Today that noise is discarded because the envelope wins in LogError.
|
|
142
|
+
# Once a failing job's buffer is snapshotted and stored, it would
|
|
143
|
+
# become the trail on the gem's own failure path. Never open one.
|
|
144
|
+
# @param job [ActiveJob::Base]
|
|
145
|
+
# @return [Boolean]
|
|
146
|
+
def capture_job?(job)
|
|
147
|
+
job.class.name.to_s.start_with?("RailsErrorDashboard::")
|
|
148
|
+
rescue StandardError
|
|
149
|
+
false
|
|
150
|
+
end
|
|
151
|
+
|
|
41
152
|
# Remove all breadcrumb subscribers
|
|
42
153
|
def unsubscribe!
|
|
43
154
|
@subscriptions.each do |sub|
|
|
@@ -78,11 +78,20 @@ module RailsErrorDashboard
|
|
|
78
78
|
|
|
79
79
|
if existing
|
|
80
80
|
# Link this record to the existing issue instead of creating a new one
|
|
81
|
-
|
|
81
|
+
link = {
|
|
82
82
|
external_issue_url: existing.external_issue_url,
|
|
83
83
|
external_issue_number: existing.external_issue_number,
|
|
84
84
|
external_issue_provider: existing.external_issue_provider
|
|
85
|
-
|
|
85
|
+
}
|
|
86
|
+
# The repository is half the issue's identity. Without it
|
|
87
|
+
# IssueTrackerClient.for_error falls back to the CONFIGURED repo,
|
|
88
|
+
# and every later comment/close/reopen for this row would hit
|
|
89
|
+
# whatever issue has that number there. Same column guard as
|
|
90
|
+
# LinkExistingIssue.
|
|
91
|
+
if ErrorLog.column_names.include?("external_issue_repo")
|
|
92
|
+
link[:external_issue_repo] = existing.external_issue_repo
|
|
93
|
+
end
|
|
94
|
+
error_log.update_columns(link)
|
|
86
95
|
return false
|
|
87
96
|
end
|
|
88
97
|
|
|
@@ -7,7 +7,8 @@ module RailsErrorDashboard
|
|
|
7
7
|
class ErrorContext
|
|
8
8
|
attr_reader :user_id, :request_url, :request_params, :user_agent, :ip_address, :platform,
|
|
9
9
|
:controller_name, :action_name, :request_id, :session_id,
|
|
10
|
-
:http_method, :hostname, :content_type, :request_duration_ms, :environment
|
|
10
|
+
:http_method, :hostname, :content_type, :request_duration_ms, :environment,
|
|
11
|
+
:occurred_at, :app_version
|
|
11
12
|
|
|
12
13
|
def initialize(context, source = nil)
|
|
13
14
|
@context = context
|
|
@@ -23,6 +24,8 @@ module RailsErrorDashboard
|
|
|
23
24
|
@action_name = extract_action_name
|
|
24
25
|
@request_id = extract_request_id
|
|
25
26
|
@session_id = extract_session_id
|
|
27
|
+
@occurred_at = extract_occurred_at
|
|
28
|
+
@app_version = extract_app_version
|
|
26
29
|
@http_method = extract_http_method
|
|
27
30
|
@hostname = extract_hostname
|
|
28
31
|
@content_type = extract_content_type
|
|
@@ -52,12 +55,40 @@ module RailsErrorDashboard
|
|
|
52
55
|
hostname: hostname,
|
|
53
56
|
content_type: content_type,
|
|
54
57
|
request_duration_ms: request_duration_ms,
|
|
55
|
-
environment: environment
|
|
58
|
+
environment: environment,
|
|
59
|
+
# Both belong in to_h, not only in the readers: LogError builds a
|
|
60
|
+
# SECOND ErrorContext from this hash on the async path, and a key
|
|
61
|
+
# missing here is silently dropped there. That hop is what lost
|
|
62
|
+
# request_id and session_id before.
|
|
63
|
+
occurred_at: occurred_at,
|
|
64
|
+
app_version: app_version
|
|
56
65
|
}
|
|
57
66
|
end
|
|
58
67
|
|
|
59
68
|
private
|
|
60
69
|
|
|
70
|
+
# A caller-supplied event time, e.g. a mobile client reporting a failure
|
|
71
|
+
# that happened while it was offline. Never in the future: a client clock
|
|
72
|
+
# can be wrong, and a future row would sort above every real error and
|
|
73
|
+
# never age out of a window.
|
|
74
|
+
def extract_occurred_at
|
|
75
|
+
raw = @context[:occurred_at]
|
|
76
|
+
return nil if raw.blank?
|
|
77
|
+
|
|
78
|
+
time = raw.is_a?(String) ? Time.zone.parse(raw) : raw
|
|
79
|
+
return nil unless time.respond_to?(:to_time)
|
|
80
|
+
|
|
81
|
+
[ time, Time.current ].min
|
|
82
|
+
rescue StandardError
|
|
83
|
+
nil
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# The release the REPORTER was running, which for a mobile or frontend
|
|
87
|
+
# report is the whole point -- it differs from the server's version.
|
|
88
|
+
def extract_app_version
|
|
89
|
+
@context[:app_version].presence
|
|
90
|
+
end
|
|
91
|
+
|
|
61
92
|
def extract_user_id
|
|
62
93
|
@context[:current_user]&.id ||
|
|
63
94
|
@context[:user_id] ||
|
|
@@ -120,12 +151,18 @@ module RailsErrorDashboard
|
|
|
120
151
|
# Additional context (from mobile apps, etc.)
|
|
121
152
|
params.merge!(@context[:additional_context]) if @context[:additional_context]
|
|
122
153
|
|
|
154
|
+
# Caller-supplied metadata, documented by ManualErrorReporter and
|
|
155
|
+
# previously accepted and discarded.
|
|
156
|
+
params.merge!(@context[:metadata]) if @context[:metadata].is_a?(Hash)
|
|
157
|
+
|
|
123
158
|
# Pre-serialized params (from async logging or double-ErrorContext path).
|
|
124
159
|
# LogError creates a second ErrorContext from error_context.to_h which
|
|
125
160
|
# has :request_params as a JSON string but no :request object.
|
|
126
161
|
return @context[:request_params] if params.empty? && @context[:request_params].present?
|
|
127
162
|
|
|
128
|
-
|
|
163
|
+
# Params read off a live request or job object never passed through the
|
|
164
|
+
# context scrub LogError does, and to_json raises on an invalid byte.
|
|
165
|
+
Services::EncodingSanitizer.scrub_deep(params).to_json
|
|
129
166
|
end
|
|
130
167
|
|
|
131
168
|
def extract_user_agent
|