rails_error_dashboard 0.3.1 → 0.4.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/README.md +236 -841
- data/app/controllers/rails_error_dashboard/errors_controller.rb +89 -0
- data/app/jobs/rails_error_dashboard/swallowed_exception_flush_job.rb +32 -0
- data/app/models/rails_error_dashboard/diagnostic_dump.rb +14 -0
- data/app/models/rails_error_dashboard/swallowed_exception.rb +38 -0
- data/app/views/layouts/rails_error_dashboard.html.erb +21 -0
- data/app/views/rails_error_dashboard/errors/_instance_variables.html.erb +55 -0
- data/app/views/rails_error_dashboard/errors/_local_variables.html.erb +46 -0
- data/app/views/rails_error_dashboard/errors/_sidebar_metadata.html.erb +48 -0
- data/app/views/rails_error_dashboard/errors/diagnostic_dumps.html.erb +182 -0
- data/app/views/rails_error_dashboard/errors/rack_attack_summary.html.erb +133 -0
- data/app/views/rails_error_dashboard/errors/show.html.erb +4 -0
- data/app/views/rails_error_dashboard/errors/swallowed_exceptions.html.erb +126 -0
- data/config/routes.rb +4 -0
- data/db/migrate/20251223000000_create_rails_error_dashboard_complete_schema.rb +33 -0
- data/db/migrate/20260306000001_add_local_variables_to_error_logs.rb +13 -0
- data/db/migrate/20260306000002_add_instance_variables_to_error_logs.rb +7 -0
- data/db/migrate/20260306000003_create_rails_error_dashboard_swallowed_exceptions.rb +34 -0
- data/db/migrate/20260307000001_create_rails_error_dashboard_diagnostic_dumps.rb +17 -0
- data/lib/generators/rails_error_dashboard/install/install_generator.rb +32 -0
- data/lib/generators/rails_error_dashboard/install/templates/initializer.rb +47 -0
- data/lib/rails_error_dashboard/commands/flush_swallowed_exceptions.rb +103 -0
- data/lib/rails_error_dashboard/commands/log_error.rb +68 -0
- data/lib/rails_error_dashboard/configuration.rb +122 -0
- data/lib/rails_error_dashboard/engine.rb +24 -0
- data/lib/rails_error_dashboard/queries/dashboard_stats.rb +32 -11
- data/lib/rails_error_dashboard/queries/rack_attack_summary.rb +90 -0
- data/lib/rails_error_dashboard/queries/swallowed_exception_summary.rb +97 -0
- data/lib/rails_error_dashboard/services/breadcrumb_collector.rb +12 -0
- data/lib/rails_error_dashboard/services/crash_capture.rb +234 -0
- data/lib/rails_error_dashboard/services/diagnostic_dump_generator.rb +98 -0
- data/lib/rails_error_dashboard/services/local_variable_capturer.rb +207 -0
- data/lib/rails_error_dashboard/services/swallowed_exception_tracker.rb +277 -0
- data/lib/rails_error_dashboard/services/system_health_snapshot.rb +33 -0
- data/lib/rails_error_dashboard/services/variable_serializer.rb +326 -0
- data/lib/rails_error_dashboard/subscribers/rack_attack_subscriber.rb +94 -0
- data/lib/rails_error_dashboard/version.rb +1 -1
- data/lib/rails_error_dashboard.rb +9 -0
- data/lib/tasks/error_dashboard.rake +34 -0
- metadata +23 -2
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RailsErrorDashboard
|
|
4
|
+
module Services
|
|
5
|
+
# TracePoint lifecycle manager for detecting swallowed (raised-then-rescued) exceptions.
|
|
6
|
+
#
|
|
7
|
+
# Uses separate TracePoint(:raise) and TracePoint(:rescue) hooks (Ruby 3.3+).
|
|
8
|
+
# Counts raises vs rescues per exception class + location pair. A high rescue ratio
|
|
9
|
+
# indicates exceptions being silently swallowed (e.g., `rescue => e; nil`).
|
|
10
|
+
#
|
|
11
|
+
# This is intentionally SEPARATE from LocalVariableCapturer — that TracePoint aggressively
|
|
12
|
+
# filters to only app-code paths, while this one needs broader visibility to detect
|
|
13
|
+
# swallowed exceptions in gem code too (e.g., Stripe::CardError rescued in a service).
|
|
14
|
+
#
|
|
15
|
+
# Safety contract:
|
|
16
|
+
# - Default OFF (opt-in via config.detect_swallowed_exceptions)
|
|
17
|
+
# - Ruby 3.3+ version gate (TracePoint(:rescue) not available before 3.3)
|
|
18
|
+
# - Thread-local counters (no shared state, no mutex in hot path)
|
|
19
|
+
# - ~500ns per raise/rescue (hash lookup + integer increment)
|
|
20
|
+
# - Zero I/O in callbacks — async flush via Command
|
|
21
|
+
# - Every callback wrapped in rescue => e (never raises)
|
|
22
|
+
# - LRU eviction when thread-local cache exceeds max size
|
|
23
|
+
# - Periodic flush via cheap timestamp check
|
|
24
|
+
class SwallowedExceptionTracker
|
|
25
|
+
RAISE_THREAD_KEY = :red_swallowed_raises
|
|
26
|
+
RESCUE_THREAD_KEY = :red_swallowed_rescues
|
|
27
|
+
FLUSH_THREAD_KEY = :red_swallowed_last_flush
|
|
28
|
+
RAISE_LOC_IVAR = :@_red_raise_loc
|
|
29
|
+
|
|
30
|
+
# Flow-control exceptions that are commonly raised/rescued in normal Rails operation.
|
|
31
|
+
# These are NOT bugs — they're control flow. Skipping them reduces noise.
|
|
32
|
+
FLOW_CONTROL_EXCEPTIONS = %w[
|
|
33
|
+
SystemExit
|
|
34
|
+
SignalException
|
|
35
|
+
Interrupt
|
|
36
|
+
Errno::EPIPE
|
|
37
|
+
Errno::ECONNRESET
|
|
38
|
+
Errno::ETIMEDOUT
|
|
39
|
+
IOError
|
|
40
|
+
ActionController::RoutingError
|
|
41
|
+
ActionController::UnknownFormat
|
|
42
|
+
ActionController::InvalidAuthenticityToken
|
|
43
|
+
ActiveRecord::RecordNotFound
|
|
44
|
+
ActionView::MissingTemplate
|
|
45
|
+
AbstractController::ActionNotFound
|
|
46
|
+
].freeze
|
|
47
|
+
|
|
48
|
+
class << self
|
|
49
|
+
# Enable both TracePoints. No-op on Ruby < 3.3 or if already enabled.
|
|
50
|
+
def enable!
|
|
51
|
+
unless RUBY_VERSION >= "3.3"
|
|
52
|
+
RailsErrorDashboard::Logger.debug(
|
|
53
|
+
"[RailsErrorDashboard] SwallowedExceptionTracker requires Ruby 3.3+ (current: #{RUBY_VERSION}). Skipping."
|
|
54
|
+
)
|
|
55
|
+
return false
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
return true if enabled?
|
|
59
|
+
|
|
60
|
+
@raise_tracepoint = TracePoint.new(:raise) do |tp|
|
|
61
|
+
on_raise(tp)
|
|
62
|
+
rescue => e
|
|
63
|
+
RailsErrorDashboard::Logger.debug(
|
|
64
|
+
"[RailsErrorDashboard] SwallowedExceptionTracker :raise callback error: #{e.class} - #{e.message}"
|
|
65
|
+
)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
@rescue_tracepoint = TracePoint.new(:rescue) do |tp|
|
|
69
|
+
on_rescue(tp)
|
|
70
|
+
rescue => e
|
|
71
|
+
RailsErrorDashboard::Logger.debug(
|
|
72
|
+
"[RailsErrorDashboard] SwallowedExceptionTracker :rescue callback error: #{e.class} - #{e.message}"
|
|
73
|
+
)
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
@raise_tracepoint.enable
|
|
77
|
+
@rescue_tracepoint.enable
|
|
78
|
+
|
|
79
|
+
at_exit { flush_all_threads! }
|
|
80
|
+
|
|
81
|
+
true
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# Disable both TracePoints and flush remaining data
|
|
85
|
+
def disable!
|
|
86
|
+
@raise_tracepoint&.disable
|
|
87
|
+
@rescue_tracepoint&.disable
|
|
88
|
+
@raise_tracepoint = nil
|
|
89
|
+
@rescue_tracepoint = nil
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# Check if currently enabled
|
|
93
|
+
def enabled?
|
|
94
|
+
@raise_tracepoint&.enabled? == true && @rescue_tracepoint&.enabled? == true
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# Force flush the current thread's counters (used by job and tests)
|
|
98
|
+
def flush!
|
|
99
|
+
raises = Thread.current[RAISE_THREAD_KEY]
|
|
100
|
+
rescues = Thread.current[RESCUE_THREAD_KEY]
|
|
101
|
+
return if raises.nil? && rescues.nil?
|
|
102
|
+
return if raises&.empty? && rescues&.empty?
|
|
103
|
+
|
|
104
|
+
# Copy and clear atomically (per-thread, no lock needed)
|
|
105
|
+
raise_snapshot = raises&.dup || {}
|
|
106
|
+
rescue_snapshot = rescues&.dup || {}
|
|
107
|
+
raises&.clear
|
|
108
|
+
rescues&.clear
|
|
109
|
+
Thread.current[FLUSH_THREAD_KEY] = Time.now.to_f
|
|
110
|
+
|
|
111
|
+
dispatch_flush(raise_snapshot, rescue_snapshot)
|
|
112
|
+
rescue => e
|
|
113
|
+
RailsErrorDashboard::Logger.debug(
|
|
114
|
+
"[RailsErrorDashboard] SwallowedExceptionTracker.flush! failed: #{e.class} - #{e.message}"
|
|
115
|
+
)
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# Read current thread's counters (for testing/inspection)
|
|
119
|
+
def current_raises
|
|
120
|
+
Thread.current[RAISE_THREAD_KEY] || {}
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def current_rescues
|
|
124
|
+
Thread.current[RESCUE_THREAD_KEY] || {}
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# Clear current thread's counters without flushing (for testing)
|
|
128
|
+
def clear!
|
|
129
|
+
Thread.current[RAISE_THREAD_KEY] = nil
|
|
130
|
+
Thread.current[RESCUE_THREAD_KEY] = nil
|
|
131
|
+
Thread.current[FLUSH_THREAD_KEY] = nil
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
private
|
|
135
|
+
|
|
136
|
+
# TracePoint(:raise) callback
|
|
137
|
+
def on_raise(tp)
|
|
138
|
+
exception = tp.raised_exception
|
|
139
|
+
|
|
140
|
+
# 1. Skip system/flow-control exceptions (cheapest check first)
|
|
141
|
+
return if skip_exception?(exception)
|
|
142
|
+
|
|
143
|
+
# 2. Build location string
|
|
144
|
+
path = tp.path.to_s
|
|
145
|
+
line = tp.lineno
|
|
146
|
+
location = "#{path}:#{line}"
|
|
147
|
+
|
|
148
|
+
# 3. Set location ivar on exception for raise→rescue matching
|
|
149
|
+
exception.instance_variable_set(RAISE_LOC_IVAR, location)
|
|
150
|
+
|
|
151
|
+
# 4. Increment raise counter
|
|
152
|
+
class_name = exception.class.name || exception.class.to_s
|
|
153
|
+
key = "#{class_name}|#{location}"
|
|
154
|
+
|
|
155
|
+
raises = (Thread.current[RAISE_THREAD_KEY] ||= {})
|
|
156
|
+
raises[key] = (raises[key] || 0) + 1
|
|
157
|
+
|
|
158
|
+
# 5. LRU eviction if over capacity
|
|
159
|
+
evict_oldest!(raises) if raises.size > max_cache_size
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
# TracePoint(:rescue) callback
|
|
163
|
+
def on_rescue(tp)
|
|
164
|
+
exception = tp.raised_exception
|
|
165
|
+
|
|
166
|
+
# 1. Skip system/flow-control exceptions
|
|
167
|
+
return if skip_exception?(exception)
|
|
168
|
+
|
|
169
|
+
# 2. Get raise location from ivar (set during :raise)
|
|
170
|
+
raise_loc = if exception.instance_variable_defined?(RAISE_LOC_IVAR)
|
|
171
|
+
exception.instance_variable_get(RAISE_LOC_IVAR)
|
|
172
|
+
end
|
|
173
|
+
return unless raise_loc
|
|
174
|
+
|
|
175
|
+
# 3. Build rescue location
|
|
176
|
+
rescue_path = tp.path.to_s
|
|
177
|
+
rescue_line = tp.lineno
|
|
178
|
+
rescue_loc = "#{rescue_path}:#{rescue_line}"
|
|
179
|
+
|
|
180
|
+
# 4. Increment rescue counter
|
|
181
|
+
class_name = exception.class.name || exception.class.to_s
|
|
182
|
+
key = "#{class_name}|#{raise_loc}->#{rescue_loc}"
|
|
183
|
+
|
|
184
|
+
rescues = (Thread.current[RESCUE_THREAD_KEY] ||= {})
|
|
185
|
+
rescues[key] = (rescues[key] || 0) + 1
|
|
186
|
+
|
|
187
|
+
# 5. LRU eviction if over capacity
|
|
188
|
+
evict_oldest!(rescues) if rescues.size > max_cache_size
|
|
189
|
+
|
|
190
|
+
# 6. Maybe flush
|
|
191
|
+
maybe_flush!
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
# Check if exception should be skipped
|
|
195
|
+
def skip_exception?(exception)
|
|
196
|
+
return true if exception.is_a?(SystemExit)
|
|
197
|
+
return true if exception.is_a?(SignalException)
|
|
198
|
+
return true if exception.is_a?(Interrupt)
|
|
199
|
+
|
|
200
|
+
class_name = exception.class.name
|
|
201
|
+
return true if class_name.nil?
|
|
202
|
+
|
|
203
|
+
# Check built-in flow-control list
|
|
204
|
+
return true if FLOW_CONTROL_EXCEPTIONS.include?(class_name)
|
|
205
|
+
|
|
206
|
+
# Check user-configured ignore list
|
|
207
|
+
ignore_list = RailsErrorDashboard.configuration.swallowed_exception_ignore_classes
|
|
208
|
+
return true if ignore_list&.any? { |klass| class_name == klass.to_s }
|
|
209
|
+
|
|
210
|
+
false
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
# LRU eviction: delete the oldest key (Ruby hashes maintain insertion order)
|
|
214
|
+
def evict_oldest!(hash)
|
|
215
|
+
oldest_key = hash.each_key.first
|
|
216
|
+
hash.delete(oldest_key) if oldest_key
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
# Cheap periodic flush check
|
|
220
|
+
def maybe_flush!
|
|
221
|
+
now = Time.now.to_f
|
|
222
|
+
last_flush = Thread.current[FLUSH_THREAD_KEY] ||= now
|
|
223
|
+
interval = RailsErrorDashboard.configuration.swallowed_exception_flush_interval
|
|
224
|
+
|
|
225
|
+
return unless (now - last_flush) >= interval
|
|
226
|
+
|
|
227
|
+
flush!
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
# Dispatch flush asynchronously via background job (zero I/O in request path).
|
|
231
|
+
# Falls back to synchronous command if job enqueue fails.
|
|
232
|
+
def dispatch_flush(raise_snapshot, rescue_snapshot, sync: false)
|
|
233
|
+
return if raise_snapshot.empty? && rescue_snapshot.empty?
|
|
234
|
+
|
|
235
|
+
if sync
|
|
236
|
+
Commands::FlushSwallowedExceptions.call(
|
|
237
|
+
raise_counts: raise_snapshot,
|
|
238
|
+
rescue_counts: rescue_snapshot
|
|
239
|
+
)
|
|
240
|
+
else
|
|
241
|
+
SwallowedExceptionFlushJob.perform_later(raise_snapshot, rescue_snapshot)
|
|
242
|
+
end
|
|
243
|
+
rescue => e
|
|
244
|
+
RailsErrorDashboard::Logger.debug(
|
|
245
|
+
"[RailsErrorDashboard] SwallowedExceptionTracker.dispatch_flush failed: #{e.class} - #{e.message}"
|
|
246
|
+
)
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
def max_cache_size
|
|
250
|
+
RailsErrorDashboard.configuration.swallowed_exception_max_cache_size || 1000
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
# Flush all threads on shutdown (best-effort)
|
|
254
|
+
def flush_all_threads!
|
|
255
|
+
Thread.list.each do |thread|
|
|
256
|
+
raises = thread[RAISE_THREAD_KEY]
|
|
257
|
+
rescues = thread[RESCUE_THREAD_KEY]
|
|
258
|
+
next if raises.nil? && rescues.nil?
|
|
259
|
+
next if raises&.empty? && rescues&.empty?
|
|
260
|
+
|
|
261
|
+
raise_snapshot = raises&.dup || {}
|
|
262
|
+
rescue_snapshot = rescues&.dup || {}
|
|
263
|
+
thread[RAISE_THREAD_KEY] = nil
|
|
264
|
+
thread[RESCUE_THREAD_KEY] = nil
|
|
265
|
+
thread[FLUSH_THREAD_KEY] = nil
|
|
266
|
+
|
|
267
|
+
dispatch_flush(raise_snapshot, rescue_snapshot, sync: true)
|
|
268
|
+
end
|
|
269
|
+
rescue => e
|
|
270
|
+
RailsErrorDashboard::Logger.debug(
|
|
271
|
+
"[RailsErrorDashboard] SwallowedExceptionTracker.flush_all_threads! failed: #{e.class} - #{e.message}"
|
|
272
|
+
)
|
|
273
|
+
end
|
|
274
|
+
end
|
|
275
|
+
end
|
|
276
|
+
end
|
|
277
|
+
end
|
|
@@ -34,6 +34,8 @@ module RailsErrorDashboard
|
|
|
34
34
|
connection_pool: connection_pool_stats,
|
|
35
35
|
puma: puma_stats,
|
|
36
36
|
job_queue: job_queue_stats,
|
|
37
|
+
ruby_vm: ruby_vm_stats,
|
|
38
|
+
yjit: yjit_stats,
|
|
37
39
|
captured_at: Time.current.iso8601
|
|
38
40
|
}
|
|
39
41
|
end
|
|
@@ -140,6 +142,37 @@ module RailsErrorDashboard
|
|
|
140
142
|
rescue => e
|
|
141
143
|
nil
|
|
142
144
|
end
|
|
145
|
+
|
|
146
|
+
# RubyVM.stat — constant/method cache invalidation rates
|
|
147
|
+
# Keys vary by Ruby version; pass through full hash for forward-compat
|
|
148
|
+
# Ruby 3.2+: constant_cache_invalidations, constant_cache_misses,
|
|
149
|
+
# global_cvar_state, next_shape_id, shape_cache_size
|
|
150
|
+
def ruby_vm_stats
|
|
151
|
+
return nil unless defined?(RubyVM) && RubyVM.respond_to?(:stat)
|
|
152
|
+
RubyVM.stat
|
|
153
|
+
rescue => e
|
|
154
|
+
nil
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
# RubyVM::YJIT.runtime_stats — JIT compilation health
|
|
158
|
+
# Cherry-picks diagnostic keys (full hash has 30+ entries)
|
|
159
|
+
def yjit_stats
|
|
160
|
+
return nil unless defined?(RubyVM::YJIT) && RubyVM::YJIT.respond_to?(:enabled?) && RubyVM::YJIT.enabled?
|
|
161
|
+
raw = RubyVM::YJIT.runtime_stats
|
|
162
|
+
{
|
|
163
|
+
inline_code_size: raw[:inline_code_size],
|
|
164
|
+
code_region_size: raw[:code_region_size],
|
|
165
|
+
compiled_iseq_count: raw[:compiled_iseq_count],
|
|
166
|
+
compiled_block_count: raw[:compiled_block_count],
|
|
167
|
+
compile_time_ns: raw[:compile_time_ns],
|
|
168
|
+
invalidation_count: raw[:invalidation_count],
|
|
169
|
+
invalidate_method_lookup: raw[:invalidate_method_lookup],
|
|
170
|
+
invalidate_constant_state_bump: raw[:invalidate_constant_state_bump],
|
|
171
|
+
object_shape_count: raw[:object_shape_count]
|
|
172
|
+
}
|
|
173
|
+
rescue => e
|
|
174
|
+
nil
|
|
175
|
+
end
|
|
143
176
|
end
|
|
144
177
|
end
|
|
145
178
|
end
|
|
@@ -0,0 +1,326 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RailsErrorDashboard
|
|
4
|
+
module Services
|
|
5
|
+
# Pure algorithm: Serialize local variables to safe JSON-compatible hash
|
|
6
|
+
#
|
|
7
|
+
# Handles circular references (thread-local Set of object_id),
|
|
8
|
+
# depth limiting, string truncation, and per-variable rescue.
|
|
9
|
+
# Never stores Binding objects.
|
|
10
|
+
#
|
|
11
|
+
# Sensitive data filtering uses SensitiveDataFilter.parameter_filter
|
|
12
|
+
# (same approach as BreadcrumbCollector) — supports String, Symbol,
|
|
13
|
+
# Regexp, and Proc patterns from Rails filter_parameters.
|
|
14
|
+
#
|
|
15
|
+
# Output format per variable:
|
|
16
|
+
# { type: "String", value: "hello", truncated: false }
|
|
17
|
+
#
|
|
18
|
+
# Safety contract:
|
|
19
|
+
# - Per-variable rescue — one bad variable never crashes extraction
|
|
20
|
+
# - Thread-local circular detection Set, cleaned in ensure
|
|
21
|
+
# - Never raises — returns {} on total failure
|
|
22
|
+
class VariableSerializer
|
|
23
|
+
THREAD_KEY = :_red_variable_serializer_seen
|
|
24
|
+
|
|
25
|
+
# Serialize a hash of variables to safe output
|
|
26
|
+
# @param locals [Hash] { variable_name => raw_value }
|
|
27
|
+
# @param max_count [Integer, nil] Override max variable count (defaults to local_variable_max_count)
|
|
28
|
+
# @param additional_filter_patterns [Array] Extra sensitive name patterns (e.g. instance_variable_filter_patterns)
|
|
29
|
+
# @return [Hash] { "variable_name" => { type:, value:, truncated:, filtered: } }
|
|
30
|
+
def self.call(locals, max_count: nil, additional_filter_patterns: [])
|
|
31
|
+
return {} unless locals.is_a?(Hash) && locals.any?
|
|
32
|
+
|
|
33
|
+
config = RailsErrorDashboard.configuration
|
|
34
|
+
max_count ||= config.local_variable_max_count || 15
|
|
35
|
+
|
|
36
|
+
# Thread-local circular reference tracking
|
|
37
|
+
Thread.current[THREAD_KEY] = Set.new
|
|
38
|
+
|
|
39
|
+
result = {}
|
|
40
|
+
locals.first(max_count).each do |name, value|
|
|
41
|
+
name_str = name.to_s
|
|
42
|
+
result[name_str] = serialize_variable(name_str, value, config)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
filter_serialized(result, additional_filter_patterns: additional_filter_patterns)
|
|
46
|
+
rescue => e
|
|
47
|
+
RailsErrorDashboard::Logger.debug("[RailsErrorDashboard] VariableSerializer.call failed: #{e.message}")
|
|
48
|
+
{}
|
|
49
|
+
ensure
|
|
50
|
+
Thread.current[THREAD_KEY] = nil
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Serialize a single variable (per-variable rescue)
|
|
54
|
+
# @return [Hash] { type:, value:, truncated: }
|
|
55
|
+
def self.serialize_variable(name, value, config)
|
|
56
|
+
max_depth = config.local_variable_max_depth || 3
|
|
57
|
+
serialized_value = serialize_value(value, config, 0, max_depth)
|
|
58
|
+
|
|
59
|
+
{
|
|
60
|
+
type: value.class.name,
|
|
61
|
+
value: serialized_value[:value],
|
|
62
|
+
truncated: serialized_value[:truncated] || false
|
|
63
|
+
}
|
|
64
|
+
rescue => e
|
|
65
|
+
{ type: "Unknown", value: "(serialization error: #{e.class.name})", truncated: false }
|
|
66
|
+
end
|
|
67
|
+
private_class_method :serialize_variable
|
|
68
|
+
|
|
69
|
+
# Recursively serialize a value with depth limiting and circular detection
|
|
70
|
+
# @return [Hash] { value:, truncated: }
|
|
71
|
+
def self.serialize_value(value, config, depth, max_depth)
|
|
72
|
+
# Depth limit reached
|
|
73
|
+
if depth >= max_depth
|
|
74
|
+
return { value: "(depth limit reached)", truncated: true }
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
case value
|
|
78
|
+
when NilClass
|
|
79
|
+
{ value: nil, truncated: false }
|
|
80
|
+
when TrueClass, FalseClass
|
|
81
|
+
{ value: value, truncated: false }
|
|
82
|
+
when Integer, Float
|
|
83
|
+
{ value: value, truncated: false }
|
|
84
|
+
when Symbol
|
|
85
|
+
{ value: value.to_s, truncated: false }
|
|
86
|
+
when String
|
|
87
|
+
serialize_string(value, config)
|
|
88
|
+
when Array
|
|
89
|
+
serialize_array(value, config, depth, max_depth)
|
|
90
|
+
when Hash
|
|
91
|
+
serialize_hash(value, config, depth, max_depth)
|
|
92
|
+
when IO, Tempfile
|
|
93
|
+
{ value: "#<#{value.class.name}>", truncated: false }
|
|
94
|
+
when Proc
|
|
95
|
+
{ value: "#<Proc>", truncated: false }
|
|
96
|
+
when Method, UnboundMethod
|
|
97
|
+
{ value: "#<#{value.class.name}: #{value.name}>", truncated: false }
|
|
98
|
+
when Class, Module
|
|
99
|
+
{ value: value.name || value.to_s, truncated: false }
|
|
100
|
+
when Regexp
|
|
101
|
+
{ value: value.inspect, truncated: false }
|
|
102
|
+
when Range
|
|
103
|
+
{ value: value.to_s, truncated: false }
|
|
104
|
+
else
|
|
105
|
+
serialize_object(value, config, depth, max_depth)
|
|
106
|
+
end
|
|
107
|
+
rescue => e
|
|
108
|
+
{ value: "(serialization error: #{e.class.name})", truncated: false }
|
|
109
|
+
end
|
|
110
|
+
private_class_method :serialize_value
|
|
111
|
+
|
|
112
|
+
def self.serialize_string(value, config)
|
|
113
|
+
max_len = config.local_variable_max_string_length || 200
|
|
114
|
+
if value.length > max_len
|
|
115
|
+
{ value: value[0, max_len], truncated: true }
|
|
116
|
+
else
|
|
117
|
+
{ value: value, truncated: false }
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
private_class_method :serialize_string
|
|
121
|
+
|
|
122
|
+
def self.serialize_array(value, config, depth, max_depth)
|
|
123
|
+
# Circular reference check
|
|
124
|
+
seen = Thread.current[THREAD_KEY]
|
|
125
|
+
if seen&.include?(value.object_id)
|
|
126
|
+
return { value: "(circular reference)", truncated: false }
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
seen&.add(value.object_id)
|
|
130
|
+
max_items = config.local_variable_max_array_items || 10
|
|
131
|
+
truncated = value.length > max_items
|
|
132
|
+
items = value.first(max_items).map do |item|
|
|
133
|
+
serialize_value(item, config, depth + 1, max_depth)[:value]
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
{ value: items, truncated: truncated }
|
|
137
|
+
end
|
|
138
|
+
private_class_method :serialize_array
|
|
139
|
+
|
|
140
|
+
def self.serialize_hash(value, config, depth, max_depth)
|
|
141
|
+
seen = Thread.current[THREAD_KEY]
|
|
142
|
+
if seen&.include?(value.object_id)
|
|
143
|
+
return { value: "(circular reference)", truncated: false }
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
seen&.add(value.object_id)
|
|
147
|
+
max_items = config.local_variable_max_hash_items || 20
|
|
148
|
+
truncated = value.length > max_items
|
|
149
|
+
result = {}
|
|
150
|
+
value.first(max_items).each do |k, v|
|
|
151
|
+
key_str = k.to_s
|
|
152
|
+
result[key_str] = serialize_value(v, config, depth + 1, max_depth)[:value]
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
{ value: result, truncated: truncated }
|
|
156
|
+
end
|
|
157
|
+
private_class_method :serialize_hash
|
|
158
|
+
|
|
159
|
+
def self.serialize_object(value, config, depth, max_depth)
|
|
160
|
+
seen = Thread.current[THREAD_KEY]
|
|
161
|
+
if seen&.include?(value.object_id)
|
|
162
|
+
return { value: "(circular reference)", truncated: false }
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
seen&.add(value.object_id)
|
|
166
|
+
|
|
167
|
+
# ActiveRecord objects — safe summary
|
|
168
|
+
if defined?(ActiveRecord::Base) && value.is_a?(ActiveRecord::Base)
|
|
169
|
+
id_str = begin
|
|
170
|
+
value.id.to_s
|
|
171
|
+
rescue
|
|
172
|
+
nil
|
|
173
|
+
end
|
|
174
|
+
label = id_str ? "#<#{value.class.name} id: #{id_str}>" : "#<#{value.class.name}>"
|
|
175
|
+
return { value: label, truncated: false }
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
# Fallback: .inspect with truncation
|
|
179
|
+
max_len = config.local_variable_max_string_length || 200
|
|
180
|
+
inspected = value.inspect
|
|
181
|
+
if inspected.length > max_len
|
|
182
|
+
{ value: inspected[0, max_len], truncated: true }
|
|
183
|
+
else
|
|
184
|
+
{ value: inspected, truncated: false }
|
|
185
|
+
end
|
|
186
|
+
rescue
|
|
187
|
+
{ value: "#<#{value.class.name rescue "Object"}>", truncated: false }
|
|
188
|
+
end
|
|
189
|
+
private_class_method :serialize_object
|
|
190
|
+
|
|
191
|
+
# --- Sensitive data filtering (post-serialization) ---
|
|
192
|
+
# Reuses SensitiveDataFilter.parameter_filter — same pattern as BreadcrumbCollector.
|
|
193
|
+
# Applied AFTER serialization so ParameterFilter works on clean JSON-compatible values.
|
|
194
|
+
|
|
195
|
+
# Filter all serialized variables for sensitive data
|
|
196
|
+
# @param result [Hash] Serialized output from call()
|
|
197
|
+
# @param additional_filter_patterns [Array] Extra sensitive name patterns
|
|
198
|
+
# @return [Hash] Filtered output
|
|
199
|
+
def self.filter_serialized(result, additional_filter_patterns: [])
|
|
200
|
+
return result unless RailsErrorDashboard.configuration.filter_sensitive_data
|
|
201
|
+
|
|
202
|
+
filter = effective_filter(additional_filter_patterns: additional_filter_patterns)
|
|
203
|
+
return result unless filter
|
|
204
|
+
|
|
205
|
+
result.each do |var_name, info|
|
|
206
|
+
# Filter the variable name itself
|
|
207
|
+
if filter_matches?(filter, var_name)
|
|
208
|
+
info[:value] = "[FILTERED]"
|
|
209
|
+
info[:filtered] = true
|
|
210
|
+
next
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
# Filter string values (credit card patterns, key=value patterns)
|
|
214
|
+
if info[:value].is_a?(String)
|
|
215
|
+
info[:value] = SensitiveDataFilter.send(:filter_message, filter, info[:value])
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
# Filter nested hash keys recursively
|
|
219
|
+
if info[:value].is_a?(Hash)
|
|
220
|
+
info[:value] = filter_hash_recursive(filter, info[:value])
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
# Filter nested array items
|
|
224
|
+
if info[:value].is_a?(Array)
|
|
225
|
+
info[:value] = filter_array_recursive(filter, info[:value])
|
|
226
|
+
end
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
result
|
|
230
|
+
rescue => e
|
|
231
|
+
RailsErrorDashboard::Logger.debug("[RailsErrorDashboard] VariableSerializer.filter_serialized failed: #{e.message}")
|
|
232
|
+
result
|
|
233
|
+
end
|
|
234
|
+
private_class_method :filter_serialized
|
|
235
|
+
|
|
236
|
+
# Build effective filter: SensitiveDataFilter base + variable-specific filter patterns
|
|
237
|
+
# @param additional_filter_patterns [Array] Extra patterns (e.g. instance_variable_filter_patterns)
|
|
238
|
+
# @return [ActiveSupport::ParameterFilter, nil]
|
|
239
|
+
def self.effective_filter(additional_filter_patterns: [])
|
|
240
|
+
base_filter = SensitiveDataFilter.parameter_filter
|
|
241
|
+
return nil unless base_filter
|
|
242
|
+
|
|
243
|
+
custom_patterns = Array(RailsErrorDashboard.configuration.local_variable_filter_patterns)
|
|
244
|
+
extra_patterns = Array(additional_filter_patterns)
|
|
245
|
+
return base_filter if custom_patterns.empty? && extra_patterns.empty?
|
|
246
|
+
|
|
247
|
+
# Gather the same patterns SensitiveDataFilter uses, plus custom ones
|
|
248
|
+
patterns = SensitiveDataFilter::DEFAULT_SENSITIVE_PATTERNS.dup
|
|
249
|
+
if defined?(Rails) && Rails.application&.config&.respond_to?(:filter_parameters)
|
|
250
|
+
patterns.concat(Array(Rails.application.config.filter_parameters))
|
|
251
|
+
end
|
|
252
|
+
custom_sdf = RailsErrorDashboard.configuration.sensitive_data_patterns
|
|
253
|
+
patterns.concat(Array(custom_sdf)) if custom_sdf
|
|
254
|
+
patterns.concat(custom_patterns)
|
|
255
|
+
patterns.concat(extra_patterns)
|
|
256
|
+
patterns.uniq!
|
|
257
|
+
|
|
258
|
+
ActiveSupport::ParameterFilter.new(patterns)
|
|
259
|
+
rescue => e
|
|
260
|
+
RailsErrorDashboard::Logger.debug("[RailsErrorDashboard] VariableSerializer.effective_filter failed: #{e.message}")
|
|
261
|
+
SensitiveDataFilter.parameter_filter
|
|
262
|
+
end
|
|
263
|
+
private_class_method :effective_filter
|
|
264
|
+
|
|
265
|
+
# Check if a key name matches any filter pattern
|
|
266
|
+
# Uses ParameterFilter's own matching — supports String, Symbol, Regexp, Proc
|
|
267
|
+
# @return [Boolean]
|
|
268
|
+
def self.filter_matches?(filter, name)
|
|
269
|
+
filtered = filter.filter(name => "x")
|
|
270
|
+
filtered[name] != "x"
|
|
271
|
+
rescue
|
|
272
|
+
false
|
|
273
|
+
end
|
|
274
|
+
private_class_method :filter_matches?
|
|
275
|
+
|
|
276
|
+
# Recursively filter hash keys and values
|
|
277
|
+
# @param filter [ActiveSupport::ParameterFilter]
|
|
278
|
+
# @param hash [Hash]
|
|
279
|
+
# @return [Hash] Filtered hash
|
|
280
|
+
def self.filter_hash_recursive(filter, hash)
|
|
281
|
+
# ParameterFilter handles nested key filtering natively
|
|
282
|
+
filtered = filter.filter(hash)
|
|
283
|
+
|
|
284
|
+
# Recurse into remaining complex values (arrays, nested hashes that might
|
|
285
|
+
# contain further structures beyond what ParameterFilter traverses)
|
|
286
|
+
filtered.each do |key, value|
|
|
287
|
+
case value
|
|
288
|
+
when String
|
|
289
|
+
filtered[key] = SensitiveDataFilter.send(:filter_message, filter, value)
|
|
290
|
+
when Array
|
|
291
|
+
filtered[key] = filter_array_recursive(filter, value)
|
|
292
|
+
end
|
|
293
|
+
end
|
|
294
|
+
|
|
295
|
+
filtered
|
|
296
|
+
rescue => e
|
|
297
|
+
RailsErrorDashboard::Logger.debug("[RailsErrorDashboard] filter_hash_recursive failed: #{e.message}")
|
|
298
|
+
hash
|
|
299
|
+
end
|
|
300
|
+
private_class_method :filter_hash_recursive
|
|
301
|
+
|
|
302
|
+
# Recursively filter array items
|
|
303
|
+
# @param filter [ActiveSupport::ParameterFilter]
|
|
304
|
+
# @param array [Array]
|
|
305
|
+
# @return [Array] Filtered array
|
|
306
|
+
def self.filter_array_recursive(filter, array)
|
|
307
|
+
array.map do |item|
|
|
308
|
+
case item
|
|
309
|
+
when String
|
|
310
|
+
SensitiveDataFilter.send(:filter_message, filter, item)
|
|
311
|
+
when Hash
|
|
312
|
+
filter_hash_recursive(filter, item)
|
|
313
|
+
when Array
|
|
314
|
+
filter_array_recursive(filter, item)
|
|
315
|
+
else
|
|
316
|
+
item
|
|
317
|
+
end
|
|
318
|
+
end
|
|
319
|
+
rescue => e
|
|
320
|
+
RailsErrorDashboard::Logger.debug("[RailsErrorDashboard] filter_array_recursive failed: #{e.message}")
|
|
321
|
+
array
|
|
322
|
+
end
|
|
323
|
+
private_class_method :filter_array_recursive
|
|
324
|
+
end
|
|
325
|
+
end
|
|
326
|
+
end
|