rails_error_dashboard 0.14.4 → 0.16.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 (113) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +19 -8
  3. data/app/controllers/rails_error_dashboard/application_controller.rb +28 -4
  4. data/app/controllers/rails_error_dashboard/errors_controller.rb +97 -4
  5. data/app/controllers/rails_error_dashboard/health_controller.rb +38 -0
  6. data/app/controllers/rails_error_dashboard/webhooks_controller.rb +10 -9
  7. data/app/helpers/rails_error_dashboard/application_helper.rb +2 -2
  8. data/app/helpers/rails_error_dashboard/backtrace_helper.rb +63 -16
  9. data/app/helpers/rails_error_dashboard/overview_helper.rb +4 -0
  10. data/app/jobs/rails_error_dashboard/baseline_alert_job.rb +20 -2
  11. data/app/jobs/rails_error_dashboard/cascade_detection_job.rb +30 -0
  12. data/app/jobs/rails_error_dashboard/concerns/plain_channel_message.rb +33 -6
  13. data/app/jobs/rails_error_dashboard/discord_error_notification_job.rb +1 -1
  14. data/app/jobs/rails_error_dashboard/pagerduty_error_notification_job.rb +1 -1
  15. data/app/jobs/rails_error_dashboard/retention_cleanup_job.rb +31 -0
  16. data/app/jobs/rails_error_dashboard/scheduled_digest_job.rb +6 -1
  17. data/app/jobs/rails_error_dashboard/slack_error_notification_job.rb +1 -1
  18. data/app/jobs/rails_error_dashboard/telegram_error_notification_job.rb +25 -0
  19. data/app/jobs/rails_error_dashboard/webhook_error_notification_job.rb +2 -18
  20. data/app/models/rails_error_dashboard/error_log.rb +54 -20
  21. data/app/models/rails_error_dashboard/error_occurrence.rb +1 -0
  22. data/app/models/rails_error_dashboard/error_occurrence_context.rb +14 -0
  23. data/app/models/rails_error_dashboard/missing_translation.rb +43 -0
  24. data/app/views/layouts/rails_error_dashboard.html.erb +14 -2
  25. data/app/views/rails_error_dashboard/errors/_captured_occurrences.html.erb +45 -0
  26. data/app/views/rails_error_dashboard/errors/_error_info.html.erb +12 -3
  27. data/app/views/rails_error_dashboard/errors/_occurrence_banner.html.erb +12 -0
  28. data/app/views/rails_error_dashboard/errors/_sidebar_metadata.html.erb +3 -1
  29. data/app/views/rails_error_dashboard/errors/_source_code.html.erb +3 -9
  30. data/app/views/rails_error_dashboard/errors/activestorage_health_summary.html.erb +28 -0
  31. data/app/views/rails_error_dashboard/errors/boot_errors.html.erb +130 -0
  32. data/app/views/rails_error_dashboard/errors/index.html.erb +1 -1
  33. data/app/views/rails_error_dashboard/errors/missing_translations.html.erb +143 -0
  34. data/app/views/rails_error_dashboard/errors/overview.html.erb +10 -7
  35. data/app/views/rails_error_dashboard/errors/platform_comparison.html.erb +16 -11
  36. data/app/views/rails_error_dashboard/errors/settings/_value_badge.html.erb +7 -4
  37. data/app/views/rails_error_dashboard/errors/settings.html.erb +23 -8
  38. data/app/views/rails_error_dashboard/errors/show.html.erb +7 -4
  39. data/config/locales/de.yml +87 -5
  40. data/config/locales/en.yml +99 -4
  41. data/config/locales/es.yml +88 -5
  42. data/config/locales/fr.yml +88 -5
  43. data/config/locales/it.yml +88 -5
  44. data/config/locales/ja.yml +86 -4
  45. data/config/locales/pl.yml +89 -5
  46. data/config/locales/pt-BR.yml +88 -5
  47. data/config/locales/ru.yml +89 -5
  48. data/config/locales/uk.yml +89 -5
  49. data/config/locales/zh-CN.yml +86 -4
  50. data/config/routes.rb +6 -0
  51. data/db/migrate/20251223000000_create_rails_error_dashboard_complete_schema.rb +10 -2
  52. data/db/migrate/20260106094256_backfill_application_for_existing_errors.rb +3 -3
  53. data/db/migrate/20261010000001_create_missing_translations.rb +50 -0
  54. data/db/migrate/20261015000001_create_rails_error_dashboard_error_occurrence_contexts.rb +35 -0
  55. data/db/migrate/20261015000002_add_capture_source_to_error_occurrences_and_error_logs.rb +46 -0
  56. data/lib/generators/rails_error_dashboard/install/install_generator.rb +9 -4
  57. data/lib/generators/rails_error_dashboard/install/templates/initializer.rb +41 -5
  58. data/lib/rails_error_dashboard/commands/assign_error.rb +24 -1
  59. data/lib/rails_error_dashboard/commands/batch_mute_errors.rb +11 -2
  60. data/lib/rails_error_dashboard/commands/batch_resolve_errors.rb +16 -3
  61. data/lib/rails_error_dashboard/commands/batch_unmute_errors.rb +7 -2
  62. data/lib/rails_error_dashboard/commands/find_or_increment_error.rb +6 -0
  63. data/lib/rails_error_dashboard/commands/flush_missing_translations.rb +101 -0
  64. data/lib/rails_error_dashboard/commands/flush_rack_attack_events.rb +2 -4
  65. data/lib/rails_error_dashboard/commands/flush_storm_counts.rb +7 -6
  66. data/lib/rails_error_dashboard/commands/flush_swallowed_exceptions.rb +2 -4
  67. data/lib/rails_error_dashboard/commands/log_error.rb +20 -8
  68. data/lib/rails_error_dashboard/commands/record_occurrence_context.rb +60 -0
  69. data/lib/rails_error_dashboard/commands/resolve_error.rb +2 -15
  70. data/lib/rails_error_dashboard/commands/update_error_status.rb +7 -0
  71. data/lib/rails_error_dashboard/configuration.rb +61 -4
  72. data/lib/rails_error_dashboard/engine.rb +42 -1
  73. data/lib/rails_error_dashboard/error_reporter.rb +4 -2
  74. data/lib/rails_error_dashboard/manual_error_reporter.rb +11 -1
  75. data/lib/rails_error_dashboard/middleware/error_catcher.rb +18 -4
  76. data/lib/rails_error_dashboard/middleware/rate_limiter.rb +7 -1
  77. data/lib/rails_error_dashboard/plugin.rb +19 -3
  78. data/lib/rails_error_dashboard/plugin_registry.rb +61 -12
  79. data/lib/rails_error_dashboard/queries/analytics_stats.rb +0 -12
  80. data/lib/rails_error_dashboard/queries/boot_errors.rb +76 -0
  81. data/lib/rails_error_dashboard/queries/co_occurring_errors.rb +59 -33
  82. data/lib/rails_error_dashboard/queries/dashboard_stats.rb +3 -11
  83. data/lib/rails_error_dashboard/queries/errors_list.rb +8 -16
  84. data/lib/rails_error_dashboard/queries/health_status.rb +191 -0
  85. data/lib/rails_error_dashboard/queries/missing_translation_summary.rb +82 -0
  86. data/lib/rails_error_dashboard/queries/platform_comparison.rb +14 -6
  87. data/lib/rails_error_dashboard/services/active_storage_probe.rb +91 -0
  88. data/lib/rails_error_dashboard/services/backtrace_parser.rb +13 -7
  89. data/lib/rails_error_dashboard/services/backtrace_processor.rb +31 -7
  90. data/lib/rails_error_dashboard/services/baseline_alert_payload_builder.rb +30 -0
  91. data/lib/rails_error_dashboard/services/crash_capture.rb +90 -2
  92. data/lib/rails_error_dashboard/services/digest_builder.rb +9 -6
  93. data/lib/rails_error_dashboard/services/error_notification_dispatcher.rb +6 -0
  94. data/lib/rails_error_dashboard/services/github_link_generator.rb +25 -14
  95. data/lib/rails_error_dashboard/services/markdown_error_formatter.rb +15 -16
  96. data/lib/rails_error_dashboard/services/missing_translation_handler.rb +152 -0
  97. data/lib/rails_error_dashboard/services/missing_translation_tracker.rb +316 -0
  98. data/lib/rails_error_dashboard/services/repository_url.rb +18 -0
  99. data/lib/rails_error_dashboard/services/resolution_callbacks.rb +44 -0
  100. data/lib/rails_error_dashboard/services/severity_classifier.rb +52 -0
  101. data/lib/rails_error_dashboard/services/similarity_calculator.rb +3 -9
  102. data/lib/rails_error_dashboard/services/source_code_reader.rb +23 -3
  103. data/lib/rails_error_dashboard/services/storm_protection/count_buffer.rb +6 -3
  104. data/lib/rails_error_dashboard/services/storm_protection/gate.rb +6 -1
  105. data/lib/rails_error_dashboard/services/telegram_delivery.rb +134 -0
  106. data/lib/rails_error_dashboard/services/telegram_payload_builder.rb +105 -0
  107. data/lib/rails_error_dashboard/services/webhook_delivery.rb +63 -0
  108. data/lib/rails_error_dashboard/services/webhook_signer.rb +80 -0
  109. data/lib/rails_error_dashboard/value_objects/occurrence_snapshot.rb +23 -0
  110. data/lib/rails_error_dashboard/version.rb +1 -1
  111. data/lib/rails_error_dashboard.rb +28 -0
  112. data/lib/tasks/error_dashboard.rake +2 -3
  113. metadata +29 -2
@@ -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)
@@ -206,6 +218,7 @@ module RailsErrorDashboard
206
218
  # Instance variable capture from tp.self (receiver object at raise time)
207
219
  attr_accessor :enable_instance_variables # Master switch (default: false)
208
220
  attr_accessor :instance_variable_max_count # Max ivars to capture (default: 20)
221
+ attr_accessor :occurrence_context_limit # Occurrences per error whose context is kept (default: 25; 0 = off)
209
222
  attr_accessor :instance_variable_filter_patterns # Additional sensitive ivar patterns (default: [])
210
223
 
211
224
  # Swallowed exception detection via TracePoint(:raise) + TracePoint(:rescue) (Ruby 3.3+ only)
@@ -231,6 +244,12 @@ module RailsErrorDashboard
231
244
  attr_accessor :rack_attack_max_cache_size # Max buffered keys per thread (default: 1000)
232
245
  attr_accessor :rack_attack_flush_interval # Seconds between DB flushes (default: 5)
233
246
 
247
+ # Missing-translation tracking — counts the HOST app's I18n misses by
248
+ # locale and key in their own table. Wraps I18n.exception_handler
249
+ # (delegating to the handler the app already had); the one place RED
250
+ # touches global I18n state, so it is opt-in.
251
+ attr_accessor :enable_missing_translation_tracking # Master switch (default: false)
252
+
234
253
  # ActionCable event tracking (requires enable_breadcrumbs = true)
235
254
  attr_accessor :enable_actioncable_tracking # Master switch (default: false)
236
255
  # ActiveStorage event tracking (requires enable_breadcrumbs = true)
@@ -309,6 +328,12 @@ module RailsErrorDashboard
309
328
  @discord_webhook_url = ENV["DISCORD_WEBHOOK_URL"]
310
329
  @enable_discord_notifications = false
311
330
 
331
+ # Telegram notification settings
332
+ @enable_telegram_notifications = false
333
+ @telegram_bot_token = ENV["TELEGRAM_BOT_TOKEN"]
334
+ @telegram_chat_id = ENV["TELEGRAM_CHAT_ID"]
335
+ @telegram_api_base_url = ENV.fetch("TELEGRAM_API_BASE_URL", "https://api.telegram.org")
336
+
312
337
  # PagerDuty notification settings (critical errors only)
313
338
  @pagerduty_integration_key = ENV["PAGERDUTY_INTEGRATION_KEY"]
314
339
  @enable_pagerduty_notifications = false
@@ -316,6 +341,10 @@ module RailsErrorDashboard
316
341
  # Generic webhook settings (array of URLs)
317
342
  @webhook_urls = ENV.fetch("WEBHOOK_URLS", "").split(",").map(&:strip).reject(&:empty?)
318
343
  @enable_webhook_notifications = false
344
+ # nil leaves outbound webhooks unsigned, as before. Secret-only (no
345
+ # separate "enable" flag): a secret with nothing to apply it to is the
346
+ # one combination that has no sensible meaning.
347
+ @webhook_signing_secret = ENV["WEBHOOK_SIGNING_SECRET"].presence
319
348
 
320
349
  # Scheduled digest defaults - OFF by default (opt-in)
321
350
  @enable_scheduled_digests = false
@@ -453,6 +482,9 @@ module RailsErrorDashboard
453
482
  # Instance variable capture defaults - OFF by default (opt-in)
454
483
  @enable_instance_variables = false # Capture ivars from tp.self at raise time
455
484
  @instance_variable_max_count = 20 # Max ivars per exception
485
+ # Keep the moment-of-failure context of the newest 25 occurrences of each
486
+ # error, not only the latest. 0 keeps none.
487
+ @occurrence_context_limit = 25
456
488
  @instance_variable_filter_patterns = [] # Additional sensitive ivar name patterns
457
489
 
458
490
  # Swallowed exception detection defaults - OFF by default (Ruby 3.3+ opt-in)
@@ -483,6 +515,10 @@ module RailsErrorDashboard
483
515
  # roughly one write per thread per interval.
484
516
  @rack_attack_flush_interval = 5 # Seconds between DB flushes
485
517
 
518
+ # Missing-translation tracking - OFF by default (opt-in). Persists to its
519
+ # own table; does NOT require breadcrumbs.
520
+ @enable_missing_translation_tracking = false
521
+
486
522
  # ActionCable event tracking defaults - OFF by default (opt-in, requires breadcrumbs)
487
523
  @enable_actioncable_tracking = false
488
524
  # ActiveStorage event tracking defaults - OFF by default (opt-in, requires breadcrumbs)
@@ -642,6 +678,13 @@ module RailsErrorDashboard
642
678
  end
643
679
  end
644
680
 
681
+ # Any other git_branch_strategy links to main. A warning, not an error:
682
+ # a link format is not worth stopping a boot.
683
+ if git_branch_strategy && !%w[commit_sha current_branch main].include?(git_branch_strategy.to_s)
684
+ warnings << "git_branch_strategy must be one of :commit_sha, :current_branch or :main " \
685
+ "(got: #{git_branch_strategy.inspect}). Repository links will point at main."
686
+ end
687
+
645
688
  # Validate custom_fingerprint (must respond to .call if set)
646
689
  if custom_fingerprint && !custom_fingerprint.respond_to?(:call)
647
690
  errors << "custom_fingerprint must respond to .call (lambda, proc, or object with .call method)"
@@ -680,6 +723,10 @@ module RailsErrorDashboard
680
723
  errors << "instance_variable_max_count must be at least 1 (got: #{instance_variable_max_count})"
681
724
  end
682
725
 
726
+ unless occurrence_context_limit.is_a?(Integer) && occurrence_context_limit >= 0
727
+ errors << "occurrence_context_limit must be a whole number of 0 or more (got: #{occurrence_context_limit.inspect})"
728
+ end
729
+
683
730
  # Validate swallowed exception detection settings
684
731
  # Auto-disable on Ruby < 3.3 (warn, don't crash)
685
732
  if detect_swallowed_exceptions && RUBY_VERSION < "3.3"
@@ -810,6 +857,15 @@ module RailsErrorDashboard
810
857
  errors << "discord_webhook_url is required when enable_discord_notifications is true"
811
858
  end
812
859
 
860
+ if enable_telegram_notifications
861
+ if telegram_bot_token.to_s.strip.empty?
862
+ errors << "telegram_bot_token is required when enable_telegram_notifications is true"
863
+ end
864
+ if telegram_chat_id.to_s.strip.empty?
865
+ errors << "telegram_chat_id is required when enable_telegram_notifications is true"
866
+ end
867
+ end
868
+
813
869
  if enable_pagerduty_notifications && (pagerduty_integration_key.nil? || pagerduty_integration_key.strip.empty?)
814
870
  errors << "pagerduty_integration_key is required when enable_pagerduty_notifications is true"
815
871
  end
@@ -1085,9 +1141,10 @@ module RailsErrorDashboard
1085
1141
  return nil if effective_issue_tracker_provider == :linear
1086
1142
  return nil if git_repository_url.blank?
1087
1143
 
1088
- # Extract owner/repo from URL: https://github.com/owner/repo(.git)
1089
- match = git_repository_url.match(%r{[:/]([^/]+/[^/]+?)(?:\.git)?$})
1090
- match&.[](1)
1144
+ # owner/repo from the normalized URL, so ".git", ".git/" and trailing
1145
+ # slashes all name the same repository the View Source links use
1146
+ url = Services::RepositoryUrl.normalize(git_repository_url)
1147
+ url.match(%r{[:/]([^/]+/[^/]+)\z})&.[](1)
1091
1148
  end
1092
1149
 
1093
1150
  # Resolve the issue tracker API token (supports string or lambda)
@@ -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!
@@ -46,8 +46,10 @@ module RailsErrorDashboard
46
46
  # Skip duplicate reports from our own middleware when the subscriber
47
47
  # already captured this error with full request context above.
48
48
  # Without this, async logging enqueues two jobs for one exception —
49
- # and non-deterministic job ordering can overwrite good data.
50
- if source == "rack.middleware" &&
49
+ # and non-deterministic job ordering can overwrite good data. The
50
+ # middleware's report also carries context[:middleware], because Rails
51
+ # 7.0's reporter passes subscribers no source.
52
+ if (source == "rack.middleware" || context[:middleware]) &&
51
53
  Thread.current[:rails_error_dashboard_reported_errors]&.include?(error.object_id)
52
54
  return
53
55
  end
@@ -49,7 +49,12 @@ module RailsErrorDashboard
49
49
  # believe they had set a value that was never read.
50
50
  # @param source [String, nil] Source identifier (e.g., "frontend", "mobile_app")
51
51
  #
52
- # @return [ErrorLog, nil] The created error log record, or nil if filtered/ignored
52
+ # @return [ErrorLog, nil] The created or updated error log record. nil when
53
+ # no row is written now, or none is returned:
54
+ # - the error is ignored or sampled out (ignored_exceptions, sampling_rate)
55
+ # - storm protection only counts it (:count_only)
56
+ # - async_logging is on: a job writes the row later
57
+ # - logging failed internally (RED never raises into your code)
53
58
  #
54
59
  # @example Basic usage
55
60
  # ManualErrorReporter.report(
@@ -106,6 +111,11 @@ module RailsErrorDashboard
106
111
  occurred_at: occurred_at || Time.current
107
112
  }.compact # Remove nil values
108
113
 
114
+ # Marks this as a client report, so nothing reads this app's source files
115
+ # for its backtrace. Set here, after the caller's values, and from no
116
+ # argument: source: is only a label.
117
+ context[:_capture_source] = "client"
118
+
109
119
  # severity is deliberately NOT forwarded: nothing downstream reads it,
110
120
  # and passing it on would keep the illusion that it does.
111
121
  if severity.present?
@@ -38,15 +38,19 @@ module RailsErrorDashboard
38
38
  # Report to Rails.error (will be logged by our ErrorReporter)
39
39
  # CRITICAL: Wrap in rescue to ensure gem failures don't break the app
40
40
  begin
41
- Rails.error.report(exception,
41
+ options = {
42
42
  handled: false,
43
43
  severity: :error,
44
44
  context: {
45
45
  request: ActionDispatch::Request.new(env),
46
46
  middleware: true
47
- },
48
- source: "rack.middleware"
49
- )
47
+ }
48
+ }
49
+ # Rails 7.0's ErrorReporter#report takes no source: keyword (7.1
50
+ # added it). Passing one raised ArgumentError there, which the rescue
51
+ # below swallowed, so on 7.0 this middleware reported nothing.
52
+ options[:source] = "rack.middleware" if report_takes_source?
53
+ Rails.error.report(exception, **options)
50
54
  rescue => e
51
55
  # If error reporting fails, log it but DON'T break the app
52
56
  RailsErrorDashboard::Logger.error("[RailsErrorDashboard] Middleware error reporting failed: #{e.class} - #{e.message}")
@@ -61,6 +65,16 @@ module RailsErrorDashboard
61
65
  Thread.current[:rails_error_dashboard_reported_errors] = nil
62
66
  RailsErrorDashboard::Services::BreadcrumbCollector.clear_buffer
63
67
  end
68
+
69
+ private
70
+
71
+ # Asked of the reporter's class, not the object: a stubbed or wrapped
72
+ # report method hides its real keywords.
73
+ def report_takes_source?
74
+ Rails.error.class.instance_method(:report).parameters.any? do |type, name|
75
+ name == :source || type == :keyrest
76
+ end
77
+ end
64
78
  end
65
79
  end
66
80
  end
@@ -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
@@ -29,8 +29,6 @@ module RailsErrorDashboard
29
29
  errors_by_hour: errors_by_hour,
30
30
  top_users: top_affected_users,
31
31
  resolution_rate: resolution_rate,
32
- mobile_errors: mobile_errors_count,
33
- api_errors: api_errors_count,
34
32
  pattern_insights: pattern_insights
35
33
  }
36
34
  end
@@ -270,16 +268,6 @@ module RailsErrorDashboard
270
268
  ((resolved_count.to_f / total_groups) * 100).round(1)
271
269
  end
272
270
 
273
- def mobile_errors_count
274
- Queries::EventVolume.in_window(base_scope.where(platform: [ "iOS", "Android" ]), @start_date)
275
- end
276
-
277
- def api_errors_count
278
- Queries::EventVolume.in_window(
279
- base_scope.where("platform IS NULL OR platform = ?", "API"), @start_date
280
- )
281
- end
282
-
283
271
  # Pattern insights for top error types
284
272
  # Analyzes occurrence patterns and bursts for top 5 error types
285
273
  def pattern_insights
@@ -0,0 +1,76 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsErrorDashboard
4
+ module Queries
5
+ # Query: the crashes that happened while the process was still booting.
6
+ #
7
+ # A boot crash (a Zeitwerk::NameError from eager loading, a SyntaxError in
8
+ # a model, an initializer that raised) is imported by CrashCapture with
9
+ # platform "boot_crash" and the file and constant it names, when it names
10
+ # them, in environment_info.boot. One entry per error row, most recent
11
+ # first: the question is "what stopped the last deploy", not a frequency
12
+ # ranking.
13
+ class BootErrors
14
+ PLATFORM = "boot_crash"
15
+
16
+ def self.call(days = 30, application_id: nil)
17
+ new(days, application_id: application_id).call
18
+ end
19
+
20
+ def initialize(days = 30, application_id: nil)
21
+ @days = days
22
+ @application_id = application_id
23
+ @start_date = days.days.ago
24
+ end
25
+
26
+ # @return [Hash] entries: [{id:, error_type:, message:, file:, constant:,
27
+ # count:, first_seen:, last_seen:, resolved:}], unresolved_count: Integer
28
+ def call
29
+ rows = base_query
30
+ .order(last_seen_at: :desc, id: :desc)
31
+ .pluck(:id, :error_type, :message, :environment_info, :occurrence_count,
32
+ :first_seen_at, :last_seen_at, :resolved)
33
+
34
+ entries = rows.map do |id, error_type, message, env_info, count, first_seen, last_seen, resolved|
35
+ boot = boot_detail(env_info)
36
+ {
37
+ id: id,
38
+ error_type: error_type.to_s,
39
+ message: message.to_s,
40
+ file: boot["file"].presence,
41
+ constant: boot["constant"].presence,
42
+ count: [ count.to_i, 1 ].max,
43
+ first_seen: first_seen,
44
+ last_seen: last_seen,
45
+ resolved: resolved ? true : false
46
+ }
47
+ end
48
+
49
+ { entries: entries, unresolved_count: entries.count { |e| !e[:resolved] } }
50
+ rescue => e
51
+ Rails.logger.error("[RailsErrorDashboard] BootErrors query failed: #{e.class}: #{e.message}")
52
+ { entries: [], unresolved_count: 0 }
53
+ end
54
+
55
+ private
56
+
57
+ def base_query
58
+ scope = ErrorLog.where(platform: PLATFORM).where("last_seen_at >= ?", @start_date)
59
+ scope = scope.where(application_id: @application_id) if @application_id.present?
60
+ scope
61
+ end
62
+
63
+ # environment_info is JSON text; the boot detail is optional and the
64
+ # column may hold anything an older row wrote, so every step is guarded.
65
+ def boot_detail(env_info)
66
+ return {} if env_info.blank?
67
+
68
+ parsed = env_info.is_a?(String) ? JSON.parse(env_info) : env_info
69
+ boot = parsed.is_a?(Hash) ? parsed["boot"] : nil
70
+ boot.is_a?(Hash) ? boot : {}
71
+ rescue JSON::ParserError, TypeError
72
+ {}
73
+ end
74
+ end
75
+ end
76
+ end