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.
Files changed (38) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +36 -18
  3. data/app/helpers/rails_error_dashboard/application_helper.rb +8 -0
  4. data/app/jobs/rails_error_dashboard/retention_cleanup_job.rb +27 -23
  5. data/app/views/layouts/rails_error_dashboard.html.erb +13 -0
  6. data/config/locales/de.yml +4 -0
  7. data/config/locales/en.yml +5 -0
  8. data/config/locales/es.yml +4 -0
  9. data/config/locales/fr.yml +4 -0
  10. data/config/locales/it.yml +4 -0
  11. data/config/locales/ja.yml +4 -0
  12. data/config/locales/pl.yml +4 -0
  13. data/config/locales/pt-BR.yml +4 -0
  14. data/config/locales/ru.yml +4 -0
  15. data/config/locales/uk.yml +4 -0
  16. data/config/locales/zh-CN.yml +4 -0
  17. data/db/migrate/20251225074653_remove_environment_from_error_logs.rb +9 -0
  18. data/lib/generators/rails_error_dashboard/install/install_generator.rb +45 -50
  19. data/lib/generators/rails_error_dashboard/install/templates/initializer.rb +17 -10
  20. data/lib/generators/rails_error_dashboard/solid_queue/solid_queue_generator.rb +41 -21
  21. data/lib/generators/rails_error_dashboard/uninstall/uninstall_generator.rb +79 -66
  22. data/lib/rails_error_dashboard/commands/drop_all_tables.rb +77 -0
  23. data/lib/rails_error_dashboard/commands/log_error.rb +5 -2
  24. data/lib/rails_error_dashboard/configuration.rb +67 -4
  25. data/lib/rails_error_dashboard/engine.rb +10 -4
  26. data/lib/rails_error_dashboard/logger.rb +11 -3
  27. data/lib/rails_error_dashboard/middleware/rate_limiter.rb +15 -43
  28. data/lib/rails_error_dashboard/plugin.rb +30 -0
  29. data/lib/rails_error_dashboard/queries/errors_list.rb +3 -1
  30. data/lib/rails_error_dashboard/queries/uninstall_plan.rb +63 -0
  31. data/lib/rails_error_dashboard/services/exception_filter.rb +4 -1
  32. data/lib/rails_error_dashboard/services/solid_queue_config_check.rb +261 -0
  33. data/lib/rails_error_dashboard/version.rb +1 -1
  34. data/lib/rails_error_dashboard.rb +3 -0
  35. data/lib/tasks/error_dashboard.rake +77 -5
  36. data/lib/tasks/rails_error_dashboard_tasks.rake +37 -43
  37. metadata +5 -3
  38. 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
- silent: 4
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 || :silent
93
- LOG_LEVELS[level] >= LOG_LEVELS[config_level]
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
- # Protects both dashboard UI and API endpoints from abuse
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
- # Rate limits by endpoint type (relative to engine mount path)
9
- API_LIMIT = { limit: 100, period: 60 }.freeze # 100 req/min
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
- # Find matching rate limit configuration
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 rate_limit_response(request, limit_config)
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 find_limit_config(path)
60
- if path.start_with?("#{engine_mount_path}/api")
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
- ActiveRecord::Base.connection.adapter_name.downcase == "postgresql"
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 both string class names and regex patterns
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
@@ -1,3 +1,3 @@
1
1
  module RailsErrorDashboard
2
- VERSION = "0.14.2"
2
+ VERSION = "0.14.4"
3
3
  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.