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.
- checksums.yaml +4 -4
- data/README.md +35 -0
- data/app/controllers/rails_error_dashboard/application_controller.rb +156 -0
- data/app/controllers/rails_error_dashboard/errors_controller.rb +95 -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 +39 -28
- 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 +23 -0
- 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
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RailsErrorDashboard
|
|
4
|
+
# Per-user language selection for the dashboard, persisted in the session.
|
|
5
|
+
#
|
|
6
|
+
# Its own controller rather than another action on ErrorsController: this
|
|
7
|
+
# touches no error data, and keeping it separate means the picker cannot be
|
|
8
|
+
# reached by anything that filters or loads errors.
|
|
9
|
+
class LocalesController < ApplicationController
|
|
10
|
+
# POST /locale
|
|
11
|
+
#
|
|
12
|
+
# Sets session[:red_locale] and returns the user to where they were.
|
|
13
|
+
#
|
|
14
|
+
# The new locale takes effect on the NEXT request, not this one. That is
|
|
15
|
+
# deliberate and it is what keeps the around_action ordering intact
|
|
16
|
+
# (P5-T1 REQ-9): with_dashboard_locale has already resolved and set
|
|
17
|
+
# Current.locale by the time this action body runs, so writing the session
|
|
18
|
+
# here cannot disturb a locale that is mid-request. The redirect then
|
|
19
|
+
# renders the target page under the new value.
|
|
20
|
+
def create
|
|
21
|
+
requested = params[:locale].to_s
|
|
22
|
+
|
|
23
|
+
if I18nStore.available?(requested)
|
|
24
|
+
# Store the canonical shipped spelling, not what the user sent.
|
|
25
|
+
# I18nStore.resolve matches case-insensitively, so "PT-br" would
|
|
26
|
+
# otherwise be written to the session and re-resolved on every
|
|
27
|
+
# subsequent request.
|
|
28
|
+
session[:red_locale] = I18nStore.resolve(requested)
|
|
29
|
+
else
|
|
30
|
+
# REQ-6: an unrecognized value is cleared rather than left to fail
|
|
31
|
+
# silently on every later request. Falling through to config is the
|
|
32
|
+
# correct behaviour, and clearing is what makes it happen.
|
|
33
|
+
session.delete(:red_locale)
|
|
34
|
+
flash[:alert] = red_t("red.flash.locale.unavailable")
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
redirect_to safe_return_path
|
|
38
|
+
rescue StandardError => e
|
|
39
|
+
# Session storage can be unavailable (REQ-7: an API-only host where the
|
|
40
|
+
# engine's session middleware did not take effect). Changing the language
|
|
41
|
+
# is never worth breaking the dashboard over — the user keeps the
|
|
42
|
+
# configured locale and the page still renders.
|
|
43
|
+
Rails.logger.warn("[RailsErrorDashboard] Locale selection failed: #{e.class} - #{e.message}")
|
|
44
|
+
redirect_to safe_return_path
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
private
|
|
48
|
+
|
|
49
|
+
# Where to send the user back to (REQ-8: same page, query params intact).
|
|
50
|
+
#
|
|
51
|
+
# Never trusts the raw Referer. An absolute URL from another origin would
|
|
52
|
+
# turn the picker into an open redirect, so only the path and query of a
|
|
53
|
+
# referer that belongs to this engine are reused; anything else falls back
|
|
54
|
+
# to the dashboard root.
|
|
55
|
+
def safe_return_path
|
|
56
|
+
referer = request.referer.to_s
|
|
57
|
+
return default_return_path if referer.empty?
|
|
58
|
+
|
|
59
|
+
uri = URI.parse(referer)
|
|
60
|
+
return default_return_path unless same_origin?(uri)
|
|
61
|
+
|
|
62
|
+
candidate = uri.path.to_s
|
|
63
|
+
return default_return_path unless candidate.start_with?(engine_mount_path)
|
|
64
|
+
|
|
65
|
+
uri.query.present? ? "#{candidate}?#{uri.query}" : candidate
|
|
66
|
+
rescue StandardError
|
|
67
|
+
default_return_path
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def same_origin?(uri)
|
|
71
|
+
return true if uri.host.nil? # a relative referer
|
|
72
|
+
|
|
73
|
+
uri.host == request.host && uri.port == request.port
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def engine_mount_path
|
|
77
|
+
path = RailsErrorDashboard::Engine.routes.find_script_name({})
|
|
78
|
+
path.presence || "/"
|
|
79
|
+
rescue StandardError
|
|
80
|
+
"/"
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def default_return_path
|
|
84
|
+
overview_path
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|
|
@@ -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
|
)
|