rails_error_dashboard 0.5.9 → 0.5.11

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 +60 -7
  3. data/app/controllers/rails_error_dashboard/errors_controller.rb +41 -0
  4. data/app/controllers/rails_error_dashboard/webhooks_controller.rb +2 -1
  5. data/app/helpers/rails_error_dashboard/backtrace_helper.rb +12 -0
  6. data/app/jobs/rails_error_dashboard/scheduled_digest_job.rb +40 -0
  7. data/app/mailers/rails_error_dashboard/digest_mailer.rb +23 -0
  8. data/app/mailers/rails_error_dashboard/error_notification_mailer.rb +2 -1
  9. data/app/views/layouts/rails_error_dashboard.html.erb +10 -0
  10. data/app/views/rails_error_dashboard/digest_mailer/digest_summary.html.erb +172 -0
  11. data/app/views/rails_error_dashboard/digest_mailer/digest_summary.text.erb +49 -0
  12. data/app/views/rails_error_dashboard/errors/_sidebar_metadata.html.erb +14 -1
  13. data/app/views/rails_error_dashboard/errors/_source_code.html.erb +20 -7
  14. data/app/views/rails_error_dashboard/errors/releases.html.erb +284 -0
  15. data/app/views/rails_error_dashboard/errors/settings/_value_badge.html.erb +15 -0
  16. data/app/views/rails_error_dashboard/errors/settings.html.erb +37 -1
  17. data/app/views/rails_error_dashboard/errors/show.html.erb +21 -0
  18. data/app/views/rails_error_dashboard/errors/user_impact.html.erb +172 -0
  19. data/config/routes.rb +4 -0
  20. data/lib/generators/rails_error_dashboard/install/install_generator.rb +6 -0
  21. data/lib/generators/rails_error_dashboard/install/templates/README +9 -18
  22. data/lib/generators/rails_error_dashboard/install/templates/initializer.rb +32 -2
  23. data/lib/rails_error_dashboard/configuration.rb +55 -13
  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/release_timeline.rb +181 -0
  27. data/lib/rails_error_dashboard/queries/user_impact_summary.rb +93 -0
  28. data/lib/rails_error_dashboard/services/coverage_tracker.rb +139 -0
  29. data/lib/rails_error_dashboard/services/digest_builder.rb +158 -0
  30. data/lib/rails_error_dashboard/services/notification_helpers.rb +2 -1
  31. data/lib/rails_error_dashboard/subscribers/issue_tracker_subscriber.rb +6 -7
  32. data/lib/rails_error_dashboard/version.rb +1 -1
  33. data/lib/rails_error_dashboard.rb +4 -0
  34. data/lib/tasks/error_dashboard.rake +23 -0
  35. metadata +35 -9
@@ -444,10 +444,40 @@ RailsErrorDashboard.configure do |config|
444
444
  config.git_sha = ENV["GIT_SHA"]
445
445
  # config.total_users_for_impact = 10000 # For user impact % calculation
446
446
 
447
- # Git repository URL for clickable commit links
447
+ # Git repository URL for clickable commit links and issue tracking
448
448
  # Examples:
449
449
  # GitHub: "https://github.com/username/repo"
450
450
  # GitLab: "https://gitlab.com/username/repo"
451
- # Bitbucket: "https://bitbucket.org/username/repo"
451
+ # Codeberg: "https://codeberg.org/username/repo"
452
452
  # config.git_repository_url = ENV["GIT_REPOSITORY_URL"]
453
+
454
+ # ============================================================================
455
+ # ISSUE TRACKING (GitHub / GitLab / Codeberg)
456
+ # ============================================================================
457
+ #
458
+ # One switch enables everything: issue creation, auto-create on first
459
+ # occurrence, lifecycle sync (resolve → close, reopen → reopen), platform
460
+ # state mirroring (status, assignees, labels), and comment display.
461
+ #
462
+ # IMPORTANT: When enabled, the dashboard shows platform state instead of
463
+ # internal workflow controls:
464
+ # - "Mark as Resolved" → replaced by issue open/closed from platform
465
+ # - Workflow Status → issue state from platform
466
+ # - Assigned To → assignees from platform (with avatars)
467
+ # - Priority → labels from platform (with colors)
468
+ # - Snooze and Mute remain (no platform equivalent)
469
+ #
470
+ # Setup:
471
+ # 1. Create a RED bot account on GitHub/GitLab/Codeberg
472
+ # 2. Generate a token and set RED_BOT_TOKEN env var
473
+ # 3. Set git_repository_url above (already used for source code linking)
474
+ # 4. Enable:
475
+ #
476
+ # config.enable_issue_tracking = true
477
+ # config.issue_tracker_token = ENV["RED_BOT_TOKEN"]
478
+ #
479
+ # Optional overrides:
480
+ # config.issue_tracker_labels = ["bug"] # Labels added to new issues
481
+ # config.issue_tracker_auto_create_severities = [:critical, :high] # Auto-create threshold
482
+ # config.issue_webhook_secret = ENV["ISSUE_WEBHOOK_SECRET"] # Enables two-way webhook sync
453
483
  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
 
@@ -82,17 +87,17 @@ module RailsErrorDashboard
82
87
  attr_accessor :git_repository_url
83
88
 
84
89
  # Issue tracker integration (GitHub, GitLab, Codeberg/Gitea/Forgejo)
85
- attr_accessor :enable_issue_tracking # Master switch (default: false)
86
- attr_accessor :issue_tracker_provider # :github, :gitlab, :codeberg (auto-detected from git_repository_url)
90
+ # One switch enables everything: issue creation, auto-create, lifecycle sync,
91
+ # platform state mirroring, and comment display. Webhooks activate when
92
+ # issue_webhook_secret is set.
93
+ attr_accessor :enable_issue_tracking # Master switch (default: false) — enables all platform integration
87
94
  attr_accessor :issue_tracker_token # String or lambda/proc for Rails credentials
95
+ attr_accessor :issue_tracker_provider # :github, :gitlab, :codeberg (auto-detected from git_repository_url)
88
96
  attr_accessor :issue_tracker_repo # "owner/repo" (auto-extracted from git_repository_url)
89
97
  attr_accessor :issue_tracker_labels # Array of label strings (default: ["bug"])
90
98
  attr_accessor :issue_tracker_api_url # Custom API base URL for self-hosted instances
91
- attr_accessor :auto_create_issues # Boolean (default: false) — auto-create issues for new errors
92
- attr_accessor :auto_create_issues_on_first_occurrence # Boolean (default: true) create on first occurrence
93
- attr_accessor :auto_create_issues_for_severities # Array of symbols (default: [:critical, :high])
94
- attr_accessor :enable_issue_webhooks # Boolean (default: false) — receive webhooks for two-way sync
95
- attr_accessor :issue_webhook_secret # String — HMAC secret for webhook signature verification
99
+ attr_accessor :issue_tracker_auto_create_severities # Auto-create for these severities (default: [:critical, :high])
100
+ attr_accessor :issue_webhook_secret # HMAC secretwebhooks activate when this is set
96
101
 
97
102
  # Advanced error analysis features
98
103
  attr_accessor :enable_similar_errors # Fuzzy error matching
@@ -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)
@@ -244,17 +257,14 @@ module RailsErrorDashboard
244
257
  @total_users_for_impact = nil # Auto-detect if not set
245
258
  @git_repository_url = ENV["GIT_REPOSITORY_URL"]
246
259
 
247
- # Issue tracker integration defaults — OFF by default
260
+ # Issue tracker integration defaults — OFF by default, one switch enables all
248
261
  @enable_issue_tracking = false
262
+ @issue_tracker_token = ENV["RED_BOT_TOKEN"] || ENV["ISSUE_TRACKER_TOKEN"]
249
263
  @issue_tracker_provider = nil # Auto-detect from git_repository_url
250
- @issue_tracker_token = ENV["ISSUE_TRACKER_TOKEN"]
251
264
  @issue_tracker_repo = nil # Auto-extract from git_repository_url
252
265
  @issue_tracker_labels = [ "bug" ]
253
266
  @issue_tracker_api_url = nil # For self-hosted instances
254
- @auto_create_issues = false
255
- @auto_create_issues_on_first_occurrence = true
256
- @auto_create_issues_for_severities = [ :critical, :high ]
257
- @enable_issue_webhooks = false
267
+ @issue_tracker_auto_create_severities = [ :critical, :high ]
258
268
  @issue_webhook_secret = ENV["ISSUE_WEBHOOK_SECRET"]
259
269
 
260
270
  # Advanced error analysis features (all OFF by default - opt-in)
@@ -328,6 +338,9 @@ module RailsErrorDashboard
328
338
  # Diagnostic dump defaults - OFF by default (opt-in)
329
339
  @enable_diagnostic_dump = false # On-demand system state snapshot
330
340
 
341
+ # Code path coverage defaults - OFF by default (opt-in, Ruby 3.2+)
342
+ @enable_coverage_tracking = false
343
+
331
344
  # Rack Attack event tracking defaults - OFF by default (opt-in, requires breadcrumbs)
332
345
  @enable_rack_attack_tracking = false
333
346
 
@@ -655,6 +668,14 @@ module RailsErrorDashboard
655
668
  end
656
669
  end
657
670
 
671
+ # Detect the engine's mount path from the host app routes.
672
+ # Falls back to "/red" if detection fails.
673
+ #
674
+ # @return [String] The mount path (e.g. "/red", "/admin/red", "/error_dashboard")
675
+ def engine_mount_path
676
+ @engine_mount_path ||= detect_engine_mount_path
677
+ end
678
+
658
679
  # Get the effective user model (auto-detected if not configured)
659
680
  #
660
681
  # @return [String, nil] User model class name
@@ -692,5 +713,26 @@ module RailsErrorDashboard
692
713
  def clear_total_users_cache!
693
714
  @total_users_cache = {}
694
715
  end
716
+
717
+ # Detect where the engine is mounted in the host app's routes.
718
+ # @return [String] mount path (default: "/red")
719
+ def detect_engine_mount_path
720
+ return "/red" unless defined?(Rails) && Rails.application
721
+
722
+ Rails.application.routes.routes.each do |route|
723
+ app = route.app
724
+ app = app.app if app.respond_to?(:app)
725
+ if app == RailsErrorDashboard::Engine || (app.is_a?(Class) && app <= RailsErrorDashboard::Engine)
726
+ path = route.path.spec.to_s.sub("(.:format)", "").chomp("/")
727
+ return path if path.present?
728
+ end
729
+ rescue => e
730
+ next
731
+ end
732
+
733
+ "/red"
734
+ rescue => e
735
+ "/red"
736
+ end
695
737
  end
696
738
  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,181 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsErrorDashboard
4
+ module Queries
5
+ # Query: Build a release timeline from error data, showing per-version health stats,
6
+ # "new in this release" error detection, stability indicators, and release-over-release deltas.
7
+ # Uses existing app_version and git_sha columns on error_logs — no new migration needed.
8
+ class ReleaseTimeline
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
+ return empty_result unless has_version_column?
21
+
22
+ releases = build_releases
23
+ {
24
+ releases: releases,
25
+ summary: build_summary(releases)
26
+ }
27
+ rescue => e
28
+ Rails.logger.error("[RailsErrorDashboard] ReleaseTimeline failed: #{e.class}: #{e.message}")
29
+ empty_result
30
+ end
31
+
32
+ private
33
+
34
+ def base_scope
35
+ scope = ErrorLog.where("occurred_at >= ?", @start_date)
36
+ .where.not(app_version: [ nil, "" ])
37
+ scope = scope.where(application_id: @application_id) if @application_id.present?
38
+ scope
39
+ end
40
+
41
+ def build_releases
42
+ version_stats = aggregate_version_stats
43
+ return [] if version_stats.empty?
44
+
45
+ new_errors = new_errors_per_version
46
+ avg_errors = version_stats.sum { |_v, s| s[:total_errors] }.to_f / version_stats.size
47
+
48
+ # Sort chronologically by first_seen (oldest first) for delta calculation
49
+ sorted_chrono = version_stats.sort_by { |_v, s| s[:first_seen] }
50
+
51
+ releases = []
52
+ sorted_chrono.each_with_index do |(version, stats), idx|
53
+ previous_count = idx > 0 ? sorted_chrono[idx - 1][1][:total_errors] : nil
54
+
55
+ releases << {
56
+ version: version,
57
+ git_shas: stats[:git_shas],
58
+ first_seen: stats[:first_seen],
59
+ last_seen: stats[:last_seen],
60
+ current: false, # set below
61
+ total_errors: stats[:total_errors],
62
+ unique_error_types: stats[:unique_error_types],
63
+ new_error_count: new_errors[version] || 0,
64
+ stability: stability_indicator(stats[:total_errors], avg_errors),
65
+ problematic: stats[:total_errors] > (avg_errors * 2),
66
+ delta_from_previous: previous_count ? (stats[:total_errors] - previous_count) : nil,
67
+ delta_percentage: previous_count && previous_count > 0 ? ((stats[:total_errors] - previous_count).to_f / previous_count * 100).round(1) : nil
68
+ }
69
+ end
70
+
71
+ # Mark the most recent release as current
72
+ releases.last[:current] = true if releases.any?
73
+
74
+ # Return in reverse chronological order (newest first)
75
+ releases.reverse
76
+ end
77
+
78
+ # Single GROUP BY query for per-version aggregates
79
+ def aggregate_version_stats
80
+ rows = base_scope
81
+ .group(:app_version)
82
+ .select(
83
+ :app_version,
84
+ "COUNT(*) AS total_count",
85
+ "COUNT(DISTINCT error_type) AS unique_types",
86
+ "MIN(occurred_at) AS first_seen_at",
87
+ "MAX(occurred_at) AS last_seen_at"
88
+ )
89
+
90
+ # Collect git_shas per version in a second lightweight query
91
+ sha_map = {}
92
+ if has_git_sha_column?
93
+ base_scope.where.not(git_sha: [ nil, "" ])
94
+ .group(:app_version, :git_sha)
95
+ .pluck(:app_version, :git_sha)
96
+ .each do |version, sha|
97
+ (sha_map[version] ||= []) << sha
98
+ end
99
+ sha_map.each_value(&:uniq!)
100
+ end
101
+
102
+ rows.each_with_object({}) do |row, result|
103
+ version = row.app_version
104
+ first_seen = row.first_seen_at
105
+ last_seen = row.last_seen_at
106
+ first_seen = Time.zone.parse(first_seen) if first_seen.is_a?(String)
107
+ last_seen = Time.zone.parse(last_seen) if last_seen.is_a?(String)
108
+
109
+ result[version] = {
110
+ total_errors: row.total_count.to_i,
111
+ unique_error_types: row.unique_types.to_i,
112
+ first_seen: first_seen,
113
+ last_seen: last_seen,
114
+ git_shas: sha_map[version] || []
115
+ }
116
+ end
117
+ rescue => e
118
+ Rails.logger.error("[RailsErrorDashboard] ReleaseTimeline aggregate failed: #{e.class}: #{e.message}")
119
+ {}
120
+ end
121
+
122
+ # For each error_hash in the window, find which app_version it first appeared in.
123
+ # Count per version to get "new errors introduced in this release".
124
+ def new_errors_per_version
125
+ return {} unless has_error_hash_column?
126
+
127
+ # Get the earliest occurrence per error_hash, with its app_version
128
+ # We need (error_hash, app_version) at MIN(occurred_at)
129
+ earliest = {}
130
+ base_scope.select(:error_hash, :app_version, :occurred_at)
131
+ .where.not(error_hash: [ nil, "" ])
132
+ .order(:occurred_at)
133
+ .each do |row|
134
+ earliest[row.error_hash] ||= row.app_version
135
+ end
136
+
137
+ # Count how many error_hashes have each version as their earliest
138
+ earliest.values.tally
139
+ rescue => e
140
+ Rails.logger.error("[RailsErrorDashboard] ReleaseTimeline new_errors failed: #{e.class}: #{e.message}")
141
+ {}
142
+ end
143
+
144
+ def stability_indicator(count, avg)
145
+ return :green if avg <= 0
146
+ ratio = count.to_f / avg
147
+ if ratio <= 1.0
148
+ :green
149
+ elsif ratio <= 2.0
150
+ :yellow
151
+ else
152
+ :red
153
+ end
154
+ end
155
+
156
+ def build_summary(releases)
157
+ {
158
+ total_releases: releases.size,
159
+ current_version: releases.first&.dig(:version),
160
+ avg_errors_per_release: releases.any? ? (releases.sum { |r| r[:total_errors] }.to_f / releases.size).round(1) : 0
161
+ }
162
+ end
163
+
164
+ def empty_result
165
+ { releases: [], summary: { total_releases: 0, current_version: nil, avg_errors_per_release: 0 } }
166
+ end
167
+
168
+ def has_version_column?
169
+ ErrorLog.column_names.include?("app_version")
170
+ end
171
+
172
+ def has_git_sha_column?
173
+ ErrorLog.column_names.include?("git_sha")
174
+ end
175
+
176
+ def has_error_hash_column?
177
+ ErrorLog.column_names.include?("error_hash")
178
+ end
179
+ end
180
+ end
181
+ 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