rails-profiler 0.31.1 → 0.31.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 +4 -4
- data/CHANGELOG.md +122 -0
- data/app/assets/builds/profiler-toolbar.js +13 -6
- data/app/assets/builds/profiler.js +73 -37
- data/app/controllers/profiler/api/profiles_controller.rb +2 -0
- data/app/controllers/profiler/api/toolbar_controller.rb +2 -0
- data/app/controllers/profiler/profiles_controller.rb +2 -0
- data/lib/profiler/allocation_counter.rb +21 -0
- data/lib/profiler/collectors/base_collector.rb +39 -3
- data/lib/profiler/collectors/cache_collector.rb +8 -3
- data/lib/profiler/collectors/database_collector.rb +133 -10
- data/lib/profiler/collectors/env_collector.rb +12 -4
- data/lib/profiler/collectors/exception_collector.rb +12 -9
- data/lib/profiler/collectors/flamegraph_collector.rb +10 -5
- data/lib/profiler/collectors/function_profiler_collector.rb +28 -4
- data/lib/profiler/collectors/http_collector.rb +5 -0
- data/lib/profiler/collectors/log_collector.rb +5 -0
- data/lib/profiler/collectors/mailer_collector.rb +2 -8
- data/lib/profiler/collectors/request_collector.rb +14 -11
- data/lib/profiler/collectors/routes_collector.rb +7 -50
- data/lib/profiler/collectors/scoped_notifications.rb +229 -0
- data/lib/profiler/collectors/view_collector.rb +7 -2
- data/lib/profiler/configuration.rb +27 -4
- data/lib/profiler/console_profiler.rb +2 -17
- data/lib/profiler/instrumentation/executor_context_propagation.rb +47 -0
- data/lib/profiler/instrumentation/request_context.rb +77 -0
- data/lib/profiler/instrumentation/thread_context_propagation.rb +11 -16
- data/lib/profiler/job_profiler.rb +6 -17
- data/lib/profiler/mcp/resources/recent_requests.rb +2 -0
- data/lib/profiler/mcp/tools/get_profile_detail.rb +21 -2
- data/lib/profiler/mcp/tools/get_test_profile_detail.rb +1 -1
- data/lib/profiler/middleware/capturing_body.rb +112 -0
- data/lib/profiler/middleware/profiler_middleware.rb +261 -53
- data/lib/profiler/middleware/streamed_profile.rb +135 -0
- data/lib/profiler/middleware/toolbar_injector.rb +3 -10
- data/lib/profiler/models/profile.rb +69 -5
- data/lib/profiler/process_snapshot.rb +157 -0
- data/lib/profiler/redaction.rb +10 -0
- data/lib/profiler/test_profiler.rb +2 -17
- data/lib/profiler/version.rb +1 -1
- data/lib/profiler.rb +6 -0
- metadata +12 -5
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../current_context"
|
|
4
|
+
|
|
5
|
+
module Profiler
|
|
6
|
+
module Collectors
|
|
7
|
+
# ActiveSupport::Notifications subscriptions scoped to the request that made them.
|
|
8
|
+
#
|
|
9
|
+
# The notifications bus is process-wide: a collector subscribed on it directly receives the
|
|
10
|
+
# events of every thread, another request's SQL included. Here the process holds one
|
|
11
|
+
# subscriber per event name, whatever the number of requests being profiled, and hands each
|
|
12
|
+
# event to the handlers of the execution context that emitted it only.
|
|
13
|
+
#
|
|
14
|
+
# The context is ActiveSupport::IsolatedExecutionState, which Rails itself scopes its
|
|
15
|
+
# connections and instrumenters to: a thread, or a fiber with
|
|
16
|
+
# config.active_support.isolation_level = :fiber. ActionController::Live shares it with the
|
|
17
|
+
# thread it runs the action in, and ThreadContextPropagation hands it to the threads a request
|
|
18
|
+
# starts. A thread with no scope of its own that carries a profile's token
|
|
19
|
+
# (Profiler::CurrentContext), as a server thread iterating a streamed body does, gets the scope
|
|
20
|
+
# opened under that token. A scope lasts until its last handler is released, after the
|
|
21
|
+
# response body is closed for a streamed response, wherever that release runs; the next
|
|
22
|
+
# request of the thread then opens a new one, so a thread still holding the old scope, or the
|
|
23
|
+
# old token, receives nothing more.
|
|
24
|
+
module ScopedNotifications
|
|
25
|
+
STATE_KEY = :profiler_notification_scope
|
|
26
|
+
# Fiber storage, inherited by the fibers and threads a fiber creates: Ruby 3.2+.
|
|
27
|
+
FIBER_STORAGE = Fiber.respond_to?(:[])
|
|
28
|
+
|
|
29
|
+
# The handlers of one request, by event name.
|
|
30
|
+
class Scope
|
|
31
|
+
attr_reader :token, :owner
|
|
32
|
+
|
|
33
|
+
def initialize(token = nil)
|
|
34
|
+
@token = token
|
|
35
|
+
@owner = Thread.current
|
|
36
|
+
@mutex = Mutex.new
|
|
37
|
+
@handlers = {}
|
|
38
|
+
@size = 0
|
|
39
|
+
@closed = false
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def closed?
|
|
43
|
+
@closed
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def handlers_for(event)
|
|
47
|
+
@handlers[event]
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# False when the scope was closed meanwhile: the caller opens a new one.
|
|
51
|
+
def add(event, handler)
|
|
52
|
+
@mutex.synchronize do
|
|
53
|
+
return false if @closed
|
|
54
|
+
|
|
55
|
+
@handlers[event] = [*@handlers[event], handler].freeze
|
|
56
|
+
@size += 1
|
|
57
|
+
true
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# nil when the handler was not there (released already), :last when it was the scope's
|
|
62
|
+
# last one, :removed otherwise.
|
|
63
|
+
def remove(event, handler)
|
|
64
|
+
@mutex.synchronize do
|
|
65
|
+
list = @handlers[event]
|
|
66
|
+
return nil unless list&.any? { |h| h.equal?(handler) }
|
|
67
|
+
|
|
68
|
+
rest = list.reject { |h| h.equal?(handler) }
|
|
69
|
+
rest.empty? ? @handlers.delete(event) : @handlers[event] = rest.freeze
|
|
70
|
+
@size -= 1
|
|
71
|
+
@closed = @size.zero?
|
|
72
|
+
@closed ? :last : :removed
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
Handle = Struct.new(:scope, :event, :handler)
|
|
78
|
+
|
|
79
|
+
@mutex = Mutex.new
|
|
80
|
+
@subscribers = {} # event name => [subscriber, handler count]
|
|
81
|
+
@by_token = {}.freeze # profile token => the scope opened under it, replaced on write
|
|
82
|
+
|
|
83
|
+
class << self
|
|
84
|
+
# Calls +handler+ with (name, started, finished, id, payload), monotonic times, for each
|
|
85
|
+
# +event+ the current execution context emits until the handle is unsubscribed.
|
|
86
|
+
def subscribe(event, &handler)
|
|
87
|
+
# The process subscriber first: when the notifier fails, nothing was added to the scope.
|
|
88
|
+
retain(event)
|
|
89
|
+
scope = current_scope
|
|
90
|
+
scope = open_scope until scope.add(event, handler)
|
|
91
|
+
Handle.new(scope, event, handler)
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# Safe from any thread, and more than once.
|
|
95
|
+
def unsubscribe(handle)
|
|
96
|
+
return unless handle
|
|
97
|
+
|
|
98
|
+
removed = handle.scope.remove(handle.event, handle.handler)
|
|
99
|
+
return unless removed
|
|
100
|
+
|
|
101
|
+
release(handle.event)
|
|
102
|
+
close_scope(handle.scope) if removed == :last
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# The current context's scope, for ThreadContextPropagation to hand to a new thread: the
|
|
106
|
+
# one of its execution state, or else the one a fiber inherited from the fiber that created
|
|
107
|
+
# it (Fiber storage, Ruby 3.2+). With isolation_level = :fiber, a fiber the request creates
|
|
108
|
+
# (render stream: true renders the layout in one) has no execution state of its own.
|
|
109
|
+
def current
|
|
110
|
+
scope = state[STATE_KEY]
|
|
111
|
+
scope = fiber_scope if scope.nil? || scope.closed?
|
|
112
|
+
scope unless scope.nil? || scope.closed?
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# The scope the current context's events go to.
|
|
116
|
+
def routed_scope
|
|
117
|
+
scope = current
|
|
118
|
+
return scope if scope
|
|
119
|
+
|
|
120
|
+
token = CurrentContext.token
|
|
121
|
+
@by_token[token] if token
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def adopt(scope)
|
|
125
|
+
state[STATE_KEY] = scope
|
|
126
|
+
Fiber[STATE_KEY] = scope if FIBER_STORAGE
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
# A thread a pool creates inherits the Fiber storage of the code that created it, which
|
|
130
|
+
# may be a request's: it must not keep that request's scope.
|
|
131
|
+
def forget_inherited_scope
|
|
132
|
+
Fiber[STATE_KEY] = nil if FIBER_STORAGE && !Fiber[STATE_KEY].nil?
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
# Runs the block with a scope of its own when the current one was opened by another thread:
|
|
136
|
+
# a job a pool performs for the request that enqueued it (ActiveJob's :async adapter) is
|
|
137
|
+
# profiled apart from that request, which may still be running. A job performed inline,
|
|
138
|
+
# on the request's own thread, keeps sharing its scope, as it always did.
|
|
139
|
+
def apart_from_other_threads
|
|
140
|
+
scope = current
|
|
141
|
+
return yield if scope.nil? || scope.owner.equal?(Thread.current)
|
|
142
|
+
|
|
143
|
+
previous = adopted
|
|
144
|
+
adopt(nil)
|
|
145
|
+
begin
|
|
146
|
+
yield
|
|
147
|
+
ensure
|
|
148
|
+
restore(previous)
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
# What the current context holds, closed or not, for RequestContext to put back.
|
|
153
|
+
def adopted
|
|
154
|
+
[state[STATE_KEY], fiber_scope]
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
def restore(adopted)
|
|
158
|
+
state[STATE_KEY], inherited = adopted
|
|
159
|
+
Fiber[STATE_KEY] = inherited if FIBER_STORAGE
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
private
|
|
163
|
+
|
|
164
|
+
def state
|
|
165
|
+
defined?(ActiveSupport::IsolatedExecutionState) ? ActiveSupport::IsolatedExecutionState : Thread.current
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
def current_scope
|
|
169
|
+
current || open_scope
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
def fiber_scope
|
|
173
|
+
Fiber[STATE_KEY] if FIBER_STORAGE
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
def open_scope
|
|
177
|
+
token = CurrentContext.token
|
|
178
|
+
scope = Scope.new(token)
|
|
179
|
+
if token
|
|
180
|
+
@mutex.synchronize { @by_token = @by_token.merge(token => scope).freeze }
|
|
181
|
+
end
|
|
182
|
+
adopt(scope)
|
|
183
|
+
scope
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
def close_scope(scope)
|
|
187
|
+
state[STATE_KEY] = nil if state[STATE_KEY].equal?(scope)
|
|
188
|
+
Fiber[STATE_KEY] = nil if FIBER_STORAGE && fiber_scope.equal?(scope)
|
|
189
|
+
return unless scope.token
|
|
190
|
+
|
|
191
|
+
@mutex.synchronize do
|
|
192
|
+
@by_token = @by_token.reject { |_, s| s.equal?(scope) }.freeze if @by_token[scope.token].equal?(scope)
|
|
193
|
+
end
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
def retain(event)
|
|
197
|
+
@mutex.synchronize do
|
|
198
|
+
entry = (@subscribers[event] ||= [subscribe_process(event), 0])
|
|
199
|
+
entry[1] += 1
|
|
200
|
+
end
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
def release(event)
|
|
204
|
+
@mutex.synchronize do
|
|
205
|
+
entry = @subscribers[event]
|
|
206
|
+
return unless entry
|
|
207
|
+
|
|
208
|
+
entry[1] -= 1
|
|
209
|
+
return unless entry[1] <= 0
|
|
210
|
+
|
|
211
|
+
ActiveSupport::Notifications.unsubscribe(entry[0])
|
|
212
|
+
@subscribers.delete(event)
|
|
213
|
+
end
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
def subscribe_process(event)
|
|
217
|
+
ActiveSupport::Notifications.monotonic_subscribe(event) do |name, started, finished, id, payload|
|
|
218
|
+
routed_scope&.handlers_for(event)&.each do |handler|
|
|
219
|
+
handler.call(name, started, finished, id, payload)
|
|
220
|
+
rescue StandardError => e
|
|
221
|
+
# Never into the application's query, nor at the expense of the other collectors.
|
|
222
|
+
warn "Profiler: a collector failed on #{event}: #{e.class}: #{e.message}"
|
|
223
|
+
end
|
|
224
|
+
end
|
|
225
|
+
end
|
|
226
|
+
end
|
|
227
|
+
end
|
|
228
|
+
end
|
|
229
|
+
end
|
|
@@ -34,7 +34,7 @@ module Profiler
|
|
|
34
34
|
def subscribe
|
|
35
35
|
return unless defined?(ActiveSupport::Notifications)
|
|
36
36
|
|
|
37
|
-
@subscriptions <<
|
|
37
|
+
@subscriptions << subscribe_notification("render_template.action_view") do |name, started, finished, unique_id, payload|
|
|
38
38
|
duration = ((finished - started) * 1000).round(2)
|
|
39
39
|
@views << {
|
|
40
40
|
identifier: payload[:identifier],
|
|
@@ -43,7 +43,7 @@ module Profiler
|
|
|
43
43
|
}
|
|
44
44
|
end
|
|
45
45
|
|
|
46
|
-
@subscriptions <<
|
|
46
|
+
@subscriptions << subscribe_notification("render_partial.action_view") do |name, started, finished, unique_id, payload|
|
|
47
47
|
duration = ((finished - started) * 1000).round(2)
|
|
48
48
|
@partials << {
|
|
49
49
|
identifier: payload[:identifier],
|
|
@@ -66,6 +66,11 @@ module Profiler
|
|
|
66
66
|
store_data(data)
|
|
67
67
|
end
|
|
68
68
|
|
|
69
|
+
# Collect reads only what the collector gathered itself.
|
|
70
|
+
def collect_from_any_thread?
|
|
71
|
+
true
|
|
72
|
+
end
|
|
73
|
+
|
|
69
74
|
def unsubscribe
|
|
70
75
|
unsubscribe_notifications(@subscriptions)
|
|
71
76
|
end
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
require "pathname"
|
|
4
4
|
require_relative "local_request"
|
|
5
|
+
require_relative "allocation_counter"
|
|
5
6
|
|
|
6
7
|
module Profiler
|
|
7
8
|
# Header the profiler's own clients send on every request. A page on another origin cannot
|
|
@@ -31,8 +32,8 @@ module Profiler
|
|
|
31
32
|
RAILS_DEFAULTED = %i[enabled storage track_tests].freeze
|
|
32
33
|
|
|
33
34
|
attr_accessor :storage_options, :collectors,
|
|
34
|
-
:skip_paths, :slow_query_threshold, :max_queries_warning,
|
|
35
|
-
:track_memory, :
|
|
35
|
+
:skip_paths, :slow_query_threshold, :max_queries_warning, :sql_backtrace,
|
|
36
|
+
:track_memory, :allocated_objects_warning_threshold,
|
|
36
37
|
:mcp_enabled, :mcp_transport, :mcp_port,
|
|
37
38
|
:authorization_mode, :max_profiles, :extension_cors_enabled,
|
|
38
39
|
:cors_allowed_origins, :api_forgery_protection, :frame_ancestors,
|
|
@@ -43,7 +44,7 @@ module Profiler
|
|
|
43
44
|
:apply_env_overrides_when_disabled, :restrict_storage_permissions,
|
|
44
45
|
:test_runner_allow_undiscovered_files,
|
|
45
46
|
:track_mailers, :capture_mail_body, :sanitize_mailer_recipients, :mailer_skip_actions,
|
|
46
|
-
:compress_bodies, :compress_body_threshold,
|
|
47
|
+
:compress_bodies, :compress_body_threshold, :max_captured_body_bytes,
|
|
47
48
|
:redact_sensitive_data, :filter_parameters, :env_allowlist,
|
|
48
49
|
:name, :master_url, :self_url,
|
|
49
50
|
:cluster_heartbeat_interval, :cluster_offline_threshold,
|
|
@@ -61,8 +62,14 @@ module Profiler
|
|
|
61
62
|
@skip_paths = [%r{^/_profiler}, /\.well-known/, /favicon\.ico/, /manifest\.json/]
|
|
62
63
|
@slow_query_threshold = 100 # milliseconds
|
|
63
64
|
@max_queries_warning = 50
|
|
65
|
+
# Where each query comes from: :first_and_slow captures the caller the first time a
|
|
66
|
+
# statement runs in the request and for every slow query, :all for every query (as earlier
|
|
67
|
+
# versions did, about 0.1 ms each), :none never.
|
|
68
|
+
@sql_backtrace = :first_and_slow
|
|
64
69
|
@track_memory = true
|
|
65
|
-
|
|
70
|
+
# Not compared with anything yet. The default is the former 100 MB memory threshold read
|
|
71
|
+
# as the objects it stood for.
|
|
72
|
+
@allocated_objects_warning_threshold = 100 * 1024 * 1024 / AllocationCounter::LEGACY_BYTES_PER_OBJECT
|
|
66
73
|
@mcp_enabled = false
|
|
67
74
|
@mcp_transport = :stdio
|
|
68
75
|
@mcp_port = 3001
|
|
@@ -95,6 +102,9 @@ module Profiler
|
|
|
95
102
|
@mailer_skip_actions = []
|
|
96
103
|
@compress_bodies = true
|
|
97
104
|
@compress_body_threshold = 10 * 1024 # 10 KB
|
|
105
|
+
# The request and response bodies kept in a profile stop at this many bytes; the
|
|
106
|
+
# application still reads and sends all of them. nil keeps whole bodies.
|
|
107
|
+
@max_captured_body_bytes = 256 * 1024
|
|
98
108
|
@redact_sensitive_data = true
|
|
99
109
|
@filter_parameters = DEFAULT_FILTER_PARAMETERS.dup
|
|
100
110
|
@env_allowlist = DEFAULT_ENV_ALLOWLIST.dup
|
|
@@ -126,6 +136,19 @@ module Profiler
|
|
|
126
136
|
|
|
127
137
|
attr_reader :cluster_allowed_slave_urls
|
|
128
138
|
|
|
139
|
+
# Deprecated: the figure it was meant for was never a byte count (see AllocationCounter).
|
|
140
|
+
# Read and written as allocated_objects_warning_threshold times the bytes per object the
|
|
141
|
+
# former figure used.
|
|
142
|
+
def memory_warning_threshold
|
|
143
|
+
@allocated_objects_warning_threshold&.*(AllocationCounter::LEGACY_BYTES_PER_OBJECT)
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def memory_warning_threshold=(bytes)
|
|
147
|
+
warn "[Profiler] memory_warning_threshold is deprecated, set allocated_objects_warning_threshold " \
|
|
148
|
+
"(a number of objects) instead"
|
|
149
|
+
@allocated_objects_warning_threshold = bytes && bytes / AllocationCounter::LEGACY_BYTES_PER_OBJECT
|
|
150
|
+
end
|
|
151
|
+
|
|
129
152
|
# :any, or a list of slave URLs and anchored Regexp patterns (see Profiler::Cluster::Security).
|
|
130
153
|
# A Regexp that is not anchored with \A and \z is refused here, at boot, rather than ignored.
|
|
131
154
|
def cluster_allowed_slave_urls=(value)
|
|
@@ -48,7 +48,7 @@ module Profiler
|
|
|
48
48
|
|
|
49
49
|
exception_collector = collectors.find { |c| c.is_a?(Collectors::ExceptionCollector) }
|
|
50
50
|
|
|
51
|
-
|
|
51
|
+
allocations_before = AllocationCounter.current if Profiler.configuration.track_memory
|
|
52
52
|
|
|
53
53
|
console_status = "completed"
|
|
54
54
|
|
|
@@ -66,7 +66,7 @@ module Profiler
|
|
|
66
66
|
ensure
|
|
67
67
|
Profiler::CurrentContext.token = previous_token
|
|
68
68
|
if Profiler.configuration.track_memory
|
|
69
|
-
profile.
|
|
69
|
+
profile.allocated_objects = AllocationCounter.current - allocations_before
|
|
70
70
|
end
|
|
71
71
|
|
|
72
72
|
profile.finish(console_status == "completed" ? 200 : 500)
|
|
@@ -86,20 +86,5 @@ module Profiler
|
|
|
86
86
|
# After collect, and also when collect or the storage failed.
|
|
87
87
|
Collectors::Lifecycle.release_all(collectors)
|
|
88
88
|
end
|
|
89
|
-
|
|
90
|
-
private
|
|
91
|
-
|
|
92
|
-
def current_memory
|
|
93
|
-
return 0 unless defined?(GC.stat)
|
|
94
|
-
|
|
95
|
-
stats = GC.stat
|
|
96
|
-
if stats.key?(:total_allocated_size)
|
|
97
|
-
stats[:total_allocated_size]
|
|
98
|
-
elsif stats.key?(:total_allocated_objects)
|
|
99
|
-
stats[:total_allocated_objects] * 40
|
|
100
|
-
else
|
|
101
|
-
0
|
|
102
|
-
end
|
|
103
|
-
end
|
|
104
89
|
end
|
|
105
90
|
end
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "concurrent"
|
|
4
|
+
require_relative "request_context"
|
|
5
|
+
|
|
6
|
+
module Profiler
|
|
7
|
+
module Instrumentation
|
|
8
|
+
# A task posted to a concurrent-ruby executor while a request is profiled runs in that
|
|
9
|
+
# request's context, whichever pool thread runs it and whenever: Concurrent::Promises,
|
|
10
|
+
# Concurrent::Future, ActiveJob's :async adapter and ActionController::Live all post their
|
|
11
|
+
# work this way. The pool thread has its own context back once the task returns.
|
|
12
|
+
module ExecutorContextPropagation
|
|
13
|
+
def post(*args, &task)
|
|
14
|
+
return super if task.nil?
|
|
15
|
+
|
|
16
|
+
context = RequestContext.capture
|
|
17
|
+
return super if context.nil?
|
|
18
|
+
|
|
19
|
+
wrapper = proc do |*task_args|
|
|
20
|
+
RequestContext.with(context) { task.call(*task_args) }
|
|
21
|
+
end
|
|
22
|
+
wrapper.ruby2_keywords
|
|
23
|
+
super(*args, &wrapper)
|
|
24
|
+
end
|
|
25
|
+
ruby2_keywords :post
|
|
26
|
+
|
|
27
|
+
# The executors that define post themselves; the others inherit it from one of them.
|
|
28
|
+
EXECUTORS = %w[
|
|
29
|
+
Concurrent::RubyExecutorService
|
|
30
|
+
Concurrent::SimpleExecutorService
|
|
31
|
+
Concurrent::TimerSet
|
|
32
|
+
Concurrent::SerializedExecutionDelegator
|
|
33
|
+
].freeze
|
|
34
|
+
|
|
35
|
+
def self.install!
|
|
36
|
+
EXECUTORS.each do |name|
|
|
37
|
+
executor = Object.const_get(name)
|
|
38
|
+
executor.prepend(self) unless executor.ancestors.include?(self)
|
|
39
|
+
rescue NameError
|
|
40
|
+
next # not in this version of concurrent-ruby
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
Profiler::Instrumentation::ExecutorContextPropagation.install!
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../collectors/scoped_notifications"
|
|
4
|
+
|
|
5
|
+
module Profiler
|
|
6
|
+
module Instrumentation
|
|
7
|
+
# What a request being profiled hands to the work it starts on other threads: its
|
|
8
|
+
# notification scope and the collectors that record through thread-local slots.
|
|
9
|
+
#
|
|
10
|
+
# It travels with the work, not with a thread: a thread the application starts with
|
|
11
|
+
# Thread.new gets it for the life of its block (ThreadContextPropagation), a task posted to a
|
|
12
|
+
# concurrent-ruby executor for the time the task runs (ExecutorContextPropagation). A thread
|
|
13
|
+
# an executor creates to run tasks, a pool worker or a Puma thread, gets nothing: it outlives
|
|
14
|
+
# the request and serves others.
|
|
15
|
+
module RequestContext
|
|
16
|
+
KEYS = %i[
|
|
17
|
+
profiler_http_collector
|
|
18
|
+
profiler_flamegraph_collector
|
|
19
|
+
].freeze
|
|
20
|
+
|
|
21
|
+
# The files whose code calls Thread.new to create the threads of a pool: concurrent-ruby's
|
|
22
|
+
# executors (a worker of RubyThreadPoolExecutor, the thread of SimpleExecutorService) and
|
|
23
|
+
# Puma's thread pool.
|
|
24
|
+
POOL_FILES = %r{/concurrent/executor/[a-z_]+\.rb\z|/puma/thread_pool\.rb\z}
|
|
25
|
+
|
|
26
|
+
module_function
|
|
27
|
+
|
|
28
|
+
# The current request's context, or nil outside of one.
|
|
29
|
+
def capture
|
|
30
|
+
slots = KEYS.filter_map do |key|
|
|
31
|
+
value = Thread.current[key]
|
|
32
|
+
[key, value] unless value.nil?
|
|
33
|
+
end.to_h
|
|
34
|
+
scope = Collectors::ScopedNotifications.current
|
|
35
|
+
return nil if slots.empty? && scope.nil?
|
|
36
|
+
|
|
37
|
+
{ slots: slots, scope: scope }.freeze
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Runs the block in +context+, and gives the thread back what it had before.
|
|
41
|
+
def with(context)
|
|
42
|
+
return yield if context.nil?
|
|
43
|
+
|
|
44
|
+
previous_slots = KEYS.to_h { |key| [key, Thread.current[key]] }
|
|
45
|
+
previous_scope = Collectors::ScopedNotifications.adopted
|
|
46
|
+
KEYS.each { |key| Thread.current[key] = context[:slots][key] }
|
|
47
|
+
Collectors::ScopedNotifications.adopt(context[:scope])
|
|
48
|
+
begin
|
|
49
|
+
yield
|
|
50
|
+
ensure
|
|
51
|
+
previous_slots.each { |key, value| Thread.current[key] = value }
|
|
52
|
+
Collectors::ScopedNotifications.restore(previous_scope)
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# The block of a pool's thread, rid of the request's scope its Fiber storage inherits.
|
|
57
|
+
def without_inherited(block)
|
|
58
|
+
wrapper = proc do |*args|
|
|
59
|
+
Collectors::ScopedNotifications.forget_inherited_scope
|
|
60
|
+
block.call(*args)
|
|
61
|
+
end
|
|
62
|
+
wrapper.ruby2_keywords
|
|
63
|
+
wrapper
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Whether the thread being created is one of a pool's: then it must not inherit. Only the
|
|
67
|
+
# code that calls Thread.new counts; a thread the application starts from a task, or from a
|
|
68
|
+
# request a pool thread serves, is the request's.
|
|
69
|
+
def creating_pool_thread?
|
|
70
|
+
# 0 is this method, 1 the Thread#initialize patch, 2 Thread.new, which reports the file
|
|
71
|
+
# of the code that called it.
|
|
72
|
+
location = caller_locations(2, 1)&.first
|
|
73
|
+
location ? POOL_FILES.match?(location.path.to_s) : false
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
@@ -1,24 +1,24 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require_relative "request_context"
|
|
4
|
+
|
|
3
5
|
module Profiler
|
|
4
6
|
module Instrumentation
|
|
7
|
+
# A thread the application starts while a request is profiled works for that request: it gets
|
|
8
|
+
# the request's context (RequestContext) for the life of its block. A thread a pool creates
|
|
9
|
+
# (concurrent-ruby, Puma) does not: it outlives the request, and the tasks it runs bring
|
|
10
|
+
# their own context (ExecutorContextPropagation).
|
|
5
11
|
module ThreadContextPropagation
|
|
6
|
-
PROPAGATED_KEYS =
|
|
7
|
-
profiler_http_collector
|
|
8
|
-
profiler_flamegraph_collector
|
|
9
|
-
].freeze
|
|
12
|
+
PROPAGATED_KEYS = RequestContext::KEYS
|
|
10
13
|
|
|
11
14
|
def initialize(*args, &block)
|
|
12
15
|
# Ruby refuses a Thread with no block, and the wrapper below is a block, so
|
|
13
16
|
# without this the error would never come while a profile is being collected.
|
|
14
17
|
return super if block.nil?
|
|
15
18
|
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
end.to_h
|
|
20
|
-
|
|
21
|
-
return super if parent_context.empty?
|
|
19
|
+
context = RequestContext.capture
|
|
20
|
+
return super if context.nil?
|
|
21
|
+
return super(*args, &RequestContext.without_inherited(block)) if RequestContext.creating_pool_thread?
|
|
22
22
|
|
|
23
23
|
# Thread.new hands its arguments to the block, so the wrapper has to take
|
|
24
24
|
# them and pass them on. Ruby 3.4 depends on it in the stdlib: the Happy
|
|
@@ -30,12 +30,7 @@ module Profiler
|
|
|
30
30
|
# on this method, keeps a trailing keyword hash a keyword hash rather than
|
|
31
31
|
# flattening it into a positional argument.
|
|
32
32
|
wrapper = proc do |*block_args|
|
|
33
|
-
|
|
34
|
-
begin
|
|
35
|
-
block&.call(*block_args)
|
|
36
|
-
ensure
|
|
37
|
-
PROPAGATED_KEYS.each { |k| Thread.current[k] = nil }
|
|
38
|
-
end
|
|
33
|
+
RequestContext.with(context) { block&.call(*block_args) }
|
|
39
34
|
end
|
|
40
35
|
wrapper.ruby2_keywords
|
|
41
36
|
|
|
@@ -31,14 +31,16 @@ module Profiler
|
|
|
31
31
|
def self.profile(job_class:, job_id:, queue:, arguments:, executions:, parent_token: nil, &block)
|
|
32
32
|
return block.call unless Profiler.enabled? && Profiler.configuration.track_jobs
|
|
33
33
|
|
|
34
|
-
new(
|
|
34
|
+
profiler = new(
|
|
35
35
|
job_class: job_class,
|
|
36
36
|
job_id: job_id,
|
|
37
37
|
queue: queue,
|
|
38
38
|
arguments: arguments,
|
|
39
39
|
executions: executions,
|
|
40
40
|
parent_token: parent_token
|
|
41
|
-
)
|
|
41
|
+
)
|
|
42
|
+
# Performed by a pool for the request that enqueued it: the job's notifications are its own.
|
|
43
|
+
Collectors::ScopedNotifications.apart_from_other_threads { profiler.run(&block) }
|
|
42
44
|
end
|
|
43
45
|
|
|
44
46
|
def initialize(job_class:, job_id:, queue:, arguments:, executions:, parent_token: nil)
|
|
@@ -71,7 +73,7 @@ module Profiler
|
|
|
71
73
|
|
|
72
74
|
exception_collector = collectors.find { |c| c.is_a?(Collectors::ExceptionCollector) }
|
|
73
75
|
|
|
74
|
-
|
|
76
|
+
allocations_before = AllocationCounter.current if Profiler.configuration.track_memory
|
|
75
77
|
|
|
76
78
|
job_status = "completed"
|
|
77
79
|
error_message = nil
|
|
@@ -92,7 +94,7 @@ module Profiler
|
|
|
92
94
|
Thread.current[:profiler_current_job_class] = previous_job_class
|
|
93
95
|
Profiler::CurrentContext.token = previous_token
|
|
94
96
|
if Profiler.configuration.track_memory
|
|
95
|
-
profile.
|
|
97
|
+
profile.allocated_objects = AllocationCounter.current - allocations_before
|
|
96
98
|
end
|
|
97
99
|
|
|
98
100
|
job_collector.update_status(job_status, error_message)
|
|
@@ -131,18 +133,5 @@ module Profiler
|
|
|
131
133
|
arg.to_s
|
|
132
134
|
end
|
|
133
135
|
end
|
|
134
|
-
|
|
135
|
-
def current_memory
|
|
136
|
-
return 0 unless defined?(GC.stat)
|
|
137
|
-
|
|
138
|
-
stats = GC.stat
|
|
139
|
-
if stats.key?(:total_allocated_size)
|
|
140
|
-
stats[:total_allocated_size]
|
|
141
|
-
elsif stats.key?(:total_allocated_objects)
|
|
142
|
-
stats[:total_allocated_objects] * 40
|
|
143
|
-
else
|
|
144
|
-
0
|
|
145
|
-
end
|
|
146
|
-
end
|
|
147
136
|
end
|
|
148
137
|
end
|
|
@@ -14,6 +14,8 @@ module Profiler
|
|
|
14
14
|
method: profile.method,
|
|
15
15
|
status: profile.status,
|
|
16
16
|
duration: profile.duration&.round(2),
|
|
17
|
+
allocated_objects: profile.allocated_objects,
|
|
18
|
+
# Deprecated, kept for the readers that expect it: allocated_objects times 40, in MB.
|
|
17
19
|
memory: profile.memory ? (profile.memory / 1024.0 / 1024.0).round(2) : nil,
|
|
18
20
|
timestamp: profile.started_at&.iso8601,
|
|
19
21
|
query_count: profile.collector_data("database")&.dig("total_queries") || 0,
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require_relative "../slave_support"
|
|
4
|
+
require_relative "../../process_snapshot"
|
|
4
5
|
|
|
5
6
|
require "shellwords"
|
|
6
7
|
require "cgi"
|
|
@@ -22,7 +23,10 @@ module Profiler
|
|
|
22
23
|
|
|
23
24
|
storage = MCP::SlaveSupport.resolve_storage(params)
|
|
24
25
|
profile = if token == "latest"
|
|
25
|
-
|
|
26
|
+
# Loaded by its token: a slave adds its own routes and ENV to what it serves then,
|
|
27
|
+
# never to the profiles it lists.
|
|
28
|
+
latest = storage.list(limit: 1).first
|
|
29
|
+
latest && (storage.load(latest.token) || latest)
|
|
26
30
|
else
|
|
27
31
|
storage.load(token)
|
|
28
32
|
end
|
|
@@ -50,6 +54,9 @@ module Profiler
|
|
|
50
54
|
def self.format_profile_detail(profile, params = {})
|
|
51
55
|
requested = params["sections"]&.map(&:to_s)
|
|
52
56
|
want = ->(name) { requested.nil? || requested.include?(name) }
|
|
57
|
+
# The route table and ENV are the process's, not stored in the profile. A slave's
|
|
58
|
+
# profile comes with the slave's: this process's would be another process's.
|
|
59
|
+
Profiler::ProcessSnapshot.hydrate(profile) unless params["slave"]
|
|
53
60
|
|
|
54
61
|
lines = []
|
|
55
62
|
lines += section_overview(profile) if want.("overview")
|
|
@@ -87,7 +94,7 @@ module Profiler
|
|
|
87
94
|
lines << "**Request:** #{profile.method} #{profile.path}"
|
|
88
95
|
lines << "**Status:** #{profile.status}"
|
|
89
96
|
lines << "**Duration:** #{profile.duration.round(2)} ms"
|
|
90
|
-
lines << "**
|
|
97
|
+
lines << "**Allocated objects:** #{profile.allocated_objects}" if profile.allocated_objects
|
|
91
98
|
lines << "**Time:** #{profile.started_at}"
|
|
92
99
|
if profile.gem_version
|
|
93
100
|
if profile.gem_version != Profiler::VERSION
|
|
@@ -184,6 +191,9 @@ module Profiler
|
|
|
184
191
|
req_body = req_data["request_body"]
|
|
185
192
|
if req_body && !req_body.empty?
|
|
186
193
|
lines << "## Request Body"
|
|
194
|
+
if req_data["request_body_truncated"]
|
|
195
|
+
lines << truncation_note(req_data["request_body_size"], req_data["request_body_size_is_minimum"])
|
|
196
|
+
end
|
|
187
197
|
formatted = BodyFormatter.format_body(
|
|
188
198
|
profile.token,
|
|
189
199
|
"request_body",
|
|
@@ -209,6 +219,10 @@ module Profiler
|
|
|
209
219
|
resp_body = profile.response_body
|
|
210
220
|
if resp_body && !resp_body.empty?
|
|
211
221
|
lines << "## Response Body"
|
|
222
|
+
response_data = profile.collector_data("request") || {}
|
|
223
|
+
if response_data["response_body_truncated"]
|
|
224
|
+
lines << truncation_note(response_data["response_body_size"], response_data["response_body_size_is_minimum"])
|
|
225
|
+
end
|
|
212
226
|
formatted = BodyFormatter.format_body(
|
|
213
227
|
profile.token,
|
|
214
228
|
"response_body",
|
|
@@ -222,6 +236,11 @@ module Profiler
|
|
|
222
236
|
lines
|
|
223
237
|
end
|
|
224
238
|
|
|
239
|
+
def self.truncation_note(size, minimum)
|
|
240
|
+
"_Truncated: the body was #{"at least " if minimum}#{size || "?"} bytes, only its beginning was kept " \
|
|
241
|
+
"(max_captured_body_bytes)._"
|
|
242
|
+
end
|
|
243
|
+
|
|
225
244
|
def self.section_curl(profile)
|
|
226
245
|
req_data = profile.collector_data("request")
|
|
227
246
|
lines = []
|