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
|
@@ -3,6 +3,13 @@
|
|
|
3
3
|
module RailsErrorDashboard
|
|
4
4
|
# Registry for managing plugins
|
|
5
5
|
# Provides plugin registration and event dispatching
|
|
6
|
+
#
|
|
7
|
+
# A plugin is host code running inside RED's own paths, so nothing it does
|
|
8
|
+
# may break them: a `name` that raises (the base class raises
|
|
9
|
+
# NotImplementedError, a ScriptError that `rescue => e` does not catch), an
|
|
10
|
+
# `on_register` that raises, or an `enabled?` that raises is logged and
|
|
11
|
+
# treated as "no", never propagated. A plugin is in the list only once
|
|
12
|
+
# registration has fully succeeded.
|
|
6
13
|
class PluginRegistry
|
|
7
14
|
class << self
|
|
8
15
|
# Get all registered plugins
|
|
@@ -12,26 +19,41 @@ module RailsErrorDashboard
|
|
|
12
19
|
|
|
13
20
|
# Register a plugin
|
|
14
21
|
# @param plugin [Plugin] The plugin instance to register
|
|
22
|
+
# @return [Boolean] true when registered, false when skipped
|
|
23
|
+
# @raise [ArgumentError] not a Plugin, or no usable name
|
|
15
24
|
def register(plugin)
|
|
16
25
|
unless plugin.is_a?(Plugin)
|
|
17
26
|
raise ArgumentError, "Plugin must be an instance of RailsErrorDashboard::Plugin"
|
|
18
27
|
end
|
|
19
28
|
|
|
20
|
-
|
|
21
|
-
|
|
29
|
+
name = name_of(plugin)
|
|
30
|
+
if name.blank?
|
|
31
|
+
raise ArgumentError, "Plugin #{plugin.class} must implement #name and return a non-blank String"
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
if plugins.any? { |p| name_of(p) == name }
|
|
35
|
+
RailsErrorDashboard::Logger.warn("Plugin '#{name}' is already registered, skipping")
|
|
36
|
+
return false
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
begin
|
|
40
|
+
plugin.on_register
|
|
41
|
+
rescue => e
|
|
42
|
+
RailsErrorDashboard::Logger.error(
|
|
43
|
+
"[RailsErrorDashboard] Plugin '#{name}' failed in on_register: #{e.class} - #{e.message}; not registered"
|
|
44
|
+
)
|
|
22
45
|
return false
|
|
23
46
|
end
|
|
24
47
|
|
|
25
48
|
plugins << plugin
|
|
26
|
-
plugin
|
|
27
|
-
RailsErrorDashboard::Logger.info("Registered plugin: #{plugin.name} (#{plugin.version})")
|
|
49
|
+
RailsErrorDashboard::Logger.info("Registered plugin: #{name} (#{version_of(plugin)})")
|
|
28
50
|
true
|
|
29
51
|
end
|
|
30
52
|
|
|
31
53
|
# Unregister a plugin by name
|
|
32
54
|
# @param plugin_name [String] The name of the plugin to unregister
|
|
33
55
|
def unregister(plugin_name)
|
|
34
|
-
plugins.reject! { |p| p
|
|
56
|
+
plugins.reject! { |p| name_of(p) == plugin_name }
|
|
35
57
|
end
|
|
36
58
|
|
|
37
59
|
# Clear all plugins (useful for testing)
|
|
@@ -43,7 +65,7 @@ module RailsErrorDashboard
|
|
|
43
65
|
# @param plugin_name [String] The name of the plugin
|
|
44
66
|
# @return [Plugin, nil] The plugin instance or nil if not found
|
|
45
67
|
def find(plugin_name)
|
|
46
|
-
plugins.find { |p| p
|
|
68
|
+
plugins.find { |p| name_of(p) == plugin_name }
|
|
47
69
|
end
|
|
48
70
|
|
|
49
71
|
# Dispatch an event to all registered plugins
|
|
@@ -51,7 +73,7 @@ module RailsErrorDashboard
|
|
|
51
73
|
# @param args [Array] Arguments to pass to the event handler
|
|
52
74
|
def dispatch(event_name, *args)
|
|
53
75
|
plugins.each do |plugin|
|
|
54
|
-
next unless
|
|
76
|
+
next unless enabled?(plugin)
|
|
55
77
|
|
|
56
78
|
plugin.safe_execute(event_name, *args)
|
|
57
79
|
end
|
|
@@ -69,20 +91,47 @@ module RailsErrorDashboard
|
|
|
69
91
|
|
|
70
92
|
# Get list of plugin names
|
|
71
93
|
def names
|
|
72
|
-
plugins.map(
|
|
94
|
+
plugins.map { |p| name_of(p) }
|
|
73
95
|
end
|
|
74
96
|
|
|
75
97
|
# Get plugin information for debugging
|
|
76
98
|
def info
|
|
77
99
|
plugins.map do |plugin|
|
|
78
100
|
{
|
|
79
|
-
name: plugin
|
|
80
|
-
version: plugin
|
|
81
|
-
description: plugin.description,
|
|
82
|
-
enabled:
|
|
101
|
+
name: name_of(plugin),
|
|
102
|
+
version: version_of(plugin),
|
|
103
|
+
description: (plugin.description rescue nil),
|
|
104
|
+
enabled: enabled?(plugin)
|
|
83
105
|
}
|
|
84
106
|
end
|
|
85
107
|
end
|
|
108
|
+
|
|
109
|
+
private
|
|
110
|
+
|
|
111
|
+
# A plugin's name, or nil when #name is missing or raises.
|
|
112
|
+
def name_of(plugin)
|
|
113
|
+
value = plugin.name
|
|
114
|
+
value.is_a?(String) ? value : value&.to_s
|
|
115
|
+
rescue StandardError, NotImplementedError
|
|
116
|
+
nil
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def version_of(plugin)
|
|
120
|
+
plugin.version.to_s
|
|
121
|
+
rescue StandardError, NotImplementedError
|
|
122
|
+
"unknown"
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
# enabled? is host code too. A raise is logged once per dispatch and
|
|
126
|
+
# read as disabled, so the other plugins still run.
|
|
127
|
+
def enabled?(plugin)
|
|
128
|
+
plugin.enabled? ? true : false
|
|
129
|
+
rescue => e
|
|
130
|
+
RailsErrorDashboard::Logger.error(
|
|
131
|
+
"[RailsErrorDashboard] Plugin '#{name_of(plugin) || plugin.class}' failed in enabled?: #{e.class} - #{e.message}; treated as disabled"
|
|
132
|
+
)
|
|
133
|
+
false
|
|
134
|
+
end
|
|
86
135
|
end
|
|
87
136
|
end
|
|
88
137
|
end
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RailsErrorDashboard
|
|
4
|
+
module Queries
|
|
5
|
+
# Query: the crashes that happened while the process was still booting.
|
|
6
|
+
#
|
|
7
|
+
# A boot crash (a Zeitwerk::NameError from eager loading, a SyntaxError in
|
|
8
|
+
# a model, an initializer that raised) is imported by CrashCapture with
|
|
9
|
+
# platform "boot_crash" and the file and constant it names, when it names
|
|
10
|
+
# them, in environment_info.boot. One entry per error row, most recent
|
|
11
|
+
# first: the question is "what stopped the last deploy", not a frequency
|
|
12
|
+
# ranking.
|
|
13
|
+
class BootErrors
|
|
14
|
+
PLATFORM = "boot_crash"
|
|
15
|
+
|
|
16
|
+
def self.call(days = 30, application_id: nil)
|
|
17
|
+
new(days, application_id: application_id).call
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def initialize(days = 30, application_id: nil)
|
|
21
|
+
@days = days
|
|
22
|
+
@application_id = application_id
|
|
23
|
+
@start_date = days.days.ago
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# @return [Hash] entries: [{id:, error_type:, message:, file:, constant:,
|
|
27
|
+
# count:, first_seen:, last_seen:, resolved:}], unresolved_count: Integer
|
|
28
|
+
def call
|
|
29
|
+
rows = base_query
|
|
30
|
+
.order(last_seen_at: :desc, id: :desc)
|
|
31
|
+
.pluck(:id, :error_type, :message, :environment_info, :occurrence_count,
|
|
32
|
+
:first_seen_at, :last_seen_at, :resolved)
|
|
33
|
+
|
|
34
|
+
entries = rows.map do |id, error_type, message, env_info, count, first_seen, last_seen, resolved|
|
|
35
|
+
boot = boot_detail(env_info)
|
|
36
|
+
{
|
|
37
|
+
id: id,
|
|
38
|
+
error_type: error_type.to_s,
|
|
39
|
+
message: message.to_s,
|
|
40
|
+
file: boot["file"].presence,
|
|
41
|
+
constant: boot["constant"].presence,
|
|
42
|
+
count: [ count.to_i, 1 ].max,
|
|
43
|
+
first_seen: first_seen,
|
|
44
|
+
last_seen: last_seen,
|
|
45
|
+
resolved: resolved ? true : false
|
|
46
|
+
}
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
{ entries: entries, unresolved_count: entries.count { |e| !e[:resolved] } }
|
|
50
|
+
rescue => e
|
|
51
|
+
Rails.logger.error("[RailsErrorDashboard] BootErrors query failed: #{e.class}: #{e.message}")
|
|
52
|
+
{ entries: [], unresolved_count: 0 }
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
private
|
|
56
|
+
|
|
57
|
+
def base_query
|
|
58
|
+
scope = ErrorLog.where(platform: PLATFORM).where("last_seen_at >= ?", @start_date)
|
|
59
|
+
scope = scope.where(application_id: @application_id) if @application_id.present?
|
|
60
|
+
scope
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# environment_info is JSON text; the boot detail is optional and the
|
|
64
|
+
# column may hold anything an older row wrote, so every step is guarded.
|
|
65
|
+
def boot_detail(env_info)
|
|
66
|
+
return {} if env_info.blank?
|
|
67
|
+
|
|
68
|
+
parsed = env_info.is_a?(String) ? JSON.parse(env_info) : env_info
|
|
69
|
+
boot = parsed.is_a?(Hash) ? parsed["boot"] : nil
|
|
70
|
+
boot.is_a?(Hash) ? boot : {}
|
|
71
|
+
rescue JSON::ParserError, TypeError
|
|
72
|
+
{}
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
@@ -305,19 +305,11 @@ module RailsErrorDashboard
|
|
|
305
305
|
# breakdown makes them agree by construction instead of by luck --
|
|
306
306
|
# asserted by spec/queries/dashboard_breakdowns_event_volume_spec.rb.
|
|
307
307
|
def errors_by_severity_7d
|
|
308
|
-
critical = Services::SeverityClassifier::CRITICAL_ERROR_TYPES
|
|
309
|
-
high = Services::SeverityClassifier::HIGH_SEVERITY_ERROR_TYPES
|
|
310
|
-
medium = Services::SeverityClassifier::MEDIUM_SEVERITY_ERROR_TYPES
|
|
311
|
-
|
|
312
308
|
totals = { critical: 0, high: 0, medium: 0, low: 0 }
|
|
313
309
|
weekly_volume.by_group_attribute(:error_type).each do |error_type, count|
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
elsif medium.include?(error_type) then :medium
|
|
318
|
-
else :low
|
|
319
|
-
end
|
|
320
|
-
totals[bucket] += count
|
|
310
|
+
# classify honours custom_severity_rules; the bare constants did not.
|
|
311
|
+
bucket = Services::SeverityClassifier.classify(error_type)
|
|
312
|
+
totals[totals.key?(bucket) ? bucket : :low] += count
|
|
321
313
|
end
|
|
322
314
|
totals
|
|
323
315
|
end
|
|
@@ -150,33 +150,27 @@ module RailsErrorDashboard
|
|
|
150
150
|
end
|
|
151
151
|
end
|
|
152
152
|
|
|
153
|
+
# RED's own connection: with a separate error database, the main
|
|
154
|
+
# database can use a different adapter.
|
|
153
155
|
def postgresql?
|
|
154
|
-
|
|
156
|
+
ErrorLogsRecord.connection.adapter_name.downcase == "postgresql"
|
|
155
157
|
end
|
|
156
158
|
|
|
157
159
|
def filter_by_severity(query)
|
|
158
160
|
return query unless @filters[:severity].present?
|
|
159
161
|
|
|
160
|
-
#
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
when :high
|
|
165
|
-
Services::SeverityClassifier
|
|
166
|
-
when :medium
|
|
167
|
-
Services::SeverityClassifier::MEDIUM_SEVERITY_ERROR_TYPES
|
|
162
|
+
# Severity is not a column: it is classified from error_type, built-in
|
|
163
|
+
# lists plus config.custom_severity_rules. The classifier owns both, so
|
|
164
|
+
# the filter agrees with the badges by construction.
|
|
165
|
+
case @filters[:severity].to_sym
|
|
166
|
+
when :critical, :high, :medium
|
|
167
|
+
query.where(error_type: Services::SeverityClassifier.error_types_for(@filters[:severity]))
|
|
168
168
|
when :low
|
|
169
169
|
# Low severity = everything NOT in the other categories
|
|
170
|
-
|
|
171
|
-
Services::SeverityClassifier::HIGH_SEVERITY_ERROR_TYPES +
|
|
172
|
-
Services::SeverityClassifier::MEDIUM_SEVERITY_ERROR_TYPES
|
|
173
|
-
# Use NOT IN to filter out categorized errors
|
|
174
|
-
return query.where.not(error_type: all_categorized)
|
|
170
|
+
query.where.not(error_type: Services::SeverityClassifier.categorized_error_types)
|
|
175
171
|
else
|
|
176
|
-
|
|
172
|
+
query
|
|
177
173
|
end
|
|
178
|
-
|
|
179
|
-
query.where(error_type: error_types)
|
|
180
174
|
end
|
|
181
175
|
|
|
182
176
|
# Phase 3: Workflow filter methods
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RailsErrorDashboard
|
|
4
|
+
module Queries
|
|
5
|
+
# Query: is the error dashboard itself able to do its job right now?
|
|
6
|
+
#
|
|
7
|
+
# "Who watches the watchmen": if the error database is unreachable, or the
|
|
8
|
+
# async queue that carries captures is misconfigured, no error ever reaches
|
|
9
|
+
# the dashboard and nothing else will say so. GET /health answers that
|
|
10
|
+
# question for an uptime monitor.
|
|
11
|
+
#
|
|
12
|
+
# Four checks, each individually rescued so one failing probe never hides
|
|
13
|
+
# the others:
|
|
14
|
+
#
|
|
15
|
+
# database SELECT 1 on the ERROR database (ErrorLogsRecord's
|
|
16
|
+
# pool, so use_separate_database is honoured), plus
|
|
17
|
+
# "are RED's tables there". The one check that can make
|
|
18
|
+
# the whole answer "down".
|
|
19
|
+
# errors last capture time and the last-24h count: cheap,
|
|
20
|
+
# both served by the occurred_at index.
|
|
21
|
+
# queue the Active Job adapter RED's jobs run on, whether
|
|
22
|
+
# captures are async, and the Solid Queue config
|
|
23
|
+
# problems the boot check found (memoised per process).
|
|
24
|
+
# storm_protection the circuit breaker's current state.
|
|
25
|
+
# active_storage one existence check against the host's configured
|
|
26
|
+
# storage service (Services::ActiveStorageProbe).
|
|
27
|
+
# Storage is the app's dependency, not RED's, so a
|
|
28
|
+
# failure here degrades the answer rather than taking
|
|
29
|
+
# it down; "skipped" when ActiveStorage is not set up.
|
|
30
|
+
#
|
|
31
|
+
# Budget: one DB round trip plus two indexed queries. No COUNT(*) over the
|
|
32
|
+
# whole table (unbounded on a large install), no file reads (the Solid
|
|
33
|
+
# Queue check is memoised), nothing external. Never raises.
|
|
34
|
+
class HealthStatus
|
|
35
|
+
OK = "ok"
|
|
36
|
+
DEGRADED = "degraded"
|
|
37
|
+
DOWN = "down"
|
|
38
|
+
# A check that does not apply to this host: neutral for the overall answer.
|
|
39
|
+
SKIPPED = "skipped"
|
|
40
|
+
|
|
41
|
+
def self.call
|
|
42
|
+
new.call
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def call
|
|
46
|
+
started = monotonic_now
|
|
47
|
+
|
|
48
|
+
database = check_database
|
|
49
|
+
checks = {
|
|
50
|
+
database: database,
|
|
51
|
+
# Skipped when the database is down: the queries would only fail
|
|
52
|
+
# again, slower, against a server that is not answering.
|
|
53
|
+
errors: database[:status] == DOWN ? skipped("database unreachable") : check_errors,
|
|
54
|
+
queue: check_queue,
|
|
55
|
+
storm_protection: check_storm_protection,
|
|
56
|
+
active_storage: check_active_storage
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
{
|
|
60
|
+
status: overall_status(checks),
|
|
61
|
+
version: RailsErrorDashboard::VERSION,
|
|
62
|
+
timestamp: Time.current.utc.iso8601,
|
|
63
|
+
checks: checks,
|
|
64
|
+
duration_ms: elapsed_ms(started)
|
|
65
|
+
}
|
|
66
|
+
rescue StandardError => e
|
|
67
|
+
# Last line of defence: the controller must always have something to
|
|
68
|
+
# render, and a health endpoint that 500s is the wrong kind of alarm.
|
|
69
|
+
{
|
|
70
|
+
status: DOWN,
|
|
71
|
+
version: RailsErrorDashboard::VERSION,
|
|
72
|
+
timestamp: Time.current.utc.iso8601,
|
|
73
|
+
checks: {},
|
|
74
|
+
error: e.class.name
|
|
75
|
+
}
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# The HTTP status the controller should answer with.
|
|
79
|
+
def self.http_status_for(result)
|
|
80
|
+
result[:status] == DOWN ? 503 : 200
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
private
|
|
84
|
+
|
|
85
|
+
def check_database
|
|
86
|
+
config = RailsErrorDashboard.configuration
|
|
87
|
+
started = monotonic_now
|
|
88
|
+
|
|
89
|
+
adapter = nil
|
|
90
|
+
ErrorLogsRecord.connection_pool.with_connection do |connection|
|
|
91
|
+
adapter = connection.adapter_name
|
|
92
|
+
connection.select_value("SELECT 1")
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
tables_present = ErrorLog.table_exists?
|
|
96
|
+
|
|
97
|
+
{
|
|
98
|
+
status: tables_present ? OK : DOWN,
|
|
99
|
+
adapter: adapter,
|
|
100
|
+
separate_database: config.use_separate_database ? true : false,
|
|
101
|
+
tables_present: tables_present,
|
|
102
|
+
latency_ms: elapsed_ms(started)
|
|
103
|
+
}.tap do |check|
|
|
104
|
+
check[:reason] = "missing_tables" unless tables_present
|
|
105
|
+
end
|
|
106
|
+
rescue StandardError => e
|
|
107
|
+
{
|
|
108
|
+
status: DOWN,
|
|
109
|
+
separate_database: RailsErrorDashboard.configuration.use_separate_database ? true : false,
|
|
110
|
+
# Class only: the message can carry a host name or a connection string.
|
|
111
|
+
error: e.class.name
|
|
112
|
+
}
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def check_errors
|
|
116
|
+
last = ErrorLog.maximum(:occurred_at)
|
|
117
|
+
{
|
|
118
|
+
status: OK,
|
|
119
|
+
last_error_at: last&.utc&.iso8601,
|
|
120
|
+
last_24h: ErrorLog.where("occurred_at >= ?", 24.hours.ago).count
|
|
121
|
+
}
|
|
122
|
+
rescue StandardError => e
|
|
123
|
+
{ status: DEGRADED, error: e.class.name }
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def check_queue
|
|
127
|
+
config = RailsErrorDashboard.configuration
|
|
128
|
+
problems = Services::SolidQueueConfigCheck.current_problems
|
|
129
|
+
|
|
130
|
+
{
|
|
131
|
+
status: problems.empty? ? OK : DEGRADED,
|
|
132
|
+
async_logging: config.async_logging ? true : false,
|
|
133
|
+
adapter: ApplicationJob.queue_adapter_name.to_s,
|
|
134
|
+
problems: problems
|
|
135
|
+
}
|
|
136
|
+
rescue StandardError => e
|
|
137
|
+
{ status: DEGRADED, error: e.class.name }
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def check_storm_protection
|
|
141
|
+
enabled = RailsErrorDashboard.configuration.enable_storm_protection ? true : false
|
|
142
|
+
state = enabled ? Services::StormProtection::Gate.state.to_s : "closed"
|
|
143
|
+
|
|
144
|
+
{
|
|
145
|
+
# Any state but closed means captures are being shed or merely
|
|
146
|
+
# counted: the dashboard is lagging reality by design, and a monitor
|
|
147
|
+
# should know.
|
|
148
|
+
status: state == "closed" ? OK : DEGRADED,
|
|
149
|
+
enabled: enabled,
|
|
150
|
+
state: state
|
|
151
|
+
}
|
|
152
|
+
rescue StandardError => e
|
|
153
|
+
{ status: DEGRADED, error: e.class.name }
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
def check_active_storage
|
|
157
|
+
probe = Services::ActiveStorageProbe.call
|
|
158
|
+
case probe[:status]
|
|
159
|
+
when Services::ActiveStorageProbe::OK
|
|
160
|
+
{ status: OK, service: probe[:service], latency_ms: probe[:latency_ms] }
|
|
161
|
+
when Services::ActiveStorageProbe::SKIPPED
|
|
162
|
+
{ status: SKIPPED, reason: probe[:reason] }
|
|
163
|
+
else
|
|
164
|
+
{ status: DEGRADED, service: probe[:service], error: probe[:error] }
|
|
165
|
+
end
|
|
166
|
+
rescue StandardError => e
|
|
167
|
+
{ status: DEGRADED, error: e.class.name }
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
def skipped(reason)
|
|
171
|
+
{ status: DEGRADED, skipped: reason }
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
def overall_status(checks)
|
|
175
|
+
statuses = checks.values.map { |c| c[:status] }
|
|
176
|
+
return DOWN if statuses.include?(DOWN)
|
|
177
|
+
return DEGRADED if statuses.include?(DEGRADED)
|
|
178
|
+
|
|
179
|
+
OK
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
def monotonic_now
|
|
183
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
def elapsed_ms(started)
|
|
187
|
+
((monotonic_now - started) * 1000).round(2)
|
|
188
|
+
end
|
|
189
|
+
end
|
|
190
|
+
end
|
|
191
|
+
end
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RailsErrorDashboard
|
|
4
|
+
module Queries
|
|
5
|
+
# Query: the host app's missing translation keys, most-missed first.
|
|
6
|
+
#
|
|
7
|
+
# Reads the missing_translations table (indexed SQL, no JSON parsing) for
|
|
8
|
+
# rows last seen inside the window. One entry per (locale, key): the same
|
|
9
|
+
# key missing in two locales is two rows, because fixing one locale file
|
|
10
|
+
# does not fix the other.
|
|
11
|
+
class MissingTranslationSummary
|
|
12
|
+
def self.call(days = 30, application_id: nil)
|
|
13
|
+
new(days, application_id: application_id).call
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def initialize(days = 30, application_id: nil)
|
|
17
|
+
@days = days
|
|
18
|
+
@application_id = application_id
|
|
19
|
+
@start_date = days.days.ago
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# @return [Hash] entries: [{locale:, key:, source:, count:, first_seen:, last_seen:}],
|
|
23
|
+
# overflow_count: Integer, locales: [String]
|
|
24
|
+
# One pluck of every in-window row; entries, the locale set and the
|
|
25
|
+
# overflow total all come out of it, so a page view is two statements
|
|
26
|
+
# (table_exists? and this) whatever the row count.
|
|
27
|
+
def call
|
|
28
|
+
return empty unless MissingTranslation.table_exists?
|
|
29
|
+
|
|
30
|
+
merged = {}
|
|
31
|
+
overflow = 0
|
|
32
|
+
|
|
33
|
+
base_query.pluck(:locale, :translation_key, :source, :miss_count, :first_seen_at, :last_seen_at)
|
|
34
|
+
.each do |locale, key, source, count, first_seen, last_seen|
|
|
35
|
+
# Misses the tracker could not attribute to a key because its
|
|
36
|
+
# buffer was full: kept out of the listing (they belong to no
|
|
37
|
+
# key) but totalled, so the page never silently understates.
|
|
38
|
+
if key == MissingTranslation::OVERFLOW_KEY
|
|
39
|
+
overflow += count.to_i
|
|
40
|
+
next
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Merged by (locale, key) rather than trusted to be unique: the
|
|
44
|
+
# upsert index includes application_id, and a NULL there (no
|
|
45
|
+
# application_name configured) lets two threads' first writes of
|
|
46
|
+
# the same key both land, on every database. Two rows for one key
|
|
47
|
+
# would read as two problems; summing them reads as one.
|
|
48
|
+
entry = merged[[ locale.to_s, key.to_s ]] ||= {
|
|
49
|
+
locale: locale.to_s, key: key.to_s, source: nil, count: 0, first_seen: nil, last_seen: nil
|
|
50
|
+
}
|
|
51
|
+
entry[:count] += count.to_i
|
|
52
|
+
entry[:source] ||= source.presence
|
|
53
|
+
entry[:first_seen] = [ entry[:first_seen], first_seen ].compact.min
|
|
54
|
+
entry[:last_seen] = [ entry[:last_seen], last_seen ].compact.max
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
entries = merged.values.sort_by { |e| [ -e[:count], -(e[:last_seen]&.to_f || 0) ] }
|
|
58
|
+
|
|
59
|
+
{
|
|
60
|
+
entries: entries,
|
|
61
|
+
overflow_count: overflow,
|
|
62
|
+
locales: entries.map { |e| e[:locale] }.uniq.sort
|
|
63
|
+
}
|
|
64
|
+
rescue => e
|
|
65
|
+
Rails.logger.error("[RailsErrorDashboard] MissingTranslationSummary query failed: #{e.class}: #{e.message}")
|
|
66
|
+
empty
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
private
|
|
70
|
+
|
|
71
|
+
def empty
|
|
72
|
+
{ entries: [], overflow_count: 0, locales: [] }
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def base_query
|
|
76
|
+
scope = MissingTranslation.seen_since(@start_date)
|
|
77
|
+
scope = scope.for_application(@application_id) if @application_id.present?
|
|
78
|
+
scope
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RailsErrorDashboard
|
|
4
|
+
module Services
|
|
5
|
+
# Is the host's ActiveStorage service reachable right now?
|
|
6
|
+
#
|
|
7
|
+
# One existence check for a key that is never stored. The answer itself
|
|
8
|
+
# (false) is irrelevant; the round trip is the test: Disk stats a path,
|
|
9
|
+
# S3/GCS/Azure make one HEAD-style request. A raise (credentials, DNS, a
|
|
10
|
+
# bucket that no longer exists, a disk that is not mounted) means the
|
|
11
|
+
# service is down for the app too, which is what an operator wants to
|
|
12
|
+
# know before the upload errors start.
|
|
13
|
+
#
|
|
14
|
+
# Read by the /health endpoint and the ActiveStorage Health page, both
|
|
15
|
+
# on demand: nothing here runs on the capture path. Never raises.
|
|
16
|
+
class ActiveStorageProbe
|
|
17
|
+
# Namespaced so it cannot collide with a real blob key, which are
|
|
18
|
+
# 28-character base36 tokens.
|
|
19
|
+
PROBE_KEY = "rails_error_dashboard/health-probe"
|
|
20
|
+
|
|
21
|
+
OK = "ok"
|
|
22
|
+
DOWN = "down"
|
|
23
|
+
SKIPPED = "skipped"
|
|
24
|
+
|
|
25
|
+
def self.call
|
|
26
|
+
new.call
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def call
|
|
30
|
+
return skipped("active_storage_not_loaded") unless defined?(::ActiveStorage)
|
|
31
|
+
# Checked before ActiveStorage::Blob is touched: loading that class
|
|
32
|
+
# reads config/storage.yml and raises when the file is missing, which
|
|
33
|
+
# on a host that never set storage up is "not configured", not "down".
|
|
34
|
+
return skipped("no_service_configured") unless configured?
|
|
35
|
+
|
|
36
|
+
service = storage_service
|
|
37
|
+
return skipped("no_service_configured") if service.nil?
|
|
38
|
+
|
|
39
|
+
started = monotonic_now
|
|
40
|
+
service.exist?(PROBE_KEY)
|
|
41
|
+
|
|
42
|
+
{ status: OK, service: service_name(service), latency_ms: elapsed_ms(started) }
|
|
43
|
+
rescue => e
|
|
44
|
+
# Class only: the message can carry a bucket name, a host or a path.
|
|
45
|
+
{ status: DOWN, service: safe_service_name(service), error: e.class.name }
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
private
|
|
49
|
+
|
|
50
|
+
def skipped(reason)
|
|
51
|
+
{ status: SKIPPED, reason: reason }
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# The configured service object. A seam: specs replace it with a double
|
|
55
|
+
# rather than stubbing ActiveStorage::Blob, whose class load reads the
|
|
56
|
+
# blobs table's schema (absent in the dummy app).
|
|
57
|
+
def storage_service
|
|
58
|
+
::ActiveStorage::Blob.service
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# config.active_storage.service names the service chosen for this
|
|
62
|
+
# environment; nil when the app never configured storage.
|
|
63
|
+
def configured?
|
|
64
|
+
::Rails.application.config.active_storage.service.present?
|
|
65
|
+
rescue
|
|
66
|
+
false
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# The name from config/storage.yml ("amazon", "local"), which is what
|
|
70
|
+
# the operator recognises; the class otherwise.
|
|
71
|
+
def service_name(service)
|
|
72
|
+
name = service.respond_to?(:name) ? service.name : nil
|
|
73
|
+
name.presence&.to_s || service.class.name.to_s.demodulize.delete_suffix("Service").downcase
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def safe_service_name(service)
|
|
77
|
+
service ? service_name(service) : nil
|
|
78
|
+
rescue
|
|
79
|
+
nil
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def monotonic_now
|
|
83
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def elapsed_ms(started)
|
|
87
|
+
((monotonic_now - started) * 1000).round(2)
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|
|
@@ -94,15 +94,21 @@ module RailsErrorDashboard
|
|
|
94
94
|
end
|
|
95
95
|
|
|
96
96
|
def app_code?(file_path)
|
|
97
|
-
#
|
|
98
|
-
|
|
97
|
+
# A gem's own app/ or lib/ (an engine's models, a gem's lib) is not
|
|
98
|
+
# the host's code: it got the View Source button and a link into the
|
|
99
|
+
# host's repository for a file that is not there.
|
|
100
|
+
return false if gem_code?(file_path) || ruby_code?(file_path)
|
|
99
101
|
|
|
100
|
-
# Match /
|
|
101
|
-
|
|
102
|
-
return
|
|
102
|
+
# Match /app/ or app/ (shortened paths start without leading /), and
|
|
103
|
+
# the other host directories a Rails.root-relative frame can start with
|
|
104
|
+
return true if file_path.include?("/app/") || file_path.start_with?("app/", "config/", "db/")
|
|
103
105
|
|
|
104
|
-
|
|
105
|
-
|
|
106
|
+
# Match /lib/ or lib/ (the host's lib/, gems and stdlib excluded above)
|
|
107
|
+
file_path.include?("/lib/") || file_path.start_with?("lib/")
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def ruby_code?(file_path)
|
|
111
|
+
file_path.include?("/ruby/") || file_path.start_with?("ruby/")
|
|
106
112
|
end
|
|
107
113
|
|
|
108
114
|
def gem_code?(file_path)
|