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.
Files changed (57) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +20 -4
  3. data/app/controllers/rails_error_dashboard/application_controller.rb +2 -5
  4. data/app/controllers/rails_error_dashboard/errors_controller.rb +2 -3
  5. data/app/jobs/rails_error_dashboard/async_error_logging_job.rb +10 -0
  6. data/app/jobs/rails_error_dashboard/baseline_alert_job.rb +19 -15
  7. data/app/jobs/rails_error_dashboard/discord_error_notification_job.rb +19 -9
  8. data/app/jobs/rails_error_dashboard/pagerduty_error_notification_job.rb +37 -11
  9. data/app/jobs/rails_error_dashboard/retention_cleanup_job.rb +44 -0
  10. data/app/jobs/rails_error_dashboard/webhook_error_notification_job.rb +38 -16
  11. data/app/models/rails_error_dashboard/error_log.rb +10 -0
  12. data/app/models/rails_error_dashboard/error_logs_record.rb +11 -6
  13. data/app/views/layouts/rails_error_dashboard.html.erb +16 -0
  14. data/app/views/rails_error_dashboard/errors/_error_row.html.erb +3 -0
  15. data/app/views/rails_error_dashboard/errors/_stats.html.erb +12 -4
  16. data/app/views/rails_error_dashboard/errors/index.html.erb +9 -7
  17. data/app/views/rails_error_dashboard/errors/show.html.erb +138 -7
  18. data/db/migrate/20251223000000_create_rails_error_dashboard_complete_schema.rb +36 -0
  19. data/db/migrate/20251224081522_add_better_tracking_to_error_logs.rb +1 -1
  20. data/db/migrate/20251224101217_add_controller_action_to_error_logs.rb +1 -1
  21. data/db/migrate/20251225071314_add_optimized_indexes_to_error_logs.rb +1 -1
  22. data/db/migrate/20251225074653_remove_environment_from_error_logs.rb +1 -1
  23. data/db/migrate/20251225085859_add_enhanced_metrics_to_error_logs.rb +1 -1
  24. data/db/migrate/20251225093603_add_similarity_tracking_to_error_logs.rb +1 -1
  25. data/db/migrate/20251225100236_create_error_occurrences.rb +1 -1
  26. data/db/migrate/20251225101920_create_cascade_patterns.rb +1 -1
  27. data/db/migrate/20251225102500_create_error_baselines.rb +1 -1
  28. data/db/migrate/20251226020000_add_workflow_fields_to_error_logs.rb +1 -1
  29. data/db/migrate/20251226020100_create_error_comments.rb +1 -1
  30. data/db/migrate/20251230075315_cleanup_orphaned_migrations.rb +1 -1
  31. data/db/migrate/20260220000001_add_exception_cause_to_error_logs.rb +9 -0
  32. data/db/migrate/20260220000002_add_enriched_context_to_error_logs.rb +12 -0
  33. data/db/migrate/20260220000003_add_time_series_indexes_to_error_logs.rb +67 -0
  34. data/db/migrate/20260221000001_add_environment_info_to_error_logs.rb +9 -0
  35. data/db/migrate/20260221000002_add_reopened_at_to_error_logs.rb +9 -0
  36. data/lib/generators/rails_error_dashboard/install/install_generator.rb +145 -24
  37. data/lib/generators/rails_error_dashboard/install/templates/initializer.rb +12 -8
  38. data/lib/rails_error_dashboard/commands/find_or_increment_error.rb +58 -10
  39. data/lib/rails_error_dashboard/commands/log_error.rb +109 -10
  40. data/lib/rails_error_dashboard/configuration.rb +52 -0
  41. data/lib/rails_error_dashboard/manual_error_reporter.rb +12 -0
  42. data/lib/rails_error_dashboard/middleware/error_catcher.rb +3 -0
  43. data/lib/rails_error_dashboard/queries/dashboard_stats.rb +8 -0
  44. data/lib/rails_error_dashboard/queries/errors_list.rb +8 -0
  45. data/lib/rails_error_dashboard/services/backtrace_parser.rb +31 -0
  46. data/lib/rails_error_dashboard/services/backtrace_processor.rb +31 -1
  47. data/lib/rails_error_dashboard/services/cause_chain_extractor.rb +62 -0
  48. data/lib/rails_error_dashboard/services/environment_snapshot.rb +85 -0
  49. data/lib/rails_error_dashboard/services/error_hash_generator.rb +50 -2
  50. data/lib/rails_error_dashboard/services/notification_throttler.rb +109 -0
  51. data/lib/rails_error_dashboard/services/platform_detector.rb +36 -11
  52. data/lib/rails_error_dashboard/services/sensitive_data_filter.rb +176 -0
  53. data/lib/rails_error_dashboard/value_objects/error_context.rb +81 -4
  54. data/lib/rails_error_dashboard/version.rb +1 -1
  55. data/lib/rails_error_dashboard.rb +11 -6
  56. data/lib/tasks/error_dashboard.rake +158 -2
  57. 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
- @enable_separate_database = @selected_features&.dig(:separate_database) || options[:separate_database]
206
- @database_name = options[:database]
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 " Installation Complete!", :green
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
- if @database_name
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
- say " 1. Run: rails db:migrate"
324
- say " 2. Update credentials in config/initializers/rails_error_dashboard.rb"
325
- say " 3. Restart your Rails server"
326
- say " 4. Visit http://localhost:3000/error_dashboard"
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 "📖 Documentation:", :white
329
- say " Quick Start: docs/QUICKSTART.md", :white
330
- say " Complete Feature Guide: docs/FEATURES.md", :white
331
- say " All Docs: docs/README.md", :white
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 "⚙️ To enable/disable features later:", :white
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 will be stored in a dedicated database
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
- <% if @database_name -%>
158
- config.database = :<%= @database_name %>
159
- <% else -%>
160
- # config.database = :error_dashboard # Uncomment and set your database name
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 # Database name when using separate database
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
- # If an unresolved error with the same hash exists within 24 hours, increments
8
- # its occurrence count. Otherwise creates a new error record.
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 = find_existing
23
+ # Priority 1: Find unresolved match (existing behavior)
24
+ existing = find_unresolved
25
+ return increment_existing(existing) if existing
21
26
 
22
- if existing
23
- increment_existing(existing)
24
- else
25
- create_new_or_retry
26
- end
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 find_existing
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
- raise
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(truncated_backtrace)
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 only for new errors (not increments)
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
- Services::ErrorNotificationDispatcher.call(error_log)
117
- # Dispatch plugin event for new error
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
- # Emit ActiveSupport::Notifications instrumentation events
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
- # Dispatch plugin event for error recurrence
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)