rails_error_dashboard 0.14.3 → 0.15.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 +45 -17
- data/app/controllers/rails_error_dashboard/application_controller.rb +28 -4
- data/app/controllers/rails_error_dashboard/errors_controller.rb +68 -2
- data/app/controllers/rails_error_dashboard/health_controller.rb +38 -0
- data/app/helpers/rails_error_dashboard/application_helper.rb +10 -2
- data/app/helpers/rails_error_dashboard/backtrace_helper.rb +56 -2
- data/app/jobs/rails_error_dashboard/baseline_alert_job.rb +20 -2
- data/app/jobs/rails_error_dashboard/cascade_detection_job.rb +30 -0
- data/app/jobs/rails_error_dashboard/concerns/plain_channel_message.rb +33 -6
- data/app/jobs/rails_error_dashboard/discord_error_notification_job.rb +1 -1
- data/app/jobs/rails_error_dashboard/pagerduty_error_notification_job.rb +1 -1
- data/app/jobs/rails_error_dashboard/retention_cleanup_job.rb +26 -0
- data/app/jobs/rails_error_dashboard/scheduled_digest_job.rb +6 -1
- data/app/jobs/rails_error_dashboard/slack_error_notification_job.rb +1 -1
- data/app/jobs/rails_error_dashboard/telegram_error_notification_job.rb +25 -0
- data/app/jobs/rails_error_dashboard/webhook_error_notification_job.rb +2 -18
- data/app/models/rails_error_dashboard/error_log.rb +43 -12
- data/app/models/rails_error_dashboard/missing_translation.rb +43 -0
- data/app/views/layouts/rails_error_dashboard.html.erb +24 -1
- data/app/views/rails_error_dashboard/errors/_error_info.html.erb +3 -1
- data/app/views/rails_error_dashboard/errors/_sidebar_metadata.html.erb +3 -1
- data/app/views/rails_error_dashboard/errors/_source_code.html.erb +3 -9
- data/app/views/rails_error_dashboard/errors/activestorage_health_summary.html.erb +28 -0
- data/app/views/rails_error_dashboard/errors/boot_errors.html.erb +130 -0
- data/app/views/rails_error_dashboard/errors/index.html.erb +1 -1
- data/app/views/rails_error_dashboard/errors/missing_translations.html.erb +143 -0
- data/app/views/rails_error_dashboard/errors/settings/_value_badge.html.erb +7 -4
- data/app/views/rails_error_dashboard/errors/settings.html.erb +19 -5
- data/config/locales/de.yml +72 -5
- data/config/locales/en.yml +83 -4
- data/config/locales/es.yml +72 -5
- data/config/locales/fr.yml +72 -5
- data/config/locales/it.yml +72 -5
- data/config/locales/ja.yml +72 -4
- data/config/locales/pl.yml +72 -5
- data/config/locales/pt-BR.yml +72 -5
- data/config/locales/ru.yml +72 -5
- data/config/locales/uk.yml +72 -5
- data/config/locales/zh-CN.yml +72 -4
- data/config/routes.rb +6 -0
- data/db/migrate/20251223000000_create_rails_error_dashboard_complete_schema.rb +10 -2
- data/db/migrate/20251225074653_remove_environment_from_error_logs.rb +9 -0
- data/db/migrate/20261010000001_create_missing_translations.rb +50 -0
- data/lib/generators/rails_error_dashboard/install/install_generator.rb +40 -38
- data/lib/generators/rails_error_dashboard/install/templates/initializer.rb +31 -3
- data/lib/generators/rails_error_dashboard/uninstall/uninstall_generator.rb +11 -2
- data/lib/rails_error_dashboard/commands/assign_error.rb +24 -1
- data/lib/rails_error_dashboard/commands/batch_resolve_errors.rb +9 -1
- data/lib/rails_error_dashboard/commands/flush_missing_translations.rb +103 -0
- data/lib/rails_error_dashboard/commands/log_error.rb +6 -3
- data/lib/rails_error_dashboard/commands/resolve_error.rb +2 -15
- data/lib/rails_error_dashboard/commands/update_error_status.rb +7 -0
- data/lib/rails_error_dashboard/configuration.rb +75 -2
- data/lib/rails_error_dashboard/engine.rb +52 -5
- data/lib/rails_error_dashboard/middleware/rate_limiter.rb +21 -43
- data/lib/rails_error_dashboard/plugin.rb +49 -3
- data/lib/rails_error_dashboard/plugin_registry.rb +61 -12
- data/lib/rails_error_dashboard/queries/boot_errors.rb +76 -0
- data/lib/rails_error_dashboard/queries/dashboard_stats.rb +3 -11
- data/lib/rails_error_dashboard/queries/errors_list.rb +11 -17
- data/lib/rails_error_dashboard/queries/health_status.rb +191 -0
- data/lib/rails_error_dashboard/queries/missing_translation_summary.rb +82 -0
- data/lib/rails_error_dashboard/services/active_storage_probe.rb +91 -0
- data/lib/rails_error_dashboard/services/backtrace_parser.rb +13 -7
- data/lib/rails_error_dashboard/services/backtrace_processor.rb +15 -0
- data/lib/rails_error_dashboard/services/baseline_alert_payload_builder.rb +30 -0
- data/lib/rails_error_dashboard/services/crash_capture.rb +85 -2
- data/lib/rails_error_dashboard/services/digest_builder.rb +9 -6
- data/lib/rails_error_dashboard/services/error_notification_dispatcher.rb +6 -0
- data/lib/rails_error_dashboard/services/exception_filter.rb +4 -1
- data/lib/rails_error_dashboard/services/github_link_generator.rb +25 -9
- data/lib/rails_error_dashboard/services/missing_translation_handler.rb +152 -0
- data/lib/rails_error_dashboard/services/missing_translation_tracker.rb +316 -0
- data/lib/rails_error_dashboard/services/resolution_callbacks.rb +44 -0
- data/lib/rails_error_dashboard/services/severity_classifier.rb +52 -0
- data/lib/rails_error_dashboard/services/solid_queue_config_check.rb +47 -0
- data/lib/rails_error_dashboard/services/source_code_reader.rb +23 -3
- data/lib/rails_error_dashboard/services/telegram_delivery.rb +134 -0
- data/lib/rails_error_dashboard/services/telegram_payload_builder.rb +105 -0
- data/lib/rails_error_dashboard/services/webhook_delivery.rb +63 -0
- data/lib/rails_error_dashboard/services/webhook_signer.rb +80 -0
- data/lib/rails_error_dashboard/version.rb +1 -1
- data/lib/rails_error_dashboard.rb +12 -0
- data/lib/tasks/error_dashboard.rake +43 -2
- metadata +21 -2
|
@@ -57,6 +57,12 @@ module RailsErrorDashboard
|
|
|
57
57
|
# Strip everything before /lib/ruby/ (Ruby stdlib)
|
|
58
58
|
elsif line.include?("/lib/ruby/")
|
|
59
59
|
line.sub(%r{^.*/lib/ruby/}, "ruby/")
|
|
60
|
+
# A frame under Rails.root is that path relative to it, whatever the
|
|
61
|
+
# root is called. The /app/ heuristic below cannot tell a root named
|
|
62
|
+
# /app from the app/ directory: /app/lib/foo.rb became app/lib/foo.rb,
|
|
63
|
+
# which the source reader then looked for at /app/app/lib/foo.rb.
|
|
64
|
+
elsif (root = rails_root_prefix) && line.start_with?(root)
|
|
65
|
+
line.delete_prefix(root)
|
|
60
66
|
# Strip everything before /app/ (application code)
|
|
61
67
|
elsif line.include?("/app/")
|
|
62
68
|
line.sub(%r{^.*/app/}, "app/")
|
|
@@ -69,6 +75,15 @@ module RailsErrorDashboard
|
|
|
69
75
|
end
|
|
70
76
|
# Keep public — used by LogError for cause chain backtrace shortening too.
|
|
71
77
|
|
|
78
|
+
# "#{Rails.root}/", or nil outside Rails.
|
|
79
|
+
def self.rails_root_prefix
|
|
80
|
+
return nil unless defined?(Rails) && Rails.respond_to?(:root) && Rails.root
|
|
81
|
+
|
|
82
|
+
"#{Rails.root}/"
|
|
83
|
+
rescue StandardError
|
|
84
|
+
nil
|
|
85
|
+
end
|
|
86
|
+
|
|
72
87
|
# Calculate a signature hash from backtrace for fuzzy similarity matching
|
|
73
88
|
# Extracts file paths and method names, ignoring line numbers,
|
|
74
89
|
# then produces an order-independent SHA256 digest.
|
|
@@ -9,6 +9,7 @@ module RailsErrorDashboard
|
|
|
9
9
|
# @example
|
|
10
10
|
# BaselineAlertPayloadBuilder.slack_payload(error_log, anomaly_data)
|
|
11
11
|
# BaselineAlertPayloadBuilder.discord_payload(error_log, anomaly_data)
|
|
12
|
+
# BaselineAlertPayloadBuilder.telegram_payload(error_log, anomaly_data)
|
|
12
13
|
# BaselineAlertPayloadBuilder.webhook_payload(error_log, anomaly_data)
|
|
13
14
|
class BaselineAlertPayloadBuilder
|
|
14
15
|
ANOMALY_EMOJIS = {
|
|
@@ -140,6 +141,30 @@ module RailsErrorDashboard
|
|
|
140
141
|
}
|
|
141
142
|
end
|
|
142
143
|
|
|
144
|
+
# Build the Telegram sendMessage payload for a baseline anomaly. HTML
|
|
145
|
+
# parse mode with every value escaped; see TelegramPayloadBuilder for
|
|
146
|
+
# why HTML and not MarkdownV2.
|
|
147
|
+
# @return [Hash] sendMessage payload without chat_id
|
|
148
|
+
def self.telegram_payload(error_log, anomaly_data, locale: I18nStore::DEFAULT_LOCALE)
|
|
149
|
+
tg = TelegramPayloadBuilder
|
|
150
|
+
lines = [
|
|
151
|
+
tg.bold(NotificationHelpers.t("red.notifications.baseline_alert.heading", locale)),
|
|
152
|
+
tg.field(:application, NotificationHelpers.app_name(error_log), locale),
|
|
153
|
+
tg.field(:error_type, error_log.error_type, locale),
|
|
154
|
+
tg.field(:platform, error_log.platform || NotificationHelpers.unknown(locale), locale),
|
|
155
|
+
telegram_field(:severity, anomaly_level(anomaly_data[:level], locale), locale),
|
|
156
|
+
telegram_field(:std_devs, std_devs_text(anomaly_data, locale), locale),
|
|
157
|
+
telegram_field(:threshold, threshold_text(anomaly_data, locale), locale),
|
|
158
|
+
telegram_field(:baseline_type, anomaly_data[:baseline_type] || NotificationHelpers.not_available(locale), locale),
|
|
159
|
+
tg.field(:message, NotificationHelpers.truncate_message(error_log.message, 200), locale, code: true),
|
|
160
|
+
"",
|
|
161
|
+
tg.link(NotificationHelpers.dashboard_url(error_log),
|
|
162
|
+
NotificationHelpers.t("red.notifications.error_alert.view_in_dashboard", locale))
|
|
163
|
+
]
|
|
164
|
+
|
|
165
|
+
tg.html_payload(lines.join("\n"))
|
|
166
|
+
end
|
|
167
|
+
|
|
143
168
|
# Build generic webhook payload for baseline anomaly
|
|
144
169
|
# @param error_log [ErrorLog] The error
|
|
145
170
|
# @param anomaly_data [Hash] Anomaly information
|
|
@@ -179,6 +204,11 @@ module RailsErrorDashboard
|
|
|
179
204
|
NotificationHelpers.t("red.notifications.baseline_alert.labels.#{label}", locale)
|
|
180
205
|
end
|
|
181
206
|
|
|
207
|
+
# Telegram's "<b>Label:</b> value" shape, for a baseline-only label.
|
|
208
|
+
def self.telegram_field(label, value, locale)
|
|
209
|
+
"#{TelegramPayloadBuilder.bold("#{baseline_label(label, locale)}:")} #{TelegramPayloadBuilder.escape(value)}"
|
|
210
|
+
end
|
|
211
|
+
|
|
182
212
|
# Slack's bold-label-newline-value shape, for a baseline-only label.
|
|
183
213
|
def self.baseline_field(label, value, locale)
|
|
184
214
|
"*#{baseline_label(label, locale)}:*\n#{value}"
|
|
@@ -14,6 +14,16 @@ module RailsErrorDashboard
|
|
|
14
14
|
# disk as JSON. On the next boot, `import!` reads crash files and creates
|
|
15
15
|
# ErrorLog records with severity "fatal".
|
|
16
16
|
#
|
|
17
|
+
# BOOT CRASHES: the hook is registered from an engine initializer that
|
|
18
|
+
# runs right after the host's config/initializers (so the configuration
|
|
19
|
+
# is known) and before Rails' :eager_load! finisher. That ordering is the
|
|
20
|
+
# whole point: a Zeitwerk::NameError from eager loading, a SyntaxError in
|
|
21
|
+
# a model, an initializer that raised -- these kill the process before
|
|
22
|
+
# config.after_initialize ever runs, so a hook registered there (as this
|
|
23
|
+
# one was until 0.15) never saw them. A crash captured while
|
|
24
|
+
# Rails.application.initialized? is still false is marked phase "boot",
|
|
25
|
+
# imported with platform "boot_crash", and listed on the Boot Errors page.
|
|
26
|
+
#
|
|
17
27
|
# Safety contract:
|
|
18
28
|
# - Default OFF (opt-in via config.enable_crash_capture)
|
|
19
29
|
# - Writes to tmpfile, NOT the database (connection pool may be closed)
|
|
@@ -24,6 +34,14 @@ module RailsErrorDashboard
|
|
|
24
34
|
class CrashCapture
|
|
25
35
|
FILE_PREFIX = "red_crash_"
|
|
26
36
|
|
|
37
|
+
BOOT_PHASE = "boot"
|
|
38
|
+
RUNTIME_PHASE = "runtime"
|
|
39
|
+
BOOT_PLATFORM = "boot_crash"
|
|
40
|
+
RUNTIME_PLATFORM = "crash_capture"
|
|
41
|
+
|
|
42
|
+
# "expected file /app/app/models/foo.rb to define constant Foo, but didn't"
|
|
43
|
+
ZEITWERK_MESSAGE = /expected file (?<file>.+?) to define constant (?<constant>\S+), but didn't/
|
|
44
|
+
|
|
27
45
|
class << self
|
|
28
46
|
# Enable crash capture. Registers the `at_exit` hook and records boot time.
|
|
29
47
|
# @return [true]
|
|
@@ -108,6 +126,11 @@ module RailsErrorDashboard
|
|
|
108
126
|
# Rails version (may not be available during crash)
|
|
109
127
|
data[:rails_version] = Rails.version if defined?(Rails) && Rails.respond_to?(:version)
|
|
110
128
|
|
|
129
|
+
# Boot or runtime: a process that died before initialize! finished
|
|
130
|
+
# never served a request, which is a different kind of outage.
|
|
131
|
+
data[:phase] = boot_phase? ? BOOT_PHASE : RUNTIME_PHASE
|
|
132
|
+
data[:boot] = boot_detail(exception) if data[:phase] == BOOT_PHASE
|
|
133
|
+
|
|
111
134
|
# Uptime
|
|
112
135
|
if @boot_time
|
|
113
136
|
data[:uptime_seconds] = (Process.clock_gettime(Process::CLOCK_MONOTONIC) - @boot_time).round(1)
|
|
@@ -122,6 +145,51 @@ module RailsErrorDashboard
|
|
|
122
145
|
data
|
|
123
146
|
end
|
|
124
147
|
|
|
148
|
+
# True while Rails is still running its initializers (eager loading
|
|
149
|
+
# included). Rails.application.initialized? flips only after the last
|
|
150
|
+
# finisher has run. Outside Rails (a plain Ruby script) there is no
|
|
151
|
+
# boot phase to speak of.
|
|
152
|
+
def boot_phase?
|
|
153
|
+
defined?(Rails) && Rails.respond_to?(:application) && Rails.application &&
|
|
154
|
+
!Rails.application.initialized?
|
|
155
|
+
rescue
|
|
156
|
+
false
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
# What a boot crash names, for the Boot Errors page: the file and the
|
|
160
|
+
# constant for a Zeitwerk::NameError, else the first backtrace frame
|
|
161
|
+
# under Rails.root (a SyntaxError or an initializer that raised).
|
|
162
|
+
# Paths are made relative to Rails.root so the page reads
|
|
163
|
+
# "app/models/foo.rb", not a deploy directory.
|
|
164
|
+
def boot_detail(exception)
|
|
165
|
+
detail = {}
|
|
166
|
+
root = rails_root_prefix
|
|
167
|
+
|
|
168
|
+
if exception.class.name == "Zeitwerk::NameError" && (m = ZEITWERK_MESSAGE.match(exception.message.to_s))
|
|
169
|
+
detail[:file] = relative_path(m[:file], root)
|
|
170
|
+
detail[:constant] = m[:constant]
|
|
171
|
+
elsif (frame = Array(exception.backtrace).find { |f| root && f.to_s.start_with?(root) })
|
|
172
|
+
detail[:file] = relative_path(frame.to_s.split(":in ").first, root)
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
detail
|
|
176
|
+
rescue
|
|
177
|
+
{}
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
def rails_root_prefix
|
|
181
|
+
return nil unless defined?(Rails) && Rails.respond_to?(:root) && Rails.root
|
|
182
|
+
|
|
183
|
+
"#{Rails.root}/"
|
|
184
|
+
rescue
|
|
185
|
+
nil
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
def relative_path(path, root)
|
|
189
|
+
path = path.to_s
|
|
190
|
+
root && path.start_with?(root) ? path.delete_prefix(root) : path
|
|
191
|
+
end
|
|
192
|
+
|
|
125
193
|
def extract_cause_chain(exception)
|
|
126
194
|
causes = []
|
|
127
195
|
current = exception.cause
|
|
@@ -155,6 +223,8 @@ module RailsErrorDashboard
|
|
|
155
223
|
cause_chain = data["cause_chain"]
|
|
156
224
|
cause_json = cause_chain.is_a?(Array) && cause_chain.any? ? cause_chain.to_json : nil
|
|
157
225
|
|
|
226
|
+
boot = data["phase"] == BOOT_PHASE
|
|
227
|
+
|
|
158
228
|
# Build environment_info from crash metadata
|
|
159
229
|
env_info = {
|
|
160
230
|
ruby_version: data["ruby_version"],
|
|
@@ -164,6 +234,8 @@ module RailsErrorDashboard
|
|
|
164
234
|
uptime_seconds: data["uptime_seconds"],
|
|
165
235
|
gc: data["gc"],
|
|
166
236
|
crash_captured_at: data["timestamp"],
|
|
237
|
+
phase: data["phase"],
|
|
238
|
+
boot: (data["boot"] if boot && data["boot"].is_a?(Hash) && data["boot"].any?),
|
|
167
239
|
source: "crash_capture"
|
|
168
240
|
}.compact
|
|
169
241
|
|
|
@@ -180,7 +252,9 @@ module RailsErrorDashboard
|
|
|
180
252
|
message: data["message"] || "Process crash captured via at_exit hook",
|
|
181
253
|
backtrace: backtrace_text,
|
|
182
254
|
occurred_at: occurred_at,
|
|
183
|
-
platform
|
|
255
|
+
# Boot crashes get their own platform so the Boot Errors page and
|
|
256
|
+
# the index's platform filter can find them.
|
|
257
|
+
platform: boot ? BOOT_PLATFORM : RUNTIME_PLATFORM,
|
|
184
258
|
resolved: false
|
|
185
259
|
}
|
|
186
260
|
|
|
@@ -210,7 +284,16 @@ module RailsErrorDashboard
|
|
|
210
284
|
attributes[:last_seen_at] = occurred_at
|
|
211
285
|
end
|
|
212
286
|
|
|
213
|
-
|
|
287
|
+
# Through the same find-or-increment as every other capture: a crash
|
|
288
|
+
# that repeats on every restart is one row with a count, not a new
|
|
289
|
+
# row per restart -- and a plain create! collided with the unique
|
|
290
|
+
# group-identity index on the second one, which left the file as
|
|
291
|
+
# .failed and the crash unrecorded.
|
|
292
|
+
if attributes[:error_hash]
|
|
293
|
+
Commands::FindOrIncrementError.call(attributes[:error_hash], attributes)
|
|
294
|
+
else
|
|
295
|
+
ErrorLog.create!(attributes)
|
|
296
|
+
end
|
|
214
297
|
|
|
215
298
|
# Delete file after successful import
|
|
216
299
|
File.delete(file)
|
|
@@ -93,9 +93,7 @@ module RailsErrorDashboard
|
|
|
93
93
|
unresolved = scope.where(resolved: false).count
|
|
94
94
|
# Severity is computed from error_type via SeverityClassifier (not a DB column).
|
|
95
95
|
# Count critical+high by matching known error type patterns via SQL WHERE IN.
|
|
96
|
-
|
|
97
|
-
Services::SeverityClassifier::HIGH_SEVERITY_ERROR_TYPES
|
|
98
|
-
critical_high = scope.where(error_type: critical_types).count
|
|
96
|
+
critical_high = scope.where(error_type: critical_and_high_types).count
|
|
99
97
|
|
|
100
98
|
total = resolved + unresolved
|
|
101
99
|
resolution_rate = total > 0 ? (resolved.to_f / total * 100).round(1) : 0
|
|
@@ -141,11 +139,9 @@ module RailsErrorDashboard
|
|
|
141
139
|
end
|
|
142
140
|
|
|
143
141
|
def critical_unresolved
|
|
144
|
-
critical_types = Services::SeverityClassifier::CRITICAL_ERROR_TYPES +
|
|
145
|
-
Services::SeverityClassifier::HIGH_SEVERITY_ERROR_TYPES
|
|
146
142
|
base_scope
|
|
147
143
|
.where(resolved: false)
|
|
148
|
-
.where(error_type:
|
|
144
|
+
.where(error_type: critical_and_high_types)
|
|
149
145
|
.order(occurred_at: :desc)
|
|
150
146
|
.limit(5)
|
|
151
147
|
.map do |error|
|
|
@@ -161,6 +157,13 @@ module RailsErrorDashboard
|
|
|
161
157
|
[]
|
|
162
158
|
end
|
|
163
159
|
|
|
160
|
+
|
|
161
|
+
# Built-in lists adjusted by custom_severity_rules, so the digest's
|
|
162
|
+
# "critical/high" agrees with the dashboard's badges.
|
|
163
|
+
def critical_and_high_types
|
|
164
|
+
Services::SeverityClassifier.error_types_for(:critical) +
|
|
165
|
+
Services::SeverityClassifier.error_types_for(:high)
|
|
166
|
+
end
|
|
164
167
|
def build_comparison
|
|
165
168
|
previous_start = (@days * 2).days.ago
|
|
166
169
|
previous_end = @start_date
|
|
@@ -19,6 +19,7 @@ module RailsErrorDashboard
|
|
|
19
19
|
(config.enable_slack_notifications && config.slack_webhook_url.present?) ||
|
|
20
20
|
(config.enable_email_notifications && config.notification_email_recipients.present?) ||
|
|
21
21
|
(config.enable_discord_notifications && config.discord_webhook_url.present?) ||
|
|
22
|
+
TelegramDelivery.configured?(config) ||
|
|
22
23
|
(config.enable_pagerduty_notifications && config.pagerduty_integration_key.present?) ||
|
|
23
24
|
(config.enable_webhook_notifications && config.webhook_urls.present?) || false
|
|
24
25
|
end
|
|
@@ -58,6 +59,11 @@ module RailsErrorDashboard
|
|
|
58
59
|
fired << "discord"
|
|
59
60
|
end
|
|
60
61
|
|
|
62
|
+
if TelegramDelivery.configured?(config)
|
|
63
|
+
TelegramErrorNotificationJob.perform_later(error_log.id, locale)
|
|
64
|
+
fired << "telegram"
|
|
65
|
+
end
|
|
66
|
+
|
|
61
67
|
if config.enable_pagerduty_notifications && config.pagerduty_integration_key.present?
|
|
62
68
|
PagerdutyErrorNotificationJob.perform_later(error_log.id, locale)
|
|
63
69
|
fired << "pagerduty"
|
|
@@ -28,7 +28,8 @@ module RailsErrorDashboard
|
|
|
28
28
|
end
|
|
29
29
|
|
|
30
30
|
# Check if exception is in the ignored exceptions list
|
|
31
|
-
# Supports
|
|
31
|
+
# Supports class-name strings and Class/Module objects (both match
|
|
32
|
+
# subclasses), and regex patterns (matched against the class name)
|
|
32
33
|
# @param exception [Exception] The exception to check
|
|
33
34
|
# @return [Boolean] true if the exception should be ignored
|
|
34
35
|
def self.ignored?(exception)
|
|
@@ -41,6 +42,8 @@ module RailsErrorDashboard
|
|
|
41
42
|
case ignored
|
|
42
43
|
when String
|
|
43
44
|
exception.is_a?(ignored.constantize)
|
|
45
|
+
when Module
|
|
46
|
+
exception.is_a?(ignored)
|
|
44
47
|
when Regexp
|
|
45
48
|
exception_class_name.match?(ignored)
|
|
46
49
|
else
|
|
@@ -135,20 +135,36 @@ module RailsErrorDashboard
|
|
|
135
135
|
def normalize_file_path
|
|
136
136
|
path = file_path.strip
|
|
137
137
|
|
|
138
|
-
#
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
path =
|
|
138
|
+
# A frame under Rails.root is that path relative to the root, exactly.
|
|
139
|
+
root = rails_root_prefix
|
|
140
|
+
path = path.delete_prefix(root) if root && path.start_with?(root)
|
|
141
|
+
|
|
142
|
+
if path.start_with?("/")
|
|
143
|
+
# An absolute path under some other root: the best guess is the
|
|
144
|
+
# last standard Rails directory in it ("/Users/foo/myapp/app/..." ->
|
|
145
|
+
# "app/...").
|
|
146
|
+
path = path.sub(%r{^/}, "")
|
|
147
|
+
match = path.match(%r{.*/((?:app|lib|config|db|spec|test)/.*)\z})
|
|
148
|
+
else
|
|
149
|
+
# Already relative (BacktraceProcessor stores frames that way): the
|
|
150
|
+
# FIRST standard directory at a segment boundary. The greedy guess
|
|
151
|
+
# above, applied here, linked app/services/config/loader.rb to
|
|
152
|
+
# config/loader.rb and app/models/contest/x.rb to test/x.rb.
|
|
153
|
+
match = path.match(%r{(?:\A|/)((?:app|lib|config|db|spec|test)/.*)\z})
|
|
147
154
|
end
|
|
155
|
+
path = match[1] if match
|
|
148
156
|
|
|
149
157
|
path
|
|
150
158
|
end
|
|
151
159
|
|
|
160
|
+
def rails_root_prefix
|
|
161
|
+
return nil unless defined?(Rails) && Rails.respond_to?(:root) && Rails.root
|
|
162
|
+
|
|
163
|
+
"#{Rails.root}/"
|
|
164
|
+
rescue StandardError
|
|
165
|
+
nil
|
|
166
|
+
end
|
|
167
|
+
|
|
152
168
|
# Generate GitHub link
|
|
153
169
|
#
|
|
154
170
|
# Format: https://github.com/user/repo/blob/{ref}/path/to/file.rb#L42
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RailsErrorDashboard
|
|
4
|
+
module Services
|
|
5
|
+
# Wraps the host app's I18n.exception_handler so every missing translation
|
|
6
|
+
# is counted, then hands the exception to the handler the app already had.
|
|
7
|
+
#
|
|
8
|
+
# WHY THE EXCEPTION HANDLER: I18n.t calls it for every miss
|
|
9
|
+
# (i18n/lib/i18n.rb, handle_exception). There is no
|
|
10
|
+
# ActiveSupport::Notifications event for a miss, so subscribing, as the
|
|
11
|
+
# deprecation tracker does, is not an option.
|
|
12
|
+
#
|
|
13
|
+
# WHY ALSO A VIEW-HELPER HOOK: the view helper t() does NOT go through
|
|
14
|
+
# the exception handler before Rails 8.1. It looks the key up with a
|
|
15
|
+
# sentinel default (so I18n never misses) and renders the "translation
|
|
16
|
+
# missing" span from its own missing_translation method; only 8.1 added
|
|
17
|
+
# an exception_handler call there. Templates are where most misses live,
|
|
18
|
+
# so ViewHelperHook is prepended onto ActionView's TranslationHelper and
|
|
19
|
+
# counts in missing_translation itself. A thread flag set around super
|
|
20
|
+
# keeps the handler from counting the same miss a second time on 8.1.
|
|
21
|
+
#
|
|
22
|
+
# WHAT IS NOT SEEN, by design: a lookup given a :default never misses, and
|
|
23
|
+
# with raise_on_missing_translations (or `raise: true`) I18n raises before
|
|
24
|
+
# reaching any handler -- that miss surfaces as an exception, which RED
|
|
25
|
+
# captures as an error anyway.
|
|
26
|
+
#
|
|
27
|
+
# These are the only places RED touches global I18n and ActionView state,
|
|
28
|
+
# which is why they are opt-in (enable_missing_translation_tracking,
|
|
29
|
+
# default false) and transparent: no return value changes, no exception
|
|
30
|
+
# the original would have raised is swallowed, and uninstall! puts the
|
|
31
|
+
# original handler back (the view hook stays prepended -- Ruby cannot
|
|
32
|
+
# un-prepend -- but does nothing while the handler is not installed).
|
|
33
|
+
# RED's own dashboard strings never arrive here; I18nStore resolves them
|
|
34
|
+
# in a private backend.
|
|
35
|
+
class MissingTranslationHandler
|
|
36
|
+
# Set by ViewHelperHook around super so a Rails 8.1+ missing_translation,
|
|
37
|
+
# which calls the exception handler itself, is counted once, not twice.
|
|
38
|
+
VIEW_HOOK_FLAG = :red_missing_translation_in_view_hook
|
|
39
|
+
|
|
40
|
+
attr_reader :original
|
|
41
|
+
|
|
42
|
+
# @param original [#call, Symbol] the handler being wrapped. I18n allows
|
|
43
|
+
# a Symbol naming a method on I18n itself; it is delegated the same way
|
|
44
|
+
# I18n.handle_exception would.
|
|
45
|
+
def initialize(original)
|
|
46
|
+
@original = original
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# The I18n exception handler contract.
|
|
50
|
+
def call(exception, locale, key, options)
|
|
51
|
+
track(exception, locale, key, options) if exception.is_a?(::I18n::MissingTranslation)
|
|
52
|
+
delegate(exception, locale, key, options)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Prepended onto ActionView::Helpers::TranslationHelper: counts the miss
|
|
56
|
+
# the view helper is about to render as "translation missing".
|
|
57
|
+
module ViewHelperHook
|
|
58
|
+
private
|
|
59
|
+
|
|
60
|
+
def missing_translation(key, options)
|
|
61
|
+
if MissingTranslationHandler.installed?
|
|
62
|
+
MissingTranslationHandler.record_view_miss(key, options)
|
|
63
|
+
Thread.current.thread_variable_set(MissingTranslationHandler::VIEW_HOOK_FLAG, true)
|
|
64
|
+
end
|
|
65
|
+
super
|
|
66
|
+
ensure
|
|
67
|
+
Thread.current.thread_variable_set(MissingTranslationHandler::VIEW_HOOK_FLAG, nil)
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
class << self
|
|
72
|
+
# Wrap the current global handler and hook the view helper. Idempotent:
|
|
73
|
+
# a second install (code reloading, a spec that installs twice) does
|
|
74
|
+
# not stack wrappers.
|
|
75
|
+
# @return [MissingTranslationHandler]
|
|
76
|
+
def install!
|
|
77
|
+
install_view_hook!
|
|
78
|
+
|
|
79
|
+
current = ::I18n.exception_handler
|
|
80
|
+
return current if current.is_a?(self)
|
|
81
|
+
|
|
82
|
+
::I18n.exception_handler = new(current)
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# Put the wrapped handler back. A no-op when nothing is installed.
|
|
86
|
+
def uninstall!
|
|
87
|
+
current = ::I18n.exception_handler
|
|
88
|
+
return unless current.is_a?(self)
|
|
89
|
+
|
|
90
|
+
::I18n.exception_handler = current.original
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def installed?
|
|
94
|
+
::I18n.exception_handler.is_a?(self)
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# Count a miss the view helper reported. Never raises.
|
|
98
|
+
def record_view_miss(key, options)
|
|
99
|
+
options = {} unless options.is_a?(Hash)
|
|
100
|
+
locale = options[:locale] || ::I18n.locale
|
|
101
|
+
full_key = ::I18n.normalize_keys(nil, key, options[:scope]).join(".")
|
|
102
|
+
|
|
103
|
+
MissingTranslationTracker.record(locale: locale.to_s, key: full_key)
|
|
104
|
+
rescue => e
|
|
105
|
+
nil
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
private
|
|
109
|
+
|
|
110
|
+
def install_view_hook!
|
|
111
|
+
return unless defined?(::ActionView::Helpers::TranslationHelper)
|
|
112
|
+
|
|
113
|
+
helper = ::ActionView::Helpers::TranslationHelper
|
|
114
|
+
helper.prepend(ViewHelperHook) unless helper.ancestors.include?(ViewHelperHook)
|
|
115
|
+
rescue => e
|
|
116
|
+
RailsErrorDashboard::Logger.debug(
|
|
117
|
+
"[RailsErrorDashboard] MissingTranslationHandler view hook not installed: #{e.class} - #{e.message}"
|
|
118
|
+
)
|
|
119
|
+
nil
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
private
|
|
124
|
+
|
|
125
|
+
# Counting must never change what the host sees, so it is rescued on its
|
|
126
|
+
# own and the exception is delegated whatever happened here.
|
|
127
|
+
def track(exception, locale, key, options)
|
|
128
|
+
# The view hook already counted this one (Rails 8.1+ calls the
|
|
129
|
+
# handler from inside missing_translation).
|
|
130
|
+
return if Thread.current.thread_variable_get(VIEW_HOOK_FLAG)
|
|
131
|
+
|
|
132
|
+
# The full dotted key as I18n normalised it, scope included, locale
|
|
133
|
+
# excluded: "users.show.greeting", not "en.users.show.greeting".
|
|
134
|
+
scope = options.is_a?(Hash) ? options[:scope] : nil
|
|
135
|
+
full_key = ::I18n.normalize_keys(nil, exception.key, scope).join(".")
|
|
136
|
+
|
|
137
|
+
MissingTranslationTracker.record(locale: locale.to_s, key: full_key)
|
|
138
|
+
rescue => e
|
|
139
|
+
nil
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def delegate(exception, locale, key, options)
|
|
143
|
+
case original
|
|
144
|
+
when Symbol
|
|
145
|
+
::I18n.send(original, exception, locale, key, options)
|
|
146
|
+
else
|
|
147
|
+
original.call(exception, locale, key, options)
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
end
|