rails_error_dashboard 0.14.3 → 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 (86) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +45 -17
  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 +10 -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 +24 -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 +72 -5
  31. data/config/locales/en.yml +83 -4
  32. data/config/locales/es.yml +72 -5
  33. data/config/locales/fr.yml +72 -5
  34. data/config/locales/it.yml +72 -5
  35. data/config/locales/ja.yml +72 -4
  36. data/config/locales/pl.yml +72 -5
  37. data/config/locales/pt-BR.yml +72 -5
  38. data/config/locales/ru.yml +72 -5
  39. data/config/locales/uk.yml +72 -5
  40. data/config/locales/zh-CN.yml +72 -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/20251225074653_remove_environment_from_error_logs.rb +9 -0
  44. data/db/migrate/20261010000001_create_missing_translations.rb +50 -0
  45. data/lib/generators/rails_error_dashboard/install/install_generator.rb +40 -38
  46. data/lib/generators/rails_error_dashboard/install/templates/initializer.rb +31 -3
  47. data/lib/generators/rails_error_dashboard/uninstall/uninstall_generator.rb +11 -2
  48. data/lib/rails_error_dashboard/commands/assign_error.rb +24 -1
  49. data/lib/rails_error_dashboard/commands/batch_resolve_errors.rb +9 -1
  50. data/lib/rails_error_dashboard/commands/flush_missing_translations.rb +103 -0
  51. data/lib/rails_error_dashboard/commands/log_error.rb +6 -3
  52. data/lib/rails_error_dashboard/commands/resolve_error.rb +2 -15
  53. data/lib/rails_error_dashboard/commands/update_error_status.rb +7 -0
  54. data/lib/rails_error_dashboard/configuration.rb +75 -2
  55. data/lib/rails_error_dashboard/engine.rb +52 -5
  56. data/lib/rails_error_dashboard/middleware/rate_limiter.rb +21 -43
  57. data/lib/rails_error_dashboard/plugin.rb +49 -3
  58. data/lib/rails_error_dashboard/plugin_registry.rb +61 -12
  59. data/lib/rails_error_dashboard/queries/boot_errors.rb +76 -0
  60. data/lib/rails_error_dashboard/queries/dashboard_stats.rb +3 -11
  61. data/lib/rails_error_dashboard/queries/errors_list.rb +11 -17
  62. data/lib/rails_error_dashboard/queries/health_status.rb +191 -0
  63. data/lib/rails_error_dashboard/queries/missing_translation_summary.rb +82 -0
  64. data/lib/rails_error_dashboard/services/active_storage_probe.rb +91 -0
  65. data/lib/rails_error_dashboard/services/backtrace_parser.rb +13 -7
  66. data/lib/rails_error_dashboard/services/backtrace_processor.rb +15 -0
  67. data/lib/rails_error_dashboard/services/baseline_alert_payload_builder.rb +30 -0
  68. data/lib/rails_error_dashboard/services/crash_capture.rb +85 -2
  69. data/lib/rails_error_dashboard/services/digest_builder.rb +9 -6
  70. data/lib/rails_error_dashboard/services/error_notification_dispatcher.rb +6 -0
  71. data/lib/rails_error_dashboard/services/exception_filter.rb +4 -1
  72. data/lib/rails_error_dashboard/services/github_link_generator.rb +25 -9
  73. data/lib/rails_error_dashboard/services/missing_translation_handler.rb +152 -0
  74. data/lib/rails_error_dashboard/services/missing_translation_tracker.rb +316 -0
  75. data/lib/rails_error_dashboard/services/resolution_callbacks.rb +44 -0
  76. data/lib/rails_error_dashboard/services/severity_classifier.rb +52 -0
  77. data/lib/rails_error_dashboard/services/solid_queue_config_check.rb +47 -0
  78. data/lib/rails_error_dashboard/services/source_code_reader.rb +23 -3
  79. data/lib/rails_error_dashboard/services/telegram_delivery.rb +134 -0
  80. data/lib/rails_error_dashboard/services/telegram_payload_builder.rb +105 -0
  81. data/lib/rails_error_dashboard/services/webhook_delivery.rb +63 -0
  82. data/lib/rails_error_dashboard/services/webhook_signer.rb +80 -0
  83. data/lib/rails_error_dashboard/version.rb +1 -1
  84. data/lib/rails_error_dashboard.rb +12 -0
  85. data/lib/tasks/error_dashboard.rake +43 -2
  86. metadata +21 -2
@@ -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,
@@ -878,8 +878,11 @@ module RailsErrorDashboard
878
878
  # Return if no anomaly detected
879
879
  return unless anomaly[:anomaly]
880
880
 
881
- # Check if severity level should trigger alert
882
- return unless config.baseline_alert_severities.include?(anomaly[:level])
881
+ # baseline_alert_severities lists ERROR severities (critical/high/
882
+ # medium/low, as validated), not anomaly levels. Comparing it with
883
+ # anomaly[:level] (elevated/high/critical) skipped every anomaly below
884
+ # threshold + 1 sigma, and :medium/:low never matched at all.
885
+ return unless config.baseline_alert_severities.include?(error_log.severity)
883
886
 
884
887
  # Enqueue alert job (which will handle throttling)
885
888
  BaselineAlertJob.perform_later(error_log.id, anomaly, ApplicationJob.enqueue_locale)
@@ -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
 
@@ -8,6 +8,15 @@ module RailsErrorDashboard
8
8
  DEFAULT_DASHBOARD_USERNAME = "gandalf"
9
9
  DEFAULT_DASHBOARD_PASSWORD = "youshallnotpass"
10
10
 
11
+ # Settings that hold credentials. #inspect masks them, so printing the
12
+ # configuration in a console (IRB shows results with pp, which uses
13
+ # #inspect) or pasting it into an issue doesn't leak them.
14
+ SECRET_ATTRIBUTES = %i[
15
+ dashboard_password slack_webhook_url discord_webhook_url pagerduty_integration_key
16
+ webhook_urls webhook_signing_secret issue_tracker_token issue_webhook_secret llm_api_key
17
+ telegram_bot_token
18
+ ].freeze
19
+
11
20
  # Dashboard authentication (always required)
12
21
  attr_accessor :dashboard_username
13
22
  attr_accessor :dashboard_password
@@ -38,6 +47,14 @@ module RailsErrorDashboard
38
47
  attr_accessor :discord_webhook_url
39
48
  attr_accessor :enable_discord_notifications
40
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
+
41
58
  # PagerDuty notifications (critical errors only)
42
59
  attr_accessor :pagerduty_integration_key
43
60
  attr_accessor :enable_pagerduty_notifications
@@ -45,6 +62,9 @@ module RailsErrorDashboard
45
62
  # Generic webhook notifications
46
63
  attr_accessor :webhook_urls
47
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
48
68
 
49
69
  # Scheduled digests (daily/weekly summary emails)
50
70
  attr_accessor :enable_scheduled_digests # Master switch (default: false)
@@ -223,6 +243,12 @@ module RailsErrorDashboard
223
243
  attr_accessor :rack_attack_max_cache_size # Max buffered keys per thread (default: 1000)
224
244
  attr_accessor :rack_attack_flush_interval # Seconds between DB flushes (default: 5)
225
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
+
226
252
  # ActionCable event tracking (requires enable_breadcrumbs = true)
227
253
  attr_accessor :enable_actioncable_tracking # Master switch (default: false)
228
254
  # ActiveStorage event tracking (requires enable_breadcrumbs = true)
@@ -301,6 +327,12 @@ module RailsErrorDashboard
301
327
  @discord_webhook_url = ENV["DISCORD_WEBHOOK_URL"]
302
328
  @enable_discord_notifications = false
303
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
+
304
336
  # PagerDuty notification settings (critical errors only)
305
337
  @pagerduty_integration_key = ENV["PAGERDUTY_INTEGRATION_KEY"]
306
338
  @enable_pagerduty_notifications = false
@@ -308,6 +340,10 @@ module RailsErrorDashboard
308
340
  # Generic webhook settings (array of URLs)
309
341
  @webhook_urls = ENV.fetch("WEBHOOK_URLS", "").split(",").map(&:strip).reject(&:empty?)
310
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
311
347
 
312
348
  # Scheduled digest defaults - OFF by default (opt-in)
313
349
  @enable_scheduled_digests = false
@@ -351,7 +387,7 @@ module RailsErrorDashboard
351
387
 
352
388
  # Rate limiting defaults
353
389
  @enable_rate_limiting = false # OFF by default (opt-in)
354
- @rate_limit_per_minute = 100 # Requests per minute per IP for API endpoints
390
+ @rate_limit_per_minute = 300 # Requests per minute per IP, per dashboard path
355
391
 
356
392
  # Enhanced metrics defaults
357
393
  @app_version = ENV["APP_VERSION"]
@@ -380,7 +416,7 @@ module RailsErrorDashboard
380
416
  # Baseline alert defaults
381
417
  @enable_baseline_alerts = false # OFF by default (opt-in)
382
418
  @baseline_alert_threshold_std_devs = ENV.fetch("BASELINE_ALERT_THRESHOLD", "2.0").to_f
383
- @baseline_alert_severities = [ :critical, :high ] # Alert on critical and high severity anomalies
419
+ @baseline_alert_severities = [ :critical, :high ] # Alert only for errors of these severities
384
420
  @baseline_alert_cooldown_minutes = ENV.fetch("BASELINE_ALERT_COOLDOWN", "120").to_i
385
421
 
386
422
  # Source code integration defaults - OFF by default (opt-in)
@@ -475,6 +511,10 @@ module RailsErrorDashboard
475
511
  # roughly one write per thread per interval.
476
512
  @rack_attack_flush_interval = 5 # Seconds between DB flushes
477
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
+
478
518
  # ActionCable event tracking defaults - OFF by default (opt-in, requires breadcrumbs)
479
519
  @enable_actioncable_tracking = false
480
520
  # ActiveStorage event tracking defaults - OFF by default (opt-in, requires breadcrumbs)
@@ -522,6 +562,30 @@ module RailsErrorDashboard
522
562
  initialize
523
563
  end
524
564
 
565
+ # Whether config/database.yml has the entry config.database names for this
566
+ # environment. Without it the engine skips connects_to and RED uses the
567
+ # main database, where its tables usually don't exist, so nothing is
568
+ # recorded. The engine and error_dashboard:verify both ask this.
569
+ #
570
+ # @param env_name [String] the Rails environment
571
+ # @return [Boolean]
572
+ def separate_database_entry?(env_name = Rails.env)
573
+ name = (database || :error_dashboard).to_s
574
+ ActiveRecord::Base.configurations.configs_for(env_name: env_name.to_s).any? { |config| config.name == name }
575
+ end
576
+
577
+ # Like Object#inspect, with every credential in SECRET_ATTRIBUTES replaced
578
+ # by [FILTERED] when set. Unset ones still show nil or [], which is useful
579
+ # when checking why a channel stays off.
580
+ def inspect
581
+ attributes = instance_variables.map do |ivar|
582
+ value = instance_variable_get(ivar)
583
+ secret = SECRET_ATTRIBUTES.include?(ivar.to_s.delete_prefix("@").to_sym) && value.present?
584
+ "#{ivar}=#{secret ? '[FILTERED]' : value.inspect}"
585
+ end
586
+ "#<#{self.class.name} #{attributes.join(', ')}>"
587
+ end
588
+
525
589
  # Validate configuration values
526
590
  # Raises ConfigurationError if any validation fails
527
591
  # Logs warnings for non-fatal issues (e.g., Ruby version incompatibilities)
@@ -778,6 +842,15 @@ module RailsErrorDashboard
778
842
  errors << "discord_webhook_url is required when enable_discord_notifications is true"
779
843
  end
780
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
+
781
854
  if enable_pagerduty_notifications && (pagerduty_integration_key.nil? || pagerduty_integration_key.strip.empty?)
782
855
  errors << "pagerduty_integration_key is required when enable_pagerduty_notifications is true"
783
856
  end
@@ -11,10 +11,11 @@ module RailsErrorDashboard
11
11
 
12
12
  # Guard: skip connects_to if the database config doesn't exist yet in database.yml.
13
13
  # This happens during `rails generate` when the initializer was just created but
14
- # the user hasn't added the database.yml entry yet.
15
- db_configs = ActiveRecord::Base.configurations.configs_for(env_name: Rails.env)
16
- unless db_configs.any? { |c| c.name == database_name.to_s }
17
- Rails.logger.warn "[Rails Error Dashboard] Separate database '#{database_name}' is not configured in database.yml for the '#{Rails.env}' environment. Skipping connects_to. See https://github.com/AnjanJ/rails_error_dashboard/blob/main/docs/guides/DATABASE_OPTIONS.md"
14
+ # the user hasn't added the database.yml entry yet. Logged at error level:
15
+ # RED then uses the main database, and unless that has RED's tables no
16
+ # error is recorded. error_dashboard:verify reports it as FAILED.
17
+ unless RailsErrorDashboard.configuration.separate_database_entry?
18
+ Rails.logger.error "[Rails Error Dashboard] Separate database '#{database_name}' is not configured in database.yml for the '#{Rails.env}' environment. Skipping connects_to: errors are not recorded until it is added. See https://github.com/AnjanJ/rails_error_dashboard/blob/main/docs/guides/DATABASE_OPTIONS.md"
18
19
  next
19
20
  end
20
21
 
@@ -47,6 +48,22 @@ module RailsErrorDashboard
47
48
  end
48
49
  end
49
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
+
50
67
  # Validate configuration after initialization
51
68
  initializer "rails_error_dashboard.validate_config", after: :load_config_initializers do
52
69
  config.after_initialize do
@@ -76,6 +93,11 @@ module RailsErrorDashboard
76
93
  # configured: then it is never consulted.
77
94
  RailsErrorDashboard.detected_git_sha if RailsErrorDashboard.configuration.git_sha.blank?
78
95
 
96
+ # One error-level line if Solid Queue runs RED's jobs with a
97
+ # config/queue.yml that never would (no dispatcher, no worker for RED's
98
+ # queues). Reads the file once; never raises. The dashboard shows a banner.
99
+ RailsErrorDashboard::Services::SolidQueueConfigCheck.log_current_problems
100
+
79
101
  if RailsErrorDashboard.configuration.enable_error_subscriber
80
102
  Rails.error.subscribe(RailsErrorDashboard::ErrorReporter.new)
81
103
  end
@@ -127,6 +149,28 @@ module RailsErrorDashboard
127
149
  at_exit { RailsErrorDashboard::Services::RackAttackTracker.flush_all_threads! }
128
150
  end
129
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
+
130
174
  # Drain the storm count buffer at the end of every request and job, and
131
175
  # again at process exit.
132
176
  #
@@ -218,7 +262,10 @@ module RailsErrorDashboard
218
262
  end
219
263
  end
220
264
 
221
- # 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.
222
269
  if RailsErrorDashboard.configuration.enable_crash_capture
223
270
  RailsErrorDashboard::Services::CrashCapture.import!
224
271
  RailsErrorDashboard::Services::CrashCapture.enable!
@@ -2,12 +2,11 @@
2
2
 
3
3
  module RailsErrorDashboard
4
4
  module Middleware
5
- # Rate limiting middleware for Rails Error Dashboard routes
6
- # Protects both dashboard UI and API endpoints from abuse
5
+ # Rate limiting middleware for Rails Error Dashboard routes: at most
6
+ # config.rate_limit_per_minute requests per IP per path, per minute.
7
7
  class RateLimiter
8
- # Rate limits by endpoint type (relative to engine mount path)
9
- API_LIMIT = { limit: 100, period: 60 }.freeze # 100 req/min
10
- DASHBOARD_LIMIT = { limit: 300, period: 60 }.freeze # 300 req/min
8
+ PERIOD = 60 # seconds
9
+ DEFAULT_LIMIT = 300 # requests per PERIOD, when rate_limit_per_minute isn't a positive number
11
10
 
12
11
  def initialize(app)
13
12
  @app = app
@@ -22,16 +21,14 @@ module RailsErrorDashboard
22
21
  # Only apply rate limiting to error dashboard routes
23
22
  return @app.call(env) unless error_dashboard_route?(request.path)
24
23
 
25
- # Find matching rate limit configuration
26
- limit_config = find_limit_config(request.path)
27
- return @app.call(env) unless limit_config
24
+ limit_config = { limit: configured_limit, period: PERIOD }
28
25
 
29
26
  # Check rate limit
30
- key = rate_limit_key(request)
27
+ key = rate_limit_key(request, limit_config)
31
28
  current_count = @cache.read(key).to_i
32
29
 
33
30
  if current_count >= limit_config[:limit]
34
- return rate_limit_response(request, limit_config)
31
+ return html_rate_limit_response(limit_config)
35
32
  end
36
33
 
37
34
  # Increment counter with expiration
@@ -46,55 +43,36 @@ module RailsErrorDashboard
46
43
  RailsErrorDashboard.configuration.enable_rate_limiting
47
44
  end
48
45
 
46
+ # nil passes validation, and a limit of 0 would refuse every request.
47
+ def configured_limit
48
+ limit = RailsErrorDashboard.configuration.rate_limit_per_minute.to_i
49
+ limit.positive? ? limit : DEFAULT_LIMIT
50
+ end
51
+
49
52
  def engine_mount_path
50
53
  @engine_mount_path ||= RailsErrorDashboard.configuration.engine_mount_path
51
54
  rescue
52
55
  "/red"
53
56
  end
54
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.
55
61
  def error_dashboard_route?(path)
56
- path.start_with?(engine_mount_path)
57
- end
62
+ mount = engine_mount_path.to_s.chomp("/")
63
+ return true if mount.empty?
58
64
 
59
- def find_limit_config(path)
60
- if path.start_with?("#{engine_mount_path}/api")
61
- API_LIMIT
62
- else
63
- DASHBOARD_LIMIT
64
- end
65
+ path == mount || path.start_with?("#{mount}/")
65
66
  end
66
67
 
67
- def rate_limit_key(request)
68
- # Key format: rate_limit:IP:path_prefix:time_window
68
+ def rate_limit_key(request, limit_config)
69
+ # Key format: rate_limit:IP:path:time_window
69
70
  # Time window ensures keys expire and reset
70
- limit_config = find_limit_config(request.path)
71
71
  time_window = Time.now.to_i / limit_config[:period]
72
72
 
73
73
  "rate_limit:#{request.ip}:#{request.path}:#{time_window}"
74
74
  end
75
75
 
76
- def rate_limit_response(request, limit_config)
77
- # Return JSON for API requests, HTML for dashboard
78
- if request.path.start_with?("#{engine_mount_path}/api")
79
- json_rate_limit_response(limit_config)
80
- else
81
- html_rate_limit_response(limit_config)
82
- end
83
- end
84
-
85
- def json_rate_limit_response(limit_config)
86
- [
87
- 429,
88
- {
89
- "Content-Type" => "application/json",
90
- "Retry-After" => limit_config[:period].to_s,
91
- "X-RateLimit-Limit" => limit_config[:limit].to_s,
92
- "X-RateLimit-Period" => "#{limit_config[:period]} seconds"
93
- },
94
- [ { error: "Rate limit exceeded. Please try again later." }.to_json ]
95
- ]
96
- end
97
-
98
76
  def html_rate_limit_response(limit_config)
99
77
  body = <<~HTML
100
78
  <!DOCTYPE html>
@@ -54,12 +54,42 @@ module RailsErrorDashboard
54
54
  # Override in subclass to handle event
55
55
  end
56
56
 
57
+ # Called when a resolved error occurs again and is reopened
58
+ # @param error_log [ErrorLog] The reopened error log
59
+ def on_error_reopened(error_log)
60
+ # Override in subclass to handle event
61
+ end
62
+
57
63
  # Called when an error is resolved
58
64
  # @param error_log [ErrorLog] The resolved error log
59
65
  def on_error_resolved(error_log)
60
66
  # Override in subclass to handle event
61
67
  end
62
68
 
69
+ # Called when an error is muted
70
+ # @param error_log [ErrorLog] The muted error log
71
+ def on_error_muted(error_log)
72
+ # Override in subclass to handle event
73
+ end
74
+
75
+ # Called when an error is unmuted
76
+ # @param error_log [ErrorLog] The unmuted error log
77
+ def on_error_unmuted(error_log)
78
+ # Override in subclass to handle event
79
+ end
80
+
81
+ # Called when errors are batch muted
82
+ # @param error_logs [Array<ErrorLog>] The muted error logs
83
+ def on_errors_batch_muted(error_logs)
84
+ # Override in subclass to handle event
85
+ end
86
+
87
+ # Called when errors are batch unmuted
88
+ # @param error_logs [Array<ErrorLog>] The unmuted error logs
89
+ def on_errors_batch_unmuted(error_logs)
90
+ # Override in subclass to handle event
91
+ end
92
+
63
93
  # Called when errors are batch resolved
64
94
  # @param error_logs [Array<ErrorLog>] The resolved error logs
65
95
  def on_errors_batch_resolved(error_logs)
@@ -91,11 +121,27 @@ module RailsErrorDashboard
91
121
 
92
122
  send(method_name, *args)
93
123
  rescue => e
94
- # Log plugin failures but never propagate - plugins must not break the app
95
- RailsErrorDashboard::Logger.error("[RailsErrorDashboard] Plugin '#{name}' failed in #{method_name}: #{e.class} - #{e.message}")
96
- 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}")
97
129
  RailsErrorDashboard::Logger.error(e.backtrace&.first(10)&.join("\n")) if e.backtrace
98
130
  nil # Explicitly return nil, never raise
99
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
100
146
  end
101
147
  end