rails_error_dashboard 0.1.29 → 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 (57) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +34 -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 +66 -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/index.html.erb +64 -31
  13. data/app/views/rails_error_dashboard/errors/overview.html.erb +181 -3
  14. data/app/views/rails_error_dashboard/errors/platform_comparison.html.erb +2 -1
  15. data/app/views/rails_error_dashboard/errors/settings/_value_badge.html.erb +286 -0
  16. data/app/views/rails_error_dashboard/errors/settings.html.erb +146 -480
  17. data/app/views/rails_error_dashboard/errors/show.html.erb +44 -20
  18. data/db/migrate/20251223000000_create_rails_error_dashboard_complete_schema.rb +188 -0
  19. data/db/migrate/20251224000001_create_rails_error_dashboard_error_logs.rb +5 -0
  20. data/db/migrate/20251224081522_add_better_tracking_to_error_logs.rb +3 -0
  21. data/db/migrate/20251224101217_add_controller_action_to_error_logs.rb +3 -0
  22. data/db/migrate/20251225071314_add_optimized_indexes_to_error_logs.rb +4 -0
  23. data/db/migrate/20251225074653_remove_environment_from_error_logs.rb +3 -0
  24. data/db/migrate/20251225085859_add_enhanced_metrics_to_error_logs.rb +3 -0
  25. data/db/migrate/20251225093603_add_similarity_tracking_to_error_logs.rb +3 -0
  26. data/db/migrate/20251225100236_create_error_occurrences.rb +3 -0
  27. data/db/migrate/20251225101920_create_cascade_patterns.rb +3 -0
  28. data/db/migrate/20251225102500_create_error_baselines.rb +3 -0
  29. data/db/migrate/20251226020000_add_workflow_fields_to_error_logs.rb +3 -0
  30. data/db/migrate/20251226020100_create_error_comments.rb +3 -0
  31. data/db/migrate/20251229111223_add_additional_performance_indexes.rb +4 -0
  32. data/db/migrate/20260106094220_create_rails_error_dashboard_applications.rb +3 -0
  33. data/db/migrate/20260106094233_add_application_to_error_logs.rb +3 -0
  34. data/db/migrate/20260106094318_finalize_application_foreign_key.rb +5 -0
  35. data/lib/generators/rails_error_dashboard/install/install_generator.rb +32 -0
  36. data/lib/generators/rails_error_dashboard/install/templates/initializer.rb +37 -4
  37. data/lib/rails_error_dashboard/configuration.rb +160 -3
  38. data/lib/rails_error_dashboard/configuration_error.rb +24 -0
  39. data/lib/rails_error_dashboard/engine.rb +17 -0
  40. data/lib/rails_error_dashboard/helpers/user_model_detector.rb +138 -0
  41. data/lib/rails_error_dashboard/queries/dashboard_stats.rb +19 -4
  42. data/lib/rails_error_dashboard/queries/errors_list.rb +20 -8
  43. data/lib/rails_error_dashboard/services/error_normalizer.rb +143 -0
  44. data/lib/rails_error_dashboard/services/git_blame_reader.rb +195 -0
  45. data/lib/rails_error_dashboard/services/github_link_generator.rb +159 -0
  46. data/lib/rails_error_dashboard/services/source_code_reader.rb +214 -0
  47. data/lib/rails_error_dashboard/version.rb +1 -1
  48. data/lib/rails_error_dashboard.rb +6 -0
  49. metadata +13 -10
  50. data/app/assets/stylesheets/rails_error_dashboard/_catppuccin_mocha.scss +0 -107
  51. data/app/assets/stylesheets/rails_error_dashboard/_components.scss +0 -625
  52. data/app/assets/stylesheets/rails_error_dashboard/_layout.scss +0 -257
  53. data/app/assets/stylesheets/rails_error_dashboard/_theme_variables.scss +0 -203
  54. data/app/assets/stylesheets/rails_error_dashboard/application.css +0 -15
  55. data/app/assets/stylesheets/rails_error_dashboard/application.css.map +0 -7
  56. data/app/assets/stylesheets/rails_error_dashboard/application.scss +0 -61
  57. data/app/views/layouts/rails_error_dashboard/application.html.erb +0 -55
@@ -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
@@ -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
@@ -152,17 +152,29 @@ module RailsErrorDashboard
152
152
  end
153
153
 
154
154
  def filter_by_assignment(query)
155
- return query unless @filters[:assigned_to].present?
156
155
  return query unless ErrorLog.column_names.include?("assigned_to")
157
156
 
158
- case @filters[:assigned_to]
159
- when "__unassigned__"
160
- query.unassigned
161
- when "__assigned__"
162
- query.assigned
163
- else
164
- 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])
165
175
  end
176
+
177
+ query
166
178
  end
167
179
 
168
180
  def filter_by_priority(query)
@@ -0,0 +1,143 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsErrorDashboard
4
+ module Services
5
+ # Smart error message normalization for better error grouping
6
+ #
7
+ # Replaces dynamic values (IDs, UUIDs, timestamps, etc.) with placeholders
8
+ # while preserving semantic meaning. This improves error deduplication accuracy
9
+ # compared to naive "replace all numbers" approach.
10
+ #
11
+ # @example
12
+ # ErrorNormalizer.normalize("User 123 not found")
13
+ # # => "User :id not found"
14
+ #
15
+ # ErrorNormalizer.normalize("Expected 2 arguments, got 5")
16
+ # # => "Expected 2 arguments, got 5" (preserves meaningful numbers)
17
+ #
18
+ class ErrorNormalizer
19
+ # Patterns for smart normalization
20
+ # Order matters: more specific patterns should come first
21
+ PATTERNS = {
22
+ # UUIDs (e.g., "550e8400-e29b-41d4-a716-446655440000")
23
+ uuid: /\b[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\b/i,
24
+
25
+ # Memory addresses (e.g., "<User:0x00007f8b1a2b3c4d>", "0x00007f8b1a2b3c4d")
26
+ # MUST come before hash_id to match memory addresses first
27
+ memory_address: /#?<[^>]+:0x[0-9a-f]+>/i,
28
+ hex_address: /\b0x[0-9a-f]{8,16}\b/i,
29
+
30
+ # Timestamps (ISO8601 and common formats)
31
+ # Remove timezone offset separately to avoid leaving it behind
32
+ timestamp_iso: /\d{4}-\d{2}-\d{2}[T\s]\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:?\d{2})?/,
33
+ timestamp_unix: /\btimestamp[:\s]+\d{10,13}\b/i,
34
+
35
+ # Tokens and API keys (long alphanumeric strings)
36
+ # MUST come before large_number to match long tokens first
37
+ token: /\b[a-z0-9]{32,}\b/i,
38
+
39
+ # Object IDs and database IDs (e.g., "User #123", "id: 456", "ID=789")
40
+ # MUST come before hash_id to match specific ID patterns first
41
+ object_id: /(?:#|(?:id|ID)(?:\s*[=:#]\s*|\s+))\d+\b/,
42
+ # Ruby-style object references (e.g., "User:123", "#<User:123>")
43
+ hash_id: /#?<?[A-Z]\w*:\d+>?/,
44
+
45
+ # File paths with dynamic components (but check for UUIDs in path first)
46
+ # More specific pattern: match /tmp/ followed by UUID-like or hash-like segment
47
+ temp_path: %r{/(?:tmp|var/tmp|private/tmp)/(?:[a-z0-9_-]+/)*[a-z0-9_-]+(?:\.[a-z0-9]+)?},
48
+
49
+ # Numbered URL paths - MUST come before large_number
50
+ # Capture the leading slash with the number, and optional trailing slash
51
+ numbered_path: %r{/\d+(?=/|$)}, # e.g., "/api/users/123/posts" → "/api/users:numbered_path/posts"
52
+
53
+ # Email addresses (preserve domain, replace local part)
54
+ email: /\b[\w.+-]+@[\w.-]+\.[a-z]{2,}\b/i,
55
+
56
+ # IP addresses
57
+ ipv4: /\b(?:\d{1,3}\.){3}\d{1,3}\b/,
58
+ ipv6: /\b(?:[0-9a-f]{1,4}:){7}[0-9a-f]{1,4}\b/i,
59
+
60
+ # Hexadecimal values (but not in memory addresses - already handled)
61
+ hex_value: /\b0x[0-9a-f]+\b/i,
62
+
63
+ # Standalone large numbers (likely IDs, but preserve small numbers < 1000)
64
+ # MUST come last to avoid matching parts of other patterns
65
+ large_number: /\b\d{4,}\b/
66
+ }.freeze
67
+
68
+ class << self
69
+ # Normalize an error message by replacing dynamic values with placeholders
70
+ #
71
+ # @param message [String] the error message to normalize
72
+ # @return [String] the normalized message
73
+ def normalize(message)
74
+ return "" if message.nil?
75
+ return message if message.strip.empty? # Preserve whitespace-only strings
76
+
77
+ normalized = message.dup
78
+
79
+ # Apply each pattern in order
80
+ PATTERNS.each do |type, pattern|
81
+ normalized.gsub!(pattern, ":#{type}")
82
+ end
83
+
84
+ # Clean up leftover timezone offsets that weren't caught by timestamp pattern
85
+ normalized.gsub!(/\s+[+-]\d{2}:\d{2}$/, "")
86
+
87
+ normalized
88
+ end
89
+
90
+ # Extract significant backtrace frames, skipping gem/vendor code
91
+ #
92
+ # @param backtrace [String] the full backtrace string
93
+ # @param count [Integer] number of frames to extract (default: 3)
94
+ # @return [String, nil] the significant frames joined with "|"
95
+ def extract_significant_frames(backtrace, count: 3)
96
+ return nil if backtrace.blank?
97
+
98
+ frames = backtrace.split("\n")
99
+ .map(&:strip)
100
+ .reject { |line| gem_or_vendor_code?(line) }
101
+ .reject { |line| ruby_stdlib_code?(line) }
102
+ .first(count)
103
+ .map { |line| extract_file_and_method(line) }
104
+ .compact
105
+
106
+ frames.empty? ? nil : frames.join("|")
107
+ end
108
+
109
+ private
110
+
111
+ # Check if a backtrace line is from gem/vendor code
112
+ def gem_or_vendor_code?(line)
113
+ line.include?("vendor/bundle") ||
114
+ line.include?("gems/") ||
115
+ line.include?(".gem/ruby")
116
+ end
117
+
118
+ # Check if a backtrace line is from Ruby standard library
119
+ def ruby_stdlib_code?(line)
120
+ line.include?("/ruby/") ||
121
+ line.include?("/lib/ruby/") ||
122
+ line.match?(%r{ruby-\d+\.\d+\.\d+/lib})
123
+ end
124
+
125
+ # Extract file path and method name from a backtrace line
126
+ # Example: "/app/models/user.rb:10:in `name'" => "/app/models/user.rb:name"
127
+ def extract_file_and_method(line)
128
+ # Match pattern: file.rb:line:in `method'
129
+ match = line.match(%r{^(.+\.rb):(\d+)(?::in `(.+)')?})
130
+ return nil unless match
131
+
132
+ file = match[1]
133
+ method = match[3]
134
+
135
+ # Remove absolute path prefix for consistency
136
+ file = file.sub(%r{.*/(?=app/)}, "")
137
+
138
+ method ? "#{file}:#{method}" : file
139
+ end
140
+ end
141
+ end
142
+ end
143
+ end