rails-profiler 0.31.2 → 0.31.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +119 -0
  3. data/app/assets/builds/profiler.js +20 -4
  4. data/app/controllers/profiler/api/console_controller.rb +1 -11
  5. data/app/controllers/profiler/api/jobs_controller.rb +1 -11
  6. data/app/controllers/profiler/api/profiles_controller.rb +20 -28
  7. data/app/controllers/profiler/api/tests_controller.rb +1 -11
  8. data/app/controllers/profiler/api/toolbar_controller.rb +2 -20
  9. data/app/controllers/profiler/application_controller.rb +55 -15
  10. data/app/controllers/profiler/profiles_controller.rb +2 -16
  11. data/lib/profiler/ajax_data.rb +40 -0
  12. data/lib/profiler/cluster/slave_proxy.rb +7 -5
  13. data/lib/profiler/collectors/base_collector.rb +8 -3
  14. data/lib/profiler/collectors/cache_collector.rb +3 -3
  15. data/lib/profiler/collectors/database_collector.rb +128 -10
  16. data/lib/profiler/collectors/env_collector.rb +12 -4
  17. data/lib/profiler/collectors/exception_collector.rb +7 -9
  18. data/lib/profiler/collectors/flamegraph_collector.rb +5 -5
  19. data/lib/profiler/collectors/function_profiler_collector.rb +28 -4
  20. data/lib/profiler/collectors/mailer_collector.rb +2 -8
  21. data/lib/profiler/collectors/routes_collector.rb +7 -50
  22. data/lib/profiler/collectors/scoped_notifications.rb +229 -0
  23. data/lib/profiler/collectors/view_collector.rb +2 -2
  24. data/lib/profiler/configuration.rb +20 -5
  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 +4 -2
  29. data/lib/profiler/mcp/resources/failing_tests.rb +1 -1
  30. data/lib/profiler/mcp/resources/recent_console.rb +2 -2
  31. data/lib/profiler/mcp/resources/recent_jobs.rb +2 -2
  32. data/lib/profiler/mcp/resources/slow_tests.rb +1 -1
  33. data/lib/profiler/mcp/server.rb +8 -2
  34. data/lib/profiler/mcp/tools/get_profile_ajax.rb +2 -0
  35. data/lib/profiler/mcp/tools/get_profile_detail.rb +8 -1
  36. data/lib/profiler/mcp/tools/get_test_profile_detail.rb +1 -1
  37. data/lib/profiler/mcp/tools/query_console_profiles.rb +1 -1
  38. data/lib/profiler/mcp/tools/query_jobs.rb +1 -1
  39. data/lib/profiler/mcp/tools/query_profiles.rb +1 -1
  40. data/lib/profiler/mcp/tools/query_test_profiles.rb +1 -1
  41. data/lib/profiler/mcp/tools/run_tests.rb +1 -1
  42. data/lib/profiler/middleware/profiler_middleware.rb +4 -1
  43. data/lib/profiler/process_snapshot.rb +157 -0
  44. data/lib/profiler/railtie.rb +3 -0
  45. data/lib/profiler/storage/file_store.rb +332 -58
  46. data/lib/profiler/storage/memory_store.rb +60 -31
  47. data/lib/profiler/storage/private_files.rb +13 -0
  48. data/lib/profiler/storage/redis_store.rb +251 -32
  49. data/lib/profiler/storage/sqlite_store.rb +57 -9
  50. data/lib/profiler/storage/summary.rb +50 -0
  51. data/lib/profiler/storage/unavailable.rb +58 -0
  52. data/lib/profiler/test_helpers/reporter.rb +1 -1
  53. data/lib/profiler/version.rb +1 -1
  54. data/lib/profiler.rb +17 -1
  55. metadata +8 -1
@@ -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],
@@ -29,13 +29,15 @@ module Profiler
29
29
 
30
30
  # Options whose default depends on the Rails environment: the railtie sets them only when
31
31
  # the application has not assigned them itself (see #default).
32
- RAILS_DEFAULTED = %i[enabled storage track_tests].freeze
32
+ RAILS_DEFAULTED = %i[enabled storage track_tests max_profiles].freeze
33
+
34
+ BACKEND_LOCK = Mutex.new
33
35
 
34
36
  attr_accessor :storage_options, :collectors,
35
- :skip_paths, :slow_query_threshold, :max_queries_warning,
37
+ :skip_paths, :slow_query_threshold, :max_queries_warning, :sql_backtrace,
36
38
  :track_memory, :allocated_objects_warning_threshold,
37
39
  :mcp_enabled, :mcp_transport, :mcp_port,
38
- :authorization_mode, :max_profiles, :extension_cors_enabled,
40
+ :authorization_mode, :extension_cors_enabled,
39
41
  :cors_allowed_origins, :api_forgery_protection, :frame_ancestors,
40
42
  :track_ajax, :ajax_skip_paths,
41
43
  :track_http, :slow_http_threshold, :http_skip_hosts, :http_backtrace_depth,
@@ -51,7 +53,7 @@ module Profiler
51
53
  :cluster_master, :cluster_secret, :cluster_require_secret,
52
54
  :cluster_allow_insecure_http
53
55
 
54
- attr_reader :authorize_block, :enabled, :track_tests
56
+ attr_reader :authorize_block, :enabled, :track_tests, :max_profiles
55
57
 
56
58
  def initialize
57
59
  @assigned = []
@@ -62,6 +64,10 @@ module Profiler
62
64
  @skip_paths = [%r{^/_profiler}, /\.well-known/, /favicon\.ico/, /manifest\.json/]
63
65
  @slow_query_threshold = 100 # milliseconds
64
66
  @max_queries_warning = 50
67
+ # Where each query comes from: :first_and_slow captures the caller the first time a
68
+ # statement runs in the request and for every slow query, :all for every query (as earlier
69
+ # versions did, about 0.1 ms each), :none never.
70
+ @sql_backtrace = :first_and_slow
65
71
  @track_memory = true
66
72
  # Not compared with anything yet. The default is the former 100 MB memory threshold read
67
73
  # as the objects it stood for.
@@ -177,6 +183,13 @@ module Profiler
177
183
  @track_tests = value
178
184
  end
179
185
 
186
+ # How many profiles a store keeps, the oldest evicted first; nil for no cap on the count (the
187
+ # memory store still keeps 100). The railtie leaves it nil in the test environment.
188
+ def max_profiles=(value)
189
+ @assigned |= [:max_profiles]
190
+ @max_profiles = value
191
+ end
192
+
180
193
  # Sets one of RAILS_DEFAULTED to value unless the application assigned it, in
181
194
  # config/application.rb for instance, which runs before the railtie's initializers.
182
195
  def default(option, value)
@@ -242,8 +255,10 @@ module Profiler
242
255
  @storage_backend = nil
243
256
  end
244
257
 
258
+ # Under a lock of its own, for a caller that asks the configuration directly; Profiler.storage
259
+ # takes it inside its own, always in that order.
245
260
  def storage_backend
246
- @storage_backend ||= build_storage_backend
261
+ @storage_backend || BACKEND_LOCK.synchronize { @storage_backend ||= build_storage_backend }
247
262
  end
248
263
 
249
264
  private
@@ -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)
@@ -5,7 +5,7 @@ module Profiler
5
5
  module Resources
6
6
  class FailingTests
7
7
  def self.call
8
- profiles = Profiler.storage.list(limit: 500)
8
+ profiles = Profiler.storage.list(limit: 500, type: "test")
9
9
  failing = profiles.select do |p|
10
10
  next false unless p.profile_type == "test"
11
11
  test_data = p.collector_data("test") || {}
@@ -5,8 +5,8 @@ module Profiler
5
5
  module Resources
6
6
  class RecentConsole
7
7
  def self.call
8
- profiles = Profiler.storage.list(limit: 200)
9
- consoles = profiles.select { |p| p.profile_type == "console" }.first(50)
8
+ profiles = Profiler.storage.list(limit: 50, type: "console")
9
+ consoles = profiles.select { |p| p.profile_type == "console" }
10
10
 
11
11
  data = consoles.map do |profile|
12
12
  console_data = profile.collector_data("console") || {}
@@ -5,8 +5,8 @@ module Profiler
5
5
  module Resources
6
6
  class RecentJobs
7
7
  def self.call
8
- profiles = Profiler.storage.list(limit: 200)
9
- jobs = profiles.select { |p| p.profile_type == "job" }.first(50)
8
+ profiles = Profiler.storage.list(limit: 50, type: "job")
9
+ jobs = profiles.select { |p| p.profile_type == "job" }
10
10
 
11
11
  data = jobs.map do |profile|
12
12
  job_data = profile.collector_data("job") || {}
@@ -5,7 +5,7 @@ module Profiler
5
5
  module Resources
6
6
  class SlowTests
7
7
  def self.call
8
- profiles = Profiler.storage.list(limit: 500)
8
+ profiles = Profiler.storage.list(limit: 500, type: "test")
9
9
  tests = profiles.select { |p| p.profile_type == "test" }
10
10
  .sort_by { |p| -p.duration }
11
11
  .first(10)
@@ -35,8 +35,12 @@ module Profiler
35
35
  handler = @resource_handlers[uri]
36
36
  next [{ uri: uri, mimeType: "application/json", text: "Resource not found: #{uri}" }] unless handler
37
37
 
38
- result = handler.call
39
- [{ uri: result[:uri], mimeType: result[:mimeType], text: result[:text] }]
38
+ begin
39
+ result = handler.call
40
+ [{ uri: result[:uri], mimeType: result[:mimeType], text: result[:text] }]
41
+ rescue Profiler::Storage::Unavailable::Error => e
42
+ [{ uri: uri, mimeType: "text/plain", text: e.message }]
43
+ end
40
44
  end
41
45
  end
42
46
 
@@ -393,6 +397,8 @@ module Profiler
393
397
  ::MCP::Tool.define(name: name, description: description, input_schema: input_schema) do |server_context: nil, **args|
394
398
  result = handler.call(args.transform_keys(&:to_s))
395
399
  result.is_a?(::MCP::Tool::Response) ? result : ::MCP::Tool::Response.new(result)
400
+ rescue Profiler::Storage::Unavailable::Error => e
401
+ ::MCP::Tool::Response.new([{ type: "text", text: e.message }], error: true)
396
402
  end
397
403
  end
398
404
 
@@ -22,6 +22,8 @@ module Profiler
22
22
  return [{ type: "text", text: "Profile not found: #{token}" }]
23
23
  end
24
24
 
25
+ # The sub-requests are saved after the page: computed from its children, as the page does.
26
+ Profiler::AjaxData.attach(profile, storage.find_by_parent(profile.token))
25
27
  ajax_data = profile.collector_data("ajax")
26
28
  unless ajax_data && ajax_data["total_requests"].to_i > 0
27
29
  return [{ type: "text", text: "No AJAX requests found in this profile" }]
@@ -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")
@@ -14,7 +14,7 @@ module Profiler
14
14
 
15
15
  storage = MCP::SlaveSupport.resolve_storage(params)
16
16
  profile = if token == "latest"
17
- profiles = storage.list(limit: 200)
17
+ profiles = storage.list(limit: 1, type: "test")
18
18
  profiles.find { |p| p.profile_type == "test" }
19
19
  else
20
20
  storage.load(token)
@@ -12,7 +12,7 @@ module Profiler
12
12
  limit = params["limit"]&.to_i || 20
13
13
  fetch_size = [limit * 5, 500].min
14
14
  storage = MCP::SlaveSupport.resolve_storage(params)
15
- profiles = storage.list(limit: fetch_size)
15
+ profiles = storage.list(limit: fetch_size, type: "console")
16
16
 
17
17
  consoles = profiles.select { |p| p.profile_type == "console" }
18
18
 
@@ -12,7 +12,7 @@ module Profiler
12
12
  limit = params["limit"]&.to_i || 20
13
13
  fetch_size = [limit * 5, 500].min
14
14
  storage = MCP::SlaveSupport.resolve_storage(params)
15
- profiles = storage.list(limit: fetch_size)
15
+ profiles = storage.list(limit: fetch_size, type: "job")
16
16
 
17
17
  jobs = profiles.select { |p| p.profile_type == "job" }
18
18
 
@@ -12,7 +12,7 @@ module Profiler
12
12
  storage = MCP::SlaveSupport.resolve_storage(params)
13
13
  limit = params["limit"]&.to_i || 20
14
14
  fetch_size = [limit * 5, 500].min
15
- profiles = storage.list(limit: fetch_size)
15
+ profiles = storage.list(limit: fetch_size, type: params["profile_type"])
16
16
 
17
17
  profiles = profiles.select { |p| p.path&.include?(params["path"]) } if params["path"]
18
18
  profiles = profiles.select { |p| p.method == params["method"]&.upcase } if params["method"]
@@ -12,7 +12,7 @@ module Profiler
12
12
  limit = params["limit"]&.to_i || 20
13
13
  fetch_size = [limit * 5, 500].min
14
14
  storage = MCP::SlaveSupport.resolve_storage(params)
15
- profiles = storage.list(limit: fetch_size)
15
+ profiles = storage.list(limit: fetch_size, type: "test")
16
16
 
17
17
  tests = profiles.select { |p| p.profile_type == "test" }
18
18