rails_error_dashboard 0.8.3 → 0.9.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 (111) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +35 -0
  3. data/app/controllers/rails_error_dashboard/application_controller.rb +156 -0
  4. data/app/controllers/rails_error_dashboard/errors_controller.rb +95 -73
  5. data/app/controllers/rails_error_dashboard/locales_controller.rb +87 -0
  6. data/app/helpers/rails_error_dashboard/application_helper.rb +20 -14
  7. data/app/helpers/rails_error_dashboard/backtrace_helper.rb +15 -6
  8. data/app/helpers/rails_error_dashboard/i18n_helper.rb +154 -0
  9. data/app/helpers/rails_error_dashboard/mailer_i18n_helper.rb +69 -0
  10. data/app/helpers/rails_error_dashboard/overview_helper.rb +18 -6
  11. data/app/helpers/rails_error_dashboard/user_agent_helper.rb +25 -11
  12. data/app/jobs/rails_error_dashboard/application_job.rb +5 -0
  13. data/app/jobs/rails_error_dashboard/baseline_alert_job.rb +18 -16
  14. data/app/jobs/rails_error_dashboard/concerns/localized_job.rb +83 -0
  15. data/app/jobs/rails_error_dashboard/discord_error_notification_job.rb +4 -2
  16. data/app/jobs/rails_error_dashboard/email_error_notification_job.rb +4 -2
  17. data/app/jobs/rails_error_dashboard/pagerduty_error_notification_job.rb +6 -2
  18. data/app/jobs/rails_error_dashboard/scheduled_digest_job.rb +8 -3
  19. data/app/jobs/rails_error_dashboard/slack_error_notification_job.rb +6 -4
  20. data/app/jobs/rails_error_dashboard/storm_notification_job.rb +26 -8
  21. data/app/jobs/rails_error_dashboard/webhook_error_notification_job.rb +4 -2
  22. data/app/mailers/rails_error_dashboard/application_mailer.rb +14 -0
  23. data/app/mailers/rails_error_dashboard/digest_mailer.rb +18 -2
  24. data/app/mailers/rails_error_dashboard/error_notification_mailer.rb +19 -2
  25. data/app/views/layouts/rails_error_dashboard.html.erb +255 -92
  26. data/app/views/rails_error_dashboard/digest_mailer/digest_summary.html.erb +23 -22
  27. data/app/views/rails_error_dashboard/digest_mailer/digest_summary.text.erb +30 -19
  28. data/app/views/rails_error_dashboard/error_notification_mailer/error_alert.html.erb +18 -16
  29. data/app/views/rails_error_dashboard/error_notification_mailer/error_alert.text.erb +21 -14
  30. data/app/views/rails_error_dashboard/errors/_ai_help_panel.html.erb +11 -7
  31. data/app/views/rails_error_dashboard/errors/_breadcrumbs_group.html.erb +45 -38
  32. data/app/views/rails_error_dashboard/errors/_co_occurring_errors.html.erb +17 -14
  33. data/app/views/rails_error_dashboard/errors/_discussion.html.erb +27 -9
  34. data/app/views/rails_error_dashboard/errors/_error_cascades.html.erb +25 -21
  35. data/app/views/rails_error_dashboard/errors/_error_info.html.erb +25 -22
  36. data/app/views/rails_error_dashboard/errors/_error_row.html.erb +24 -12
  37. data/app/views/rails_error_dashboard/errors/_instance_variables.html.erb +12 -11
  38. data/app/views/rails_error_dashboard/errors/_issue_section.html.erb +19 -13
  39. data/app/views/rails_error_dashboard/errors/_llm_summary.html.erb +14 -14
  40. data/app/views/rails_error_dashboard/errors/_local_variables.html.erb +8 -8
  41. data/app/views/rails_error_dashboard/errors/_modals.html.erb +60 -52
  42. data/app/views/rails_error_dashboard/errors/_pattern_insights.html.erb +59 -49
  43. data/app/views/rails_error_dashboard/errors/_request_context.html.erb +23 -20
  44. data/app/views/rails_error_dashboard/errors/_show_scripts.html.erb +14 -14
  45. data/app/views/rails_error_dashboard/errors/_sidebar_metadata.html.erb +118 -93
  46. data/app/views/rails_error_dashboard/errors/_similar_errors.html.erb +18 -12
  47. data/app/views/rails_error_dashboard/errors/_source_code.html.erb +20 -5
  48. data/app/views/rails_error_dashboard/errors/_stats.html.erb +5 -5
  49. data/app/views/rails_error_dashboard/errors/_timeline.html.erb +7 -7
  50. data/app/views/rails_error_dashboard/errors/_user_errors_table.html.erb +15 -12
  51. data/app/views/rails_error_dashboard/errors/actioncable_health_summary.html.erb +25 -25
  52. data/app/views/rails_error_dashboard/errors/activestorage_health_summary.html.erb +28 -28
  53. data/app/views/rails_error_dashboard/errors/analytics.html.erb +82 -82
  54. data/app/views/rails_error_dashboard/errors/cache_health_summary.html.erb +27 -27
  55. data/app/views/rails_error_dashboard/errors/correlation.html.erb +61 -57
  56. data/app/views/rails_error_dashboard/errors/database_health_summary.html.erb +64 -65
  57. data/app/views/rails_error_dashboard/errors/deprecations.html.erb +23 -23
  58. data/app/views/rails_error_dashboard/errors/diagnostic_dumps.html.erb +48 -36
  59. data/app/views/rails_error_dashboard/errors/index.html.erb +121 -70
  60. data/app/views/rails_error_dashboard/errors/job_health_summary.html.erb +30 -30
  61. data/app/views/rails_error_dashboard/errors/llm_health_summary.html.erb +38 -39
  62. data/app/views/rails_error_dashboard/errors/n_plus_one_summary.html.erb +28 -25
  63. data/app/views/rails_error_dashboard/errors/overview.html.erb +45 -39
  64. data/app/views/rails_error_dashboard/errors/platform_comparison.html.erb +45 -44
  65. data/app/views/rails_error_dashboard/errors/rack_attack_summary.html.erb +39 -28
  66. data/app/views/rails_error_dashboard/errors/releases.html.erb +46 -47
  67. data/app/views/rails_error_dashboard/errors/settings/_value_badge.html.erb +48 -44
  68. data/app/views/rails_error_dashboard/errors/settings.html.erb +122 -112
  69. data/app/views/rails_error_dashboard/errors/show.html.erb +44 -37
  70. data/app/views/rails_error_dashboard/errors/storms.html.erb +30 -24
  71. data/app/views/rails_error_dashboard/errors/swallowed_exceptions.html.erb +26 -26
  72. data/app/views/rails_error_dashboard/errors/user_impact.html.erb +31 -30
  73. data/config/locales/de.yml +2007 -0
  74. data/config/locales/en.yml +2719 -0
  75. data/config/locales/es.yml +2079 -0
  76. data/config/locales/fr.yml +2082 -0
  77. data/config/locales/it.yml +2060 -0
  78. data/config/locales/ja.yml +1802 -0
  79. data/config/locales/pl.yml +2108 -0
  80. data/config/locales/pt-BR.yml +2064 -0
  81. data/config/locales/ru.yml +2109 -0
  82. data/config/locales/uk.yml +2106 -0
  83. data/config/locales/zh-CN.yml +1791 -0
  84. data/config/routes.rb +5 -0
  85. data/db/migrate/20260325000001_fix_swallowed_exceptions_index_for_mysql.rb +9 -3
  86. data/lib/rails_error_dashboard/commands/batch_delete_errors.rb +3 -1
  87. data/lib/rails_error_dashboard/commands/batch_mute_errors.rb +4 -2
  88. data/lib/rails_error_dashboard/commands/batch_resolve_errors.rb +4 -2
  89. data/lib/rails_error_dashboard/commands/batch_unmute_errors.rb +4 -2
  90. data/lib/rails_error_dashboard/commands/create_issue.rb +5 -3
  91. data/lib/rails_error_dashboard/commands/link_existing_issue.rb +4 -2
  92. data/lib/rails_error_dashboard/commands/log_error.rb +1 -1
  93. data/lib/rails_error_dashboard/configuration.rb +23 -0
  94. data/lib/rails_error_dashboard/current.rb +56 -0
  95. data/lib/rails_error_dashboard/i18n_store.rb +293 -0
  96. data/lib/rails_error_dashboard/private_backend.rb +213 -0
  97. data/lib/rails_error_dashboard/services/baseline_alert_payload_builder.rb +89 -22
  98. data/lib/rails_error_dashboard/services/digest_builder.rb +21 -7
  99. data/lib/rails_error_dashboard/services/discord_payload_builder.rb +27 -15
  100. data/lib/rails_error_dashboard/services/error_notification_dispatcher.rb +10 -5
  101. data/lib/rails_error_dashboard/services/localized_time_formatter.rb +117 -0
  102. data/lib/rails_error_dashboard/services/notification_helpers.rb +87 -4
  103. data/lib/rails_error_dashboard/services/pagerduty_payload_builder.rb +22 -5
  104. data/lib/rails_error_dashboard/services/slack_payload_builder.rb +48 -28
  105. data/lib/rails_error_dashboard/services/storm_protection/gate.rb +2 -1
  106. data/lib/rails_error_dashboard/services/webhook_payload_builder.rb +15 -1
  107. data/lib/rails_error_dashboard/translation.rb +39 -0
  108. data/lib/rails_error_dashboard/version.rb +1 -1
  109. data/lib/rails_error_dashboard.rb +4 -0
  110. data/lib/tasks/error_dashboard.rake +5 -1
  111. metadata +24 -4
@@ -0,0 +1,83 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsErrorDashboard
4
+ module Concerns
5
+ # Gives a job an explicit, serialized locale instead of an inherited one.
6
+ #
7
+ # WHY JOBS CANNOT JUST READ Current.locale
8
+ #
9
+ # Mailers and notification jobs render outside the dashboard's
10
+ # around_action. There is no request, so there is no request locale.
11
+ # Reading Current.locale in a job returns nil at best — and at worst, on a
12
+ # reused Puma or job-runner thread, whatever an unrelated request left
13
+ # behind. That is the same bug class as #143 and #148: state outliving the
14
+ # request that set it.
15
+ #
16
+ # So the locale is resolved at ENQUEUE time (see .enqueue_locale) and
17
+ # travels in the job's arguments. The job then reads only its own argument.
18
+ #
19
+ # WHY ENQUEUE TIME RESOLVES TO CONFIG, NOT TO A USER
20
+ #
21
+ # Notifications are enqueued from the capture path — log_error, the storm
22
+ # gate — which runs inside a *host app* request, not a dashboard one. There
23
+ # is no dashboard user in scope and Current.locale is correctly nil there.
24
+ # The meaningful answer is config.dashboard_locale, which is what
25
+ # Current.locale_or_default returns when Current.locale is unset. Resolving
26
+ # through that one path keeps a single precedence chain for the whole gem
27
+ # and lets a dashboard-initiated enqueue (a future "send test notification"
28
+ # button) pick up the acting user's locale for free.
29
+ #
30
+ # BACKWARD COMPATIBILITY IS NOT OPTIONAL HERE
31
+ #
32
+ # Queues drain across a deploy. Jobs enqueued by the previous version have
33
+ # no locale argument at all, and they must still run — a notification is
34
+ # never worth losing over a translation detail. Every consumer therefore
35
+ # defaults the argument, and #job_locale hardens whatever arrives.
36
+ module LocalizedJob
37
+ extend ActiveSupport::Concern
38
+
39
+ class_methods do
40
+ # The locale to serialize into a job's arguments, resolved in the
41
+ # enqueueing thread while its context is still valid.
42
+ #
43
+ # Returns a String, never a Symbol: ActiveJob serializes Symbols
44
+ # inconsistently across adapters, and Sidekiq's JSON round-trip turns
45
+ # one into a String anyway. Being explicit avoids a locale that is a
46
+ # Symbol on the inline adapter and a String in production.
47
+ #
48
+ # @return [String] a locale RED ships. Never nil, never raises.
49
+ def enqueue_locale
50
+ Current.locale_or_default
51
+ rescue StandardError
52
+ I18nStore::DEFAULT_LOCALE
53
+ end
54
+ end
55
+
56
+ private
57
+
58
+ # The locale this job should render in, from its own argument.
59
+ #
60
+ # Total by design. A nil (pre-upgrade payload), a garbage string, a
61
+ # Symbol, or a locale RED does not ship all degrade to
62
+ # config.dashboard_locale and then to English. REQ-4: a notification must
63
+ # never be lost because of a locale problem.
64
+ #
65
+ # @param value [Object] whatever arrived in the job arguments
66
+ # @return [String] a locale RED ships
67
+ def job_locale(value)
68
+ candidate = value.to_s.strip
69
+ return Current.locale_or_default if candidate.empty?
70
+
71
+ # resolve/1 already degrades an unknown tag to English, but an unknown
72
+ # tag should first get a chance at the configured locale — a queued job
73
+ # carrying a stale tag is closer to "no opinion" than to "wants
74
+ # English".
75
+ return I18nStore.resolve(candidate) if I18nStore.available?(candidate)
76
+
77
+ Current.locale_or_default
78
+ rescue StandardError
79
+ I18nStore::DEFAULT_LOCALE
80
+ end
81
+ end
82
+ end
83
+ end
@@ -5,13 +5,15 @@ module RailsErrorDashboard
5
5
  class DiscordErrorNotificationJob < ApplicationJob
6
6
  queue_as :default
7
7
 
8
- def perform(error_log_id)
8
+ # @param locale [String, nil] resolved at enqueue time. nil for jobs
9
+ # enqueued by a pre-Phase-4 version still draining from the queue.
10
+ def perform(error_log_id, locale = nil)
9
11
  error_log = ErrorLog.find(error_log_id)
10
12
  webhook_url = RailsErrorDashboard.configuration.discord_webhook_url
11
13
 
12
14
  return unless webhook_url.present?
13
15
 
14
- payload = Services::DiscordPayloadBuilder.call(error_log)
16
+ payload = Services::DiscordPayloadBuilder.call(error_log, locale: job_locale(locale))
15
17
  post_json(webhook_url, payload)
16
18
  rescue StandardError => e
17
19
  Rails.logger.error("[RailsErrorDashboard] Failed to send Discord notification: #{e.message}")
@@ -4,14 +4,16 @@ module RailsErrorDashboard
4
4
  class EmailErrorNotificationJob < ApplicationJob
5
5
  queue_as :error_notifications
6
6
 
7
- def perform(error_log_id)
7
+ # @param locale [String, nil] resolved at enqueue time. nil for jobs
8
+ # enqueued by a pre-Phase-4 version still draining from the queue.
9
+ def perform(error_log_id, locale = nil)
8
10
  error_log = ErrorLog.find_by(id: error_log_id)
9
11
  return unless error_log
10
12
 
11
13
  recipients = RailsErrorDashboard.configuration.notification_email_recipients
12
14
  return unless recipients.present?
13
15
 
14
- ErrorNotificationMailer.error_alert(error_log, recipients).deliver_now
16
+ ErrorNotificationMailer.error_alert(error_log, recipients, locale: job_locale(locale)).deliver_now
15
17
  rescue => e
16
18
  Rails.logger.error("[RailsErrorDashboard] Failed to send email notification: #{e.message}")
17
19
  Rails.logger.error(e.backtrace&.first(5)&.join("\n")) if e.backtrace
@@ -8,7 +8,9 @@ module RailsErrorDashboard
8
8
 
9
9
  PAGERDUTY_EVENTS_API = "https://events.pagerduty.com/v2/enqueue"
10
10
 
11
- def perform(error_log_id)
11
+ # @param locale [String, nil] resolved at enqueue time. nil for jobs
12
+ # enqueued by a pre-Phase-4 version still draining from the queue.
13
+ def perform(error_log_id, locale = nil)
12
14
  error_log = ErrorLog.find(error_log_id)
13
15
 
14
16
  # Only trigger PagerDuty for critical errors
@@ -17,7 +19,9 @@ module RailsErrorDashboard
17
19
  routing_key = RailsErrorDashboard.configuration.pagerduty_integration_key
18
20
  return unless routing_key.present?
19
21
 
20
- payload = Services::PagerdutyPayloadBuilder.call(error_log, routing_key: routing_key)
22
+ payload = Services::PagerdutyPayloadBuilder.call(
23
+ error_log, routing_key: routing_key, locale: job_locale(locale)
24
+ )
21
25
  response = post_json(PAGERDUTY_EVENTS_API, payload)
22
26
 
23
27
  unless response_success?(response)
@@ -12,18 +12,23 @@ module RailsErrorDashboard
12
12
  class ScheduledDigestJob < ApplicationJob
13
13
  queue_as :default
14
14
 
15
- def perform(period: "daily", application_id: nil)
15
+ # @param locale [String, nil] resolved at enqueue time. nil for jobs
16
+ # enqueued by a pre-Phase-4 version still draining from the queue.
17
+ def perform(period: "daily", application_id: nil, locale: nil)
16
18
  return unless RailsErrorDashboard.configuration.enable_scheduled_digests
17
19
 
18
20
  recipients = effective_recipients
19
21
  return if recipients.blank?
20
22
 
23
+ resolved_locale = job_locale(locale)
24
+
21
25
  digest = Services::DigestBuilder.call(
22
26
  period: period.to_sym,
23
- application_id: application_id
27
+ application_id: application_id,
28
+ locale: resolved_locale
24
29
  )
25
30
 
26
- DigestMailer.digest_summary(digest, recipients).deliver_now
31
+ DigestMailer.digest_summary(digest, recipients, locale: resolved_locale).deliver_now
27
32
  rescue => e
28
33
  Rails.logger.error("[RailsErrorDashboard] ScheduledDigestJob failed: #{e.class}: #{e.message}")
29
34
  end
@@ -4,21 +4,23 @@ module RailsErrorDashboard
4
4
  class SlackErrorNotificationJob < ApplicationJob
5
5
  queue_as :error_notifications
6
6
 
7
- def perform(error_log_id)
7
+ # @param locale [String, nil] resolved at enqueue time. nil for jobs
8
+ # enqueued by a pre-Phase-4 version still draining from the queue.
9
+ def perform(error_log_id, locale = nil)
8
10
  error_log = ErrorLog.find_by(id: error_log_id)
9
11
  return unless error_log
10
12
 
11
13
  webhook_url = RailsErrorDashboard.configuration.slack_webhook_url
12
14
  return unless webhook_url.present?
13
15
 
14
- send_slack_notification(error_log, webhook_url)
16
+ send_slack_notification(error_log, webhook_url, job_locale(locale))
15
17
  rescue => e
16
18
  Rails.logger.error("Failed to send Slack notification: #{e.message}")
17
19
  end
18
20
 
19
21
  private
20
22
 
21
- def send_slack_notification(error_log, webhook_url)
23
+ def send_slack_notification(error_log, webhook_url, locale)
22
24
  require "net/http"
23
25
  require "json"
24
26
 
@@ -31,7 +33,7 @@ module RailsErrorDashboard
31
33
  http.read_timeout = 10 # 10 seconds to read response
32
34
 
33
35
  request = Net::HTTP::Post.new(uri.path, { "Content-Type" => "application/json" })
34
- request.body = Services::SlackPayloadBuilder.call(error_log).to_json
36
+ request.body = Services::SlackPayloadBuilder.call(error_log, locale: locale).to_json
35
37
 
36
38
  response = http.request(request)
37
39
 
@@ -11,9 +11,11 @@ module RailsErrorDashboard
11
11
 
12
12
  # @param started_at [String] ISO8601 episode start
13
13
  # @param state [String] breaker state at notification time ("shedding"/"open")
14
- def perform(started_at:, state: "shedding")
14
+ # @param locale [String, nil] resolved at enqueue time. nil for jobs
15
+ # enqueued by a pre-Phase-4 version still draining from the queue.
16
+ def perform(started_at:, state: "shedding", locale: nil)
15
17
  config = RailsErrorDashboard.configuration
16
- message = build_message(started_at, state, config)
18
+ message = build_message(started_at, state, config, job_locale(locale))
17
19
 
18
20
  if config.enable_slack_notifications && config.slack_webhook_url.present?
19
21
  post_json(config.slack_webhook_url, { text: message })
@@ -38,14 +40,30 @@ module RailsErrorDashboard
38
40
 
39
41
  private
40
42
 
41
- def build_message(started_at, state, config)
42
- mode = state == "open" ? "count-only mode (occurrences tallied, detail paused)" : "shedding mode (context sampling active)"
43
+ # Assembled from four keys rather than one, because the dashboard link is
44
+ # conditional. Each key is a whole sentence never a fragment joined to
45
+ # another, which would bake English word order into every translation.
46
+ #
47
+ # ":warning:" is Slack/Discord emoji shortcode, not text.
48
+ def build_message(started_at, state, config, locale)
49
+ mode_key = state == "open" ? "open" : "shedding"
50
+ mode = t("red.notifications.storm.mode.#{mode_key}", locale)
51
+
43
52
  dashboard = (config.dashboard_base_url || "").chomp("/")
44
- link = dashboard.present? ? " Dashboard: #{dashboard}/errors/storms" : ""
53
+ link = if dashboard.present?
54
+ " " + t("red.notifications.storm.dashboard", locale, url: "#{dashboard}/errors/storms")
55
+ else
56
+ ""
57
+ end
58
+
59
+ ":warning: " \
60
+ "#{t("red.notifications.storm.detected", locale, application: app_name(config), started_at: started_at)} " \
61
+ "#{t("red.notifications.storm.engaged", locale, mode: mode)} " \
62
+ "#{t("red.notifications.storm.suppressed", locale)}#{link}"
63
+ end
45
64
 
46
- ":warning: Error storm detected in #{app_name(config)} at #{started_at}. " \
47
- "Storm protection engaged — #{mode}. Per-error notifications are " \
48
- "suppressed until the storm subsides; exact counts are preserved.#{link}"
65
+ def t(key, locale, **options)
66
+ RailsErrorDashboard::I18nStore.translate(key, locale: locale, **options)
49
67
  end
50
68
 
51
69
  def app_name(config)
@@ -6,7 +6,9 @@ module RailsErrorDashboard
6
6
  class WebhookErrorNotificationJob < ApplicationJob
7
7
  queue_as :default
8
8
 
9
- def perform(error_log_id)
9
+ # @param locale [String, nil] resolved at enqueue time. nil for jobs
10
+ # enqueued by a pre-Phase-4 version still draining from the queue.
11
+ def perform(error_log_id, locale = nil)
10
12
  error_log = ErrorLog.find(error_log_id)
11
13
  webhook_urls = RailsErrorDashboard.configuration.webhook_urls
12
14
 
@@ -15,7 +17,7 @@ module RailsErrorDashboard
15
17
  # Ensure webhook_urls is an array
16
18
  urls = Array(webhook_urls)
17
19
 
18
- payload = Services::WebhookPayloadBuilder.call(error_log)
20
+ payload = Services::WebhookPayloadBuilder.call(error_log, locale: job_locale(locale))
19
21
 
20
22
  urls.each do |url|
21
23
  send_webhook(url, payload, error_log)
@@ -4,5 +4,19 @@ module RailsErrorDashboard
4
4
  class ApplicationMailer < ActionMailer::Base
5
5
  default from: -> { RailsErrorDashboard.configuration.notification_email_from }
6
6
  layout false
7
+
8
+ # Mailers do not pick up engine helpers the way controllers do, so these
9
+ # are included explicitly.
10
+ #
11
+ # I18nHelper gives the .html.erb parts red_t; MailerI18nHelper adds
12
+ # red_mail_t (unescaped, for the .text.erb parts) and red_mail_time (a
13
+ # localized timestamp that needs no JavaScript).
14
+ #
15
+ # The locale arrives as an explicit argument and is assigned to
16
+ # @red_locale, which I18nHelper#red_locale prefers over Current — a mailer
17
+ # renders outside the request that set Current, and on a reused thread
18
+ # Current holds an unrelated request's value (P4-T1).
19
+ helper RailsErrorDashboard::I18nHelper
20
+ helper RailsErrorDashboard::MailerI18nHelper
7
21
  end
8
22
  end
@@ -2,18 +2,34 @@
2
2
 
3
3
  module RailsErrorDashboard
4
4
  class DigestMailer < ApplicationMailer
5
- def digest_summary(digest, recipients)
5
+ # @param locale [String] resolved at enqueue time, never read from
6
+ # Current — a mailer renders outside the request that set it.
7
+ def digest_summary(digest, recipients, locale: I18nStore::DEFAULT_LOCALE)
6
8
  @digest = digest
7
9
  @dashboard_url = dashboard_base_url
10
+ @red_locale = locale
8
11
 
9
12
  mail(
10
13
  to: recipients,
11
- subject: "RED Digest — #{digest[:stats][:new_errors]} new errors (#{digest[:period_label]})"
14
+ subject: subject_for(digest, locale)
12
15
  )
13
16
  end
14
17
 
15
18
  private
16
19
 
20
+ # "N new errors" was an English binary plural built by interpolation. It is
21
+ # now a real plural key, selected by the locale's CLDR rules (REQ-1).
22
+ def subject_for(digest, locale)
23
+ count = digest[:stats][:new_errors].to_i
24
+
25
+ I18nStore.translate(
26
+ "red.mailers.digest.subject",
27
+ locale: locale,
28
+ count: count,
29
+ period: digest[:period_label]
30
+ )
31
+ end
32
+
17
33
  def dashboard_base_url
18
34
  base = RailsErrorDashboard.configuration.dashboard_base_url || "http://localhost:3000"
19
35
  mount_path = RailsErrorDashboard.configuration.engine_mount_path
@@ -2,18 +2,35 @@
2
2
 
3
3
  module RailsErrorDashboard
4
4
  class ErrorNotificationMailer < ApplicationMailer
5
- def error_alert(error_log, recipients)
5
+ # @param locale [String] resolved at enqueue time, never read from
6
+ # Current — a mailer renders outside the request that set it.
7
+ def error_alert(error_log, recipients, locale: I18nStore::DEFAULT_LOCALE)
6
8
  @error_log = error_log
7
9
  @dashboard_url = dashboard_url(error_log)
10
+ @red_locale = locale
8
11
 
9
12
  mail(
10
13
  to: recipients,
11
- subject: "🚨 [#{error_log.application&.name || 'Unknown'}] #{error_log.error_type}: #{truncate_subject(error_log.message)}"
14
+ subject: subject_for(error_log, locale)
12
15
  )
13
16
  end
14
17
 
15
18
  private
16
19
 
20
+ # The subject is assembled from a key rather than interpolated inline, so a
21
+ # locale can reorder or drop the emoji. error_type and message are verbatim
22
+ # error content and are never translated.
23
+ def subject_for(error_log, locale)
24
+ I18nStore.translate(
25
+ "red.mailers.error_alert.subject",
26
+ locale: locale,
27
+ application: error_log.application&.name ||
28
+ I18nStore.translate("red.mailers.shared.unknown_application", locale: locale),
29
+ error_type: error_log.error_type,
30
+ message: truncate_subject(error_log.message)
31
+ )
32
+ end
33
+
17
34
  def dashboard_url(error_log)
18
35
  base_url = RailsErrorDashboard.configuration.dashboard_base_url || "http://localhost:3000"
19
36
  mount_path = RailsErrorDashboard.configuration.engine_mount_path