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.
- checksums.yaml +4 -4
- data/README.md +35 -0
- data/app/controllers/rails_error_dashboard/application_controller.rb +107 -4
- data/app/controllers/rails_error_dashboard/errors_controller.rb +91 -73
- data/app/controllers/rails_error_dashboard/locales_controller.rb +87 -0
- data/app/helpers/rails_error_dashboard/application_helper.rb +20 -14
- data/app/helpers/rails_error_dashboard/backtrace_helper.rb +15 -6
- data/app/helpers/rails_error_dashboard/i18n_helper.rb +154 -0
- data/app/helpers/rails_error_dashboard/mailer_i18n_helper.rb +69 -0
- data/app/helpers/rails_error_dashboard/overview_helper.rb +18 -6
- data/app/helpers/rails_error_dashboard/user_agent_helper.rb +25 -11
- data/app/jobs/rails_error_dashboard/application_job.rb +5 -0
- data/app/jobs/rails_error_dashboard/baseline_alert_job.rb +18 -16
- data/app/jobs/rails_error_dashboard/concerns/localized_job.rb +83 -0
- data/app/jobs/rails_error_dashboard/discord_error_notification_job.rb +4 -2
- data/app/jobs/rails_error_dashboard/email_error_notification_job.rb +4 -2
- data/app/jobs/rails_error_dashboard/pagerduty_error_notification_job.rb +6 -2
- data/app/jobs/rails_error_dashboard/scheduled_digest_job.rb +8 -3
- data/app/jobs/rails_error_dashboard/slack_error_notification_job.rb +6 -4
- data/app/jobs/rails_error_dashboard/storm_notification_job.rb +26 -8
- data/app/jobs/rails_error_dashboard/webhook_error_notification_job.rb +4 -2
- data/app/mailers/rails_error_dashboard/application_mailer.rb +14 -0
- data/app/mailers/rails_error_dashboard/digest_mailer.rb +18 -2
- data/app/mailers/rails_error_dashboard/error_notification_mailer.rb +19 -2
- data/app/views/layouts/rails_error_dashboard.html.erb +255 -92
- data/app/views/rails_error_dashboard/digest_mailer/digest_summary.html.erb +23 -22
- data/app/views/rails_error_dashboard/digest_mailer/digest_summary.text.erb +30 -19
- data/app/views/rails_error_dashboard/error_notification_mailer/error_alert.html.erb +18 -16
- data/app/views/rails_error_dashboard/error_notification_mailer/error_alert.text.erb +21 -14
- data/app/views/rails_error_dashboard/errors/_ai_help_panel.html.erb +11 -7
- data/app/views/rails_error_dashboard/errors/_breadcrumbs_group.html.erb +45 -38
- data/app/views/rails_error_dashboard/errors/_co_occurring_errors.html.erb +17 -14
- data/app/views/rails_error_dashboard/errors/_discussion.html.erb +27 -9
- data/app/views/rails_error_dashboard/errors/_error_cascades.html.erb +25 -21
- data/app/views/rails_error_dashboard/errors/_error_info.html.erb +25 -22
- data/app/views/rails_error_dashboard/errors/_error_row.html.erb +24 -12
- data/app/views/rails_error_dashboard/errors/_instance_variables.html.erb +12 -11
- data/app/views/rails_error_dashboard/errors/_issue_section.html.erb +19 -13
- data/app/views/rails_error_dashboard/errors/_llm_summary.html.erb +14 -14
- data/app/views/rails_error_dashboard/errors/_local_variables.html.erb +8 -8
- data/app/views/rails_error_dashboard/errors/_modals.html.erb +60 -52
- data/app/views/rails_error_dashboard/errors/_pattern_insights.html.erb +59 -49
- data/app/views/rails_error_dashboard/errors/_request_context.html.erb +23 -20
- data/app/views/rails_error_dashboard/errors/_show_scripts.html.erb +14 -14
- data/app/views/rails_error_dashboard/errors/_sidebar_metadata.html.erb +118 -93
- data/app/views/rails_error_dashboard/errors/_similar_errors.html.erb +18 -12
- data/app/views/rails_error_dashboard/errors/_source_code.html.erb +20 -5
- data/app/views/rails_error_dashboard/errors/_stats.html.erb +5 -5
- data/app/views/rails_error_dashboard/errors/_timeline.html.erb +7 -7
- data/app/views/rails_error_dashboard/errors/_user_errors_table.html.erb +15 -12
- data/app/views/rails_error_dashboard/errors/actioncable_health_summary.html.erb +25 -25
- data/app/views/rails_error_dashboard/errors/activestorage_health_summary.html.erb +28 -28
- data/app/views/rails_error_dashboard/errors/analytics.html.erb +82 -82
- data/app/views/rails_error_dashboard/errors/cache_health_summary.html.erb +27 -27
- data/app/views/rails_error_dashboard/errors/correlation.html.erb +61 -57
- data/app/views/rails_error_dashboard/errors/database_health_summary.html.erb +64 -65
- data/app/views/rails_error_dashboard/errors/deprecations.html.erb +23 -23
- data/app/views/rails_error_dashboard/errors/diagnostic_dumps.html.erb +48 -36
- data/app/views/rails_error_dashboard/errors/index.html.erb +121 -70
- data/app/views/rails_error_dashboard/errors/job_health_summary.html.erb +30 -30
- data/app/views/rails_error_dashboard/errors/llm_health_summary.html.erb +38 -39
- data/app/views/rails_error_dashboard/errors/n_plus_one_summary.html.erb +28 -25
- data/app/views/rails_error_dashboard/errors/overview.html.erb +45 -39
- data/app/views/rails_error_dashboard/errors/platform_comparison.html.erb +45 -44
- data/app/views/rails_error_dashboard/errors/rack_attack_summary.html.erb +30 -29
- data/app/views/rails_error_dashboard/errors/releases.html.erb +46 -47
- data/app/views/rails_error_dashboard/errors/settings/_value_badge.html.erb +48 -44
- data/app/views/rails_error_dashboard/errors/settings.html.erb +122 -112
- data/app/views/rails_error_dashboard/errors/show.html.erb +44 -37
- data/app/views/rails_error_dashboard/errors/storms.html.erb +30 -24
- data/app/views/rails_error_dashboard/errors/swallowed_exceptions.html.erb +26 -26
- data/app/views/rails_error_dashboard/errors/user_impact.html.erb +31 -30
- data/config/locales/de.yml +2007 -0
- data/config/locales/en.yml +2719 -0
- data/config/locales/es.yml +2079 -0
- data/config/locales/fr.yml +2082 -0
- data/config/locales/it.yml +2060 -0
- data/config/locales/ja.yml +1802 -0
- data/config/locales/pl.yml +2108 -0
- data/config/locales/pt-BR.yml +2064 -0
- data/config/locales/ru.yml +2109 -0
- data/config/locales/uk.yml +2106 -0
- data/config/locales/zh-CN.yml +1791 -0
- data/config/routes.rb +5 -0
- data/db/migrate/20260325000001_fix_swallowed_exceptions_index_for_mysql.rb +9 -3
- data/lib/rails_error_dashboard/commands/batch_delete_errors.rb +3 -1
- data/lib/rails_error_dashboard/commands/batch_mute_errors.rb +4 -2
- data/lib/rails_error_dashboard/commands/batch_resolve_errors.rb +4 -2
- data/lib/rails_error_dashboard/commands/batch_unmute_errors.rb +4 -2
- data/lib/rails_error_dashboard/commands/create_issue.rb +5 -3
- data/lib/rails_error_dashboard/commands/link_existing_issue.rb +4 -2
- data/lib/rails_error_dashboard/commands/log_error.rb +1 -1
- data/lib/rails_error_dashboard/configuration.rb +10 -3
- data/lib/rails_error_dashboard/current.rb +56 -0
- data/lib/rails_error_dashboard/i18n_store.rb +293 -0
- data/lib/rails_error_dashboard/private_backend.rb +213 -0
- data/lib/rails_error_dashboard/services/baseline_alert_payload_builder.rb +89 -22
- data/lib/rails_error_dashboard/services/digest_builder.rb +21 -7
- data/lib/rails_error_dashboard/services/discord_payload_builder.rb +27 -15
- data/lib/rails_error_dashboard/services/error_notification_dispatcher.rb +10 -5
- data/lib/rails_error_dashboard/services/localized_time_formatter.rb +117 -0
- data/lib/rails_error_dashboard/services/notification_helpers.rb +87 -4
- data/lib/rails_error_dashboard/services/pagerduty_payload_builder.rb +22 -5
- data/lib/rails_error_dashboard/services/slack_payload_builder.rb +48 -28
- data/lib/rails_error_dashboard/services/storm_protection/gate.rb +2 -1
- data/lib/rails_error_dashboard/services/webhook_payload_builder.rb +15 -1
- data/lib/rails_error_dashboard/translation.rb +39 -0
- data/lib/rails_error_dashboard/version.rb +1 -1
- data/lib/rails_error_dashboard.rb +4 -0
- data/lib/tasks/error_dashboard.rake +5 -1
- 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:
|
|
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
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
-
"
|
|
131
|
+
red_t("red.common.frame_category.app")
|
|
123
132
|
when :gem
|
|
124
|
-
"
|
|
133
|
+
red_t("red.common.frame_category.gem")
|
|
125
134
|
when :framework
|
|
126
|
-
"
|
|
135
|
+
red_t("red.common.frame_category.framework")
|
|
127
136
|
when :ruby_core
|
|
128
|
-
"
|
|
137
|
+
red_t("red.common.frame_category.ruby_core")
|
|
129
138
|
else
|
|
130
|
-
"
|
|
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; 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'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
|
-
"
|
|
43
|
+
red_t("red.common.trend.increasing")
|
|
36
44
|
when :decreasing
|
|
37
|
-
"
|
|
45
|
+
red_t("red.common.trend.decreasing")
|
|
38
46
|
else
|
|
39
|
-
"
|
|
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
|
-
"
|
|
82
|
+
red_t("red.common.health_status.healthy")
|
|
71
83
|
when :warning
|
|
72
|
-
"
|
|
84
|
+
red_t("red.common.health_status.warning")
|
|
73
85
|
else
|
|
74
|
-
"
|
|
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
|
-
|
|
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
|
-
"
|
|
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
|
-
"
|
|
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 "
|
|
100
|
-
return "
|
|
101
|
-
return "
|
|
102
|
-
"
|
|
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:
|
|
121
|
+
browser_name: unknown,
|
|
108
122
|
browser_version: nil,
|
|
109
|
-
os_name:
|
|
110
|
-
device_type:
|
|
111
|
-
platform:
|
|
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
|
-
|
|
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
|