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
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: e53e69cbdde6c7e775444a738e0069cef86994f505f77d86673a15e25adc768d
4
- data.tar.gz: c134819ed6bff52aa1d5ad7309b752e1ed6c6a6e1803510d4387462f1607b73a
3
+ metadata.gz: 3ca8756c9efed6c4619db0cbc1226f104deb5f7287f972b137f9a8bf291a40c4
4
+ data.tar.gz: dcb61c33b3262c081493beb1ee783923b3c7b3ac8d4cd26ef9195ea4e3ff182d
5
5
  SHA512:
6
- metadata.gz: 1a277a3d46f4967dc8d875957e95b4d2df118c5a7a96dfcdac83e4a932c4aa4fbd73e1498ef5250bf714e96b5842d99814b617e7f530befcef2eac07984e571c
7
- data.tar.gz: 978b1b85623419ca8fd8afe3c9550f24be03dbd5e1a8ef02f8ded99270cff218f2e2d68622b66b9edc7def30a46cb574c8b06bdb7b3cdb6f3502b91a919d7941
6
+ metadata.gz: 2576d031d1b4d3e6e462392ed5ca00bc83c12eaed23b9ec07ea842bd3365e373d7eff1b4c53ae73e779366fc947ad70f01f15e8b1108c8fb36e85409986ebd22
7
+ data.tar.gz: 4b4d5914c82c06e58fd1d8e14157a28b2d0a9bbc00d15f574a5a5bdb0e782c0e9957042f25c38ba2d6cbc93c159b0b4d2f921667d552e397f37fed896bcc2245
data/CHANGELOG.md CHANGED
@@ -19,6 +19,125 @@ 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
+
78
+ ## [0.31.3] - 2026-10-05
79
+
80
+ <!-- stamped -->
81
+
82
+ ### Fixed
83
+
84
+ - **Collectors:** a profile no longer records the SQL queries, views, cache operations, timeline
85
+ events, exceptions and mails of other requests. The collectors subscribed to the process-wide
86
+ `ActiveSupport::Notifications` bus without looking at the thread, so on a multi-threaded server
87
+ (Puma, Sidekiq) a profile could show the queries of a concurrent request, another user's
88
+ included. Each collector now records the events of its own request: its thread, the threads it
89
+ starts with `Thread.new`, the thread `ActionController::Live` runs the action in, the fibers
90
+ it creates (Ruby 3.2+, the layout of `render stream: true` under Falcon), the server thread
91
+ iterating a streamed body, and the queries of `load_async`, which Rails reports on the
92
+ request's thread. The process holds one subscriber per event instead of one per profiled request,
93
+ so an SQL event costs the same however many requests are profiled at once (6 us with 8 requests
94
+ profiled, 260 us before, measured). A job performed inline still shows in both profiles; one
95
+ performed by ActiveJob's `:async` adapter is profiled apart from the request that enqueued it,
96
+ even while that request still runs. A test profile no longer shows the queries the application
97
+ server runs for a system test in its own thread: they are in the profile of that HTTP request.
98
+ - **Instrumentation:** a thread created by a pool while a request ran (a concurrent-ruby worker
99
+ behind `Concurrent::Promises`, ActiveJob's `:async` adapter or `ActionController::Live`, or the
100
+ thread Puma starts for a request marked as IO bound) no longer keeps that request's HTTP and
101
+ timeline collectors for good, which put the outgoing HTTP calls of other requests' tasks in its
102
+ profile. A task posted to a concurrent-ruby executor now runs in the context of the request that
103
+ posted it, and a pool thread inherits nothing. On Puma with 8 threads, 40 concurrent requests
104
+ each running a query and an HTTP call in a `Concurrent::Promises.future`: 0 queries and 0 HTTP
105
+ calls of another request in a profile, none lost, against 560 foreign queries, 35 misattributed
106
+ and 36 lost HTTP calls before (measured, `script/bench/puma_attribution.rb`).
107
+ - **Function profiler:** without the `stackprof` gem, which the gem does not depend on, the
108
+ function profiler stays off instead of falling back to a `TracePoint` enabled on every thread of
109
+ the process, which called `GC.stat` four times per method and tripled the cost of a request, for
110
+ a tab that is not shown. To trace again, set `Profiler.function_profiling_tracepoint_fallback =
111
+ true`; the `TracePoint`, like the one of the `full` mode chosen from the dashboard, is now
112
+ enabled on the request's thread only. The Timeline tab now says that stackprof is missing and
113
+ how to add it, instead of a button to enable function profiling that changed nothing, and no
114
+ longer says that sampling is on by default whatever the Gemfile.
115
+ - **Database:** the caller of a query is captured the first time a statement runs in the request
116
+ (values left out, as the N+1 detection groups them) and for every slow query, instead of for
117
+ every query, at about 0.1 ms each. The N+1 groups of the Database tab and of the MCP
118
+ `n1-patterns` resource keep their location. The backtrace now starts at the application's code
119
+ that ran the query, cleaned by `Rails.backtrace_cleaner` as in the Exception tab, or without
120
+ Rails, past the frames of gems and of Ruby; it often started inside Active Support, with the
121
+ application's first frame too far down for the three frames `n1-patterns` shows.
122
+ `config.sql_backtrace = :all` captures every query again, `:none` none.
123
+ - **Routes and Env tabs:** a profile no longer stores the whole route table and `ENV`, rebuilt and
124
+ sorted on every request (the route table alone cost 7.7 ms per request with 210 routes, and 29 KB
125
+ per profile, measured). It keeps the route its request matched, the one of the request's verb
126
+ (a `PUT` showed the `PATCH` route of the same action). The Routes tab, the toolbar and the MCP
127
+ `get_profile` tool list the route table of the process that serves them, rebuilt when the
128
+ routes are reloaded in development; the Env tab of an HTTP profile shows the current `ENV` of that
129
+ process, not the `ENV` as it was during the request, masked as before. The profiles of jobs,
130
+ console expressions and tests, which run in another process, keep that process's `ENV`, masked,
131
+ as before. Profiles saved by an earlier version keep
132
+ showing their own table and variables. With both changes, the profiler adds about 10 to 13 ms to
133
+ the reference page of `script/bench/request_overhead.rb` instead of 21 to 22 ms with stackprof
134
+ and 26 ms without it in 0.31.2 (Ruby 3.3, measured); the README gives this measured cost instead
135
+ of "< 5ms per request".
136
+ - **Upgrading:** to get the previous behaviours back, set `config.sql_backtrace = :all` and
137
+ `Profiler.function_profiling_tracepoint_fallback = true` in `config/initializers/profiler.rb`.
138
+ The Routes and Env tabs cannot show the table and the variables of the request's time any more:
139
+ they show the process's current ones.
140
+
22
141
  ## [0.31.2] - 2026-10-05
23
142
 
24
143
  <!-- stamped -->
@@ -7413,7 +7413,9 @@
7413
7413
  const [totalCount, setTotalCount] = d2(0);
7414
7414
  const [fnSortKey, setFnSortKey] = d2("total_duration");
7415
7415
  const [fnSortDir, setFnSortDir] = d2("desc");
7416
- const [fnEnabled, setFnEnabled] = d2(functionProfileData?.enabled ?? false);
7416
+ const [fnEnabled, setFnEnabled] = d2(
7417
+ functionProfileData?.reason === "stackprof_missing" ? true : functionProfileData?.enabled ?? false
7418
+ );
7417
7419
  const [fnMaxFrames, setFnMaxFrames] = d2(functionProfileData?.max_frames ?? 2e3);
7418
7420
  const [fnMode, setFnMode] = d2(functionProfileData?.mode === "lite" ? "lite" : "full");
7419
7421
  const [fnClock, setFnClock] = d2(functionProfileData?.clock ?? "wall");
@@ -7722,6 +7724,7 @@
7722
7724
  const [hoveredFnName, setHoveredFnName] = d2(null);
7723
7725
  const fnRendererRef = A2(null);
7724
7726
  const hasData = enabled && data?.enabled && (data.functions?.length ?? 0) > 0;
7727
+ const stackprofMissing = data?.reason === "stackprof_missing" && mode === "lite";
7725
7728
  const dataMode = data?.mode ?? "full";
7726
7729
  const dataClock = data?.clock ?? "wall";
7727
7730
  const isSampling = dataMode === "lite";
@@ -7814,7 +7817,7 @@
7814
7817
  class: `profiler-fn-profiling__toggle${enabled ? " profiler-fn-profiling__toggle--active" : ""}`,
7815
7818
  onClick: onToggle,
7816
7819
  disabled: toggling,
7817
- children: toggling ? "\u2026" : enabled ? "Enabled \u2014 click to disable" : "Disabled \u2014 click to enable"
7820
+ children: toggling ? "\u2026" : enabled ? "Enabled (click to disable)" : "Disabled (click to enable)"
7818
7821
  }
7819
7822
  )
7820
7823
  ] })
@@ -7823,12 +7826,25 @@
7823
7826
  "Enable function profiling to see where your app spends time.",
7824
7827
  " ",
7825
7828
  /* @__PURE__ */ u3("strong", { children: "Lite" }),
7826
- ": statistical sampling via stackprof \u2014 very low overhead (<1%), enabled by default.",
7829
+ ": statistical sampling with the stackprof gem, to add to the application's Gemfile; very low overhead (<1%), on by default once stackprof is installed.",
7827
7830
  " ",
7828
7831
  /* @__PURE__ */ u3("strong", { children: "Full" }),
7829
7832
  ": exhaustive TracePoint tracing with memory bytes \u2014 significant overhead."
7830
7833
  ] }),
7831
- enabled && !hasData && /* @__PURE__ */ u3("p", { class: "profiler-fn-profiling__hint", children: "Function profiling is active. Data will appear on the next request." }),
7834
+ enabled && stackprofMissing && /* @__PURE__ */ u3("p", { class: "profiler-fn-profiling__hint", children: [
7835
+ "No data: sampling needs the ",
7836
+ /* @__PURE__ */ u3("code", { children: "stackprof" }),
7837
+ " gem, which is not installed. Add",
7838
+ " ",
7839
+ /* @__PURE__ */ u3("code", { children: 'gem "stackprof"' }),
7840
+ " to the application's Gemfile. To trace every method call instead, at a much higher cost, choose ",
7841
+ /* @__PURE__ */ u3("strong", { children: "Full" }),
7842
+ ", or set",
7843
+ " ",
7844
+ /* @__PURE__ */ u3("code", { children: "Profiler.function_profiling_tracepoint_fallback = true" }),
7845
+ " in an initializer."
7846
+ ] }),
7847
+ enabled && !hasData && !stackprofMissing && /* @__PURE__ */ u3("p", { class: "profiler-fn-profiling__hint", children: "Function profiling is active. Data will appear on the next request." }),
7832
7848
  hasData && /* @__PURE__ */ u3(k, { children: [
7833
7849
  data.frame_cap_reached && /* @__PURE__ */ u3("div", { class: "profiler-fn-profiling__cap-warning", children: [
7834
7850
  "\u26A0 Frame cap reached (",
@@ -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
@@ -29,6 +25,8 @@ module Profiler
29
25
 
30
26
  # Recalculate AJAX collector data (since AJAX requests happen after page load)
31
27
  recalculate_ajax_data(profile)
28
+ # The route table and ENV are the process's, not stored in the profile
29
+ Profiler::ProcessSnapshot.hydrate(profile)
32
30
 
33
31
  render json: profile.to_h.merge(
34
32
  child_jobs: build_child_jobs(profile),
@@ -55,22 +53,16 @@ module Profiler
55
53
  %w[1 true].include?(params[:all_types].to_s)
56
54
  end
57
55
 
58
- def recalculate_ajax_data(profile)
59
- # Find AJAX collector in the configured collectors
60
- ajax_collector_class = Profiler::Collectors::AjaxCollector
61
-
62
- if Profiler.configuration.collectors.include?(ajax_collector_class)
63
- collector = ajax_collector_class.new(profile)
64
- 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
65
59
 
66
- # Update tab metadata to reflect has_data status
67
- if profile.instance_variable_get(:@collectors_metadata)
68
- ajax_tab = profile.instance_variable_get(:@collectors_metadata).find { |tab| tab[:key] == 'ajax' }
69
- if ajax_tab
70
- ajax_tab[:has_data] = collector.has_data?
71
- end
72
- end
73
- 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)
74
66
  end
75
67
  end
76
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
@@ -16,32 +16,14 @@ module Profiler
16
16
 
17
17
  # Recalculate AJAX collector data (since AJAX requests happen after page load)
18
18
  recalculate_ajax_data(profile)
19
+ # The route table and ENV are the process's, not stored in the profile
20
+ Profiler::ProcessSnapshot.hydrate(profile)
19
21
 
20
22
  render json: {
21
23
  profile: profile.to_h.merge(child_jobs: build_child_jobs(profile)),
22
24
  events_cursor: events_cursor
23
25
  }
24
26
  end
25
-
26
- private
27
-
28
- def recalculate_ajax_data(profile)
29
- # Find AJAX collector in the configured collectors
30
- ajax_collector_class = Profiler::Collectors::AjaxCollector
31
-
32
- if Profiler.configuration.collectors.include?(ajax_collector_class)
33
- collector = ajax_collector_class.new(profile)
34
- collector.collect
35
-
36
- # Update tab metadata to reflect has_data status
37
- if profile.instance_variable_get(:@collectors_metadata)
38
- ajax_tab = profile.instance_variable_get(:@collectors_metadata).find { |tab| tab[:key] == 'ajax' }
39
- if ajax_tab
40
- ajax_tab[:has_data] = collector.has_data?
41
- end
42
- end
43
- end
44
- end
45
27
  end
46
28
  end
47
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)
@@ -22,6 +22,8 @@ module Profiler
22
22
 
23
23
  # Recalculate AJAX collector data (since AJAX requests happen after page load)
24
24
  recalculate_ajax_data(@profile)
25
+ # The route table and ENV are the process's, not stored in the profile
26
+ Profiler::ProcessSnapshot.hydrate(@profile)
25
27
 
26
28
  @profile_data = @profile.to_h.merge(
27
29
  child_jobs: build_child_jobs(@profile),
@@ -142,22 +144,6 @@ module Profiler
142
144
  end
143
145
  end
144
146
 
145
- def recalculate_ajax_data(profile)
146
- ajax_collector_class = Profiler::Collectors::AjaxCollector
147
-
148
- if Profiler.configuration.collectors.include?(ajax_collector_class)
149
- collector = ajax_collector_class.new(profile, storage: @resolved_storage || Profiler.storage)
150
- collector.collect
151
-
152
- if profile.instance_variable_get(:@collectors_metadata)
153
- ajax_tab = profile.instance_variable_get(:@collectors_metadata).find { |tab| tab[:key] == 'ajax' }
154
- if ajax_tab
155
- ajax_tab[:has_data] = collector.has_data?
156
- end
157
- end
158
- end
159
- end
160
-
161
147
  def filter_profiles(profiles)
162
148
  filtered = profiles
163
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.
@@ -2,6 +2,7 @@
2
2
 
3
3
  require_relative "../redaction"
4
4
  require_relative "lifecycle"
5
+ require_relative "scoped_notifications"
5
6
 
6
7
  module Profiler
7
8
  module Collectors
@@ -143,10 +144,14 @@ module Profiler
143
144
  @claimed_thread_slots = nil
144
145
  end
145
146
 
146
- def unsubscribe_notifications(subscriptions)
147
- return unless defined?(ActiveSupport::Notifications)
147
+ # Subscribes to +event+ for the request being profiled only: the block never sees what
148
+ # another request's thread emits (see ScopedNotifications). Returns the handle to release.
149
+ def subscribe_notification(event, &block)
150
+ ScopedNotifications.subscribe(event, &block)
151
+ end
148
152
 
149
- subscriptions.each { |sub| ActiveSupport::Notifications.unsubscribe(sub) }
153
+ def unsubscribe_notifications(subscriptions)
154
+ subscriptions.each { |handle| ScopedNotifications.unsubscribe(handle) }
150
155
  subscriptions.clear
151
156
  end
152
157
  end
@@ -35,7 +35,7 @@ module Profiler
35
35
  def subscribe
36
36
  return unless defined?(ActiveSupport::Notifications)
37
37
 
38
- @subscriptions << ActiveSupport::Notifications.monotonic_subscribe("cache_read.active_support") do |name, started, finished, unique_id, payload|
38
+ @subscriptions << subscribe_notification("cache_read.active_support") do |name, started, finished, unique_id, payload|
39
39
  duration = ((finished - started) * 1000).round(2)
40
40
  @cache_reads << {
41
41
  key: payload[:key],
@@ -44,7 +44,7 @@ module Profiler
44
44
  }
45
45
  end
46
46
 
47
- @subscriptions << ActiveSupport::Notifications.monotonic_subscribe("cache_write.active_support") do |name, started, finished, unique_id, payload|
47
+ @subscriptions << subscribe_notification("cache_write.active_support") do |name, started, finished, unique_id, payload|
48
48
  duration = ((finished - started) * 1000).round(2)
49
49
  @cache_writes << {
50
50
  key: payload[:key],
@@ -52,7 +52,7 @@ module Profiler
52
52
  }
53
53
  end
54
54
 
55
- @subscriptions << ActiveSupport::Notifications.monotonic_subscribe("cache_delete.active_support") do |name, started, finished, unique_id, payload|
55
+ @subscriptions << subscribe_notification("cache_delete.active_support") do |name, started, finished, unique_id, payload|
56
56
  duration = ((finished - started) * 1000).round(2)
57
57
  @cache_deletes << {
58
58
  key: payload[:key],