rails_pulse 0.4.0.pre.4 → 0.4.0
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 +64 -87
- data/README.md +83 -111
- data/app/controllers/concerns/session_filters_concern.rb +4 -3
- data/app/controllers/concerns/time_range_concern.rb +29 -19
- data/app/controllers/rails_pulse/application_controller.rb +9 -5
- data/app/controllers/rails_pulse/dashboard_controller.rb +20 -11
- data/app/controllers/rails_pulse/routes_controller.rb +46 -0
- data/app/helpers/rails_pulse/application_helper.rb +4 -0
- data/app/javascript/rails_pulse/controllers/chart_controller.js +27 -0
- data/app/models/rails_pulse/cards/base.rb +61 -25
- data/app/models/rails_pulse/dashboard/charts/response_time_percentiles.rb +38 -15
- data/app/models/rails_pulse/dashboard/charts/throughput_and_errors.rb +36 -13
- data/app/models/rails_pulse/dashboard/concerns/time_range_helper.rb +3 -0
- data/app/models/rails_pulse/dashboard/health_summary.rb +3 -1
- data/app/models/rails_pulse/dashboard/needs_attention.rb +3 -1
- data/app/models/rails_pulse/dashboard/storage_status.rb +38 -68
- data/app/models/rails_pulse/exceptions/cards/exception_rate.rb +1 -5
- data/app/models/rails_pulse/exceptions/cards/open_groups.rb +4 -4
- data/app/models/rails_pulse/exceptions/cards/total_occurrences.rb +3 -4
- data/app/models/rails_pulse/jobs/cards/failure_rate.rb +7 -20
- data/app/models/rails_pulse/jobs/cards/p95_duration.rb +5 -8
- data/app/models/rails_pulse/jobs/cards/total_runs.rb +4 -4
- data/app/models/rails_pulse/queries/cards/average_query_times.rb +6 -9
- data/app/models/rails_pulse/queries/cards/database_load.rb +7 -10
- data/app/models/rails_pulse/queries/cards/execution_rate.rb +4 -6
- data/app/models/rails_pulse/queries/cards/percentile_query_times.rb +5 -8
- data/app/models/rails_pulse/route.rb +7 -1
- data/app/models/rails_pulse/routes/cards/error_rates.rb +5 -11
- data/app/models/rails_pulse/routes/cards/percentile_response_times.rb +3 -1
- data/app/models/rails_pulse/routes/cards/request_count_totals.rb +4 -6
- data/app/models/rails_pulse/summary.rb +13 -18
- data/app/models/rails_pulse/time_range_preference.rb +28 -0
- data/app/models/rails_pulse/time_window.rb +61 -0
- data/app/services/rails_pulse/summary_service.rb +112 -162
- data/app/services/rails_pulse/tag_filter_service.rb +10 -1
- data/app/views/layouts/rails_pulse/_time_range_selector.html.erb +2 -2
- data/app/views/rails_pulse/dashboard/index.html.erb +6 -3
- data/app/views/rails_pulse/routes/_archived_summary_table.html.erb +46 -0
- data/app/views/rails_pulse/routes/_archived_table_pagination.html.erb +28 -0
- data/app/views/rails_pulse/routes/show.html.erb +14 -2
- data/lib/generators/rails_pulse/install_generator.rb +5 -2
- data/lib/generators/rails_pulse/templates/rails_pulse.rb +7 -2
- data/lib/rails_pulse/cleanup_service.rb +7 -7
- data/lib/rails_pulse/configuration.rb +9 -1
- data/lib/rails_pulse/engine.rb +14 -42
- data/lib/rails_pulse/middleware/request_collector.rb +2 -17
- data/lib/rails_pulse/standalone.rb +12 -0
- data/lib/rails_pulse/subscribers/operation_subscriber.rb +45 -3
- data/lib/rails_pulse/tracker.rb +241 -25
- data/lib/rails_pulse/version.rb +1 -1
- data/lib/rails_pulse.rb +3 -16
- data/lib/rails_pulse_server.ru +37 -23
- data/public/rails-pulse-assets/rails-pulse.js +1 -1
- metadata +7 -9
- data/app/assets/images/rails_pulse/dashboard.png +0 -0
- data/app/assets/images/rails_pulse/query-show.png +0 -0
- data/app/assets/images/rails_pulse/request-show.png +0 -0
- data/app/assets/images/rails_pulse/request.png +0 -0
- data/app/assets/images/rails_pulse/route-show.png +0 -0
- data/lib/rails_pulse/extensions/active_record.rb +0 -124
data/lib/rails_pulse/engine.rb
CHANGED
|
@@ -7,50 +7,11 @@ require "rails_pulse/subscribers/operation_subscriber"
|
|
|
7
7
|
require "rails_pulse/subscribers/exception_subscriber"
|
|
8
8
|
require "rails_pulse/job_run_collector"
|
|
9
9
|
require "rails_pulse/active_job_extensions"
|
|
10
|
-
require "rails_pulse/extensions/active_record"
|
|
11
10
|
require "request_store"
|
|
12
11
|
require "rack/static"
|
|
13
12
|
require "ransack"
|
|
14
13
|
|
|
15
14
|
module RailsPulse
|
|
16
|
-
# Manually load services to avoid Zeitwerk autoload issues
|
|
17
|
-
autoload :SqlQueryNormalizer, File.expand_path("../../app/services/rails_pulse/sql_query_normalizer", __dir__)
|
|
18
|
-
autoload :SummaryService, File.expand_path("../../app/services/rails_pulse/summary_service", __dir__)
|
|
19
|
-
autoload :QueryAnalysisService, File.expand_path("../../app/services/rails_pulse/query_analysis_service", __dir__)
|
|
20
|
-
autoload :TagFilterService, File.expand_path("../../app/services/rails_pulse/tag_filter_service", __dir__)
|
|
21
|
-
autoload :ExceptionCaptureService, File.expand_path("../../app/services/rails_pulse/exception_capture_service", __dir__)
|
|
22
|
-
autoload :ExceptionMessageSanitizer, File.expand_path("../../app/services/rails_pulse/exception_message_sanitizer", __dir__)
|
|
23
|
-
autoload :OperationSuggestions, File.expand_path("../../app/services/rails_pulse/operation_suggestions", __dir__)
|
|
24
|
-
autoload :RoutePathNormalizer, File.expand_path("../../app/services/rails_pulse/route_path_normalizer", __dir__)
|
|
25
|
-
autoload :RouteRecognizer, File.expand_path("../../app/services/rails_pulse/route_recognizer", __dir__)
|
|
26
|
-
autoload :RouteMerger, File.expand_path("../../app/services/rails_pulse/route_merger", __dir__)
|
|
27
|
-
autoload :RouteMigrator, File.expand_path("../../app/services/rails_pulse/route_migrator", __dir__)
|
|
28
|
-
autoload :RouteControllerActionBackfiller, File.expand_path("../../app/services/rails_pulse/route_controller_action_backfiller", __dir__)
|
|
29
|
-
|
|
30
|
-
# Historical comparison operations. This namespace is the read-only interface
|
|
31
|
-
# for "what changed?" — everything else in the codebase, and anything built on
|
|
32
|
-
# top of Rails Pulse later, should ask these rather than querying summaries
|
|
33
|
-
# directly.
|
|
34
|
-
module Operations
|
|
35
|
-
autoload :ChangePoint, File.expand_path("../../app/services/rails_pulse/operations/change_point", __dir__)
|
|
36
|
-
autoload :Compare, File.expand_path("../../app/services/rails_pulse/operations/compare", __dir__)
|
|
37
|
-
autoload :Comparison, File.expand_path("../../app/services/rails_pulse/operations/comparison", __dir__)
|
|
38
|
-
autoload :Metric, File.expand_path("../../app/services/rails_pulse/operations/metric", __dir__)
|
|
39
|
-
autoload :Series, File.expand_path("../../app/services/rails_pulse/operations/series", __dir__)
|
|
40
|
-
autoload :Subject, File.expand_path("../../app/services/rails_pulse/operations/subject", __dir__)
|
|
41
|
-
end
|
|
42
|
-
|
|
43
|
-
# Analysis services
|
|
44
|
-
module Analysis
|
|
45
|
-
autoload :BacktraceAnalyzer, File.expand_path("../../app/services/rails_pulse/analysis/backtrace_analyzer", __dir__)
|
|
46
|
-
autoload :BaseAnalyzer, File.expand_path("../../app/services/rails_pulse/analysis/base_analyzer", __dir__)
|
|
47
|
-
autoload :ExplainPlanAnalyzer, File.expand_path("../../app/services/rails_pulse/analysis/explain_plan_analyzer", __dir__)
|
|
48
|
-
autoload :IndexRecommendationEngine, File.expand_path("../../app/services/rails_pulse/analysis/index_recommendation_engine", __dir__)
|
|
49
|
-
autoload :NPlusOneDetector, File.expand_path("../../app/services/rails_pulse/analysis/n_plus_one_detector", __dir__)
|
|
50
|
-
autoload :QueryCharacteristicsAnalyzer, File.expand_path("../../app/services/rails_pulse/analysis/query_characteristics_analyzer", __dir__)
|
|
51
|
-
autoload :SuggestionGenerator, File.expand_path("../../app/services/rails_pulse/analysis/suggestion_generator", __dir__)
|
|
52
|
-
end
|
|
53
|
-
|
|
54
15
|
# Installer services
|
|
55
16
|
module Installers
|
|
56
17
|
autoload :MigrationInstaller, File.expand_path("installers/migration_installer", __dir__)
|
|
@@ -72,9 +33,20 @@ module RailsPulse
|
|
|
72
33
|
class Engine < ::Rails::Engine
|
|
73
34
|
isolate_namespace RailsPulse
|
|
74
35
|
|
|
75
|
-
#
|
|
76
|
-
|
|
77
|
-
|
|
36
|
+
# Zeitwerk derives constant names from file names through the host's
|
|
37
|
+
# inflections. A host that declares `inflect.acronym "SQL"` (or "CSP")
|
|
38
|
+
# would expect sql_query_normalizer.rb to define SQLQueryNormalizer and
|
|
39
|
+
# fail to eager load in production. Pin the names of the files whose
|
|
40
|
+
# basenames contain a common acronym so the gem's constants do not depend
|
|
41
|
+
# on the host's inflections.
|
|
42
|
+
ACRONYM_SAFE_INFLECTIONS = {
|
|
43
|
+
"sql_query_normalizer" => "SqlQueryNormalizer",
|
|
44
|
+
"csp_helper" => "CspHelper",
|
|
45
|
+
"csp_test_controller" => "CspTestController"
|
|
46
|
+
}.freeze
|
|
47
|
+
|
|
48
|
+
initializer "rails_pulse.inflections", before: :set_autoload_paths do
|
|
49
|
+
Rails.autoloaders.main.inflector.inflect(ACRONYM_SAFE_INFLECTIONS) if Rails.respond_to?(:autoloaders)
|
|
78
50
|
end
|
|
79
51
|
|
|
80
52
|
# Load Rake tasks
|
|
@@ -36,10 +36,9 @@ module RailsPulse
|
|
|
36
36
|
status, headers, response = @app.call(env)
|
|
37
37
|
duration = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - start_time) * 1000).round(2)
|
|
38
38
|
|
|
39
|
-
# Collect all tracking data
|
|
40
|
-
#
|
|
39
|
+
# Collect all tracking data. Operations are copied because the writer
|
|
40
|
+
# thread annotates them (N+1 detection) after this request has moved on.
|
|
41
41
|
operations = RequestStore.store[:rails_pulse_operations] || []
|
|
42
|
-
detect_n_plus_one(operations)
|
|
43
42
|
path_params = env["action_dispatch.request.path_parameters"] || {}
|
|
44
43
|
controller_action = [ path_params[:controller], path_params[:action] ].compact.join("#").presence
|
|
45
44
|
tracking_data = {
|
|
@@ -86,20 +85,6 @@ module RailsPulse
|
|
|
86
85
|
nil
|
|
87
86
|
end
|
|
88
87
|
|
|
89
|
-
def detect_n_plus_one(operations)
|
|
90
|
-
sql_ops = operations.select { |op| op[:operation_type] == "sql" }
|
|
91
|
-
return if sql_ops.size < 2
|
|
92
|
-
|
|
93
|
-
groups = sql_ops.group_by { |op| RailsPulse::SqlQueryNormalizer.normalize(op[:actual_sql].to_s) }
|
|
94
|
-
groups.each do |normalized_sql, ops|
|
|
95
|
-
next if ops.size < 2
|
|
96
|
-
ops.each do |op|
|
|
97
|
-
op[:repeated_query_group] = normalized_sql
|
|
98
|
-
op[:repetition_count] = ops.size
|
|
99
|
-
end
|
|
100
|
-
end
|
|
101
|
-
end
|
|
102
|
-
|
|
103
88
|
def should_ignore_route?(req)
|
|
104
89
|
# Get ignored routes from configuration
|
|
105
90
|
ignored_routes = RailsPulse.configuration.ignored_routes || []
|
|
@@ -43,6 +43,18 @@ module RailsPulse
|
|
|
43
43
|
# prepended override stays but defers to the mount prefix again.
|
|
44
44
|
def exit_standalone!
|
|
45
45
|
@standalone = false
|
|
46
|
+
@host_authentication_ignored_noted = false
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# ApplicationController logs a one-line notice when the standalone
|
|
50
|
+
# dashboard ignores the host's authentication_method / authorize hooks.
|
|
51
|
+
# Returns true the first time it is called in this process (log now),
|
|
52
|
+
# false afterwards. Process-wide on purpose: a per-controller flag
|
|
53
|
+
# repeats the notice for every engine controller a visitor reaches.
|
|
54
|
+
def note_host_authentication_ignored!
|
|
55
|
+
return false if @host_authentication_ignored_noted
|
|
56
|
+
|
|
57
|
+
@host_authentication_ignored_noted = true
|
|
46
58
|
end
|
|
47
59
|
end
|
|
48
60
|
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
module RailsPulse
|
|
2
2
|
module Subscribers
|
|
3
3
|
class OperationSubscriber
|
|
4
|
+
FRAME_SLICE = 40
|
|
5
|
+
|
|
4
6
|
class << self
|
|
5
7
|
def subscribe!
|
|
6
8
|
subscribe_sql_queries!
|
|
@@ -40,10 +42,30 @@ module RailsPulse
|
|
|
40
42
|
@app_path ||= Rails.root.join("app").to_s
|
|
41
43
|
end
|
|
42
44
|
|
|
45
|
+
# The first frame under the host's app/ directory, or nil.
|
|
46
|
+
#
|
|
47
|
+
# This runs for every SQL, template, cache and HTTP event, so it must
|
|
48
|
+
# not materialise the whole stack: a bare caller_locations on a
|
|
49
|
+
# typical 150-frame controller action costs ~30 µs per call, which is
|
|
50
|
+
# ~15 ms on a 500-query request before the response goes out.
|
|
51
|
+
# Thread.each_caller_location (Ruby 3.2+) yields frames lazily and
|
|
52
|
+
# stops at the first match; older Rubies walk the stack in slices.
|
|
43
53
|
def find_app_frame
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
54
|
+
if Thread.respond_to?(:each_caller_location)
|
|
55
|
+
Thread.each_caller_location do |loc|
|
|
56
|
+
path = loc.path
|
|
57
|
+
return path if path&.start_with?(app_path)
|
|
58
|
+
end
|
|
59
|
+
return nil
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
offset = 2
|
|
63
|
+
while (frames = caller_locations(offset, FRAME_SLICE)) && !frames.empty?
|
|
64
|
+
frames.each do |loc|
|
|
65
|
+
path = loc.path
|
|
66
|
+
return path if path&.start_with?(app_path)
|
|
67
|
+
end
|
|
68
|
+
offset += FRAME_SLICE
|
|
47
69
|
end
|
|
48
70
|
nil
|
|
49
71
|
end
|
|
@@ -133,6 +155,7 @@ module RailsPulse
|
|
|
133
155
|
|
|
134
156
|
def store_operation(label:, operation_type:, start:, finish:, codebase_location:)
|
|
135
157
|
return unless RailsPulse.configuration.enabled
|
|
158
|
+
return if RequestStore.store[:skip_recording_rails_pulse_activity]
|
|
136
159
|
|
|
137
160
|
request_id = RequestStore.store[:rails_pulse_request_id]
|
|
138
161
|
job_run_id = RequestStore.store[:rails_pulse_job_run_id]
|
|
@@ -157,6 +180,10 @@ module RailsPulse
|
|
|
157
180
|
ActiveSupport::Notifications.subscribe "sql.active_record" do |name, start, finish, id, payload|
|
|
158
181
|
begin
|
|
159
182
|
next if payload[:name] == "SCHEMA"
|
|
183
|
+
# Cached reads never hit the database, so there's nothing to time or
|
|
184
|
+
# analyze for N+1s — skip before the stack walk in capture_operation.
|
|
185
|
+
next if payload[:cached]
|
|
186
|
+
next if query_ignored?(payload[:sql])
|
|
160
187
|
capture_operation(name, start, finish, payload, "sql", :sql, extra: { row_count: payload[:row_count] })
|
|
161
188
|
rescue => e
|
|
162
189
|
RailsPulse.logger.error "Exception in SQL subscriber: #{e.class} - #{e.message}"
|
|
@@ -164,6 +191,21 @@ module RailsPulse
|
|
|
164
191
|
end
|
|
165
192
|
end
|
|
166
193
|
|
|
194
|
+
def query_ignored?(sql)
|
|
195
|
+
return false if sql.nil?
|
|
196
|
+
|
|
197
|
+
RailsPulse.configuration.ignored_queries.any? do |pattern|
|
|
198
|
+
case pattern
|
|
199
|
+
when String
|
|
200
|
+
pattern == sql
|
|
201
|
+
when Regexp
|
|
202
|
+
pattern.match?(sql)
|
|
203
|
+
else
|
|
204
|
+
false
|
|
205
|
+
end
|
|
206
|
+
end
|
|
207
|
+
end
|
|
208
|
+
|
|
167
209
|
def subscribe_controller_actions!
|
|
168
210
|
ActiveSupport::Notifications.subscribe "process_action.action_controller" do |name, start, finish, id, payload|
|
|
169
211
|
begin
|
data/lib/rails_pulse/tracker.rb
CHANGED
|
@@ -1,4 +1,17 @@
|
|
|
1
1
|
module RailsPulse
|
|
2
|
+
# Persists the data RequestCollector gathers for one request: the route,
|
|
3
|
+
# the request row and its operations.
|
|
4
|
+
#
|
|
5
|
+
# With `config.async` (the default) the middleware hands the payload to a
|
|
6
|
+
# single per-process Writer thread through a bounded queue and returns
|
|
7
|
+
# immediately. The writer drains the queue in batches on one connection
|
|
8
|
+
# checked out of the Rails Pulse pool, so tracking never holds more than one
|
|
9
|
+
# of the host's connections and a traffic burst never fans out into a thread
|
|
10
|
+
# per request. When the queue is full the payload is dropped and counted;
|
|
11
|
+
# losing a sample is the intended trade for never slowing the host down.
|
|
12
|
+
#
|
|
13
|
+
# With `config.async = false`, or whenever the pool's connection is pinned
|
|
14
|
+
# to the current thread (transactional tests), writes happen inline.
|
|
2
15
|
module Tracker
|
|
3
16
|
# PG::Connection::PQTRANS_INERROR (libpq enum value 3) — the connection's transaction
|
|
4
17
|
# was aborted by a DB-level error and no ROLLBACK has been issued yet. Defined as a
|
|
@@ -7,22 +20,163 @@ module RailsPulse
|
|
|
7
20
|
PG_TRANSACTION_INERROR = 3
|
|
8
21
|
private_constant :PG_TRANSACTION_INERROR
|
|
9
22
|
|
|
23
|
+
# The background writer: one thread, one bounded queue, one connection.
|
|
24
|
+
class Writer
|
|
25
|
+
BATCH_SIZE = 50
|
|
26
|
+
DROP_LOG_INTERVAL = 60 # seconds between "queue full" log lines
|
|
27
|
+
SHUTDOWN_TIMEOUT = 5 # seconds to wait for the queue to drain at exit
|
|
28
|
+
|
|
29
|
+
attr_reader :queue_size, :dropped
|
|
30
|
+
|
|
31
|
+
def initialize(queue_size:, auto_start: true)
|
|
32
|
+
@queue_size = queue_size
|
|
33
|
+
@auto_start = auto_start
|
|
34
|
+
@mutex = Mutex.new
|
|
35
|
+
@queue = SizedQueue.new(queue_size)
|
|
36
|
+
@thread = nil
|
|
37
|
+
@pid = Process.pid
|
|
38
|
+
@dropped = 0
|
|
39
|
+
@drop_window_count = 0
|
|
40
|
+
@drop_window_started = nil
|
|
41
|
+
@exit_hook_installed = false
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Never blocks. Returns false (and counts a drop) when the queue is full.
|
|
45
|
+
def enqueue(data)
|
|
46
|
+
ensure_thread!
|
|
47
|
+
@queue.push(data, true)
|
|
48
|
+
true
|
|
49
|
+
rescue ThreadError, ClosedQueueError
|
|
50
|
+
record_drop
|
|
51
|
+
false
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def size
|
|
55
|
+
@queue.size
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def running?
|
|
59
|
+
@thread&.alive? || false
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Persist everything queued so far on the calling thread. Used by
|
|
63
|
+
# shutdown when the writer thread is gone, and by tests.
|
|
64
|
+
def drain
|
|
65
|
+
batch = []
|
|
66
|
+
batch << @queue.pop(true) while !@queue.empty?
|
|
67
|
+
Tracker.perform_tracking_batch(batch) unless batch.empty?
|
|
68
|
+
batch.size
|
|
69
|
+
rescue ThreadError
|
|
70
|
+
batch.size
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# Stop accepting work, let the thread finish what is queued (up to
|
|
74
|
+
# `timeout` seconds), then persist any remainder inline. Safe to call
|
|
75
|
+
# more than once; the next enqueue starts a fresh queue and thread.
|
|
76
|
+
def shutdown(timeout: SHUTDOWN_TIMEOUT)
|
|
77
|
+
thread = nil
|
|
78
|
+
@mutex.synchronize do
|
|
79
|
+
@queue.close
|
|
80
|
+
thread = @thread
|
|
81
|
+
@thread = nil
|
|
82
|
+
end
|
|
83
|
+
thread.join(timeout) if thread&.alive?
|
|
84
|
+
thread.kill if thread&.alive?
|
|
85
|
+
drain
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
private
|
|
89
|
+
|
|
90
|
+
def ensure_thread!
|
|
91
|
+
return unless @auto_start
|
|
92
|
+
return if @thread&.alive? && @pid == Process.pid
|
|
93
|
+
|
|
94
|
+
@mutex.synchronize do
|
|
95
|
+
# A forked child inherits the parent's queue contents and a thread
|
|
96
|
+
# that no longer exists. Start clean; the parent still owns its data.
|
|
97
|
+
if @pid != Process.pid
|
|
98
|
+
@pid = Process.pid
|
|
99
|
+
@queue = SizedQueue.new(@queue_size)
|
|
100
|
+
elsif @queue.closed?
|
|
101
|
+
@queue = SizedQueue.new(@queue_size)
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
next if @thread&.alive?
|
|
105
|
+
|
|
106
|
+
@thread = Thread.new { run }
|
|
107
|
+
@thread&.name = "rails_pulse_writer"
|
|
108
|
+
install_exit_hook
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def run
|
|
113
|
+
# pop returns nil once the queue is closed and empty.
|
|
114
|
+
while (first = @queue.pop)
|
|
115
|
+
batch = [ first ]
|
|
116
|
+
batch << @queue.pop(true) while batch.size < BATCH_SIZE && !@queue.empty?
|
|
117
|
+
Tracker.perform_tracking_batch(batch)
|
|
118
|
+
end
|
|
119
|
+
rescue => e
|
|
120
|
+
# perform_tracking_batch rescues its own errors, so this is a bug in the
|
|
121
|
+
# loop itself. Report it; the next enqueue starts a fresh thread.
|
|
122
|
+
RailsPulse.logger.error("Rails Pulse writer thread stopped: #{e.class} - #{e.message}")
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def install_exit_hook
|
|
126
|
+
return if @exit_hook_installed
|
|
127
|
+
|
|
128
|
+
@exit_hook_installed = true
|
|
129
|
+
at_exit { shutdown }
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def record_drop
|
|
133
|
+
@mutex.synchronize do
|
|
134
|
+
@dropped += 1
|
|
135
|
+
@drop_window_count += 1
|
|
136
|
+
now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
137
|
+
next if @drop_window_started && now - @drop_window_started < DROP_LOG_INTERVAL
|
|
138
|
+
|
|
139
|
+
RailsPulse.logger.warn(
|
|
140
|
+
"Rails Pulse writer queue is full (#{@queue_size}); dropped #{@drop_window_count} " \
|
|
141
|
+
"request(s) since the last notice. Raise config.async_queue_size or check database latency."
|
|
142
|
+
)
|
|
143
|
+
@drop_window_started = now
|
|
144
|
+
@drop_window_count = 0
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
|
|
10
149
|
class << self
|
|
11
150
|
def track_request(data)
|
|
12
151
|
return if RequestStore.store[:skip_recording_rails_pulse_activity]
|
|
13
152
|
return unless RailsPulse::SchemaCheck.tracking_allowed?
|
|
14
153
|
|
|
15
154
|
if RailsPulse.configuration.async && !connection_shared_across_threads?
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
rescue => e
|
|
19
|
-
log_error(e)
|
|
20
|
-
end
|
|
155
|
+
writer.enqueue(data)
|
|
156
|
+
nil
|
|
21
157
|
else
|
|
22
158
|
perform_tracking(data)
|
|
23
159
|
end
|
|
24
160
|
end
|
|
25
161
|
|
|
162
|
+
# Queue depth and lifetime drop count of the background writer, for
|
|
163
|
+
# health output. Zeros before the writer has been used.
|
|
164
|
+
def stats
|
|
165
|
+
current = @writer
|
|
166
|
+
{ queue_size: current&.size || 0, dropped: current&.dropped || 0, running: current&.running? || false }
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
# Persist everything queued so far and stop the writer thread. The next
|
|
170
|
+
# tracked request starts it again.
|
|
171
|
+
def flush!
|
|
172
|
+
@writer&.shutdown
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
# Discard the writer, queue contents included. For tests.
|
|
176
|
+
def reset_writer!
|
|
177
|
+
@writer_mutex.synchronize { @writer = nil }
|
|
178
|
+
end
|
|
179
|
+
|
|
26
180
|
def healthy?
|
|
27
181
|
RailsPulse::ApplicationRecord.connection.execute("SELECT 1")
|
|
28
182
|
true
|
|
@@ -30,39 +184,98 @@ module RailsPulse
|
|
|
30
184
|
false
|
|
31
185
|
end
|
|
32
186
|
|
|
33
|
-
|
|
187
|
+
# Marks SQL operations that repeat within one request with the
|
|
188
|
+
# normalized statement they share and how many times it ran. Runs on the
|
|
189
|
+
# writer so the normalisation cost stays off the request thread.
|
|
190
|
+
def detect_n_plus_one(operations)
|
|
191
|
+
sql_ops = operations.select { |op| op[:operation_type] == "sql" }
|
|
192
|
+
return if sql_ops.size < 2
|
|
34
193
|
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
194
|
+
groups = sql_ops.group_by { |op| RailsPulse::SqlQueryNormalizer.normalize(op[:actual_sql].to_s) }
|
|
195
|
+
groups.each do |normalized_sql, ops|
|
|
196
|
+
next if ops.size < 2
|
|
197
|
+
ops.each do |op|
|
|
198
|
+
op[:repeated_query_group] = normalized_sql
|
|
199
|
+
op[:repetition_count] = ops.size
|
|
200
|
+
end
|
|
201
|
+
end
|
|
202
|
+
end
|
|
39
203
|
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
204
|
+
# One connection checkout for the whole batch. A request that fails to
|
|
205
|
+
# persist is logged and skipped; the others in the batch still land, and
|
|
206
|
+
# their operations go in with a single insert.
|
|
207
|
+
def perform_tracking_batch(batch)
|
|
208
|
+
with_writer_connection do
|
|
209
|
+
persisted = batch.filter_map do |data|
|
|
210
|
+
request = persist_request(data)
|
|
211
|
+
[ request, data[:operations] ]
|
|
212
|
+
rescue => e
|
|
213
|
+
log_error(e)
|
|
214
|
+
nil
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
rows = persisted.flat_map { |request, operations| operation_rows(request, operations) }
|
|
218
|
+
RailsPulse::Operation.persist_bulk(rows, {}) unless rows.empty?
|
|
219
|
+
end
|
|
220
|
+
rescue => e
|
|
221
|
+
log_error(e)
|
|
222
|
+
nil
|
|
223
|
+
end
|
|
53
224
|
|
|
54
|
-
|
|
55
|
-
RailsPulse::Operation.persist_bulk(ops, request_id: request.id)
|
|
225
|
+
private
|
|
56
226
|
|
|
227
|
+
def writer
|
|
228
|
+
@writer_mutex.synchronize do
|
|
229
|
+
@writer ||= Writer.new(queue_size: RailsPulse.configuration.async_queue_size)
|
|
230
|
+
end
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
def perform_tracking(data)
|
|
234
|
+
with_writer_connection do
|
|
235
|
+
request = persist_request(data)
|
|
236
|
+
rows = operation_rows(request, data[:operations])
|
|
237
|
+
RailsPulse::Operation.persist_bulk(rows, {}) unless rows.empty?
|
|
57
238
|
request
|
|
58
239
|
end
|
|
59
240
|
rescue => e
|
|
60
241
|
log_error(e)
|
|
61
242
|
nil
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
def with_writer_connection
|
|
246
|
+
RailsPulse::ApplicationRecord.connection_pool.with_connection do |conn|
|
|
247
|
+
clear_aborted_transaction(conn)
|
|
248
|
+
RequestStore.store[:skip_recording_rails_pulse_activity] = true
|
|
249
|
+
yield
|
|
250
|
+
end
|
|
62
251
|
ensure
|
|
63
252
|
RequestStore.store[:skip_recording_rails_pulse_activity] = false
|
|
64
253
|
end
|
|
65
254
|
|
|
255
|
+
def persist_request(data)
|
|
256
|
+
route = RailsPulse::Route.find_or_create_for_request(data[:method], data[:path], controller_action: data[:controller_action])
|
|
257
|
+
|
|
258
|
+
RailsPulse::Request.create!(
|
|
259
|
+
route: route,
|
|
260
|
+
method: data[:method],
|
|
261
|
+
duration: data[:duration],
|
|
262
|
+
status: data[:status],
|
|
263
|
+
is_error: data[:is_error],
|
|
264
|
+
request_uuid: data[:request_uuid],
|
|
265
|
+
controller_action: data[:controller_action],
|
|
266
|
+
occurred_at: data[:occurred_at],
|
|
267
|
+
response_size_bytes: data[:response_size_bytes]
|
|
268
|
+
)
|
|
269
|
+
end
|
|
270
|
+
|
|
271
|
+
def operation_rows(request, operations)
|
|
272
|
+
operations = Array(operations)
|
|
273
|
+
return [] if operations.empty?
|
|
274
|
+
|
|
275
|
+
detect_n_plus_one(operations)
|
|
276
|
+
operations.map { |op| op.merge(request_id: request.id, job_run_id: nil) }
|
|
277
|
+
end
|
|
278
|
+
|
|
66
279
|
# Transactional tests pin one connection per pool and hand that same
|
|
67
280
|
# connection to every thread that asks for one. A background writer would
|
|
68
281
|
# then interleave its statements with the test's on a single socket, which
|
|
@@ -93,5 +306,8 @@ module RailsPulse
|
|
|
93
306
|
RailsPulse.logger.error(error.backtrace.join("\n")) if RailsPulse.logger.debug?
|
|
94
307
|
end
|
|
95
308
|
end
|
|
309
|
+
|
|
310
|
+
@writer = nil
|
|
311
|
+
@writer_mutex = Mutex.new
|
|
96
312
|
end
|
|
97
313
|
end
|
data/lib/rails_pulse/version.rb
CHANGED
data/lib/rails_pulse.rb
CHANGED
|
@@ -33,6 +33,9 @@ module RailsPulse
|
|
|
33
33
|
end
|
|
34
34
|
|
|
35
35
|
def logger
|
|
36
|
+
configured = configuration&.logger
|
|
37
|
+
return configured if configured
|
|
38
|
+
|
|
36
39
|
if defined?(Rails) && Rails.respond_to?(:logger) && Rails.logger
|
|
37
40
|
@logger ||= ActiveSupport::TaggedLogging.new(Rails.logger).tagged("RailsPulse")
|
|
38
41
|
else
|
|
@@ -40,22 +43,6 @@ module RailsPulse
|
|
|
40
43
|
end
|
|
41
44
|
end
|
|
42
45
|
|
|
43
|
-
def clear_metric_cache!
|
|
44
|
-
Rails.cache.delete_matched("rails_pulse_metric*")
|
|
45
|
-
end
|
|
46
|
-
|
|
47
|
-
def warm_metric_cache!
|
|
48
|
-
# Pre-warm cache for common metrics
|
|
49
|
-
[ :average_response_times, :percentile_response_times, :request_count_totals, :error_rate_per_route ].each do |metric|
|
|
50
|
-
begin
|
|
51
|
-
logger.info "Warming cache for metric: #{metric}"
|
|
52
|
-
# This would trigger cache generation by making the request
|
|
53
|
-
rescue => e
|
|
54
|
-
logger.error "Failed to warm cache for #{metric}: #{e.message}"
|
|
55
|
-
end
|
|
56
|
-
end
|
|
57
|
-
end
|
|
58
|
-
|
|
59
46
|
def connects_to
|
|
60
47
|
configuration&.connects_to
|
|
61
48
|
end
|
data/lib/rails_pulse_server.ru
CHANGED
|
@@ -26,10 +26,8 @@ RailsPulse.standalone!
|
|
|
26
26
|
$stdout.sync = true
|
|
27
27
|
$stderr.sync = true
|
|
28
28
|
|
|
29
|
-
# Build the Rack app with session support
|
|
30
|
-
require "rack/session/cookie"
|
|
31
29
|
require "rack/static"
|
|
32
|
-
require "
|
|
30
|
+
require "rack/method_override"
|
|
33
31
|
require_relative "rails_pulse/rack_compat"
|
|
34
32
|
require_relative "rails_pulse/middleware/asset_server"
|
|
35
33
|
|
|
@@ -62,6 +60,22 @@ class DashboardApp
|
|
|
62
60
|
end
|
|
63
61
|
end
|
|
64
62
|
|
|
63
|
+
# Rails::Engine#call is entered directly here, skipping the action_dispatch.*
|
|
64
|
+
# env defaults Rails::Application#call normally seeds before its middleware
|
|
65
|
+
# stack runs. ActionDispatch::Cookies and Session::CookieStore below need
|
|
66
|
+
# those (the encryption key generator, in particular) — without them,
|
|
67
|
+
# ActionController::RequestForgeryProtection#commit_csrf_token never fires,
|
|
68
|
+
# and every CSRF token silently fails to persist to the session.
|
|
69
|
+
class SeedRailsEnv
|
|
70
|
+
def initialize(app)
|
|
71
|
+
@app = app
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def call(env)
|
|
75
|
+
@app.call(Rails.application.env_config.merge(env))
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
|
|
65
79
|
# Dashboard assets. RouteHelper#asset_path emits one of two URL shapes: the
|
|
66
80
|
# gem-served fallback (/rails-pulse-assets/<version>/...) or, once
|
|
67
81
|
# assets:precompile has run, the digested copies it installs under the host's
|
|
@@ -79,32 +93,32 @@ use Rack::Static,
|
|
|
79
93
|
root: Rails.public_path.to_s,
|
|
80
94
|
header_rules: [ [ :all, RailsPulse::Engine.asset_headers ] ]
|
|
81
95
|
|
|
82
|
-
#
|
|
83
|
-
#
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
96
|
+
# The mounted engine gets this from the host app's default stack; here it
|
|
97
|
+
# must be added explicitly or the settings forms' PATCH routes 404.
|
|
98
|
+
use Rack::MethodOverride
|
|
99
|
+
|
|
100
|
+
# Fail fast on boot rather than on the first request. secret_key_base checks
|
|
101
|
+
# SECRET_KEY_BASE, then the host's encrypted credentials.
|
|
102
|
+
Rails.application.secret_key_base.presence || raise(
|
|
103
|
+
"No secret_key_base available for the standalone dashboard " \
|
|
104
|
+
"(set SECRET_KEY_BASE, or config/credentials)."
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
use SeedRailsEnv
|
|
108
|
+
|
|
109
|
+
# ActionDispatch::Cookies and Session::CookieStore, not the plain rack-session
|
|
110
|
+
# gem's Rack::Session::Cookie: only these know to call commit_csrf_token, so a
|
|
111
|
+
# CSRF token generated for a form actually gets persisted to the session
|
|
112
|
+
# instead of discarded at the end of the request.
|
|
113
|
+
use ActionDispatch::Cookies
|
|
98
114
|
|
|
99
115
|
# `secure` keeps the session cookie off plain HTTP in production. Set
|
|
100
116
|
# RAILS_PULSE_INSECURE_SESSION=1 only for a deliberately non-TLS deployment
|
|
101
117
|
# (a private network with no reverse proxy in front).
|
|
102
|
-
use
|
|
118
|
+
use ActionDispatch::Session::CookieStore,
|
|
103
119
|
key: "rails_pulse_session",
|
|
104
|
-
secret: secret_key,
|
|
105
120
|
same_site: :lax,
|
|
106
|
-
httponly: true,
|
|
107
121
|
secure: Rails.env.production? && ENV["RAILS_PULSE_INSECURE_SESSION"].blank?,
|
|
108
|
-
|
|
122
|
+
expire_after: 86400 # 1 day
|
|
109
123
|
|
|
110
124
|
run DashboardApp.new
|