rails_error_dashboard 0.14.2 → 0.14.4
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 +36 -18
- data/app/helpers/rails_error_dashboard/application_helper.rb +8 -0
- data/app/jobs/rails_error_dashboard/retention_cleanup_job.rb +27 -23
- data/app/views/layouts/rails_error_dashboard.html.erb +13 -0
- data/config/locales/de.yml +4 -0
- data/config/locales/en.yml +5 -0
- data/config/locales/es.yml +4 -0
- data/config/locales/fr.yml +4 -0
- data/config/locales/it.yml +4 -0
- data/config/locales/ja.yml +4 -0
- data/config/locales/pl.yml +4 -0
- data/config/locales/pt-BR.yml +4 -0
- data/config/locales/ru.yml +4 -0
- data/config/locales/uk.yml +4 -0
- data/config/locales/zh-CN.yml +4 -0
- data/db/migrate/20251225074653_remove_environment_from_error_logs.rb +9 -0
- data/lib/generators/rails_error_dashboard/install/install_generator.rb +45 -50
- data/lib/generators/rails_error_dashboard/install/templates/initializer.rb +17 -10
- data/lib/generators/rails_error_dashboard/solid_queue/solid_queue_generator.rb +41 -21
- data/lib/generators/rails_error_dashboard/uninstall/uninstall_generator.rb +79 -66
- data/lib/rails_error_dashboard/commands/drop_all_tables.rb +77 -0
- data/lib/rails_error_dashboard/commands/log_error.rb +5 -2
- data/lib/rails_error_dashboard/configuration.rb +67 -4
- data/lib/rails_error_dashboard/engine.rb +10 -4
- data/lib/rails_error_dashboard/logger.rb +11 -3
- data/lib/rails_error_dashboard/middleware/rate_limiter.rb +15 -43
- data/lib/rails_error_dashboard/plugin.rb +30 -0
- data/lib/rails_error_dashboard/queries/errors_list.rb +3 -1
- data/lib/rails_error_dashboard/queries/uninstall_plan.rb +63 -0
- data/lib/rails_error_dashboard/services/exception_filter.rb +4 -1
- data/lib/rails_error_dashboard/services/solid_queue_config_check.rb +261 -0
- data/lib/rails_error_dashboard/version.rb +1 -1
- data/lib/rails_error_dashboard.rb +3 -0
- data/lib/tasks/error_dashboard.rake +77 -5
- data/lib/tasks/rails_error_dashboard_tasks.rake +37 -43
- metadata +5 -3
- data/lib/generators/rails_error_dashboard/solid_queue/templates/queue.yml +0 -55
|
@@ -18,12 +18,17 @@ module RailsErrorDashboard
|
|
|
18
18
|
# config.log_level = :error
|
|
19
19
|
# end
|
|
20
20
|
module Logger
|
|
21
|
+
# The levels Configuration#validate! accepts: it reads this hash, so the two
|
|
22
|
+
# can't drift apart again (validate! once accepted :fatal while this hash
|
|
23
|
+
# lacked it, and every log call raised). RED emits nothing at :fatal, so it
|
|
24
|
+
# logs nothing, as with Ruby's Logger.
|
|
21
25
|
LOG_LEVELS = {
|
|
22
26
|
debug: 0,
|
|
23
27
|
info: 1,
|
|
24
28
|
warn: 2,
|
|
25
29
|
error: 3,
|
|
26
|
-
|
|
30
|
+
fatal: 4,
|
|
31
|
+
silent: 5
|
|
27
32
|
}.freeze
|
|
28
33
|
|
|
29
34
|
class << self
|
|
@@ -89,8 +94,11 @@ module RailsErrorDashboard
|
|
|
89
94
|
# @param level [Symbol] The log level to check (:debug, :info, :warn, :error)
|
|
90
95
|
# @return [Boolean]
|
|
91
96
|
def log_level_enabled?(level)
|
|
92
|
-
config_level = RailsErrorDashboard.configuration.log_level
|
|
93
|
-
LOG_LEVELS
|
|
97
|
+
config_level = RailsErrorDashboard.configuration.log_level.to_s.to_sym
|
|
98
|
+
LOG_LEVELS.fetch(level) >= LOG_LEVELS.fetch(config_level, LOG_LEVELS[:silent])
|
|
99
|
+
rescue StandardError
|
|
100
|
+
# The logger runs inside rescue blocks on the capture path; it must never raise.
|
|
101
|
+
false
|
|
94
102
|
end
|
|
95
103
|
|
|
96
104
|
# Format message with gem prefix
|
|
@@ -2,12 +2,11 @@
|
|
|
2
2
|
|
|
3
3
|
module RailsErrorDashboard
|
|
4
4
|
module Middleware
|
|
5
|
-
# Rate limiting middleware for Rails Error Dashboard routes
|
|
6
|
-
#
|
|
5
|
+
# Rate limiting middleware for Rails Error Dashboard routes: at most
|
|
6
|
+
# config.rate_limit_per_minute requests per IP per path, per minute.
|
|
7
7
|
class RateLimiter
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
DASHBOARD_LIMIT = { limit: 300, period: 60 }.freeze # 300 req/min
|
|
8
|
+
PERIOD = 60 # seconds
|
|
9
|
+
DEFAULT_LIMIT = 300 # requests per PERIOD, when rate_limit_per_minute isn't a positive number
|
|
11
10
|
|
|
12
11
|
def initialize(app)
|
|
13
12
|
@app = app
|
|
@@ -22,16 +21,14 @@ module RailsErrorDashboard
|
|
|
22
21
|
# Only apply rate limiting to error dashboard routes
|
|
23
22
|
return @app.call(env) unless error_dashboard_route?(request.path)
|
|
24
23
|
|
|
25
|
-
|
|
26
|
-
limit_config = find_limit_config(request.path)
|
|
27
|
-
return @app.call(env) unless limit_config
|
|
24
|
+
limit_config = { limit: configured_limit, period: PERIOD }
|
|
28
25
|
|
|
29
26
|
# Check rate limit
|
|
30
|
-
key = rate_limit_key(request)
|
|
27
|
+
key = rate_limit_key(request, limit_config)
|
|
31
28
|
current_count = @cache.read(key).to_i
|
|
32
29
|
|
|
33
30
|
if current_count >= limit_config[:limit]
|
|
34
|
-
return
|
|
31
|
+
return html_rate_limit_response(limit_config)
|
|
35
32
|
end
|
|
36
33
|
|
|
37
34
|
# Increment counter with expiration
|
|
@@ -46,6 +43,12 @@ module RailsErrorDashboard
|
|
|
46
43
|
RailsErrorDashboard.configuration.enable_rate_limiting
|
|
47
44
|
end
|
|
48
45
|
|
|
46
|
+
# nil passes validation, and a limit of 0 would refuse every request.
|
|
47
|
+
def configured_limit
|
|
48
|
+
limit = RailsErrorDashboard.configuration.rate_limit_per_minute.to_i
|
|
49
|
+
limit.positive? ? limit : DEFAULT_LIMIT
|
|
50
|
+
end
|
|
51
|
+
|
|
49
52
|
def engine_mount_path
|
|
50
53
|
@engine_mount_path ||= RailsErrorDashboard.configuration.engine_mount_path
|
|
51
54
|
rescue
|
|
@@ -56,45 +59,14 @@ module RailsErrorDashboard
|
|
|
56
59
|
path.start_with?(engine_mount_path)
|
|
57
60
|
end
|
|
58
61
|
|
|
59
|
-
def
|
|
60
|
-
|
|
61
|
-
API_LIMIT
|
|
62
|
-
else
|
|
63
|
-
DASHBOARD_LIMIT
|
|
64
|
-
end
|
|
65
|
-
end
|
|
66
|
-
|
|
67
|
-
def rate_limit_key(request)
|
|
68
|
-
# Key format: rate_limit:IP:path_prefix:time_window
|
|
62
|
+
def rate_limit_key(request, limit_config)
|
|
63
|
+
# Key format: rate_limit:IP:path:time_window
|
|
69
64
|
# Time window ensures keys expire and reset
|
|
70
|
-
limit_config = find_limit_config(request.path)
|
|
71
65
|
time_window = Time.now.to_i / limit_config[:period]
|
|
72
66
|
|
|
73
67
|
"rate_limit:#{request.ip}:#{request.path}:#{time_window}"
|
|
74
68
|
end
|
|
75
69
|
|
|
76
|
-
def rate_limit_response(request, limit_config)
|
|
77
|
-
# Return JSON for API requests, HTML for dashboard
|
|
78
|
-
if request.path.start_with?("#{engine_mount_path}/api")
|
|
79
|
-
json_rate_limit_response(limit_config)
|
|
80
|
-
else
|
|
81
|
-
html_rate_limit_response(limit_config)
|
|
82
|
-
end
|
|
83
|
-
end
|
|
84
|
-
|
|
85
|
-
def json_rate_limit_response(limit_config)
|
|
86
|
-
[
|
|
87
|
-
429,
|
|
88
|
-
{
|
|
89
|
-
"Content-Type" => "application/json",
|
|
90
|
-
"Retry-After" => limit_config[:period].to_s,
|
|
91
|
-
"X-RateLimit-Limit" => limit_config[:limit].to_s,
|
|
92
|
-
"X-RateLimit-Period" => "#{limit_config[:period]} seconds"
|
|
93
|
-
},
|
|
94
|
-
[ { error: "Rate limit exceeded. Please try again later." }.to_json ]
|
|
95
|
-
]
|
|
96
|
-
end
|
|
97
|
-
|
|
98
70
|
def html_rate_limit_response(limit_config)
|
|
99
71
|
body = <<~HTML
|
|
100
72
|
<!DOCTYPE html>
|
|
@@ -54,12 +54,42 @@ module RailsErrorDashboard
|
|
|
54
54
|
# Override in subclass to handle event
|
|
55
55
|
end
|
|
56
56
|
|
|
57
|
+
# Called when a resolved error occurs again and is reopened
|
|
58
|
+
# @param error_log [ErrorLog] The reopened error log
|
|
59
|
+
def on_error_reopened(error_log)
|
|
60
|
+
# Override in subclass to handle event
|
|
61
|
+
end
|
|
62
|
+
|
|
57
63
|
# Called when an error is resolved
|
|
58
64
|
# @param error_log [ErrorLog] The resolved error log
|
|
59
65
|
def on_error_resolved(error_log)
|
|
60
66
|
# Override in subclass to handle event
|
|
61
67
|
end
|
|
62
68
|
|
|
69
|
+
# Called when an error is muted
|
|
70
|
+
# @param error_log [ErrorLog] The muted error log
|
|
71
|
+
def on_error_muted(error_log)
|
|
72
|
+
# Override in subclass to handle event
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# Called when an error is unmuted
|
|
76
|
+
# @param error_log [ErrorLog] The unmuted error log
|
|
77
|
+
def on_error_unmuted(error_log)
|
|
78
|
+
# Override in subclass to handle event
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# Called when errors are batch muted
|
|
82
|
+
# @param error_logs [Array<ErrorLog>] The muted error logs
|
|
83
|
+
def on_errors_batch_muted(error_logs)
|
|
84
|
+
# Override in subclass to handle event
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# Called when errors are batch unmuted
|
|
88
|
+
# @param error_logs [Array<ErrorLog>] The unmuted error logs
|
|
89
|
+
def on_errors_batch_unmuted(error_logs)
|
|
90
|
+
# Override in subclass to handle event
|
|
91
|
+
end
|
|
92
|
+
|
|
63
93
|
# Called when errors are batch resolved
|
|
64
94
|
# @param error_logs [Array<ErrorLog>] The resolved error logs
|
|
65
95
|
def on_errors_batch_resolved(error_logs)
|
|
@@ -150,8 +150,10 @@ 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)
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RailsErrorDashboard
|
|
4
|
+
module Queries
|
|
5
|
+
# Every RED table on a connection, in an order that can be dropped.
|
|
6
|
+
#
|
|
7
|
+
# The tables are discovered rather than listed by hand: a hand-kept list
|
|
8
|
+
# knew 5 of 13 tables, so uninstall left the rest behind (rack_attack_events
|
|
9
|
+
# among them, which holds IP addresses). The order comes from the real
|
|
10
|
+
# foreign keys: a table that references another is dropped first.
|
|
11
|
+
#
|
|
12
|
+
# Uses the raw connection only, never the models, so no model's schema
|
|
13
|
+
# cache ever records a table as dropped.
|
|
14
|
+
#
|
|
15
|
+
# @example
|
|
16
|
+
# UninstallPlan.call(RailsErrorDashboard::ErrorLogsRecord.connection)
|
|
17
|
+
# # => [{ table: "rails_error_dashboard_error_comments", rows: 12 }, ...]
|
|
18
|
+
class UninstallPlan
|
|
19
|
+
PREFIX = "rails_error_dashboard_"
|
|
20
|
+
|
|
21
|
+
class CycleError < StandardError; end
|
|
22
|
+
|
|
23
|
+
# @param connection [ActiveRecord::ConnectionAdapters::AbstractAdapter]
|
|
24
|
+
# @return [Array<Hash>] { table: String, rows: Integer or nil } in drop order
|
|
25
|
+
def self.call(connection)
|
|
26
|
+
tables = connection.tables.select { |table| table.start_with?(PREFIX) }
|
|
27
|
+
references = tables.to_h do |table|
|
|
28
|
+
[ table, connection.foreign_keys(table).map(&:to_table) & tables ]
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
order(references).map { |table| { table: table, rows: row_count(connection, table) } }
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# @param references [Hash{String => Array<String>}] each table and the tables it references
|
|
35
|
+
# @return [Array<String>] every table, each before the tables it references
|
|
36
|
+
# @raise [CycleError] when the foreign keys form a cycle
|
|
37
|
+
def self.order(references)
|
|
38
|
+
remaining = references.keys.sort
|
|
39
|
+
ordered = []
|
|
40
|
+
|
|
41
|
+
until remaining.empty?
|
|
42
|
+
# Droppable once no other remaining table references it.
|
|
43
|
+
droppable = remaining.reject do |table|
|
|
44
|
+
remaining.any? { |other| other != table && references.fetch(other, []).include?(table) }
|
|
45
|
+
end
|
|
46
|
+
raise CycleError, "foreign-key cycle among: #{remaining.join(', ')}" if droppable.empty?
|
|
47
|
+
|
|
48
|
+
ordered.concat(droppable)
|
|
49
|
+
remaining -= droppable
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
ordered
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def self.row_count(connection, table)
|
|
56
|
+
connection.select_value("SELECT COUNT(*) FROM #{connection.quote_table_name(table)}").to_i
|
|
57
|
+
rescue ActiveRecord::ActiveRecordError
|
|
58
|
+
nil
|
|
59
|
+
end
|
|
60
|
+
private_class_method :row_count
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
@@ -28,7 +28,8 @@ module RailsErrorDashboard
|
|
|
28
28
|
end
|
|
29
29
|
|
|
30
30
|
# Check if exception is in the ignored exceptions list
|
|
31
|
-
# Supports
|
|
31
|
+
# Supports class-name strings and Class/Module objects (both match
|
|
32
|
+
# subclasses), and regex patterns (matched against the class name)
|
|
32
33
|
# @param exception [Exception] The exception to check
|
|
33
34
|
# @return [Boolean] true if the exception should be ignored
|
|
34
35
|
def self.ignored?(exception)
|
|
@@ -41,6 +42,8 @@ module RailsErrorDashboard
|
|
|
41
42
|
case ignored
|
|
42
43
|
when String
|
|
43
44
|
exception.is_a?(ignored.constantize)
|
|
45
|
+
when Module
|
|
46
|
+
exception.is_a?(ignored)
|
|
44
47
|
when Regexp
|
|
45
48
|
exception_class_name.match?(ignored)
|
|
46
49
|
else
|
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "pathname"
|
|
4
|
+
|
|
5
|
+
module RailsErrorDashboard
|
|
6
|
+
module Services
|
|
7
|
+
# Pure algorithm: what in a Solid Queue config would stop jobs from running
|
|
8
|
+
#
|
|
9
|
+
# Mirrors Solid Queue 1.7.0's own rules, so it reports exactly what Solid
|
|
10
|
+
# Queue would do with the file:
|
|
11
|
+
# - SolidQueue::Configuration reads the section for the environment, or the
|
|
12
|
+
# whole file when there is none, keeps workers/dispatchers/scheduler, and
|
|
13
|
+
# only falls back to its defaults (a "*" worker and a dispatcher) when
|
|
14
|
+
# none of the three is there. A section with workers and no dispatchers
|
|
15
|
+
# therefore runs NO dispatcher: nothing scheduled ever becomes ready, so
|
|
16
|
+
# no `retry_on wait:` or `perform_later(wait:)` runs, the host app's own
|
|
17
|
+
# jobs included. That is what the retired rails_error_dashboard:solid_queue
|
|
18
|
+
# generator wrote.
|
|
19
|
+
# - SolidQueue::QueueSelector treats a worker without queues as "*", "*" as
|
|
20
|
+
# every queue, and a trailing "*" as a prefix.
|
|
21
|
+
#
|
|
22
|
+
# @example
|
|
23
|
+
# path = SolidQueueConfigCheck.config_path(Rails.root)
|
|
24
|
+
# SolidQueueConfigCheck.call(path, environments: %w[development production])
|
|
25
|
+
# # => { problems: ["production: workers but no dispatcher ..."], skipped: nil }
|
|
26
|
+
class SolidQueueConfigCheck
|
|
27
|
+
DEFAULT_CONFIG_FILE = "config/queue.yml"
|
|
28
|
+
PROCESS_KEYS = %i[workers dispatchers scheduler].freeze
|
|
29
|
+
# Before 1.4.0, scheduler: was not a process key: a section with only a
|
|
30
|
+
# scheduler fell back to the defaults, a "*" worker and a dispatcher.
|
|
31
|
+
SCHEDULER_KEY_SINCE = Gem::Version.new("1.4.0")
|
|
32
|
+
GUIDE_URL = "https://anjanj.github.io/rails_error_dashboard/docs/guides/solid-queue-setup/#what-red-needs-from-solid-queue"
|
|
33
|
+
|
|
34
|
+
# The fix printed with a reported problem. It is never Solid Queue's
|
|
35
|
+
# installer: whoever sees this already uses Solid Queue, and its 1.7.0
|
|
36
|
+
# installer also rewrites production.rb to use a separate queue database.
|
|
37
|
+
FIX = [
|
|
38
|
+
"Fix: edit the file. Give each environment a dispatchers: block and a worker",
|
|
39
|
+
"for RED's queues (a \"*\" worker covers them). Guide: #{GUIDE_URL}"
|
|
40
|
+
].freeze
|
|
41
|
+
|
|
42
|
+
# The config file Solid Queue reads, honouring SOLID_QUEUE_CONFIG as it does.
|
|
43
|
+
#
|
|
44
|
+
# @param root [Pathname, String] the application root
|
|
45
|
+
# @return [Pathname]
|
|
46
|
+
def self.config_path(root)
|
|
47
|
+
Pathname(root.to_s).join(ENV["SOLID_QUEUE_CONFIG"].presence || DEFAULT_CONFIG_FILE)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# The environments an app defines, from config/environments/*.rb.
|
|
51
|
+
#
|
|
52
|
+
# @param root [Pathname, String] the application root
|
|
53
|
+
# @return [Array<String>]
|
|
54
|
+
def self.app_environments(root)
|
|
55
|
+
Dir[Pathname(root.to_s).join("config", "environments", "*.rb").to_s]
|
|
56
|
+
.map { |file| File.basename(file, ".rb") }
|
|
57
|
+
.sort
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# The running app's verdict: the problems that would stop RED's jobs in
|
|
61
|
+
# this environment, or [] when Solid Queue doesn't run them or there is
|
|
62
|
+
# no config file. Worked out once per process (it reads and parses a
|
|
63
|
+
# file); the dashboard banner and the boot log line both ask. Never raises.
|
|
64
|
+
#
|
|
65
|
+
# @return [Array<String>]
|
|
66
|
+
def self.current_problems
|
|
67
|
+
return @current_problems if defined?(@current_problems)
|
|
68
|
+
|
|
69
|
+
@current_problems = compute_current_problems.freeze
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# One error-level line at boot when there is a problem.
|
|
73
|
+
#
|
|
74
|
+
# @param logger [Logger]
|
|
75
|
+
# @return [void]
|
|
76
|
+
def self.log_current_problems(logger = Rails.logger)
|
|
77
|
+
problems = current_problems
|
|
78
|
+
return if problems.empty?
|
|
79
|
+
|
|
80
|
+
logger.error(
|
|
81
|
+
"[Rails Error Dashboard] Solid Queue won't run RED's jobs in #{problems.join('; ')}. " \
|
|
82
|
+
"Errors captured in the background and notifications wait in the queue until " \
|
|
83
|
+
"config/queue.yml is fixed: bin/rails error_dashboard:verify shows how. Guide: #{GUIDE_URL}"
|
|
84
|
+
)
|
|
85
|
+
rescue StandardError
|
|
86
|
+
nil
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# For specs.
|
|
90
|
+
def self.reset_current_problems!
|
|
91
|
+
remove_instance_variable(:@current_problems) if defined?(@current_problems)
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def self.compute_current_problems
|
|
95
|
+
return [] unless RailsErrorDashboard::ApplicationJob.queue_adapter_name.to_s == "solid_queue"
|
|
96
|
+
|
|
97
|
+
path = config_path(Rails.root)
|
|
98
|
+
return [] unless path.exist?
|
|
99
|
+
|
|
100
|
+
result = call(path, environments: [ Rails.env.to_s ])
|
|
101
|
+
result[:skipped] ? [] : result[:problems]
|
|
102
|
+
rescue StandardError, ScriptError
|
|
103
|
+
[]
|
|
104
|
+
end
|
|
105
|
+
private_class_method :compute_current_problems
|
|
106
|
+
|
|
107
|
+
# The queues RED's jobs are enqueued on, as this app names them
|
|
108
|
+
# (queue_name_prefix included).
|
|
109
|
+
#
|
|
110
|
+
# @return [Array<String>]
|
|
111
|
+
def self.red_queue_names
|
|
112
|
+
[ RailsErrorDashboard::AsyncErrorLoggingJob, RailsErrorDashboard::SlackErrorNotificationJob ]
|
|
113
|
+
.map { |job| job.queue_name.to_s }
|
|
114
|
+
.uniq
|
|
115
|
+
rescue => e
|
|
116
|
+
RailsErrorDashboard::Logger.debug("SolidQueueConfigCheck could not read queue names: #{e.class}: #{e.message}")
|
|
117
|
+
%w[default error_notifications]
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# Read the file the way Solid Queue does (ERB, then YAML with aliases) and
|
|
121
|
+
# check each environment. Never raises: an unreadable file is reported as
|
|
122
|
+
# skipped.
|
|
123
|
+
#
|
|
124
|
+
# @param path [Pathname, String]
|
|
125
|
+
# @param environments [Array<String>]
|
|
126
|
+
# @param queue_names [Array<String>]
|
|
127
|
+
# @return [Hash] { problems: Array<String>, skipped: String or nil }
|
|
128
|
+
def self.call(path, environments:, queue_names: red_queue_names)
|
|
129
|
+
{ problems: check(parse(path), environments: environments, queue_names: queue_names), skipped: nil }
|
|
130
|
+
rescue StandardError, ScriptError => e # ScriptError: ERB in the file that doesn't compile
|
|
131
|
+
{ problems: [], skipped: "could not read #{path}: #{e.class}: #{e.message.lines.first.to_s.strip}" }
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
# @param path [Pathname, String]
|
|
135
|
+
# @return [Hash] the parsed file, symbolized keys, as Solid Queue reads it
|
|
136
|
+
def self.parse(path)
|
|
137
|
+
ActiveSupport::ConfigurationFile.parse(Pathname(path.to_s)).deep_symbolize_keys
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
# @param config [Hash] the parsed file, symbolized keys
|
|
141
|
+
# @param environments [Array<String>]
|
|
142
|
+
# @param queue_names [Array<String>]
|
|
143
|
+
# @return [Array<String>] one line per problem, prefixed with the environment
|
|
144
|
+
def self.check(config, environments:, queue_names:)
|
|
145
|
+
environments.flat_map do |env|
|
|
146
|
+
facts = processes_for(config, environment: env, queue_names: queue_names)
|
|
147
|
+
problems_in(facts, queue_names).map { |problem| "#{env}: #{problem}" }
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
# What Solid Queue would start for one environment. The contract spec
|
|
152
|
+
# compares this with the real SolidQueue::Configuration and QueueSelector.
|
|
153
|
+
#
|
|
154
|
+
# @param config [Hash] the parsed file, symbolized keys
|
|
155
|
+
# @param environment [String]
|
|
156
|
+
# @param queue_names [Array<String>]
|
|
157
|
+
# @param solid_queue_version [String, nil] the rules to follow; the loaded
|
|
158
|
+
# Solid Queue's by default, the newest when none is loaded
|
|
159
|
+
# @return [Hash] { dispatchers: Integer, workers: Integer,
|
|
160
|
+
# processed: Array<String> (the queue_names a worker picks up), error: String or nil }
|
|
161
|
+
def self.processes_for(config, environment:, queue_names:, solid_queue_version: loaded_solid_queue_version)
|
|
162
|
+
section = section_for(config, environment)
|
|
163
|
+
return cannot_start("the #{environment} section is not a set of settings") unless section.is_a?(Hash)
|
|
164
|
+
|
|
165
|
+
section = section.slice(*process_keys(solid_queue_version))
|
|
166
|
+
# Empty: Solid Queue runs its defaults, a "*" worker and a dispatcher.
|
|
167
|
+
return { dispatchers: 1, workers: 1, processed: queue_names.dup, error: nil } if section.empty?
|
|
168
|
+
|
|
169
|
+
error = shape_error(section)
|
|
170
|
+
return cannot_start(error) if error
|
|
171
|
+
|
|
172
|
+
# bin/jobs runs in fork mode: each worker entry starts `processes` of them.
|
|
173
|
+
workers = section.fetch(:workers, []).select { |worker| worker.fetch(:processes, 1).positive? }
|
|
174
|
+
{
|
|
175
|
+
dispatchers: section.fetch(:dispatchers, []).size,
|
|
176
|
+
workers: workers.sum { |worker| worker.fetch(:processes, 1) },
|
|
177
|
+
processed: queue_names.select { |name| workers.any? { |worker| processes?(worker, name) } },
|
|
178
|
+
error: nil
|
|
179
|
+
}
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
def self.loaded_solid_queue_version
|
|
183
|
+
::SolidQueue::VERSION if defined?(::SolidQueue::VERSION)
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
def self.process_keys(solid_queue_version)
|
|
187
|
+
return PROCESS_KEYS if solid_queue_version.nil? || Gem::Version.new(solid_queue_version) >= SCHEDULER_KEY_SINCE
|
|
188
|
+
|
|
189
|
+
PROCESS_KEYS - [ :scheduler ]
|
|
190
|
+
rescue ArgumentError # a version string Gem::Version can't read
|
|
191
|
+
PROCESS_KEYS
|
|
192
|
+
end
|
|
193
|
+
private_class_method :process_keys
|
|
194
|
+
|
|
195
|
+
# SolidQueue::Configuration#config_from: the environment's section when
|
|
196
|
+
# present, else the whole file.
|
|
197
|
+
def self.section_for(config, env)
|
|
198
|
+
config[env.to_sym] ? config[env.to_sym] : config
|
|
199
|
+
end
|
|
200
|
+
private_class_method :section_for
|
|
201
|
+
|
|
202
|
+
# Settings Solid Queue raises on while it builds its processes, so
|
|
203
|
+
# bin/jobs can't start: a key present with no value, a map where a list
|
|
204
|
+
# belongs, a list entry that isn't a set of settings, or processes that
|
|
205
|
+
# isn't a whole number.
|
|
206
|
+
def self.shape_error(section)
|
|
207
|
+
%i[workers dispatchers].each do |key|
|
|
208
|
+
next unless section.key?(key)
|
|
209
|
+
|
|
210
|
+
list = section[key]
|
|
211
|
+
return "#{key}: must be a list, each entry starting with \"- \"" unless list.is_a?(Array)
|
|
212
|
+
return "every entry under #{key}: must be a set of settings" unless list.all?(Hash)
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
if section.fetch(:workers, []).any? { |worker| worker.key?(:processes) && !worker[:processes].is_a?(Integer) }
|
|
216
|
+
return "a worker's processes: must be a whole number"
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
nil
|
|
220
|
+
end
|
|
221
|
+
private_class_method :shape_error
|
|
222
|
+
|
|
223
|
+
def self.cannot_start(reason)
|
|
224
|
+
{ dispatchers: 0, workers: 0, processed: [], error: reason }
|
|
225
|
+
end
|
|
226
|
+
private_class_method :cannot_start
|
|
227
|
+
|
|
228
|
+
def self.problems_in(facts, queue_names)
|
|
229
|
+
return [ "Solid Queue can't start with this config: #{facts[:error]}" ] if facts[:error]
|
|
230
|
+
|
|
231
|
+
dispatchers = facts[:dispatchers]
|
|
232
|
+
workers = facts[:workers]
|
|
233
|
+
return [ "no worker and no dispatcher, so no job ever runs" ] if workers.zero? && dispatchers.zero?
|
|
234
|
+
return [ "no worker process starts (no workers, or processes: 0), so no job is ever performed" ] if workers.zero?
|
|
235
|
+
|
|
236
|
+
problems = []
|
|
237
|
+
if dispatchers.zero?
|
|
238
|
+
problems << "workers but no dispatcher, so scheduled jobs and retries " \
|
|
239
|
+
"(retry_on wait:, perform_later(wait:)) never run, the app's own jobs included"
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
uncovered = queue_names - facts[:processed]
|
|
243
|
+
problems << "no worker processes #{uncovered.join(' or ')}" if uncovered.any?
|
|
244
|
+
problems
|
|
245
|
+
end
|
|
246
|
+
private_class_method :problems_in
|
|
247
|
+
|
|
248
|
+
# SolidQueue::QueueSelector: no queues means "*"; "*" is every queue; a
|
|
249
|
+
# trailing "*" is a prefix.
|
|
250
|
+
def self.processes?(worker, queue_name)
|
|
251
|
+
queues = worker.is_a?(Hash) ? Array(worker[:queues]).map { |queue| queue.to_s.strip } : []
|
|
252
|
+
queues = [ "*" ] if queues.empty?
|
|
253
|
+
|
|
254
|
+
queues.any? do |queue|
|
|
255
|
+
queue == "*" || queue == queue_name || (queue.end_with?("*") && queue_name.start_with?(queue.delete_suffix("*")))
|
|
256
|
+
end
|
|
257
|
+
end
|
|
258
|
+
private_class_method :processes?
|
|
259
|
+
end
|
|
260
|
+
end
|
|
261
|
+
end
|
|
@@ -57,6 +57,7 @@ require "rails_error_dashboard/services/statistical_classifier"
|
|
|
57
57
|
require "rails_error_dashboard/services/source_code_reader"
|
|
58
58
|
require "rails_error_dashboard/services/git_blame_reader"
|
|
59
59
|
require "rails_error_dashboard/services/git_head_reader"
|
|
60
|
+
require "rails_error_dashboard/services/solid_queue_config_check"
|
|
60
61
|
require "rails_error_dashboard/services/github_link_generator"
|
|
61
62
|
require "rails_error_dashboard/services/cause_chain_extractor"
|
|
62
63
|
require "rails_error_dashboard/services/environment_snapshot"
|
|
@@ -137,6 +138,8 @@ require "rails_error_dashboard/commands/flush_swallowed_exceptions"
|
|
|
137
138
|
require "rails_error_dashboard/commands/flush_rack_attack_events"
|
|
138
139
|
require "rails_error_dashboard/commands/scrub_invalid_encoding"
|
|
139
140
|
require "rails_error_dashboard/commands/backfill_resolved_at"
|
|
141
|
+
require "rails_error_dashboard/queries/uninstall_plan"
|
|
142
|
+
require "rails_error_dashboard/commands/drop_all_tables"
|
|
140
143
|
require "rails_error_dashboard/queries/errors_list"
|
|
141
144
|
# Before the queries that use it: window volume is shared by dashboard and
|
|
142
145
|
# analytics, and both would otherwise rescue a NameError into a silent zero.
|