rails_error_dashboard 0.8.4 → 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 +107 -4
  4. data/app/controllers/rails_error_dashboard/errors_controller.rb +91 -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 +30 -29
  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 +10 -3
  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
@@ -183,7 +183,8 @@ module RailsErrorDashboard
183
183
  # @param format [Symbol] Format preset (:full, :short, :date_only, :time_only, :datetime)
184
184
  # @param fallback [String] Text to show if time is nil
185
185
  # @return [String] HTML safe span with data attributes for JS conversion
186
- def local_time(time, format: :full, fallback: "N/A")
186
+ def local_time(time, format: :full, fallback: nil)
187
+ fallback ||= red_t("red.common.not_available")
187
188
  return fallback if time.nil?
188
189
 
189
190
  # Convert to UTC if not already
@@ -192,18 +193,13 @@ module RailsErrorDashboard
192
193
  # ISO 8601 format for JavaScript parsing
193
194
  iso_time = utc_time.iso8601
194
195
 
195
- # Format presets for data-format attribute
196
+ # Format presets come from the locale, not from literals here: "%B %d, %Y"
197
+ # is a US ordering as much as it is English words, and other locales want
198
+ # a different one (German: "%d. %B %Y"). An unrecognised format is still
199
+ # treated as a caller-supplied strftime pattern, as before.
196
200
  format_string = case format
197
- when :full
198
- "%B %d, %Y %I:%M:%S %p" # December 31, 2024 11:59:59 PM
199
- when :short
200
- "%m/%d %I:%M%p" # 12/31 11:59PM
201
- when :date_only
202
- "%B %d, %Y" # December 31, 2024
203
- when :time_only
204
- "%I:%M:%S %p" # 11:59:59 PM
205
- when :datetime
206
- "%b %d, %Y %H:%M" # Dec 31, 2024 23:59
201
+ when :full, :short, :date_only, :time_only, :datetime
202
+ red_time_format(format)
207
203
  else
208
204
  format.to_s
209
205
  end
@@ -234,7 +230,8 @@ module RailsErrorDashboard
234
230
  # @param time [Time, DateTime, nil] The timestamp to display
235
231
  # @param fallback [String] Text to show if time is nil
236
232
  # @return [String] HTML safe span with data attributes for JS conversion
237
- def local_time_ago(time, fallback: "N/A")
233
+ def local_time_ago(time, fallback: nil)
234
+ fallback ||= red_t("red.common.not_available")
238
235
  return fallback if time.nil?
239
236
 
240
237
  # Convert to UTC if not already
@@ -243,7 +240,16 @@ module RailsErrorDashboard
243
240
 
244
241
  content_tag(
245
242
  :span,
246
- time_ago_in_words(time) + " ago", # Fallback for non-JS browsers
243
+ # Interpolated, not concatenated: several languages put the equivalent
244
+ # of "ago" before the duration, and some inflect it.
245
+ #
246
+ # NOTE: time_ago_in_words is Rails' own helper and translates through
247
+ # the HOST app's I18n, not RED's. In practice the browser replaces this
248
+ # text immediately (see formatRelativeTime in the layout) and it only
249
+ # shows for non-JS clients, so a host/dashboard locale mismatch here is
250
+ # cosmetic and brief. Phase 3 localizes the JS side, which is what
251
+ # users actually see.
252
+ red_t("red.time.ago", duration: time_ago_in_words(time)), # Fallback for non-JS browsers
247
253
  class: "local-time-ago",
248
254
  data: {
249
255
  utc: iso_time
@@ -2,6 +2,11 @@
2
2
 
3
3
  module RailsErrorDashboard
4
4
  module BacktraceHelper
5
+ # These helpers translate, so they depend on red_t explicitly rather
6
+ # than on the controller happening to mix every engine helper into
7
+ # one view context.
8
+ include I18nHelper
9
+
5
10
  # Language mapping for syntax highlighting
6
11
  LANGUAGE_MAP = {
7
12
  ".rb" => "ruby",
@@ -115,19 +120,23 @@ module RailsErrorDashboard
115
120
  end
116
121
  end
117
122
 
118
- # Format category name
123
+ # Format category name.
124
+ #
125
+ # This labels where a frame came from, not what it says — the backtrace
126
+ # itself is diagnostic output and is never translated. "Gem", "Rails" and
127
+ # "Ruby" inside these labels are product names.
119
128
  def frame_category_name(category)
120
129
  case category
121
130
  when :app
122
- "Your Code"
131
+ red_t("red.common.frame_category.app")
123
132
  when :gem
124
- "Gem"
133
+ red_t("red.common.frame_category.gem")
125
134
  when :framework
126
- "Rails Framework"
135
+ red_t("red.common.frame_category.framework")
127
136
  when :ruby_core
128
- "Ruby Core"
137
+ red_t("red.common.frame_category.ruby_core")
129
138
  else
130
- "Unknown"
139
+ red_t("red.common.frame_category.unknown")
131
140
  end
132
141
  end
133
142
 
@@ -0,0 +1,154 @@
1
+ module RailsErrorDashboard
2
+ # View-layer entry point for RED's translations.
3
+ #
4
+ # The helper is deliberately named red_t, not t. Two reasons:
5
+ #
6
+ # 1. Overriding `t` in an engine helper risks colliding with the host app's
7
+ # own helpers and with Rails' built-in translate, which has lazy-lookup
8
+ # behaviour ("t('.title')") that RED's private backend does not provide.
9
+ # 2. An explicit name makes every translated site greppable — worth a lot
10
+ # when the remaining ~1,600 strings get extracted a page at a time.
11
+ module I18nHelper
12
+ # red_js_t needs escape_javascript. In a request the controller mixes every
13
+ # engine helper into one view context, so it happens to be there — but that
14
+ # is incidental, and it is absent in a type: :helper spec or anywhere this
15
+ # module is included on its own. Include it explicitly rather than depend on
16
+ # the ambient context; the same fix P2-T11 applied to the T10 helpers.
17
+ include ActionView::Helpers::JavaScriptHelper
18
+ # Translate a key in the current request's locale.
19
+ #
20
+ # Output is HTML-escaped unless the key ends in _html, matching Rails'
21
+ # convention. Escaping happens here rather than in I18nStore because it is
22
+ # a view concern — mailers' text parts and JS payloads must not be escaped.
23
+ #
24
+ # @param key [String, Symbol] e.g. "red.nav.errors"
25
+ # @return [String] never nil, never raises
26
+ def red_t(key, **options)
27
+ value = I18nStore.translate(key, locale: red_locale, **options)
28
+
29
+ if key.to_s.end_with?("_html")
30
+ value.html_safe
31
+ else
32
+ ERB::Util.html_escape(value)
33
+ end
34
+ rescue StandardError
35
+ ""
36
+ end
37
+
38
+ # Pluralized translation. Selects the plural form from +count+ using the
39
+ # locale's CLDR rules, so it handles languages with more than English's
40
+ # two forms.
41
+ #
42
+ # Always use this rather than a ternary on "s" — an English binary plural
43
+ # is wrong in most languages, and several have no plural distinction at all.
44
+ #
45
+ # @param count [Integer] drives form selection and is available as %{count}
46
+ def red_tp(key, count:, **options)
47
+ red_t(key, count: count, **options)
48
+ end
49
+
50
+ # The locale this render should use.
51
+ #
52
+ # An explicit @red_locale wins over Current. Mailer and notification
53
+ # templates render outside the dashboard's around_action: there is no
54
+ # request, so Current.locale is nil at best and — on a reused Puma or
55
+ # job-runner thread — another request's leftover value at worst. That is
56
+ # the #143/#148 bug class. Those templates are handed a locale resolved at
57
+ # enqueue time (Concerns::LocalizedJob) and assigned to @red_locale, so
58
+ # reading it here is what makes the async path independent of thread state.
59
+ #
60
+ # Views rendered in a real dashboard request set no @red_locale and fall
61
+ # through to Current, which is correct there.
62
+ #
63
+ # @return [String] a locale RED ships
64
+ def red_locale
65
+ explicit = defined?(@red_locale) ? @red_locale : nil
66
+ return I18nStore.resolve(explicit) if explicit.present?
67
+
68
+ Current.locale_or_default
69
+ rescue StandardError
70
+ I18nStore::DEFAULT_LOCALE
71
+ end
72
+
73
+ # Translate for interpolation into a JavaScript string literal.
74
+ #
75
+ # red_t html-escapes, which is right for page text and wrong here. The two
76
+ # sinks in the layout's script blocks disagree about entities: showToast
77
+ # and innerHTML decode ' back to an apostrophe, but textContent renders
78
+ # it literally, so a French string would display "d'accéder" on screen.
79
+ #
80
+ # escape_javascript is what the surrounding code already uses for flash
81
+ # messages (`<%= j flash[:notice] %>`), and it is the correct escaping for
82
+ # this position: it neutralizes quotes, backslashes and line terminators
83
+ # that would break out of or truncate the literal, without touching
84
+ # characters the sink will render.
85
+ #
86
+ # Values still pass through the same total lookup, so a missing key is
87
+ # readable text rather than a raise inside a <script> block.
88
+ #
89
+ # @param key [String, Symbol] e.g. "red.ui_js.toast.copied"
90
+ # @return [String] html_safe, escaped for a JS string literal
91
+ def red_js_t(key, **options)
92
+ escape_javascript(I18nStore.translate(key, locale: red_locale, **options)).html_safe
93
+ rescue StandardError
94
+ ""
95
+ end
96
+
97
+ # Pluralized variant of red_js_t, for counts written by JS.
98
+ def red_js_tp(key, count:, **options)
99
+ red_js_t(key, count: count, **options)
100
+ end
101
+
102
+ # The translation payload handed to the browser as window.RED_I18N.
103
+ #
104
+ # Deliberately NOT the whole dictionary. This ships inside every dashboard
105
+ # page, so it carries only what JavaScript re-renders on the client: the
106
+ # red.js.* subtree and the strftime patterns that formatDateTime() is
107
+ # handed via data-format. Server-rendered strings already arrive as HTML
108
+ # and must never be sent twice.
109
+ #
110
+ # Values are raw, not html-escaped. They are serialized as JSON by
111
+ # js_safe_json — which neutralizes "</" against a </script> breakout — and
112
+ # then consumed as JS strings, so ERB escaping here would render literal
113
+ # &amp;amp; in the browser.
114
+ #
115
+ # @return [Hash] never nil, never raises. {} at worst.
116
+ def red_js_translations
117
+ locale = red_locale
118
+
119
+ {
120
+ "locale" => locale,
121
+ "js" => I18nStore.subtree("red.js", locale: locale),
122
+ "formats" => I18nStore.subtree("red.time.formats", locale: locale),
123
+ # Not under red.js because the server renders it too — local_time_ago
124
+ # wraps the same key. Sharing one key is the point: both sides then
125
+ # put "ago" wherever the language wants it, rather than each guessing.
126
+ "ago" => I18nStore.translate("red.time.ago", locale: locale)
127
+ }
128
+ rescue StandardError
129
+ {}
130
+ end
131
+
132
+ # A strftime pattern for one of ApplicationHelper#local_time's presets,
133
+ # localized. Falls back to the English pattern for an unknown preset.
134
+ #
135
+ # Kept here rather than inline in the view so the Ruby fallback rendering
136
+ # and the browser's data-format re-render always agree on the pattern.
137
+ #
138
+ # @param preset [Symbol, String] :full, :short, :date_only, :time_only, :datetime
139
+ # @return [String] a raw strftime pattern — NOT html-escaped, since it is
140
+ # consumed by strftime and by the JS date formatter, not rendered as text
141
+ def red_time_format(preset)
142
+ key = "red.time.formats.#{preset}"
143
+ pattern = I18nStore.translate(key, locale: red_locale)
144
+
145
+ # A miss returns humanized key text, which would be a nonsense strftime
146
+ # pattern. Detect it by the absence of any % directive and fall back.
147
+ return pattern if pattern.include?("%")
148
+
149
+ I18nStore.translate("red.time.formats.full", locale: I18nStore::DEFAULT_LOCALE)
150
+ rescue StandardError
151
+ "%B %d, %Y %I:%M:%S %p"
152
+ end
153
+ end
154
+ end
@@ -0,0 +1,69 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsErrorDashboard
4
+ # Translation entry points for mailer templates.
5
+ #
6
+ # Mailers need two things the dashboard's I18nHelper does not provide.
7
+ #
8
+ # 1. AN UNESCAPED VARIANT, for the .text.erb parts.
9
+ #
10
+ # red_t html-escapes, which is right for a web page and wrong for a plain
11
+ # text email: a French string would arrive in the recipient's mail client
12
+ # as "Impossible d&#39;accéder". The .html.erb parts still use red_t. The
13
+ # split mirrors I18nHelper vs. Translation elsewhere in the gem — the test
14
+ # is where the string lands, not what it looks like.
15
+ #
16
+ # 2. A DATE FORMATTER THAT DOES NOT DEPEND ON JAVASCRIPT.
17
+ #
18
+ # ApplicationHelper#local_time renders a <span data-format> that the
19
+ # dashboard's JS re-renders in the reader's timezone and locale. Email has
20
+ # no JS, so a mailer must produce finished text server-side.
21
+ #
22
+ # Ruby's strftime is not locale-aware: %B is "August" whatever RED's
23
+ # locale is. P3-T2 already put localized month, day and meridian names in
24
+ # red.js.* for the browser's formatDateTime(); red_mail_time substitutes
25
+ # the same vocabulary before handing the rest to strftime, so an email and
26
+ # the dashboard render a date identically.
27
+ module MailerI18nHelper
28
+ # red_locale and red_time_format live in I18nHelper. Include it explicitly
29
+ # rather than relying on the mailer's view context happening to have both
30
+ # mixed in: a helper that calls another helper without including it works
31
+ # in a request and fails everywhere else, and the total rescues in this
32
+ # path would swallow the NoMethodError into an empty string. That failure
33
+ # mode already bit P2-T11 and P3-T3.
34
+ include I18nHelper
35
+
36
+ # Translate for a text/plain mailer part. Same lookup as red_t, no HTML
37
+ # escaping. Use red_t in the .html.erb parts.
38
+ #
39
+ # @return [String] never nil, never raises
40
+ def red_mail_t(key, **options)
41
+ I18nStore.translate(key, locale: red_locale, **options)
42
+ rescue StandardError
43
+ ""
44
+ end
45
+
46
+ # Pluralized variant of red_mail_t.
47
+ def red_mail_tp(key, count:, **options)
48
+ red_mail_t(key, count: count, **options)
49
+ end
50
+
51
+ # A finished, localized timestamp for an email.
52
+ #
53
+ # @param time [Time, nil]
54
+ # @param format [Symbol] a preset from red.time.formats
55
+ # @return [String] "" for nil — a mailer must never render "undefined",
56
+ # and must never raise on the way to a notification
57
+ def red_mail_time(time, format: :full)
58
+ return "" if time.nil?
59
+
60
+ utc = time.respond_to?(:utc) ? time.utc : time
61
+ pattern = red_time_format(format)
62
+
63
+ "#{Services::LocalizedTimeFormatter.call(utc, pattern: pattern, locale: red_locale)} UTC"
64
+ rescue StandardError
65
+ # A timestamp is worth losing; the notification is not.
66
+ time.to_s
67
+ end
68
+ end
69
+ end
@@ -2,6 +2,11 @@
2
2
 
3
3
  module RailsErrorDashboard
4
4
  module OverviewHelper
5
+ # These helpers translate, so they depend on red_t explicitly rather
6
+ # than on the controller happening to mix every engine helper into
7
+ # one view context.
8
+ include I18nHelper
9
+
5
10
  # All helper methods return Bootstrap semantic classes (success, warning, danger)
6
11
  # These automatically map to Catppuccin Mocha colors in dark theme via CSS variables:
7
12
  # - success → --ctp-green
@@ -29,14 +34,17 @@ module RailsErrorDashboard
29
34
  value > 0 ? "text-danger" : "text-success"
30
35
  end
31
36
 
37
+ # The direction is a machine symbol; these are its display labels. An
38
+ # unrecognized value reads as stable, which is what the English original
39
+ # did with its else branch.
32
40
  def trend_text(direction)
33
41
  case direction
34
42
  when :increasing
35
- "Increasing"
43
+ red_t("red.common.trend.increasing")
36
44
  when :decreasing
37
- "Decreasing"
45
+ red_t("red.common.trend.decreasing")
38
46
  else
39
- "Stable"
47
+ red_t("red.common.trend.stable")
40
48
  end
41
49
  end
42
50
 
@@ -64,14 +72,18 @@ module RailsErrorDashboard
64
72
  end
65
73
  end
66
74
 
75
+ # The emoji is part of the English rendering and lives in the key so a
76
+ # locale can drop it. These are deliberately not the
77
+ # red.analytics.platform_comparison.health_card keys — that page renders
78
+ # the same states without emoji.
67
79
  def health_status_text(status)
68
80
  case status
69
81
  when :healthy
70
- "✅ Healthy"
82
+ red_t("red.common.health_status.healthy")
71
83
  when :warning
72
- "⚠️ Warning"
84
+ red_t("red.common.health_status.warning")
73
85
  else
74
- "🔴 Critical"
86
+ red_t("red.common.health_status.critical")
75
87
  end
76
88
  end
77
89
  end
@@ -2,6 +2,11 @@
2
2
 
3
3
  module RailsErrorDashboard
4
4
  module UserAgentHelper
5
+ # These helpers translate, so they depend on red_t explicitly rather
6
+ # than on the controller happening to mix every engine helper into
7
+ # one view context.
8
+ include I18nHelper
9
+
5
10
  # Parse user agent string into browser, OS, and device info
6
11
  def parse_user_agent(user_agent_string)
7
12
  return default_user_agent_info if user_agent_string.blank?
@@ -13,7 +18,11 @@ module RailsErrorDashboard
13
18
  browser_version: browser.version,
14
19
  os_name: os_name(browser),
15
20
  device_type: device_type(browser),
16
- platform: browser.platform.to_s.titleize,
21
+ # No titleize: the platform is a machine identifier from the browser
22
+ # gem, and titleize applies English morphology to it. Nothing renders
23
+ # this value today; it stays raw so a future reader gets the identifier
24
+ # rather than a mangled version of one.
25
+ platform: browser.platform.to_s,
17
26
  is_mobile: browser.device.mobile?,
18
27
  is_tablet: browser.device.tablet?,
19
28
  is_bot: browser.bot?
@@ -76,6 +85,8 @@ module RailsErrorDashboard
76
85
 
77
86
  private
78
87
 
88
+ # Browser and OS names are brands and stay as written in every locale.
89
+ # Only the "Unknown" fallback is a word rather than a name.
79
90
  def browser_name(browser)
80
91
  return "Chrome" if browser.chrome?
81
92
  return "Firefox" if browser.firefox?
@@ -83,7 +94,7 @@ module RailsErrorDashboard
83
94
  return "Edge" if browser.edge?
84
95
  return "Opera" if browser.opera?
85
96
  return "Internet Explorer" if browser.ie?
86
- "Unknown"
97
+ red_t("red.common.unknown")
87
98
  end
88
99
 
89
100
  def os_name(browser)
@@ -92,23 +103,26 @@ module RailsErrorDashboard
92
103
  return "Linux" if browser.platform.linux?
93
104
  return "Android" if browser.platform.android?
94
105
  return "iOS" if browser.platform.ios?
95
- "Unknown"
106
+ red_t("red.common.unknown")
96
107
  end
97
108
 
109
+ # Unlike the browser and OS, these four are ordinary words, not brands.
98
110
  def device_type(browser)
99
- return "Mobile" if browser.device.mobile?
100
- return "Tablet" if browser.device.tablet?
101
- return "Bot" if browser.bot?
102
- "Desktop"
111
+ return red_t("red.common.device_type.mobile") if browser.device.mobile?
112
+ return red_t("red.common.device_type.tablet") if browser.device.tablet?
113
+ return red_t("red.common.device_type.bot") if browser.bot?
114
+ red_t("red.common.device_type.desktop")
103
115
  end
104
116
 
105
117
  def default_user_agent_info
118
+ unknown = red_t("red.common.unknown")
119
+
106
120
  {
107
- browser_name: "Unknown",
121
+ browser_name: unknown,
108
122
  browser_version: nil,
109
- os_name: "Unknown",
110
- device_type: "Unknown",
111
- platform: "Unknown",
123
+ os_name: unknown,
124
+ device_type: unknown,
125
+ platform: unknown,
112
126
  is_mobile: false,
113
127
  is_tablet: false,
114
128
  is_bot: false
@@ -1,5 +1,10 @@
1
1
  module RailsErrorDashboard
2
2
  class ApplicationJob < ActiveJob::Base
3
+ # Explicit, serialized locale for any job that renders user-facing text.
4
+ # Jobs run outside the dashboard's around_action, so they must never read
5
+ # the request-scoped locale directly — see Concerns::LocalizedJob for why.
6
+ include Concerns::LocalizedJob
7
+
3
8
  # CRITICAL: Ensure job failures don't break the app or spam error logs
4
9
  # Retry failed jobs with exponential backoff, but limit attempts
5
10
  retry_on StandardError, wait: :exponentially_longer, attempts: 3
@@ -11,7 +11,9 @@ module RailsErrorDashboard
11
11
 
12
12
  # @param error_log_id [Integer] The error log that triggered the alert
13
13
  # @param anomaly_data [Hash] Anomaly information from baseline check
14
- def perform(error_log_id, anomaly_data)
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(error_log_id, anomaly_data, locale = nil)
15
17
  error_log = ErrorLog.find_by(id: error_log_id)
16
18
  return unless error_log
17
19
 
@@ -36,49 +38,49 @@ module RailsErrorDashboard
36
38
  )
37
39
 
38
40
  # Send notifications through all enabled channels
39
- send_notifications(error_log, anomaly_data, config)
41
+ send_notifications(error_log, anomaly_data, config, job_locale(locale))
40
42
  end
41
43
 
42
44
  private
43
45
 
44
- def send_notifications(error_log, anomaly_data, config)
46
+ def send_notifications(error_log, anomaly_data, config, locale)
45
47
  # Slack notification
46
48
  if config.enable_slack_notifications && config.slack_webhook_url.present?
47
- send_slack_notification(error_log, anomaly_data, config)
49
+ send_slack_notification(error_log, anomaly_data, config, locale)
48
50
  end
49
51
 
50
52
  # Email notification
51
53
  if config.enable_email_notifications && config.notification_email_recipients.any?
52
- send_email_notification(error_log, anomaly_data, config)
54
+ send_email_notification(error_log, anomaly_data, config, locale)
53
55
  end
54
56
 
55
57
  # Discord notification
56
58
  if config.enable_discord_notifications && config.discord_webhook_url.present?
57
- send_discord_notification(error_log, anomaly_data, config)
59
+ send_discord_notification(error_log, anomaly_data, config, locale)
58
60
  end
59
61
 
60
62
  # Webhook notification
61
63
  if config.enable_webhook_notifications && config.webhook_urls.any?
62
- send_webhook_notification(error_log, anomaly_data, config)
64
+ send_webhook_notification(error_log, anomaly_data, config, locale)
63
65
  end
64
66
 
65
67
  # PagerDuty for critical anomalies
66
68
  if config.enable_pagerduty_notifications &&
67
69
  config.pagerduty_integration_key.present? &&
68
70
  anomaly_data[:level] == :critical
69
- send_pagerduty_notification(error_log, anomaly_data, config)
71
+ send_pagerduty_notification(error_log, anomaly_data, config, locale)
70
72
  end
71
73
  end
72
74
 
73
- def send_slack_notification(error_log, anomaly_data, config)
74
- payload = Services::BaselineAlertPayloadBuilder.slack_payload(error_log, anomaly_data)
75
+ def send_slack_notification(error_log, anomaly_data, config, locale)
76
+ payload = Services::BaselineAlertPayloadBuilder.slack_payload(error_log, anomaly_data, locale: locale)
75
77
 
76
78
  post_json(config.slack_webhook_url, payload)
77
79
  rescue => e
78
80
  Rails.logger.error("Failed to send baseline alert to Slack: #{e.message}")
79
81
  end
80
82
 
81
- def send_email_notification(error_log, _anomaly_data, _config)
83
+ def send_email_notification(error_log, _anomaly_data, _config, _locale)
82
84
  Rails.logger.info(
83
85
  "Baseline alert email would be sent for #{error_log.error_type}"
84
86
  )
@@ -86,16 +88,16 @@ module RailsErrorDashboard
86
88
  Rails.logger.error("Failed to send baseline alert email: #{e.message}")
87
89
  end
88
90
 
89
- def send_discord_notification(error_log, anomaly_data, config)
90
- payload = Services::BaselineAlertPayloadBuilder.discord_payload(error_log, anomaly_data)
91
+ def send_discord_notification(error_log, anomaly_data, config, locale)
92
+ payload = Services::BaselineAlertPayloadBuilder.discord_payload(error_log, anomaly_data, locale: locale)
91
93
 
92
94
  post_json(config.discord_webhook_url, payload)
93
95
  rescue => e
94
96
  Rails.logger.error("Failed to send baseline alert to Discord: #{e.message}")
95
97
  end
96
98
 
97
- def send_webhook_notification(error_log, anomaly_data, config)
98
- payload = Services::BaselineAlertPayloadBuilder.webhook_payload(error_log, anomaly_data)
99
+ def send_webhook_notification(error_log, anomaly_data, config, locale)
100
+ payload = Services::BaselineAlertPayloadBuilder.webhook_payload(error_log, anomaly_data, locale: locale)
99
101
 
100
102
  config.webhook_urls.each do |url|
101
103
  post_json(url, payload)
@@ -120,7 +122,7 @@ module RailsErrorDashboard
120
122
  end
121
123
  end
122
124
 
123
- def send_pagerduty_notification(error_log, _anomaly_data, _config)
125
+ def send_pagerduty_notification(error_log, _anomaly_data, _config, _locale)
124
126
  Rails.logger.info(
125
127
  "Baseline alert PagerDuty notification for #{error_log.error_type}"
126
128
  )
@@ -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