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,117 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsErrorDashboard
4
+ module Services
5
+ # strftime with the word-producing directives resolved from RED's own
6
+ # dictionary.
7
+ #
8
+ # WHY THIS EXISTS
9
+ #
10
+ # Ruby's strftime is not locale-aware: %B is "August" whatever RED's locale
11
+ # is. On the dashboard that does not matter, because a timestamp is
12
+ # rendered as a <span data-format> and the browser re-renders it through
13
+ # formatDateTime() using the red.js.* month/day/meridian vocabulary P3-T2
14
+ # added. Anywhere there is no browser — email (P4-T2), Slack and Discord
15
+ # payloads (P4-T3) — the server has to do that substitution itself.
16
+ #
17
+ # Sharing one implementation is the point: an email, a Slack message and
18
+ # the dashboard should not disagree about what a date looks like.
19
+ #
20
+ # Takes the locale as an argument rather than reading Current, because
21
+ # every caller runs inside a job where Current is nil or belongs to an
22
+ # unrelated request.
23
+ class LocalizedTimeFormatter
24
+ # The directives whose output is a *word*, and therefore locale-dependent:
25
+ # %B %b (month), %A %a (weekday), %p %P (meridian). Everything else
26
+ # (%Y, %m, %d, %H, %M, %S, %Z …) is digits or a timezone abbreviation and
27
+ # is left to strftime.
28
+ LOCALIZED_DIRECTIVES = /%[BbAaPp]/
29
+
30
+ # Stand-in for a localized word while strftime runs. Contains no percent,
31
+ # so strftime cannot read it as a directive, and no space, so it cannot
32
+ # alter the spacing the pattern asked for. The index keeps two
33
+ # occurrences of the same directive independent.
34
+ PLACEHOLDER = "\u0000RED%<index>d\u0000"
35
+
36
+ # @param time [Time]
37
+ # @param pattern [String] a strftime pattern
38
+ # @param locale [String]
39
+ # @return [String]
40
+ def self.call(time, pattern:, locale:)
41
+ new(locale).render(time, pattern)
42
+ end
43
+
44
+ def initialize(locale)
45
+ @locale = locale
46
+ end
47
+
48
+ # Two-pass, via placeholders: substituting a translated month name
49
+ # directly would put arbitrary text into the pattern, and while a French
50
+ # "%A" -> "mardi" is harmless, a translation containing a literal percent
51
+ # sign would corrupt every directive after it. The placeholder carries no
52
+ # percent, so strftime cannot misread it.
53
+ def render(time, pattern)
54
+ replacements = []
55
+
56
+ staged = pattern.gsub(LOCALIZED_DIRECTIVES) do |directive|
57
+ word = localized_word(directive, time)
58
+ next directive if word.nil?
59
+
60
+ replacements << word
61
+ format(PLACEHOLDER, index: replacements.length - 1)
62
+ end
63
+
64
+ rendered = time.strftime(staged)
65
+
66
+ replacements.each_with_index do |word, index|
67
+ rendered = rendered.sub(format(PLACEHOLDER, index: index), word)
68
+ end
69
+
70
+ rendered
71
+ end
72
+
73
+ private
74
+
75
+ attr_reader :locale
76
+
77
+ # @return [String, nil] nil means "leave it to strftime"
78
+ def localized_word(directive, time)
79
+ case directive
80
+ when "%B" then name_array(:months, 12)[time.month - 1]
81
+ when "%b" then name_array(:months_short, 12)[time.month - 1]
82
+ when "%A" then name_array(:days, 7)[time.wday]
83
+ when "%a" then name_array(:days_short, 7)[time.wday]
84
+ when "%p" then meridian_for(time)
85
+ when "%P" then meridian_for(time)&.downcase
86
+ end
87
+ end
88
+
89
+ # I18nStore.subtree returns SYMBOL keys — the backend loads the YAML
90
+ # symbolized. Indexing with a String silently yields nil here, and
91
+ # because a nil array falls back to English the bug looks exactly like
92
+ # "this locale has no translation" rather than like a defect. Caught in
93
+ # P4-T2 by a spec that asserted a translated month name and got English.
94
+ #
95
+ # A locale that translates only some of these must not produce a mix of
96
+ # translated and English names, so the whole array falls back together —
97
+ # the same rule I18nStore.subtree applies for the JS payload.
98
+ def name_array(key, expected_length)
99
+ names = I18nStore.subtree("red.js", locale: locale)[key]
100
+ return names if names.is_a?(Array) && names.length == expected_length
101
+
102
+ fallback = I18nStore.subtree("red.js", locale: I18nStore::DEFAULT_LOCALE)[key]
103
+ fallback.is_a?(Array) ? fallback : []
104
+ end
105
+
106
+ # A 24-hour locale may legitimately translate these to "", which is why
107
+ # this checks for a String rather than for presence.
108
+ def meridian_for(time)
109
+ meridian = I18nStore.subtree("red.js.meridian", locale: locale)
110
+ value = time.hour >= 12 ? meridian[:pm] : meridian[:am]
111
+ return value if value.is_a?(String)
112
+
113
+ time.hour >= 12 ? "PM" : "AM"
114
+ end
115
+ end
116
+ end
117
+ end
@@ -9,6 +9,81 @@ module RailsErrorDashboard
9
9
  module NotificationHelpers
10
10
  module_function
11
11
 
12
+ # Translate for a notification payload.
13
+ #
14
+ # Positional locale rather than the ambient one: a payload is built
15
+ # inside a job, where Current.locale is nil at best and an unrelated
16
+ # request's value at worst (P4-T1). Every builder threads the locale it
17
+ # was handed.
18
+ #
19
+ # No escaping — these values go into JSON, not HTML.
20
+ #
21
+ # @return [String] never nil, never raises
22
+ def t(key, locale, **options)
23
+ I18nStore.translate(key, locale: locale, **options)
24
+ end
25
+
26
+ # A "*Label:*\nvalue" Slack mrkdwn field.
27
+ #
28
+ # The bold-label-newline-value shape is Slack markup, not language, so it
29
+ # stays here rather than being baked into each translation — a translator
30
+ # editing "*%{label}:*" could break the formatting for every field at
31
+ # once. Only the label itself is translated.
32
+ #
33
+ # @param label [Symbol] a key under red.notifications.error_alert.labels
34
+ def field(label, value, locale)
35
+ "*#{t("red.notifications.error_alert.labels.#{label}", locale)}:*\n#{value}"
36
+ end
37
+
38
+ # One label from red.notifications.error_alert.labels.
39
+ #
40
+ # Discord wants a bare field name, Slack wants it wrapped in its mrkdwn
41
+ # bold-and-newline shape — hence this and #field rather than one method.
42
+ #
43
+ # @param label [Symbol]
44
+ def label(label, locale)
45
+ t("red.notifications.error_alert.labels.#{label}", locale)
46
+ end
47
+
48
+ # @return [String] the localized "Unknown" placeholder
49
+ def unknown(locale)
50
+ t("red.notifications.shared.unknown", locale)
51
+ end
52
+
53
+ # @return [String] the localized "N/A" placeholder
54
+ def not_available(locale)
55
+ t("red.notifications.shared.not_available", locale)
56
+ end
57
+
58
+ # A human-readable timestamp for a notification body.
59
+ #
60
+ # Uses the locale's date format and month names rather than strftime's,
61
+ # which are always English (see MailerI18nHelper for the same problem).
62
+ # Falls back to the machine format if anything goes wrong — a wrong-looking
63
+ # timestamp is better than a lost notification.
64
+ #
65
+ # @return [String] "" for nil
66
+ def format_datetime(time, locale)
67
+ return "" if time.nil?
68
+
69
+ utc = time.respond_to?(:utc) ? time.utc : time
70
+ pattern = I18nStore.translate("red.time.formats.full", locale: locale)
71
+ # A miss returns humanized key text, which would be a nonsense strftime
72
+ # pattern. Detect it the same way red_time_format does.
73
+ pattern = I18nStore.translate("red.time.formats.full", locale: I18nStore::DEFAULT_LOCALE) unless pattern.include?("%")
74
+
75
+ LocalizedTimeFormatter.call(utc, pattern: pattern, locale: locale)
76
+ rescue StandardError
77
+ format_time(time)
78
+ end
79
+
80
+ # A localized timestamp, or the localized "N/A" for nil.
81
+ def format_datetime_or_na(time, locale)
82
+ return not_available(locale) if time.nil?
83
+
84
+ format_datetime(time, locale)
85
+ end
86
+
12
87
  # Generate dashboard URL for an error
13
88
  # @param error_log [ErrorLog] The error
14
89
  # @return [String] Full URL to the error detail page
@@ -46,16 +121,24 @@ module RailsErrorDashboard
46
121
  end
47
122
 
48
123
  # Extract first backtrace line (truncated)
124
+ #
125
+ # The line itself is diagnostic output and is never translated — only the
126
+ # "N/A" shown in its absence is, and only when a locale is supplied.
127
+ # Callers that pass no locale keep the literal, so existing non-i18n
128
+ # callers are unaffected.
129
+ #
49
130
  # @param backtrace [String, Array, nil] Raw backtrace
50
131
  # @param length [Integer] Maximum length (default 100)
51
- # @return [String] First line or "N/A"
52
- def extract_first_backtrace_line(backtrace, length = 100)
53
- return "N/A" if backtrace.nil?
132
+ # @param locale [String, nil] locale for the placeholder
133
+ # @return [String] First line, or the "N/A" placeholder
134
+ def extract_first_backtrace_line(backtrace, length = 100, locale: nil)
135
+ placeholder = locale ? not_available(locale) : "N/A"
136
+ return placeholder if backtrace.nil?
54
137
 
55
138
  lines = backtrace.is_a?(String) ? backtrace.lines : backtrace
56
139
  first_line = lines.first&.strip
57
140
 
58
- return "N/A" if first_line.nil?
141
+ return placeholder if first_line.nil?
59
142
  first_line.length > length ? "#{first_line[0...length]}..." : first_line
60
143
  end
61
144
 
@@ -12,16 +12,32 @@ module RailsErrorDashboard
12
12
  class PagerdutyPayloadBuilder
13
13
  # @param error_log [ErrorLog] The error to build a payload for
14
14
  # @param routing_key [String] PagerDuty integration key
15
+ # @param locale [String] resolved by the enqueueing thread (P4-T1)
15
16
  # @return [Hash] PagerDuty Events API v2 payload
16
- def self.call(error_log, routing_key:)
17
+ #
18
+ # ONLY "summary" AND THE LINK TEXT ARE LOCALIZED.
19
+ #
20
+ # event_action ("trigger") and severity ("critical") are PagerDuty API
21
+ # enums, not adjectives — PagerDuty rejects any other value, so a
22
+ # translated "critical" would drop the incident rather than localize it.
23
+ # The custom_details keys are consumed by whatever the operator has wired
24
+ # downstream, and "client" is RED's product name.
25
+ def self.call(error_log, routing_key:, locale: I18nStore::DEFAULT_LOCALE)
17
26
  {
18
27
  routing_key: routing_key,
19
28
  event_action: "trigger",
20
29
  payload: {
21
- summary: "[#{NotificationHelpers.app_name(error_log)}] Critical Error: #{error_log.error_type} in #{error_log.platform}",
30
+ summary: NotificationHelpers.t(
31
+ "red.notifications.error_alert.pagerduty_summary",
32
+ locale,
33
+ application: NotificationHelpers.app_name(error_log),
34
+ error_type: error_log.error_type,
35
+ platform: error_log.platform
36
+ ),
37
+ # API enum, not an adjective. Never translated.
22
38
  severity: "critical",
23
39
  source: NotificationHelpers.error_source(error_log),
24
- component: error_log.controller_name || "Unknown",
40
+ component: error_log.controller_name || NotificationHelpers.unknown(locale),
25
41
  group: error_log.error_type,
26
42
  class: error_log.error_type,
27
43
  custom_details: {
@@ -41,10 +57,11 @@ module RailsErrorDashboard
41
57
  links: [
42
58
  {
43
59
  href: NotificationHelpers.dashboard_url(error_log),
44
- text: "View in Error Dashboard"
60
+ text: NotificationHelpers.t("red.notifications.error_alert.view_in_dashboard", locale)
45
61
  }
46
62
  ],
47
- client: "Rails Error Dashboard",
63
+ # Product name — not translated.
64
+ client: NotificationHelpers.t("red.notifications.shared.product_name", locale),
48
65
  client_url: NotificationHelpers.dashboard_url(error_log)
49
66
  }
50
67
  end
@@ -11,100 +11,120 @@ module RailsErrorDashboard
11
11
  # # => { text: "...", blocks: [...] }
12
12
  class SlackPayloadBuilder
13
13
  # @param error_log [ErrorLog] The error to build a payload for
14
+ # @param locale [String] resolved by the enqueueing thread (P4-T1)
14
15
  # @return [Hash] Slack Block Kit payload
15
- def self.call(error_log)
16
+ #
17
+ # Only the human-readable *values* are localized. Every "type" and
18
+ # "style" below is Slack Block Kit vocabulary that Slack parses —
19
+ # translating one would produce an invalid payload, not a localized one.
20
+ def self.call(error_log, locale: I18nStore::DEFAULT_LOCALE)
16
21
  {
17
- text: "🚨 New Error Alert",
22
+ text: NotificationHelpers.t("red.notifications.error_alert.fallback", locale),
18
23
  blocks: [
19
- header_block,
20
- fields_block(error_log),
21
- message_block(error_log),
22
- user_block(error_log),
23
- request_block(error_log),
24
- actions_block(error_log),
25
- context_block(error_log)
24
+ header_block(locale),
25
+ fields_block(error_log, locale),
26
+ message_block(error_log, locale),
27
+ user_block(error_log, locale),
28
+ request_block(error_log, locale),
29
+ actions_block(error_log, locale),
30
+ context_block(error_log, locale)
26
31
  ].compact
27
32
  }
28
33
  end
29
34
 
30
- def self.header_block
35
+ def self.header_block(locale)
31
36
  {
32
37
  type: "header",
33
38
  text: {
34
39
  type: "plain_text",
35
- text: "🚨 Error Alert",
40
+ text: NotificationHelpers.t("red.notifications.error_alert.heading", locale),
36
41
  emoji: true
37
42
  }
38
43
  }
39
44
  end
40
45
 
41
- def self.fields_block(error_log)
46
+ def self.fields_block(error_log, locale)
42
47
  {
43
48
  type: "section",
44
49
  fields: [
45
50
  {
46
51
  type: "mrkdwn",
47
- text: "*Application:*\n#{NotificationHelpers.app_name(error_log)}"
52
+ text: NotificationHelpers.field(:application, NotificationHelpers.app_name(error_log), locale)
48
53
  },
49
54
  {
50
55
  type: "mrkdwn",
51
- text: "*Error Type:*\n`#{error_log.error_type}`"
56
+ text: NotificationHelpers.field(:error_type, "`#{error_log.error_type}`", locale)
52
57
  },
53
58
  {
54
59
  type: "mrkdwn",
55
- text: "*Platform:*\n#{NotificationHelpers.platform_emoji(error_log.platform)} #{error_log.platform || 'Unknown'}"
60
+ text: NotificationHelpers.field(
61
+ :platform,
62
+ "#{NotificationHelpers.platform_emoji(error_log.platform)} " \
63
+ "#{error_log.platform || NotificationHelpers.unknown(locale)}",
64
+ locale
65
+ )
56
66
  },
57
67
  {
58
68
  type: "mrkdwn",
59
- text: "*Occurred:*\n#{error_log.occurred_at.strftime('%B %d, %Y at %I:%M %p')}"
69
+ text: NotificationHelpers.field(
70
+ :occurred, NotificationHelpers.format_datetime(error_log.occurred_at, locale), locale
71
+ )
60
72
  }
61
73
  ]
62
74
  }
63
75
  end
64
76
 
65
- def self.message_block(error_log)
77
+ def self.message_block(error_log, locale)
66
78
  {
67
79
  type: "section",
68
80
  text: {
69
81
  type: "mrkdwn",
70
- text: "*Message:*\n```#{NotificationHelpers.truncate_message(error_log.message)}```"
82
+ # Message content is diagnostic output and is never translated.
83
+ text: NotificationHelpers.field(
84
+ :message, "```#{NotificationHelpers.truncate_message(error_log.message)}```", locale
85
+ )
71
86
  }
72
87
  }
73
88
  end
74
89
 
75
- def self.user_block(error_log)
90
+ def self.user_block(error_log, locale)
76
91
  return nil unless error_log.user_id.present?
77
92
 
78
- user_email = error_log.user&.email || "User ##{error_log.user_id}"
93
+ user_email = error_log.user&.email ||
94
+ NotificationHelpers.t("red.notifications.error_alert.user_fallback", locale, id: error_log.user_id)
79
95
 
80
96
  {
81
97
  type: "section",
82
98
  fields: [
83
99
  {
84
100
  type: "mrkdwn",
85
- text: "*User:*\n#{user_email}"
101
+ text: NotificationHelpers.field(:user, user_email, locale)
86
102
  },
87
103
  {
88
104
  type: "mrkdwn",
89
- text: "*IP Address:*\n#{error_log.ip_address || 'N/A'}"
105
+ text: NotificationHelpers.field(
106
+ :ip_address, error_log.ip_address || NotificationHelpers.not_available(locale), locale
107
+ )
90
108
  }
91
109
  ]
92
110
  }
93
111
  end
94
112
 
95
- def self.request_block(error_log)
113
+ def self.request_block(error_log, locale)
96
114
  return nil unless error_log.request_url.present?
97
115
 
98
116
  {
99
117
  type: "section",
100
118
  text: {
101
119
  type: "mrkdwn",
102
- text: "*Request URL:*\n`#{NotificationHelpers.truncate_message(error_log.request_url, 200)}`"
120
+ text: NotificationHelpers.field(
121
+ :request_url, "`#{NotificationHelpers.truncate_message(error_log.request_url, 200)}`", locale
122
+ )
103
123
  }
104
124
  }
105
125
  end
106
126
 
107
- def self.actions_block(error_log)
127
+ def self.actions_block(error_log, locale)
108
128
  {
109
129
  type: "actions",
110
130
  elements: [
@@ -112,7 +132,7 @@ module RailsErrorDashboard
112
132
  type: "button",
113
133
  text: {
114
134
  type: "plain_text",
115
- text: "View Details",
135
+ text: NotificationHelpers.t("red.notifications.error_alert.view_details", locale),
116
136
  emoji: true
117
137
  },
118
138
  url: NotificationHelpers.dashboard_url(error_log),
@@ -122,13 +142,13 @@ module RailsErrorDashboard
122
142
  }
123
143
  end
124
144
 
125
- def self.context_block(error_log)
145
+ def self.context_block(error_log, locale)
126
146
  {
127
147
  type: "context",
128
148
  elements: [
129
149
  {
130
150
  type: "mrkdwn",
131
- text: "Error ID: #{error_log.id}"
151
+ text: NotificationHelpers.t("red.notifications.error_alert.error_id", locale, id: error_log.id)
132
152
  }
133
153
  ]
134
154
  }
@@ -240,7 +240,8 @@ module RailsErrorDashboard
240
240
  @storm_notified_episode = episode[:started_at]
241
241
  StormNotificationJob.perform_later(
242
242
  started_at: episode[:started_at].iso8601,
243
- state: state.to_s
243
+ state: state.to_s,
244
+ locale: ApplicationJob.enqueue_locale
244
245
  )
245
246
  rescue => e
246
247
  RailsErrorDashboard::Logger.error(
@@ -11,8 +11,22 @@ module RailsErrorDashboard
11
11
  # # => { event: "error.created", timestamp: "...", error: { ... } }
12
12
  class WebhookPayloadBuilder
13
13
  # @param error_log [ErrorLog] The error to build a payload for
14
+ # @param locale [String] accepted so the signature matches its siblings
14
15
  # @return [Hash] Webhook payload
15
- def self.call(error_log)
16
+ #
17
+ # DELIBERATELY NOT LOCALIZED (P4-T3 REQ-2, REQ-6).
18
+ #
19
+ # Every key here, and every value, is consumed by a program the operator
20
+ # wrote — not read by a person. "error.created", the severity string, the
21
+ # ISO8601 timestamps and the field names are an API contract; translating
22
+ # any of them silently breaks whatever is parsing the payload. There is
23
+ # no human-readable chrome in this payload to translate.
24
+ #
25
+ # The locale parameter exists so every builder has the same signature and
26
+ # the dispatcher does not need to special-case this one.
27
+ def self.call(error_log, locale: I18nStore::DEFAULT_LOCALE)
28
+ _locale = locale
29
+
16
30
  {
17
31
  event: "error.created",
18
32
  timestamp: Time.current.iso8601,
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsErrorDashboard
4
+ # Translation for code that is not a view.
5
+ #
6
+ # I18nHelper#red_t is a view helper: it html-escapes its output, because that
7
+ # is what a template needs. Controllers and commands produce flash messages
8
+ # and result strings that Rails escapes when the view renders them, so
9
+ # escaping here would double-escape every apostrophe.
10
+ #
11
+ # Both paths resolve the locale the same way — Current.locale_or_default —
12
+ # so a flash message set during a request renders in the same locale as the
13
+ # page that shows it.
14
+ #
15
+ # Like everything else in the i18n path, these never raise. A controller
16
+ # cannot be the place a translation lookup takes down the dashboard.
17
+ module Translation
18
+ # @param key [String, Symbol] e.g. "red.flash.issue.created"
19
+ # @return [String] never nil, never raises, never html-escaped
20
+ def red_t(key, **options)
21
+ I18nStore.translate(key, locale: red_locale, **options)
22
+ rescue StandardError
23
+ ""
24
+ end
25
+
26
+ # Pluralized translation. Selects the form from +count+ using the locale's
27
+ # CLDR rules rather than an English binary plural.
28
+ def red_tp(key, count:, **options)
29
+ red_t(key, count: count, **options)
30
+ end
31
+
32
+ # @return [String] a locale RED ships
33
+ def red_locale
34
+ Current.locale_or_default
35
+ rescue StandardError
36
+ I18nStore::DEFAULT_LOCALE
37
+ end
38
+ end
39
+ end
@@ -1,3 +1,3 @@
1
1
  module RailsErrorDashboard
2
- VERSION = "0.8.3"
2
+ VERSION = "0.9.0"
3
3
  end
@@ -1,5 +1,8 @@
1
1
  require "rails_error_dashboard/version"
2
2
  require "rails_error_dashboard/engine"
3
+ require "rails_error_dashboard/i18n_store"
4
+ require "rails_error_dashboard/current"
5
+ require "rails_error_dashboard/translation"
3
6
  require "rails_error_dashboard/configuration_error"
4
7
  require "rails_error_dashboard/test_error"
5
8
  require "rails_error_dashboard/configuration"
@@ -37,6 +40,7 @@ require "rails_error_dashboard/services/error_normalizer"
37
40
  require "rails_error_dashboard/services/exception_filter"
38
41
  require "rails_error_dashboard/services/error_hash_generator"
39
42
  require "rails_error_dashboard/services/error_notification_dispatcher"
43
+ require "rails_error_dashboard/services/localized_time_formatter"
40
44
  require "rails_error_dashboard/services/notification_helpers"
41
45
  require "rails_error_dashboard/services/slack_payload_builder"
42
46
  require "rails_error_dashboard/services/discord_payload_builder"
@@ -545,7 +545,11 @@ namespace :error_dashboard do
545
545
  end
546
546
 
547
547
  puts "Sending #{period} error digest to #{recipients.join(', ')}..."
548
- RailsErrorDashboard::ScheduledDigestJob.perform_later(period: period, application_id: app_id)
548
+ RailsErrorDashboard::ScheduledDigestJob.perform_later(
549
+ period: period,
550
+ application_id: app_id,
551
+ locale: RailsErrorDashboard::ApplicationJob.enqueue_locale
552
+ )
549
553
  puts "Digest job enqueued."
550
554
  end
551
555
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: rails_error_dashboard
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.8.3
4
+ version: 0.9.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Anjan Jagirdar
@@ -169,14 +169,14 @@ dependencies:
169
169
  requirements:
170
170
  - - "~>"
171
171
  - !ruby/object:Gem::Version
172
- version: '0.22'
172
+ version: '1.1'
173
173
  type: :development
174
174
  prerelease: false
175
175
  version_requirements: !ruby/object:Gem::Requirement
176
176
  requirements:
177
177
  - - "~>"
178
178
  - !ruby/object:Gem::Version
179
- version: '0.22'
179
+ version: '1.1'
180
180
  - !ruby/object:Gem::Dependency
181
181
  name: appraisal
182
182
  requirement: !ruby/object:Gem::Requirement
@@ -246,9 +246,12 @@ files:
246
246
  - Rakefile
247
247
  - app/controllers/rails_error_dashboard/application_controller.rb
248
248
  - app/controllers/rails_error_dashboard/errors_controller.rb
249
+ - app/controllers/rails_error_dashboard/locales_controller.rb
249
250
  - app/controllers/rails_error_dashboard/webhooks_controller.rb
250
251
  - app/helpers/rails_error_dashboard/application_helper.rb
251
252
  - app/helpers/rails_error_dashboard/backtrace_helper.rb
253
+ - app/helpers/rails_error_dashboard/i18n_helper.rb
254
+ - app/helpers/rails_error_dashboard/mailer_i18n_helper.rb
252
255
  - app/helpers/rails_error_dashboard/overview_helper.rb
253
256
  - app/helpers/rails_error_dashboard/user_agent_helper.rb
254
257
  - app/jobs/rails_error_dashboard/add_issue_recurrence_comment_job.rb
@@ -256,6 +259,7 @@ files:
256
259
  - app/jobs/rails_error_dashboard/async_error_logging_job.rb
257
260
  - app/jobs/rails_error_dashboard/baseline_alert_job.rb
258
261
  - app/jobs/rails_error_dashboard/close_linked_issue_job.rb
262
+ - app/jobs/rails_error_dashboard/concerns/localized_job.rb
259
263
  - app/jobs/rails_error_dashboard/create_issue_job.rb
260
264
  - app/jobs/rails_error_dashboard/discord_error_notification_job.rb
261
265
  - app/jobs/rails_error_dashboard/email_error_notification_job.rb
@@ -331,6 +335,17 @@ files:
331
335
  - app/views/rails_error_dashboard/errors/storms.html.erb
332
336
  - app/views/rails_error_dashboard/errors/swallowed_exceptions.html.erb
333
337
  - app/views/rails_error_dashboard/errors/user_impact.html.erb
338
+ - config/locales/de.yml
339
+ - config/locales/en.yml
340
+ - config/locales/es.yml
341
+ - config/locales/fr.yml
342
+ - config/locales/it.yml
343
+ - config/locales/ja.yml
344
+ - config/locales/pl.yml
345
+ - config/locales/pt-BR.yml
346
+ - config/locales/ru.yml
347
+ - config/locales/uk.yml
348
+ - config/locales/zh-CN.yml
334
349
  - config/routes.rb
335
350
  - db/development.sqlite3
336
351
  - db/migrate/20251223000000_create_rails_error_dashboard_complete_schema.rb
@@ -404,9 +419,11 @@ files:
404
419
  - lib/rails_error_dashboard/commands/upsert_cascade_pattern.rb
405
420
  - lib/rails_error_dashboard/configuration.rb
406
421
  - lib/rails_error_dashboard/configuration_error.rb
422
+ - lib/rails_error_dashboard/current.rb
407
423
  - lib/rails_error_dashboard/engine.rb
408
424
  - lib/rails_error_dashboard/error_reporter.rb
409
425
  - lib/rails_error_dashboard/helpers/user_model_detector.rb
426
+ - lib/rails_error_dashboard/i18n_store.rb
410
427
  - lib/rails_error_dashboard/integrations/llm_middleware.rb
411
428
  - lib/rails_error_dashboard/integrations/llm_span_processor.rb
412
429
  - lib/rails_error_dashboard/integrations/o_tel.rb
@@ -420,6 +437,7 @@ files:
420
437
  - lib/rails_error_dashboard/plugins/audit_log_plugin.rb
421
438
  - lib/rails_error_dashboard/plugins/jira_integration_plugin.rb
422
439
  - lib/rails_error_dashboard/plugins/metrics_plugin.rb
440
+ - lib/rails_error_dashboard/private_backend.rb
423
441
  - lib/rails_error_dashboard/queries/action_cable_summary.rb
424
442
  - lib/rails_error_dashboard/queries/active_storage_summary.rb
425
443
  - lib/rails_error_dashboard/queries/analytics_stats.rb
@@ -481,6 +499,7 @@ files:
481
499
  - lib/rails_error_dashboard/services/llm_cost_estimator.rb
482
500
  - lib/rails_error_dashboard/services/llm_summary.rb
483
501
  - lib/rails_error_dashboard/services/local_variable_capturer.rb
502
+ - lib/rails_error_dashboard/services/localized_time_formatter.rb
484
503
  - lib/rails_error_dashboard/services/markdown_error_formatter.rb
485
504
  - lib/rails_error_dashboard/services/n_plus_one_detector.rb
486
505
  - lib/rails_error_dashboard/services/notification_helpers.rb
@@ -513,6 +532,7 @@ files:
513
532
  - lib/rails_error_dashboard/subscribers/llm_call_subscriber.rb
514
533
  - lib/rails_error_dashboard/subscribers/rack_attack_subscriber.rb
515
534
  - lib/rails_error_dashboard/test_error.rb
535
+ - lib/rails_error_dashboard/translation.rb
516
536
  - lib/rails_error_dashboard/value_objects/error_context.rb
517
537
  - lib/rails_error_dashboard/value_objects/llm_call_event.rb
518
538
  - lib/rails_error_dashboard/version.rb
@@ -530,7 +550,7 @@ metadata:
530
550
  funding_uri: https://github.com/sponsors/AnjanJ
531
551
  post_install_message: |
532
552
  ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
533
- RED (Rails Error Dashboard) v0.8.3
553
+ RED (Rails Error Dashboard) v0.9.0
534
554
  ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
535
555
 
536
556
  First install: