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
@@ -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,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