rails_error_dashboard 0.14.4 → 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 +12 -5
- 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 +2 -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 +11 -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 +68 -5
- data/config/locales/en.yml +78 -4
- data/config/locales/es.yml +68 -5
- data/config/locales/fr.yml +68 -5
- data/config/locales/it.yml +68 -5
- data/config/locales/ja.yml +68 -4
- data/config/locales/pl.yml +68 -5
- data/config/locales/pt-BR.yml +68 -5
- data/config/locales/ru.yml +68 -5
- data/config/locales/uk.yml +68 -5
- data/config/locales/zh-CN.yml +68 -4
- data/config/routes.rb +6 -0
- data/db/migrate/20251223000000_create_rails_error_dashboard_complete_schema.rb +10 -2
- data/db/migrate/20261010000001_create_missing_translations.rb +50 -0
- data/lib/generators/rails_error_dashboard/install/install_generator.rb +9 -4
- data/lib/generators/rails_error_dashboard/install/templates/initializer.rb +27 -0
- 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 +1 -1
- 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 +42 -1
- data/lib/rails_error_dashboard/engine.rb +42 -1
- 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/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 +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/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/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/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
- metadata +21 -2
|
@@ -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
|
|
@@ -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
|
|
@@ -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)
|
|
@@ -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}"
|