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.
- checksums.yaml +4 -4
- data/README.md +34 -6
- data/app/controllers/rails_error_dashboard/errors_controller.rb +22 -0
- data/app/helpers/rails_error_dashboard/application_helper.rb +79 -7
- data/app/helpers/rails_error_dashboard/backtrace_helper.rb +149 -0
- data/app/models/rails_error_dashboard/application.rb +1 -1
- data/app/models/rails_error_dashboard/error_log.rb +44 -16
- data/app/views/layouts/rails_error_dashboard.html.erb +66 -1237
- data/app/views/rails_error_dashboard/errors/_error_row.html.erb +10 -2
- data/app/views/rails_error_dashboard/errors/_source_code.html.erb +76 -0
- data/app/views/rails_error_dashboard/errors/_timeline.html.erb +18 -82
- data/app/views/rails_error_dashboard/errors/index.html.erb +64 -31
- data/app/views/rails_error_dashboard/errors/overview.html.erb +181 -3
- data/app/views/rails_error_dashboard/errors/platform_comparison.html.erb +2 -1
- data/app/views/rails_error_dashboard/errors/settings/_value_badge.html.erb +286 -0
- data/app/views/rails_error_dashboard/errors/settings.html.erb +146 -480
- data/app/views/rails_error_dashboard/errors/show.html.erb +44 -20
- data/db/migrate/20251223000000_create_rails_error_dashboard_complete_schema.rb +188 -0
- data/db/migrate/20251224000001_create_rails_error_dashboard_error_logs.rb +5 -0
- data/db/migrate/20251224081522_add_better_tracking_to_error_logs.rb +3 -0
- data/db/migrate/20251224101217_add_controller_action_to_error_logs.rb +3 -0
- data/db/migrate/20251225071314_add_optimized_indexes_to_error_logs.rb +4 -0
- data/db/migrate/20251225074653_remove_environment_from_error_logs.rb +3 -0
- data/db/migrate/20251225085859_add_enhanced_metrics_to_error_logs.rb +3 -0
- data/db/migrate/20251225093603_add_similarity_tracking_to_error_logs.rb +3 -0
- data/db/migrate/20251225100236_create_error_occurrences.rb +3 -0
- data/db/migrate/20251225101920_create_cascade_patterns.rb +3 -0
- data/db/migrate/20251225102500_create_error_baselines.rb +3 -0
- data/db/migrate/20251226020000_add_workflow_fields_to_error_logs.rb +3 -0
- data/db/migrate/20251226020100_create_error_comments.rb +3 -0
- data/db/migrate/20251229111223_add_additional_performance_indexes.rb +4 -0
- data/db/migrate/20260106094220_create_rails_error_dashboard_applications.rb +3 -0
- data/db/migrate/20260106094233_add_application_to_error_logs.rb +3 -0
- data/db/migrate/20260106094318_finalize_application_foreign_key.rb +5 -0
- data/lib/generators/rails_error_dashboard/install/install_generator.rb +32 -0
- data/lib/generators/rails_error_dashboard/install/templates/initializer.rb +37 -4
- data/lib/rails_error_dashboard/configuration.rb +160 -3
- data/lib/rails_error_dashboard/configuration_error.rb +24 -0
- data/lib/rails_error_dashboard/engine.rb +17 -0
- data/lib/rails_error_dashboard/helpers/user_model_detector.rb +138 -0
- data/lib/rails_error_dashboard/queries/dashboard_stats.rb +19 -4
- data/lib/rails_error_dashboard/queries/errors_list.rb +20 -8
- data/lib/rails_error_dashboard/services/error_normalizer.rb +143 -0
- data/lib/rails_error_dashboard/services/git_blame_reader.rb +195 -0
- data/lib/rails_error_dashboard/services/github_link_generator.rb +159 -0
- data/lib/rails_error_dashboard/services/source_code_reader.rb +214 -0
- data/lib/rails_error_dashboard/version.rb +1 -1
- data/lib/rails_error_dashboard.rb +6 -0
- metadata +13 -10
- data/app/assets/stylesheets/rails_error_dashboard/_catppuccin_mocha.scss +0 -107
- data/app/assets/stylesheets/rails_error_dashboard/_components.scss +0 -625
- data/app/assets/stylesheets/rails_error_dashboard/_layout.scss +0 -257
- data/app/assets/stylesheets/rails_error_dashboard/_theme_variables.scss +0 -203
- data/app/assets/stylesheets/rails_error_dashboard/application.css +0 -15
- data/app/assets/stylesheets/rails_error_dashboard/application.css.map +0 -7
- data/app/assets/stylesheets/rails_error_dashboard/application.scss +0 -61
- 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 =
|
|
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
|
-
|
|
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 =
|
|
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
|
|
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(
|
|
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
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
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
|