rails_error_dashboard 0.3.1 → 0.4.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 (39) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +160 -861
  3. data/app/controllers/rails_error_dashboard/errors_controller.rb +89 -0
  4. data/app/jobs/rails_error_dashboard/swallowed_exception_flush_job.rb +32 -0
  5. data/app/models/rails_error_dashboard/diagnostic_dump.rb +14 -0
  6. data/app/models/rails_error_dashboard/swallowed_exception.rb +38 -0
  7. data/app/views/layouts/rails_error_dashboard.html.erb +21 -0
  8. data/app/views/rails_error_dashboard/errors/_instance_variables.html.erb +55 -0
  9. data/app/views/rails_error_dashboard/errors/_local_variables.html.erb +46 -0
  10. data/app/views/rails_error_dashboard/errors/diagnostic_dumps.html.erb +182 -0
  11. data/app/views/rails_error_dashboard/errors/rack_attack_summary.html.erb +133 -0
  12. data/app/views/rails_error_dashboard/errors/show.html.erb +4 -0
  13. data/app/views/rails_error_dashboard/errors/swallowed_exceptions.html.erb +126 -0
  14. data/config/routes.rb +4 -0
  15. data/db/migrate/20251223000000_create_rails_error_dashboard_complete_schema.rb +33 -0
  16. data/db/migrate/20260306000001_add_local_variables_to_error_logs.rb +13 -0
  17. data/db/migrate/20260306000002_add_instance_variables_to_error_logs.rb +7 -0
  18. data/db/migrate/20260306000003_create_rails_error_dashboard_swallowed_exceptions.rb +34 -0
  19. data/db/migrate/20260307000001_create_rails_error_dashboard_diagnostic_dumps.rb +17 -0
  20. data/lib/generators/rails_error_dashboard/install/install_generator.rb +32 -0
  21. data/lib/generators/rails_error_dashboard/install/templates/initializer.rb +47 -0
  22. data/lib/rails_error_dashboard/commands/flush_swallowed_exceptions.rb +103 -0
  23. data/lib/rails_error_dashboard/commands/log_error.rb +68 -0
  24. data/lib/rails_error_dashboard/configuration.rb +122 -0
  25. data/lib/rails_error_dashboard/engine.rb +24 -0
  26. data/lib/rails_error_dashboard/queries/dashboard_stats.rb +32 -11
  27. data/lib/rails_error_dashboard/queries/rack_attack_summary.rb +90 -0
  28. data/lib/rails_error_dashboard/queries/swallowed_exception_summary.rb +97 -0
  29. data/lib/rails_error_dashboard/services/breadcrumb_collector.rb +12 -0
  30. data/lib/rails_error_dashboard/services/crash_capture.rb +234 -0
  31. data/lib/rails_error_dashboard/services/diagnostic_dump_generator.rb +98 -0
  32. data/lib/rails_error_dashboard/services/local_variable_capturer.rb +207 -0
  33. data/lib/rails_error_dashboard/services/swallowed_exception_tracker.rb +277 -0
  34. data/lib/rails_error_dashboard/services/variable_serializer.rb +326 -0
  35. data/lib/rails_error_dashboard/subscribers/rack_attack_subscriber.rb +94 -0
  36. data/lib/rails_error_dashboard/version.rb +1 -1
  37. data/lib/rails_error_dashboard.rb +9 -0
  38. data/lib/tasks/error_dashboard.rake +34 -0
  39. 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
@@ -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
@@ -0,0 +1,94 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsErrorDashboard
4
+ module Subscribers
5
+ # Registers ActiveSupport::Notifications subscribers for Rack::Attack events.
6
+ #
7
+ # Rack Attack (v5.0+) emits:
8
+ # - throttle.rack_attack — rate-limited requests
9
+ # - blocklist.rack_attack — blocked requests
10
+ # - track.rack_attack — tracked (observed) requests
11
+ #
12
+ # Each event is captured as a breadcrumb with category "rack_attack",
13
+ # allowing correlation between rate-limit events and error spikes.
14
+ #
15
+ # SAFETY RULES (HOST_APP_SAFETY.md):
16
+ # - Every subscriber wrapped in rescue => e; nil
17
+ # - Never raise from subscriber callbacks
18
+ # - Skip if buffer is nil (not in a request context)
19
+ class RackAttackSubscriber
20
+ EVENTS = %w[
21
+ throttle.rack_attack
22
+ blocklist.rack_attack
23
+ track.rack_attack
24
+ ].freeze
25
+
26
+ # Event subscriptions managed by this class
27
+ @subscriptions = []
28
+
29
+ class << self
30
+ attr_reader :subscriptions
31
+
32
+ # Register all Rack Attack event subscribers
33
+ # @return [Array] Array of subscription objects
34
+ def subscribe!
35
+ @subscriptions = []
36
+
37
+ EVENTS.each do |event_name|
38
+ @subscriptions << subscribe_event(event_name)
39
+ end
40
+
41
+ @subscriptions
42
+ end
43
+
44
+ # Remove all Rack Attack subscribers
45
+ def unsubscribe!
46
+ @subscriptions.each do |sub|
47
+ ActiveSupport::Notifications.unsubscribe(sub) if sub
48
+ rescue => e
49
+ nil
50
+ end
51
+ @subscriptions = []
52
+ end
53
+
54
+ private
55
+
56
+ def subscribe_event(event_name)
57
+ ActiveSupport::Notifications.subscribe(event_name) do |*args|
58
+ event = ActiveSupport::Notifications::Event.new(*args)
59
+ handle_rack_attack(event, event_name)
60
+ rescue => e
61
+ nil
62
+ end
63
+ end
64
+
65
+ def handle_rack_attack(event, event_name)
66
+ return unless Services::BreadcrumbCollector.current_buffer
67
+
68
+ request = event.payload[:request]
69
+ return unless request
70
+
71
+ env = request.respond_to?(:env) ? request.env : {}
72
+
73
+ match_type = event_name.split(".").first # "throttle", "blocklist", "track"
74
+ rule = env["rack.attack.matched"].to_s
75
+ discriminator = env["rack.attack.match_discriminator"].to_s
76
+ path = request.respond_to?(:path) ? request.path.to_s : ""
77
+ method = request.respond_to?(:request_method) ? request.request_method.to_s : ""
78
+
79
+ message = "#{match_type}: #{rule} (#{discriminator}) #{method} #{path}"
80
+
81
+ metadata = {
82
+ rule: rule,
83
+ type: match_type,
84
+ discriminator: discriminator,
85
+ path: path,
86
+ method: method
87
+ }
88
+
89
+ Services::BreadcrumbCollector.add("rack_attack", message, metadata: metadata)
90
+ end
91
+ end
92
+ end
93
+ end
94
+ end
@@ -1,3 +1,3 @@
1
1
  module RailsErrorDashboard
2
- VERSION = "0.3.1"
2
+ VERSION = "0.4.0"
3
3
  end