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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +119 -0
- data/app/assets/builds/profiler.js +20 -4
- data/app/controllers/profiler/api/console_controller.rb +1 -11
- data/app/controllers/profiler/api/jobs_controller.rb +1 -11
- data/app/controllers/profiler/api/profiles_controller.rb +20 -28
- data/app/controllers/profiler/api/tests_controller.rb +1 -11
- data/app/controllers/profiler/api/toolbar_controller.rb +2 -20
- data/app/controllers/profiler/application_controller.rb +55 -15
- data/app/controllers/profiler/profiles_controller.rb +2 -16
- data/lib/profiler/ajax_data.rb +40 -0
- data/lib/profiler/cluster/slave_proxy.rb +7 -5
- data/lib/profiler/collectors/base_collector.rb +8 -3
- data/lib/profiler/collectors/cache_collector.rb +3 -3
- data/lib/profiler/collectors/database_collector.rb +128 -10
- data/lib/profiler/collectors/env_collector.rb +12 -4
- data/lib/profiler/collectors/exception_collector.rb +7 -9
- data/lib/profiler/collectors/flamegraph_collector.rb +5 -5
- data/lib/profiler/collectors/function_profiler_collector.rb +28 -4
- data/lib/profiler/collectors/mailer_collector.rb +2 -8
- data/lib/profiler/collectors/routes_collector.rb +7 -50
- data/lib/profiler/collectors/scoped_notifications.rb +229 -0
- data/lib/profiler/collectors/view_collector.rb +2 -2
- data/lib/profiler/configuration.rb +20 -5
- data/lib/profiler/instrumentation/executor_context_propagation.rb +47 -0
- data/lib/profiler/instrumentation/request_context.rb +77 -0
- data/lib/profiler/instrumentation/thread_context_propagation.rb +11 -16
- data/lib/profiler/job_profiler.rb +4 -2
- data/lib/profiler/mcp/resources/failing_tests.rb +1 -1
- data/lib/profiler/mcp/resources/recent_console.rb +2 -2
- data/lib/profiler/mcp/resources/recent_jobs.rb +2 -2
- data/lib/profiler/mcp/resources/slow_tests.rb +1 -1
- data/lib/profiler/mcp/server.rb +8 -2
- data/lib/profiler/mcp/tools/get_profile_ajax.rb +2 -0
- data/lib/profiler/mcp/tools/get_profile_detail.rb +8 -1
- data/lib/profiler/mcp/tools/get_test_profile_detail.rb +1 -1
- data/lib/profiler/mcp/tools/query_console_profiles.rb +1 -1
- data/lib/profiler/mcp/tools/query_jobs.rb +1 -1
- data/lib/profiler/mcp/tools/query_profiles.rb +1 -1
- data/lib/profiler/mcp/tools/query_test_profiles.rb +1 -1
- data/lib/profiler/mcp/tools/run_tests.rb +1 -1
- data/lib/profiler/middleware/profiler_middleware.rb +4 -1
- data/lib/profiler/process_snapshot.rb +157 -0
- data/lib/profiler/railtie.rb +3 -0
- data/lib/profiler/storage/file_store.rb +332 -58
- data/lib/profiler/storage/memory_store.rb +60 -31
- data/lib/profiler/storage/private_files.rb +13 -0
- data/lib/profiler/storage/redis_store.rb +251 -32
- data/lib/profiler/storage/sqlite_store.rb +57 -9
- data/lib/profiler/storage/summary.rb +50 -0
- data/lib/profiler/storage/unavailable.rb +58 -0
- data/lib/profiler/test_helpers/reporter.rb +1 -1
- data/lib/profiler/version.rb +1 -1
- data/lib/profiler.rb +17 -1
- metadata +8 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 3ca8756c9efed6c4619db0cbc1226f104deb5f7287f972b137f9a8bf291a40c4
|
|
4
|
+
data.tar.gz: dcb61c33b3262c081493beb1ee783923b3c7b3ac8d4cd26ef9195ea4e3ff182d
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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(
|
|
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
|
|
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
|
|
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 &&
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
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
|
|
59
|
-
|
|
60
|
-
|
|
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
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
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
|
-
|
|
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
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
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
|
|
39
|
-
|
|
40
|
-
|
|
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
|
-
|
|
147
|
-
|
|
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
|
-
|
|
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 <<
|
|
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 <<
|
|
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 <<
|
|
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],
|