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
|
@@ -37,6 +37,34 @@ module RailsErrorDashboard
|
|
|
37
37
|
context = context.merge(_serialized_system_health: Services::SystemHealthSnapshot.capture)
|
|
38
38
|
end
|
|
39
39
|
|
|
40
|
+
# Capture local variables NOW (TracePoint attaches to exception, must extract before job dispatch)
|
|
41
|
+
if RailsErrorDashboard.configuration.enable_local_variables
|
|
42
|
+
begin
|
|
43
|
+
raw_locals = Services::LocalVariableCapturer.extract(exception)
|
|
44
|
+
if raw_locals.is_a?(Hash) && raw_locals.any?
|
|
45
|
+
context = context.merge(_serialized_local_variables: Services::VariableSerializer.call(raw_locals))
|
|
46
|
+
end
|
|
47
|
+
rescue => e
|
|
48
|
+
RailsErrorDashboard::Logger.debug("[RailsErrorDashboard] Async local variable serialization failed: #{e.message}")
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Capture instance variables NOW (same reason — attached to exception object)
|
|
53
|
+
if RailsErrorDashboard.configuration.enable_instance_variables
|
|
54
|
+
begin
|
|
55
|
+
raw_ivars = Services::LocalVariableCapturer.extract_instance_vars(exception)
|
|
56
|
+
if raw_ivars.is_a?(Hash) && raw_ivars.any?
|
|
57
|
+
context = context.merge(_serialized_instance_variables: Services::VariableSerializer.call(
|
|
58
|
+
raw_ivars,
|
|
59
|
+
max_count: RailsErrorDashboard.configuration.instance_variable_max_count,
|
|
60
|
+
additional_filter_patterns: RailsErrorDashboard.configuration.instance_variable_filter_patterns
|
|
61
|
+
))
|
|
62
|
+
end
|
|
63
|
+
rescue => e
|
|
64
|
+
RailsErrorDashboard::Logger.debug("[RailsErrorDashboard] Async instance variable serialization failed: #{e.message}")
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
|
|
40
68
|
# Enqueue the async job using ActiveJob
|
|
41
69
|
# The queue adapter (:sidekiq, :solid_queue, :async) is configured separately
|
|
42
70
|
AsyncErrorLoggingJob.perform_later(exception_data, context)
|
|
@@ -179,6 +207,46 @@ module RailsErrorDashboard
|
|
|
179
207
|
attributes[:system_health] = health_data.to_json
|
|
180
208
|
end
|
|
181
209
|
|
|
210
|
+
# Capture local variables (if enabled and column exists)
|
|
211
|
+
if ErrorLog.column_names.include?("local_variables") && RailsErrorDashboard.configuration.enable_local_variables
|
|
212
|
+
begin
|
|
213
|
+
# Sync path: extract from exception ivar
|
|
214
|
+
raw_locals = Services::LocalVariableCapturer.extract(@exception)
|
|
215
|
+
# Async path fallback: use pre-serialized locals from call_async context
|
|
216
|
+
raw_locals ||= @context[:_serialized_local_variables]
|
|
217
|
+
if raw_locals.is_a?(Hash) && raw_locals.any?
|
|
218
|
+
serialized = raw_locals == @context[:_serialized_local_variables] ? raw_locals : Services::VariableSerializer.call(raw_locals)
|
|
219
|
+
attributes[:local_variables] = serialized.to_json
|
|
220
|
+
end
|
|
221
|
+
rescue => e
|
|
222
|
+
RailsErrorDashboard::Logger.debug("[RailsErrorDashboard] Local variable serialization failed: #{e.message}")
|
|
223
|
+
end
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
# Capture instance variables (if enabled and column exists)
|
|
227
|
+
if ErrorLog.column_names.include?("instance_variables") && RailsErrorDashboard.configuration.enable_instance_variables
|
|
228
|
+
begin
|
|
229
|
+
# Sync path: extract from exception ivar
|
|
230
|
+
raw_ivars = Services::LocalVariableCapturer.extract_instance_vars(@exception)
|
|
231
|
+
# Async path fallback: use pre-serialized ivars from call_async context
|
|
232
|
+
raw_ivars ||= @context[:_serialized_instance_variables]
|
|
233
|
+
if raw_ivars.is_a?(Hash) && raw_ivars.any?
|
|
234
|
+
serialized = if raw_ivars == @context[:_serialized_instance_variables]
|
|
235
|
+
raw_ivars
|
|
236
|
+
else
|
|
237
|
+
Services::VariableSerializer.call(
|
|
238
|
+
raw_ivars,
|
|
239
|
+
max_count: RailsErrorDashboard.configuration.instance_variable_max_count,
|
|
240
|
+
additional_filter_patterns: RailsErrorDashboard.configuration.instance_variable_filter_patterns
|
|
241
|
+
)
|
|
242
|
+
end
|
|
243
|
+
attributes[:instance_variables] = serialized.to_json
|
|
244
|
+
end
|
|
245
|
+
rescue => e
|
|
246
|
+
RailsErrorDashboard::Logger.debug("[RailsErrorDashboard] Instance variable serialization failed: #{e.message}")
|
|
247
|
+
end
|
|
248
|
+
end
|
|
249
|
+
|
|
182
250
|
# Find existing error or create new one
|
|
183
251
|
# This ensures accurate occurrence tracking
|
|
184
252
|
error_log = ErrorLog.find_or_increment_by_hash(error_hash, attributes.merge(error_hash: error_hash))
|
|
@@ -127,6 +127,37 @@ module RailsErrorDashboard
|
|
|
127
127
|
# System health snapshot (GC, memory, threads, connection pool at error time)
|
|
128
128
|
attr_accessor :enable_system_health # Master switch (default: false)
|
|
129
129
|
|
|
130
|
+
# Local variable capture via TracePoint(:raise)
|
|
131
|
+
attr_accessor :enable_local_variables # Master switch (default: false)
|
|
132
|
+
attr_accessor :local_variable_max_count # Max variables to capture (default: 15)
|
|
133
|
+
attr_accessor :local_variable_max_depth # Max object nesting depth (default: 3)
|
|
134
|
+
attr_accessor :local_variable_max_string_length # Max string value length (default: 200)
|
|
135
|
+
attr_accessor :local_variable_max_array_items # Max array items to serialize (default: 10)
|
|
136
|
+
attr_accessor :local_variable_max_hash_items # Max hash entries to serialize (default: 20)
|
|
137
|
+
attr_accessor :local_variable_filter_patterns # Additional sensitive name patterns (default: [])
|
|
138
|
+
|
|
139
|
+
# Instance variable capture from tp.self (receiver object at raise time)
|
|
140
|
+
attr_accessor :enable_instance_variables # Master switch (default: false)
|
|
141
|
+
attr_accessor :instance_variable_max_count # Max ivars to capture (default: 20)
|
|
142
|
+
attr_accessor :instance_variable_filter_patterns # Additional sensitive ivar patterns (default: [])
|
|
143
|
+
|
|
144
|
+
# Swallowed exception detection via TracePoint(:raise) + TracePoint(:rescue) (Ruby 3.3+ only)
|
|
145
|
+
attr_accessor :detect_swallowed_exceptions # Master switch (default: false)
|
|
146
|
+
attr_accessor :swallowed_exception_max_cache_size # Max entries per thread (default: 1000)
|
|
147
|
+
attr_accessor :swallowed_exception_flush_interval # Seconds between flushes (default: 60)
|
|
148
|
+
attr_accessor :swallowed_exception_threshold # Rescue ratio to flag (default: 0.95)
|
|
149
|
+
attr_accessor :swallowed_exception_ignore_classes # Additional exception classes to skip (default: [])
|
|
150
|
+
|
|
151
|
+
# Process crash capture via at_exit hook
|
|
152
|
+
attr_accessor :enable_crash_capture # Master switch (default: false)
|
|
153
|
+
attr_accessor :crash_capture_path # Directory for crash files (default: Dir.tmpdir)
|
|
154
|
+
|
|
155
|
+
# On-demand diagnostic dump (rake task + dashboard endpoint)
|
|
156
|
+
attr_accessor :enable_diagnostic_dump # Master switch (default: false)
|
|
157
|
+
|
|
158
|
+
# Rack Attack event tracking (requires enable_breadcrumbs = true)
|
|
159
|
+
attr_accessor :enable_rack_attack_tracking # Master switch (default: false)
|
|
160
|
+
|
|
130
161
|
# Notification callbacks (managed via helper methods, not set directly)
|
|
131
162
|
attr_reader :notification_callbacks
|
|
132
163
|
|
|
@@ -238,6 +269,37 @@ module RailsErrorDashboard
|
|
|
238
269
|
# System health snapshot defaults - OFF by default (opt-in)
|
|
239
270
|
@enable_system_health = false # Capture GC, memory, threads, connection pool at error time
|
|
240
271
|
|
|
272
|
+
# Local variable capture defaults - OFF by default (opt-in)
|
|
273
|
+
@enable_local_variables = false # TracePoint(:raise) for local var capture
|
|
274
|
+
@local_variable_max_count = 15 # Max variables per exception
|
|
275
|
+
@local_variable_max_depth = 3 # Max nesting depth for objects
|
|
276
|
+
@local_variable_max_string_length = 200 # Truncate strings beyond this
|
|
277
|
+
@local_variable_max_array_items = 10 # Max array items to serialize
|
|
278
|
+
@local_variable_max_hash_items = 20 # Max hash entries to serialize
|
|
279
|
+
@local_variable_filter_patterns = [] # Additional sensitive variable name patterns
|
|
280
|
+
|
|
281
|
+
# Instance variable capture defaults - OFF by default (opt-in)
|
|
282
|
+
@enable_instance_variables = false # Capture ivars from tp.self at raise time
|
|
283
|
+
@instance_variable_max_count = 20 # Max ivars per exception
|
|
284
|
+
@instance_variable_filter_patterns = [] # Additional sensitive ivar name patterns
|
|
285
|
+
|
|
286
|
+
# Swallowed exception detection defaults - OFF by default (Ruby 3.3+ opt-in)
|
|
287
|
+
@detect_swallowed_exceptions = false # TracePoint(:raise) + TracePoint(:rescue)
|
|
288
|
+
@swallowed_exception_max_cache_size = 1000 # Max entries per thread-local hash
|
|
289
|
+
@swallowed_exception_flush_interval = 60 # Seconds between DB flushes
|
|
290
|
+
@swallowed_exception_threshold = 0.95 # Rescue ratio to flag as swallowed
|
|
291
|
+
@swallowed_exception_ignore_classes = [] # Additional exception classes to skip
|
|
292
|
+
|
|
293
|
+
# Process crash capture defaults - OFF by default (opt-in)
|
|
294
|
+
@enable_crash_capture = false # at_exit hook for fatal crash capture
|
|
295
|
+
@crash_capture_path = nil # nil = Dir.tmpdir
|
|
296
|
+
|
|
297
|
+
# Diagnostic dump defaults - OFF by default (opt-in)
|
|
298
|
+
@enable_diagnostic_dump = false # On-demand system state snapshot
|
|
299
|
+
|
|
300
|
+
# Rack Attack event tracking defaults - OFF by default (opt-in, requires breadcrumbs)
|
|
301
|
+
@enable_rack_attack_tracking = false
|
|
302
|
+
|
|
241
303
|
# Internal logging defaults - SILENT by default
|
|
242
304
|
@enable_internal_logging = false # Opt-in for debugging
|
|
243
305
|
@log_level = :silent # Silent by default, use :debug, :info, :warn, :error, or :silent
|
|
@@ -256,11 +318,13 @@ module RailsErrorDashboard
|
|
|
256
318
|
|
|
257
319
|
# Validate configuration values
|
|
258
320
|
# Raises ConfigurationError if any validation fails
|
|
321
|
+
# Logs warnings for non-fatal issues (e.g., Ruby version incompatibilities)
|
|
259
322
|
#
|
|
260
323
|
# @raise [ConfigurationError] if configuration is invalid
|
|
261
324
|
# @return [true] if configuration is valid
|
|
262
325
|
def validate!
|
|
263
326
|
errors = []
|
|
327
|
+
warnings = []
|
|
264
328
|
|
|
265
329
|
# Validate sampling_rate (must be between 0.0 and 1.0)
|
|
266
330
|
if sampling_rate && (sampling_rate < 0.0 || sampling_rate > 1.0)
|
|
@@ -330,6 +394,59 @@ module RailsErrorDashboard
|
|
|
330
394
|
errors << "n_plus_one_threshold must be at least 2 (got: #{n_plus_one_threshold})"
|
|
331
395
|
end
|
|
332
396
|
|
|
397
|
+
# Validate local variable capture settings
|
|
398
|
+
if enable_local_variables
|
|
399
|
+
if local_variable_max_count && local_variable_max_count < 1
|
|
400
|
+
errors << "local_variable_max_count must be at least 1 (got: #{local_variable_max_count})"
|
|
401
|
+
end
|
|
402
|
+
if local_variable_max_depth && local_variable_max_depth < 1
|
|
403
|
+
errors << "local_variable_max_depth must be at least 1 (got: #{local_variable_max_depth})"
|
|
404
|
+
end
|
|
405
|
+
if local_variable_max_string_length && local_variable_max_string_length < 1
|
|
406
|
+
errors << "local_variable_max_string_length must be at least 1 (got: #{local_variable_max_string_length})"
|
|
407
|
+
end
|
|
408
|
+
end
|
|
409
|
+
|
|
410
|
+
# Validate instance variable capture settings
|
|
411
|
+
if enable_instance_variables && instance_variable_max_count && instance_variable_max_count < 1
|
|
412
|
+
errors << "instance_variable_max_count must be at least 1 (got: #{instance_variable_max_count})"
|
|
413
|
+
end
|
|
414
|
+
|
|
415
|
+
# Validate swallowed exception detection settings
|
|
416
|
+
# Auto-disable on Ruby < 3.3 (warn, don't crash)
|
|
417
|
+
if detect_swallowed_exceptions && RUBY_VERSION < "3.3"
|
|
418
|
+
warnings << "detect_swallowed_exceptions requires Ruby 3.3+ (current: #{RUBY_VERSION}). " \
|
|
419
|
+
"TracePoint(:rescue) was added in Ruby 3.3 (Feature #19572). " \
|
|
420
|
+
"Feature has been auto-disabled. Upgrade Ruby to use this feature."
|
|
421
|
+
@detect_swallowed_exceptions = false
|
|
422
|
+
end
|
|
423
|
+
# Validate sub-settings only if feature is still active after version check
|
|
424
|
+
if detect_swallowed_exceptions
|
|
425
|
+
if swallowed_exception_max_cache_size && swallowed_exception_max_cache_size < 1
|
|
426
|
+
errors << "swallowed_exception_max_cache_size must be at least 1 (got: #{swallowed_exception_max_cache_size})"
|
|
427
|
+
end
|
|
428
|
+
if swallowed_exception_flush_interval && swallowed_exception_flush_interval < 1
|
|
429
|
+
errors << "swallowed_exception_flush_interval must be at least 1 (got: #{swallowed_exception_flush_interval})"
|
|
430
|
+
end
|
|
431
|
+
if swallowed_exception_threshold && (swallowed_exception_threshold < 0.0 || swallowed_exception_threshold > 1.0)
|
|
432
|
+
errors << "swallowed_exception_threshold must be between 0.0 and 1.0 (got: #{swallowed_exception_threshold})"
|
|
433
|
+
end
|
|
434
|
+
end
|
|
435
|
+
|
|
436
|
+
# Validate rack_attack tracking requires breadcrumbs
|
|
437
|
+
if enable_rack_attack_tracking && !enable_breadcrumbs
|
|
438
|
+
warnings << "enable_rack_attack_tracking requires enable_breadcrumbs = true. " \
|
|
439
|
+
"Rack Attack tracking has been auto-disabled."
|
|
440
|
+
@enable_rack_attack_tracking = false
|
|
441
|
+
end
|
|
442
|
+
|
|
443
|
+
# Validate crash capture path (must exist if custom path specified)
|
|
444
|
+
if enable_crash_capture && crash_capture_path
|
|
445
|
+
unless Dir.exist?(crash_capture_path)
|
|
446
|
+
errors << "crash_capture_path '#{crash_capture_path}' does not exist"
|
|
447
|
+
end
|
|
448
|
+
end
|
|
449
|
+
|
|
333
450
|
# Validate notification dependencies
|
|
334
451
|
if enable_slack_notifications && (slack_webhook_url.nil? || slack_webhook_url.strip.empty?)
|
|
335
452
|
errors << "slack_webhook_url is required when enable_slack_notifications is true"
|
|
@@ -388,6 +505,11 @@ module RailsErrorDashboard
|
|
|
388
505
|
errors << "notification_threshold_alerts must be an Array (got: #{notification_threshold_alerts.class})"
|
|
389
506
|
end
|
|
390
507
|
|
|
508
|
+
# Log warnings (non-fatal issues)
|
|
509
|
+
warnings.each do |warning|
|
|
510
|
+
Rails.logger.warn "[Rails Error Dashboard] #{warning}" if defined?(Rails) && Rails.respond_to?(:logger) && Rails.logger
|
|
511
|
+
end
|
|
512
|
+
|
|
391
513
|
# Raise exception if any errors found
|
|
392
514
|
raise ConfigurationError, errors if errors.any?
|
|
393
515
|
|
|
@@ -69,6 +69,30 @@ module RailsErrorDashboard
|
|
|
69
69
|
if RailsErrorDashboard.configuration.enable_breadcrumbs
|
|
70
70
|
RailsErrorDashboard::Subscribers::BreadcrumbSubscriber.subscribe!
|
|
71
71
|
end
|
|
72
|
+
|
|
73
|
+
# Subscribe to Rack Attack AS::Notifications events (requires breadcrumbs + Rack::Attack)
|
|
74
|
+
if RailsErrorDashboard.configuration.enable_rack_attack_tracking &&
|
|
75
|
+
RailsErrorDashboard.configuration.enable_breadcrumbs &&
|
|
76
|
+
defined?(Rack::Attack)
|
|
77
|
+
RailsErrorDashboard::Subscribers::RackAttackSubscriber.subscribe!
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# Enable TracePoint(:raise) for local variable and/or instance variable capture
|
|
81
|
+
if RailsErrorDashboard.configuration.enable_local_variables ||
|
|
82
|
+
RailsErrorDashboard.configuration.enable_instance_variables
|
|
83
|
+
RailsErrorDashboard::Services::LocalVariableCapturer.enable!
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# Enable TracePoint(:raise) + TracePoint(:rescue) for swallowed exception detection
|
|
87
|
+
if RailsErrorDashboard.configuration.detect_swallowed_exceptions
|
|
88
|
+
RailsErrorDashboard::Services::SwallowedExceptionTracker.enable!
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# Import crash files from previous process death, then register at_exit hook
|
|
92
|
+
if RailsErrorDashboard.configuration.enable_crash_capture
|
|
93
|
+
RailsErrorDashboard::Services::CrashCapture.import!
|
|
94
|
+
RailsErrorDashboard::Services::CrashCapture.enable!
|
|
95
|
+
end
|
|
72
96
|
end
|
|
73
97
|
end
|
|
74
98
|
end
|
|
@@ -309,37 +309,58 @@ module RailsErrorDashboard
|
|
|
309
309
|
def top_errors_by_impact
|
|
310
310
|
base_scope.where("occurred_at >= ?", 7.days.ago)
|
|
311
311
|
.group(:error_type, :id)
|
|
312
|
-
.select("error_type, id, occurrence_count,
|
|
312
|
+
.select("error_type, id, message, occurred_at, occurrence_count,
|
|
313
313
|
COUNT(DISTINCT user_id) as affected_users,
|
|
314
314
|
COUNT(DISTINCT user_id) * occurrence_count as impact_score")
|
|
315
315
|
.order("impact_score DESC")
|
|
316
316
|
.limit(6)
|
|
317
317
|
.map do |error|
|
|
318
|
-
full_error = ErrorLog.find(error.id)
|
|
319
318
|
{
|
|
320
319
|
id: error.id,
|
|
321
320
|
error_type: error.error_type,
|
|
322
|
-
message:
|
|
323
|
-
severity:
|
|
321
|
+
message: error.message&.truncate(80),
|
|
322
|
+
severity: Services::SeverityClassifier.classify(error.error_type),
|
|
324
323
|
occurrence_count: error.occurrence_count,
|
|
325
324
|
affected_users: error.affected_users.to_i,
|
|
326
325
|
impact_score: error.impact_score.to_i,
|
|
327
|
-
occurred_at:
|
|
326
|
+
occurred_at: error.occurred_at
|
|
328
327
|
}
|
|
329
328
|
end
|
|
330
329
|
end
|
|
331
330
|
|
|
332
331
|
# Calculate average resolution time (MTTR) in hours for the last 30 days
|
|
332
|
+
# Uses SQL AVG to avoid loading all resolved errors into Ruby memory
|
|
333
333
|
def average_resolution_time
|
|
334
|
-
|
|
335
|
-
return nil
|
|
334
|
+
scope = base_scope.resolved.where("resolved_at >= ?", 30.days.ago)
|
|
335
|
+
return nil unless scope.exists?
|
|
336
336
|
|
|
337
|
-
|
|
338
|
-
|
|
337
|
+
avg_seconds = scope.pick(Arel.sql(avg_seconds_sql))
|
|
338
|
+
return nil unless avg_seconds
|
|
339
|
+
|
|
340
|
+
(avg_seconds.to_f / 3600.0).round(2)
|
|
341
|
+
end
|
|
342
|
+
|
|
343
|
+
def avg_seconds_sql
|
|
344
|
+
case db_adapter
|
|
345
|
+
when :postgresql
|
|
346
|
+
"AVG(EXTRACT(EPOCH FROM (resolved_at - occurred_at)))"
|
|
347
|
+
when :mysql
|
|
348
|
+
"AVG(TIMESTAMPDIFF(SECOND, occurred_at, resolved_at))"
|
|
349
|
+
else
|
|
350
|
+
# SQLite: julianday difference * 86400 gives seconds
|
|
351
|
+
"AVG((julianday(resolved_at) - julianday(occurred_at)) * 86400)"
|
|
339
352
|
end
|
|
353
|
+
end
|
|
340
354
|
|
|
341
|
-
|
|
342
|
-
|
|
355
|
+
def db_adapter
|
|
356
|
+
adapter = ErrorLog.connection.adapter_name.downcase
|
|
357
|
+
if adapter.include?("postgresql")
|
|
358
|
+
:postgresql
|
|
359
|
+
elsif adapter.include?("mysql") || adapter.include?("trilogy")
|
|
360
|
+
:mysql
|
|
361
|
+
else
|
|
362
|
+
:sqlite
|
|
363
|
+
end
|
|
343
364
|
end
|
|
344
365
|
end
|
|
345
366
|
end
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RailsErrorDashboard
|
|
4
|
+
module Queries
|
|
5
|
+
# Query: Aggregate Rack Attack events from breadcrumbs across all errors
|
|
6
|
+
# Scans error_logs breadcrumbs JSON, filters for "rack_attack" category crumbs,
|
|
7
|
+
# and groups by rule name with counts, unique IPs, paths, and error associations.
|
|
8
|
+
class RackAttackSummary
|
|
9
|
+
def self.call(days = 30, application_id: nil)
|
|
10
|
+
new(days, application_id: application_id).call
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def initialize(days = 30, application_id: nil)
|
|
14
|
+
@days = days
|
|
15
|
+
@application_id = application_id
|
|
16
|
+
@start_date = days.days.ago
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def call
|
|
20
|
+
{
|
|
21
|
+
events: aggregated_events
|
|
22
|
+
}
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
private
|
|
26
|
+
|
|
27
|
+
def base_query
|
|
28
|
+
scope = ErrorLog.where("occurred_at >= ?", @start_date)
|
|
29
|
+
.where.not(breadcrumbs: nil)
|
|
30
|
+
scope = scope.where(application_id: @application_id) if @application_id.present?
|
|
31
|
+
scope
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def aggregated_events
|
|
35
|
+
results = {}
|
|
36
|
+
|
|
37
|
+
base_query.select(:id, :breadcrumbs, :occurred_at).find_each(batch_size: 500) do |error_log|
|
|
38
|
+
crumbs = parse_breadcrumbs(error_log.breadcrumbs)
|
|
39
|
+
next if crumbs.empty?
|
|
40
|
+
|
|
41
|
+
rack_attack_crumbs = crumbs.select { |c| c["c"] == "rack_attack" }
|
|
42
|
+
next if rack_attack_crumbs.empty?
|
|
43
|
+
|
|
44
|
+
rack_attack_crumbs.each do |crumb|
|
|
45
|
+
meta = crumb["meta"] || {}
|
|
46
|
+
rule = meta["rule"].to_s.presence || "unknown"
|
|
47
|
+
|
|
48
|
+
if results[rule]
|
|
49
|
+
results[rule][:count] += 1
|
|
50
|
+
results[rule][:ips] << meta["discriminator"].to_s if meta["discriminator"].present?
|
|
51
|
+
results[rule][:paths] << meta["path"].to_s if meta["path"].present?
|
|
52
|
+
results[rule][:error_ids] << error_log.id
|
|
53
|
+
results[rule][:last_seen] = [ results[rule][:last_seen], error_log.occurred_at ].compact.max
|
|
54
|
+
else
|
|
55
|
+
results[rule] = {
|
|
56
|
+
rule: rule,
|
|
57
|
+
match_type: meta["type"].to_s,
|
|
58
|
+
count: 1,
|
|
59
|
+
ips: Set.new([ meta["discriminator"].to_s ].reject(&:blank?)),
|
|
60
|
+
paths: Set.new([ meta["path"].to_s ].reject(&:blank?)),
|
|
61
|
+
error_ids: [ error_log.id ],
|
|
62
|
+
last_seen: error_log.occurred_at
|
|
63
|
+
}
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
results.values.each do |r|
|
|
69
|
+
r[:error_ids] = r[:error_ids].uniq
|
|
70
|
+
r[:error_count] = r[:error_ids].size
|
|
71
|
+
r[:unique_ips] = r[:ips].size
|
|
72
|
+
r[:top_path] = r[:paths].first
|
|
73
|
+
r[:ips] = r[:ips].to_a
|
|
74
|
+
r[:paths] = r[:paths].to_a
|
|
75
|
+
end
|
|
76
|
+
results.values.sort_by { |r| -r[:count] }
|
|
77
|
+
rescue => e
|
|
78
|
+
Rails.logger.error("[RailsErrorDashboard] RackAttackSummary query failed: #{e.class}: #{e.message}")
|
|
79
|
+
[]
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def parse_breadcrumbs(raw)
|
|
83
|
+
return [] if raw.blank?
|
|
84
|
+
JSON.parse(raw)
|
|
85
|
+
rescue JSON::ParserError
|
|
86
|
+
[]
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RailsErrorDashboard
|
|
4
|
+
module Queries
|
|
5
|
+
# Query: Aggregate swallowed exception data across hourly buckets for dashboard display.
|
|
6
|
+
#
|
|
7
|
+
# Groups by (exception_class, raise_location) and sums raise/rescue counts across
|
|
8
|
+
# all rescue_locations and time buckets. This is important because the tracker stores
|
|
9
|
+
# raise events (rescue_location=nil) and rescue events (rescue_location set) in separate
|
|
10
|
+
# rows — grouping by rescue_location too would prevent the ratio from ever being computed.
|
|
11
|
+
# Filters to entries with rescue_ratio >= threshold (i.e., likely swallowed).
|
|
12
|
+
# Returns array of hashes sorted by total rescue count descending.
|
|
13
|
+
class SwallowedExceptionSummary
|
|
14
|
+
def self.call(days = 30, application_id: nil)
|
|
15
|
+
new(days, application_id: application_id).call
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def initialize(days = 30, application_id: nil)
|
|
19
|
+
@days = days
|
|
20
|
+
@application_id = application_id
|
|
21
|
+
@start_date = days.days.ago.beginning_of_hour
|
|
22
|
+
@threshold = RailsErrorDashboard.configuration.swallowed_exception_threshold
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def call
|
|
26
|
+
entries = aggregated_entries
|
|
27
|
+
{
|
|
28
|
+
entries: entries,
|
|
29
|
+
summary: {
|
|
30
|
+
total_swallowed_classes: entries.map { |e| e[:exception_class] }.uniq.size,
|
|
31
|
+
total_rescue_count: entries.sum { |e| e[:rescue_count] },
|
|
32
|
+
total_raise_count: entries.sum { |e| e[:raise_count] }
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
rescue => e
|
|
36
|
+
Rails.logger.error("[RailsErrorDashboard] SwallowedExceptionSummary failed: #{e.class}: #{e.message}")
|
|
37
|
+
{ entries: [], summary: { total_swallowed_classes: 0, total_rescue_count: 0, total_raise_count: 0 } }
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
private
|
|
41
|
+
|
|
42
|
+
def base_query
|
|
43
|
+
return SwallowedException.none unless table_exists?
|
|
44
|
+
|
|
45
|
+
scope = SwallowedException.since(@start_date)
|
|
46
|
+
scope = scope.for_application(@application_id) if @application_id.present?
|
|
47
|
+
scope
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def aggregated_entries
|
|
51
|
+
# Group by (exception_class, raise_location) only — NOT rescue_location.
|
|
52
|
+
# The tracker stores raise events (rescue_location=nil) and rescue events
|
|
53
|
+
# (rescue_location set) as separate DB rows. Grouping by rescue_location
|
|
54
|
+
# would keep them separate, making the ratio always 0 or infinity.
|
|
55
|
+
rows = base_query
|
|
56
|
+
.group(:exception_class, :raise_location)
|
|
57
|
+
.select(
|
|
58
|
+
:exception_class, :raise_location,
|
|
59
|
+
"SUM(raise_count) AS total_raise_count",
|
|
60
|
+
"SUM(rescue_count) AS total_rescue_count",
|
|
61
|
+
"MAX(last_seen_at) AS last_seen",
|
|
62
|
+
"MAX(rescue_location) AS top_rescue_location"
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
rows.filter_map do |row|
|
|
66
|
+
raise_count = row.total_raise_count.to_i
|
|
67
|
+
rescue_count = row.total_rescue_count.to_i
|
|
68
|
+
ratio = raise_count > 0 ? (rescue_count.to_f / raise_count).round(4) : 0.0
|
|
69
|
+
|
|
70
|
+
next unless ratio >= @threshold
|
|
71
|
+
|
|
72
|
+
last_seen = row.last_seen
|
|
73
|
+
last_seen = Time.zone.parse(last_seen) if last_seen.is_a?(String)
|
|
74
|
+
|
|
75
|
+
{
|
|
76
|
+
exception_class: row.exception_class,
|
|
77
|
+
raise_location: row.raise_location,
|
|
78
|
+
rescue_location: row.top_rescue_location,
|
|
79
|
+
raise_count: raise_count,
|
|
80
|
+
rescue_count: rescue_count,
|
|
81
|
+
rescue_ratio: ratio,
|
|
82
|
+
last_seen: last_seen
|
|
83
|
+
}
|
|
84
|
+
end.sort_by { |e| -e[:rescue_count] }
|
|
85
|
+
rescue => e
|
|
86
|
+
Rails.logger.error("[RailsErrorDashboard] SwallowedExceptionSummary query failed: #{e.class}: #{e.message}")
|
|
87
|
+
[]
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def table_exists?
|
|
91
|
+
SwallowedException.table_exists?
|
|
92
|
+
rescue ActiveRecord::NoDatabaseError, ActiveRecord::StatementInvalid
|
|
93
|
+
false
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
end
|
|
@@ -114,6 +114,18 @@ module RailsErrorDashboard
|
|
|
114
114
|
[]
|
|
115
115
|
end
|
|
116
116
|
|
|
117
|
+
# Non-destructive read of current breadcrumbs (does NOT clear the buffer)
|
|
118
|
+
# Used by DiagnosticDumpGenerator for on-demand snapshots.
|
|
119
|
+
# @return [Array<Hash>] Array of breadcrumb hashes (empty if none)
|
|
120
|
+
def self.current_breadcrumbs
|
|
121
|
+
buffer = Thread.current[THREAD_KEY]
|
|
122
|
+
return [] unless buffer
|
|
123
|
+
buffer.to_a
|
|
124
|
+
rescue => e
|
|
125
|
+
RailsErrorDashboard::Logger.debug("[RailsErrorDashboard] BreadcrumbCollector.current_breadcrumbs failed: #{e.message}")
|
|
126
|
+
[]
|
|
127
|
+
end
|
|
128
|
+
|
|
117
129
|
# Get the current buffer (for inspection)
|
|
118
130
|
# @return [RingBuffer, nil]
|
|
119
131
|
def self.current_buffer
|