rails-profiler 0.31.3 → 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 (37) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +56 -0
  3. data/app/controllers/profiler/api/console_controller.rb +1 -11
  4. data/app/controllers/profiler/api/jobs_controller.rb +1 -11
  5. data/app/controllers/profiler/api/profiles_controller.rb +18 -28
  6. data/app/controllers/profiler/api/tests_controller.rb +1 -11
  7. data/app/controllers/profiler/api/toolbar_controller.rb +0 -20
  8. data/app/controllers/profiler/application_controller.rb +55 -15
  9. data/app/controllers/profiler/profiles_controller.rb +0 -16
  10. data/lib/profiler/ajax_data.rb +40 -0
  11. data/lib/profiler/cluster/slave_proxy.rb +7 -5
  12. data/lib/profiler/configuration.rb +15 -4
  13. data/lib/profiler/mcp/resources/failing_tests.rb +1 -1
  14. data/lib/profiler/mcp/resources/recent_console.rb +2 -2
  15. data/lib/profiler/mcp/resources/recent_jobs.rb +2 -2
  16. data/lib/profiler/mcp/resources/slow_tests.rb +1 -1
  17. data/lib/profiler/mcp/server.rb +8 -2
  18. data/lib/profiler/mcp/tools/get_profile_ajax.rb +2 -0
  19. data/lib/profiler/mcp/tools/get_test_profile_detail.rb +1 -1
  20. data/lib/profiler/mcp/tools/query_console_profiles.rb +1 -1
  21. data/lib/profiler/mcp/tools/query_jobs.rb +1 -1
  22. data/lib/profiler/mcp/tools/query_profiles.rb +1 -1
  23. data/lib/profiler/mcp/tools/query_test_profiles.rb +1 -1
  24. data/lib/profiler/mcp/tools/run_tests.rb +1 -1
  25. data/lib/profiler/middleware/profiler_middleware.rb +4 -1
  26. data/lib/profiler/railtie.rb +3 -0
  27. data/lib/profiler/storage/file_store.rb +332 -58
  28. data/lib/profiler/storage/memory_store.rb +60 -31
  29. data/lib/profiler/storage/private_files.rb +13 -0
  30. data/lib/profiler/storage/redis_store.rb +251 -32
  31. data/lib/profiler/storage/sqlite_store.rb +57 -9
  32. data/lib/profiler/storage/summary.rb +50 -0
  33. data/lib/profiler/storage/unavailable.rb +58 -0
  34. data/lib/profiler/test_helpers/reporter.rb +1 -1
  35. data/lib/profiler/version.rb +1 -1
  36. data/lib/profiler.rb +12 -1
  37. metadata +4 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: '09422d8df091081147998c5e266018f8156874c3f2590b4269dfc1bab5b2efcf'
4
- data.tar.gz: 3f0ac06620cf6d1f0297528cdce39e80962b736e835d127538968a2aff8b748f
3
+ metadata.gz: 3ca8756c9efed6c4619db0cbc1226f104deb5f7287f972b137f9a8bf291a40c4
4
+ data.tar.gz: dcb61c33b3262c081493beb1ee783923b3c7b3ac8d4cd26ef9195ea4e3ff182d
5
5
  SHA512:
6
- metadata.gz: 7d74679898828ec69cea3718945c08e8c8ce4aec93d1ccb8942de28617a888ff2dfc2fbde4e4c01892f9a05a8723f713907a87b378b3be5dd11d5cb7cec7500b
7
- data.tar.gz: 918333cbc7efbb6c61217adb4c52ced40be60a62b621fb828e942878598defd92d69bd36672bf515bcd5b6f9e4ed82869268fe704f64b674c309e2f61846fe6a
6
+ metadata.gz: 2576d031d1b4d3e6e462392ed5ca00bc83c12eaed23b9ec07ea842bd3365e373d7eff1b4c53ae73e779366fc947ad70f01f15e8b1108c8fb36e85409986ebd22
7
+ data.tar.gz: 4b4d5914c82c06e58fd1d8e14157a28b2d0a9bbc00d15f574a5a5bdb0e782c0e9957042f25c38ba2d6cbc93c159b0b4d2f921667d552e397f37fed896bcc2245
data/CHANGELOG.md CHANGED
@@ -19,6 +19,62 @@ every commit of every tag interval is accounted for one way or the other.
19
19
 
20
20
  ## [Unreleased]
21
21
 
22
+ ## [0.31.4] - 2026-10-05
23
+
24
+ <!-- stamped -->
25
+
26
+ ### Fixed
27
+
28
+ - **Storage:** A save into the file store costs the same whatever the number of profiles on disk:
29
+ it listed the directory and read the size of every file on each save, about 5 ms with 500
30
+ profiles and 100 ms with 10,000, now under 1 ms. The store keeps an index next to the profiles
31
+ (`.profiles-index.jsonl`, with its lock `.profiles-index.lock`), which the processes writing to
32
+ the directory share; a missing or damaged one is rebuilt from the profile files, so the profiles
33
+ already saved are found without any migration. Temporary files left by a killed process are now
34
+ removed. The index relies on `flock`: a file store shared between hosts over NFS is not
35
+ supported, use Redis there.
36
+ - **Storage:** `config.max_profiles` (100) is honoured by every backend, the first saved profiles
37
+ evicted first (a job saved when it ends counts as new, whenever it started): only the memory
38
+ store applied it, so the file store grew to `max_size` (100 MB,
39
+ tens of thousands of profiles), SQLite without limit and Redis up to its TTL. In the test
40
+ environment, where every example saves a profile, there is no cap on the count unless the
41
+ application sets one.
42
+ - **Storage:** Finding the children of a page (its AJAX sub-requests and its jobs) reads only
43
+ them, through an index of their parent, instead of reading and parsing every stored profile
44
+ (about 260 ms with 1,500 profiles of 17 KB in the file store, now under 1 ms). Every profile page
45
+ and toolbar paid it, once for the child jobs and once more for the AJAX tab; they now read the
46
+ children once.
47
+ - **Dashboard:** The HTTP, job, console and test lists are filtered and paged by the storage, and
48
+ list summaries of the profiles (without the bodies, params, headers and nested collector data):
49
+ they loaded the 1,000 newest profiles in full on every page (about 200 ms with 1,500 profiles of
50
+ 17 KB, now under 10 ms), showed nothing past the thousandth, said there was no next page there,
51
+ and missed the profiles of one type behind 1,000 newer ones of another. The MCP tools and
52
+ resources that list one type of profile (jobs, console, tests) and the test report ask the
53
+ storage for that type too, and found nothing behind 500 newer profiles of another type.
54
+ - **AJAX:** The AJAX tab shows the sub-requests of a page whatever `config.collectors` holds: it
55
+ only appeared with `AjaxCollector` in the list, which the default list does not have. The jobs a
56
+ page enqueued are no longer counted as AJAX requests: they stay under its child jobs. The MCP
57
+ tool `get_profile_ajax` finds the sub-requests too: it read the AJAX data saved with the page,
58
+ before any sub-request.
59
+ - **Storage:** The storage is created once, under a lock: the first requests of a threaded server
60
+ could each create their own store (16 for 16 threads in the spec), and with the memory store the
61
+ profiles saved in all but one were lost. Several file, SQLite or Redis stores share their data
62
+ and lost nothing.
63
+ - **Storage:** A store that cannot be created (its index or lock replaced by a symbolic link, say)
64
+ is reported once per process instead of with a backtrace on every request: the profiles are not
65
+ saved and no toolbar is added, the dashboard and its API answer `503` with the cause in one line
66
+ (no path) instead of a `500`, the MCP tools an error with it, and the store is created again
67
+ once the cause is gone.
68
+ - **Upgrading:** the file, SQLite and Redis stores now keep 100 profiles outside the test
69
+ environment, and **the first save after the upgrade removes the profiles past the cap**, the
70
+ first saved first: of 3,000 profiles written by 0.31.1 in the file store, about 80 remain, and
71
+ that first save takes about a second, during which the other processes writing to the store
72
+ wait. To keep them, set `config.max_profiles` (or `storage_options[:max_profiles]`) **before**
73
+ upgrading: to a higher number, or to `nil` for the earlier behaviour, no cap on the count (the
74
+ file store then keeps up to `max_size`, Redis up to its TTL). The memory store keeps 100 with
75
+ `nil`, as before. A Redis store written by an earlier version is indexed once, by one process,
76
+ on first use, which also removes the profiles past the cap then (about 0.4 s for 20,000).
77
+
22
78
  ## [0.31.3] - 2026-10-05
23
79
 
24
80
  <!-- stamped -->
@@ -4,17 +4,7 @@ module Profiler
4
4
  module Api
5
5
  class ConsoleController < ApplicationController
6
6
  def index
7
- limit = (params[:limit] || 50).to_i
8
- offset = (params[:offset] || 0).to_i
9
- all = Profiler.storage.list(limit: 1000, offset: 0)
10
- console = all.select { |p| p.profile_type == "console" }
11
- page = console.drop(offset).first(limit + 1)
12
- render json: {
13
- profiles: page.first(limit).map(&:to_h),
14
- limit: limit,
15
- offset: offset,
16
- has_more: page.size > limit
17
- }
7
+ render_profile_page(type: "console")
18
8
  end
19
9
 
20
10
  def show
@@ -4,17 +4,7 @@ module Profiler
4
4
  module Api
5
5
  class JobsController < ApplicationController
6
6
  def index
7
- limit = (params[:limit] || 50).to_i
8
- offset = (params[:offset] || 0).to_i
9
- all = Profiler.storage.list(limit: 1000, offset: 0)
10
- jobs = all.select { |p| p.profile_type == "job" }
11
- page = jobs.drop(offset).first(limit + 1)
12
- render json: {
13
- profiles: page.first(limit).map(&:to_h),
14
- limit: limit,
15
- offset: offset,
16
- has_more: page.size > limit
17
- }
7
+ render_profile_page(type: "job")
18
8
  end
19
9
 
20
10
  def show
@@ -4,20 +4,16 @@ module Profiler
4
4
  module Api
5
5
  class ProfilesController < ApplicationController
6
6
  def index
7
- limit = (params[:limit] || 50).to_i
8
- offset = (params[:offset] || 0).to_i
9
- all = Profiler.storage.list(limit: 1000, offset: 0)
10
7
  # all_types is an opt-in used by the cluster proxy so it can mirror the full
11
- # storage.list contract; the dashboard relies on the default http-only filter.
12
- scope = all_types? ? all : all.select { |p| p.profile_type == "http" }
13
- scope = scope.select { |p| p.parent_token == params[:parent_token] } if params[:parent_token].present?
14
- page = scope.drop(offset).first(limit + 1)
15
- render json: {
16
- profiles: page.first(limit).map(&:to_h),
17
- limit: limit,
18
- offset: offset,
19
- has_more: page.size > limit
20
- }
8
+ # storage.list contract (every type, or the one given as type, full profiles); the
9
+ # dashboard relies on the default: http profiles, as summaries.
10
+ if params[:parent_token].present?
11
+ render_children_page
12
+ elsif all_types?
13
+ render_profile_page(type: profile_type_param, summary: false)
14
+ else
15
+ render_profile_page(type: "http")
16
+ end
21
17
  end
22
18
 
23
19
  def show
@@ -57,22 +53,16 @@ module Profiler
57
53
  %w[1 true].include?(params[:all_types].to_s)
58
54
  end
59
55
 
60
- def recalculate_ajax_data(profile)
61
- # Find AJAX collector in the configured collectors
62
- ajax_collector_class = Profiler::Collectors::AjaxCollector
63
-
64
- if Profiler.configuration.collectors.include?(ajax_collector_class)
65
- collector = ajax_collector_class.new(profile)
66
- collector.collect
56
+ def profile_type_param
57
+ params[:type].to_s.match?(/\A[a-z_]{1,32}\z/) ? params[:type].to_s : nil
58
+ end
67
59
 
68
- # Update tab metadata to reflect has_data status
69
- if profile.instance_variable_get(:@collectors_metadata)
70
- ajax_tab = profile.instance_variable_get(:@collectors_metadata).find { |tab| tab[:key] == 'ajax' }
71
- if ajax_tab
72
- ajax_tab[:has_data] = collector.has_data?
73
- end
74
- end
75
- end
60
+ # The children of a page, oldest last, read through the store's parent index.
61
+ def render_children_page
62
+ limit, offset = page_params
63
+ children = Profiler.storage.find_by_parent(params[:parent_token].to_s).reverse
64
+ children = children.select { |p| p.profile_type == "http" } unless all_types?
65
+ render_page(children.drop(offset).first(limit + 1), limit, offset)
76
66
  end
77
67
  end
78
68
  end
@@ -4,17 +4,7 @@ module Profiler
4
4
  module Api
5
5
  class TestsController < ApplicationController
6
6
  def index
7
- limit = (params[:limit] || 50).to_i
8
- offset = (params[:offset] || 0).to_i
9
- all = Profiler.storage.list(limit: 1000, offset: 0)
10
- tests = all.select { |p| p.profile_type == "test" }
11
- page = tests.drop(offset).first(limit + 1)
12
- render json: {
13
- profiles: page.first(limit).map(&:to_h),
14
- limit: limit,
15
- offset: offset,
16
- has_more: page.size > limit
17
- }
7
+ render_profile_page(type: "test")
18
8
  end
19
9
 
20
10
  def show
@@ -24,26 +24,6 @@ module Profiler
24
24
  events_cursor: events_cursor
25
25
  }
26
26
  end
27
-
28
- private
29
-
30
- def recalculate_ajax_data(profile)
31
- # Find AJAX collector in the configured collectors
32
- ajax_collector_class = Profiler::Collectors::AjaxCollector
33
-
34
- if Profiler.configuration.collectors.include?(ajax_collector_class)
35
- collector = ajax_collector_class.new(profile)
36
- collector.collect
37
-
38
- # Update tab metadata to reflect has_data status
39
- if profile.instance_variable_get(:@collectors_metadata)
40
- ajax_tab = profile.instance_variable_get(:@collectors_metadata).find { |tab| tab[:key] == 'ajax' }
41
- if ajax_tab
42
- ajax_tab[:has_data] = collector.has_data?
43
- end
44
- end
45
- end
46
- end
47
27
  end
48
28
  end
49
29
  end
@@ -6,6 +6,9 @@ module Profiler
6
6
  class ApplicationController < ActionController::Base
7
7
  layout "profiler/application"
8
8
 
9
+ # A store that cannot be created: the cause in one line, as a 503, for every page and API.
10
+ rescue_from Profiler::Storage::Unavailable::Error, with: :storage_unavailable
11
+
9
12
  before_action :check_authorization
10
13
  before_action :authorize_request
11
14
  # Declared after the access checks, so that an unauthorized request gets its 403 first.
@@ -48,6 +51,14 @@ module Profiler
48
51
  deny("Missing #{Profiler::FORGERY_PROTECTION_HEADER} header or CSRF token")
49
52
  end
50
53
 
54
+ def storage_unavailable(error)
55
+ if controller_path.start_with?("profiler/api/")
56
+ render json: { error: error.message }, status: :service_unavailable
57
+ else
58
+ render plain: error.message, status: :service_unavailable
59
+ end
60
+ end
61
+
51
62
  def deny(message)
52
63
  if controller_path.start_with?("profiler/api/")
53
64
  render json: { error: message }, status: :forbidden
@@ -56,22 +67,51 @@ module Profiler
56
67
  end
57
68
  end
58
69
 
70
+ # A page of one type of profile, filtered, ordered and paged by the store: one profile more
71
+ # than the limit tells whether there is a next page, at any offset.
72
+ def render_profile_page(type:, summary: true)
73
+ limit, offset = page_params
74
+ render_page(Profiler.storage.list(limit: limit + 1, offset: offset, type: type, summary: summary), limit, offset)
75
+ end
76
+
77
+ def render_page(profiles, limit, offset)
78
+ render json: {
79
+ profiles: profiles.first(limit).map(&:to_h),
80
+ limit: limit,
81
+ offset: offset,
82
+ has_more: profiles.size > limit
83
+ }
84
+ end
85
+
86
+ def page_params
87
+ [[(params[:limit] || 50).to_i, 0].max, [(params[:offset] || 0).to_i, 0].max]
88
+ end
89
+
90
+ # The children of a page, read once per request: the child jobs and the AJAX tab share them.
91
+ def child_profiles(profile)
92
+ @child_profiles ||= {}
93
+ @child_profiles[profile.token] ||= (@resolved_storage || Profiler.storage).find_by_parent(profile.token)
94
+ end
95
+
96
+ # The AJAX sub-requests are saved after the page: their tab is computed here, whatever the
97
+ # configured collector list (Profiler::AjaxData).
98
+ def recalculate_ajax_data(profile)
99
+ Profiler::AjaxData.attach(profile, child_profiles(profile))
100
+ end
101
+
59
102
  def build_child_jobs(profile)
60
- storage = @resolved_storage || Profiler.storage
61
- storage.find_by_parent(profile.token)
62
- .select { |p| p.profile_type == "job" }
63
- .map do |j|
64
- job_data = j.collector_data("job") || {}
65
- {
66
- token: j.token,
67
- job_class: j.path,
68
- job_id: job_data["job_id"],
69
- queue: job_data["queue"],
70
- status: job_data["status"],
71
- duration: j.duration,
72
- started_at: j.started_at&.iso8601
73
- }
74
- end
103
+ child_profiles(profile).select { |p| p.profile_type == "job" }.map do |j|
104
+ job_data = j.collector_data("job") || {}
105
+ {
106
+ token: j.token,
107
+ job_class: j.path,
108
+ job_id: job_data["job_id"],
109
+ queue: job_data["queue"],
110
+ status: job_data["status"],
111
+ duration: j.duration,
112
+ started_at: j.started_at&.iso8601
113
+ }
114
+ end
75
115
  end
76
116
 
77
117
  def build_parent_summary(profile)
@@ -144,22 +144,6 @@ module Profiler
144
144
  end
145
145
  end
146
146
 
147
- def recalculate_ajax_data(profile)
148
- ajax_collector_class = Profiler::Collectors::AjaxCollector
149
-
150
- if Profiler.configuration.collectors.include?(ajax_collector_class)
151
- collector = ajax_collector_class.new(profile, storage: @resolved_storage || Profiler.storage)
152
- collector.collect
153
-
154
- if profile.instance_variable_get(:@collectors_metadata)
155
- ajax_tab = profile.instance_variable_get(:@collectors_metadata).find { |tab| tab[:key] == 'ajax' }
156
- if ajax_tab
157
- ajax_tab[:has_data] = collector.has_data?
158
- end
159
- end
160
- end
161
- end
162
-
163
147
  def filter_profiles(profiles)
164
148
  filtered = profiles
165
149
 
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "collectors/ajax_collector"
4
+
5
+ module Profiler
6
+ # The AJAX tab of a page: its sub-requests are saved after the page itself, so the tab is
7
+ # computed when the page is shown, from its children, whatever the configured collector list.
8
+ # The children are the ones the caller already read (the same find_by_parent gives the child
9
+ # jobs): only the http ones are sub-requests, the jobs stay in child_jobs.
10
+ module AjaxData
11
+ # Hands the children already read to the AjaxCollector in place of the storage.
12
+ Children = Struct.new(:profiles) do
13
+ def find_by_parent(_parent_token)
14
+ profiles
15
+ end
16
+ end
17
+
18
+ module_function
19
+
20
+ # Adds the ajax collector data to profile, and its tab when it has none: a page without
21
+ # sub-requests and without the tab is left as it is.
22
+ def attach(profile, children)
23
+ requests = children.select { |child| child.profile_type == "http" }
24
+ # A copy: the memory store hands out the very tabs it keeps.
25
+ tabs = profile.collectors_metadata = (profile.collectors_metadata || []).map(&:dup)
26
+ tab = tabs.find { |entry| (entry[:key] || entry["key"]).to_s == "ajax" }
27
+ return if requests.empty? && tab.nil?
28
+
29
+ collector = Collectors::AjaxCollector.new(profile, storage: Children.new(requests))
30
+ collector.collect
31
+ if tab.nil?
32
+ profile.add_collector_metadata(collector)
33
+ elsif tab.key?("key")
34
+ tab["has_data"] = collector.has_data?
35
+ else
36
+ tab[:has_data] = collector.has_data?
37
+ end
38
+ end
39
+ end
40
+ end
@@ -33,11 +33,13 @@ module Profiler
33
33
 
34
34
  # Storage-compatible interface for MCP query tools
35
35
 
36
- def list(limit: 50, offset: 0, **)
37
- # all_types mirrors Profiler.storage.list, which returns every profile type;
38
- # without it the proxy would only ever see http profiles (see ProfilesController#index).
39
- data = get_json("/_profiler/api/profiles", limit: limit, offset: offset, all_types: true)
40
- Array(data["profiles"]).map { |h| profile_from_api(h) }
36
+ def list(limit: 50, offset: 0, type: nil, **)
37
+ # all_types mirrors Profiler.storage.list, which returns every profile type, or the one
38
+ # given as type; without it the proxy would only ever see http profiles (see
39
+ # ProfilesController#index). A slave of an earlier version ignores type: filtered here too.
40
+ data = get_json("/_profiler/api/profiles", limit: limit, offset: offset, all_types: true, type: type)
41
+ profiles = Array(data["profiles"]).map { |h| profile_from_api(h) }
42
+ type ? profiles.select { |p| p.profile_type == type.to_s } : profiles
41
43
  end
42
44
 
43
45
  # A token that is not one the gem issues is not found, without asking the slave.
@@ -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
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 = []
@@ -181,6 +183,13 @@ module Profiler
181
183
  @track_tests = value
182
184
  end
183
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
+
184
193
  # Sets one of RAILS_DEFAULTED to value unless the application assigned it, in
185
194
  # config/application.rb for instance, which runs before the railtie's initializers.
186
195
  def default(option, value)
@@ -246,8 +255,10 @@ module Profiler
246
255
  @storage_backend = nil
247
256
  end
248
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.
249
260
  def storage_backend
250
- @storage_backend ||= build_storage_backend
261
+ @storage_backend || BACKEND_LOCK.synchronize { @storage_backend ||= build_storage_backend }
251
262
  end
252
263
 
253
264
  private
@@ -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" }]
@@ -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
 
@@ -114,7 +114,7 @@ module Profiler
114
114
  end
115
115
 
116
116
  def self.collect_run_profiles(since)
117
- Profiler.storage.list(limit: 500).select do |p|
117
+ Profiler.storage.list(limit: 500, type: "test").select do |p|
118
118
  p.profile_type == "test" && p.started_at && p.started_at >= since
119
119
  end.map(&:token)
120
120
  rescue
@@ -104,7 +104,10 @@ module Profiler
104
104
  record_response(env, profile, request_body, status, headers, content, content.bytesize, true)
105
105
  collect_all(profile, collectors)
106
106
 
107
- Profiler.storage.save(profile.token, profile)
107
+ storage = Profiler.storage
108
+ storage.save(profile.token, profile)
109
+ # Not saved: no token to send, no toolbar to inject for it.
110
+ return [status, headers, body] if storage.is_a?(Profiler::Storage::Unavailable)
108
111
 
109
112
  set_header(headers, TOKEN_HEADER, profile.token)
110
113
 
@@ -15,6 +15,9 @@ module Profiler
15
15
  config.default(:enabled, development_or_test)
16
16
  config.default(:storage, development_or_test ? :file : :memory)
17
17
  config.default(:track_tests, Rails.env.test?)
18
+ # One test profile per example: no cap on the count in the test environment, so that the
19
+ # report at the end of a suite sees all of them.
20
+ config.default(:max_profiles, Rails.env.test? ? nil : 100)
18
21
 
19
22
  app.config.profiler.each do |key, value|
20
23
  if config.respond_to?("#{key}=")