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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +77 -171
- data/README.md +2 -0
- data/app/controllers/rails_pulse/application_controller.rb +60 -1
- data/app/controllers/rails_pulse/deployments_controller.rb +1 -4
- data/app/helpers/rails_pulse/breadcrumbs_helper.rb +6 -3
- data/app/models/rails_pulse/deployment.rb +6 -0
- data/app/services/rails_pulse/exception_capture_service.rb +2 -0
- data/app/services/rails_pulse/route_controller_action_backfiller.rb +0 -4
- data/app/views/rails_pulse/shared/schema_outdated.html.erb +96 -0
- data/exe/rails_pulse_server +9 -0
- data/lib/generators/rails_pulse/install_generator.rb +2 -0
- data/lib/generators/rails_pulse/templates/rails_pulse.rb +22 -1
- data/lib/generators/rails_pulse/upgrade_generator.rb +56 -5
- data/lib/rails_pulse/configuration.rb +21 -0
- data/lib/rails_pulse/engine.rb +5 -2
- data/lib/rails_pulse/installers/config_updater.rb +17 -0
- data/lib/rails_pulse/job_run_collector.rb +1 -1
- data/lib/rails_pulse/middleware/asset_server.rb +5 -4
- data/lib/rails_pulse/schema_check.rb +136 -0
- data/lib/rails_pulse/standalone.rb +50 -0
- data/lib/rails_pulse/tasks/status_reporter.rb +217 -0
- data/lib/rails_pulse/tracker.rb +1 -0
- data/lib/rails_pulse/version.rb +1 -1
- data/lib/rails_pulse.rb +2 -0
- data/lib/rails_pulse_server.ru +37 -4
- data/lib/tasks/rails_pulse.rake +48 -2
- data/lib/tasks/rails_pulse_tasks.rake +5 -0
- metadata +7 -3
|
@@ -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
|
|
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
|
-
|
|
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!
|
data/lib/rails_pulse/engine.rb
CHANGED
|
@@ -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
|
-
"
|
|
155
|
-
"
|
|
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
|
|
|
@@ -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
|
-
{ "
|
|
68
|
+
{ "cache-control" => "no-cache, no-store, must-revalidate", "pragma" => "no-cache" }
|
|
68
69
|
else
|
|
69
70
|
{
|
|
70
|
-
"
|
|
71
|
-
"
|
|
72
|
-
"
|
|
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
|
data/lib/rails_pulse/tracker.rb
CHANGED
|
@@ -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
|