rails_error_dashboard 0.1.37 → 0.2.0
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 +20 -4
- data/app/controllers/rails_error_dashboard/application_controller.rb +2 -5
- data/app/controllers/rails_error_dashboard/errors_controller.rb +2 -3
- data/app/jobs/rails_error_dashboard/async_error_logging_job.rb +10 -0
- data/app/jobs/rails_error_dashboard/baseline_alert_job.rb +19 -15
- data/app/jobs/rails_error_dashboard/discord_error_notification_job.rb +19 -9
- data/app/jobs/rails_error_dashboard/pagerduty_error_notification_job.rb +37 -11
- data/app/jobs/rails_error_dashboard/retention_cleanup_job.rb +44 -0
- data/app/jobs/rails_error_dashboard/webhook_error_notification_job.rb +38 -16
- data/app/models/rails_error_dashboard/error_log.rb +10 -0
- data/app/models/rails_error_dashboard/error_logs_record.rb +11 -6
- data/app/views/layouts/rails_error_dashboard.html.erb +16 -0
- data/app/views/rails_error_dashboard/errors/_error_row.html.erb +3 -0
- data/app/views/rails_error_dashboard/errors/_stats.html.erb +12 -4
- data/app/views/rails_error_dashboard/errors/index.html.erb +9 -7
- data/app/views/rails_error_dashboard/errors/show.html.erb +138 -7
- data/db/migrate/20251223000000_create_rails_error_dashboard_complete_schema.rb +36 -0
- data/db/migrate/20251224081522_add_better_tracking_to_error_logs.rb +1 -1
- data/db/migrate/20251224101217_add_controller_action_to_error_logs.rb +1 -1
- data/db/migrate/20251225071314_add_optimized_indexes_to_error_logs.rb +1 -1
- data/db/migrate/20251225074653_remove_environment_from_error_logs.rb +1 -1
- data/db/migrate/20251225085859_add_enhanced_metrics_to_error_logs.rb +1 -1
- data/db/migrate/20251225093603_add_similarity_tracking_to_error_logs.rb +1 -1
- data/db/migrate/20251225100236_create_error_occurrences.rb +1 -1
- data/db/migrate/20251225101920_create_cascade_patterns.rb +1 -1
- data/db/migrate/20251225102500_create_error_baselines.rb +1 -1
- data/db/migrate/20251226020000_add_workflow_fields_to_error_logs.rb +1 -1
- data/db/migrate/20251226020100_create_error_comments.rb +1 -1
- data/db/migrate/20251230075315_cleanup_orphaned_migrations.rb +1 -1
- data/db/migrate/20260220000001_add_exception_cause_to_error_logs.rb +9 -0
- data/db/migrate/20260220000002_add_enriched_context_to_error_logs.rb +12 -0
- data/db/migrate/20260220000003_add_time_series_indexes_to_error_logs.rb +67 -0
- data/db/migrate/20260221000001_add_environment_info_to_error_logs.rb +9 -0
- data/db/migrate/20260221000002_add_reopened_at_to_error_logs.rb +9 -0
- data/lib/generators/rails_error_dashboard/install/install_generator.rb +145 -24
- data/lib/generators/rails_error_dashboard/install/templates/initializer.rb +12 -8
- data/lib/rails_error_dashboard/commands/find_or_increment_error.rb +58 -10
- data/lib/rails_error_dashboard/commands/log_error.rb +109 -10
- data/lib/rails_error_dashboard/configuration.rb +52 -0
- data/lib/rails_error_dashboard/manual_error_reporter.rb +12 -0
- data/lib/rails_error_dashboard/middleware/error_catcher.rb +3 -0
- data/lib/rails_error_dashboard/queries/dashboard_stats.rb +8 -0
- data/lib/rails_error_dashboard/queries/errors_list.rb +8 -0
- data/lib/rails_error_dashboard/services/backtrace_parser.rb +31 -0
- data/lib/rails_error_dashboard/services/backtrace_processor.rb +31 -1
- data/lib/rails_error_dashboard/services/cause_chain_extractor.rb +62 -0
- data/lib/rails_error_dashboard/services/environment_snapshot.rb +85 -0
- data/lib/rails_error_dashboard/services/error_hash_generator.rb +50 -2
- data/lib/rails_error_dashboard/services/notification_throttler.rb +109 -0
- data/lib/rails_error_dashboard/services/platform_detector.rb +36 -11
- data/lib/rails_error_dashboard/services/sensitive_data_filter.rb +176 -0
- data/lib/rails_error_dashboard/value_objects/error_context.rb +81 -4
- data/lib/rails_error_dashboard/version.rb +1 -1
- data/lib/rails_error_dashboard.rb +11 -6
- data/lib/tasks/error_dashboard.rake +158 -2
- metadata +14 -60
|
@@ -56,6 +56,7 @@ module RailsErrorDashboard
|
|
|
56
56
|
@selected_features = {}
|
|
57
57
|
|
|
58
58
|
# Feature definitions with descriptions - organized by category
|
|
59
|
+
# Note: separate_database is handled separately via select_database_mode
|
|
59
60
|
features = [
|
|
60
61
|
# === NOTIFICATIONS ===
|
|
61
62
|
{
|
|
@@ -102,12 +103,6 @@ module RailsErrorDashboard
|
|
|
102
103
|
description: "Log only % of non-critical errors (reduce volume)",
|
|
103
104
|
category: "Performance"
|
|
104
105
|
},
|
|
105
|
-
{
|
|
106
|
-
key: :separate_database,
|
|
107
|
-
name: "Separate Error Database",
|
|
108
|
-
description: "Store errors in dedicated database",
|
|
109
|
-
category: "Performance"
|
|
110
|
-
},
|
|
111
106
|
|
|
112
107
|
# === ADVANCED ANALYTICS ===
|
|
113
108
|
{
|
|
@@ -191,6 +186,55 @@ module RailsErrorDashboard
|
|
|
191
186
|
say "\n"
|
|
192
187
|
end
|
|
193
188
|
|
|
189
|
+
def select_database_mode
|
|
190
|
+
# Skip if not interactive or if --separate_database was passed via CLI
|
|
191
|
+
if options[:separate_database]
|
|
192
|
+
@database_mode = :separate
|
|
193
|
+
@database_name = options[:database] || "error_dashboard"
|
|
194
|
+
@application_name = detect_application_name
|
|
195
|
+
return
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
return unless options[:interactive] && behavior == :invoke
|
|
199
|
+
return unless $stdin.tty?
|
|
200
|
+
|
|
201
|
+
say "-" * 70
|
|
202
|
+
say " Database Setup", :cyan
|
|
203
|
+
say "-" * 70
|
|
204
|
+
say "\n"
|
|
205
|
+
say " How do you want to store error data?\n", :white
|
|
206
|
+
say " 1) Same database (default) - store errors in your app's primary database", :white
|
|
207
|
+
say " 2) Separate database - dedicated database for error data (recommended for production)", :white
|
|
208
|
+
say " 3) Shared database - connect to an existing error database shared by multiple apps", :white
|
|
209
|
+
say "\n"
|
|
210
|
+
|
|
211
|
+
response = ask(" Choose (1/2/3):", :yellow, limited_to: [ "1", "2", "3", "" ])
|
|
212
|
+
|
|
213
|
+
case response
|
|
214
|
+
when "2"
|
|
215
|
+
@database_mode = :separate
|
|
216
|
+
@database_name = "error_dashboard"
|
|
217
|
+
@application_name = detect_application_name
|
|
218
|
+
|
|
219
|
+
say "\n Database key: error_dashboard", :green
|
|
220
|
+
say " Application name: #{@application_name}", :green
|
|
221
|
+
say "\n"
|
|
222
|
+
when "3"
|
|
223
|
+
@database_mode = :multi_app
|
|
224
|
+
@database_name = "error_dashboard"
|
|
225
|
+
@application_name = detect_application_name
|
|
226
|
+
|
|
227
|
+
say "\n Database key: error_dashboard", :green
|
|
228
|
+
say " Application name: #{@application_name}", :green
|
|
229
|
+
say " This app will share the error database with your other apps.", :white
|
|
230
|
+
say "\n"
|
|
231
|
+
else
|
|
232
|
+
@database_mode = :same
|
|
233
|
+
@database_name = nil
|
|
234
|
+
@application_name = detect_application_name
|
|
235
|
+
end
|
|
236
|
+
end
|
|
237
|
+
|
|
194
238
|
def create_initializer_file
|
|
195
239
|
# Notifications
|
|
196
240
|
@enable_slack = @selected_features&.dig(:slack) || options[:slack]
|
|
@@ -202,8 +246,12 @@ module RailsErrorDashboard
|
|
|
202
246
|
# Performance
|
|
203
247
|
@enable_async_logging = @selected_features&.dig(:async_logging) || options[:async_logging]
|
|
204
248
|
@enable_error_sampling = @selected_features&.dig(:error_sampling) || options[:error_sampling]
|
|
205
|
-
|
|
206
|
-
|
|
249
|
+
|
|
250
|
+
# Database mode (set by select_database_mode or CLI flags)
|
|
251
|
+
@database_mode ||= :same
|
|
252
|
+
@enable_separate_database = @database_mode == :separate || @database_mode == :multi_app
|
|
253
|
+
@enable_multi_app = @database_mode == :multi_app
|
|
254
|
+
# @database_name and @application_name are set by select_database_mode
|
|
207
255
|
|
|
208
256
|
# Advanced Analytics
|
|
209
257
|
@enable_baseline_alerts = @selected_features&.dig(:baseline_alerts) || options[:baseline_alerts]
|
|
@@ -234,7 +282,7 @@ module RailsErrorDashboard
|
|
|
234
282
|
|
|
235
283
|
say "\n"
|
|
236
284
|
say "=" * 70
|
|
237
|
-
say "
|
|
285
|
+
say " Installation Complete!", :green
|
|
238
286
|
say "=" * 70
|
|
239
287
|
say "\n"
|
|
240
288
|
|
|
@@ -309,28 +357,40 @@ module RailsErrorDashboard
|
|
|
309
357
|
say " → Set PAGERDUTY_INTEGRATION_KEY in .env", :yellow if @enable_pagerduty
|
|
310
358
|
say " → Set WEBHOOK_URLS in .env", :yellow if @enable_webhooks
|
|
311
359
|
say " → Ensure Sidekiq/Solid Queue running", :yellow if @enable_async_logging
|
|
360
|
+
|
|
361
|
+
# Database-specific instructions
|
|
312
362
|
if @enable_separate_database
|
|
313
|
-
|
|
314
|
-
say " → Configure '#{@database_name}' database in database.yml", :yellow
|
|
315
|
-
else
|
|
316
|
-
say " → Configure database in database.yml and set config.database", :yellow
|
|
317
|
-
end
|
|
318
|
-
say " See docs/guides/DATABASE_OPTIONS.md for details", :yellow
|
|
363
|
+
show_database_setup_instructions
|
|
319
364
|
end
|
|
320
365
|
|
|
321
366
|
say "\n"
|
|
322
367
|
say "Next Steps:", :cyan
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
368
|
+
if @enable_separate_database
|
|
369
|
+
say " 1. Add the database.yml entry shown above"
|
|
370
|
+
say " 2. Run: rails db:create:error_dashboard"
|
|
371
|
+
if @enable_multi_app
|
|
372
|
+
say " 3. Run migrations (only needed on the FIRST app):"
|
|
373
|
+
say " rails db:migrate:error_dashboard"
|
|
374
|
+
else
|
|
375
|
+
say " 3. Run: rails db:migrate:error_dashboard"
|
|
376
|
+
end
|
|
377
|
+
say " 4. Update credentials in config/initializers/rails_error_dashboard.rb"
|
|
378
|
+
say " 5. Restart your Rails server"
|
|
379
|
+
say " 6. Visit http://localhost:3000/error_dashboard"
|
|
380
|
+
say " 7. Verify: rails error_dashboard:verify"
|
|
381
|
+
else
|
|
382
|
+
say " 1. Run: rails db:migrate"
|
|
383
|
+
say " 2. Update credentials in config/initializers/rails_error_dashboard.rb"
|
|
384
|
+
say " 3. Restart your Rails server"
|
|
385
|
+
say " 4. Visit http://localhost:3000/error_dashboard"
|
|
386
|
+
end
|
|
327
387
|
say "\n"
|
|
328
|
-
say "
|
|
329
|
-
say "
|
|
330
|
-
say "
|
|
331
|
-
say "
|
|
388
|
+
say "Documentation:", :white
|
|
389
|
+
say " Quick Start: docs/QUICKSTART.md", :white
|
|
390
|
+
say " Database Setup: docs/guides/DATABASE_OPTIONS.md", :white
|
|
391
|
+
say " Complete Feature Guide: docs/FEATURES.md", :white
|
|
332
392
|
say "\n"
|
|
333
|
-
say "
|
|
393
|
+
say "To enable/disable features later:", :white
|
|
334
394
|
say " Edit config/initializers/rails_error_dashboard.rb", :white
|
|
335
395
|
say "\n"
|
|
336
396
|
end
|
|
@@ -338,6 +398,67 @@ module RailsErrorDashboard
|
|
|
338
398
|
def show_readme
|
|
339
399
|
# Skip the old README display since we have the new summary
|
|
340
400
|
end
|
|
401
|
+
|
|
402
|
+
private
|
|
403
|
+
|
|
404
|
+
def detect_application_name
|
|
405
|
+
if defined?(Rails) && Rails.application
|
|
406
|
+
Rails.application.class.module_parent_name
|
|
407
|
+
else
|
|
408
|
+
"MyApp"
|
|
409
|
+
end
|
|
410
|
+
end
|
|
411
|
+
|
|
412
|
+
def show_database_setup_instructions
|
|
413
|
+
app_name_snake = @application_name.to_s.underscore
|
|
414
|
+
|
|
415
|
+
say "\n"
|
|
416
|
+
say "-" * 70
|
|
417
|
+
say " Database Setup Required", :yellow
|
|
418
|
+
say "-" * 70
|
|
419
|
+
say "\n"
|
|
420
|
+
say " Add this to your config/database.yml:\n", :white
|
|
421
|
+
|
|
422
|
+
if @enable_multi_app
|
|
423
|
+
say " # Shared error database (same physical DB across all your apps)", :white
|
|
424
|
+
else
|
|
425
|
+
say " # Separate error database", :white
|
|
426
|
+
end
|
|
427
|
+
|
|
428
|
+
say "\n development:", :cyan
|
|
429
|
+
say " primary:", :white
|
|
430
|
+
say " <<: *default", :white
|
|
431
|
+
say " database: #{app_name_snake}_development", :white
|
|
432
|
+
say " error_dashboard:", :white
|
|
433
|
+
say " <<: *default", :white
|
|
434
|
+
if @enable_multi_app
|
|
435
|
+
say " database: shared_errors_development", :white
|
|
436
|
+
else
|
|
437
|
+
say " database: #{app_name_snake}_errors_development", :white
|
|
438
|
+
end
|
|
439
|
+
say " migrations_paths: db/error_dashboard_migrate", :white
|
|
440
|
+
|
|
441
|
+
say "\n production:", :cyan
|
|
442
|
+
say " primary:", :white
|
|
443
|
+
say " <<: *default", :white
|
|
444
|
+
say " database: #{app_name_snake}_production", :white
|
|
445
|
+
say " error_dashboard:", :white
|
|
446
|
+
say " <<: *default", :white
|
|
447
|
+
if @enable_multi_app
|
|
448
|
+
say " database: shared_errors_production", :white
|
|
449
|
+
else
|
|
450
|
+
say " database: #{app_name_snake}_errors_production", :white
|
|
451
|
+
end
|
|
452
|
+
say " migrations_paths: db/error_dashboard_migrate", :white
|
|
453
|
+
say "\n"
|
|
454
|
+
|
|
455
|
+
if @enable_multi_app
|
|
456
|
+
say " For multi-app: all apps must point to the same physical database.", :yellow
|
|
457
|
+
say " Only the FIRST app needs to run migrations.", :yellow
|
|
458
|
+
say " Other apps just need the database.yml entry and 'rails error_dashboard:verify'.", :yellow
|
|
459
|
+
say "\n"
|
|
460
|
+
end
|
|
461
|
+
end
|
|
341
462
|
end
|
|
342
463
|
end
|
|
343
464
|
end
|
|
@@ -151,22 +151,26 @@ RailsErrorDashboard.configure do |config|
|
|
|
151
151
|
|
|
152
152
|
<% if @enable_separate_database -%>
|
|
153
153
|
# Separate Error Database - ENABLED
|
|
154
|
-
# Errors
|
|
155
|
-
# See docs/guides/DATABASE_OPTIONS.md for setup instructions
|
|
154
|
+
# Errors are stored in a dedicated database for isolation and scalability.
|
|
155
|
+
# See docs/guides/DATABASE_OPTIONS.md for setup instructions.
|
|
156
156
|
config.use_separate_database = true
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
#
|
|
157
|
+
config.database = :<%= @database_name || "error_dashboard" %>
|
|
158
|
+
<% if @enable_multi_app -%>
|
|
159
|
+
|
|
160
|
+
# Multi-app mode: multiple Rails apps share this error database.
|
|
161
|
+
# Each app is identified by its application_name.
|
|
162
|
+
# Auto-detected from Rails.application.class.module_parent_name if not set.
|
|
163
|
+
config.application_name = "<%= @application_name %>"
|
|
161
164
|
<% end -%>
|
|
162
165
|
# To disable: Set config.use_separate_database = false
|
|
163
166
|
|
|
164
167
|
<% else -%>
|
|
165
168
|
# Separate Error Database - DISABLED
|
|
166
|
-
# Errors are stored in your main application database
|
|
169
|
+
# Errors are stored in your main application database.
|
|
167
170
|
# To enable: Set config.use_separate_database = true and configure database.yml
|
|
171
|
+
# See docs/guides/DATABASE_OPTIONS.md for setup instructions.
|
|
168
172
|
config.use_separate_database = false
|
|
169
|
-
# config.database = :error_dashboard
|
|
173
|
+
# config.database = :error_dashboard
|
|
170
174
|
|
|
171
175
|
<% end -%>
|
|
172
176
|
# ============================================================================
|
|
@@ -4,8 +4,11 @@ module RailsErrorDashboard
|
|
|
4
4
|
module Commands
|
|
5
5
|
# Command: Find an existing error by hash or create a new one
|
|
6
6
|
# Uses pessimistic locking to prevent race conditions in multi-app scenarios.
|
|
7
|
-
#
|
|
8
|
-
#
|
|
7
|
+
#
|
|
8
|
+
# Search order:
|
|
9
|
+
# 1. Unresolved errors with same hash within 24 hours → increment occurrence count
|
|
10
|
+
# 2. Resolved/wont_fix errors with same hash (any age) → reopen and increment
|
|
11
|
+
# 3. No match → create new error record
|
|
9
12
|
class FindOrIncrementError
|
|
10
13
|
def self.call(error_hash, attributes = {})
|
|
11
14
|
new(error_hash, attributes).call
|
|
@@ -17,18 +20,21 @@ module RailsErrorDashboard
|
|
|
17
20
|
end
|
|
18
21
|
|
|
19
22
|
def call
|
|
20
|
-
existing
|
|
23
|
+
# Priority 1: Find unresolved match (existing behavior)
|
|
24
|
+
existing = find_unresolved
|
|
25
|
+
return increment_existing(existing) if existing
|
|
21
26
|
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
+
# Priority 2: Find resolved/wont_fix match → reopen
|
|
28
|
+
resolved = find_resolved
|
|
29
|
+
return reopen_existing(resolved) if resolved
|
|
30
|
+
|
|
31
|
+
# Priority 3: Create new record
|
|
32
|
+
create_new_or_retry
|
|
27
33
|
end
|
|
28
34
|
|
|
29
35
|
private
|
|
30
36
|
|
|
31
|
-
def
|
|
37
|
+
def find_unresolved
|
|
32
38
|
ErrorLog.unresolved
|
|
33
39
|
.where(error_hash: @error_hash)
|
|
34
40
|
.where(application_id: @attributes[:application_id])
|
|
@@ -38,6 +44,16 @@ module RailsErrorDashboard
|
|
|
38
44
|
.first
|
|
39
45
|
end
|
|
40
46
|
|
|
47
|
+
def find_resolved
|
|
48
|
+
ErrorLog
|
|
49
|
+
.where(error_hash: @error_hash)
|
|
50
|
+
.where(application_id: @attributes[:application_id])
|
|
51
|
+
.where(status: %w[resolved wont_fix])
|
|
52
|
+
.lock
|
|
53
|
+
.order(last_seen_at: :desc)
|
|
54
|
+
.first
|
|
55
|
+
end
|
|
56
|
+
|
|
41
57
|
def increment_existing(error)
|
|
42
58
|
error.update!(
|
|
43
59
|
occurrence_count: error.occurrence_count + 1,
|
|
@@ -51,9 +67,29 @@ module RailsErrorDashboard
|
|
|
51
67
|
error
|
|
52
68
|
end
|
|
53
69
|
|
|
70
|
+
def reopen_existing(error)
|
|
71
|
+
attrs = {
|
|
72
|
+
resolved: false,
|
|
73
|
+
status: "new",
|
|
74
|
+
resolved_at: nil,
|
|
75
|
+
occurrence_count: error.occurrence_count + 1,
|
|
76
|
+
last_seen_at: Time.current,
|
|
77
|
+
user_id: @attributes[:user_id] || error.user_id,
|
|
78
|
+
request_url: @attributes[:request_url] || error.request_url,
|
|
79
|
+
request_params: @attributes[:request_params] || error.request_params,
|
|
80
|
+
user_agent: @attributes[:user_agent] || error.user_agent,
|
|
81
|
+
ip_address: @attributes[:ip_address] || error.ip_address
|
|
82
|
+
}
|
|
83
|
+
attrs[:reopened_at] = Time.current if ErrorLog.column_names.include?("reopened_at")
|
|
84
|
+
error.update!(attrs)
|
|
85
|
+
error.just_reopened = true
|
|
86
|
+
error
|
|
87
|
+
end
|
|
88
|
+
|
|
54
89
|
def create_new_or_retry
|
|
55
90
|
ErrorLog.create!(@attributes.reverse_merge(resolved: false))
|
|
56
91
|
rescue ActiveRecord::RecordNotUnique
|
|
92
|
+
# Race condition: another process created the same error
|
|
57
93
|
retry_existing = ErrorLog.unresolved
|
|
58
94
|
.where(error_hash: @error_hash)
|
|
59
95
|
.where(application_id: @attributes[:application_id])
|
|
@@ -68,7 +104,19 @@ module RailsErrorDashboard
|
|
|
68
104
|
)
|
|
69
105
|
retry_existing
|
|
70
106
|
else
|
|
71
|
-
|
|
107
|
+
# Also check resolved in race condition path
|
|
108
|
+
retry_resolved = ErrorLog
|
|
109
|
+
.where(error_hash: @error_hash)
|
|
110
|
+
.where(application_id: @attributes[:application_id])
|
|
111
|
+
.where(status: %w[resolved wont_fix])
|
|
112
|
+
.lock
|
|
113
|
+
.first
|
|
114
|
+
|
|
115
|
+
if retry_resolved
|
|
116
|
+
reopen_existing(retry_resolved)
|
|
117
|
+
else
|
|
118
|
+
raise
|
|
119
|
+
end
|
|
72
120
|
end
|
|
73
121
|
end
|
|
74
122
|
end
|
|
@@ -23,7 +23,8 @@ module RailsErrorDashboard
|
|
|
23
23
|
exception_data = {
|
|
24
24
|
class_name: exception.class.name,
|
|
25
25
|
message: exception.message,
|
|
26
|
-
backtrace: exception.backtrace
|
|
26
|
+
backtrace: exception.backtrace,
|
|
27
|
+
cause_chain: serialize_cause_chain(exception)
|
|
27
28
|
}
|
|
28
29
|
|
|
29
30
|
# Enqueue the async job using ActiveJob
|
|
@@ -31,6 +32,37 @@ module RailsErrorDashboard
|
|
|
31
32
|
AsyncErrorLoggingJob.perform_later(exception_data, context)
|
|
32
33
|
end
|
|
33
34
|
|
|
35
|
+
# Serialize cause chain for async job serialization
|
|
36
|
+
# Returns an array of hashes (not JSON string) for ActiveJob compatibility
|
|
37
|
+
def self.serialize_cause_chain(exception)
|
|
38
|
+
return nil unless exception.respond_to?(:cause) && exception.cause
|
|
39
|
+
|
|
40
|
+
chain = []
|
|
41
|
+
current = exception.cause
|
|
42
|
+
seen = Set.new
|
|
43
|
+
depth = 0
|
|
44
|
+
|
|
45
|
+
while current && depth < 5
|
|
46
|
+
break if seen.include?(current.object_id)
|
|
47
|
+
seen.add(current.object_id)
|
|
48
|
+
|
|
49
|
+
chain << {
|
|
50
|
+
class_name: current.class.name,
|
|
51
|
+
message: current.message&.to_s,
|
|
52
|
+
backtrace: current.backtrace&.first(20)
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
current = current.respond_to?(:cause) ? current.cause : nil
|
|
56
|
+
depth += 1
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
chain.empty? ? nil : chain
|
|
60
|
+
rescue => e
|
|
61
|
+
RailsErrorDashboard::Logger.debug("[RailsErrorDashboard] Cause chain serialization failed: #{e.message}")
|
|
62
|
+
nil
|
|
63
|
+
end
|
|
64
|
+
private_class_method :serialize_cause_chain
|
|
65
|
+
|
|
34
66
|
def initialize(exception, context = {})
|
|
35
67
|
@exception = exception
|
|
36
68
|
@context = context
|
|
@@ -63,17 +95,32 @@ module RailsErrorDashboard
|
|
|
63
95
|
occurred_at: Time.current
|
|
64
96
|
}
|
|
65
97
|
|
|
98
|
+
# Enriched request context (if columns exist)
|
|
99
|
+
enrich_with_request_context(attributes, error_context)
|
|
100
|
+
|
|
101
|
+
# Extract exception cause chain (if column exists)
|
|
102
|
+
if ErrorLog.column_names.include?("exception_cause")
|
|
103
|
+
cause_json = Services::CauseChainExtractor.call(@exception)
|
|
104
|
+
# Fall back to pre-serialized cause chain from async job context
|
|
105
|
+
cause_json ||= build_cause_json_from_context
|
|
106
|
+
attributes[:exception_cause] = cause_json
|
|
107
|
+
end
|
|
108
|
+
|
|
66
109
|
# Generate error hash for deduplication (including controller/action context and application)
|
|
67
110
|
error_hash = Services::ErrorHashGenerator.call(
|
|
68
111
|
@exception,
|
|
69
112
|
controller_name: error_context.controller_name,
|
|
70
113
|
action_name: error_context.action_name,
|
|
71
|
-
application_id: application.id
|
|
114
|
+
application_id: application.id,
|
|
115
|
+
context: @context
|
|
72
116
|
)
|
|
73
117
|
|
|
74
118
|
# Calculate backtrace signature for fuzzy matching (if column exists)
|
|
75
119
|
if ErrorLog.column_names.include?("backtrace_signature")
|
|
76
|
-
attributes[:backtrace_signature] = Services::BacktraceProcessor.calculate_signature(
|
|
120
|
+
attributes[:backtrace_signature] = Services::BacktraceProcessor.calculate_signature(
|
|
121
|
+
truncated_backtrace,
|
|
122
|
+
locations: @exception.backtrace_locations
|
|
123
|
+
)
|
|
77
124
|
end
|
|
78
125
|
|
|
79
126
|
# Add git/release info if columns exist
|
|
@@ -91,6 +138,14 @@ module RailsErrorDashboard
|
|
|
91
138
|
detect_version_from_file
|
|
92
139
|
end
|
|
93
140
|
|
|
141
|
+
# Add environment snapshot (if column exists)
|
|
142
|
+
if ErrorLog.column_names.include?("environment_info")
|
|
143
|
+
attributes[:environment_info] = Services::EnvironmentSnapshot.snapshot.to_json
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
# Apply sensitive data filtering (on by default)
|
|
147
|
+
attributes = Services::SensitiveDataFilter.filter_attributes(attributes)
|
|
148
|
+
|
|
94
149
|
# Find existing error or create new one
|
|
95
150
|
# This ensures accurate occurrence tracking
|
|
96
151
|
error_log = ErrorLog.find_or_increment_by_hash(error_hash, attributes.merge(error_hash: error_hash))
|
|
@@ -110,18 +165,31 @@ module RailsErrorDashboard
|
|
|
110
165
|
end
|
|
111
166
|
end
|
|
112
167
|
|
|
113
|
-
# Send notifications
|
|
114
|
-
# Check if this is first occurrence or error was just created
|
|
168
|
+
# Send notifications for new errors and reopened errors (with throttling)
|
|
115
169
|
if error_log.occurrence_count == 1
|
|
116
|
-
|
|
117
|
-
|
|
170
|
+
# Brand new error — notify if severity meets minimum
|
|
171
|
+
if Services::NotificationThrottler.severity_meets_minimum?(error_log)
|
|
172
|
+
Services::ErrorNotificationDispatcher.call(error_log)
|
|
173
|
+
Services::NotificationThrottler.record_notification(error_log)
|
|
174
|
+
end
|
|
118
175
|
PluginRegistry.dispatch(:on_error_logged, error_log)
|
|
119
|
-
# Trigger notification callbacks
|
|
120
176
|
trigger_callbacks(error_log)
|
|
121
|
-
|
|
177
|
+
emit_instrumentation_events(error_log)
|
|
178
|
+
elsif error_log.just_reopened
|
|
179
|
+
# Reopened error — notify if meets severity + not in cooldown
|
|
180
|
+
if Services::NotificationThrottler.should_notify?(error_log)
|
|
181
|
+
Services::ErrorNotificationDispatcher.call(error_log)
|
|
182
|
+
Services::NotificationThrottler.record_notification(error_log)
|
|
183
|
+
end
|
|
184
|
+
PluginRegistry.dispatch(:on_error_reopened, error_log)
|
|
185
|
+
trigger_callbacks(error_log)
|
|
122
186
|
emit_instrumentation_events(error_log)
|
|
123
187
|
else
|
|
124
|
-
#
|
|
188
|
+
# Recurring unresolved error — check threshold milestones
|
|
189
|
+
if Services::NotificationThrottler.threshold_reached?(error_log)
|
|
190
|
+
Services::ErrorNotificationDispatcher.call(error_log)
|
|
191
|
+
Services::NotificationThrottler.record_notification(error_log)
|
|
192
|
+
end
|
|
125
193
|
PluginRegistry.dispatch(:on_error_recurred, error_log)
|
|
126
194
|
end
|
|
127
195
|
|
|
@@ -226,6 +294,37 @@ module RailsErrorDashboard
|
|
|
226
294
|
RailsErrorDashboard::Logger.error("Failed to check baseline anomaly: #{e.message}")
|
|
227
295
|
end
|
|
228
296
|
|
|
297
|
+
# Add enriched request context fields if columns exist
|
|
298
|
+
def enrich_with_request_context(attributes, error_context)
|
|
299
|
+
column_names = ErrorLog.column_names
|
|
300
|
+
|
|
301
|
+
attributes[:http_method] = error_context.http_method if column_names.include?("http_method")
|
|
302
|
+
attributes[:hostname] = error_context.hostname if column_names.include?("hostname")
|
|
303
|
+
attributes[:content_type] = error_context.content_type if column_names.include?("content_type")
|
|
304
|
+
attributes[:request_duration_ms] = error_context.request_duration_ms if column_names.include?("request_duration_ms")
|
|
305
|
+
end
|
|
306
|
+
|
|
307
|
+
# Build cause chain JSON from pre-serialized async job context
|
|
308
|
+
# Used when exception was reconstructed and has no Ruby cause
|
|
309
|
+
def build_cause_json_from_context
|
|
310
|
+
serialized = @context[:_serialized_cause_chain]
|
|
311
|
+
return nil unless serialized.is_a?(Array) && serialized.any?
|
|
312
|
+
|
|
313
|
+
chain = serialized.map do |entry|
|
|
314
|
+
entry = entry.symbolize_keys if entry.respond_to?(:symbolize_keys)
|
|
315
|
+
{
|
|
316
|
+
class_name: entry[:class_name],
|
|
317
|
+
message: entry[:message]&.to_s&.slice(0, 1000),
|
|
318
|
+
backtrace: entry[:backtrace]&.first(20)
|
|
319
|
+
}
|
|
320
|
+
end
|
|
321
|
+
|
|
322
|
+
chain.to_json
|
|
323
|
+
rescue => e
|
|
324
|
+
RailsErrorDashboard::Logger.debug("[RailsErrorDashboard] Failed to build cause JSON from context: #{e.message}")
|
|
325
|
+
nil
|
|
326
|
+
end
|
|
327
|
+
|
|
229
328
|
# Detect git SHA from git command (fallback)
|
|
230
329
|
def detect_git_sha_from_command
|
|
231
330
|
return nil unless File.exist?(Rails.root.join(".git"))
|
|
@@ -52,6 +52,12 @@ module RailsErrorDashboard
|
|
|
52
52
|
# Exceptions to ignore (array of strings, regexes, or classes)
|
|
53
53
|
attr_accessor :ignored_exceptions
|
|
54
54
|
|
|
55
|
+
# Custom fingerprint lambda for error deduplication
|
|
56
|
+
# When set, overrides the default ErrorHashGenerator logic.
|
|
57
|
+
# Receives (exception, context) and must return a String.
|
|
58
|
+
# Example: ->(exception, context) { "#{exception.class.name}:#{context[:controller_name]}" }
|
|
59
|
+
attr_accessor :custom_fingerprint
|
|
60
|
+
|
|
55
61
|
# Sampling rate for non-critical errors (0.0 to 1.0, default 1.0 = 100%)
|
|
56
62
|
attr_accessor :sampling_rate
|
|
57
63
|
|
|
@@ -96,6 +102,18 @@ module RailsErrorDashboard
|
|
|
96
102
|
attr_accessor :only_show_app_code_source # Hide gems/stdlib (default: true)
|
|
97
103
|
attr_accessor :git_branch_strategy # :commit_sha, :current_branch, :main (default: :commit_sha)
|
|
98
104
|
|
|
105
|
+
# Sensitive data filtering (on by default)
|
|
106
|
+
# Redacts passwords, tokens, credit cards, SSNs, etc. before storage.
|
|
107
|
+
# Uses built-in defaults + Rails' filter_parameters + custom patterns.
|
|
108
|
+
# Set to false if you want raw data stored (you own your database).
|
|
109
|
+
attr_accessor :filter_sensitive_data
|
|
110
|
+
attr_accessor :sensitive_data_patterns # Additional patterns beyond Rails' filter_parameters
|
|
111
|
+
|
|
112
|
+
# Notification throttling (prevents alert fatigue)
|
|
113
|
+
attr_accessor :notification_minimum_severity # Minimum severity to notify (default: :low = notify all)
|
|
114
|
+
attr_accessor :notification_cooldown_minutes # Per-error cooldown in minutes (default: 5, 0 = disabled)
|
|
115
|
+
attr_accessor :notification_threshold_alerts # Occurrence milestones that trigger notification (default: [10, 50, 100, 500, 1000])
|
|
116
|
+
|
|
99
117
|
# Notification callbacks (managed via helper methods, not set directly)
|
|
100
118
|
attr_reader :notification_callbacks
|
|
101
119
|
|
|
@@ -146,6 +164,7 @@ module RailsErrorDashboard
|
|
|
146
164
|
# Advanced configuration defaults
|
|
147
165
|
@custom_severity_rules = {}
|
|
148
166
|
@ignored_exceptions = []
|
|
167
|
+
@custom_fingerprint = nil # Lambda: ->(exception, context) { "custom_key" }
|
|
149
168
|
@sampling_rate = 1.0 # 100% by default
|
|
150
169
|
@async_logging = false
|
|
151
170
|
@async_adapter = :sidekiq # Battle-tested default
|
|
@@ -183,6 +202,15 @@ module RailsErrorDashboard
|
|
|
183
202
|
@only_show_app_code_source = true # Hide gem/vendor code for security
|
|
184
203
|
@git_branch_strategy = :commit_sha # Use error's git_sha (most accurate)
|
|
185
204
|
|
|
205
|
+
# Sensitive data filtering defaults - ON by default (filters passwords, tokens, credit cards, etc.)
|
|
206
|
+
@filter_sensitive_data = true
|
|
207
|
+
@sensitive_data_patterns = []
|
|
208
|
+
|
|
209
|
+
# Notification throttling defaults
|
|
210
|
+
@notification_minimum_severity = :low # Notify on all severities (current behavior)
|
|
211
|
+
@notification_cooldown_minutes = 5 # 5 min cooldown per error_hash (0 = disabled)
|
|
212
|
+
@notification_threshold_alerts = [ 10, 50, 100, 500, 1000 ] # Occurrence milestones
|
|
213
|
+
|
|
186
214
|
# Internal logging defaults - SILENT by default
|
|
187
215
|
@enable_internal_logging = false # Opt-in for debugging
|
|
188
216
|
@log_level = :silent # Silent by default, use :debug, :info, :warn, :error, or :silent
|
|
@@ -255,6 +283,11 @@ module RailsErrorDashboard
|
|
|
255
283
|
end
|
|
256
284
|
end
|
|
257
285
|
|
|
286
|
+
# Validate custom_fingerprint (must respond to .call if set)
|
|
287
|
+
if custom_fingerprint && !custom_fingerprint.respond_to?(:call)
|
|
288
|
+
errors << "custom_fingerprint must respond to .call (lambda, proc, or object with .call method)"
|
|
289
|
+
end
|
|
290
|
+
|
|
258
291
|
# Validate notification dependencies
|
|
259
292
|
if enable_slack_notifications && (slack_webhook_url.nil? || slack_webhook_url.strip.empty?)
|
|
260
293
|
errors << "slack_webhook_url is required when enable_slack_notifications is true"
|
|
@@ -294,6 +327,25 @@ module RailsErrorDashboard
|
|
|
294
327
|
errors << "total_users_for_impact must be at least 1 (got: #{total_users_for_impact})"
|
|
295
328
|
end
|
|
296
329
|
|
|
330
|
+
# Validate notification_minimum_severity (must be valid symbol)
|
|
331
|
+
if notification_minimum_severity
|
|
332
|
+
valid_notification_severities = %i[critical high medium low]
|
|
333
|
+
unless valid_notification_severities.include?(notification_minimum_severity)
|
|
334
|
+
errors << "notification_minimum_severity must be one of #{valid_notification_severities.inspect} " \
|
|
335
|
+
"(got: #{notification_minimum_severity.inspect})"
|
|
336
|
+
end
|
|
337
|
+
end
|
|
338
|
+
|
|
339
|
+
# Validate notification_cooldown_minutes (must be non-negative if set)
|
|
340
|
+
if notification_cooldown_minutes && notification_cooldown_minutes < 0
|
|
341
|
+
errors << "notification_cooldown_minutes must be 0 or greater (got: #{notification_cooldown_minutes})"
|
|
342
|
+
end
|
|
343
|
+
|
|
344
|
+
# Validate notification_threshold_alerts (must be array of positive integers if set)
|
|
345
|
+
if notification_threshold_alerts && !notification_threshold_alerts.is_a?(Array)
|
|
346
|
+
errors << "notification_threshold_alerts must be an Array (got: #{notification_threshold_alerts.class})"
|
|
347
|
+
end
|
|
348
|
+
|
|
297
349
|
# Raise exception if any errors found
|
|
298
350
|
raise ConfigurationError, errors if errors.any?
|
|
299
351
|
|
|
@@ -136,6 +136,18 @@ module RailsErrorDashboard
|
|
|
136
136
|
@backtrace = backtrace
|
|
137
137
|
end
|
|
138
138
|
|
|
139
|
+
# SyntheticExceptions don't have real backtrace_locations (Ruby Thread::Backtrace::Location objects).
|
|
140
|
+
# LogError calls this for backtrace_signature calculation — returning nil is safe.
|
|
141
|
+
def backtrace_locations
|
|
142
|
+
nil
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
# SyntheticExceptions don't have a cause chain.
|
|
146
|
+
# LogError calls this for CauseChainExtractor — returning nil skips extraction.
|
|
147
|
+
def cause
|
|
148
|
+
nil
|
|
149
|
+
end
|
|
150
|
+
|
|
139
151
|
# Returns a mock class object that represents the error type
|
|
140
152
|
# @return [Object] A class-like object with the error type as its name
|
|
141
153
|
def class
|
|
@@ -20,6 +20,9 @@ module RailsErrorDashboard
|
|
|
20
20
|
end
|
|
21
21
|
|
|
22
22
|
def call(env)
|
|
23
|
+
# Record request start time for duration calculation
|
|
24
|
+
env["rails_error_dashboard.request_start"] = Time.now.to_f
|
|
25
|
+
|
|
23
26
|
@app.call(env)
|
|
24
27
|
rescue => exception
|
|
25
28
|
# Report to Rails.error (will be logged by our ErrorReporter)
|