rails_error_dashboard 0.1.28 → 0.1.30

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 (61) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +50 -6
  3. data/app/controllers/rails_error_dashboard/errors_controller.rb +22 -0
  4. data/app/helpers/rails_error_dashboard/application_helper.rb +79 -7
  5. data/app/helpers/rails_error_dashboard/backtrace_helper.rb +149 -0
  6. data/app/models/rails_error_dashboard/application.rb +1 -1
  7. data/app/models/rails_error_dashboard/error_log.rb +44 -16
  8. data/app/views/layouts/rails_error_dashboard.html.erb +71 -1237
  9. data/app/views/rails_error_dashboard/errors/_error_row.html.erb +10 -2
  10. data/app/views/rails_error_dashboard/errors/_source_code.html.erb +76 -0
  11. data/app/views/rails_error_dashboard/errors/_timeline.html.erb +18 -82
  12. data/app/views/rails_error_dashboard/errors/_user_errors_table.html.erb +70 -0
  13. data/app/views/rails_error_dashboard/errors/analytics.html.erb +9 -37
  14. data/app/views/rails_error_dashboard/errors/correlation.html.erb +11 -37
  15. data/app/views/rails_error_dashboard/errors/index.html.erb +64 -31
  16. data/app/views/rails_error_dashboard/errors/overview.html.erb +181 -3
  17. data/app/views/rails_error_dashboard/errors/platform_comparison.html.erb +2 -1
  18. data/app/views/rails_error_dashboard/errors/settings/_value_badge.html.erb +286 -0
  19. data/app/views/rails_error_dashboard/errors/settings.html.erb +146 -480
  20. data/app/views/rails_error_dashboard/errors/show.html.erb +102 -76
  21. data/db/migrate/20251223000000_create_rails_error_dashboard_complete_schema.rb +188 -0
  22. data/db/migrate/20251224000001_create_rails_error_dashboard_error_logs.rb +5 -0
  23. data/db/migrate/20251224081522_add_better_tracking_to_error_logs.rb +3 -0
  24. data/db/migrate/20251224101217_add_controller_action_to_error_logs.rb +3 -0
  25. data/db/migrate/20251225071314_add_optimized_indexes_to_error_logs.rb +4 -0
  26. data/db/migrate/20251225074653_remove_environment_from_error_logs.rb +3 -0
  27. data/db/migrate/20251225085859_add_enhanced_metrics_to_error_logs.rb +3 -0
  28. data/db/migrate/20251225093603_add_similarity_tracking_to_error_logs.rb +3 -0
  29. data/db/migrate/20251225100236_create_error_occurrences.rb +3 -0
  30. data/db/migrate/20251225101920_create_cascade_patterns.rb +3 -0
  31. data/db/migrate/20251225102500_create_error_baselines.rb +3 -0
  32. data/db/migrate/20251226020000_add_workflow_fields_to_error_logs.rb +3 -0
  33. data/db/migrate/20251226020100_create_error_comments.rb +3 -0
  34. data/db/migrate/20251229111223_add_additional_performance_indexes.rb +4 -0
  35. data/db/migrate/20260106094220_create_rails_error_dashboard_applications.rb +3 -0
  36. data/db/migrate/20260106094233_add_application_to_error_logs.rb +3 -0
  37. data/db/migrate/20260106094318_finalize_application_foreign_key.rb +5 -0
  38. data/lib/generators/rails_error_dashboard/install/install_generator.rb +32 -0
  39. data/lib/generators/rails_error_dashboard/install/templates/initializer.rb +37 -4
  40. data/lib/rails_error_dashboard/configuration.rb +160 -3
  41. data/lib/rails_error_dashboard/configuration_error.rb +24 -0
  42. data/lib/rails_error_dashboard/engine.rb +17 -0
  43. data/lib/rails_error_dashboard/helpers/user_model_detector.rb +138 -0
  44. data/lib/rails_error_dashboard/queries/analytics_stats.rb +1 -2
  45. data/lib/rails_error_dashboard/queries/dashboard_stats.rb +19 -4
  46. data/lib/rails_error_dashboard/queries/errors_list.rb +27 -8
  47. data/lib/rails_error_dashboard/services/error_normalizer.rb +143 -0
  48. data/lib/rails_error_dashboard/services/git_blame_reader.rb +195 -0
  49. data/lib/rails_error_dashboard/services/github_link_generator.rb +159 -0
  50. data/lib/rails_error_dashboard/services/source_code_reader.rb +214 -0
  51. data/lib/rails_error_dashboard/version.rb +1 -1
  52. data/lib/rails_error_dashboard.rb +6 -0
  53. metadata +14 -10
  54. data/app/assets/stylesheets/rails_error_dashboard/_catppuccin_mocha.scss +0 -107
  55. data/app/assets/stylesheets/rails_error_dashboard/_components.scss +0 -625
  56. data/app/assets/stylesheets/rails_error_dashboard/_layout.scss +0 -257
  57. data/app/assets/stylesheets/rails_error_dashboard/_theme_variables.scss +0 -203
  58. data/app/assets/stylesheets/rails_error_dashboard/application.css +0 -15
  59. data/app/assets/stylesheets/rails_error_dashboard/application.css.map +0 -7
  60. data/app/assets/stylesheets/rails_error_dashboard/application.scss +0 -61
  61. data/app/views/layouts/rails_error_dashboard/application.html.erb +0 -55
@@ -27,6 +27,9 @@ module RailsErrorDashboard
27
27
  class_option :error_correlation, type: :boolean, default: false, desc: "Enable error correlation analysis"
28
28
  class_option :platform_comparison, type: :boolean, default: false, desc: "Enable platform comparison analytics"
29
29
  class_option :occurrence_patterns, type: :boolean, default: false, desc: "Enable occurrence pattern detection"
30
+ # Developer tools options
31
+ class_option :source_code_integration, type: :boolean, default: false, desc: "Enable source code viewer (NEW!)"
32
+ class_option :git_blame, type: :boolean, default: false, desc: "Enable git blame integration (NEW!)"
30
33
 
31
34
  def welcome_message
32
35
  say "\n"
@@ -148,6 +151,20 @@ module RailsErrorDashboard
148
151
  name: "Occurrence Pattern Detection",
149
152
  description: "Detect cyclical patterns and bursts",
150
153
  category: "Advanced Analytics"
154
+ },
155
+
156
+ # === DEVELOPER TOOLS ===
157
+ {
158
+ key: :source_code_integration,
159
+ name: "Source Code Integration (NEW!)",
160
+ description: "View source code directly in error details",
161
+ category: "Developer Tools"
162
+ },
163
+ {
164
+ key: :git_blame,
165
+ name: "Git Blame Integration (NEW!)",
166
+ description: "Show git blame info (author, commit, timestamp)",
167
+ category: "Developer Tools"
151
168
  }
152
169
  ]
153
170
 
@@ -197,6 +214,10 @@ module RailsErrorDashboard
197
214
  @enable_platform_comparison = @selected_features&.dig(:platform_comparison) || options[:platform_comparison]
198
215
  @enable_occurrence_patterns = @selected_features&.dig(:occurrence_patterns) || options[:occurrence_patterns]
199
216
 
217
+ # Developer Tools
218
+ @enable_source_code_integration = @selected_features&.dig(:source_code_integration) || options[:source_code_integration]
219
+ @enable_git_blame = @selected_features&.dig(:git_blame) || options[:git_blame]
220
+
200
221
  template "initializer.rb", "config/initializers/rails_error_dashboard.rb"
201
222
  end
202
223
 
@@ -268,6 +289,17 @@ module RailsErrorDashboard
268
289
  enabled_count += analytics_features.size
269
290
  end
270
291
 
292
+ # Developer Tools
293
+ developer_tools_features = []
294
+ developer_tools_features << "Source Code Integration" if @enable_source_code_integration
295
+ developer_tools_features << "Git Blame" if @enable_git_blame
296
+
297
+ if developer_tools_features.any?
298
+ say "\nDeveloper Tools:", :cyan
299
+ say " ✓ #{developer_tools_features.join(", ")}", :green
300
+ enabled_count += developer_tools_features.size
301
+ end
302
+
271
303
  say "\n"
272
304
  say "Configuration Required:", :yellow if enabled_count > 0
273
305
  say " → Edit config/initializers/rails_error_dashboard.rb", :yellow if @enable_error_sampling
@@ -22,8 +22,9 @@ RailsErrorDashboard.configure do |config|
22
22
  # User model for error associations
23
23
  config.user_model = "User"
24
24
 
25
- # Error retention policy (days to keep errors before auto-deletion)
26
- config.retention_days = 90
25
+ # Error retention policy - nil means keep forever (no automatic deletion)
26
+ # To manually cleanup old errors: rails error_dashboard:cleanup_resolved DAYS=90
27
+ config.retention_days = nil
27
28
 
28
29
  # ============================================================================
29
30
  # NOTIFICATION SETTINGS
@@ -120,8 +121,8 @@ RailsErrorDashboard.configure do |config|
120
121
  # config.async_adapter = :sidekiq # Options: :sidekiq, :solid_queue, :async
121
122
 
122
123
  <% end -%>
123
- # Backtrace size limiting (reduces storage by ~80%)
124
- config.max_backtrace_lines = 50
124
+ # Backtrace size limiting (100 lines is industry standard: Rollbar, Airbrake, Bugsnag)
125
+ config.max_backtrace_lines = 100
125
126
 
126
127
  <% if @enable_error_sampling -%>
127
128
  # Error Sampling - ENABLED
@@ -262,6 +263,38 @@ RailsErrorDashboard.configure do |config|
262
263
  config.enable_occurrence_patterns = false
263
264
 
264
265
  <% end -%>
266
+ # ============================================================================
267
+ # DEVELOPER TOOLS (NEW!)
268
+ # ============================================================================
269
+
270
+ <% if @enable_source_code_integration -%>
271
+ # Source Code Integration - ENABLED (NEW!)
272
+ # View source code directly in error details with inline viewer
273
+ config.enable_source_code_integration = true
274
+ # To disable: Set config.enable_source_code_integration = false
275
+
276
+ <% else -%>
277
+ # Source Code Integration - DISABLED (NEW!)
278
+ # To enable: Set config.enable_source_code_integration = true
279
+ config.enable_source_code_integration = false
280
+
281
+ <% end -%>
282
+ <% if @enable_git_blame -%>
283
+ # Git Blame Integration - ENABLED (NEW!)
284
+ # Show git blame info (author, commit, timestamp) for each source line
285
+ config.enable_git_blame = true
286
+ # To disable: Set config.enable_git_blame = false
287
+
288
+ <% else -%>
289
+ # Git Blame Integration - DISABLED (NEW!)
290
+ # To enable: Set config.enable_git_blame = true (requires Git installed)
291
+ config.enable_git_blame = false
292
+
293
+ <% end -%>
294
+ # Repository settings (auto-detected from git remote, optional override)
295
+ # config.repository_url = ENV["REPOSITORY_URL"] # e.g., "https://github.com/user/repo"
296
+ # config.repository_branch = ENV.fetch("REPOSITORY_BRANCH", "main") # Default branch
297
+
265
298
  # ============================================================================
266
299
  # INTERNAL LOGGING (Silent by Default)
267
300
  # ============================================================================
@@ -88,6 +88,14 @@ module RailsErrorDashboard
88
88
  attr_accessor :baseline_alert_severities # Array of severities to alert on (default: [:critical, :high])
89
89
  attr_accessor :baseline_alert_cooldown_minutes # Minutes between alerts for same error type (default: 120)
90
90
 
91
+ # Source code integration (show code in backtrace)
92
+ attr_accessor :enable_source_code_integration # Master switch (default: false)
93
+ attr_accessor :source_code_context_lines # Lines before/after (default: 5)
94
+ attr_accessor :enable_git_blame # Show git blame (default: false)
95
+ attr_accessor :source_code_cache_ttl # Cache TTL in seconds (default: 3600)
96
+ attr_accessor :only_show_app_code_source # Hide gems/stdlib (default: true)
97
+ attr_accessor :git_branch_strategy # :commit_sha, :current_branch, :main (default: :commit_sha)
98
+
91
99
  # Notification callbacks (managed via helper methods, not set directly)
92
100
  attr_reader :notification_callbacks
93
101
 
@@ -100,7 +108,7 @@ module RailsErrorDashboard
100
108
  @dashboard_username = ENV.fetch("ERROR_DASHBOARD_USER", "gandalf")
101
109
  @dashboard_password = ENV.fetch("ERROR_DASHBOARD_PASSWORD", "youshallnotpass")
102
110
 
103
- @user_model = "User"
111
+ @user_model = nil # Auto-detect if not set
104
112
 
105
113
  # Multi-app support defaults
106
114
  @application_name = ENV["APPLICATION_NAME"] # Auto-detected if not set
@@ -128,7 +136,9 @@ module RailsErrorDashboard
128
136
 
129
137
  @use_separate_database = ENV.fetch("USE_SEPARATE_ERROR_DB", "false") == "true"
130
138
 
131
- @retention_days = 90
139
+ # Retention policy - nil means keep forever (no automatic deletion)
140
+ # Users can run 'rails error_dashboard:cleanup_resolved DAYS=90' to manually clean up
141
+ @retention_days = nil
132
142
 
133
143
  @enable_middleware = true
134
144
  @enable_error_subscriber = true
@@ -139,7 +149,7 @@ module RailsErrorDashboard
139
149
  @sampling_rate = 1.0 # 100% by default
140
150
  @async_logging = false
141
151
  @async_adapter = :sidekiq # Battle-tested default
142
- @max_backtrace_lines = 50
152
+ @max_backtrace_lines = 100 # Matches industry standard (Rollbar, Airbrake)
143
153
 
144
154
  # Rate limiting defaults
145
155
  @enable_rate_limiting = false # OFF by default (opt-in)
@@ -165,6 +175,14 @@ module RailsErrorDashboard
165
175
  @baseline_alert_severities = [ :critical, :high ] # Alert on critical and high severity anomalies
166
176
  @baseline_alert_cooldown_minutes = ENV.fetch("BASELINE_ALERT_COOLDOWN", "120").to_i
167
177
 
178
+ # Source code integration defaults - OFF by default (opt-in)
179
+ @enable_source_code_integration = false # Master switch
180
+ @source_code_context_lines = 5 # Show ±5 lines around target line
181
+ @enable_git_blame = false # Show git blame info
182
+ @source_code_cache_ttl = 3600 # 1 hour cache
183
+ @only_show_app_code_source = true # Hide gem/vendor code for security
184
+ @git_branch_strategy = :commit_sha # Use error's git_sha (most accurate)
185
+
168
186
  # Internal logging defaults - SILENT by default
169
187
  @enable_internal_logging = false # Opt-in for debugging
170
188
  @log_level = :silent # Silent by default, use :debug, :info, :warn, :error, or :silent
@@ -180,5 +198,144 @@ module RailsErrorDashboard
180
198
  def reset!
181
199
  initialize
182
200
  end
201
+
202
+ # Validate configuration values
203
+ # Raises ConfigurationError if any validation fails
204
+ #
205
+ # @raise [ConfigurationError] if configuration is invalid
206
+ # @return [true] if configuration is valid
207
+ def validate!
208
+ errors = []
209
+
210
+ # Validate sampling_rate (must be between 0.0 and 1.0)
211
+ if sampling_rate && (sampling_rate < 0.0 || sampling_rate > 1.0)
212
+ errors << "sampling_rate must be between 0.0 and 1.0 (got: #{sampling_rate})"
213
+ end
214
+
215
+ # Validate retention_days (must be positive)
216
+ if retention_days && retention_days < 1
217
+ errors << "retention_days must be at least 1 day (got: #{retention_days})"
218
+ end
219
+
220
+ # Validate max_backtrace_lines (must be positive)
221
+ if max_backtrace_lines && max_backtrace_lines < 1
222
+ errors << "max_backtrace_lines must be at least 1 (got: #{max_backtrace_lines})"
223
+ end
224
+
225
+ # Validate rate_limit_per_minute (must be positive if rate limiting enabled)
226
+ if enable_rate_limiting && rate_limit_per_minute && rate_limit_per_minute < 1
227
+ errors << "rate_limit_per_minute must be at least 1 (got: #{rate_limit_per_minute})"
228
+ end
229
+
230
+ # Validate baseline alert threshold (must be positive)
231
+ if enable_baseline_alerts && baseline_alert_threshold_std_devs && baseline_alert_threshold_std_devs <= 0
232
+ errors << "baseline_alert_threshold_std_devs must be positive (got: #{baseline_alert_threshold_std_devs})"
233
+ end
234
+
235
+ # Validate baseline alert cooldown (must be positive)
236
+ if enable_baseline_alerts && baseline_alert_cooldown_minutes && baseline_alert_cooldown_minutes < 1
237
+ errors << "baseline_alert_cooldown_minutes must be at least 1 (got: #{baseline_alert_cooldown_minutes})"
238
+ end
239
+
240
+ # Validate baseline alert severities (must be valid symbols)
241
+ if enable_baseline_alerts && baseline_alert_severities
242
+ valid_severities = %i[critical high medium low]
243
+ invalid_severities = baseline_alert_severities - valid_severities
244
+ if invalid_severities.any?
245
+ errors << "baseline_alert_severities contains invalid values: #{invalid_severities.inspect}. " \
246
+ "Valid options: #{valid_severities.inspect}"
247
+ end
248
+ end
249
+
250
+ # Validate async_adapter (must be valid adapter)
251
+ if async_logging && async_adapter
252
+ valid_adapters = %i[sidekiq solid_queue async]
253
+ unless valid_adapters.include?(async_adapter)
254
+ errors << "async_adapter must be one of #{valid_adapters.inspect} (got: #{async_adapter.inspect})"
255
+ end
256
+ end
257
+
258
+ # Validate notification dependencies
259
+ if enable_slack_notifications && (slack_webhook_url.nil? || slack_webhook_url.strip.empty?)
260
+ errors << "slack_webhook_url is required when enable_slack_notifications is true"
261
+ end
262
+
263
+ if enable_email_notifications && notification_email_recipients.empty?
264
+ errors << "notification_email_recipients is required when enable_email_notifications is true"
265
+ end
266
+
267
+ if enable_discord_notifications && (discord_webhook_url.nil? || discord_webhook_url.strip.empty?)
268
+ errors << "discord_webhook_url is required when enable_discord_notifications is true"
269
+ end
270
+
271
+ if enable_pagerduty_notifications && (pagerduty_integration_key.nil? || pagerduty_integration_key.strip.empty?)
272
+ errors << "pagerduty_integration_key is required when enable_pagerduty_notifications is true"
273
+ end
274
+
275
+ if enable_webhook_notifications && webhook_urls.empty?
276
+ errors << "webhook_urls is required when enable_webhook_notifications is true"
277
+ end
278
+
279
+ # Validate separate database configuration
280
+ if use_separate_database && (database.nil? || database.to_s.strip.empty?)
281
+ errors << "database configuration is required when use_separate_database is true"
282
+ end
283
+
284
+ # Validate log level (must be valid symbol)
285
+ if log_level
286
+ valid_log_levels = %i[debug info warn error fatal silent]
287
+ unless valid_log_levels.include?(log_level)
288
+ errors << "log_level must be one of #{valid_log_levels.inspect} (got: #{log_level.inspect})"
289
+ end
290
+ end
291
+
292
+ # Validate total_users_for_impact (must be positive if set)
293
+ if total_users_for_impact && total_users_for_impact < 1
294
+ errors << "total_users_for_impact must be at least 1 (got: #{total_users_for_impact})"
295
+ end
296
+
297
+ # Raise exception if any errors found
298
+ raise ConfigurationError, errors if errors.any?
299
+
300
+ true
301
+ end
302
+
303
+ # Get the effective user model (auto-detected if not configured)
304
+ #
305
+ # @return [String, nil] User model class name
306
+ def effective_user_model
307
+ return @user_model if @user_model.present?
308
+
309
+ RailsErrorDashboard::Helpers::UserModelDetector.detect_user_model
310
+ end
311
+
312
+ # Get the effective total users count (auto-detected if not configured)
313
+ # Caches the result for 5 minutes to avoid repeated queries
314
+ #
315
+ # @return [Integer, nil] Total users count
316
+ def effective_total_users
317
+ return @total_users_for_impact if @total_users_for_impact.present?
318
+
319
+ # Cache auto-detected value for 5 minutes
320
+ @total_users_cache ||= {}
321
+ cache_key = :auto_detected_count
322
+ cached_at = @total_users_cache[:cached_at]
323
+
324
+ if cached_at && (Time.current - cached_at) < 300 # 5 minutes
325
+ return @total_users_cache[cache_key]
326
+ end
327
+
328
+ count = RailsErrorDashboard::Helpers::UserModelDetector.detect_total_users
329
+
330
+ @total_users_cache[cache_key] = count
331
+ @total_users_cache[:cached_at] = Time.current
332
+
333
+ count
334
+ end
335
+
336
+ # Clear the total users cache
337
+ def clear_total_users_cache!
338
+ @total_users_cache = {}
339
+ end
183
340
  end
184
341
  end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsErrorDashboard
4
+ # Custom exception for configuration validation errors
5
+ # Provides clear, actionable error messages when configuration is invalid
6
+ class ConfigurationError < StandardError
7
+ attr_reader :errors
8
+
9
+ def initialize(errors)
10
+ @errors = errors.is_a?(Array) ? errors : [ errors ]
11
+ super(build_message)
12
+ end
13
+
14
+ private
15
+
16
+ def build_message
17
+ header = "Rails Error Dashboard configuration is invalid:\n\n"
18
+ body = @errors.map.with_index(1) { |error, index| " #{index}. #{error}" }.join("\n")
19
+ footer = "\n\nPlease fix these issues in config/initializers/rails_error_dashboard.rb"
20
+
21
+ header + body + footer
22
+ end
23
+ end
24
+ end
@@ -2,6 +2,11 @@ module RailsErrorDashboard
2
2
  class Engine < ::Rails::Engine
3
3
  isolate_namespace RailsErrorDashboard
4
4
 
5
+ # Serve static files from engine's public directory
6
+ initializer "rails_error_dashboard.assets" do |app|
7
+ app.middleware.use ::ActionDispatch::Static, "#{root}/public"
8
+ end
9
+
5
10
  # Configure database connection for error models
6
11
  # This runs early, before middleware setup, but after database.yml is loaded
7
12
  initializer "rails_error_dashboard.database", before: :load_config_initializers do
@@ -38,6 +43,18 @@ module RailsErrorDashboard
38
43
  end
39
44
  end
40
45
 
46
+ # Validate configuration after initialization
47
+ initializer "rails_error_dashboard.validate_config", after: :load_config_initializers do
48
+ config.after_initialize do
49
+ begin
50
+ RailsErrorDashboard.configuration.validate!
51
+ rescue ConfigurationError => e
52
+ Rails.logger.error "[Rails Error Dashboard] #{e.message}"
53
+ raise
54
+ end
55
+ end
56
+ end
57
+
41
58
  # Subscribe to Rails error reporter
42
59
  config.after_initialize do
43
60
  if RailsErrorDashboard.configuration.enable_error_subscriber
@@ -0,0 +1,138 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsErrorDashboard
4
+ module Helpers
5
+ # Automatically detects the User model and total users count
6
+ # Handles both single database and separate database setups
7
+ class UserModelDetector
8
+ class << self
9
+ # Auto-detect the user model name
10
+ # Returns the configured model if set, otherwise tries to detect User model
11
+ #
12
+ # @return [String, nil] The user model class name or nil if not found
13
+ def detect_user_model
14
+ # Return configured model if explicitly set
15
+ configured_model = RailsErrorDashboard.configuration.user_model
16
+ return configured_model if configured_model.present? && configured_model != "User"
17
+
18
+ # Try to detect User model
19
+ return "User" if user_model_exists?
20
+
21
+ # Check for common alternatives
22
+ %w[Account Member Person].each do |model_name|
23
+ return model_name if model_exists?(model_name)
24
+ end
25
+
26
+ nil
27
+ end
28
+
29
+ # Auto-detect total users count
30
+ # Returns the configured value if set, otherwise queries the user model
31
+ #
32
+ # @return [Integer, nil] Total users count or nil if unavailable
33
+ def detect_total_users
34
+ # Return configured value if explicitly set
35
+ configured_count = RailsErrorDashboard.configuration.total_users_for_impact
36
+ return configured_count if configured_count.present?
37
+
38
+ # Try to query user model count
39
+ user_model_name = detect_user_model
40
+ return nil unless user_model_name
41
+
42
+ query_user_count(user_model_name)
43
+ rescue StandardError => e
44
+ # Silently return nil if query fails (DB not accessible, model doesn't have count, etc.)
45
+ log_error("Failed to query user count: #{e.message}")
46
+ nil
47
+ end
48
+
49
+ # Check if User model exists and is loaded
50
+ #
51
+ # @return [Boolean]
52
+ def user_model_exists?
53
+ model_exists?("User")
54
+ end
55
+
56
+ # Check if a specific model exists and is loaded
57
+ #
58
+ # @param [String] model_name The model class name to check
59
+ # @return [Boolean]
60
+ def model_exists?(model_name)
61
+ # Check if model file exists
62
+ return false unless model_file_exists?(model_name)
63
+
64
+ # Try to constantize the model
65
+ begin
66
+ model_class = model_name.constantize
67
+ model_class.is_a?(Class) && model_class < ActiveRecord::Base
68
+ rescue NameError, LoadError
69
+ false
70
+ end
71
+ end
72
+
73
+ # Check if model file exists in app/models
74
+ #
75
+ # @param [String] model_name The model class name to check
76
+ # @return [Boolean]
77
+ def model_file_exists?(model_name)
78
+ return false unless defined?(Rails)
79
+
80
+ # Convert to snake_case filename (e.g., "User" -> "user.rb")
81
+ filename = model_name.underscore + ".rb"
82
+ model_path = Rails.root.join("app", "models", filename)
83
+
84
+ File.exist?(model_path)
85
+ end
86
+
87
+ # Query the user count from the model
88
+ # Handles connection to main database even if error dashboard uses separate DB
89
+ #
90
+ # @param [String] model_name The model class name to query
91
+ # @return [Integer, nil] User count or nil if query fails
92
+ def query_user_count(model_name)
93
+ model_class = model_name.constantize
94
+
95
+ # Ensure we're querying the main database, not the error dashboard database
96
+ # User models always connect to the primary/main database
97
+ if model_class.respond_to?(:count)
98
+ # Use a timeout to avoid hanging the dashboard
99
+ Timeout.timeout(5) do
100
+ model_class.count
101
+ end
102
+ end
103
+ rescue NameError, LoadError => e
104
+ log_error("Model not found: #{model_name} - #{e.message}")
105
+ nil
106
+ rescue Timeout::Error => e
107
+ log_error("Timeout querying #{model_name}.count - #{e.message}")
108
+ nil
109
+ rescue StandardError => e
110
+ log_error("Error querying #{model_name}.count: #{e.message}")
111
+ nil
112
+ end
113
+
114
+ # Get user impact percentage for an error
115
+ # Calculates percentage of users affected based on unique_users_count
116
+ #
117
+ # @param [Integer] unique_users_count Number of unique users affected
118
+ # @return [Float, nil] Percentage or nil if total users unavailable
119
+ def calculate_user_impact(unique_users_count)
120
+ return nil unless unique_users_count.present? && unique_users_count.positive?
121
+
122
+ total_users = detect_total_users
123
+ return nil unless total_users.present? && total_users.positive?
124
+
125
+ ((unique_users_count.to_f / total_users) * 100).round(2)
126
+ end
127
+
128
+ private
129
+
130
+ def log_error(message)
131
+ return unless RailsErrorDashboard.configuration.enable_internal_logging
132
+
133
+ Rails.logger.warn("[RailsErrorDashboard::UserModelDetector] #{message}")
134
+ end
135
+ end
136
+ end
137
+ end
138
+ end
@@ -100,8 +100,7 @@ module RailsErrorDashboard
100
100
  .count
101
101
  .sort_by { |_, count| -count }
102
102
  .first(10)
103
- .map { |user_id, count| [ find_user_email(user_id, user_model), count ] }
104
- .to_h
103
+ .map { |user_id, count| { user_id: user_id, email: find_user_email(user_id, user_model), count: count } }
105
104
  end
106
105
 
107
106
  def find_user_email(user_id, user_model)
@@ -38,7 +38,8 @@ module RailsErrorDashboard
38
38
  affected_users_change: affected_users_change,
39
39
  trend_percentage: trend_percentage,
40
40
  trend_direction: trend_direction,
41
- top_errors_by_impact: top_errors_by_impact
41
+ top_errors_by_impact: top_errors_by_impact,
42
+ average_resolution_time: average_resolution_time
42
43
  }
43
44
  end
44
45
  rescue => e
@@ -66,7 +67,8 @@ module RailsErrorDashboard
66
67
  affected_users_change: 0,
67
68
  trend_percentage: 0.0,
68
69
  trend_direction: :stable,
69
- top_errors_by_impact: []
70
+ top_errors_by_impact: [],
71
+ average_resolution_time: nil
70
72
  }
71
73
  end
72
74
  end
@@ -308,7 +310,7 @@ module RailsErrorDashboard
308
310
  end
309
311
  end
310
312
 
311
- # Get top 5 errors ranked by impact score
313
+ # Get top 6 errors ranked by impact score
312
314
  # Impact = affected_users_count × occurrence_count
313
315
  def top_errors_by_impact
314
316
  base_scope.where("occurred_at >= ?", 7.days.ago)
@@ -317,7 +319,7 @@ module RailsErrorDashboard
317
319
  COUNT(DISTINCT user_id) as affected_users,
318
320
  COUNT(DISTINCT user_id) * occurrence_count as impact_score")
319
321
  .order("impact_score DESC")
320
- .limit(5)
322
+ .limit(6)
321
323
  .map do |error|
322
324
  full_error = ErrorLog.find(error.id)
323
325
  {
@@ -332,6 +334,19 @@ module RailsErrorDashboard
332
334
  }
333
335
  end
334
336
  end
337
+
338
+ # Calculate average resolution time (MTTR) in hours for the last 30 days
339
+ def average_resolution_time
340
+ resolved_errors = base_scope.resolved.where("resolved_at >= ?", 30.days.ago)
341
+ return nil if resolved_errors.empty?
342
+
343
+ total_seconds = resolved_errors.sum do |error|
344
+ (error.resolved_at - error.occurred_at).to_i
345
+ end
346
+
347
+ average_seconds = total_seconds / resolved_errors.count.to_f
348
+ (average_seconds / 3600.0).round(2) # Convert to hours
349
+ end
335
350
  end
336
351
  end
337
352
  end
@@ -29,6 +29,7 @@ module RailsErrorDashboard
29
29
  query = filter_by_resolved(query)
30
30
  query = filter_by_platform(query)
31
31
  query = filter_by_application(query)
32
+ query = filter_by_user_id(query)
32
33
  query = filter_by_search(query)
33
34
  query = filter_by_severity(query)
34
35
  query = filter_by_timeframe(query)
@@ -82,6 +83,12 @@ module RailsErrorDashboard
82
83
  query.where(application_id: @filters[:application_id])
83
84
  end
84
85
 
86
+ def filter_by_user_id(query)
87
+ return query unless @filters[:user_id].present?
88
+
89
+ query.where(user_id: @filters[:user_id])
90
+ end
91
+
85
92
  def filter_by_search(query)
86
93
  return query unless @filters[:search].present?
87
94
 
@@ -145,17 +152,29 @@ module RailsErrorDashboard
145
152
  end
146
153
 
147
154
  def filter_by_assignment(query)
148
- return query unless @filters[:assigned_to].present?
149
155
  return query unless ErrorLog.column_names.include?("assigned_to")
150
156
 
151
- case @filters[:assigned_to]
152
- when "__unassigned__"
153
- query.unassigned
154
- when "__assigned__"
155
- query.assigned
156
- else
157
- query.by_assignee(@filters[:assigned_to])
157
+ # Handle assigned_to filter (All/Unassigned/Assigned)
158
+ if @filters[:assigned_to].present?
159
+ case @filters[:assigned_to]
160
+ when "__unassigned__"
161
+ query = query.unassigned
162
+ when "__assigned__"
163
+ query = query.assigned
164
+ # If assignee_name is also provided, filter by specific assignee
165
+ if @filters[:assignee_name].present?
166
+ query = query.by_assignee(@filters[:assignee_name])
167
+ end
168
+ else
169
+ # Specific assignee name provided in assigned_to
170
+ query = query.by_assignee(@filters[:assigned_to])
171
+ end
172
+ elsif @filters[:assignee_name].present?
173
+ # If only assignee_name is provided without assigned_to filter
174
+ query = query.by_assignee(@filters[:assignee_name])
158
175
  end
176
+
177
+ query
159
178
  end
160
179
 
161
180
  def filter_by_priority(query)