rails_error_dashboard 0.5.10 → 0.5.12

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 (35) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +34 -1
  3. data/app/controllers/rails_error_dashboard/errors_controller.rb +31 -0
  4. data/app/helpers/rails_error_dashboard/backtrace_helper.rb +12 -0
  5. data/app/jobs/rails_error_dashboard/scheduled_digest_job.rb +40 -0
  6. data/app/mailers/rails_error_dashboard/digest_mailer.rb +23 -0
  7. data/app/mailers/rails_error_dashboard/error_notification_mailer.rb +2 -1
  8. data/app/views/layouts/rails_error_dashboard.html.erb +5 -0
  9. data/app/views/rails_error_dashboard/digest_mailer/digest_summary.html.erb +172 -0
  10. data/app/views/rails_error_dashboard/digest_mailer/digest_summary.text.erb +49 -0
  11. data/app/views/rails_error_dashboard/errors/_error_row.html.erb +10 -10
  12. data/app/views/rails_error_dashboard/errors/_source_code.html.erb +20 -7
  13. data/app/views/rails_error_dashboard/errors/settings.html.erb +7 -3
  14. data/app/views/rails_error_dashboard/errors/show.html.erb +21 -0
  15. data/app/views/rails_error_dashboard/errors/user_impact.html.erb +172 -0
  16. data/config/routes.rb +3 -0
  17. data/lib/generators/rails_error_dashboard/install/install_generator.rb +173 -160
  18. data/lib/generators/rails_error_dashboard/install/templates/README +9 -18
  19. data/lib/generators/rails_error_dashboard/install/templates/initializer.rb +15 -10
  20. data/lib/rails_error_dashboard/configuration.rb +83 -26
  21. data/lib/rails_error_dashboard/engine.rb +10 -0
  22. data/lib/rails_error_dashboard/error_reporter.rb +26 -0
  23. data/lib/rails_error_dashboard/middleware/error_catcher.rb +7 -0
  24. data/lib/rails_error_dashboard/middleware/rate_limiter.rb +16 -12
  25. data/lib/rails_error_dashboard/plugins/jira_integration_plugin.rb +2 -1
  26. data/lib/rails_error_dashboard/queries/user_impact_summary.rb +93 -0
  27. data/lib/rails_error_dashboard/services/coverage_tracker.rb +139 -0
  28. data/lib/rails_error_dashboard/services/digest_builder.rb +158 -0
  29. data/lib/rails_error_dashboard/services/notification_helpers.rb +2 -1
  30. data/lib/rails_error_dashboard/subscribers/issue_tracker_subscriber.rb +2 -1
  31. data/lib/rails_error_dashboard/value_objects/error_context.rb +5 -0
  32. data/lib/rails_error_dashboard/version.rb +1 -1
  33. data/lib/rails_error_dashboard.rb +3 -0
  34. data/lib/tasks/error_dashboard.rake +23 -0
  35. metadata +33 -9
@@ -138,17 +138,19 @@ RailsErrorDashboard.configure do |config|
138
138
 
139
139
  <% if @enable_async_logging -%>
140
140
  # Async Error Logging - ENABLED
141
- # Errors will be logged in background jobs for better performance
141
+ # Errors are logged in background jobs zero impact on request response time.
142
+ # Default adapter is :async (Rails built-in, no extra infrastructure needed).
143
+ # Swap to :sidekiq or :solid_queue when you have a background worker running.
142
144
  config.async_logging = true
143
- config.async_adapter = :sidekiq # Options: :sidekiq, :solid_queue, :async
145
+ config.async_adapter = :async # Options: :async (built-in), :sidekiq, :solid_queue
144
146
  # To disable: Set config.async_logging = false
145
147
 
146
148
  <% else -%>
147
149
  # Async Error Logging - DISABLED
148
- # Errors are logged synchronously (blocking)
149
- # To enable: Set config.async_logging = true and configure adapter
150
+ # Errors are logged synchronously (adds ~1-5ms to requests that trigger errors)
151
+ # To enable: Set config.async_logging = true
150
152
  config.async_logging = false
151
- # config.async_adapter = :sidekiq # Options: :sidekiq, :solid_queue, :async
153
+ # config.async_adapter = :async # Options: :async (built-in), :sidekiq, :solid_queue
152
154
 
153
155
  <% end -%>
154
156
  # Backtrace size limiting (100 lines is industry standard: Rollbar, Airbrake, Bugsnag)
@@ -156,15 +158,18 @@ RailsErrorDashboard.configure do |config|
156
158
 
157
159
  <% if @enable_error_sampling -%>
158
160
  # Error Sampling - ENABLED
159
- # Reduce volume by logging only a percentage of non-critical errors
160
- # Critical errors are ALWAYS logged regardless of sampling rate
161
- config.sampling_rate = 0.1 # 10% - Adjust as needed (0.0 to 1.0)
161
+ # Samples non-critical errors to reduce storage volume.
162
+ # Critical and high severity errors are ALWAYS logged at 100% regardless of this setting.
163
+ # 0.5 = log 50% of non-critical occurrences halves storage while keeping
164
+ # occurrence counts meaningful and error patterns visible.
165
+ # Tune lower (e.g. 0.1) if one error is flooding the DB; set to 1.0 to log everything.
166
+ config.sampling_rate = 0.5 # 50% of non-critical errors
162
167
  # To disable: Set config.sampling_rate = 1.0 (100%)
163
168
 
164
169
  <% else -%>
165
170
  # Error Sampling - DISABLED
166
- # All errors are logged (100% sampling rate)
167
- # To enable: Set config.sampling_rate < 1.0 (e.g., 0.1 for 10%)
171
+ # All errors are logged (100% sampling rate).
172
+ # To enable: Set config.sampling_rate < 1.0 (e.g., 0.5 for 50%, 0.1 for 10%)
168
173
  config.sampling_rate = 1.0
169
174
 
170
175
  <% end -%>
@@ -34,6 +34,11 @@ module RailsErrorDashboard
34
34
  attr_accessor :webhook_urls
35
35
  attr_accessor :enable_webhook_notifications
36
36
 
37
+ # Scheduled digests (daily/weekly summary emails)
38
+ attr_accessor :enable_scheduled_digests # Master switch (default: false)
39
+ attr_accessor :digest_frequency # :daily or :weekly (default: :daily)
40
+ attr_accessor :digest_recipients # Array of emails (default: notification_email_recipients)
41
+
37
42
  # Separate database configuration
38
43
  attr_accessor :use_separate_database
39
44
 
@@ -168,6 +173,9 @@ module RailsErrorDashboard
168
173
  # On-demand diagnostic dump (rake task + dashboard endpoint)
169
174
  attr_accessor :enable_diagnostic_dump # Master switch (default: false)
170
175
 
176
+ # Code path coverage (diagnostic mode — Ruby 3.2+)
177
+ attr_accessor :enable_coverage_tracking # Master switch (default: false)
178
+
171
179
  # Rack Attack event tracking (requires enable_breadcrumbs = true)
172
180
  attr_accessor :enable_rack_attack_tracking # Master switch (default: false)
173
181
 
@@ -215,6 +223,11 @@ module RailsErrorDashboard
215
223
  @webhook_urls = ENV.fetch("WEBHOOK_URLS", "").split(",").map(&:strip).reject(&:empty?)
216
224
  @enable_webhook_notifications = false
217
225
 
226
+ # Scheduled digest defaults - OFF by default (opt-in)
227
+ @enable_scheduled_digests = false
228
+ @digest_frequency = :daily
229
+ @digest_recipients = nil # falls back to notification_email_recipients
230
+
218
231
  @use_separate_database = ENV.fetch("USE_SEPARATE_ERROR_DB", "false") == "true"
219
232
 
220
233
  # Retention policy - days to keep errors before automatic deletion (default: 90)
@@ -325,6 +338,9 @@ module RailsErrorDashboard
325
338
  # Diagnostic dump defaults - OFF by default (opt-in)
326
339
  @enable_diagnostic_dump = false # On-demand system state snapshot
327
340
 
341
+ # Code path coverage defaults - OFF by default (opt-in, Ruby 3.2+)
342
+ @enable_coverage_tracking = false
343
+
328
344
  # Rack Attack event tracking defaults - OFF by default (opt-in, requires breadcrumbs)
329
345
  @enable_rack_attack_tracking = false
330
346
 
@@ -495,44 +511,56 @@ module RailsErrorDashboard
495
511
  @enable_activestorage_tracking = false
496
512
  end
497
513
 
514
+ # Skip credential/service-dependent validations during Docker builds.
515
+ # SECRET_KEY_BASE_DUMMY=1 means no credentials or external services available.
516
+ build_env = ENV["SECRET_KEY_BASE_DUMMY"].present?
517
+
498
518
  # Validate issue tracking configuration
499
- if enable_issue_tracking && effective_issue_tracker_token.blank?
500
- warnings << "enable_issue_tracking is true but no token configured. " \
501
- "Set issue_tracker_token or RED_BOT_TOKEN env var. " \
502
- "Tip: Create a dedicated RED (Rails Error Dashboard) bot account on your platform."
503
- end
519
+ unless build_env
520
+ if enable_issue_tracking && effective_issue_tracker_token.blank?
521
+ warnings << "enable_issue_tracking is true but no token configured. " \
522
+ "Set issue_tracker_token or RED_BOT_TOKEN env var. " \
523
+ "Tip: Create a dedicated RED (Rails Error Dashboard) bot account on your platform."
524
+ end
504
525
 
505
- if enable_issue_tracking && effective_issue_tracker_provider.nil?
506
- warnings << "enable_issue_tracking is true but provider could not be detected. " \
507
- "Set issue_tracker_provider or git_repository_url."
526
+ if enable_issue_tracking && effective_issue_tracker_provider.nil?
527
+ warnings << "enable_issue_tracking is true but provider could not be detected. " \
528
+ "Set issue_tracker_provider or git_repository_url."
529
+ end
508
530
  end
509
531
 
510
- # Validate crash capture path (must exist if custom path specified)
511
- if enable_crash_capture && crash_capture_path
532
+ # Validate crash capture path auto-create if missing
533
+ if enable_crash_capture && crash_capture_path && !build_env
512
534
  unless Dir.exist?(crash_capture_path)
513
- errors << "crash_capture_path '#{crash_capture_path}' does not exist"
535
+ begin
536
+ FileUtils.mkdir_p(crash_capture_path)
537
+ rescue => e
538
+ errors << "crash_capture_path '#{crash_capture_path}' could not be created: #{e.message}"
539
+ end
514
540
  end
515
541
  end
516
542
 
517
- # Validate notification dependencies
518
- if enable_slack_notifications && (slack_webhook_url.nil? || slack_webhook_url.strip.empty?)
519
- errors << "slack_webhook_url is required when enable_slack_notifications is true"
520
- end
543
+ # Validate notification dependencies (skip during builds — credentials unavailable)
544
+ unless build_env
545
+ if enable_slack_notifications && (slack_webhook_url.nil? || slack_webhook_url.strip.empty?)
546
+ errors << "slack_webhook_url is required when enable_slack_notifications is true"
547
+ end
521
548
 
522
- if enable_email_notifications && notification_email_recipients.empty?
523
- errors << "notification_email_recipients is required when enable_email_notifications is true"
524
- end
549
+ if enable_email_notifications && notification_email_recipients.empty?
550
+ errors << "notification_email_recipients is required when enable_email_notifications is true"
551
+ end
525
552
 
526
- if enable_discord_notifications && (discord_webhook_url.nil? || discord_webhook_url.strip.empty?)
527
- errors << "discord_webhook_url is required when enable_discord_notifications is true"
528
- end
553
+ if enable_discord_notifications && (discord_webhook_url.nil? || discord_webhook_url.strip.empty?)
554
+ errors << "discord_webhook_url is required when enable_discord_notifications is true"
555
+ end
529
556
 
530
- if enable_pagerduty_notifications && (pagerduty_integration_key.nil? || pagerduty_integration_key.strip.empty?)
531
- errors << "pagerduty_integration_key is required when enable_pagerduty_notifications is true"
532
- end
557
+ if enable_pagerduty_notifications && (pagerduty_integration_key.nil? || pagerduty_integration_key.strip.empty?)
558
+ errors << "pagerduty_integration_key is required when enable_pagerduty_notifications is true"
559
+ end
533
560
 
534
- if enable_webhook_notifications && webhook_urls.empty?
535
- errors << "webhook_urls is required when enable_webhook_notifications is true"
561
+ if enable_webhook_notifications && webhook_urls.empty?
562
+ errors << "webhook_urls is required when enable_webhook_notifications is true"
563
+ end
536
564
  end
537
565
 
538
566
  # Validate separate database configuration
@@ -652,6 +680,14 @@ module RailsErrorDashboard
652
680
  end
653
681
  end
654
682
 
683
+ # Detect the engine's mount path from the host app routes.
684
+ # Falls back to "/red" if detection fails.
685
+ #
686
+ # @return [String] The mount path (e.g. "/red", "/admin/red", "/error_dashboard")
687
+ def engine_mount_path
688
+ @engine_mount_path ||= detect_engine_mount_path
689
+ end
690
+
655
691
  # Get the effective user model (auto-detected if not configured)
656
692
  #
657
693
  # @return [String, nil] User model class name
@@ -689,5 +725,26 @@ module RailsErrorDashboard
689
725
  def clear_total_users_cache!
690
726
  @total_users_cache = {}
691
727
  end
728
+
729
+ # Detect where the engine is mounted in the host app's routes.
730
+ # @return [String] mount path (default: "/red")
731
+ def detect_engine_mount_path
732
+ return "/red" unless defined?(Rails) && Rails.application
733
+
734
+ Rails.application.routes.routes.each do |route|
735
+ app = route.app
736
+ app = app.app if app.respond_to?(:app)
737
+ if app == RailsErrorDashboard::Engine || (app.is_a?(Class) && app <= RailsErrorDashboard::Engine)
738
+ path = route.path.spec.to_s.sub("(.:format)", "").chomp("/")
739
+ return path if path.present?
740
+ end
741
+ rescue => e
742
+ next
743
+ end
744
+
745
+ "/red"
746
+ rescue => e
747
+ "/red"
748
+ end
692
749
  end
693
750
  end
@@ -61,6 +61,16 @@ module RailsErrorDashboard
61
61
 
62
62
  # Subscribe to Rails error reporter
63
63
  config.after_initialize do
64
+ # Skip all runtime features during Docker asset precompilation.
65
+ # SECRET_KEY_BASE_DUMMY=1 signals a build environment — no database,
66
+ # no credentials, no external services are available. Activating
67
+ # TracePoint hooks, error subscribers, or background jobs here causes
68
+ # infinite retry loops and connection failures (issues #1-5).
69
+ if ENV["SECRET_KEY_BASE_DUMMY"].present?
70
+ Rails.logger.info "[Rails Error Dashboard] Build environment detected (SECRET_KEY_BASE_DUMMY) — skipping runtime features."
71
+ next
72
+ end
73
+
64
74
  if RailsErrorDashboard.configuration.enable_error_subscriber
65
75
  Rails.error.subscribe(RailsErrorDashboard::ErrorReporter.new)
66
76
  end
@@ -24,6 +24,32 @@ module RailsErrorDashboard
24
24
 
25
25
  # CRITICAL: Wrap entire process in rescue to ensure failures don't break the app
26
26
  begin
27
+ # Enrich context with request data from Thread.current when available.
28
+ # Rails internals (ActionDispatch::Executor) report errors with
29
+ # source: "application.action_dispatch" but pass NO request object,
30
+ # resulting in placeholder values ("Rails Application", "{}", etc.).
31
+ # Our middleware stores the Rack env in Thread.current so we can
32
+ # build a proper request here — fixing issue #106.
33
+ if context[:request].nil? && Thread.current[:rails_error_dashboard_request_env]
34
+ env = Thread.current[:rails_error_dashboard_request_env]
35
+ context = context.merge(request: ActionDispatch::Request.new(env))
36
+ end
37
+
38
+ # Skip duplicate reports from our own middleware when the subscriber
39
+ # already captured this error with full request context above.
40
+ # Without this, async logging enqueues two jobs for one exception —
41
+ # and non-deterministic job ordering can overwrite good data.
42
+ if source == "rack.middleware" &&
43
+ Thread.current[:rails_error_dashboard_reported_errors]&.include?(error.object_id)
44
+ return
45
+ end
46
+
47
+ # Track that we've reported this error (for dedup with middleware)
48
+ if Thread.current[:rails_error_dashboard_request_env]
49
+ Thread.current[:rails_error_dashboard_reported_errors] ||= Set.new
50
+ Thread.current[:rails_error_dashboard_reported_errors].add(error.object_id)
51
+ end
52
+
27
53
  # Extract context information
28
54
  error_context = ValueObjects::ErrorContext.new(context, source)
29
55
 
@@ -23,6 +23,11 @@ module RailsErrorDashboard
23
23
  # Record request start time for duration calculation
24
24
  env["rails_error_dashboard.request_start"] = Time.now.to_f
25
25
 
26
+ # Store request env in thread-local so the ErrorReporter subscriber
27
+ # can access request context when Rails.error.report fires from
28
+ # Rails internals (e.g., ActionDispatch::ShowExceptions)
29
+ Thread.current[:rails_error_dashboard_request_env] = env
30
+
26
31
  # Initialize breadcrumb buffer for this request
27
32
  if RailsErrorDashboard.configuration.enable_breadcrumbs
28
33
  RailsErrorDashboard::Services::BreadcrumbCollector.init_buffer
@@ -52,6 +57,8 @@ module RailsErrorDashboard
52
57
  raise exception
53
58
  ensure
54
59
  # CRITICAL: Always clean up thread-local storage (Puma reuses threads)
60
+ Thread.current[:rails_error_dashboard_request_env] = nil
61
+ Thread.current[:rails_error_dashboard_reported_errors] = nil
55
62
  RailsErrorDashboard::Services::BreadcrumbCollector.clear_buffer
56
63
  end
57
64
  end
@@ -5,14 +5,9 @@ module RailsErrorDashboard
5
5
  # Rate limiting middleware for Rails Error Dashboard routes
6
6
  # Protects both dashboard UI and API endpoints from abuse
7
7
  class RateLimiter
8
- # Rate limits by endpoint type
9
- LIMITS = {
10
- # API endpoints (mobile/frontend) - stricter limits
11
- "/error_dashboard/api" => { limit: 100, period: 60 }, # 100 req/min
12
-
13
- # Dashboard pages (human users) - more lenient
14
- "/error_dashboard" => { limit: 300, period: 60 } # 300 req/min
15
- }.freeze
8
+ # Rate limits by endpoint type (relative to engine mount path)
9
+ API_LIMIT = { limit: 100, period: 60 }.freeze # 100 req/min
10
+ DASHBOARD_LIMIT = { limit: 300, period: 60 }.freeze # 300 req/min
16
11
 
17
12
  def initialize(app)
18
13
  @app = app
@@ -51,13 +46,22 @@ module RailsErrorDashboard
51
46
  RailsErrorDashboard.configuration.enable_rate_limiting
52
47
  end
53
48
 
49
+ def engine_mount_path
50
+ @engine_mount_path ||= RailsErrorDashboard.configuration.engine_mount_path
51
+ rescue
52
+ "/red"
53
+ end
54
+
54
55
  def error_dashboard_route?(path)
55
- path.start_with?("/error_dashboard")
56
+ path.start_with?(engine_mount_path)
56
57
  end
57
58
 
58
59
  def find_limit_config(path)
59
- # Match most specific route first (API before dashboard)
60
- LIMITS.find { |pattern, _| path.start_with?(pattern) }&.last
60
+ if path.start_with?("#{engine_mount_path}/api")
61
+ API_LIMIT
62
+ else
63
+ DASHBOARD_LIMIT
64
+ end
61
65
  end
62
66
 
63
67
  def rate_limit_key(request)
@@ -71,7 +75,7 @@ module RailsErrorDashboard
71
75
 
72
76
  def rate_limit_response(request, limit_config)
73
77
  # Return JSON for API requests, HTML for dashboard
74
- if request.path.start_with?("/error_dashboard/api")
78
+ if request.path.start_with?("#{engine_mount_path}/api")
75
79
  json_rate_limit_response(limit_config)
76
80
  else
77
81
  html_rate_limit_response(limit_config)
@@ -114,7 +114,8 @@ module RailsErrorDashboard
114
114
 
115
115
  def dashboard_url(error_log)
116
116
  base_url = RailsErrorDashboard.configuration.dashboard_base_url || "http://localhost:3000"
117
- "#{base_url}/error_dashboard/errors/#{error_log.id}"
117
+ mount_path = RailsErrorDashboard.configuration.engine_mount_path
118
+ "#{base_url}#{mount_path}/errors/#{error_log.id}"
118
119
  end
119
120
  end
120
121
  end
@@ -0,0 +1,93 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsErrorDashboard
4
+ module Queries
5
+ # Query: Rank errors by unique user impact — how many distinct users each error type affects.
6
+ # Surfaces "this error affected 847 unique users" prominently.
7
+ # An error hitting 1 user 1000 times is different from an error hitting 1000 users once.
8
+ class UserImpactSummary
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
+ all_entries = build_entries
21
+ {
22
+ entries: all_entries,
23
+ summary: build_summary(all_entries)
24
+ }
25
+ rescue => e
26
+ Rails.logger.error("[RailsErrorDashboard] UserImpactSummary failed: #{e.class}: #{e.message}")
27
+ empty_result
28
+ end
29
+
30
+ private
31
+
32
+ def base_scope
33
+ scope = ErrorLog.where("occurred_at >= ?", @start_date)
34
+ .where.not(user_id: nil)
35
+ scope = scope.where(application_id: @application_id) if @application_id.present?
36
+ scope
37
+ end
38
+
39
+ def build_entries
40
+ # Group by error_type, count distinct users and total occurrences
41
+ user_counts = base_scope
42
+ .group(:error_type)
43
+ .distinct
44
+ .count(:user_id)
45
+
46
+ occurrence_counts = base_scope
47
+ .group(:error_type)
48
+ .count
49
+
50
+ total_users = effective_total_users
51
+
52
+ user_counts.map do |error_type, unique_users|
53
+ occurrences = occurrence_counts[error_type] || 0
54
+ sample = base_scope.where(error_type: error_type).order(occurred_at: :desc).first
55
+ impact_pct = total_users && total_users > 0 ? (unique_users.to_f / total_users * 100).round(1) : nil
56
+
57
+ {
58
+ error_type: error_type,
59
+ message: sample&.message.to_s.truncate(120),
60
+ unique_users: unique_users,
61
+ total_occurrences: occurrences,
62
+ impact_percentage: impact_pct,
63
+ severity: sample&.severity,
64
+ last_seen: sample&.occurred_at,
65
+ id: sample&.id
66
+ }
67
+ end.sort_by { |e| -e[:unique_users] }
68
+ rescue => e
69
+ Rails.logger.error("[RailsErrorDashboard] UserImpactSummary.build_entries failed: #{e.class}: #{e.message}")
70
+ []
71
+ end
72
+
73
+ def build_summary(entries)
74
+ {
75
+ total_error_types_with_users: entries.size,
76
+ total_unique_users_affected: entries.sum { |e| e[:unique_users] },
77
+ most_impactful: entries.first&.dig(:error_type),
78
+ total_users: effective_total_users
79
+ }
80
+ end
81
+
82
+ def effective_total_users
83
+ RailsErrorDashboard.configuration.effective_total_users
84
+ rescue => e
85
+ nil
86
+ end
87
+
88
+ def empty_result
89
+ { entries: [], summary: { total_error_types_with_users: 0, total_unique_users_affected: 0, most_impactful: nil, total_users: nil } }
90
+ end
91
+ end
92
+ end
93
+ end
@@ -0,0 +1,139 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "coverage"
4
+
5
+ module RailsErrorDashboard
6
+ module Services
7
+ # Diagnostic-mode code path coverage using Ruby's Coverage API.
8
+ #
9
+ # Operator enables coverage via dashboard button, reproduces the error,
10
+ # views source code with executed-line overlay, then disables.
11
+ # Zero overhead when off. Uses oneshot_lines mode (each line fires once).
12
+ #
13
+ # SAFETY:
14
+ # - Coverage is process-global (not thread-local) — data blends across threads
15
+ # - oneshot_lines mode has near-zero ongoing overhead
16
+ # - peek_result is read-only, does not mutate state
17
+ # - Every method rescue-wrapped (never raises)
18
+ # - Ruby 3.2+ required for Coverage.setup with oneshot_lines
19
+ class CoverageTracker
20
+ @active = false
21
+ @we_started = false
22
+ @mutex = Mutex.new
23
+
24
+ class << self
25
+ # Check if the current Ruby version supports Coverage with oneshot_lines
26
+ def supported?
27
+ RUBY_VERSION >= "3.2"
28
+ end
29
+
30
+ # Start coverage collection in oneshot_lines mode
31
+ # @return [Boolean] true if successfully enabled
32
+ def enable!
33
+ return false unless supported?
34
+ return true if @active
35
+
36
+ @mutex.synchronize do
37
+ return true if @active
38
+
39
+ Coverage.setup(oneshot_lines: true)
40
+ Coverage.resume
41
+ @we_started = true
42
+ @active = true
43
+ end
44
+
45
+ true
46
+ rescue => e
47
+ # Coverage.setup raises RuntimeError if another session is active (e.g. SimpleCov).
48
+ # In that case, we piggyback on the existing session for peek_result.
49
+ if e.is_a?(RuntimeError) && coverage_running?
50
+ @mutex.synchronize do
51
+ @we_started = false
52
+ @active = true
53
+ end
54
+ true
55
+ else
56
+ Rails.logger.error("[RailsErrorDashboard] CoverageTracker.enable! failed: #{e.class}: #{e.message}")
57
+ @active = false
58
+ false
59
+ end
60
+ end
61
+
62
+ # Stop coverage collection and clear all data
63
+ def disable!
64
+ return unless @active
65
+
66
+ @mutex.synchronize do
67
+ return unless @active
68
+ # Only call Coverage.result if WE started the session
69
+ # Don't kill another tool's coverage (e.g. SimpleCov)
70
+ if @we_started
71
+ begin
72
+ Coverage.result
73
+ rescue RuntimeError
74
+ # Already stopped
75
+ end
76
+ end
77
+ @active = false
78
+ @we_started = false
79
+ end
80
+ rescue => e
81
+ Rails.logger.error("[RailsErrorDashboard] CoverageTracker.disable! failed: #{e.class}: #{e.message}")
82
+ @active = false
83
+ @we_started = false
84
+ end
85
+
86
+ # Whether coverage is currently being collected
87
+ def active?
88
+ @active
89
+ end
90
+
91
+ # Get executed line numbers for a specific file
92
+ # @param file_path [String] absolute path to the source file
93
+ # @return [Hash{Integer => Boolean}] line_number => executed?, or nil if inactive
94
+ def peek(file_path)
95
+ return nil if file_path.nil?
96
+ return nil unless @active
97
+
98
+ result = Coverage.peek_result
99
+ file_data = result[file_path]
100
+ return {} unless file_data
101
+
102
+ # Coverage result format varies by mode:
103
+ # - oneshot_lines: { oneshot_lines: [nil, 0, nil, 1, ...] }
104
+ # - lines (SimpleCov): { lines: [0, 1, nil, 2, ...] } or just [0, 1, nil, 2, ...]
105
+ # where index = line_number - 1, nil = not executable, 0 = not hit, N>0 = hit
106
+ lines_data = if file_data.is_a?(Hash)
107
+ file_data[:oneshot_lines] || file_data[:lines]
108
+ elsif file_data.is_a?(Array)
109
+ file_data
110
+ end
111
+ return {} unless lines_data.is_a?(Array)
112
+
113
+ executed = {}
114
+ lines_data.each_with_index do |val, idx|
115
+ next if val.nil? # not an executable line
116
+
117
+ line_number = idx + 1
118
+ executed[line_number] = val > 0
119
+ end
120
+
121
+ executed
122
+ rescue => e
123
+ Rails.logger.error("[RailsErrorDashboard] CoverageTracker.peek failed: #{e.class}: #{e.message}")
124
+ {}
125
+ end
126
+
127
+ private
128
+
129
+ # Check if Coverage is currently running (e.g. via SimpleCov)
130
+ def coverage_running?
131
+ Coverage.peek_result
132
+ true
133
+ rescue RuntimeError
134
+ false
135
+ end
136
+ end
137
+ end
138
+ end
139
+ end