rails_pulse 0.4.0.pre.2 → 0.4.0.pre.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.
@@ -314,6 +314,18 @@ RailsPulse.configure do |config|
314
314
  # end
315
315
  # }
316
316
 
317
+ # STANDALONE DASHBOARD (bin/rails_pulse_server, see docs/deployment-modes.md):
318
+ # that process has your models but not your app's session, Warden or Devise
319
+ # helpers, and runs on its own hostname, so the hooks above are ignored there.
320
+ # By default it uses HTTP Basic auth (RAILS_PULSE_USERNAME / RAILS_PULSE_PASSWORD).
321
+ # Set this to run something else instead; same rules as authentication_method
322
+ # (deny by rendering or redirecting, or by returning false).
323
+ # config.standalone_authentication_method = proc {
324
+ # authenticate_or_request_with_http_token do |token, _options|
325
+ # ActiveSupport::SecurityUtils.secure_compare(token, ENV["RAILS_PULSE_DASHBOARD_TOKEN"].to_s)
326
+ # end
327
+ # }
328
+
317
329
  # ====================================================================================================
318
330
  # DEPLOYMENT TRACKING
319
331
  # ====================================================================================================
@@ -338,8 +350,11 @@ RailsPulse.configure do |config|
338
350
  # -H "Content-Type: application/json" \
339
351
  # -d '{"deployment": {"revision": "abc1234", "metadata": {"environment": "production"}}}'
340
352
  #
341
- # Or use the rake task for simple shell-based deploys:
353
+ # Or use the rake tasks for shell-based deploys (no token or HTTP access needed):
342
354
  # rake rails_pulse:record_deployment[abc1234]
355
+ # rake rails_pulse:finish_deployment[abc1234]
356
+ # Metadata for the rake task comes from an environment variable, as a JSON object:
357
+ # RAILS_PULSE_DEPLOYMENT_METADATA='{"environment":"production"}' rake rails_pulse:record_deployment[abc1234]
343
358
 
344
359
  # config.deployment_api_token = ENV["RAILS_PULSE_DEPLOYMENT_TOKEN"]
345
360
 
@@ -422,6 +437,12 @@ RailsPulse.configure do |config|
422
437
  # Show a dashboard banner when summary data is stale (default: true)
423
438
  # config.warn_on_stale_summaries = true
424
439
 
440
+ # When this gem version is newer than the Rails Pulse tables it is connected
441
+ # to (deployed before `db:migrate`, or a rolling restart), pause tracking after
442
+ # one logged warning and answer every dashboard page with a 503 that lists the
443
+ # upgrade commands, instead of erroring on each request (default: true).
444
+ # config.schema_check_enabled = true
445
+
425
446
  # Set to false to skip dashboard middleware and asset serving entirely.
426
447
  # Useful for standalone/API-only deployments that use a separate dashboard app.
427
448
  # config.mount_dashboard = true
@@ -170,11 +170,7 @@ module RailsPulse
170
170
  missing_columns = detect_missing_columns
171
171
 
172
172
  if missing_columns.empty?
173
- if @initializer_updated
174
- say "Schema is up to date. Review initializer changes with git diff.", :green
175
- else
176
- say "Rails Pulse is up to date! No migration needed.", :green
177
- end
173
+ report_outstanding_work(migrate_command)
178
174
  return
179
175
  end
180
176
 
@@ -203,6 +199,61 @@ module RailsPulse
203
199
  add_null_action_unique_index_to_routes
204
200
  ].freeze
205
201
 
202
+ # "Rails Pulse is up to date! No migration needed." used to be printed
203
+ # whenever every migration *file* was already in the app — including the
204
+ # common case where they were copied in one deploy and have not yet been
205
+ # run against this database, or where the schema migrated but
206
+ # `rails_pulse:migrate_routes` is still outstanding. A host in that state
207
+ # is one deploy away from the 0.4 gem running against 0.3 tables. Say
208
+ # what is actually left instead.
209
+ def report_outstanding_work(migrate_command)
210
+ pending = pending_migration_files
211
+ backfill = route_backfill_outstanding?
212
+
213
+ if pending.empty? && !backfill
214
+ if @initializer_updated
215
+ say "Schema is up to date. Review initializer changes with git diff.", :green
216
+ else
217
+ say "Rails Pulse is up to date! No migration needed.", :green
218
+ end
219
+ return
220
+ end
221
+
222
+ say "All of this version's migration files are present, but this database is not up to date:", :yellow
223
+ if pending.any?
224
+ say " #{pending.size} migration file(s) have not been run:", :yellow
225
+ pending.each { |filename| say " - #{filename}", :yellow }
226
+ end
227
+ say " Route actions have not been backfilled.", :yellow if backfill
228
+
229
+ include_route_backfill = backfill || requires_route_backfill?(pending)
230
+ say_route_backfill_warning if include_route_backfill
231
+ say_next_steps(migrate_command, include_route_backfill: include_route_backfill)
232
+ end
233
+
234
+ # Migration files on disk whose version is not recorded on the connection
235
+ # this install uses. Any failure (no database, stubbed connection) reads
236
+ # as "nothing pending" so the generator never blocks on it.
237
+ def pending_migration_files
238
+ pool = if @is_separate_db && defined?(RailsPulse::ApplicationRecord)
239
+ RailsPulse::ApplicationRecord.connection_pool
240
+ else
241
+ ActiveRecord::Base.connection_pool
242
+ end
243
+ context = pool.migration_context
244
+ applied = context.get_all_versions
245
+ context.migrations.reject { |migration| applied.include?(migration.version) }
246
+ .map { |migration| File.basename(migration.filename) }
247
+ rescue StandardError
248
+ []
249
+ end
250
+
251
+ def route_backfill_outstanding?
252
+ defined?(RailsPulse::Route) && RailsPulse::Route.needs_action_backfill?
253
+ rescue StandardError
254
+ false
255
+ end
256
+
206
257
  def requires_route_backfill?(migrations)
207
258
  migrations.any? do |name|
208
259
  ROUTE_BACKFILL_MIGRATIONS.any? { |fragment| name.include?(fragment) }
@@ -21,6 +21,7 @@ module RailsPulse
21
21
  :authentication_enabled,
22
22
  :authentication_method,
23
23
  :authorize,
24
+ :standalone_authentication_method,
24
25
  :authentication_redirect_path,
25
26
  :tags,
26
27
  :job_tracking_mode,
@@ -32,6 +33,7 @@ module RailsPulse
32
33
  :service_level_objectives,
33
34
  :query_service_level_objectives,
34
35
  :warn_on_stale_summaries,
36
+ :schema_check_enabled,
35
37
  :baseline_window,
36
38
  :comparison_window,
37
39
  :hourly_summary_retention
@@ -104,6 +106,10 @@ module RailsPulse
104
106
  # Fail-closed predicate: ->(controller) { true to allow }. Anything
105
107
  # falsy is a 403. Runs after authentication_method, if both are set.
106
108
  @authorize = nil
109
+ # Used instead of authentication_method / authorize by the standalone
110
+ # dashboard server, where host session helpers are unavailable. nil
111
+ # means HTTP Basic against RAILS_PULSE_USERNAME / RAILS_PULSE_PASSWORD.
112
+ @standalone_authentication_method = nil
107
113
  @authentication_redirect_path = "/"
108
114
  @tags = [ "ignored", "critical", "experimental" ]
109
115
  @job_tracking_mode = :universal
@@ -130,6 +136,10 @@ module RailsPulse
130
136
  # Show a warning banner when summaries haven't been generated recently
131
137
  @warn_on_stale_summaries = true
132
138
 
139
+ # Pause tracking and gate the dashboard when the tables are behind this
140
+ # gem version (see RailsPulse::SchemaCheck). Off only as an escape hatch.
141
+ @schema_check_enabled = true
142
+
133
143
  # Historical comparison. The baseline is the traffic-weighted metric across
134
144
  # `baseline_window` of day summaries; `comparison_window` is the recent
135
145
  # slice measured against it.
@@ -218,6 +228,7 @@ module RailsPulse
218
228
  validate_service_level_objectives_settings!
219
229
  validate_query_service_level_objectives_settings!
220
230
  validate_comparison_settings!
231
+ validate_schema_check_settings!
221
232
  end
222
233
 
223
234
  private
@@ -326,6 +337,16 @@ module RailsPulse
326
337
  if @authorize && !@authorize.respond_to?(:call)
327
338
  raise ArgumentError, "authorize must respond to #call (a proc or lambda receiving the controller and returning true to allow), got #{@authorize.class}"
328
339
  end
340
+
341
+ if @standalone_authentication_method && !@standalone_authentication_method.is_a?(Proc)
342
+ raise ArgumentError, "standalone_authentication_method must be a Proc or nil, got #{@standalone_authentication_method.class}"
343
+ end
344
+ end
345
+
346
+ def validate_schema_check_settings!
347
+ return if [ true, false ].include?(@schema_check_enabled)
348
+
349
+ raise ArgumentError, "schema_check_enabled must be true or false, got #{@schema_check_enabled.inspect}"
329
350
  end
330
351
 
331
352
  def validate_tags!
@@ -66,6 +66,7 @@ module RailsPulse
66
66
  # Task runners
67
67
  module Tasks
68
68
  autoload :CleanupTaskRunner, File.expand_path("tasks/cleanup_task_runner", __dir__)
69
+ autoload :StatusReporter, File.expand_path("tasks/status_reporter", __dir__)
69
70
  end
70
71
 
71
72
  class Engine < ::Rails::Engine
@@ -149,10 +150,12 @@ module RailsPulse
149
150
 
150
151
  private
151
152
 
153
+ # Lowercase keys: Rack 3 requires them, and Rack::Lint (which rackup
154
+ # inserts in its development environment) rejects mixed case with a 500.
152
155
  def self.asset_headers
153
156
  {
154
- "Cache-Control" => "public, max-age=31536000, immutable",
155
- "Vary" => "Accept-Encoding"
157
+ "cache-control" => "public, max-age=31536000, immutable",
158
+ "vary" => "Accept-Encoding"
156
159
  }
157
160
  end
158
161
  end
@@ -27,6 +27,11 @@ module RailsPulse
27
27
  new(destination: destination, source: source, output: output).update
28
28
  end
29
29
 
30
+ # What `update` would add, without writing anything.
31
+ def self.missing(destination:, source: nil)
32
+ new(destination: destination, source: source).missing
33
+ end
34
+
30
35
  def self.template_path
31
36
  File.expand_path("../../../../lib/generators/rails_pulse/templates/rails_pulse.rb", __FILE__)
32
37
  end
@@ -37,6 +42,18 @@ module RailsPulse
37
42
  @output = output
38
43
  end
39
44
 
45
+ # { keys: [setting keys absent from the host file], hash_keys: [names of
46
+ # entries absent from a hash the host does set] }. Empty arrays when the
47
+ # host mentions everything; both empty when the file does not exist.
48
+ def missing
49
+ return { keys: [], hash_keys: [] } unless File.exist?(destination)
50
+
51
+ host = File.read(destination)
52
+ missing_keys = template_settings.map { |setting| setting[:key] } - mentioned_keys(host)
53
+ hash_keys = missing_hash_entries(host, missing_keys)
54
+ { keys: missing_keys, hash_keys: hash_keys.map { |entry| entry[:name] } }
55
+ end
56
+
40
57
  def update
41
58
  return { status: :missing, keys: [], hash_keys: [] } unless File.exist?(destination)
42
59
 
@@ -90,7 +90,7 @@ module RailsPulse
90
90
 
91
91
  def tracking_enabled?
92
92
  config = RailsPulse.configuration
93
- config.enabled && config.track_jobs
93
+ config.enabled && config.track_jobs && RailsPulse::SchemaCheck.tracking_allowed?
94
94
  end
95
95
 
96
96
  def ignore_job?(job)
@@ -62,14 +62,15 @@ module RailsPulse
62
62
  path.to_s.sub(VERSIONED_ASSET_PATH, '\1')
63
63
  end
64
64
 
65
+ # Lowercase keys: Rack 3 requires them, and Rack::Lint rejects mixed case.
65
66
  def cache_headers
66
67
  if defined?(Rails) && Rails.env.development?
67
- { "Cache-Control" => "no-cache, no-store, must-revalidate", "Pragma" => "no-cache" }
68
+ { "cache-control" => "no-cache, no-store, must-revalidate", "pragma" => "no-cache" }
68
69
  else
69
70
  {
70
- "Cache-Control" => "public, max-age=31536000, immutable",
71
- "Vary" => "Accept-Encoding",
72
- "Expires" => (Time.now + 1.year).httpdate
71
+ "cache-control" => "public, max-age=31536000, immutable",
72
+ "vary" => "Accept-Encoding",
73
+ "expires" => (Time.now + 1.year).httpdate
73
74
  }
74
75
  end
75
76
  end
@@ -0,0 +1,136 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "generators/rails_pulse/base_methods"
4
+
5
+ module RailsPulse
6
+ # Answers one question about the live Rails Pulse tables: can this gem
7
+ # version safely read and write them?
8
+ #
9
+ # The answer is "no" exactly when new gem code is running against tables
10
+ # that predate it — a deploy that shipped the gem before `db:migrate`, or a
11
+ # rolling restart that left old and new processes side by side. Without
12
+ # this guard that state surfaces as a "Failed to persist tracking data"
13
+ # log line on every request and a 500 on every dashboard page. With it,
14
+ # tracking pauses after one warning and the dashboard explains what to run.
15
+ #
16
+ # The comparison is deliberately explicit and small: every Rails Pulse
17
+ # table must exist, and SENTINEL_COLUMNS lists the columns that incremental
18
+ # migrations have added since a table was first created (see
19
+ # db/rails_pulse_migrate). Presence only, never types. It runs at most once
20
+ # per process, and any database error is treated as "not outdated" so a
21
+ # booting app without a database (assets precompile, db:create) is never
22
+ # blocked. `config.schema_check_enabled = false` turns it off entirely.
23
+ module SchemaCheck
24
+ UPGRADE_INSTRUCTIONS = [
25
+ "rails generate rails_pulse:upgrade",
26
+ "rails db:migrate # separate Pulse database: rails db:migrate:rails_pulse",
27
+ "rails rails_pulse:migrate_routes # once, after the route identity migrations"
28
+ ].freeze
29
+
30
+ # Every table the gem expects. Kept in one place already; reused here so a
31
+ # new table is covered as soon as it is added there.
32
+ REQUIRED_TABLES = RailsPulse::Generators::BaseMethods::RAILS_PULSE_TABLES
33
+
34
+ # Columns added by incremental migrations after the table itself was
35
+ # introduced. When db/rails_pulse_migrate gains a migration that adds a
36
+ # column the models depend on, add it here too (CLAUDE.md lists the other
37
+ # places); test/lib/rails_pulse/schema_check_test.rb checks every entry
38
+ # against db/rails_pulse_schema.rb so the list cannot drift from it.
39
+ SENTINEL_COLUMNS = {
40
+ "rails_pulse_routes" => %w[controller_action http_methods],
41
+ "rails_pulse_requests" => %w[method response_size_bytes],
42
+ "rails_pulse_operations" => %w[actual_sql row_count repeated_query_group],
43
+ "rails_pulse_jobs" => %w[p95_duration p99_duration],
44
+ "rails_pulse_exception_groups" => %w[location]
45
+ }.freeze
46
+
47
+ class << self
48
+ def enabled?
49
+ RailsPulse.configuration.schema_check_enabled
50
+ end
51
+
52
+ def current?
53
+ missing.empty?
54
+ end
55
+
56
+ # { "rails_pulse_routes" => ["http_methods", "controller_action"] }
57
+ # for a table missing columns, { "rails_pulse_exception_groups" => [:table] }
58
+ # for a table that does not exist at all. Empty when everything is present
59
+ # or the check is disabled.
60
+ def missing
61
+ return {} unless enabled?
62
+
63
+ cached = @missing
64
+ return cached if cached
65
+
66
+ result = compute_missing
67
+ # Only remember a completed comparison; a database error is retried
68
+ # on the next call rather than cached as either answer.
69
+ @missing = result unless result.nil?
70
+ result || {}
71
+ end
72
+
73
+ # Tracking entry points call this instead of writing straight away. The
74
+ # first refusal logs the upgrade instructions; later ones are silent.
75
+ def tracking_allowed?
76
+ return true if current?
77
+
78
+ warn_once!
79
+ false
80
+ end
81
+
82
+ def warn_once!
83
+ return if @warned
84
+
85
+ @warned = true
86
+ RailsPulse.logger.error(warning_message)
87
+ end
88
+
89
+ def warning_message
90
+ lines = missing.map do |table, columns|
91
+ columns == [ :table ] ? " #{table} (table missing)" : " #{table}: #{columns.join(', ')}"
92
+ end
93
+ <<~MESSAGE.chomp
94
+ Rails Pulse #{RailsPulse::VERSION} is running against a database that has not been migrated for it.
95
+ Tracking is paused and the dashboard is unavailable until the schema is upgraded. Missing:
96
+ #{lines.join("\n")}
97
+ To upgrade:
98
+ #{UPGRADE_INSTRUCTIONS.map { |line| " #{line}" }.join("\n")}
99
+ (Set config.schema_check_enabled = false to disable this check.)
100
+ MESSAGE
101
+ end
102
+
103
+ # The table => [column] map the check enforces.
104
+ def expected_schema
105
+ REQUIRED_TABLES.each_with_object({}) do |table, expected|
106
+ expected[table] = SENTINEL_COLUMNS.fetch(table, [])
107
+ end
108
+ end
109
+
110
+ def reset!
111
+ @missing = nil
112
+ @warned = nil
113
+ end
114
+
115
+ private
116
+
117
+ def compute_missing
118
+ connection = RailsPulse::ApplicationRecord.connection
119
+ expected_schema.each_with_object({}) do |(table, columns), missing|
120
+ unless connection.table_exists?(table)
121
+ missing[table] = [ :table ]
122
+ next
123
+ end
124
+
125
+ absent = columns - connection.columns(table).map(&:name)
126
+ missing[table] = absent if absent.any?
127
+ end
128
+ rescue StandardError
129
+ # ActiveRecord::ActiveRecordError plus whatever the adapter raises
130
+ # (PG::Error, SQLite3::Exception, Mysql2::Error) when the database is
131
+ # not there yet. Unknown, not outdated.
132
+ nil
133
+ end
134
+ end
135
+ end
136
+ end
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsPulse
4
+ # Process-wide "am I the standalone dashboard?" flag, set by
5
+ # lib/rails_pulse_server.ru once the host environment is loaded.
6
+ #
7
+ # Two things differ when the engine is served by its own process instead of
8
+ # mounted inside the host app, and both are decided here rather than left to
9
+ # the host to work around:
10
+ #
11
+ # * URL generation. Engine path helpers ask the host's route set where the
12
+ # engine is mounted and prefix that path — `/rails_pulse/routes` — because
13
+ # that is what a link inside the host app needs. (`mount` extends the
14
+ # engine route set with a `find_script_name` that does this.) The
15
+ # standalone server serves the engine at `/`, so that prefix 404s against
16
+ # itself. `standalone!` prepends an override that answers "" while the
17
+ # flag is set, so every helper generates root-relative paths in this
18
+ # process only. Passing `script_name: ""` or setting it in
19
+ # `default_url_options` is not enough: Rails treats the empty value as
20
+ # unset and falls through to the mount prefix.
21
+ #
22
+ # * Authentication. See ApplicationController#authenticate_rails_pulse_user!
23
+ # — host session hooks cannot run here, so the dashboard falls back to
24
+ # `config.standalone_authentication_method`, then HTTP Basic.
25
+ module Standalone
26
+ module RootScriptName
27
+ def find_script_name(options)
28
+ RailsPulse.standalone? ? "" : super
29
+ end
30
+ end
31
+
32
+ def standalone?
33
+ @standalone == true
34
+ end
35
+
36
+ def standalone!
37
+ @standalone = true
38
+ routes = Engine.routes
39
+ routes.singleton_class.prepend(RootScriptName) unless routes.singleton_class.ancestors.include?(RootScriptName)
40
+ end
41
+
42
+ # Undo standalone! — for tests that load the rackup file in-process. The
43
+ # prepended override stays but defers to the mount prefix again.
44
+ def exit_standalone!
45
+ @standalone = false
46
+ end
47
+ end
48
+
49
+ extend Standalone
50
+ end
@@ -0,0 +1,217 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsPulse
4
+ module Tasks
5
+ # `rails rails_pulse:status` — where does this install stand?
6
+ #
7
+ # Answers, from the shell and without opening the dashboard, the questions
8
+ # an upgrade tends to leave hanging: is the schema current for this gem,
9
+ # are there migration files that have not been run, has the route backfill
10
+ # been done, does the initializer mention the settings this version added.
11
+ # Anything that needs action is repeated in a closing "Action needed"
12
+ # list, and `report` returns false in that case so release scripts can
13
+ # stop on it.
14
+ class StatusReporter
15
+ attr_reader :output, :config
16
+
17
+ def self.report(output: $stdout)
18
+ new(output: output).report
19
+ end
20
+
21
+ def initialize(output: $stdout)
22
+ @output = output
23
+ @config = RailsPulse.configuration
24
+ @actions = []
25
+ end
26
+
27
+ # Returns true when nothing needs attention.
28
+ def report
29
+ output.puts "Rails Pulse #{RailsPulse::VERSION}"
30
+ output.puts
31
+
32
+ print_database
33
+ print_schema
34
+ print_migrations
35
+ print_route_backfill
36
+ print_initializer
37
+ print_tracking
38
+ print_summaries
39
+ print_actions
40
+
41
+ @actions.empty?
42
+ end
43
+
44
+ private
45
+
46
+ def action(text)
47
+ @actions << text
48
+ end
49
+
50
+ # -- sections -----------------------------------------------------------
51
+
52
+ def print_database
53
+ connection = RailsPulse::ApplicationRecord.connection
54
+ name = RailsPulse::ApplicationRecord.connection_db_config.name
55
+ separate = config.connects_to.present?
56
+ output.puts "Database: #{connection.adapter_name} (#{name}#{separate ? ", separate Pulse database" : ""})"
57
+ rescue StandardError => e
58
+ output.puts "Database: unavailable (#{e.class}: #{e.message})"
59
+ action "Database connection failed; nothing below could be checked."
60
+ end
61
+
62
+ def print_schema
63
+ RailsPulse::SchemaCheck.reset!
64
+ missing = RailsPulse::SchemaCheck.missing
65
+
66
+ if missing.empty?
67
+ output.puts "Schema: up to date for #{RailsPulse::VERSION}"
68
+ return
69
+ end
70
+
71
+ output.puts "Schema: BEHIND this gem version"
72
+ missing.each do |table, columns|
73
+ output.puts(columns == [ :table ] ? " #{table}: table missing" : " #{table}: missing #{columns.join(', ')}")
74
+ end
75
+ action "Schema is behind the gem. Run: #{upgrade_commands.join(' && ')}"
76
+ end
77
+
78
+ def print_migrations
79
+ not_copied = gem_migrations_not_in_host
80
+ pending = pending_migrations
81
+
82
+ if not_copied.empty? && pending.empty?
83
+ output.puts "Migrations: none pending"
84
+ return
85
+ end
86
+
87
+ if not_copied.any?
88
+ output.puts "Migrations: #{not_copied.size} gem migration(s) not yet copied into this app:"
89
+ not_copied.each { |name| output.puts " #{name}" }
90
+ action "Copy this version's migrations: rails generate rails_pulse:upgrade"
91
+ end
92
+
93
+ if pending.any?
94
+ output.puts "Migrations: #{pending.size} migration file(s) present but not run:"
95
+ pending.each { |name| output.puts " #{name}" }
96
+ action "Run pending migrations: #{migrate_command}"
97
+ end
98
+ end
99
+
100
+ def print_route_backfill
101
+ unless RailsPulse::SchemaCheck.current?
102
+ output.puts "Routes: (skipped — schema is behind)"
103
+ return
104
+ end
105
+
106
+ backfill = RailsPulse::Route.needs_action_backfill?
107
+ index = RailsPulse::RouteIndexes.exists?(RailsPulse::ApplicationRecord.connection)
108
+
109
+ if !backfill && index
110
+ output.puts "Routes: actions backfilled, unrecognised-path index present"
111
+ return
112
+ end
113
+
114
+ output.puts "Routes: #{backfill ? 'actions NOT backfilled' : 'actions backfilled'}, " \
115
+ "unrecognised-path index #{index ? 'present' : 'MISSING'}"
116
+ action "Backfill route actions and create the unrecognised-path index: rails rails_pulse:migrate_routes"
117
+ rescue StandardError => e
118
+ output.puts "Routes: could not check (#{e.class}: #{e.message})"
119
+ end
120
+
121
+ def print_initializer
122
+ path = Rails.root.join("config", "initializers", "rails_pulse.rb")
123
+ unless path.exist?
124
+ output.puts "Initializer: config/initializers/rails_pulse.rb not found"
125
+ action "Create the initializer: rails generate rails_pulse:install"
126
+ return
127
+ end
128
+
129
+ missing = RailsPulse::Installers::ConfigUpdater.missing(destination: path.to_s)
130
+ keys = missing[:keys] + missing[:hash_keys]
131
+ if keys.empty?
132
+ output.puts "Initializer: mentions every setting this version knows"
133
+ return
134
+ end
135
+
136
+ output.puts "Initializer: #{keys.size} setting(s) from this version not mentioned: #{keys.join(', ')}"
137
+ action "Append the new settings to the initializer: rails generate rails_pulse:upgrade (review with git diff)"
138
+ end
139
+
140
+ def print_tracking
141
+ auth = if !config.authentication_enabled
142
+ "disabled"
143
+ elsif config.authentication_method || config.authorize
144
+ "custom hook"
145
+ else
146
+ "HTTP Basic (RAILS_PULSE_PASSWORD #{ENV["RAILS_PULSE_PASSWORD"].to_s.empty? ? 'NOT set' : 'set'})"
147
+ end
148
+
149
+ output.puts "Tracking: enabled=#{config.enabled} requests=#{config.enabled} jobs=#{config.track_jobs} " \
150
+ "exceptions=#{config.track_exceptions} async=#{config.async}"
151
+ output.puts "Dashboard: mount_dashboard=#{config.mount_dashboard} authentication=#{auth}"
152
+ end
153
+
154
+ def print_summaries
155
+ last = RailsPulse::Summary.maximum(:updated_at)
156
+ if last.nil?
157
+ output.puts "Summaries: none generated yet (schedule RailsPulse::SummaryJob hourly; backfill with rails rails_pulse:backfill_summaries)"
158
+ elsif last < 2.hours.ago
159
+ output.puts "Summaries: last generated #{time_ago(last)} — stale (is RailsPulse::SummaryJob scheduled?)"
160
+ else
161
+ output.puts "Summaries: last generated #{time_ago(last)}"
162
+ end
163
+ rescue StandardError => e
164
+ output.puts "Summaries: could not check (#{e.class}: #{e.message})"
165
+ end
166
+
167
+ def print_actions
168
+ output.puts
169
+ if @actions.empty?
170
+ output.puts "OK — nothing to do."
171
+ else
172
+ output.puts "Action needed:"
173
+ @actions.each { |text| output.puts " - #{text}" }
174
+ end
175
+ end
176
+
177
+ # -- helpers ------------------------------------------------------------
178
+
179
+ def gem_migrations_not_in_host
180
+ gem_dir = RailsPulse::Engine.root.join("db", "rails_pulse_migrate")
181
+ return [] unless gem_dir.directory?
182
+
183
+ host = %w[db/migrate db/rails_pulse_migrate].flat_map do |dir|
184
+ Dir.glob(Rails.root.join(dir, "*.rb").to_s).map { |f| File.basename(f) }
185
+ end
186
+ Dir.glob(gem_dir.join("*.rb").to_s).map { |f| File.basename(f) }.sort - host
187
+ end
188
+
189
+ # Migration files on disk whose version is not recorded on the Pulse
190
+ # connection. Names only; the versions are in the filenames.
191
+ def pending_migrations
192
+ context = RailsPulse::ApplicationRecord.connection_pool.migration_context
193
+ applied = context.get_all_versions
194
+ context.migrations.reject { |m| applied.include?(m.version) }.map { |m| File.basename(m.filename) }
195
+ rescue StandardError
196
+ []
197
+ end
198
+
199
+ def upgrade_commands
200
+ [ "rails generate rails_pulse:upgrade", migrate_command, "rails rails_pulse:migrate_routes" ]
201
+ end
202
+
203
+ def migrate_command
204
+ config.connects_to.present? ? "rails db:migrate:rails_pulse" : "rails db:migrate"
205
+ end
206
+
207
+ def time_ago(time)
208
+ seconds = (Time.current - time).to_i
209
+ return "#{seconds}s ago" if seconds < 60
210
+ return "#{seconds / 60}m ago" if seconds < 3600
211
+ return "#{seconds / 3600}h ago" if seconds < 86_400
212
+
213
+ "#{seconds / 86_400}d ago"
214
+ end
215
+ end
216
+ end
217
+ end
@@ -10,6 +10,7 @@ module RailsPulse
10
10
  class << self
11
11
  def track_request(data)
12
12
  return if RequestStore.store[:skip_recording_rails_pulse_activity]
13
+ return unless RailsPulse::SchemaCheck.tracking_allowed?
13
14
 
14
15
  if RailsPulse.configuration.async && !connection_shared_across_threads?
15
16
  Thread.new do