rails_error_dashboard 0.3.0 → 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 (46) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +162 -834
  3. data/app/controllers/rails_error_dashboard/errors_controller.rb +140 -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 +33 -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/_request_context.html.erb +18 -7
  11. data/app/views/rails_error_dashboard/errors/database_health_summary.html.erb +450 -0
  12. data/app/views/rails_error_dashboard/errors/diagnostic_dumps.html.erb +182 -0
  13. data/app/views/rails_error_dashboard/errors/job_health_summary.html.erb +152 -0
  14. data/app/views/rails_error_dashboard/errors/rack_attack_summary.html.erb +133 -0
  15. data/app/views/rails_error_dashboard/errors/show.html.erb +4 -0
  16. data/app/views/rails_error_dashboard/errors/swallowed_exceptions.html.erb +126 -0
  17. data/config/routes.rb +6 -0
  18. data/db/migrate/20251223000000_create_rails_error_dashboard_complete_schema.rb +33 -0
  19. data/db/migrate/20260306000001_add_local_variables_to_error_logs.rb +13 -0
  20. data/db/migrate/20260306000002_add_instance_variables_to_error_logs.rb +7 -0
  21. data/db/migrate/20260306000003_create_rails_error_dashboard_swallowed_exceptions.rb +34 -0
  22. data/db/migrate/20260307000001_create_rails_error_dashboard_diagnostic_dumps.rb +17 -0
  23. data/lib/generators/rails_error_dashboard/install/install_generator.rb +32 -0
  24. data/lib/generators/rails_error_dashboard/install/templates/initializer.rb +47 -0
  25. data/lib/rails_error_dashboard/commands/flush_swallowed_exceptions.rb +103 -0
  26. data/lib/rails_error_dashboard/commands/log_error.rb +68 -0
  27. data/lib/rails_error_dashboard/configuration.rb +122 -0
  28. data/lib/rails_error_dashboard/engine.rb +24 -0
  29. data/lib/rails_error_dashboard/queries/dashboard_stats.rb +32 -11
  30. data/lib/rails_error_dashboard/queries/database_health_summary.rb +82 -0
  31. data/lib/rails_error_dashboard/queries/job_health_summary.rb +101 -0
  32. data/lib/rails_error_dashboard/queries/rack_attack_summary.rb +90 -0
  33. data/lib/rails_error_dashboard/queries/swallowed_exception_summary.rb +97 -0
  34. data/lib/rails_error_dashboard/services/breadcrumb_collector.rb +12 -0
  35. data/lib/rails_error_dashboard/services/crash_capture.rb +234 -0
  36. data/lib/rails_error_dashboard/services/database_health_inspector.rb +168 -0
  37. data/lib/rails_error_dashboard/services/diagnostic_dump_generator.rb +98 -0
  38. data/lib/rails_error_dashboard/services/local_variable_capturer.rb +207 -0
  39. data/lib/rails_error_dashboard/services/rspec_generator.rb +145 -0
  40. data/lib/rails_error_dashboard/services/swallowed_exception_tracker.rb +277 -0
  41. data/lib/rails_error_dashboard/services/variable_serializer.rb +326 -0
  42. data/lib/rails_error_dashboard/subscribers/rack_attack_subscriber.rb +94 -0
  43. data/lib/rails_error_dashboard/version.rb +1 -1
  44. data/lib/rails_error_dashboard.rb +13 -0
  45. data/lib/tasks/error_dashboard.rake +34 -0
  46. metadata +29 -2
@@ -356,7 +356,54 @@ RailsErrorDashboard.configure do |config|
356
356
  config.enable_system_health = false
357
357
 
358
358
  <% end -%>
359
+ <% if @enable_swallowed_exceptions -%>
360
+ # Swallowed Exception Detection - ENABLED
361
+ # Requires Ruby 3.3+ — detects exceptions that are raised then silently rescued
362
+ # Uses TracePoint(:rescue), which was added in Ruby 3.3 (Feature #19572)
363
+ config.detect_swallowed_exceptions = true
364
+ config.swallowed_exception_threshold = 0.95 # Rescue ratio to flag (95%+)
365
+ # config.swallowed_exception_flush_interval = 60 # Seconds between DB flushes
366
+ # config.swallowed_exception_max_cache_size = 1000 # Max entries per thread
367
+ # config.swallowed_exception_ignore_classes = [] # App-specific exceptions to skip
368
+ # To disable: Set config.detect_swallowed_exceptions = false
359
369
 
370
+ <% else -%>
371
+ # Swallowed Exception Detection - DISABLED
372
+ # Requires Ruby 3.3+ (TracePoint(:rescue) not available before 3.3)
373
+ # To enable: Set config.detect_swallowed_exceptions = true
374
+ config.detect_swallowed_exceptions = false
375
+ # config.swallowed_exception_threshold = 0.95
376
+
377
+ <% end -%>
378
+ <% if @enable_diagnostic_dump -%>
379
+ # Diagnostic Dump - ENABLED
380
+ # On-demand system state snapshot via rake task or dashboard button
381
+ config.enable_diagnostic_dump = true
382
+ # To disable: Set config.enable_diagnostic_dump = false
383
+
384
+ <% else -%>
385
+ # Diagnostic Dump - DISABLED
386
+ # On-demand system state snapshot (rake task + dashboard page)
387
+ # To enable: Set config.enable_diagnostic_dump = true
388
+ config.enable_diagnostic_dump = false
389
+
390
+ <% end -%>
391
+ <% if @enable_crash_capture -%>
392
+ # Process Crash Capture - ENABLED
393
+ # Captures fatal crashes via at_exit hook. Crash data is written to disk as JSON
394
+ # and imported into the database on next boot. Zero runtime overhead.
395
+ config.enable_crash_capture = true
396
+ # config.crash_capture_path = "/tmp/my_app_crashes" # Default: Dir.tmpdir
397
+ # To disable: Set config.enable_crash_capture = false
398
+
399
+ <% else -%>
400
+ # Process Crash Capture - DISABLED
401
+ # Captures fatal crashes via at_exit hook (written to disk, imported on next boot)
402
+ # To enable: Set config.enable_crash_capture = true
403
+ config.enable_crash_capture = false
404
+ # config.crash_capture_path = "/tmp/my_app_crashes"
405
+
406
+ <% end -%>
360
407
  # Repository settings (auto-detected from git remote, optional override)
361
408
  # config.repository_url = ENV["REPOSITORY_URL"] # e.g., "https://github.com/user/repo"
362
409
  # config.repository_branch = ENV.fetch("REPOSITORY_BRANCH", "main") # Default branch
@@ -0,0 +1,103 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsErrorDashboard
4
+ module Commands
5
+ # Command: Upsert swallowed exception raise/rescue counts into the database.
6
+ #
7
+ # Receives snapshot hashes from SwallowedExceptionTracker and merges them
8
+ # into hourly-bucketed rows. Uses find_or_initialize_by + increment for
9
+ # cross-database compatibility (no raw SQL upsert).
10
+ #
11
+ # raise_counts keys: "ClassName|path:line"
12
+ # rescue_counts keys: "ClassName|raise_path:line->rescue_path:line"
13
+ class FlushSwallowedExceptions
14
+ def self.call(raise_counts:, rescue_counts:)
15
+ new(raise_counts: raise_counts, rescue_counts: rescue_counts).call
16
+ end
17
+
18
+ def initialize(raise_counts:, rescue_counts:)
19
+ @raise_counts = raise_counts
20
+ @rescue_counts = rescue_counts
21
+ end
22
+
23
+ def call
24
+ period = Time.current.beginning_of_hour
25
+ app_id = current_application_id
26
+
27
+ # Process raise counts
28
+ @raise_counts.each do |key, count|
29
+ class_name, location = key.split("|", 2)
30
+ next if class_name.blank? || location.blank?
31
+
32
+ upsert_raise(class_name, location, period, app_id, count)
33
+ end
34
+
35
+ # Process rescue counts
36
+ @rescue_counts.each do |key, count|
37
+ class_name, locations = key.split("|", 2)
38
+ next if class_name.blank? || locations.blank?
39
+
40
+ raise_loc, rescue_loc = locations.split("->", 2)
41
+ next if raise_loc.blank?
42
+
43
+ upsert_rescue(class_name, raise_loc, rescue_loc, period, app_id, count)
44
+ end
45
+ rescue => e
46
+ RailsErrorDashboard::Logger.debug(
47
+ "[RailsErrorDashboard] FlushSwallowedExceptions failed: #{e.class} - #{e.message}"
48
+ )
49
+ end
50
+
51
+ private
52
+
53
+ def upsert_raise(class_name, location, period, app_id, count)
54
+ record = SwallowedException.find_or_initialize_by(
55
+ exception_class: truncate(class_name, 255),
56
+ raise_location: truncate(location, 500),
57
+ rescue_location: nil,
58
+ period_hour: period,
59
+ application_id: app_id
60
+ )
61
+
62
+ record.raise_count = (record.raise_count || 0) + count
63
+ record.last_seen_at = Time.current
64
+ record.save!
65
+ rescue => e
66
+ RailsErrorDashboard::Logger.debug(
67
+ "[RailsErrorDashboard] FlushSwallowedExceptions.upsert_raise failed for #{class_name}: #{e.message}"
68
+ )
69
+ end
70
+
71
+ def upsert_rescue(class_name, raise_loc, rescue_loc, period, app_id, count)
72
+ record = SwallowedException.find_or_initialize_by(
73
+ exception_class: truncate(class_name, 255),
74
+ raise_location: truncate(raise_loc, 500),
75
+ rescue_location: rescue_loc.present? ? truncate(rescue_loc, 500) : nil,
76
+ period_hour: period,
77
+ application_id: app_id
78
+ )
79
+
80
+ record.rescue_count = (record.rescue_count || 0) + count
81
+ record.last_seen_at = Time.current
82
+ record.save!
83
+ rescue => e
84
+ RailsErrorDashboard::Logger.debug(
85
+ "[RailsErrorDashboard] FlushSwallowedExceptions.upsert_rescue failed for #{class_name}: #{e.message}"
86
+ )
87
+ end
88
+
89
+ def current_application_id
90
+ app_name = RailsErrorDashboard.configuration.application_name
91
+ return nil unless app_name.present?
92
+
93
+ Application.find_by(name: app_name)&.id
94
+ rescue => e
95
+ nil
96
+ end
97
+
98
+ def truncate(str, max)
99
+ str.to_s.truncate(max, omission: "")
100
+ end
101
+ end
102
+ end
103
+ end
@@ -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: full_error.message&.truncate(80),
323
- severity: full_error.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: full_error.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
- resolved_errors = base_scope.resolved.where("resolved_at >= ?", 30.days.ago)
335
- return nil if resolved_errors.empty?
334
+ scope = base_scope.resolved.where("resolved_at >= ?", 30.days.ago)
335
+ return nil unless scope.exists?
336
336
 
337
- total_seconds = resolved_errors.sum do |error|
338
- (error.resolved_at - error.occurred_at).to_i
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
- average_seconds = total_seconds / resolved_errors.count.to_f
342
- (average_seconds / 3600.0).round(2) # Convert to hours
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,82 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsErrorDashboard
4
+ module Queries
5
+ # Query: Aggregate connection pool health stats from system_health across all errors
6
+ # Scans error_logs system_health JSON, extracts connection_pool data per error
7
+ class DatabaseHealthSummary
8
+ def self.call(days = 30, application_id: nil)
9
+ new(days, application_id: application_id).call
10
+ end
11
+
12
+ def initialize(days = 30, application_id: nil)
13
+ @days = days
14
+ @application_id = application_id
15
+ @start_date = days.days.ago
16
+ end
17
+
18
+ def call
19
+ {
20
+ entries: aggregated_entries
21
+ }
22
+ end
23
+
24
+ private
25
+
26
+ def base_query
27
+ scope = ErrorLog.where("occurred_at >= ?", @start_date)
28
+ .where.not(system_health: nil)
29
+ scope = scope.where(application_id: @application_id) if @application_id.present?
30
+ scope
31
+ end
32
+
33
+ def aggregated_entries
34
+ results = []
35
+
36
+ base_query.select(:id, :error_type, :system_health, :occurred_at).find_each(batch_size: 500) do |error_log|
37
+ health = parse_system_health(error_log.system_health)
38
+ next if health.blank?
39
+
40
+ pool = health["connection_pool"]
41
+ next if pool.blank?
42
+
43
+ results << build_entry(error_log, pool)
44
+ end
45
+
46
+ # Sort by stress score descending (worst first)
47
+ results.sort_by { |r| -(r[:busy] + r[:dead] + r[:waiting]) }
48
+ rescue => e
49
+ Rails.logger.error("[RailsErrorDashboard] DatabaseHealthSummary query failed: #{e.class}: #{e.message}")
50
+ []
51
+ end
52
+
53
+ def build_entry(error_log, pool)
54
+ size = pool["size"].to_i
55
+ busy = pool["busy"].to_i
56
+ dead = pool["dead"].to_i
57
+ idle = pool["idle"].to_i
58
+ waiting = pool["waiting"].to_i
59
+ utilization = size > 0 ? (busy.to_f / size * 100).round(1) : 0.0
60
+
61
+ {
62
+ error_id: error_log.id,
63
+ error_type: error_log.error_type,
64
+ size: size,
65
+ busy: busy,
66
+ dead: dead,
67
+ idle: idle,
68
+ waiting: waiting,
69
+ utilization: utilization,
70
+ occurred_at: error_log.occurred_at
71
+ }
72
+ end
73
+
74
+ def parse_system_health(raw)
75
+ return nil if raw.blank?
76
+ JSON.parse(raw)
77
+ rescue JSON::ParserError
78
+ nil
79
+ end
80
+ end
81
+ end
82
+ end