rails_error_dashboard 0.14.4 → 0.15.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 (81) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +12 -5
  3. data/app/controllers/rails_error_dashboard/application_controller.rb +28 -4
  4. data/app/controllers/rails_error_dashboard/errors_controller.rb +68 -2
  5. data/app/controllers/rails_error_dashboard/health_controller.rb +38 -0
  6. data/app/helpers/rails_error_dashboard/application_helper.rb +2 -2
  7. data/app/helpers/rails_error_dashboard/backtrace_helper.rb +56 -2
  8. data/app/jobs/rails_error_dashboard/baseline_alert_job.rb +20 -2
  9. data/app/jobs/rails_error_dashboard/cascade_detection_job.rb +30 -0
  10. data/app/jobs/rails_error_dashboard/concerns/plain_channel_message.rb +33 -6
  11. data/app/jobs/rails_error_dashboard/discord_error_notification_job.rb +1 -1
  12. data/app/jobs/rails_error_dashboard/pagerduty_error_notification_job.rb +1 -1
  13. data/app/jobs/rails_error_dashboard/retention_cleanup_job.rb +26 -0
  14. data/app/jobs/rails_error_dashboard/scheduled_digest_job.rb +6 -1
  15. data/app/jobs/rails_error_dashboard/slack_error_notification_job.rb +1 -1
  16. data/app/jobs/rails_error_dashboard/telegram_error_notification_job.rb +25 -0
  17. data/app/jobs/rails_error_dashboard/webhook_error_notification_job.rb +2 -18
  18. data/app/models/rails_error_dashboard/error_log.rb +43 -12
  19. data/app/models/rails_error_dashboard/missing_translation.rb +43 -0
  20. data/app/views/layouts/rails_error_dashboard.html.erb +11 -1
  21. data/app/views/rails_error_dashboard/errors/_error_info.html.erb +3 -1
  22. data/app/views/rails_error_dashboard/errors/_sidebar_metadata.html.erb +3 -1
  23. data/app/views/rails_error_dashboard/errors/_source_code.html.erb +3 -9
  24. data/app/views/rails_error_dashboard/errors/activestorage_health_summary.html.erb +28 -0
  25. data/app/views/rails_error_dashboard/errors/boot_errors.html.erb +130 -0
  26. data/app/views/rails_error_dashboard/errors/index.html.erb +1 -1
  27. data/app/views/rails_error_dashboard/errors/missing_translations.html.erb +143 -0
  28. data/app/views/rails_error_dashboard/errors/settings/_value_badge.html.erb +7 -4
  29. data/app/views/rails_error_dashboard/errors/settings.html.erb +19 -5
  30. data/config/locales/de.yml +68 -5
  31. data/config/locales/en.yml +78 -4
  32. data/config/locales/es.yml +68 -5
  33. data/config/locales/fr.yml +68 -5
  34. data/config/locales/it.yml +68 -5
  35. data/config/locales/ja.yml +68 -4
  36. data/config/locales/pl.yml +68 -5
  37. data/config/locales/pt-BR.yml +68 -5
  38. data/config/locales/ru.yml +68 -5
  39. data/config/locales/uk.yml +68 -5
  40. data/config/locales/zh-CN.yml +68 -4
  41. data/config/routes.rb +6 -0
  42. data/db/migrate/20251223000000_create_rails_error_dashboard_complete_schema.rb +10 -2
  43. data/db/migrate/20261010000001_create_missing_translations.rb +50 -0
  44. data/lib/generators/rails_error_dashboard/install/install_generator.rb +9 -4
  45. data/lib/generators/rails_error_dashboard/install/templates/initializer.rb +27 -0
  46. data/lib/rails_error_dashboard/commands/assign_error.rb +24 -1
  47. data/lib/rails_error_dashboard/commands/batch_resolve_errors.rb +9 -1
  48. data/lib/rails_error_dashboard/commands/flush_missing_translations.rb +103 -0
  49. data/lib/rails_error_dashboard/commands/log_error.rb +1 -1
  50. data/lib/rails_error_dashboard/commands/resolve_error.rb +2 -15
  51. data/lib/rails_error_dashboard/commands/update_error_status.rb +7 -0
  52. data/lib/rails_error_dashboard/configuration.rb +42 -1
  53. data/lib/rails_error_dashboard/engine.rb +42 -1
  54. data/lib/rails_error_dashboard/middleware/rate_limiter.rb +7 -1
  55. data/lib/rails_error_dashboard/plugin.rb +19 -3
  56. data/lib/rails_error_dashboard/plugin_registry.rb +61 -12
  57. data/lib/rails_error_dashboard/queries/boot_errors.rb +76 -0
  58. data/lib/rails_error_dashboard/queries/dashboard_stats.rb +3 -11
  59. data/lib/rails_error_dashboard/queries/errors_list.rb +8 -16
  60. data/lib/rails_error_dashboard/queries/health_status.rb +191 -0
  61. data/lib/rails_error_dashboard/queries/missing_translation_summary.rb +82 -0
  62. data/lib/rails_error_dashboard/services/active_storage_probe.rb +91 -0
  63. data/lib/rails_error_dashboard/services/backtrace_parser.rb +13 -7
  64. data/lib/rails_error_dashboard/services/backtrace_processor.rb +15 -0
  65. data/lib/rails_error_dashboard/services/baseline_alert_payload_builder.rb +30 -0
  66. data/lib/rails_error_dashboard/services/crash_capture.rb +85 -2
  67. data/lib/rails_error_dashboard/services/digest_builder.rb +9 -6
  68. data/lib/rails_error_dashboard/services/error_notification_dispatcher.rb +6 -0
  69. data/lib/rails_error_dashboard/services/github_link_generator.rb +25 -9
  70. data/lib/rails_error_dashboard/services/missing_translation_handler.rb +152 -0
  71. data/lib/rails_error_dashboard/services/missing_translation_tracker.rb +316 -0
  72. data/lib/rails_error_dashboard/services/resolution_callbacks.rb +44 -0
  73. data/lib/rails_error_dashboard/services/severity_classifier.rb +52 -0
  74. data/lib/rails_error_dashboard/services/source_code_reader.rb +23 -3
  75. data/lib/rails_error_dashboard/services/telegram_delivery.rb +134 -0
  76. data/lib/rails_error_dashboard/services/telegram_payload_builder.rb +105 -0
  77. data/lib/rails_error_dashboard/services/webhook_delivery.rb +63 -0
  78. data/lib/rails_error_dashboard/services/webhook_signer.rb +80 -0
  79. data/lib/rails_error_dashboard/version.rb +1 -1
  80. data/lib/rails_error_dashboard.rb +12 -0
  81. metadata +21 -2
@@ -127,6 +127,21 @@ RailsErrorDashboard.configure do |config|
127
127
  config.enable_discord_notifications = false
128
128
  # config.discord_webhook_url = ENV["DISCORD_WEBHOOK_URL"]
129
129
 
130
+ <% end -%>
131
+ <% if @enable_telegram -%>
132
+ # Telegram Notifications - ENABLED
133
+ config.enable_telegram_notifications = true
134
+ config.telegram_bot_token = ENV["TELEGRAM_BOT_TOKEN"]
135
+ config.telegram_chat_id = ENV["TELEGRAM_CHAT_ID"]
136
+ # To disable: Set config.enable_telegram_notifications = false
137
+
138
+ <% else -%>
139
+ # Telegram Notifications - DISABLED
140
+ # To enable: Set config.enable_telegram_notifications = true and configure a bot token and chat id
141
+ config.enable_telegram_notifications = false
142
+ # config.telegram_bot_token = ENV["TELEGRAM_BOT_TOKEN"]
143
+ # config.telegram_chat_id = ENV["TELEGRAM_CHAT_ID"]
144
+
130
145
  <% end -%>
131
146
  <% if @enable_pagerduty -%>
132
147
  # PagerDuty Integration - ENABLED (critical errors only)
@@ -145,6 +160,9 @@ RailsErrorDashboard.configure do |config|
145
160
  # Generic Webhook Notifications - ENABLED
146
161
  config.enable_webhook_notifications = true
147
162
  config.webhook_urls = ENV.fetch("WEBHOOK_URLS", "").split(",").map(&:strip).reject(&:empty?)
163
+ # Sign every webhook (HMAC-SHA256 over timestamp + body) so receivers can
164
+ # verify it came from this app. Generate with: ruby -rsecurerandom -e 'puts SecureRandom.hex(32)'
165
+ # config.webhook_signing_secret = ENV["WEBHOOK_SIGNING_SECRET"]
148
166
  # To disable: Set config.enable_webhook_notifications = false
149
167
 
150
168
  <% else -%>
@@ -152,6 +170,7 @@ RailsErrorDashboard.configure do |config|
152
170
  # To enable: Set config.enable_webhook_notifications = true and configure webhook URLs
153
171
  config.enable_webhook_notifications = false
154
172
  # config.webhook_urls = ENV.fetch("WEBHOOK_URLS", "").split(",").map(&:strip).reject(&:empty?)
173
+ # config.webhook_signing_secret = ENV["WEBHOOK_SIGNING_SECRET"] # HMAC-SHA256 signature headers
155
174
 
156
175
  <% end -%>
157
176
  # Dashboard base URL (used in notification links)
@@ -408,6 +427,14 @@ RailsErrorDashboard.configure do |config|
408
427
  # config.swallowed_exception_threshold = 0.95
409
428
 
410
429
  <% end -%>
430
+ # Missing-Translation Tracking - DISABLED
431
+ # Counts every I18n key your app looked up and did not have, by locale, with
432
+ # the first call site, on its own dashboard page (/errors/missing_translations).
433
+ # Wraps I18n.exception_handler and delegates to the handler you already had —
434
+ # the one place RED touches global I18n state, which is why it is opt-in.
435
+ # To enable: Set config.enable_missing_translation_tracking = true
436
+ config.enable_missing_translation_tracking = false
437
+
411
438
  <% if @enable_diagnostic_dump -%>
412
439
  # Diagnostic Dump - ENABLED
413
440
  # On-demand system state snapshot via rake task or dashboard button
@@ -29,10 +29,33 @@ module RailsErrorDashboard
29
29
  error.update!(
30
30
  assigned_to: assignee.truncate(MAX_ASSIGNEE_LENGTH, omission: ""),
31
31
  assigned_at: Time.current,
32
- status: "in_progress" # Auto-transition to in_progress when assigned
32
+ **status_attributes(error)
33
33
  )
34
+ # The stat cards are cached; a reopened error must show up at once.
35
+ Services::AnalyticsCacheManager.clear if error.saved_change_to_resolved?
36
+
34
37
  { success: true, error: error }
35
38
  end
39
+
40
+ private
41
+
42
+ # Assigning means someone is working on it, so the error moves to
43
+ # in_progress. Doing that to a resolved error used to leave
44
+ # resolved: true and resolved_at behind: a row that was in_progress to
45
+ # the eye, resolved to the scopes, and matched by neither the unresolved
46
+ # nor the resolved lookup, so its next recurrence opened a new row.
47
+ # Assigning a resolved error reopens it. wont_fix is sticky (H-6): the
48
+ # assignee is recorded and the status stays.
49
+ def status_attributes(error)
50
+ return {} if error.status == "wont_fix"
51
+
52
+ attrs = { status: "in_progress" }
53
+ if error.resolved? || error.status == "resolved"
54
+ attrs[:resolved] = false
55
+ attrs[:resolved_at] = nil
56
+ end
57
+ attrs
58
+ end
36
59
  end
37
60
  end
38
61
  end
@@ -44,11 +44,19 @@ module RailsErrorDashboard
44
44
  end
45
45
  end
46
46
 
47
- # Dispatch plugin event for batch resolved errors
48
47
  Services::AnalyticsCacheManager.clear if resolved_count.positive?
49
48
 
49
+ # Dispatch plugin event for batch resolved errors
50
50
  PluginRegistry.dispatch(:on_errors_batch_resolved, resolved_errors) if resolved_errors.any?
51
51
 
52
+ # Each error resolved here is as resolved as one resolved on its own
53
+ # page: the host's error_resolved callbacks (the issue tracker closes
54
+ # the linked issue from one) and the instrumentation event fire per
55
+ # error. Only the plugin event is batch-shaped, by design.
56
+ resolved_errors.each do |error|
57
+ Services::ResolutionCallbacks.call(error, resolved_by: @resolved_by_name)
58
+ end
59
+
52
60
  {
53
61
  success: failed_ids.empty?,
54
62
  count: resolved_count,
@@ -0,0 +1,103 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsErrorDashboard
4
+ module Commands
5
+ # Command: upsert a snapshot of buffered missing-translation counts.
6
+ #
7
+ # Receives { "locale\x1Fkey" => [count, source] } from
8
+ # MissingTranslationTracker and adds each count to its (locale, key,
9
+ # application) row, creating the row on first sight. find_or_initialize_by
10
+ # + save! rather than a raw upsert, for cross-database compatibility; a
11
+ # concurrent first insert of the same key is retried once as a read.
12
+ #
13
+ # Runs off the request path (executor to_complete, at_exit). Never raises:
14
+ # a failed row is logged at debug and the rest of the snapshot still lands.
15
+ class FlushMissingTranslations
16
+ def self.call(counts:)
17
+ new(counts: counts).call
18
+ end
19
+
20
+ def initialize(counts:)
21
+ @counts = counts || {}
22
+ end
23
+
24
+ def call
25
+ return if @counts.empty?
26
+ return unless MissingTranslation.table_exists?
27
+
28
+ now = Time.current
29
+ app_id = current_application_id
30
+
31
+ @counts.each do |buffer_key, (count, source)|
32
+ # A key can be built from user input, so invalid bytes are possible.
33
+ locale, translation_key = Services::MissingTranslationTracker.parse_key(
34
+ Services::EncodingSanitizer.scrub(buffer_key.to_s)
35
+ )
36
+ next if locale.blank? || translation_key.blank? || count.to_i <= 0
37
+
38
+ upsert(locale: locale, translation_key: translation_key,
39
+ source: source, count: count.to_i, app_id: app_id, now: now)
40
+ end
41
+ rescue => e
42
+ RailsErrorDashboard::Logger.debug(
43
+ "[RailsErrorDashboard] FlushMissingTranslations failed: #{e.class} - #{e.message}"
44
+ )
45
+ end
46
+
47
+ private
48
+
49
+ # Insert on first sight; otherwise an atomic SQL increment. A
50
+ # read-modify-write (load miss_count, add, save) would lose increments
51
+ # when two threads flush the same hot key at once, and with per-thread
52
+ # buffers flushing every few seconds that is the common case, not a race
53
+ # worth ignoring.
54
+ def upsert(locale:, translation_key:, source:, count:, app_id:, now:)
55
+ identity = { locale: locale, translation_key: translation_key, application_id: app_id }
56
+ clean_source = Services::EncodingSanitizer.scrub(source.to_s).presence
57
+
58
+ record = MissingTranslation.find_by(identity)
59
+ if record
60
+ increment(record, count: count, source: clean_source, now: now)
61
+ else
62
+ begin
63
+ MissingTranslation.create!(identity.merge(
64
+ miss_count: count, source: clean_source, first_seen_at: now, last_seen_at: now
65
+ ))
66
+ rescue ActiveRecord::RecordNotUnique
67
+ # Another thread inserted the row between our read and our insert:
68
+ # it exists now, so add to it. A NULL application_id lets both
69
+ # inserts through on every database; the query merges those rows.
70
+ record = MissingTranslation.find_by(identity)
71
+ raise unless record
72
+
73
+ increment(record, count: count, source: clean_source, now: now)
74
+ end
75
+ end
76
+ rescue => e
77
+ RailsErrorDashboard::Logger.debug(
78
+ "[RailsErrorDashboard] FlushMissingTranslations.upsert failed for #{locale}.#{translation_key}: #{e.class} - #{e.message}"
79
+ )
80
+ end
81
+
82
+ def increment(record, count:, source:, now:)
83
+ updates = [ "miss_count = miss_count + ?, last_seen_at = ?", count, now ]
84
+ # First-write-wins: the first call site seen is the one worth showing,
85
+ # and overwriting it on every flush would make the column flicker
86
+ # between callers of the same key. Filled in only while still blank.
87
+ if record.source.blank? && source
88
+ updates = [ "miss_count = miss_count + ?, last_seen_at = ?, source = ?", count, now, source ]
89
+ end
90
+ MissingTranslation.where(id: record.id).update_all(updates)
91
+ end
92
+
93
+ def current_application_id
94
+ app_name = RailsErrorDashboard.configuration.application_name
95
+ return nil unless app_name.present?
96
+
97
+ Application.find_by(name: app_name)&.id
98
+ rescue => e
99
+ nil
100
+ end
101
+ end
102
+ end
103
+ end
@@ -433,7 +433,7 @@ module RailsErrorDashboard
433
433
  error_type: reported_error_type || @exception.class.name,
434
434
  message: @exception.message,
435
435
  backtrace: truncated_backtrace,
436
- user_id: error_context.user_id,
436
+ user_id: ErrorLog.storable_user_id(error_context.user_id),
437
437
  request_url: error_context.request_url,
438
438
  request_params: error_context.request_params,
439
439
  user_agent: error_context.user_agent,
@@ -31,21 +31,8 @@ module RailsErrorDashboard
31
31
  # Dispatch plugin event for resolved error
32
32
  PluginRegistry.dispatch(:on_error_resolved, error)
33
33
 
34
- # Trigger notification callbacks
35
- RailsErrorDashboard.configuration.notification_callbacks[:error_resolved].each do |callback|
36
- callback.call(error)
37
- rescue => e
38
- RailsErrorDashboard::Logger.error("Error in error_resolved callback: #{e.message}")
39
- end
40
-
41
- # Emit ActiveSupport::Notifications instrumentation event
42
- ActiveSupport::Notifications.instrument("error_resolved.rails_error_dashboard", {
43
- error_log: error,
44
- error_id: error.id,
45
- error_type: error.error_type,
46
- resolved_by: @resolution_data[:resolved_by_name],
47
- resolved_at: error.resolved_at
48
- })
34
+ # Host callbacks (issue-tracker close) and the instrumentation event.
35
+ Services::ResolutionCallbacks.call(error, resolved_by: @resolution_data[:resolved_by_name])
49
36
 
50
37
  error
51
38
  end
@@ -44,6 +44,13 @@ module RailsErrorDashboard
44
44
  # The stat cards are cached; a user action must show up at once.
45
45
  Services::AnalyticsCacheManager.clear
46
46
 
47
+ # Moving to "resolved" through the dropdown is a resolution like any
48
+ # other: the linked issue closes and subscribers hear about it.
49
+ if @status == "resolved"
50
+ PluginRegistry.dispatch(:on_error_resolved, error)
51
+ Services::ResolutionCallbacks.call(error, resolved_by: error.assigned_to)
52
+ end
53
+
47
54
  { success: true, error: error }
48
55
  end
49
56
 
@@ -13,7 +13,8 @@ module RailsErrorDashboard
13
13
  # #inspect) or pasting it into an issue doesn't leak them.
14
14
  SECRET_ATTRIBUTES = %i[
15
15
  dashboard_password slack_webhook_url discord_webhook_url pagerduty_integration_key
16
- webhook_urls issue_tracker_token issue_webhook_secret llm_api_key
16
+ webhook_urls webhook_signing_secret issue_tracker_token issue_webhook_secret llm_api_key
17
+ telegram_bot_token
17
18
  ].freeze
18
19
 
19
20
  # Dashboard authentication (always required)
@@ -46,6 +47,14 @@ module RailsErrorDashboard
46
47
  attr_accessor :discord_webhook_url
47
48
  attr_accessor :enable_discord_notifications
48
49
 
50
+ # Telegram (Bot API: a bot token from @BotFather and the chat, group or
51
+ # channel the bot posts to). telegram_api_base_url exists for a local Bot
52
+ # API server or a proxy; nearly every install leaves it alone.
53
+ attr_accessor :enable_telegram_notifications
54
+ attr_accessor :telegram_bot_token
55
+ attr_accessor :telegram_chat_id
56
+ attr_accessor :telegram_api_base_url
57
+
49
58
  # PagerDuty notifications (critical errors only)
50
59
  attr_accessor :pagerduty_integration_key
51
60
  attr_accessor :enable_pagerduty_notifications
@@ -53,6 +62,9 @@ module RailsErrorDashboard
53
62
  # Generic webhook notifications
54
63
  attr_accessor :webhook_urls
55
64
  attr_accessor :enable_webhook_notifications
65
+ # When set, every POST to webhook_urls carries an HMAC-SHA256 signature
66
+ # (X-Error-Dashboard-Signature-256) and a timestamp the receiver can verify.
67
+ attr_accessor :webhook_signing_secret
56
68
 
57
69
  # Scheduled digests (daily/weekly summary emails)
58
70
  attr_accessor :enable_scheduled_digests # Master switch (default: false)
@@ -231,6 +243,12 @@ module RailsErrorDashboard
231
243
  attr_accessor :rack_attack_max_cache_size # Max buffered keys per thread (default: 1000)
232
244
  attr_accessor :rack_attack_flush_interval # Seconds between DB flushes (default: 5)
233
245
 
246
+ # Missing-translation tracking — counts the HOST app's I18n misses by
247
+ # locale and key in their own table. Wraps I18n.exception_handler
248
+ # (delegating to the handler the app already had); the one place RED
249
+ # touches global I18n state, so it is opt-in.
250
+ attr_accessor :enable_missing_translation_tracking # Master switch (default: false)
251
+
234
252
  # ActionCable event tracking (requires enable_breadcrumbs = true)
235
253
  attr_accessor :enable_actioncable_tracking # Master switch (default: false)
236
254
  # ActiveStorage event tracking (requires enable_breadcrumbs = true)
@@ -309,6 +327,12 @@ module RailsErrorDashboard
309
327
  @discord_webhook_url = ENV["DISCORD_WEBHOOK_URL"]
310
328
  @enable_discord_notifications = false
311
329
 
330
+ # Telegram notification settings
331
+ @enable_telegram_notifications = false
332
+ @telegram_bot_token = ENV["TELEGRAM_BOT_TOKEN"]
333
+ @telegram_chat_id = ENV["TELEGRAM_CHAT_ID"]
334
+ @telegram_api_base_url = ENV.fetch("TELEGRAM_API_BASE_URL", "https://api.telegram.org")
335
+
312
336
  # PagerDuty notification settings (critical errors only)
313
337
  @pagerduty_integration_key = ENV["PAGERDUTY_INTEGRATION_KEY"]
314
338
  @enable_pagerduty_notifications = false
@@ -316,6 +340,10 @@ module RailsErrorDashboard
316
340
  # Generic webhook settings (array of URLs)
317
341
  @webhook_urls = ENV.fetch("WEBHOOK_URLS", "").split(",").map(&:strip).reject(&:empty?)
318
342
  @enable_webhook_notifications = false
343
+ # nil leaves outbound webhooks unsigned, as before. Secret-only (no
344
+ # separate "enable" flag): a secret with nothing to apply it to is the
345
+ # one combination that has no sensible meaning.
346
+ @webhook_signing_secret = ENV["WEBHOOK_SIGNING_SECRET"].presence
319
347
 
320
348
  # Scheduled digest defaults - OFF by default (opt-in)
321
349
  @enable_scheduled_digests = false
@@ -483,6 +511,10 @@ module RailsErrorDashboard
483
511
  # roughly one write per thread per interval.
484
512
  @rack_attack_flush_interval = 5 # Seconds between DB flushes
485
513
 
514
+ # Missing-translation tracking - OFF by default (opt-in). Persists to its
515
+ # own table; does NOT require breadcrumbs.
516
+ @enable_missing_translation_tracking = false
517
+
486
518
  # ActionCable event tracking defaults - OFF by default (opt-in, requires breadcrumbs)
487
519
  @enable_actioncable_tracking = false
488
520
  # ActiveStorage event tracking defaults - OFF by default (opt-in, requires breadcrumbs)
@@ -810,6 +842,15 @@ module RailsErrorDashboard
810
842
  errors << "discord_webhook_url is required when enable_discord_notifications is true"
811
843
  end
812
844
 
845
+ if enable_telegram_notifications
846
+ if telegram_bot_token.to_s.strip.empty?
847
+ errors << "telegram_bot_token is required when enable_telegram_notifications is true"
848
+ end
849
+ if telegram_chat_id.to_s.strip.empty?
850
+ errors << "telegram_chat_id is required when enable_telegram_notifications is true"
851
+ end
852
+ end
853
+
813
854
  if enable_pagerduty_notifications && (pagerduty_integration_key.nil? || pagerduty_integration_key.strip.empty?)
814
855
  errors << "pagerduty_integration_key is required when enable_pagerduty_notifications is true"
815
856
  end
@@ -48,6 +48,22 @@ module RailsErrorDashboard
48
48
  end
49
49
  end
50
50
 
51
+ # Register the crash-capture at_exit hook as soon as the host's
52
+ # config/initializers have run (so enable_crash_capture is known) and
53
+ # BEFORE Rails' :eager_load! finisher. Every engine initializer runs ahead
54
+ # of the finishers, so this is early enough for a Zeitwerk::NameError, a
55
+ # SyntaxError in a model or a raising initializer to be captured; the old
56
+ # place, config.after_initialize, runs AFTER eager loading and was never
57
+ # reached by exactly the crashes a boot check exists for. The import of
58
+ # earlier crash files stays in after_initialize, where the database is.
59
+ initializer "rails_error_dashboard.crash_capture", after: :load_config_initializers do
60
+ next if ENV["SECRET_KEY_BASE_DUMMY"].present?
61
+
62
+ if RailsErrorDashboard.configuration.enable_crash_capture
63
+ RailsErrorDashboard::Services::CrashCapture.enable!
64
+ end
65
+ end
66
+
51
67
  # Validate configuration after initialization
52
68
  initializer "rails_error_dashboard.validate_config", after: :load_config_initializers do
53
69
  config.after_initialize do
@@ -133,6 +149,28 @@ module RailsErrorDashboard
133
149
  at_exit { RailsErrorDashboard::Services::RackAttackTracker.flush_all_threads! }
134
150
  end
135
151
 
152
+ # Count the host app's missing translations. The handler wrap is the one
153
+ # place RED touches global I18n state, so it runs only when opted in,
154
+ # and here in after_initialize so the handler the host's own
155
+ # initializers configured is the one being wrapped (and delegated to).
156
+ #
157
+ # Same drain points as Rack Attack tracking: to_complete fires after the
158
+ # response body is closed (never delays a request, safety rule 2) and is
159
+ # interval-gated; at_exit drains whatever buffers are still on live
160
+ # threads. Puma joins its worker threads before at_exit runs, so what a
161
+ # worker held at SIGTERM (at most FLUSH_INTERVAL seconds of misses) is
162
+ # lost with it; the hook still covers the main thread, runners and
163
+ # console sessions. at_exit, not Signal.trap (safety rule 9).
164
+ if RailsErrorDashboard.configuration.enable_missing_translation_tracking
165
+ RailsErrorDashboard::Services::MissingTranslationHandler.install!
166
+
167
+ Rails.application.executor.to_complete do
168
+ RailsErrorDashboard::Services::MissingTranslationTracker.flush_if_due!
169
+ end
170
+
171
+ at_exit { RailsErrorDashboard::Services::MissingTranslationTracker.flush_all_threads! }
172
+ end
173
+
136
174
  # Drain the storm count buffer at the end of every request and job, and
137
175
  # again at process exit.
138
176
  #
@@ -224,7 +262,10 @@ module RailsErrorDashboard
224
262
  end
225
263
  end
226
264
 
227
- # Import crash files from previous process death, then register at_exit hook
265
+ # Import crash files from a previous process death. The at_exit hook
266
+ # itself is registered by the "rails_error_dashboard.crash_capture"
267
+ # initializer above, before eager loading; enable! here is a no-op when
268
+ # that ran and a fallback when the configuration was only set later.
228
269
  if RailsErrorDashboard.configuration.enable_crash_capture
229
270
  RailsErrorDashboard::Services::CrashCapture.import!
230
271
  RailsErrorDashboard::Services::CrashCapture.enable!
@@ -55,8 +55,14 @@ module RailsErrorDashboard
55
55
  "/red"
56
56
  end
57
57
 
58
+ # The mount itself or anything below it. A plain start_with? also
59
+ # matched the host's own /redirect when the engine was at /red. An
60
+ # engine mounted at "/" owns every path.
58
61
  def error_dashboard_route?(path)
59
- path.start_with?(engine_mount_path)
62
+ mount = engine_mount_path.to_s.chomp("/")
63
+ return true if mount.empty?
64
+
65
+ path == mount || path.start_with?("#{mount}/")
60
66
  end
61
67
 
62
68
  def rate_limit_key(request, limit_config)
@@ -121,11 +121,27 @@ module RailsErrorDashboard
121
121
 
122
122
  send(method_name, *args)
123
123
  rescue => e
124
- # Log plugin failures but never propagate - plugins must not break the app
125
- RailsErrorDashboard::Logger.error("[RailsErrorDashboard] Plugin '#{name}' failed in #{method_name}: #{e.class} - #{e.message}")
126
- RailsErrorDashboard::Logger.error("Plugin version: #{version}")
124
+ # Log plugin failures but never propagate - plugins must not break the app.
125
+ # name and version are plugin code too, so the log line must not depend
126
+ # on them answering.
127
+ RailsErrorDashboard::Logger.error("[RailsErrorDashboard] Plugin '#{safe_label}' failed in #{method_name}: #{e.class} - #{e.message}")
128
+ RailsErrorDashboard::Logger.error("Plugin version: #{safe_version}")
127
129
  RailsErrorDashboard::Logger.error(e.backtrace&.first(10)&.join("\n")) if e.backtrace
128
130
  nil # Explicitly return nil, never raise
129
131
  end
132
+
133
+ private
134
+
135
+ def safe_label
136
+ name.to_s
137
+ rescue StandardError, NotImplementedError
138
+ self.class.name
139
+ end
140
+
141
+ def safe_version
142
+ version.to_s
143
+ rescue StandardError, NotImplementedError
144
+ "unknown"
145
+ end
130
146
  end
131
147
  end
@@ -3,6 +3,13 @@
3
3
  module RailsErrorDashboard
4
4
  # Registry for managing plugins
5
5
  # Provides plugin registration and event dispatching
6
+ #
7
+ # A plugin is host code running inside RED's own paths, so nothing it does
8
+ # may break them: a `name` that raises (the base class raises
9
+ # NotImplementedError, a ScriptError that `rescue => e` does not catch), an
10
+ # `on_register` that raises, or an `enabled?` that raises is logged and
11
+ # treated as "no", never propagated. A plugin is in the list only once
12
+ # registration has fully succeeded.
6
13
  class PluginRegistry
7
14
  class << self
8
15
  # Get all registered plugins
@@ -12,26 +19,41 @@ module RailsErrorDashboard
12
19
 
13
20
  # Register a plugin
14
21
  # @param plugin [Plugin] The plugin instance to register
22
+ # @return [Boolean] true when registered, false when skipped
23
+ # @raise [ArgumentError] not a Plugin, or no usable name
15
24
  def register(plugin)
16
25
  unless plugin.is_a?(Plugin)
17
26
  raise ArgumentError, "Plugin must be an instance of RailsErrorDashboard::Plugin"
18
27
  end
19
28
 
20
- if plugins.any? { |p| p.name == plugin.name }
21
- RailsErrorDashboard::Logger.warn("Plugin '#{plugin.name}' is already registered, skipping")
29
+ name = name_of(plugin)
30
+ if name.blank?
31
+ raise ArgumentError, "Plugin #{plugin.class} must implement #name and return a non-blank String"
32
+ end
33
+
34
+ if plugins.any? { |p| name_of(p) == name }
35
+ RailsErrorDashboard::Logger.warn("Plugin '#{name}' is already registered, skipping")
36
+ return false
37
+ end
38
+
39
+ begin
40
+ plugin.on_register
41
+ rescue => e
42
+ RailsErrorDashboard::Logger.error(
43
+ "[RailsErrorDashboard] Plugin '#{name}' failed in on_register: #{e.class} - #{e.message}; not registered"
44
+ )
22
45
  return false
23
46
  end
24
47
 
25
48
  plugins << plugin
26
- plugin.on_register
27
- RailsErrorDashboard::Logger.info("Registered plugin: #{plugin.name} (#{plugin.version})")
49
+ RailsErrorDashboard::Logger.info("Registered plugin: #{name} (#{version_of(plugin)})")
28
50
  true
29
51
  end
30
52
 
31
53
  # Unregister a plugin by name
32
54
  # @param plugin_name [String] The name of the plugin to unregister
33
55
  def unregister(plugin_name)
34
- plugins.reject! { |p| p.name == plugin_name }
56
+ plugins.reject! { |p| name_of(p) == plugin_name }
35
57
  end
36
58
 
37
59
  # Clear all plugins (useful for testing)
@@ -43,7 +65,7 @@ module RailsErrorDashboard
43
65
  # @param plugin_name [String] The name of the plugin
44
66
  # @return [Plugin, nil] The plugin instance or nil if not found
45
67
  def find(plugin_name)
46
- plugins.find { |p| p.name == plugin_name }
68
+ plugins.find { |p| name_of(p) == plugin_name }
47
69
  end
48
70
 
49
71
  # Dispatch an event to all registered plugins
@@ -51,7 +73,7 @@ module RailsErrorDashboard
51
73
  # @param args [Array] Arguments to pass to the event handler
52
74
  def dispatch(event_name, *args)
53
75
  plugins.each do |plugin|
54
- next unless plugin.enabled?
76
+ next unless enabled?(plugin)
55
77
 
56
78
  plugin.safe_execute(event_name, *args)
57
79
  end
@@ -69,20 +91,47 @@ module RailsErrorDashboard
69
91
 
70
92
  # Get list of plugin names
71
93
  def names
72
- plugins.map(&:name)
94
+ plugins.map { |p| name_of(p) }
73
95
  end
74
96
 
75
97
  # Get plugin information for debugging
76
98
  def info
77
99
  plugins.map do |plugin|
78
100
  {
79
- name: plugin.name,
80
- version: plugin.version,
81
- description: plugin.description,
82
- enabled: plugin.enabled?
101
+ name: name_of(plugin),
102
+ version: version_of(plugin),
103
+ description: (plugin.description rescue nil),
104
+ enabled: enabled?(plugin)
83
105
  }
84
106
  end
85
107
  end
108
+
109
+ private
110
+
111
+ # A plugin's name, or nil when #name is missing or raises.
112
+ def name_of(plugin)
113
+ value = plugin.name
114
+ value.is_a?(String) ? value : value&.to_s
115
+ rescue StandardError, NotImplementedError
116
+ nil
117
+ end
118
+
119
+ def version_of(plugin)
120
+ plugin.version.to_s
121
+ rescue StandardError, NotImplementedError
122
+ "unknown"
123
+ end
124
+
125
+ # enabled? is host code too. A raise is logged once per dispatch and
126
+ # read as disabled, so the other plugins still run.
127
+ def enabled?(plugin)
128
+ plugin.enabled? ? true : false
129
+ rescue => e
130
+ RailsErrorDashboard::Logger.error(
131
+ "[RailsErrorDashboard] Plugin '#{name_of(plugin) || plugin.class}' failed in enabled?: #{e.class} - #{e.message}; treated as disabled"
132
+ )
133
+ false
134
+ end
86
135
  end
87
136
  end
88
137
  end