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.
- checksums.yaml +4 -4
- data/README.md +34 -1
- data/app/controllers/rails_error_dashboard/errors_controller.rb +31 -0
- data/app/helpers/rails_error_dashboard/backtrace_helper.rb +12 -0
- data/app/jobs/rails_error_dashboard/scheduled_digest_job.rb +40 -0
- data/app/mailers/rails_error_dashboard/digest_mailer.rb +23 -0
- data/app/mailers/rails_error_dashboard/error_notification_mailer.rb +2 -1
- data/app/views/layouts/rails_error_dashboard.html.erb +5 -0
- data/app/views/rails_error_dashboard/digest_mailer/digest_summary.html.erb +172 -0
- data/app/views/rails_error_dashboard/digest_mailer/digest_summary.text.erb +49 -0
- data/app/views/rails_error_dashboard/errors/_error_row.html.erb +10 -10
- data/app/views/rails_error_dashboard/errors/_source_code.html.erb +20 -7
- data/app/views/rails_error_dashboard/errors/settings.html.erb +7 -3
- data/app/views/rails_error_dashboard/errors/show.html.erb +21 -0
- data/app/views/rails_error_dashboard/errors/user_impact.html.erb +172 -0
- data/config/routes.rb +3 -0
- data/lib/generators/rails_error_dashboard/install/install_generator.rb +173 -160
- data/lib/generators/rails_error_dashboard/install/templates/README +9 -18
- data/lib/generators/rails_error_dashboard/install/templates/initializer.rb +15 -10
- data/lib/rails_error_dashboard/configuration.rb +83 -26
- data/lib/rails_error_dashboard/engine.rb +10 -0
- data/lib/rails_error_dashboard/error_reporter.rb +26 -0
- data/lib/rails_error_dashboard/middleware/error_catcher.rb +7 -0
- data/lib/rails_error_dashboard/middleware/rate_limiter.rb +16 -12
- data/lib/rails_error_dashboard/plugins/jira_integration_plugin.rb +2 -1
- data/lib/rails_error_dashboard/queries/user_impact_summary.rb +93 -0
- data/lib/rails_error_dashboard/services/coverage_tracker.rb +139 -0
- data/lib/rails_error_dashboard/services/digest_builder.rb +158 -0
- data/lib/rails_error_dashboard/services/notification_helpers.rb +2 -1
- data/lib/rails_error_dashboard/subscribers/issue_tracker_subscriber.rb +2 -1
- data/lib/rails_error_dashboard/value_objects/error_context.rb +5 -0
- data/lib/rails_error_dashboard/version.rb +1 -1
- data/lib/rails_error_dashboard.rb +3 -0
- data/lib/tasks/error_dashboard.rake +23 -0
- 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
|
|
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 = :
|
|
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 (
|
|
149
|
-
# To enable: Set config.async_logging = true
|
|
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 = :
|
|
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
|
-
#
|
|
160
|
-
# Critical errors are ALWAYS logged regardless of
|
|
161
|
-
|
|
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
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
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
|
-
|
|
506
|
-
|
|
507
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
519
|
-
|
|
520
|
-
|
|
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
|
-
|
|
523
|
-
|
|
524
|
-
|
|
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
|
-
|
|
527
|
-
|
|
528
|
-
|
|
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
|
-
|
|
531
|
-
|
|
532
|
-
|
|
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
|
-
|
|
535
|
-
|
|
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
|
-
|
|
10
|
-
|
|
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?(
|
|
56
|
+
path.start_with?(engine_mount_path)
|
|
56
57
|
end
|
|
57
58
|
|
|
58
59
|
def find_limit_config(path)
|
|
59
|
-
|
|
60
|
-
|
|
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?("/
|
|
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
|
-
|
|
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
|