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.
- checksums.yaml +4 -4
- data/README.md +50 -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 +71 -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/_user_errors_table.html.erb +70 -0
- data/app/views/rails_error_dashboard/errors/analytics.html.erb +9 -37
- data/app/views/rails_error_dashboard/errors/correlation.html.erb +11 -37
- 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 +102 -76
- 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/analytics_stats.rb +1 -2
- data/lib/rails_error_dashboard/queries/dashboard_stats.rb +19 -4
- data/lib/rails_error_dashboard/queries/errors_list.rb +27 -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 +14 -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
|
@@ -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
|
|
26
|
-
|
|
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 (
|
|
124
|
-
config.max_backtrace_lines =
|
|
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 =
|
|
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
|
|
@@ -100,8 +100,7 @@ module RailsErrorDashboard
|
|
|
100
100
|
.count
|
|
101
101
|
.sort_by { |_, count| -count }
|
|
102
102
|
.first(10)
|
|
103
|
-
.map { |user_id, 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
|
|
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
|
|
@@ -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
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
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)
|