rails_error_dashboard 0.1.38 → 0.2.1
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 +21 -4
- data/app/controllers/rails_error_dashboard/errors_controller.rb +1 -0
- 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 +167 -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 +99 -43
- data/app/views/rails_error_dashboard/errors/show.html.erb +143 -12
- 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 +69 -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 -5
- data/lib/tasks/error_dashboard.rake +158 -2
- metadata +12 -58
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
class CreateErrorComments < ActiveRecord::Migration[
|
|
3
|
+
class CreateErrorComments < ActiveRecord::Migration[7.0]
|
|
4
4
|
def change
|
|
5
5
|
# Skip if squashed migration already created this table
|
|
6
6
|
return if table_exists?(:rails_error_dashboard_error_comments)
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
class AddExceptionCauseToErrorLogs < ActiveRecord::Migration[7.0]
|
|
4
|
+
def change
|
|
5
|
+
unless column_exists?(:rails_error_dashboard_error_logs, :exception_cause)
|
|
6
|
+
add_column :rails_error_dashboard_error_logs, :exception_cause, :text
|
|
7
|
+
end
|
|
8
|
+
end
|
|
9
|
+
end
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
class AddEnrichedContextToErrorLogs < ActiveRecord::Migration[7.0]
|
|
4
|
+
def change
|
|
5
|
+
unless column_exists?(:rails_error_dashboard_error_logs, :http_method)
|
|
6
|
+
add_column :rails_error_dashboard_error_logs, :http_method, :string, limit: 10
|
|
7
|
+
add_column :rails_error_dashboard_error_logs, :hostname, :string, limit: 255
|
|
8
|
+
add_column :rails_error_dashboard_error_logs, :content_type, :string, limit: 100
|
|
9
|
+
add_column :rails_error_dashboard_error_logs, :request_duration_ms, :integer
|
|
10
|
+
end
|
|
11
|
+
end
|
|
12
|
+
end
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Tier 0 time-series optimization: BRIN and functional indexes
|
|
4
|
+
#
|
|
5
|
+
# BRIN (Block Range Index) on occurred_at:
|
|
6
|
+
# - 99.9% smaller than B-tree (72KB vs 676MB on 100M rows)
|
|
7
|
+
# - Nearly identical query performance for time-range scans
|
|
8
|
+
# - Perfect for INSERT-heavy tables with naturally ordered timestamps
|
|
9
|
+
#
|
|
10
|
+
# Functional indexes for Groupdate:
|
|
11
|
+
# - Pre-compute DATE_TRUNC expressions used by group_by_day/group_by_hour
|
|
12
|
+
# - Up to 70x speedup on analytics dashboard queries
|
|
13
|
+
#
|
|
14
|
+
# All PostgreSQL-specific — gracefully skipped on SQLite/MySQL.
|
|
15
|
+
class AddTimeSeriesIndexesToErrorLogs < ActiveRecord::Migration[7.0]
|
|
16
|
+
disable_ddl_transaction!
|
|
17
|
+
|
|
18
|
+
def up
|
|
19
|
+
return unless postgresql?
|
|
20
|
+
|
|
21
|
+
# BRIN index on occurred_at for time-range scans
|
|
22
|
+
# Replaces expensive B-tree sequential scans on large tables
|
|
23
|
+
unless index_exists?(:rails_error_dashboard_error_logs, :occurred_at, name: "index_error_logs_on_occurred_at_brin")
|
|
24
|
+
execute <<-SQL
|
|
25
|
+
CREATE INDEX CONCURRENTLY index_error_logs_on_occurred_at_brin
|
|
26
|
+
ON rails_error_dashboard_error_logs
|
|
27
|
+
USING brin (occurred_at)
|
|
28
|
+
SQL
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Functional index for daily grouping (used by group_by_day)
|
|
32
|
+
unless index_exists_by_name?("index_error_logs_on_occurred_at_day")
|
|
33
|
+
execute <<-SQL
|
|
34
|
+
CREATE INDEX CONCURRENTLY index_error_logs_on_occurred_at_day
|
|
35
|
+
ON rails_error_dashboard_error_logs
|
|
36
|
+
(DATE_TRUNC('day', occurred_at))
|
|
37
|
+
SQL
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Functional index for hourly grouping (used by group_by_hour)
|
|
41
|
+
unless index_exists_by_name?("index_error_logs_on_occurred_at_hour")
|
|
42
|
+
execute <<-SQL
|
|
43
|
+
CREATE INDEX CONCURRENTLY index_error_logs_on_occurred_at_hour
|
|
44
|
+
ON rails_error_dashboard_error_logs
|
|
45
|
+
(DATE_TRUNC('hour', occurred_at))
|
|
46
|
+
SQL
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def down
|
|
51
|
+
return unless postgresql?
|
|
52
|
+
|
|
53
|
+
execute "DROP INDEX IF EXISTS index_error_logs_on_occurred_at_brin"
|
|
54
|
+
execute "DROP INDEX IF EXISTS index_error_logs_on_occurred_at_day"
|
|
55
|
+
execute "DROP INDEX IF EXISTS index_error_logs_on_occurred_at_hour"
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
private
|
|
59
|
+
|
|
60
|
+
def postgresql?
|
|
61
|
+
ActiveRecord::Base.connection.adapter_name.downcase == "postgresql"
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def index_exists_by_name?(name)
|
|
65
|
+
ActiveRecord::Base.connection.execute(
|
|
66
|
+
"SELECT 1 FROM pg_indexes WHERE indexname = '#{name}'"
|
|
67
|
+
).any?
|
|
68
|
+
end
|
|
69
|
+
end
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
class AddEnvironmentInfoToErrorLogs < ActiveRecord::Migration[7.0]
|
|
4
|
+
def change
|
|
5
|
+
unless column_exists?(:rails_error_dashboard_error_logs, :environment_info)
|
|
6
|
+
add_column :rails_error_dashboard_error_logs, :environment_info, :text
|
|
7
|
+
end
|
|
8
|
+
end
|
|
9
|
+
end
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
class AddReopenedAtToErrorLogs < ActiveRecord::Migration[7.0]
|
|
4
|
+
def change
|
|
5
|
+
unless column_exists?(:rails_error_dashboard_error_logs, :reopened_at)
|
|
6
|
+
add_column :rails_error_dashboard_error_logs, :reopened_at, :datetime
|
|
7
|
+
end
|
|
8
|
+
end
|
|
9
|
+
end
|
|
@@ -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"))
|