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,213 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "i18n"
4
+
5
+ module RailsErrorDashboard
6
+ # The backend behind I18nStore: a Backend::Simple that the host app's I18n
7
+ # configuration cannot reach into.
8
+ #
9
+ # Backend::Simple is very nearly private already — it holds its own
10
+ # translations hash and its own initialized flag. Two upstream behaviours
11
+ # break that, and both act at LOAD time rather than lookup time, so neither
12
+ # raises and neither leaves a trace. The dashboard simply renders English.
13
+ # Both were found in P4-T3, by a fixture locale that kept resolving to
14
+ # English; see tasks/i18n-sprint-plan.md.
15
+ #
16
+ # This class states the two fixes as invariants of RED's backend. The
17
+ # previous implementation got the same result by clearing and restoring
18
+ # I18n.available_locales around the load and by poking @initialized from
19
+ # outside — correct, but it mutated a global that a concurrent thread could
20
+ # observe mid-window. Nothing here touches global state.
21
+ class PrivateBackend < ::I18n::Backend::Simple
22
+ # DEFECT 1 — the host's allowlist must not decide what RED's dictionary
23
+ # CONTAINS.
24
+ #
25
+ # Upstream store_translations opens with a guard that discards the data,
26
+ # silently, returning it unstored, when all three of these hold:
27
+ #
28
+ # 1. I18n.enforce_available_locales (the Rails DEFAULT)
29
+ # 2. I18n.available_locales_initialized? (a one-way latch, below)
30
+ # 3. the locale is absent from the host's list
31
+ #
32
+ # Condition 2 is the fuse, and nothing can defuse it: it flips the first
33
+ # time anything ASSIGNS to I18n.available_locales, and I18n exposes no way
34
+ # to clear it. A host that configures locales at boot — or any gem that
35
+ # touches the setting once — arms the filter for the life of the process.
36
+ #
37
+ # The effect is that a host with `config.i18n.available_locales = [:en, :ja]`
38
+ # strips RED's de/fr/es/pt-BR out of RED's OWN private dictionary, and every
39
+ # dashboard page renders English with nothing anywhere to say why.
40
+ #
41
+ # The guard is the first statement in the upstream method, so there is no
42
+ # way to skip it via super and no supported hook to satisfy it — its three
43
+ # inputs are all host globals. What follows it is four lines of public I18n
44
+ # API, reproduced here without the guard.
45
+ #
46
+ # BECAUSE THIS RESTATES AN UPSTREAM BODY, it is pinned by
47
+ # spec/lib/rails_error_dashboard/private_backend_spec.rb, which asserts the
48
+ # upstream semantics this reproduces. If an i18n upgrade changes
49
+ # store_translations, that spec fails loudly rather than this drifting
50
+ # silently out of step.
51
+ def store_translations(locale, data, options = ::I18n::EMPTY_HASH)
52
+ locale = locale.to_sym
53
+ translations[locale] ||= Concurrent::Hash.new
54
+ data = ::I18n::Utils.deep_symbolize_keys(data) unless options.fetch(:skip_symbolize_keys, false)
55
+ STORE_MUTEX.synchronize { ::I18n::Utils.deep_merge!(translations[locale], data) }
56
+ end
57
+
58
+ # DEFECT 2 — the host's load_path must not decide what RED's dictionary
59
+ # CONTAINS either.
60
+ #
61
+ # Backend::Base#load_translations falls back to I18n.load_path when called
62
+ # with no filenames, and upstream init_translations calls it exactly that
63
+ # way. So any lookup against a backend that was never marked initialized
64
+ # merges every locale file the host app and its gems have registered into
65
+ # RED's dictionary — the precise coupling I18nStore exists to prevent. In
66
+ # RED's own suite that meant all of Faker's locales; in a host app it means
67
+ # a colliding key resolving by load order.
68
+ #
69
+ # Marking the backend initialized without reading anything makes the
70
+ # explicit load_translations(*files) in I18nStore#build_backend the only
71
+ # thing that ever populates it. Lookups are unaffected: upstream #lookup
72
+ # and #available_locales call init_translations only `unless initialized?`.
73
+ def init_translations
74
+ @initialized = true
75
+ end
76
+ # Upstream declares init_translations protected. Keep it so: widening an
77
+ # inherited method's visibility is a silent API change.
78
+ protected :init_translations
79
+
80
+ # DEFECT 3 — upstream's plural rule is English, so a locale with different
81
+ # CLDR categories cannot be rendered at all.
82
+ #
83
+ # Backend::Base#pluralization_key is `count == 1 ? :one : :other` with a
84
+ # :zero special case. That IS English (and de/fr/es/pt-BR are close enough
85
+ # that nothing noticed). It is wrong for any language whose categories
86
+ # differ, and it fails in the two opposite directions RED now has to serve:
87
+ #
88
+ # ja, zh-CN have `other` ALONE. At count 1 upstream asks for :one, the
89
+ # entry has no :one, and pluralize raises
90
+ # InvalidPluralizationData. I18nStore rescues that into the
91
+ # English fallback, so a fully translated Japanese string
92
+ # renders in ENGLISH for every count of 1 — silently.
93
+ # fr, es, have `many`. Upstream never asks for it, so the category the
94
+ # pt-BR locale is required to supply is dead weight.
95
+ #
96
+ # RED cannot depend on rails-i18n for real CLDR rules (invariant 7 — no new
97
+ # runtime dependency), and does not need to: it ships a known, small set of
98
+ # locales, and the categories each one uses are already declared in
99
+ # bin/i18n-check. This table is the runtime half of that same statement.
100
+ #
101
+ # Only `other` is guaranteed. Every branch falls back to :other when the
102
+ # category it wants is absent, so a partially-translated entry degrades to
103
+ # a rendered string rather than to the English fallback.
104
+ PLURAL_RULES = {
105
+ # No grammatical number: one form covers every count.
106
+ "ja" => ->(_count) { :other },
107
+ "zh-CN" => ->(_count) { :other },
108
+ # 0 and 1 both take the singular; millions take `many`.
109
+ "fr" => ->(count) { count.abs < 2 ? :one : :other },
110
+ # `many` is the CLDR category for large round numbers. RED's strings are
111
+ # counts of errors and users, so the practical split is one/other; asking
112
+ # for :many only when the entry actually supplies it keeps a correct
113
+ # es/pt-BR file working either way.
114
+ "es" => ->(count) { count == 1 ? :one : :other },
115
+ "pt-BR" => ->(count) { count.abs < 2 ? :one : :other },
116
+ # Russian, the first four-category locale. Unlike the Romance rules
117
+ # above, `other` is NOT the fallback bucket here — CLDR assigns it to
118
+ # fractional counts only, and every whole number lands in one/few/many.
119
+ #
120
+ # The %100 guards are the part that is easy to get wrong: 11 is `many`,
121
+ # not `one`, and 12-14 are `many`, not `few`, even though 1 and 2-4 are.
122
+ # A rule written only on %10 renders "11 ошибка" instead of "11 ошибок".
123
+ "ru" => lambda { |count|
124
+ return :other unless count.is_a?(Integer)
125
+
126
+ i = count.abs
127
+ mod10 = i % 10
128
+ mod100 = i % 100
129
+
130
+ return :one if mod10 == 1 && mod100 != 11
131
+ return :few if (2..4).cover?(mod10) && !(12..14).cover?(mod100)
132
+
133
+ :many
134
+ },
135
+ # Ukrainian takes the SAME integer rule as Russian. That is a fact about
136
+ # CLDR, not an assumption worth reusing by reference: keeping the lambda
137
+ # written out means correcting one language never silently moves the
138
+ # other, and the boundary specs pin both independently.
139
+ "uk" => lambda { |count|
140
+ return :other unless count.is_a?(Integer)
141
+
142
+ i = count.abs
143
+ mod10 = i % 10
144
+ mod100 = i % 100
145
+
146
+ return :one if mod10 == 1 && mod100 != 11
147
+ return :few if (2..4).cover?(mod10) && !(12..14).cover?(mod100)
148
+
149
+ :many
150
+ },
151
+ # Polish is four-category like ru/uk and DIFFERS from both, which is the
152
+ # trap this table exists to prevent. Polish `one` is exactly 1 — 21 and
153
+ # 101 take `many` ("21 bledow"), where ru/uk take `one` ("21 помилка").
154
+ # Copying the Russian lambda here renders every count ending in 1 above 1
155
+ # in the wrong form, and no structural check would catch it.
156
+ "pl" => lambda { |count|
157
+ return :other unless count.is_a?(Integer)
158
+
159
+ i = count.abs
160
+ mod10 = i % 10
161
+ mod100 = i % 100
162
+
163
+ return :one if i == 1
164
+ return :few if (2..4).cover?(mod10) && !(12..14).cover?(mod100)
165
+
166
+ :many
167
+ },
168
+ # Italian matches the other Romance locales: `many` is CLDR's large-round
169
+ # -number category, so the practical split for counts of errors is
170
+ # one/other, and :many is asked for only when the entry supplies it.
171
+ "it" => ->(count) { count == 1 ? :one : :other }
172
+ }.freeze
173
+ private_constant :PLURAL_RULES
174
+
175
+ # Overrides the hook rather than #pluralize, so upstream's
176
+ # InvalidPluralizationData guard still fires for an entry that genuinely
177
+ # lacks the category asked for — a missing form must stay loud, and
178
+ # I18nStore's rescue must stay a safety net rather than the normal path.
179
+ #
180
+ # A locale absent from PLURAL_RULES keeps upstream's English behaviour,
181
+ # which is the right default: de and en are English-shaped, and an unknown
182
+ # locale is better served by the documented upstream rule than by a guess.
183
+ def pluralization_key(entry, count)
184
+ rule = PLURAL_RULES[@current_pluralization_locale.to_s]
185
+ return super unless rule
186
+
187
+ return :zero if count == 0 && entry.has_key?(:zero)
188
+
189
+ key = rule.call(count)
190
+ entry.has_key?(key) ? key : :other
191
+ end
192
+
193
+ # Upstream calls pluralization_key from #pluralize, which knows the locale;
194
+ # the hook itself does not receive it. Capture it around the call rather
195
+ # than reading I18n.locale, which is the HOST's current locale and need not
196
+ # be the locale this lookup is for (I18nStore always passes locale:
197
+ # explicitly, and jobs render several locales under one I18n.locale).
198
+ def pluralize(locale, entry, count)
199
+ previous = @current_pluralization_locale
200
+ @current_pluralization_locale = locale
201
+ super
202
+ ensure
203
+ @current_pluralization_locale = previous
204
+ end
205
+
206
+ # Upstream guards its own deep_merge! with a private class-level mutex
207
+ # (Simple::Implementation::MUTEX). Reaching for that constant would couple
208
+ # this to an internal name; RED holds its own, which is equivalent because
209
+ # it guards writes to this backend's own translations hash.
210
+ STORE_MUTEX = Mutex.new
211
+ private_constant :STORE_MUTEX
212
+ end
213
+ end
@@ -30,15 +30,19 @@ module RailsErrorDashboard
30
30
  # @param error_log [ErrorLog] The error
31
31
  # @param anomaly_data [Hash] Anomaly information
32
32
  # @return [Hash] Slack payload
33
- def self.slack_payload(error_log, anomaly_data)
33
+ # @param locale [String] resolved by the enqueueing thread (P4-T1)
34
+ #
35
+ # As in SlackPayloadBuilder, the Block Kit "type" values are Slack's
36
+ # vocabulary and stay English; only the labels and button text move.
37
+ def self.slack_payload(error_log, anomaly_data, locale: I18nStore::DEFAULT_LOCALE)
34
38
  {
35
- text: "🚨 Baseline Anomaly Alert",
39
+ text: NotificationHelpers.t("red.notifications.baseline_alert.fallback", locale),
36
40
  blocks: [
37
41
  {
38
42
  type: "header",
39
43
  text: {
40
44
  type: "plain_text",
41
- text: "🚨 Baseline Anomaly Detected"
45
+ text: NotificationHelpers.t("red.notifications.baseline_alert.heading", locale)
42
46
  }
43
47
  },
44
48
  {
@@ -46,23 +50,27 @@ module RailsErrorDashboard
46
50
  fields: [
47
51
  {
48
52
  type: "mrkdwn",
49
- text: "*Application:*\n#{NotificationHelpers.app_name(error_log)}"
53
+ text: NotificationHelpers.field(:application, NotificationHelpers.app_name(error_log), locale)
50
54
  },
51
55
  {
52
56
  type: "mrkdwn",
53
- text: "*Error Type:*\n#{error_log.error_type}"
57
+ text: NotificationHelpers.field(:error_type, error_log.error_type, locale)
54
58
  },
55
59
  {
56
60
  type: "mrkdwn",
57
- text: "*Platform:*\n#{error_log.platform}"
61
+ text: NotificationHelpers.field(:platform, error_log.platform, locale)
58
62
  },
59
63
  {
60
64
  type: "mrkdwn",
61
- text: "*Severity:*\n#{anomaly_emoji(anomaly_data[:level])} #{anomaly_data[:level].to_s.upcase}"
65
+ text: baseline_field(
66
+ :severity,
67
+ "#{anomaly_emoji(anomaly_data[:level])} #{anomaly_level(anomaly_data[:level], locale)}",
68
+ locale
69
+ )
62
70
  },
63
71
  {
64
72
  type: "mrkdwn",
65
- text: "*Standard Deviations:*\n#{anomaly_data[:std_devs_above]&.round(1) above baseline"
73
+ text: baseline_field(:std_devs, std_devs_text(anomaly_data, locale), locale)
66
74
  }
67
75
  ]
68
76
  },
@@ -70,14 +78,22 @@ module RailsErrorDashboard
70
78
  type: "section",
71
79
  text: {
72
80
  type: "mrkdwn",
73
- text: "*Message:*\n```#{NotificationHelpers.truncate_message(error_log.message, 200)}```"
81
+ # Message content is diagnostic output — never translated.
82
+ text: NotificationHelpers.field(
83
+ :message, "```#{NotificationHelpers.truncate_message(error_log.message, 200)}```", locale
84
+ )
74
85
  }
75
86
  },
76
87
  {
77
88
  type: "section",
78
89
  text: {
79
90
  type: "mrkdwn",
80
- text: "*Baseline Info:*\nThreshold: #{anomaly_data[:threshold]&.round(1)} errors\nBaseline Type: #{anomaly_data[:baseline_type]}"
91
+ text: baseline_field(
92
+ :baseline_info,
93
+ "#{baseline_label(:threshold, locale)}: #{threshold_text(anomaly_data, locale)}\n" \
94
+ "#{baseline_label(:baseline_type, locale)}: #{anomaly_data[:baseline_type]}",
95
+ locale
96
+ )
81
97
  }
82
98
  },
83
99
  {
@@ -87,7 +103,7 @@ module RailsErrorDashboard
87
103
  type: "button",
88
104
  text: {
89
105
  type: "plain_text",
90
- text: "View in Dashboard"
106
+ text: NotificationHelpers.t("red.notifications.baseline_alert.view_in_dashboard", locale)
91
107
  },
92
108
  url: NotificationHelpers.dashboard_url(error_log)
93
109
  }
@@ -101,21 +117,21 @@ module RailsErrorDashboard
101
117
  # @param error_log [ErrorLog] The error
102
118
  # @param anomaly_data [Hash] Anomaly information
103
119
  # @return [Hash] Discord payload
104
- def self.discord_payload(error_log, anomaly_data)
120
+ def self.discord_payload(error_log, anomaly_data, locale: I18nStore::DEFAULT_LOCALE)
105
121
  {
106
122
  embeds: [
107
123
  {
108
- title: "🚨 Baseline Anomaly Detected",
124
+ title: NotificationHelpers.t("red.notifications.baseline_alert.heading", locale),
109
125
  color: anomaly_color(anomaly_data[:level]),
110
126
  fields: [
111
- { name: "Application", value: NotificationHelpers.app_name(error_log), inline: true },
112
- { name: "Error Type", value: error_log.error_type, inline: true },
113
- { name: "Platform", value: error_log.platform, inline: true },
114
- { name: "Severity", value: anomaly_data[:level].to_s.upcase, inline: true },
115
- { name: "Standard Deviations", value: "#{anomaly_data[:std_devs_above]&.round(1)}σ above baseline", inline: true },
116
- { name: "Threshold", value: "#{anomaly_data[:threshold]&.round(1)} errors", inline: true },
117
- { name: "Baseline Type", value: anomaly_data[:baseline_type] || "N/A", inline: true },
118
- { name: "Message", value: "```#{NotificationHelpers.truncate_message(error_log.message, 200)}```", inline: false }
127
+ { name: NotificationHelpers.label(:application, locale), value: NotificationHelpers.app_name(error_log), inline: true },
128
+ { name: NotificationHelpers.label(:error_type, locale), value: error_log.error_type, inline: true },
129
+ { name: NotificationHelpers.label(:platform, locale), value: error_log.platform, inline: true },
130
+ { name: baseline_label(:severity, locale), value: anomaly_level(anomaly_data[:level], locale), inline: true },
131
+ { name: baseline_label(:std_devs, locale), value: std_devs_text(anomaly_data, locale), inline: true },
132
+ { name: baseline_label(:threshold, locale), value: threshold_text(anomaly_data, locale), inline: true },
133
+ { name: baseline_label(:baseline_type, locale), value: anomaly_data[:baseline_type] || NotificationHelpers.not_available(locale), inline: true },
134
+ { name: NotificationHelpers.label(:message, locale), value: "```#{NotificationHelpers.truncate_message(error_log.message, 200)}```", inline: false }
119
135
  ],
120
136
  url: NotificationHelpers.dashboard_url(error_log),
121
137
  timestamp: Time.current.iso8601
@@ -128,7 +144,12 @@ module RailsErrorDashboard
128
144
  # @param error_log [ErrorLog] The error
129
145
  # @param anomaly_data [Hash] Anomaly information
130
146
  # @return [Hash] Webhook payload
131
- def self.webhook_payload(error_log, anomaly_data)
147
+ # Machine-readable. Every key AND value here is consumed by whatever the
148
+ # operator has wired up downstream, so nothing is localized — the locale
149
+ # is accepted only so the signature matches its siblings (REQ-2, REQ-6).
150
+ def self.webhook_payload(error_log, anomaly_data, locale: I18nStore::DEFAULT_LOCALE)
151
+ _locale = locale
152
+
132
153
  {
133
154
  event: "baseline_anomaly",
134
155
  timestamp: Time.current.iso8601,
@@ -151,6 +172,52 @@ module RailsErrorDashboard
151
172
  }
152
173
  end
153
174
 
175
+ # A label from red.notifications.baseline_alert.labels. Separate from
176
+ # NotificationHelpers.label because these are anomaly-specific and do not
177
+ # appear on an ordinary error alert.
178
+ def self.baseline_label(label, locale)
179
+ NotificationHelpers.t("red.notifications.baseline_alert.labels.#{label}", locale)
180
+ end
181
+
182
+ # Slack's bold-label-newline-value shape, for a baseline-only label.
183
+ def self.baseline_field(label, value, locale)
184
+ "*#{baseline_label(label, locale)}:*\n#{value}"
185
+ end
186
+
187
+ # Anomaly levels are their own vocabulary — an anomaly can be "elevated",
188
+ # an error cannot — so they do not reuse red.common.severity. Previously
189
+ # rendered with .upcase on the raw symbol, which applies English
190
+ # morphology to a machine value.
191
+ def self.anomaly_level(level, locale)
192
+ key = ANOMALY_EMOJIS.key?(level) ? level : :unknown
193
+ NotificationHelpers.t("red.notifications.baseline_alert.level.#{key}", locale)
194
+ end
195
+
196
+ def self.std_devs_text(anomaly_data, locale)
197
+ NotificationHelpers.t(
198
+ "red.notifications.baseline_alert.std_devs_above",
199
+ locale,
200
+ value: anomaly_data[:std_devs_above]&.round(1)
201
+ )
202
+ end
203
+
204
+ # "N errors" was an English binary plural; it is now a real plural key.
205
+ #
206
+ # The threshold is a rounded float (e.g. 12.5) but plural selection needs
207
+ # an Integer, so :count carries the integer and :value the number that is
208
+ # actually displayed. A locale whose plural rules differ at 1 vs 1.5 is
209
+ # not a case CLDR expresses, so rounding for selection is correct.
210
+ def self.threshold_text(anomaly_data, locale)
211
+ rounded = anomaly_data[:threshold]&.round(1)
212
+
213
+ NotificationHelpers.t(
214
+ "red.notifications.baseline_alert.threshold_errors",
215
+ locale,
216
+ count: rounded.to_i,
217
+ value: rounded
218
+ )
219
+ end
220
+
154
221
  # @param level [Symbol] Anomaly level (:critical, :high, :elevated)
155
222
  # @return [String] Emoji
156
223
  def self.anomaly_emoji(level)
@@ -9,26 +9,33 @@ module RailsErrorDashboard
9
9
  # DigestBuilder.call(period: :daily)
10
10
  # # => { period: :daily, stats: { new_errors: 12, ... }, top_errors: [...], ... }
11
11
  class DigestBuilder
12
+ # Labels are keys, not literals: they leak into both the mail subject
13
+ # and the body, so a translated digest with an English "Last 24 hours"
14
+ # in its subject line would be obviously half-done (P4-T2 REQ-4).
12
15
  PERIODS = {
13
- daily: { days: 1, label: "Last 24 hours" },
14
- weekly: { days: 7, label: "Last 7 days" }
16
+ daily: { days: 1, label_key: "red.mailers.digest.periods.daily" },
17
+ weekly: { days: 7, label_key: "red.mailers.digest.periods.weekly" }
15
18
  }.freeze
16
19
 
17
- def self.call(period: :daily, application_id: nil)
18
- new(period: period, application_id: application_id).call
20
+ def self.call(period: :daily, application_id: nil, locale: I18nStore::DEFAULT_LOCALE)
21
+ new(period: period, application_id: application_id, locale: locale).call
19
22
  end
20
23
 
21
- def initialize(period: :daily, application_id: nil)
24
+ # locale is carried from the enqueue site (P4-T1) and is what
25
+ # #period_label resolves against. Never Current: a digest is built inside
26
+ # a job, where Current is nil or belongs to an unrelated request.
27
+ def initialize(period: :daily, application_id: nil, locale: I18nStore::DEFAULT_LOCALE)
22
28
  @period = PERIODS.key?(period) ? period : :daily
23
29
  @days = PERIODS[@period][:days]
24
30
  @application_id = application_id
31
+ @locale = locale
25
32
  @start_date = @days.days.ago
26
33
  end
27
34
 
28
35
  def call
29
36
  {
30
37
  period: @period,
31
- period_label: PERIODS[@period][:label],
38
+ period_label: period_label,
32
39
  generated_at: Time.current,
33
40
  stats: build_stats,
34
41
  top_errors: top_errors,
@@ -42,6 +49,13 @@ module RailsErrorDashboard
42
49
 
43
50
  private
44
51
 
52
+ # Resolved with the locale carried from the enqueue site (P4-T1), not
53
+ # from Current — a digest is built inside a job, where Current is nil or
54
+ # belongs to an unrelated request.
55
+ def period_label
56
+ I18nStore.translate(PERIODS[@period][:label_key], locale: @locale)
57
+ end
58
+
45
59
  def base_scope
46
60
  scope = ErrorLog.where("occurred_at >= ?", @start_date)
47
61
  scope = scope.where(application_id: @application_id) if @application_id.present?
@@ -145,7 +159,7 @@ module RailsErrorDashboard
145
159
  def empty_result
146
160
  {
147
161
  period: @period,
148
- period_label: PERIODS.dig(@period, :label) || "Unknown",
162
+ period_label: period_label,
149
163
  generated_at: Time.current,
150
164
  stats: { new_errors: 0, total_occurrences: 0, resolved: 0, unresolved: 0, critical_high: 0, resolution_rate: 0 },
151
165
  top_errors: [],
@@ -18,52 +18,64 @@ module RailsErrorDashboard
18
18
  DEFAULT_COLOR = 8421504 # Gray
19
19
 
20
20
  # @param error_log [ErrorLog] The error to build a payload for
21
+ # @param locale [String] resolved by the enqueueing thread (P4-T1)
21
22
  # @return [Hash] Discord embed payload
22
- def self.call(error_log)
23
+ #
24
+ # Field *names* are human-readable and localized; "inline", "color" and
25
+ # "timestamp" are Discord embed schema and stay as they are. The footer
26
+ # is the product name, which is not translated.
27
+ def self.call(error_log, locale: I18nStore::DEFAULT_LOCALE)
28
+ unknown = NotificationHelpers.unknown(locale)
29
+ not_available = NotificationHelpers.not_available(locale)
30
+
23
31
  {
24
32
  embeds: [ {
25
- title: "🚨 New Error: #{error_log.error_type}",
33
+ # error_type is an exception class name — verbatim.
34
+ title: NotificationHelpers.t(
35
+ "red.notifications.error_alert.discord_title", locale, error_type: error_log.error_type
36
+ ),
26
37
  description: NotificationHelpers.truncate_message(error_log.message, 200),
27
38
  color: severity_color(error_log),
28
39
  fields: [
29
40
  {
30
- name: "Application",
41
+ name: NotificationHelpers.label(:application, locale),
31
42
  value: NotificationHelpers.app_name(error_log),
32
43
  inline: true
33
44
  },
34
45
  {
35
- name: "Platform",
36
- value: error_log.platform || "Unknown",
46
+ name: NotificationHelpers.label(:platform, locale),
47
+ value: error_log.platform || unknown,
37
48
  inline: true
38
49
  },
39
50
  {
40
- name: "Occurrences",
51
+ name: NotificationHelpers.label(:occurrences, locale),
41
52
  value: error_log.occurrence_count.to_s,
42
53
  inline: true
43
54
  },
44
55
  {
45
- name: "Controller",
46
- value: error_log.controller_name || "N/A",
56
+ name: NotificationHelpers.label(:controller, locale),
57
+ value: error_log.controller_name || not_available,
47
58
  inline: true
48
59
  },
49
60
  {
50
- name: "Action",
51
- value: error_log.action_name || "N/A",
61
+ name: NotificationHelpers.label(:action, locale),
62
+ value: error_log.action_name || not_available,
52
63
  inline: true
53
64
  },
54
65
  {
55
- name: "First Seen",
56
- value: NotificationHelpers.format_time(error_log.first_seen_at),
66
+ name: NotificationHelpers.label(:first_seen, locale),
67
+ value: NotificationHelpers.format_datetime_or_na(error_log.first_seen_at, locale),
57
68
  inline: true
58
69
  },
59
70
  {
60
- name: "Location",
61
- value: NotificationHelpers.extract_first_backtrace_line(error_log.backtrace),
71
+ # Backtrace content is diagnostic output — never translated.
72
+ name: NotificationHelpers.label(:location, locale),
73
+ value: NotificationHelpers.extract_first_backtrace_line(error_log.backtrace, locale: locale),
62
74
  inline: false
63
75
  }
64
76
  ],
65
77
  footer: {
66
- text: "Rails Error Dashboard"
78
+ text: NotificationHelpers.t("red.notifications.shared.product_name", locale)
67
79
  },
68
80
  timestamp: error_log.occurred_at.iso8601
69
81
  } ]
@@ -25,28 +25,33 @@ module RailsErrorDashboard
25
25
  config = RailsErrorDashboard.configuration
26
26
  fired = []
27
27
 
28
+ # Resolved once, here, in the enqueueing thread. Jobs must not
29
+ # resolve it themselves — they run without a request, possibly on a
30
+ # thread another request left state on. See Concerns::LocalizedJob.
31
+ locale = ApplicationJob.enqueue_locale
32
+
28
33
  if config.enable_slack_notifications && config.slack_webhook_url.present?
29
- SlackErrorNotificationJob.perform_later(error_log.id)
34
+ SlackErrorNotificationJob.perform_later(error_log.id, locale)
30
35
  fired << "slack"
31
36
  end
32
37
 
33
38
  if config.enable_email_notifications && config.notification_email_recipients.present?
34
- EmailErrorNotificationJob.perform_later(error_log.id)
39
+ EmailErrorNotificationJob.perform_later(error_log.id, locale)
35
40
  fired << "email"
36
41
  end
37
42
 
38
43
  if config.enable_discord_notifications && config.discord_webhook_url.present?
39
- DiscordErrorNotificationJob.perform_later(error_log.id)
44
+ DiscordErrorNotificationJob.perform_later(error_log.id, locale)
40
45
  fired << "discord"
41
46
  end
42
47
 
43
48
  if config.enable_pagerduty_notifications && config.pagerduty_integration_key.present?
44
- PagerdutyErrorNotificationJob.perform_later(error_log.id)
49
+ PagerdutyErrorNotificationJob.perform_later(error_log.id, locale)
45
50
  fired << "pagerduty"
46
51
  end
47
52
 
48
53
  if config.enable_webhook_notifications && config.webhook_urls.present?
49
- WebhookErrorNotificationJob.perform_later(error_log.id)
54
+ WebhookErrorNotificationJob.perform_later(error_log.id, locale)
50
55
  fired << "webhook"
51
56
  end
52
57