rails_error_dashboard 0.14.4 → 0.16.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 +19 -8
- data/app/controllers/rails_error_dashboard/application_controller.rb +28 -4
- data/app/controllers/rails_error_dashboard/errors_controller.rb +97 -4
- data/app/controllers/rails_error_dashboard/health_controller.rb +38 -0
- data/app/controllers/rails_error_dashboard/webhooks_controller.rb +10 -9
- data/app/helpers/rails_error_dashboard/application_helper.rb +2 -2
- data/app/helpers/rails_error_dashboard/backtrace_helper.rb +63 -16
- data/app/helpers/rails_error_dashboard/overview_helper.rb +4 -0
- 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 +31 -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 +54 -20
- data/app/models/rails_error_dashboard/error_occurrence.rb +1 -0
- data/app/models/rails_error_dashboard/error_occurrence_context.rb +14 -0
- data/app/models/rails_error_dashboard/missing_translation.rb +43 -0
- data/app/views/layouts/rails_error_dashboard.html.erb +14 -2
- data/app/views/rails_error_dashboard/errors/_captured_occurrences.html.erb +45 -0
- data/app/views/rails_error_dashboard/errors/_error_info.html.erb +12 -3
- data/app/views/rails_error_dashboard/errors/_occurrence_banner.html.erb +12 -0
- 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/overview.html.erb +10 -7
- data/app/views/rails_error_dashboard/errors/platform_comparison.html.erb +16 -11
- data/app/views/rails_error_dashboard/errors/settings/_value_badge.html.erb +7 -4
- data/app/views/rails_error_dashboard/errors/settings.html.erb +23 -8
- data/app/views/rails_error_dashboard/errors/show.html.erb +7 -4
- data/config/locales/de.yml +87 -5
- data/config/locales/en.yml +99 -4
- data/config/locales/es.yml +88 -5
- data/config/locales/fr.yml +88 -5
- data/config/locales/it.yml +88 -5
- data/config/locales/ja.yml +86 -4
- data/config/locales/pl.yml +89 -5
- data/config/locales/pt-BR.yml +88 -5
- data/config/locales/ru.yml +89 -5
- data/config/locales/uk.yml +89 -5
- data/config/locales/zh-CN.yml +86 -4
- data/config/routes.rb +6 -0
- data/db/migrate/20251223000000_create_rails_error_dashboard_complete_schema.rb +10 -2
- data/db/migrate/20260106094256_backfill_application_for_existing_errors.rb +3 -3
- data/db/migrate/20261010000001_create_missing_translations.rb +50 -0
- data/db/migrate/20261015000001_create_rails_error_dashboard_error_occurrence_contexts.rb +35 -0
- data/db/migrate/20261015000002_add_capture_source_to_error_occurrences_and_error_logs.rb +46 -0
- data/lib/generators/rails_error_dashboard/install/install_generator.rb +9 -4
- data/lib/generators/rails_error_dashboard/install/templates/initializer.rb +41 -5
- data/lib/rails_error_dashboard/commands/assign_error.rb +24 -1
- data/lib/rails_error_dashboard/commands/batch_mute_errors.rb +11 -2
- data/lib/rails_error_dashboard/commands/batch_resolve_errors.rb +16 -3
- data/lib/rails_error_dashboard/commands/batch_unmute_errors.rb +7 -2
- data/lib/rails_error_dashboard/commands/find_or_increment_error.rb +6 -0
- data/lib/rails_error_dashboard/commands/flush_missing_translations.rb +101 -0
- data/lib/rails_error_dashboard/commands/flush_rack_attack_events.rb +2 -4
- data/lib/rails_error_dashboard/commands/flush_storm_counts.rb +7 -6
- data/lib/rails_error_dashboard/commands/flush_swallowed_exceptions.rb +2 -4
- data/lib/rails_error_dashboard/commands/log_error.rb +20 -8
- data/lib/rails_error_dashboard/commands/record_occurrence_context.rb +60 -0
- 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 +61 -4
- data/lib/rails_error_dashboard/engine.rb +42 -1
- data/lib/rails_error_dashboard/error_reporter.rb +4 -2
- data/lib/rails_error_dashboard/manual_error_reporter.rb +11 -1
- data/lib/rails_error_dashboard/middleware/error_catcher.rb +18 -4
- data/lib/rails_error_dashboard/middleware/rate_limiter.rb +7 -1
- data/lib/rails_error_dashboard/plugin.rb +19 -3
- data/lib/rails_error_dashboard/plugin_registry.rb +61 -12
- data/lib/rails_error_dashboard/queries/analytics_stats.rb +0 -12
- data/lib/rails_error_dashboard/queries/boot_errors.rb +76 -0
- data/lib/rails_error_dashboard/queries/co_occurring_errors.rb +59 -33
- data/lib/rails_error_dashboard/queries/dashboard_stats.rb +3 -11
- data/lib/rails_error_dashboard/queries/errors_list.rb +8 -16
- 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/queries/platform_comparison.rb +14 -6
- 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 +31 -7
- data/lib/rails_error_dashboard/services/baseline_alert_payload_builder.rb +30 -0
- data/lib/rails_error_dashboard/services/crash_capture.rb +90 -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/github_link_generator.rb +25 -14
- data/lib/rails_error_dashboard/services/markdown_error_formatter.rb +15 -16
- 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/repository_url.rb +18 -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/similarity_calculator.rb +3 -9
- data/lib/rails_error_dashboard/services/source_code_reader.rb +23 -3
- data/lib/rails_error_dashboard/services/storm_protection/count_buffer.rb +6 -3
- data/lib/rails_error_dashboard/services/storm_protection/gate.rb +6 -1
- 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/value_objects/occurrence_snapshot.rb +23 -0
- data/lib/rails_error_dashboard/version.rb +1 -1
- data/lib/rails_error_dashboard.rb +28 -0
- data/lib/tasks/error_dashboard.rake +2 -3
- metadata +29 -2
|
@@ -16,6 +16,13 @@ module RailsErrorDashboard
|
|
|
16
16
|
# puts "#{result[:error].error_type} occurred #{result[:frequency]} times together"
|
|
17
17
|
# end
|
|
18
18
|
class CoOccurringErrors
|
|
19
|
+
# The newest occurrences of the target error that are compared. This
|
|
20
|
+
# renders on every error page while the feature is on, and it used to run
|
|
21
|
+
# one window query per lifetime occurrence: 50k occurrences, 50k queries.
|
|
22
|
+
TARGET_OCCURRENCES = 500
|
|
23
|
+
# At most this many occurrences of other errors are read for those windows
|
|
24
|
+
NEARBY_OCCURRENCES = 20_000
|
|
25
|
+
|
|
19
26
|
# Find co-occurring errors
|
|
20
27
|
#
|
|
21
28
|
# @param error_log_id [Integer] ID of target error
|
|
@@ -34,53 +41,72 @@ module RailsErrorDashboard
|
|
|
34
41
|
@limit = limit.to_i
|
|
35
42
|
end
|
|
36
43
|
|
|
44
|
+
# Four queries however many occurrences there are: the target exists, its
|
|
45
|
+
# newest occurrence times, other errors' occurrences in those windows, and
|
|
46
|
+
# the co-occurring errors themselves.
|
|
37
47
|
def find_co_occurring
|
|
38
|
-
|
|
39
|
-
return [] unless target_error
|
|
40
|
-
|
|
41
|
-
# Get all occurrences of the target error
|
|
42
|
-
target_occurrences = ErrorOccurrence.where(error_log_id: @error_log_id)
|
|
43
|
-
return [] if target_occurrences.empty?
|
|
44
|
-
|
|
45
|
-
# For each occurrence, find other errors in the time window
|
|
46
|
-
co_occurrence_data = Hash.new { |h, k| h[k] = { count: 0, delays: [] } }
|
|
48
|
+
return [] unless ErrorLog.exists?(id: @error_log_id)
|
|
47
49
|
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
end_time = occurrence.occurred_at + window
|
|
50
|
+
targets = ErrorOccurrence.where(error_log_id: @error_log_id)
|
|
51
|
+
.order(occurred_at: :desc).limit(TARGET_OCCURRENCES).pluck(:occurred_at).sort
|
|
52
|
+
return [] if targets.empty?
|
|
52
53
|
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
.in_time_window(start_time, end_time)
|
|
56
|
-
.where.not(error_log_id: @error_log_id)
|
|
57
|
-
.includes(:error_log)
|
|
54
|
+
window = @window_minutes.minutes
|
|
55
|
+
stats = Hash.new { |h, k| h[k] = { count: 0, delay_sum: 0.0 } }
|
|
58
56
|
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
57
|
+
nearby_occurrences(merged_windows(targets, window)).each do |error_log_id, occurred_at|
|
|
58
|
+
# Each target occurrence within the window of this one is a pair, as
|
|
59
|
+
# before. Delay is negative when this error came first.
|
|
60
|
+
index = targets.bsearch_index { |target| target >= occurred_at - window }
|
|
61
|
+
next unless index
|
|
62
62
|
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
63
|
+
while index < targets.size && targets[index] <= occurred_at + window
|
|
64
|
+
stats[error_log_id][:count] += 1
|
|
65
|
+
stats[error_log_id][:delay_sum] += (occurred_at - targets[index]).to_f
|
|
66
|
+
index += 1
|
|
66
67
|
end
|
|
67
68
|
end
|
|
68
69
|
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
error = ErrorLog.find(error_log_id)
|
|
72
|
-
avg_delay = data[:delays].sum / data[:delays].size
|
|
70
|
+
frequent = stats.select { |_id, data| data[:count] >= @min_frequency }
|
|
71
|
+
errors = ErrorLog.where(id: frequent.keys).index_by(&:id)
|
|
73
72
|
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
avg_delay_seconds: avg_delay.round(2)
|
|
78
|
-
}
|
|
73
|
+
results = frequent.filter_map do |error_log_id, data|
|
|
74
|
+
error = errors[error_log_id] or next
|
|
75
|
+
{ error: error, frequency: data[:count], avg_delay_seconds: (data[:delay_sum] / data[:count]).round(2) }
|
|
79
76
|
end
|
|
80
77
|
|
|
81
78
|
# Sort by frequency (most common first) and limit results
|
|
82
79
|
results.sort_by { |r| -r[:frequency] }.first(@limit)
|
|
83
80
|
end
|
|
81
|
+
|
|
82
|
+
private
|
|
83
|
+
|
|
84
|
+
# The windows around each target time, overlapping ones merged, so a burst
|
|
85
|
+
# of target occurrences becomes one range
|
|
86
|
+
def merged_windows(targets, window)
|
|
87
|
+
targets.each_with_object([]) do |target, ranges|
|
|
88
|
+
from = target - window
|
|
89
|
+
to = target + window
|
|
90
|
+
if ranges.any? && from <= ranges.last[1]
|
|
91
|
+
ranges.last[1] = to
|
|
92
|
+
else
|
|
93
|
+
ranges << [ from, to ]
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# [error_log_id, occurred_at] for other errors' occurrences in any window
|
|
99
|
+
def nearby_occurrences(ranges)
|
|
100
|
+
column = "#{ErrorOccurrence.table_name}.occurred_at"
|
|
101
|
+
in_a_window = ranges.map { "(#{column} BETWEEN ? AND ?)" }.join(" OR ")
|
|
102
|
+
|
|
103
|
+
ErrorOccurrence
|
|
104
|
+
.where.not(error_log_id: @error_log_id)
|
|
105
|
+
.where(in_a_window, *ranges.flatten)
|
|
106
|
+
.order(occurred_at: :desc)
|
|
107
|
+
.limit(NEARBY_OCCURRENCES)
|
|
108
|
+
.pluck(:error_log_id, :occurred_at)
|
|
109
|
+
end
|
|
84
110
|
end
|
|
85
111
|
end
|
|
86
112
|
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
|
|
@@ -159,26 +159,18 @@ module RailsErrorDashboard
|
|
|
159
159
|
def filter_by_severity(query)
|
|
160
160
|
return query unless @filters[:severity].present?
|
|
161
161
|
|
|
162
|
-
#
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
when :high
|
|
167
|
-
Services::SeverityClassifier
|
|
168
|
-
when :medium
|
|
169
|
-
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]))
|
|
170
168
|
when :low
|
|
171
169
|
# Low severity = everything NOT in the other categories
|
|
172
|
-
|
|
173
|
-
Services::SeverityClassifier::HIGH_SEVERITY_ERROR_TYPES +
|
|
174
|
-
Services::SeverityClassifier::MEDIUM_SEVERITY_ERROR_TYPES
|
|
175
|
-
# Use NOT IN to filter out categorized errors
|
|
176
|
-
return query.where.not(error_type: all_categorized)
|
|
170
|
+
query.where.not(error_type: Services::SeverityClassifier.categorized_error_types)
|
|
177
171
|
else
|
|
178
|
-
|
|
172
|
+
query
|
|
179
173
|
end
|
|
180
|
-
|
|
181
|
-
query.where(error_type: error_types)
|
|
182
174
|
end
|
|
183
175
|
|
|
184
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
|
|
@@ -160,18 +160,25 @@ module RailsErrorDashboard
|
|
|
160
160
|
def platform_stability_scores
|
|
161
161
|
platforms = base_scope.distinct.pluck(:platform).compact
|
|
162
162
|
error_rates = error_rate_by_platform
|
|
163
|
+
# A score compares platforms. With fewer than two that have errors in
|
|
164
|
+
# the window there is nothing to compare against, so none is rated (a
|
|
165
|
+
# lone platform used to come out Critical every time). The count is of
|
|
166
|
+
# platforms with errors in the window: the list above is every
|
|
167
|
+
# platform ever seen.
|
|
168
|
+
return {} if error_rates.size < 2
|
|
163
169
|
resolution_times = resolution_time_by_platform
|
|
164
170
|
|
|
165
|
-
|
|
166
|
-
max_errors = error_rates.values.max || 1
|
|
171
|
+
total_errors = error_rates.values.sum
|
|
167
172
|
max_resolution_time = resolution_times.values.compact.max || 1
|
|
168
173
|
|
|
169
174
|
platforms.each_with_object({}) do |platform, result|
|
|
170
175
|
error_count = error_rates[platform] || 0
|
|
171
176
|
avg_resolution = resolution_times[platform] || 0
|
|
172
177
|
|
|
173
|
-
#
|
|
174
|
-
|
|
178
|
+
# This platform's share of all errors, inverted. Against the busiest
|
|
179
|
+
# platform instead, the busiest always scored 0 here and so was always
|
|
180
|
+
# Critical.
|
|
181
|
+
error_score = total_errors.positive? ? 1.0 - (error_count.to_f / total_errors) : 1.0
|
|
175
182
|
resolution_score = avg_resolution.positive? ? 1.0 - (avg_resolution / max_resolution_time) : 1.0
|
|
176
183
|
|
|
177
184
|
# Weight: 70% error count, 30% resolution time
|
|
@@ -252,9 +259,10 @@ module RailsErrorDashboard
|
|
|
252
259
|
critical_errors: critical_errors,
|
|
253
260
|
unresolved_errors: unresolved_errors,
|
|
254
261
|
resolution_rate: resolution_rate,
|
|
255
|
-
|
|
262
|
+
# nil and :not_rated when there are fewer than two platforms
|
|
263
|
+
stability_score: stability_scores[platform],
|
|
256
264
|
error_velocity: velocity, # Positive = increasing, negative = decreasing
|
|
257
|
-
health_status: determine_health_status(stability_scores[platform]
|
|
265
|
+
health_status: stability_scores.key?(platform) ? determine_health_status(stability_scores[platform], velocity) : :not_rated
|
|
258
266
|
}
|
|
259
267
|
end
|
|
260
268
|
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)
|