rails_error_dashboard 0.14.2 → 0.14.3

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 93c0e9e0f21298eb85db1d661793709d60158c5f2c6f85b164da36b4593525ee
4
- data.tar.gz: 37bae1ddaca2d502e434e0b4e6e95f6c755619598dec20f08495ce13fd0c286a
3
+ metadata.gz: f96e195659bca5562fcf7c4ed02c1f8f69c058daec06f6902523958f5746a8a4
4
+ data.tar.gz: 2ea940fe412652bef9e0f47eb818d4da93a6d7b7cb1e32cd5820e96cf032fafe
5
5
  SHA512:
6
- metadata.gz: 9c6be20b1be293c069516ed0f5f6fc19e46203224bb9aaad6be1c645f45ebb3455e03e6b3fef8d8738e4cc658dc928cd56c9f2d403d270ec6caaaa74411074b7
7
- data.tar.gz: 7c1c344be4398b47faf98de6a9be675f41a86878b49404d76b73d1a5be91f046ded677c70ebb9c1f4118e8736873ea491edcfed07b6520465049b9ecb3e1066b
6
+ metadata.gz: b7b8a36c5ffde9bcd71a0649a0d2df111345625717ec67a997e4bd4929215700408efd95844b41506e3a48aa47fc7179cd1df140aa1274451a1a8760a5f32db7
7
+ data.tar.gz: d6b3722433ce12451ef44242874dfe98620441832626368d0e8ce9d852396d8e4b00b5cc5773f04a348d48f64254fc763cd4fae4a1580d5b399cb4aab21f3de3
data/README.md CHANGED
@@ -635,9 +635,9 @@ The installer guides you through optional feature selection — notifications, p
635
635
  http://localhost:3000/red
636
636
  ```
637
637
 
638
- Default credentials: `gandalf` / `youshallnotpass`
638
+ Default credentials: `gandalf` / `youshallnotpass`, for development and test only.
639
639
 
640
- **Change these before production!** Edit `config/initializers/rails_error_dashboard.rb`
640
+ **Before you deploy anywhere else**, set `ERROR_DASHBOARD_USER` and `ERROR_DASHBOARD_PASSWORD` in that environment. Outside development and test, the app refuses to boot on the defaults. See [Dashboard Credentials](docs/guides/CONFIGURATION.md#dashboard-credentials).
641
641
 
642
642
  ### 4. Test it out
643
643
 
@@ -654,11 +654,8 @@ raise "Test error from Rails Error Dashboard!"
654
654
 
655
655
  ```ruby
656
656
  RailsErrorDashboard.configure do |config|
657
- # Authentication
658
- config.dashboard_username = ENV.fetch('ERROR_DASHBOARD_USER', 'gandalf')
659
- config.dashboard_password = ENV.fetch('ERROR_DASHBOARD_PASSWORD', 'youshallnotpass')
660
-
661
- # Or use your existing auth (Devise, Warden, etc.):
657
+ # Dashboard login: set ERROR_DASHBOARD_USER and ERROR_DASHBOARD_PASSWORD outside
658
+ # development and test (the gem reads them itself). Or use your existing auth:
662
659
  # config.authenticate_with = -> { warden.authenticated? }
663
660
 
664
661
  # Optional features — enable as needed
@@ -33,6 +33,32 @@ module RailsErrorDashboard
33
33
  )
34
34
  end
35
35
 
36
+ # Deletes error logs and everything that references them: dependents first,
37
+ # all in batches. Occurrences, comments and cascade patterns hold foreign
38
+ # keys to the log, so deleting the logs first fails, and one unbounded
39
+ # DELETE would lock the table. Public because error_dashboard:cleanup_resolved
40
+ # deletes the same way.
41
+ #
42
+ # @param scope [ActiveRecord::Relation] the ErrorLog rows to delete
43
+ # @return [Integer] number of error logs deleted
44
+ def self.delete_with_dependents(scope)
45
+ ids = scope.select(:id)
46
+
47
+ ErrorOccurrence.where(error_log_id: ids).in_batches(of: 1000).delete_all
48
+ # Hour buckets for storm-shed events. Listed explicitly because delete_all
49
+ # does not fire the has_many :dependent callback on ErrorLog -- without
50
+ # this line the buckets outlive the group they describe, forever.
51
+ EventCount.where(error_log_id: ids).in_batches(of: 1000).delete_all if EventCount.table_exists?
52
+ ErrorComment.where(error_log_id: ids).in_batches(of: 1000).delete_all
53
+ CascadePattern.where(parent_error_id: ids)
54
+ .or(CascadePattern.where(child_error_id: ids))
55
+ .in_batches(of: 1000).delete_all
56
+
57
+ deleted = 0
58
+ scope.in_batches(of: 1000) { |batch| deleted += batch.delete_all }
59
+ deleted
60
+ end
61
+
36
62
  # @return [Integer] number of errors deleted
37
63
  def perform
38
64
  retention_days = RailsErrorDashboard.configuration.retention_days
@@ -52,29 +78,7 @@ module RailsErrorDashboard
52
78
  expired_scope = self.class.expired_scope(cutoff)
53
79
  return 0 if expired_scope.none?
54
80
 
55
- deleted_count = 0
56
-
57
- # Delete dependents first, then errors — all in batches to prevent table locks
58
- expired_ids_scope = expired_scope.select(:id)
59
-
60
- # Batch delete dependent records (occurrences, comments, cascade patterns)
61
- ErrorOccurrence.where(error_log_id: expired_ids_scope).in_batches(of: 1000).delete_all
62
- # Hour buckets for storm-shed events. Listed explicitly because this job
63
- # deletes with delete_all, which does not fire the has_many :dependent
64
- # callback on ErrorLog -- without this line the buckets outlive the group
65
- # they describe, forever. The migration's own comment promised this
66
- # pruning before the code existed.
67
- EventCount.where(error_log_id: expired_ids_scope).in_batches(of: 1000).delete_all if EventCount.table_exists?
68
- ErrorComment.where(error_log_id: expired_ids_scope).in_batches(of: 1000).delete_all
69
- CascadePattern.where(parent_error_id: expired_ids_scope)
70
- .or(CascadePattern.where(child_error_id: expired_ids_scope))
71
- .in_batches(of: 1000).delete_all
72
-
73
- # Now batch delete the error logs themselves
74
- expired_scope.in_batches(of: 1000) do |batch|
75
- batch_size = batch.delete_all
76
- deleted_count += batch_size
77
- end
81
+ deleted_count = self.class.delete_with_dependents(expired_scope)
78
82
 
79
83
  # delete_all skips callbacks, and the stat cards are cached.
80
84
  Services::AnalyticsCacheManager.clear if deleted_count > 0
@@ -15,7 +15,7 @@ module RailsErrorDashboard
15
15
  class_option :pagerduty, type: :boolean, default: false, desc: "Enable PagerDuty notifications"
16
16
  class_option :webhooks, type: :boolean, default: false, desc: "Enable webhook notifications"
17
17
  # Performance options
18
- class_option :async_logging, type: :boolean, default: true, desc: "Enable async error logging (default: true, uses Rails :async adapter — no extra infrastructure needed)"
18
+ class_option :async_logging, type: :boolean, default: true, desc: "Enable async error logging (default: true; jobs run on your app's Active Job adapter, so production needs a worker)"
19
19
  class_option :error_sampling, type: :boolean, default: false, desc: "Enable error sampling (reduce volume)"
20
20
  class_option :separate_database, type: :boolean, default: false, desc: "Use separate database for errors"
21
21
  class_option :database, type: :string, default: nil, desc: "Database name to use for errors (e.g., 'error_dashboard')"
@@ -383,7 +383,7 @@ module RailsErrorDashboard
383
383
  return
384
384
  end
385
385
 
386
- route "mount RailsErrorDashboard::Engine => '/red' # RED (Rails Error Dashboard) — also works at /error_dashboard"
386
+ route "mount RailsErrorDashboard::Engine => '/red' # RED (Rails Error Dashboard)"
387
387
  end
388
388
 
389
389
  def show_feature_summary
@@ -400,7 +400,7 @@ module RailsErrorDashboard
400
400
  say " ✓ Dashboard UI", :green
401
401
  say " ✓ Real-time Updates", :green
402
402
  say " ✓ Analytics", :green
403
- say " ✓ Async Logging (Rails :async adapter — no extra infrastructure needed)", :green
403
+ say " ✓ Async Logging (runs on your app's Active Job adapter)", :green
404
404
 
405
405
  # Count optional features enabled
406
406
  enabled_count = 0
@@ -477,14 +477,11 @@ module RailsErrorDashboard
477
477
  say " → Set PAGERDUTY_INTEGRATION_KEY in .env", :yellow if @enable_pagerduty
478
478
  say " → Set WEBHOOK_URLS in .env", :yellow if @enable_webhooks
479
479
  if @enable_async_logging
480
- # Check which async adapter is in use — only warn about external workers when needed
481
- async_section = File.exist?(File.join(destination_root, "config/initializers/rails_error_dashboard.rb")) &&
482
- File.read(File.join(destination_root, "config/initializers/rails_error_dashboard.rb"))
483
- if async_section && (async_section.include?("async_adapter = :sidekiq") || async_section.include?("async_adapter = :solid_queue"))
484
- say " → Ensure your background worker (Sidekiq/SolidQueue) is running for async logging", :yellow
485
- else
486
- say " ✓ Async logging uses Rails :async adapter — no extra process needed", :green
487
- end
480
+ # RED's jobs run on the app's own Active Job adapter; async_adapter only
481
+ # feeds validation and the Settings page. Rails 8 production uses Solid
482
+ # Queue, which needs a worker, so don't promise that none is needed.
483
+ say " → Async logging enqueues on your app's config.active_job.queue_adapter.", :yellow
484
+ say " Run a worker for the default and error_notifications queues, or set config.async_logging = false.", :yellow
488
485
  end
489
486
 
490
487
  # Database-specific instructions
@@ -503,21 +500,22 @@ module RailsErrorDashboard
503
500
  else
504
501
  say " 3. Run: rails db:migrate:error_dashboard"
505
502
  end
506
- say " 4. Update credentials in config/initializers/rails_error_dashboard.rb"
503
+ say " 4. Before deploying: set ERROR_DASHBOARD_USER and ERROR_DASHBOARD_PASSWORD there"
507
504
  say " 5. Restart your Rails server"
508
505
  say " 6. Visit http://localhost:3000/red"
509
506
  say " 7. Verify: rails error_dashboard:verify"
510
507
  else
511
508
  say " 1. Run: rails db:migrate"
512
- say " 2. Update credentials in config/initializers/rails_error_dashboard.rb"
509
+ say " 2. Before deploying: set ERROR_DASHBOARD_USER and ERROR_DASHBOARD_PASSWORD there"
513
510
  say " 3. Restart your Rails server"
514
511
  say " 4. Visit http://localhost:3000/red"
515
512
  end
516
513
  say "Authentication:", :cyan
517
- say " Default: HTTP Basic Auth (gandalf/youshallnotpass)", :white
514
+ say " Default: HTTP Basic Auth, gandalf/youshallnotpass in development and test only", :white
515
+ say " Elsewhere: set ERROR_DASHBOARD_USER and ERROR_DASHBOARD_PASSWORD, or the app won't boot", :white
518
516
  say " Devise/Warden: config.authenticate_with = -> { warden.authenticated? }", :white
519
517
  say " Session-based: config.authenticate_with = -> { session[:admin] == true }", :white
520
- say " See: https://github.com/AnjanJ/rails_error_dashboard/blob/main/docs/guides/CONFIGURATION.md#custom-authentication", :white
518
+ say " See: https://github.com/AnjanJ/rails_error_dashboard/blob/main/docs/guides/CONFIGURATION.md#dashboard-credentials", :white
521
519
  say "\n"
522
520
  say "Issue Tracking (optional):", :cyan
523
521
  say " Create a dedicated RED (Rails Error Dashboard) bot account on your platform:", :white
@@ -549,7 +547,7 @@ module RailsErrorDashboard
549
547
 
550
548
  def build_quick_defaults
551
549
  {
552
- # Async ON (built-in Rails :async adapter, zero infrastructure)
550
+ # Async ON (runs on the app's Active Job adapter)
553
551
  async_logging: true,
554
552
  # All analytics ON — run at query/background time, zero request-path overhead
555
553
  baseline_alerts: true,
@@ -5,9 +5,12 @@ RailsErrorDashboard.configure do |config|
5
5
  # AUTHENTICATION (Always Required - Cannot Be Disabled)
6
6
  # ============================================================================
7
7
 
8
- # Dashboard authentication credentials
9
- # ⚠️ CHANGE THESE BEFORE PRODUCTION! ⚠️
10
- # Authentication is ALWAYS enforced in ALL environments (production, development, test)
8
+ # Dashboard login (HTTP Basic Auth). Always enforced, in every environment.
9
+ # gandalf / youshallnotpass work in development and test only. Everywhere
10
+ # else, set ERROR_DASHBOARD_USER and ERROR_DASHBOARD_PASSWORD in the
11
+ # environment, or the app refuses to boot. Don't replace the fallback values
12
+ # below with a real password: it would end up in source control.
13
+ # https://github.com/AnjanJ/rails_error_dashboard/blob/main/docs/guides/CONFIGURATION.md#dashboard-credentials
11
14
  config.dashboard_username = ENV.fetch("ERROR_DASHBOARD_USER", "gandalf")
12
15
  config.dashboard_password = ENV.fetch("ERROR_DASHBOARD_PASSWORD", "youshallnotpass")
13
16
 
@@ -160,9 +163,11 @@ RailsErrorDashboard.configure do |config|
160
163
 
161
164
  <% if @enable_async_logging -%>
162
165
  # Async Error Logging - ENABLED
163
- # Errors are logged in background jobs — zero impact on request response time.
164
- # Default adapter is :async (Rails built-in, no extra infrastructure needed).
165
- # Swap to :sidekiq or :solid_queue when you have a background worker running.
166
+ # Errors are logged in background jobs, off the request path. The jobs run on
167
+ # your app's Active Job adapter (config.active_job.queue_adapter), so in
168
+ # production a worker must process the default and error_notifications queues
169
+ # (Rails 8: bin/jobs for Solid Queue). async_adapter below is only validated
170
+ # and shown on the Settings page; it does not choose the backend.
166
171
  config.async_logging = true
167
172
  config.async_adapter = :async # Options: :async (built-in), :sidekiq, :solid_queue
168
173
  # To disable: Set config.async_logging = false
@@ -446,7 +451,8 @@ RailsErrorDashboard.configure do |config|
446
451
  config.enable_internal_logging = false
447
452
 
448
453
  # Log level (default: :silent)
449
- # Options: :debug, :info, :warn, :error, :silent
454
+ # Options: :debug, :info, :warn, :error, :fatal, :silent
455
+ # (RED has no fatal-level messages, so :fatal logs nothing, like :silent)
450
456
  config.log_level = :silent
451
457
 
452
458
  # Example: Enable verbose logging for debugging
@@ -2,34 +2,54 @@
2
2
 
3
3
  module RailsErrorDashboard
4
4
  module Generators
5
- # Generator for Solid Queue configuration
5
+ # Deprecated: checks the app's Solid Queue config for RED. Writes nothing.
6
6
  # Usage: rails generate rails_error_dashboard:solid_queue
7
+ #
8
+ # It used to write a config/queue.yml with workers and no dispatchers.
9
+ # Solid Queue only falls back to its default dispatcher when a section has
10
+ # neither, so that file ran no dispatcher: no delayed job ran, the host
11
+ # app's own included. A Solid Queue config with a "*" worker and a
12
+ # dispatcher already processes RED's queues, so this generator now only
13
+ # checks the file the app has. It never writes Solid Queue's files or
14
+ # points at Solid Queue's installer (decision 0001). To be removed in a
15
+ # later minor release.
7
16
  class SolidQueueGenerator < Rails::Generators::Base
8
- source_root File.expand_path("templates", __dir__)
17
+ desc "Deprecated: checks config/queue.yml for problems that stop RED's jobs. Writes nothing."
9
18
 
10
- desc "Creates Solid Queue configuration for RailsErrorDashboard"
19
+ def check_queue_config
20
+ say "\nrails_error_dashboard:solid_queue is deprecated and no longer writes config/queue.yml.", :yellow
21
+ say "A Solid Queue config with a \"*\" worker and a dispatcher already processes RED's queues.\n\n"
11
22
 
12
- def create_queue_config
13
- template "queue.yml", "config/queue.yml"
23
+ check = RailsErrorDashboard::Services::SolidQueueConfigCheck
24
+ path = check.config_path(destination_root)
25
+ relative = path.relative_path_from(Pathname(destination_root)).to_s
26
+
27
+ unless path.exist?
28
+ say "No #{relative}: Solid Queue runs its defaults (a \"*\" worker and a dispatcher), which process RED's queues.", :green
29
+ return
30
+ end
31
+
32
+ environments = check.app_environments(destination_root)
33
+ environments = [ Rails.env.to_s ] if environments.empty?
34
+ result = check.call(path, environments: environments)
35
+
36
+ if result[:skipped]
37
+ say "Could not check #{relative}: #{result[:skipped]}", :yellow
38
+ elsif result[:problems].any?
39
+ say "#{relative} has problems that stop jobs from running:", :red
40
+ result[:problems].each { |problem| say " - #{problem}", :red }
41
+ say ""
42
+ check::FIX.each { |line| say line, :yellow }
43
+ else
44
+ say "#{relative} processes RED's queues (#{check.red_queue_names.join(', ')}) and runs a dispatcher.", :green
45
+ end
14
46
  end
15
47
 
16
48
  def show_instructions
17
- say "\n" + "=" * 80, :green
18
- say "Solid Queue configuration created!", :green
19
- say "=" * 80, :green
20
- say "\nNext steps:", :yellow
21
- say " 1. Install Solid Queue gem (if not already):", :cyan
22
- say " bundle add solid_queue", :white
23
- say "\n 2. Run Solid Queue migrations:", :cyan
24
- say " bin/rails solid_queue:install", :white
25
- say "\n 3. Set ActiveJob adapter in config/application.rb:", :cyan
26
- say " config.active_job.queue_adapter = :solid_queue", :white
27
- say "\n 4. Start Solid Queue worker:", :cyan
28
- say " bin/jobs", :white
29
- say "\n 5. Enable async logging in config/initializers/rails_error_dashboard.rb:", :cyan
30
- say " config.async_logging = true", :white
31
- say " config.async_adapter = :solid_queue", :white
32
- say "\n" + "=" * 80, :green
49
+ say "\nTo run RED's jobs with Solid Queue:", :cyan
50
+ say " config.active_job.queue_adapter = :solid_queue (config/environments/production.rb)", :white
51
+ say " Run a worker: bin/jobs, or SOLID_QUEUE_IN_PUMA=1 with Puma's solid_queue plugin", :white
52
+ say " Check anytime: bin/rails error_dashboard:verify\n", :white
33
53
  end
34
54
  end
35
55
  end
@@ -5,6 +5,11 @@ module RailsErrorDashboard
5
5
  class UninstallGenerator < Rails::Generators::Base
6
6
  desc "Uninstalls Rails Error Dashboard and removes all associated files and data"
7
7
 
8
+ MIGRATION_GLOBS = [
9
+ "db/migrate/*rails_error_dashboard*.rb",
10
+ "db/error_dashboard_migrate/*rails_error_dashboard*.rb"
11
+ ].freeze
12
+
8
13
  class_option :keep_data, type: :boolean, default: false, desc: "Keep error data in database (don't drop tables)"
9
14
  class_option :skip_confirmation, type: :boolean, default: false, desc: "Skip confirmation prompts"
10
15
  class_option :manual_only, type: :boolean, default: false, desc: "Show manual instructions only, don't perform automated uninstall"
@@ -35,6 +40,15 @@ module RailsErrorDashboard
35
40
  say " #{status_icon(@components[:migrations])} Migrations (#{migration_count} files)"
36
41
  say " #{status_icon(@components[:tables])} Database tables (#{table_count} tables)"
37
42
  say "\n"
43
+
44
+ # Couldn't see the error database (no database.yml entry for it, or the
45
+ # server is down). Carrying on would remove the initializer and leave
46
+ # every table behind, with nothing left that knows where they are.
47
+ if @database_error && !options[:keep_data] && !options[:manual_only]
48
+ raise Thor::Error, "Can't reach the error database, so nothing was removed: #{@database_error}\n" \
49
+ "Fix config/database.yml (or start the database) and run this again, " \
50
+ "or pass --keep-data to remove only the files."
51
+ end
38
52
  end
39
53
 
40
54
  def show_manual_instructions
@@ -42,13 +56,16 @@ module RailsErrorDashboard
42
56
  say " 📖 Manual Uninstall Instructions", :cyan
43
57
  say "=" * 80
44
58
  say "\n"
45
-
46
- say "Step 1: Remove from Gemfile", :yellow
47
- say " Open: Gemfile"
48
- say " Remove: gem 'rails_error_dashboard'"
49
- say " Run: bundle install"
59
+ say "Do these in order: the table drop needs the gem, so the Gemfile comes last.", :yellow
50
60
  say "\n"
51
61
 
62
+ if @components[:tables]
63
+ say "Step 1: Drop database tables (⚠️ DESTRUCTIVE - will delete all error data)", :yellow
64
+ say " Run: bin/rails rails_error_dashboard:db:drop"
65
+ say " It drops every RED table in foreign-key order, on the error database if you use one."
66
+ say "\n"
67
+ end
68
+
52
69
  if @components[:initializer]
53
70
  say "Step 2: Remove initializer", :yellow
54
71
  say " Delete: config/initializers/rails_error_dashboard.rb"
@@ -58,33 +75,31 @@ module RailsErrorDashboard
58
75
  if @components[:route]
59
76
  say "Step 3: Remove route", :yellow
60
77
  say " Open: config/routes.rb"
61
- say " Remove: mount RailsErrorDashboard::Engine => '/error_dashboard'"
78
+ say " Remove the line: mount RailsErrorDashboard::Engine => ..."
62
79
  say "\n"
63
80
  end
64
81
 
65
82
  if @components[:migrations]
66
83
  say "Step 4: Remove migrations", :yellow
67
- say " Delete migration files from db/migrate/:"
68
84
  migration_files.each do |file|
69
- say " - #{File.basename(file)}", :white
85
+ say " - #{file}", :white
70
86
  end
71
87
  say "\n"
72
88
  end
73
89
 
74
- if @components[:tables]
75
- say "Step 5: Drop database tables (⚠️ DESTRUCTIVE - will delete all error data)", :yellow
76
- say " Run: rails rails_error_dashboard:db:drop"
77
- say " Or manually in rails console:"
78
- say " ActiveRecord::Base.connection.execute('DROP TABLE rails_error_dashboard_error_logs')", :white
79
- say " ActiveRecord::Base.connection.execute('DROP TABLE rails_error_dashboard_error_occurrences')", :white
80
- say " ActiveRecord::Base.connection.execute('DROP TABLE rails_error_dashboard_cascade_patterns')", :white
81
- say " ActiveRecord::Base.connection.execute('DROP TABLE rails_error_dashboard_error_baselines')", :white
82
- say " ActiveRecord::Base.connection.execute('DROP TABLE rails_error_dashboard_error_comments')", :white
83
- say " ActiveRecord::Migration.drop_table(:rails_error_dashboard_error_logs) rescue nil", :white
84
- say "\n"
85
- end
90
+ say "Step 5: Separate database only: remove its entry from config/database.yml", :yellow
91
+ say "\n"
92
+
93
+ say "Step 6: Regenerate the schema file", :yellow
94
+ say " Run: bin/rails db:schema:dump (otherwise db:schema:load recreates the tables)"
95
+ say "\n"
86
96
 
87
- say "Step 6: Clean up environment variables (optional)", :yellow
97
+ say "Step 7: Remove from Gemfile", :yellow
98
+ say " Remove: gem 'rails_error_dashboard'"
99
+ say " Run: bundle install"
100
+ say "\n"
101
+
102
+ say "Step 8: Clean up environment variables (optional)", :yellow
88
103
  say " Remove from .env or environment:"
89
104
  say " - ERROR_DASHBOARD_USER"
90
105
  say " - ERROR_DASHBOARD_PASSWORD"
@@ -96,8 +111,7 @@ module RailsErrorDashboard
96
111
  say " - DASHBOARD_BASE_URL"
97
112
  say "\n"
98
113
 
99
- say "Step 7: Restart your application", :yellow
100
- say " Run: rails restart (or restart your server)"
114
+ say "Step 9: Restart your application", :yellow
101
115
  say "\n"
102
116
 
103
117
  say "=" * 80
@@ -165,6 +179,20 @@ module RailsErrorDashboard
165
179
  say "\n"
166
180
  end
167
181
 
182
+ # Before any file is removed: if the drop fails, the app still boots with
183
+ # its initializer and can reach the error database to try again.
184
+ def drop_database_tables
185
+ return if options[:manual_only]
186
+ return if options[:keep_data]
187
+ return unless @components[:tables]
188
+
189
+ say " Dropping database tables...", :yellow
190
+ dropped = RailsErrorDashboard::Commands::DropAllTables.call(connection: drop_connection)
191
+ say " ✓ Dropped #{dropped.size} database table(s)", :green
192
+ rescue => e
193
+ raise Thor::Error, "Could not drop RED's tables, so no files were removed: #{e.message}"
194
+ end
195
+
168
196
  def remove_initializer
169
197
  return if options[:manual_only]
170
198
  return unless @components[:initializer]
@@ -182,7 +210,7 @@ module RailsErrorDashboard
182
210
  say " ✓ Removed route", :green
183
211
  rescue => e
184
212
  say " ⚠️ Could not automatically remove route: #{e.message}", :yellow
185
- say " Please manually remove: mount RailsErrorDashboard::Engine => '/error_dashboard'", :yellow
213
+ say " Please remove the mount RailsErrorDashboard::Engine line from config/routes.rb", :yellow
186
214
  end
187
215
  end
188
216
 
@@ -196,35 +224,6 @@ module RailsErrorDashboard
196
224
  say " ✓ Removed #{migration_count} migration file(s)", :green
197
225
  end
198
226
 
199
- def drop_database_tables
200
- return if options[:manual_only]
201
- return if options[:keep_data]
202
- return unless @components[:tables]
203
-
204
- say " Dropping database tables...", :yellow
205
-
206
- # Drop tables in reverse order (to respect foreign keys)
207
- tables_to_drop = [
208
- "rails_error_dashboard_error_comments",
209
- "rails_error_dashboard_error_occurrences",
210
- "rails_error_dashboard_cascade_patterns",
211
- "rails_error_dashboard_error_baselines",
212
- "rails_error_dashboard_error_logs"
213
- ]
214
-
215
- dropped_count = 0
216
- tables_to_drop.each do |table|
217
- if ActiveRecord::Base.connection.table_exists?(table)
218
- ActiveRecord::Base.connection.drop_table(table, if_exists: true)
219
- dropped_count += 1
220
- end
221
- rescue => e
222
- say " ⚠️ Could not drop table #{table}: #{e.message}", :yellow
223
- end
224
-
225
- say " ✓ Dropped #{dropped_count} database table(s)", :green
226
- end
227
-
228
227
  def show_completion_message
229
228
  return if options[:manual_only]
230
229
 
@@ -282,7 +281,7 @@ module RailsErrorDashboard
282
281
  end
283
282
 
284
283
  def migration_files
285
- Dir.glob("db/migrate/*rails_error_dashboard*.rb")
284
+ Dir.glob(MIGRATION_GLOBS)
286
285
  end
287
286
 
288
287
  def migration_count
@@ -290,22 +289,27 @@ module RailsErrorDashboard
290
289
  end
291
290
 
292
291
  def tables_exist?
293
- return false unless defined?(ActiveRecord::Base)
294
- table_names.any? { |table| ActiveRecord::Base.connection.table_exists?(table) rescue false }
292
+ table_names.any?
295
293
  end
296
294
 
295
+ # The tables Queries::UninstallPlan finds on the error database, in drop
296
+ # order. Empty (with the reason shown) when that database can't be reached.
297
297
  def table_names
298
- [
299
- "rails_error_dashboard_error_logs",
300
- "rails_error_dashboard_error_occurrences",
301
- "rails_error_dashboard_cascade_patterns",
302
- "rails_error_dashboard_error_baselines",
303
- "rails_error_dashboard_error_comments"
304
- ]
298
+ @table_names ||= begin
299
+ RailsErrorDashboard::Queries::UninstallPlan.call(drop_connection).map { |entry| entry[:table] }
300
+ rescue => e
301
+ @database_error = e.message
302
+ say " ⚠️ Could not inspect the error database: #{e.message}", :yellow
303
+ []
304
+ end
305
+ end
306
+
307
+ def drop_connection
308
+ RailsErrorDashboard::Commands::DropAllTables.connection
305
309
  end
306
310
 
307
311
  def table_count
308
- table_names.count { |table| ActiveRecord::Base.connection.table_exists?(table) rescue false }
312
+ table_names.count
309
313
  end
310
314
 
311
315
  def gemfile_includes_gem?
@@ -0,0 +1,77 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsErrorDashboard
4
+ module Commands
5
+ # Drops every RED table, for uninstalling. DESTRUCTIVE: all error data goes.
6
+ #
7
+ # Used by the rails_error_dashboard:db:drop task and the uninstall
8
+ # generator. Drops on the connection RED's models use, so a separate or
9
+ # shared error database is covered, in the order Queries::UninstallPlan
10
+ # derives from the foreign keys. Where the adapter runs DDL in a transaction
11
+ # (PostgreSQL, SQLite) it is all-or-nothing. Errors are raised, never
12
+ # swallowed: a half-finished uninstall must not report success.
13
+ #
14
+ # @example
15
+ # RailsErrorDashboard::Commands::DropAllTables.call
16
+ # # => ["rails_error_dashboard_error_comments", ...]
17
+ class DropAllTables
18
+ class Error < StandardError; end
19
+
20
+ # The connection RED's models use.
21
+ #
22
+ # @raise [Error] when use_separate_database is on but the engine fell back
23
+ # to the primary database because database.yml has no entry for it:
24
+ # dropping there would leave the real error database untouched.
25
+ # @return [ActiveRecord::ConnectionAdapters::AbstractAdapter]
26
+ def self.connection
27
+ config = RailsErrorDashboard.configuration
28
+ record = RailsErrorDashboard::ErrorLogsRecord
29
+
30
+ if config.use_separate_database
31
+ expected = config.database.to_s
32
+ actual = record.connection_db_config&.name.to_s
33
+ if actual != expected
34
+ raise Error, "use_separate_database is on, but RED is connected to the '#{actual}' database, " \
35
+ "not '#{expected}': database.yml has no '#{expected}' entry for #{Rails.env}. " \
36
+ "Add it (see docs/guides/DATABASE_OPTIONS.md) and run this again, or RED's tables " \
37
+ "in the error database would be left behind."
38
+ end
39
+ end
40
+
41
+ record.connection
42
+ end
43
+
44
+ # @param connection [ActiveRecord::ConnectionAdapters::AbstractAdapter]
45
+ # @return [Array<String>] the dropped tables, in drop order
46
+ # @raise [Error] when a host table has a foreign key into a RED table
47
+ def self.call(connection: self.connection)
48
+ tables = Queries::UninstallPlan.call(connection).map { |entry| entry[:table] }
49
+ return [] if tables.empty?
50
+
51
+ blocking = host_foreign_keys(connection, tables)
52
+ if blocking.any?
53
+ raise Error, "Not dropping anything: these tables have foreign keys into RED's tables. " \
54
+ "Remove the foreign keys first: #{blocking.join('; ')}"
55
+ end
56
+
57
+ drop = -> { tables.each { |table| connection.drop_table(table, if_exists: true) } }
58
+ if connection.supports_ddl_transactions?
59
+ connection.transaction(requires_new: true) { drop.call }
60
+ else
61
+ drop.call
62
+ end
63
+
64
+ tables
65
+ end
66
+
67
+ def self.host_foreign_keys(connection, tables)
68
+ (connection.tables - tables).flat_map do |table|
69
+ connection.foreign_keys(table)
70
+ .select { |fk| tables.include?(fk.to_table) }
71
+ .map { |fk| "#{table}.#{fk.column} -> #{fk.to_table} (#{fk.name})" }
72
+ end
73
+ end
74
+ private_class_method :host_foreign_keys
75
+ end
76
+ end
77
+ end
@@ -495,7 +495,7 @@ module RailsErrorDashboard
495
495
 
496
496
  # Internal logging defaults - SILENT by default
497
497
  @enable_internal_logging = false # Opt-in for debugging
498
- @log_level = :silent # Silent by default, use :debug, :info, :warn, :error, or :silent
498
+ @log_level = :silent # Silent by default; see Logger::LOG_LEVELS for the levels
499
499
 
500
500
  # Dashboard UI
501
501
  @accent_color = :crimson # :crimson, :ruby, :ember, :violet
@@ -554,6 +554,14 @@ module RailsErrorDashboard
554
554
  errors << "Default or blank credentials cannot be used in #{Rails.env}: #{reason}. Only development and test may run on the built-in credentials. Set ERROR_DASHBOARD_USER and ERROR_DASHBOARD_PASSWORD environment variables and make sure the initializer does not overwrite them, or use authenticate_with for custom auth."
555
555
  end
556
556
 
557
+ # In development the app boots on a blank credential, and the login then
558
+ # denies everyone: say so, or the developer is locked out with no clue
559
+ # why. Development only, because a test run boots on every CI job.
560
+ if default_credentials? && credentials_problem == :blank &&
561
+ defined?(Rails) && Rails.respond_to?(:env) && Rails.env.development?
562
+ warnings.concat(blank_credential_warnings)
563
+ end
564
+
557
565
  # Validate sampling_rate (must be between 0.0 and 1.0)
558
566
  if sampling_rate && (sampling_rate < 0.0 || sampling_rate > 1.0)
559
567
  errors << "sampling_rate must be between 0.0 and 1.0 (got: #{sampling_rate})"
@@ -786,7 +794,9 @@ module RailsErrorDashboard
786
794
 
787
795
  # Validate log level (must be valid symbol)
788
796
  if log_level
789
- valid_log_levels = %i[debug info warn error fatal silent]
797
+ # Referenced here, not as a class-body constant: this file loads before
798
+ # logger.rb, and a bare Logger in the class body would be ::Logger.
799
+ valid_log_levels = RailsErrorDashboard::Logger::LOG_LEVELS.keys
790
800
  unless valid_log_levels.include?(log_level)
791
801
  errors << "log_level must be one of #{valid_log_levels.inspect} (got: #{log_level.inspect})"
792
802
  end
@@ -996,6 +1006,27 @@ module RailsErrorDashboard
996
1006
  !Rails.env.development? && !Rails.env.test?
997
1007
  end
998
1008
 
1009
+ # One warning per blank credential, naming where the blank came from: an
1010
+ # environment variable that is set but empty, or the initializer.
1011
+ #
1012
+ # @return [Array<String>]
1013
+ private def blank_credential_warnings
1014
+ {
1015
+ "ERROR_DASHBOARD_USER" => [ :dashboard_username, dashboard_username ],
1016
+ "ERROR_DASHBOARD_PASSWORD" => [ :dashboard_password, dashboard_password ]
1017
+ }.filter_map do |variable, (setting, value)|
1018
+ next unless self.class.blank_credential?(value)
1019
+
1020
+ if ENV.key?(variable) && ENV[variable].to_s == value.to_s
1021
+ "#{variable} is set but empty, so every dashboard login will be denied. " \
1022
+ "Unset it to use the development default, or give it a value."
1023
+ else
1024
+ "config.#{setting} is blank, so every dashboard login will be denied. " \
1025
+ "Remove that line from the initializer to use the development default, or give it a value."
1026
+ end
1027
+ end
1028
+ end
1029
+
999
1030
  # Resolve the effective issue tracker provider (auto-detect from git_repository_url).
1000
1031
  # Linear is never auto-detected (it is not a git forge) — set issue_tracker_provider
1001
1032
  # explicitly.
@@ -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
@@ -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
@@ -0,0 +1,214 @@
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 queues RED's jobs are enqueued on, as this app names them
61
+ # (queue_name_prefix included).
62
+ #
63
+ # @return [Array<String>]
64
+ def self.red_queue_names
65
+ [ RailsErrorDashboard::AsyncErrorLoggingJob, RailsErrorDashboard::SlackErrorNotificationJob ]
66
+ .map { |job| job.queue_name.to_s }
67
+ .uniq
68
+ rescue => e
69
+ RailsErrorDashboard::Logger.debug("SolidQueueConfigCheck could not read queue names: #{e.class}: #{e.message}")
70
+ %w[default error_notifications]
71
+ end
72
+
73
+ # Read the file the way Solid Queue does (ERB, then YAML with aliases) and
74
+ # check each environment. Never raises: an unreadable file is reported as
75
+ # skipped.
76
+ #
77
+ # @param path [Pathname, String]
78
+ # @param environments [Array<String>]
79
+ # @param queue_names [Array<String>]
80
+ # @return [Hash] { problems: Array<String>, skipped: String or nil }
81
+ def self.call(path, environments:, queue_names: red_queue_names)
82
+ { problems: check(parse(path), environments: environments, queue_names: queue_names), skipped: nil }
83
+ rescue StandardError, ScriptError => e # ScriptError: ERB in the file that doesn't compile
84
+ { problems: [], skipped: "could not read #{path}: #{e.class}: #{e.message.lines.first.to_s.strip}" }
85
+ end
86
+
87
+ # @param path [Pathname, String]
88
+ # @return [Hash] the parsed file, symbolized keys, as Solid Queue reads it
89
+ def self.parse(path)
90
+ ActiveSupport::ConfigurationFile.parse(Pathname(path.to_s)).deep_symbolize_keys
91
+ end
92
+
93
+ # @param config [Hash] the parsed file, symbolized keys
94
+ # @param environments [Array<String>]
95
+ # @param queue_names [Array<String>]
96
+ # @return [Array<String>] one line per problem, prefixed with the environment
97
+ def self.check(config, environments:, queue_names:)
98
+ environments.flat_map do |env|
99
+ facts = processes_for(config, environment: env, queue_names: queue_names)
100
+ problems_in(facts, queue_names).map { |problem| "#{env}: #{problem}" }
101
+ end
102
+ end
103
+
104
+ # What Solid Queue would start for one environment. The contract spec
105
+ # compares this with the real SolidQueue::Configuration and QueueSelector.
106
+ #
107
+ # @param config [Hash] the parsed file, symbolized keys
108
+ # @param environment [String]
109
+ # @param queue_names [Array<String>]
110
+ # @param solid_queue_version [String, nil] the rules to follow; the loaded
111
+ # Solid Queue's by default, the newest when none is loaded
112
+ # @return [Hash] { dispatchers: Integer, workers: Integer,
113
+ # processed: Array<String> (the queue_names a worker picks up), error: String or nil }
114
+ def self.processes_for(config, environment:, queue_names:, solid_queue_version: loaded_solid_queue_version)
115
+ section = section_for(config, environment)
116
+ return cannot_start("the #{environment} section is not a set of settings") unless section.is_a?(Hash)
117
+
118
+ section = section.slice(*process_keys(solid_queue_version))
119
+ # Empty: Solid Queue runs its defaults, a "*" worker and a dispatcher.
120
+ return { dispatchers: 1, workers: 1, processed: queue_names.dup, error: nil } if section.empty?
121
+
122
+ error = shape_error(section)
123
+ return cannot_start(error) if error
124
+
125
+ # bin/jobs runs in fork mode: each worker entry starts `processes` of them.
126
+ workers = section.fetch(:workers, []).select { |worker| worker.fetch(:processes, 1).positive? }
127
+ {
128
+ dispatchers: section.fetch(:dispatchers, []).size,
129
+ workers: workers.sum { |worker| worker.fetch(:processes, 1) },
130
+ processed: queue_names.select { |name| workers.any? { |worker| processes?(worker, name) } },
131
+ error: nil
132
+ }
133
+ end
134
+
135
+ def self.loaded_solid_queue_version
136
+ ::SolidQueue::VERSION if defined?(::SolidQueue::VERSION)
137
+ end
138
+
139
+ def self.process_keys(solid_queue_version)
140
+ return PROCESS_KEYS if solid_queue_version.nil? || Gem::Version.new(solid_queue_version) >= SCHEDULER_KEY_SINCE
141
+
142
+ PROCESS_KEYS - [ :scheduler ]
143
+ rescue ArgumentError # a version string Gem::Version can't read
144
+ PROCESS_KEYS
145
+ end
146
+ private_class_method :process_keys
147
+
148
+ # SolidQueue::Configuration#config_from: the environment's section when
149
+ # present, else the whole file.
150
+ def self.section_for(config, env)
151
+ config[env.to_sym] ? config[env.to_sym] : config
152
+ end
153
+ private_class_method :section_for
154
+
155
+ # Settings Solid Queue raises on while it builds its processes, so
156
+ # bin/jobs can't start: a key present with no value, a map where a list
157
+ # belongs, a list entry that isn't a set of settings, or processes that
158
+ # isn't a whole number.
159
+ def self.shape_error(section)
160
+ %i[workers dispatchers].each do |key|
161
+ next unless section.key?(key)
162
+
163
+ list = section[key]
164
+ return "#{key}: must be a list, each entry starting with \"- \"" unless list.is_a?(Array)
165
+ return "every entry under #{key}: must be a set of settings" unless list.all?(Hash)
166
+ end
167
+
168
+ if section.fetch(:workers, []).any? { |worker| worker.key?(:processes) && !worker[:processes].is_a?(Integer) }
169
+ return "a worker's processes: must be a whole number"
170
+ end
171
+
172
+ nil
173
+ end
174
+ private_class_method :shape_error
175
+
176
+ def self.cannot_start(reason)
177
+ { dispatchers: 0, workers: 0, processed: [], error: reason }
178
+ end
179
+ private_class_method :cannot_start
180
+
181
+ def self.problems_in(facts, queue_names)
182
+ return [ "Solid Queue can't start with this config: #{facts[:error]}" ] if facts[:error]
183
+
184
+ dispatchers = facts[:dispatchers]
185
+ workers = facts[:workers]
186
+ return [ "no worker and no dispatcher, so no job ever runs" ] if workers.zero? && dispatchers.zero?
187
+ return [ "no worker process starts (no workers, or processes: 0), so no job is ever performed" ] if workers.zero?
188
+
189
+ problems = []
190
+ if dispatchers.zero?
191
+ problems << "workers but no dispatcher, so scheduled jobs and retries " \
192
+ "(retry_on wait:, perform_later(wait:)) never run, the app's own jobs included"
193
+ end
194
+
195
+ uncovered = queue_names - facts[:processed]
196
+ problems << "no worker processes #{uncovered.join(' or ')}" if uncovered.any?
197
+ problems
198
+ end
199
+ private_class_method :problems_in
200
+
201
+ # SolidQueue::QueueSelector: no queues means "*"; "*" is every queue; a
202
+ # trailing "*" is a prefix.
203
+ def self.processes?(worker, queue_name)
204
+ queues = worker.is_a?(Hash) ? Array(worker[:queues]).map { |queue| queue.to_s.strip } : []
205
+ queues = [ "*" ] if queues.empty?
206
+
207
+ queues.any? do |queue|
208
+ queue == "*" || queue == queue_name || (queue.end_with?("*") && queue_name.start_with?(queue.delete_suffix("*")))
209
+ end
210
+ end
211
+ private_class_method :processes?
212
+ end
213
+ end
214
+ end
@@ -1,3 +1,3 @@
1
1
  module RailsErrorDashboard
2
- VERSION = "0.14.2"
2
+ VERSION = "0.14.3"
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.
@@ -177,8 +177,12 @@ namespace :error_dashboard do
177
177
  if config.refuse_default_credentials?
178
178
  puts "WARNING - #{label} outside development and test!"
179
179
  checks_failed += 1
180
+ elsif problem == :blank
181
+ # The login denies everyone on a blank credential, in every environment.
182
+ puts "WARNING - #{label}: every login is denied"
183
+ warnings += 1
180
184
  else
181
- puts "OK (#{label} - change before production)"
185
+ puts "OK (#{label} - set ERROR_DASHBOARD_USER and ERROR_DASHBOARD_PASSWORD before deploying)"
182
186
  warnings += 1
183
187
  end
184
188
  elsif config.dashboard_password == RailsErrorDashboard::Configuration::DEFAULT_DASHBOARD_PASSWORD
@@ -189,6 +193,31 @@ namespace :error_dashboard do
189
193
  checks_passed += 1
190
194
  end
191
195
 
196
+ # 9. Solid Queue config. A section with workers and no dispatchers runs no
197
+ # dispatcher, so no delayed job ever runs, the app's own included; the
198
+ # retired rails_error_dashboard:solid_queue generator wrote exactly that.
199
+ # Checked whenever Solid Queue is loaded, not only when it is the current
200
+ # adapter: Rails 8 sets it in production only, and verify usually runs locally.
201
+ queue_check = RailsErrorDashboard::Services::SolidQueueConfigCheck
202
+ queue_config = queue_check.config_path(Rails.root)
203
+ if defined?(SolidQueue) && queue_config.exist?
204
+ print " Solid Queue config... "
205
+ environments = queue_check.app_environments(Rails.root).presence || [ Rails.env.to_s ]
206
+ result = queue_check.call(queue_config, environments: environments)
207
+ if result[:skipped]
208
+ puts "SKIPPED (#{result[:skipped]})"
209
+ warnings += 1
210
+ elsif result[:problems].any?
211
+ puts "FAILED"
212
+ result[:problems].each { |problem| puts " - #{problem}" }
213
+ queue_check::FIX.each { |line| puts " #{line}" }
214
+ checks_failed += 1
215
+ else
216
+ puts "OK"
217
+ checks_passed += 1
218
+ end
219
+ end
220
+
192
221
  # Summary
193
222
  puts "\n" + "-" * 70
194
223
  puts " Results: #{checks_passed} passed, #{checks_failed} failed, #{warnings} warnings"
@@ -453,7 +482,7 @@ namespace :error_dashboard do
453
482
 
454
483
  # Confirm before proceeding
455
484
  print "\nProceed with deletion? (y/N): "
456
- confirmation = $stdin.gets.chomp.downcase
485
+ confirmation = $stdin.gets.to_s.chomp.downcase
457
486
 
458
487
  unless confirmation == "y" || confirmation == "yes"
459
488
  puts "\n✗ Cleanup cancelled"
@@ -463,7 +492,9 @@ namespace :error_dashboard do
463
492
 
464
493
  puts "\nDeleting errors..."
465
494
  start_time = Time.current
466
- deleted = scope.delete_all
495
+ # Dependents first, in batches: occurrences, comments and cascade patterns
496
+ # hold foreign keys to the log, so a plain delete_all failed on them.
497
+ deleted = RailsErrorDashboard::RetentionCleanupJob.delete_with_dependents(scope)
467
498
  # delete_all skips callbacks, and the stat cards are cached.
468
499
  RailsErrorDashboard::Services::AnalyticsCacheManager.clear
469
500
  elapsed = (Time.current - start_time).round(2)
@@ -16,75 +16,69 @@ namespace :rails_error_dashboard do
16
16
  puts "=" * 80
17
17
  puts "\n"
18
18
 
19
- # List tables that will be dropped
20
- tables_to_drop = [
21
- "rails_error_dashboard_error_comments",
22
- "rails_error_dashboard_error_occurrences",
23
- "rails_error_dashboard_cascade_patterns",
24
- "rails_error_dashboard_error_baselines",
25
- "rails_error_dashboard_error_logs"
26
- ]
19
+ # The connection RED's models use: the separate or shared error database
20
+ # when there is one. Raises if the engine fell back to the primary one.
21
+ connection = RailsErrorDashboard::Commands::DropAllTables.connection
22
+ database = connection.pool.db_config.name
23
+ plan = RailsErrorDashboard::Queries::UninstallPlan.call(connection)
27
24
 
28
- existing_tables = tables_to_drop.select do |table|
29
- ActiveRecord::Base.connection.table_exists?(table)
30
- end
31
-
32
- if existing_tables.empty?
33
- puts "No Rails Error Dashboard tables found in the database."
25
+ if plan.empty?
26
+ puts "No Rails Error Dashboard tables found in the '#{database}' database."
34
27
  puts "\n"
35
- exit 0
28
+ next
36
29
  end
37
30
 
38
- puts "The following tables will be PERMANENTLY DELETED:"
39
- existing_tables.each do |table|
40
- record_count = ActiveRecord::Base.connection.execute("SELECT COUNT(*) FROM #{table}").first.values.first rescue 0
41
- puts " • #{table} (#{record_count} records)"
31
+ puts "The following tables in the '#{database}' database will be PERMANENTLY DELETED:"
32
+ plan.each do |entry|
33
+ puts " • #{entry[:table]} (#{entry[:rows] || '?'} records)"
42
34
  end
43
35
  puts "\n"
36
+
37
+ if plan.any? { |entry| entry[:table] == "rails_error_dashboard_applications" }
38
+ names = connection.select_values("SELECT name FROM #{connection.quote_table_name('rails_error_dashboard_applications')} ORDER BY name")
39
+ if names.size > 1
40
+ puts "⚠️ This database holds errors for #{names.size} applications, and ALL of them will be deleted:"
41
+ names.each { |name| puts " • #{name}" }
42
+ puts "\n"
43
+ end
44
+ end
45
+
44
46
  puts "⚠️ This action CANNOT be undone!"
45
47
  puts "\n"
46
48
 
47
- # Ask for confirmation
48
49
  print "Type 'DELETE ALL DATA' to confirm: "
49
- confirmation = $stdin.gets.chomp
50
+ confirmation = $stdin.gets.to_s.chomp
50
51
 
51
52
  if confirmation != "DELETE ALL DATA"
52
53
  puts "\n"
53
54
  puts "Cancelled. No tables were dropped."
54
55
  puts "\n"
55
- exit 0
56
+ next
56
57
  end
57
58
 
58
59
  puts "\n"
59
60
  puts "Dropping tables..."
60
-
61
- # Drop tables in reverse order (respects foreign keys)
62
- dropped_count = 0
63
- existing_tables.reverse.each do |table|
64
- begin
65
- ActiveRecord::Base.connection.drop_table(table, if_exists: true)
66
- puts " ✓ Dropped #{table}"
67
- dropped_count += 1
68
- rescue => e
69
- puts " ✗ Failed to drop #{table}: #{e.message}"
70
- end
71
- end
61
+ dropped = RailsErrorDashboard::Commands::DropAllTables.call(connection: connection)
62
+ dropped.each { |table| puts " ✓ Dropped #{table}" }
72
63
 
73
64
  puts "\n"
74
65
  puts "=" * 80
75
- puts " ✅ Successfully dropped #{dropped_count} table(s)"
66
+ puts " ✅ Successfully dropped #{dropped.size} table(s)"
76
67
  puts "=" * 80
77
68
  puts "\n"
78
69
  puts "Next steps:"
79
- puts " 1. Remove gem 'rails_error_dashboard' from Gemfile"
80
- puts " 2. Run: bundle install"
81
- puts " 3. Remove initializer: config/initializers/rails_error_dashboard.rb"
82
- puts " 4. Remove route from config/routes.rb"
83
- puts " 5. Delete migration files: db/migrate/*rails_error_dashboard*.rb"
84
- puts " 6. Restart your Rails server"
70
+ puts " 1. Remove the initializer: config/initializers/rails_error_dashboard.rb"
71
+ puts " 2. Remove the route from config/routes.rb (mount RailsErrorDashboard::Engine)"
72
+ puts " 3. Delete RED's migrations: db/migrate/*rails_error_dashboard*.rb"
73
+ puts " (and db/error_dashboard_migrate/ with a separate database)"
74
+ puts " 4. Separate database: remove its entry from config/database.yml"
75
+ puts " 5. Regenerate the schema file: bin/rails db:schema:dump"
76
+ puts " (otherwise db:schema:load recreates these tables)"
77
+ puts " 6. Remove gem 'rails_error_dashboard' from the Gemfile and run: bundle install"
78
+ puts " 7. Restart your Rails server"
85
79
  puts "\n"
86
- puts "Or use the automated uninstaller:"
87
- puts " rails generate rails_error_dashboard:uninstall"
80
+ puts "Or use the automated uninstaller, before removing the gem:"
81
+ puts " bin/rails generate rails_error_dashboard:uninstall"
88
82
  puts "\n"
89
83
  end
90
84
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: rails_error_dashboard
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.14.2
4
+ version: 0.14.3
5
5
  platform: ruby
6
6
  authors:
7
7
  - Anjan Jagirdar
@@ -429,7 +429,6 @@ files:
429
429
  - lib/generators/rails_error_dashboard/install/templates/README
430
430
  - lib/generators/rails_error_dashboard/install/templates/initializer.rb
431
431
  - lib/generators/rails_error_dashboard/solid_queue/solid_queue_generator.rb
432
- - lib/generators/rails_error_dashboard/solid_queue/templates/queue.yml
433
432
  - lib/generators/rails_error_dashboard/uninstall/uninstall_generator.rb
434
433
  - lib/rails_error_dashboard.rb
435
434
  - lib/rails_error_dashboard/commands/add_error_comment.rb
@@ -442,6 +441,7 @@ files:
442
441
  - lib/rails_error_dashboard/commands/batch_unmute_errors.rb
443
442
  - lib/rails_error_dashboard/commands/calculate_cascade_probability.rb
444
443
  - lib/rails_error_dashboard/commands/create_issue.rb
444
+ - lib/rails_error_dashboard/commands/drop_all_tables.rb
445
445
  - lib/rails_error_dashboard/commands/find_or_create_application.rb
446
446
  - lib/rails_error_dashboard/commands/find_or_increment_error.rb
447
447
  - lib/rails_error_dashboard/commands/flush_rack_attack_events.rb
@@ -508,6 +508,7 @@ files:
508
508
  - lib/rails_error_dashboard/queries/similar_errors.rb
509
509
  - lib/rails_error_dashboard/queries/storm_history.rb
510
510
  - lib/rails_error_dashboard/queries/swallowed_exception_summary.rb
511
+ - lib/rails_error_dashboard/queries/uninstall_plan.rb
511
512
  - lib/rails_error_dashboard/queries/user_impact_summary.rb
512
513
  - lib/rails_error_dashboard/services/ai_agent_classifier.rb
513
514
  - lib/rails_error_dashboard/services/analytics_cache_manager.rb
@@ -563,6 +564,7 @@ files:
563
564
  - lib/rails_error_dashboard/services/severity_classifier.rb
564
565
  - lib/rails_error_dashboard/services/similarity_calculator.rb
565
566
  - lib/rails_error_dashboard/services/slack_payload_builder.rb
567
+ - lib/rails_error_dashboard/services/solid_queue_config_check.rb
566
568
  - lib/rails_error_dashboard/services/source_code_reader.rb
567
569
  - lib/rails_error_dashboard/services/statistical_classifier.rb
568
570
  - lib/rails_error_dashboard/services/storm_protection/circuit_breaker.rb
@@ -599,7 +601,7 @@ metadata:
599
601
  funding_uri: https://github.com/sponsors/AnjanJ
600
602
  post_install_message: |
601
603
  ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
602
- RED (Rails Error Dashboard) v0.14.2
604
+ RED (Rails Error Dashboard) v0.14.3
603
605
  ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
604
606
 
605
607
  First install:
@@ -1,55 +0,0 @@
1
- # Solid Queue configuration for RailsErrorDashboard
2
- #
3
- # This file configures workers for error logging and notifications.
4
- # Adjust threads and processes based on your application's needs.
5
-
6
- # Development environment - optimized for low resource usage
7
- development:
8
- workers:
9
- # Error notifications (Slack, Email, Discord, PagerDuty, Webhooks)
10
- - queues: error_notifications
11
- threads: 2
12
- processes: 1
13
- polling_interval: 1
14
-
15
- # Default queue (includes async error logging)
16
- - queues: default
17
- threads: 3
18
- processes: 1
19
- polling_interval: 1
20
-
21
- # Test environment - minimal configuration
22
- test:
23
- workers:
24
- - queues: "*"
25
- threads: 1
26
- processes: 1
27
- polling_interval: 0.1
28
-
29
- # Production environment - optimized for throughput
30
- production:
31
- workers:
32
- # Error notifications - lower concurrency (external API calls)
33
- - queues: error_notifications
34
- threads: 3
35
- processes: 1
36
- polling_interval: 0.5
37
-
38
- # Default queue - higher concurrency (database operations)
39
- - queues: default
40
- threads: 5
41
- processes: 2
42
- polling_interval: 0.5
43
-
44
- # Staging environment - similar to production but scaled down
45
- staging:
46
- workers:
47
- - queues: error_notifications
48
- threads: 2
49
- processes: 1
50
- polling_interval: 0.5
51
-
52
- - queues: default
53
- threads: 3
54
- processes: 1
55
- polling_interval: 0.5