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.
Files changed (42) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +122 -0
  3. data/app/assets/builds/profiler-toolbar.js +13 -6
  4. data/app/assets/builds/profiler.js +73 -37
  5. data/app/controllers/profiler/api/profiles_controller.rb +2 -0
  6. data/app/controllers/profiler/api/toolbar_controller.rb +2 -0
  7. data/app/controllers/profiler/profiles_controller.rb +2 -0
  8. data/lib/profiler/allocation_counter.rb +21 -0
  9. data/lib/profiler/collectors/base_collector.rb +39 -3
  10. data/lib/profiler/collectors/cache_collector.rb +8 -3
  11. data/lib/profiler/collectors/database_collector.rb +133 -10
  12. data/lib/profiler/collectors/env_collector.rb +12 -4
  13. data/lib/profiler/collectors/exception_collector.rb +12 -9
  14. data/lib/profiler/collectors/flamegraph_collector.rb +10 -5
  15. data/lib/profiler/collectors/function_profiler_collector.rb +28 -4
  16. data/lib/profiler/collectors/http_collector.rb +5 -0
  17. data/lib/profiler/collectors/log_collector.rb +5 -0
  18. data/lib/profiler/collectors/mailer_collector.rb +2 -8
  19. data/lib/profiler/collectors/request_collector.rb +14 -11
  20. data/lib/profiler/collectors/routes_collector.rb +7 -50
  21. data/lib/profiler/collectors/scoped_notifications.rb +229 -0
  22. data/lib/profiler/collectors/view_collector.rb +7 -2
  23. data/lib/profiler/configuration.rb +27 -4
  24. data/lib/profiler/console_profiler.rb +2 -17
  25. data/lib/profiler/instrumentation/executor_context_propagation.rb +47 -0
  26. data/lib/profiler/instrumentation/request_context.rb +77 -0
  27. data/lib/profiler/instrumentation/thread_context_propagation.rb +11 -16
  28. data/lib/profiler/job_profiler.rb +6 -17
  29. data/lib/profiler/mcp/resources/recent_requests.rb +2 -0
  30. data/lib/profiler/mcp/tools/get_profile_detail.rb +21 -2
  31. data/lib/profiler/mcp/tools/get_test_profile_detail.rb +1 -1
  32. data/lib/profiler/middleware/capturing_body.rb +112 -0
  33. data/lib/profiler/middleware/profiler_middleware.rb +261 -53
  34. data/lib/profiler/middleware/streamed_profile.rb +135 -0
  35. data/lib/profiler/middleware/toolbar_injector.rb +3 -10
  36. data/lib/profiler/models/profile.rb +69 -5
  37. data/lib/profiler/process_snapshot.rb +157 -0
  38. data/lib/profiler/redaction.rb +10 -0
  39. data/lib/profiler/test_profiler.rb +2 -17
  40. data/lib/profiler/version.rb +1 -1
  41. data/lib/profiler.rb +6 -0
  42. 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 << ActiveSupport::Notifications.monotonic_subscribe("render_template.action_view") do |name, started, finished, unique_id, payload|
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 << ActiveSupport::Notifications.monotonic_subscribe("render_partial.action_view") do |name, started, finished, unique_id, payload|
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, :memory_warning_threshold,
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
- @memory_warning_threshold = 100 * 1024 * 1024 # 100 MB
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
- memory_before = current_memory if Profiler.configuration.track_memory
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.memory = current_memory - memory_before
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 = %i[
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
- parent_context = PROPAGATED_KEYS.filter_map do |key|
17
- val = Thread.current[key]
18
- [key, val] unless val.nil?
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
- parent_context.each { |k, v| Thread.current[k] = v }
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
- ).run(&block)
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
- memory_before = current_memory if Profiler.configuration.track_memory
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.memory = current_memory - memory_before
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
- storage.list(limit: 1).first
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 << "**Memory:** #{(profile.memory / 1024.0 / 1024.0).round(2)} MB" if profile.memory
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 = []