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