rails-profiler 0.31.1 → 0.31.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +122 -0
- data/app/assets/builds/profiler-toolbar.js +13 -6
- data/app/assets/builds/profiler.js +73 -37
- data/app/controllers/profiler/api/profiles_controller.rb +2 -0
- data/app/controllers/profiler/api/toolbar_controller.rb +2 -0
- data/app/controllers/profiler/profiles_controller.rb +2 -0
- data/lib/profiler/allocation_counter.rb +21 -0
- data/lib/profiler/collectors/base_collector.rb +39 -3
- data/lib/profiler/collectors/cache_collector.rb +8 -3
- data/lib/profiler/collectors/database_collector.rb +133 -10
- data/lib/profiler/collectors/env_collector.rb +12 -4
- data/lib/profiler/collectors/exception_collector.rb +12 -9
- data/lib/profiler/collectors/flamegraph_collector.rb +10 -5
- data/lib/profiler/collectors/function_profiler_collector.rb +28 -4
- data/lib/profiler/collectors/http_collector.rb +5 -0
- data/lib/profiler/collectors/log_collector.rb +5 -0
- data/lib/profiler/collectors/mailer_collector.rb +2 -8
- data/lib/profiler/collectors/request_collector.rb +14 -11
- 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 +7 -2
- data/lib/profiler/configuration.rb +27 -4
- data/lib/profiler/console_profiler.rb +2 -17
- 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 +6 -17
- data/lib/profiler/mcp/resources/recent_requests.rb +2 -0
- data/lib/profiler/mcp/tools/get_profile_detail.rb +21 -2
- data/lib/profiler/mcp/tools/get_test_profile_detail.rb +1 -1
- data/lib/profiler/middleware/capturing_body.rb +112 -0
- data/lib/profiler/middleware/profiler_middleware.rb +261 -53
- data/lib/profiler/middleware/streamed_profile.rb +135 -0
- data/lib/profiler/middleware/toolbar_injector.rb +3 -10
- data/lib/profiler/models/profile.rb +69 -5
- data/lib/profiler/process_snapshot.rb +157 -0
- data/lib/profiler/redaction.rb +10 -0
- data/lib/profiler/test_profiler.rb +2 -17
- data/lib/profiler/version.rb +1 -1
- data/lib/profiler.rb +6 -0
- metadata +12 -5
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: '09422d8df091081147998c5e266018f8156874c3f2590b4269dfc1bab5b2efcf'
|
|
4
|
+
data.tar.gz: 3f0ac06620cf6d1f0297528cdce39e80962b736e835d127538968a2aff8b748f
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 7d74679898828ec69cea3718945c08e8c8ce4aec93d1ccb8942de28617a888ff2dfc2fbde4e4c01892f9a05a8723f713907a87b378b3be5dd11d5cb7cec7500b
|
|
7
|
+
data.tar.gz: 918333cbc7efbb6c61217adb4c52ced40be60a62b621fb828e942878598defd92d69bd36672bf515bcd5b6f9e4ed82869268fe704f64b674c309e2f61846fe6a
|
data/CHANGELOG.md
CHANGED
|
@@ -19,6 +19,128 @@ every commit of every tag interval is accounted for one way or the other.
|
|
|
19
19
|
|
|
20
20
|
## [Unreleased]
|
|
21
21
|
|
|
22
|
+
## [0.31.3] - 2026-10-05
|
|
23
|
+
|
|
24
|
+
<!-- stamped -->
|
|
25
|
+
|
|
26
|
+
### Fixed
|
|
27
|
+
|
|
28
|
+
- **Collectors:** a profile no longer records the SQL queries, views, cache operations, timeline
|
|
29
|
+
events, exceptions and mails of other requests. The collectors subscribed to the process-wide
|
|
30
|
+
`ActiveSupport::Notifications` bus without looking at the thread, so on a multi-threaded server
|
|
31
|
+
(Puma, Sidekiq) a profile could show the queries of a concurrent request, another user's
|
|
32
|
+
included. Each collector now records the events of its own request: its thread, the threads it
|
|
33
|
+
starts with `Thread.new`, the thread `ActionController::Live` runs the action in, the fibers
|
|
34
|
+
it creates (Ruby 3.2+, the layout of `render stream: true` under Falcon), the server thread
|
|
35
|
+
iterating a streamed body, and the queries of `load_async`, which Rails reports on the
|
|
36
|
+
request's thread. The process holds one subscriber per event instead of one per profiled request,
|
|
37
|
+
so an SQL event costs the same however many requests are profiled at once (6 us with 8 requests
|
|
38
|
+
profiled, 260 us before, measured). A job performed inline still shows in both profiles; one
|
|
39
|
+
performed by ActiveJob's `:async` adapter is profiled apart from the request that enqueued it,
|
|
40
|
+
even while that request still runs. A test profile no longer shows the queries the application
|
|
41
|
+
server runs for a system test in its own thread: they are in the profile of that HTTP request.
|
|
42
|
+
- **Instrumentation:** a thread created by a pool while a request ran (a concurrent-ruby worker
|
|
43
|
+
behind `Concurrent::Promises`, ActiveJob's `:async` adapter or `ActionController::Live`, or the
|
|
44
|
+
thread Puma starts for a request marked as IO bound) no longer keeps that request's HTTP and
|
|
45
|
+
timeline collectors for good, which put the outgoing HTTP calls of other requests' tasks in its
|
|
46
|
+
profile. A task posted to a concurrent-ruby executor now runs in the context of the request that
|
|
47
|
+
posted it, and a pool thread inherits nothing. On Puma with 8 threads, 40 concurrent requests
|
|
48
|
+
each running a query and an HTTP call in a `Concurrent::Promises.future`: 0 queries and 0 HTTP
|
|
49
|
+
calls of another request in a profile, none lost, against 560 foreign queries, 35 misattributed
|
|
50
|
+
and 36 lost HTTP calls before (measured, `script/bench/puma_attribution.rb`).
|
|
51
|
+
- **Function profiler:** without the `stackprof` gem, which the gem does not depend on, the
|
|
52
|
+
function profiler stays off instead of falling back to a `TracePoint` enabled on every thread of
|
|
53
|
+
the process, which called `GC.stat` four times per method and tripled the cost of a request, for
|
|
54
|
+
a tab that is not shown. To trace again, set `Profiler.function_profiling_tracepoint_fallback =
|
|
55
|
+
true`; the `TracePoint`, like the one of the `full` mode chosen from the dashboard, is now
|
|
56
|
+
enabled on the request's thread only. The Timeline tab now says that stackprof is missing and
|
|
57
|
+
how to add it, instead of a button to enable function profiling that changed nothing, and no
|
|
58
|
+
longer says that sampling is on by default whatever the Gemfile.
|
|
59
|
+
- **Database:** the caller of a query is captured the first time a statement runs in the request
|
|
60
|
+
(values left out, as the N+1 detection groups them) and for every slow query, instead of for
|
|
61
|
+
every query, at about 0.1 ms each. The N+1 groups of the Database tab and of the MCP
|
|
62
|
+
`n1-patterns` resource keep their location. The backtrace now starts at the application's code
|
|
63
|
+
that ran the query, cleaned by `Rails.backtrace_cleaner` as in the Exception tab, or without
|
|
64
|
+
Rails, past the frames of gems and of Ruby; it often started inside Active Support, with the
|
|
65
|
+
application's first frame too far down for the three frames `n1-patterns` shows.
|
|
66
|
+
`config.sql_backtrace = :all` captures every query again, `:none` none.
|
|
67
|
+
- **Routes and Env tabs:** a profile no longer stores the whole route table and `ENV`, rebuilt and
|
|
68
|
+
sorted on every request (the route table alone cost 7.7 ms per request with 210 routes, and 29 KB
|
|
69
|
+
per profile, measured). It keeps the route its request matched, the one of the request's verb
|
|
70
|
+
(a `PUT` showed the `PATCH` route of the same action). The Routes tab, the toolbar and the MCP
|
|
71
|
+
`get_profile` tool list the route table of the process that serves them, rebuilt when the
|
|
72
|
+
routes are reloaded in development; the Env tab of an HTTP profile shows the current `ENV` of that
|
|
73
|
+
process, not the `ENV` as it was during the request, masked as before. The profiles of jobs,
|
|
74
|
+
console expressions and tests, which run in another process, keep that process's `ENV`, masked,
|
|
75
|
+
as before. Profiles saved by an earlier version keep
|
|
76
|
+
showing their own table and variables. With both changes, the profiler adds about 10 to 13 ms to
|
|
77
|
+
the reference page of `script/bench/request_overhead.rb` instead of 21 to 22 ms with stackprof
|
|
78
|
+
and 26 ms without it in 0.31.2 (Ruby 3.3, measured); the README gives this measured cost instead
|
|
79
|
+
of "< 5ms per request".
|
|
80
|
+
- **Upgrading:** to get the previous behaviours back, set `config.sql_backtrace = :all` and
|
|
81
|
+
`Profiler.function_profiling_tracepoint_fallback = true` in `config/initializers/profiler.rb`.
|
|
82
|
+
The Routes and Env tabs cannot show the table and the variables of the request's time any more:
|
|
83
|
+
they show the process's current ones.
|
|
84
|
+
|
|
85
|
+
## [0.31.2] - 2026-10-05
|
|
86
|
+
|
|
87
|
+
<!-- stamped -->
|
|
88
|
+
|
|
89
|
+
### Fixed
|
|
90
|
+
|
|
91
|
+
- **Middleware:** Stream responses through instead of buffering them. The profiler read every
|
|
92
|
+
response body to the end before handing it to the server, so an `ActionController::Live`,
|
|
93
|
+
`response.stream` or enumerator response, or a server-sent events stream, left only once it was
|
|
94
|
+
over (a stream of 5 chunks 200 ms apart sent its first chunk after 1 s instead of 0.2 s, and an
|
|
95
|
+
endless event stream never answered), and a file sent with `send_file` lost `to_path`, so the
|
|
96
|
+
server could no longer send it by its path. A body that does not answer `to_ary`, a
|
|
97
|
+
`text/event-stream` or a body sent by its path now goes to the server chunk by chunk, keeps
|
|
98
|
+
`to_path` and `close`, and its profile is saved when the server closes it, also when the client
|
|
99
|
+
went away, with the body seen so far and the full duration. The collectors that only gather
|
|
100
|
+
what notifications and the logger hand them (SQL, views, cache, exceptions, timeline, logs,
|
|
101
|
+
outbound HTTP) stay subscribed until then, so the queries and views of a `render stream: true`
|
|
102
|
+
page or of an enumerator are still recorded; dumps, mailers, I18n and function profiling are
|
|
103
|
+
read when the application returns. While the server iterates the body, the log lines, outbound
|
|
104
|
+
HTTP calls and `Profiler.measure` blocks it runs are recorded as well. Until it is saved, a
|
|
105
|
+
streamed profile is not listed and its token answers 404: an endless event stream is never
|
|
106
|
+
listed. A body the server never closes is finished when the fiber that started it starts its
|
|
107
|
+
next profiled request, never cutting a stream that a fiber-based server such as Falcon runs
|
|
108
|
+
alongside, and its subscriptions are dropped after 5 minutes at the latest, which the profile
|
|
109
|
+
and the Request tab then say; the server still closes the application's body. Rails 7.0 pages, whose body has no `to_ary`, and pages a `Live`
|
|
110
|
+
controller renders whole still get the toolbar. The toolbar is only injected in pages returned
|
|
111
|
+
whole, so a `render stream: true` page has none.
|
|
112
|
+
- **Middleware:** Stop turning an error raised while a response body is iterated (a stream that
|
|
113
|
+
fails part way, a file sent with `send_file` that disappeared) into an empty `200`. The error now
|
|
114
|
+
reaches the server, as it does without the profiler, and the profile keeps it.
|
|
115
|
+
- **Middleware:** Keep the request and response bodies of a profile to the first
|
|
116
|
+
`config.max_captured_body_bytes` (256 KB by default) instead of copying them whole: a 2 MB upload
|
|
117
|
+
took 2.8 MB in its profile, plus transient copies. `rack.input` is read only up to that size, then
|
|
118
|
+
rewound for the application, and an input that cannot be rewound is no longer read at all. The
|
|
119
|
+
Request tab and the MCP profile detail say when a body was cut, with its whole size, or "at
|
|
120
|
+
least" that size when it is not known; the profile carries `request_body_truncated`,
|
|
121
|
+
`request_body_size`, `response_body_truncated`, `response_body_size` and their
|
|
122
|
+
`*_size_is_minimum`. Only the raw bodies are capped: the parsed `params` and the
|
|
123
|
+
`Parameters:` log line keep their full size, so a 5 MB JSON request still takes about 15 MB in
|
|
124
|
+
its profile. To keep whole bodies, as before, set `config.max_captured_body_bytes = nil`.
|
|
125
|
+
- **Profiles:** Report the number of objects allocated (`allocated_objects`) instead of a figure
|
|
126
|
+
shown as memory. `GC.stat` has no `:total_allocated_size` on the supported Rubies, so `memory`
|
|
127
|
+
was always that number times 40, never a measured byte count. The toolbar, the dashboards, the
|
|
128
|
+
profile list and the MCP tools show "Allocations". The counter belongs to the process: on a
|
|
129
|
+
multi-threaded server it includes what other threads allocated meanwhile. Nothing to change on
|
|
130
|
+
upgrade: the API and the stored profiles still carry `memory` (deprecated, the count times 40),
|
|
131
|
+
and profiles saved by earlier versions are read back with their count. The option
|
|
132
|
+
`memory_warning_threshold`, which nothing ever compared with, is deprecated for
|
|
133
|
+
`allocated_objects_warning_threshold`, a number of objects, still without effect; setting the
|
|
134
|
+
old one warns and converts it.
|
|
135
|
+
- **Toolbar:** Leave a response already framed with `Transfer-Encoding` untouched. Under Rails
|
|
136
|
+
7.0, a `render stream: true` page arrives chunked, and the toolbar was put among the chunks,
|
|
137
|
+
which broke the framing: the browser got an empty or cut page.
|
|
138
|
+
- **Toolbar:** Correct the `Content-Length` of a page the toolbar is injected into. It kept the
|
|
139
|
+
length of the page without the toolbar, so a server that honours it cut the page short.
|
|
140
|
+
- **Toolbar:** Inject the toolbar into a page whose headers are a plain `Hash` with a lower-case
|
|
141
|
+
`content-type`, the form Rack 3 requires; only `Content-Type` was looked up. Every response
|
|
142
|
+
header the profiler reads or writes now ignores the case.
|
|
143
|
+
|
|
22
144
|
## [0.31.1] - 2026-10-05
|
|
23
145
|
|
|
24
146
|
<!-- stamped -->
|
|
@@ -3831,6 +3831,16 @@
|
|
|
3831
3831
|
] });
|
|
3832
3832
|
}
|
|
3833
3833
|
|
|
3834
|
+
// app/assets/typescript/profiler/dashboard/utils.ts
|
|
3835
|
+
function allocatedObjects(profile) {
|
|
3836
|
+
if (profile.allocated_objects != null) return profile.allocated_objects;
|
|
3837
|
+
return profile.memory != null ? Math.round(profile.memory / 40) : null;
|
|
3838
|
+
}
|
|
3839
|
+
function formatAllocations(count) {
|
|
3840
|
+
return count == null ? "-" : `${count.toLocaleString("en")} objects`;
|
|
3841
|
+
}
|
|
3842
|
+
var ALLOCATIONS_HINT = "Objects allocated by the whole process while this ran: on a multi-threaded server, it includes what other threads allocated meanwhile.";
|
|
3843
|
+
|
|
3834
3844
|
// app/assets/typescript/profiler/components/toolbar/ToolbarApp.tsx
|
|
3835
3845
|
function statusClass2(status) {
|
|
3836
3846
|
if (status >= 200 && status < 300) return "profiler-text--success";
|
|
@@ -3903,12 +3913,9 @@
|
|
|
3903
3913
|
" ms"
|
|
3904
3914
|
] })
|
|
3905
3915
|
] }),
|
|
3906
|
-
profile
|
|
3907
|
-
/* @__PURE__ */ u3("span", { children: "
|
|
3908
|
-
/* @__PURE__ */ u3("strong", { children:
|
|
3909
|
-
(profile.memory / 1024 / 1024).toFixed(2),
|
|
3910
|
-
" MB"
|
|
3911
|
-
] })
|
|
3916
|
+
allocatedObjects(profile) != null && /* @__PURE__ */ u3("div", { class: "profiler-toolbar-panel-row", title: ALLOCATIONS_HINT, children: [
|
|
3917
|
+
/* @__PURE__ */ u3("span", { children: "Allocations" }),
|
|
3918
|
+
/* @__PURE__ */ u3("strong", { children: formatAllocations(allocatedObjects(profile)) })
|
|
3912
3919
|
] }),
|
|
3913
3920
|
/* @__PURE__ */ u3("div", { class: "profiler-toolbar-panel-row", children: [
|
|
3914
3921
|
/* @__PURE__ */ u3("span", { children: "Started" }),
|
|
@@ -3994,6 +3994,14 @@
|
|
|
3994
3994
|
function getGemVersion() {
|
|
3995
3995
|
return document.querySelector('meta[name="profiler-version"]')?.content ?? "";
|
|
3996
3996
|
}
|
|
3997
|
+
function allocatedObjects(profile) {
|
|
3998
|
+
if (profile.allocated_objects != null) return profile.allocated_objects;
|
|
3999
|
+
return profile.memory != null ? Math.round(profile.memory / 40) : null;
|
|
4000
|
+
}
|
|
4001
|
+
function formatAllocations(count) {
|
|
4002
|
+
return count == null ? "-" : `${count.toLocaleString("en")} objects`;
|
|
4003
|
+
}
|
|
4004
|
+
var ALLOCATIONS_HINT = "Objects allocated by the whole process while this ran: on a multi-threaded server, it includes what other threads allocated meanwhile.";
|
|
3997
4005
|
|
|
3998
4006
|
// app/assets/typescript/profiler/components/dashboard/tabs/shared/utils.ts
|
|
3999
4007
|
function methodBadge(method) {
|
|
@@ -5556,10 +5564,6 @@
|
|
|
5556
5564
|
function formatTime(iso) {
|
|
5557
5565
|
return new Date(iso).toLocaleTimeString("en", { hour12: false });
|
|
5558
5566
|
}
|
|
5559
|
-
function formatMemory(bytes) {
|
|
5560
|
-
if (!bytes) return "-";
|
|
5561
|
-
return (bytes / 1024 / 1024).toFixed(2) + " MB";
|
|
5562
|
-
}
|
|
5563
5567
|
var PRESETS = [
|
|
5564
5568
|
{ key: "slow", label: "Slow" },
|
|
5565
5569
|
{ key: "many_queries", label: "Many queries" },
|
|
@@ -5797,8 +5801,8 @@
|
|
|
5797
5801
|
bv = b.duration;
|
|
5798
5802
|
break;
|
|
5799
5803
|
case "memory":
|
|
5800
|
-
av = a3
|
|
5801
|
-
bv = b
|
|
5804
|
+
av = allocatedObjects(a3) ?? 0;
|
|
5805
|
+
bv = allocatedObjects(b) ?? 0;
|
|
5802
5806
|
break;
|
|
5803
5807
|
case "status":
|
|
5804
5808
|
av = a3.status;
|
|
@@ -6043,8 +6047,8 @@
|
|
|
6043
6047
|
"Queries ",
|
|
6044
6048
|
sortIcon(httpSort.col, httpSort.dir, "queries")
|
|
6045
6049
|
] }),
|
|
6046
|
-
/* @__PURE__ */ u3("th", { class: `sortable${httpSort.col === "memory" ? " sortable--active" : ""}`, onClick: () => toggleHttpSort("memory"), children: [
|
|
6047
|
-
"
|
|
6050
|
+
/* @__PURE__ */ u3("th", { class: `sortable${httpSort.col === "memory" ? " sortable--active" : ""}`, onClick: () => toggleHttpSort("memory"), title: ALLOCATIONS_HINT, children: [
|
|
6051
|
+
"Allocations ",
|
|
6048
6052
|
sortIcon(httpSort.col, httpSort.dir, "memory")
|
|
6049
6053
|
] }),
|
|
6050
6054
|
/* @__PURE__ */ u3("th", { class: `sortable${httpSort.col === "status" ? " sortable--active" : ""}`, onClick: () => toggleHttpSort("status"), children: [
|
|
@@ -6066,7 +6070,7 @@
|
|
|
6066
6070
|
" ms"
|
|
6067
6071
|
] }) }),
|
|
6068
6072
|
/* @__PURE__ */ u3("td", { children: p3.collectors_data?.database?.total_queries ?? "\u2014" }),
|
|
6069
|
-
/* @__PURE__ */ u3("td", { children:
|
|
6073
|
+
/* @__PURE__ */ u3("td", { children: formatAllocations(allocatedObjects(p3)) }),
|
|
6070
6074
|
/* @__PURE__ */ u3("td", { children: /* @__PURE__ */ u3("span", { class: statusClass(p3.status), children: p3.status }) }),
|
|
6071
6075
|
/* @__PURE__ */ u3("td", { class: "profiler-text--xs profiler-text--mono profiler-text--muted", children: /* @__PURE__ */ u3("button", { class: "token-copy", onClick: () => copyToken(p3.token), title: "Copy full token", children: copiedToken === p3.token ? "\u2713" : p3.token.substring(0, 8) + "\u2026" }) }),
|
|
6072
6076
|
/* @__PURE__ */ u3("td", { children: /* @__PURE__ */ u3("button", { class: "btn-row-delete", onClick: () => deleteProfile2(p3.token), title: "Delete", children: "\xD7" }) })
|
|
@@ -6458,9 +6462,36 @@
|
|
|
6458
6462
|
parts.push(` '${url}'`);
|
|
6459
6463
|
return parts.join(" \\\n");
|
|
6460
6464
|
}
|
|
6465
|
+
function truncationNotes(profile, routeData) {
|
|
6466
|
+
const notes = [];
|
|
6467
|
+
const describe = (what, truncated, size, minimum) => {
|
|
6468
|
+
if (truncated) {
|
|
6469
|
+
const total = size != null ? `${minimum ? "at least " : ""}${size.toLocaleString("en")} bytes, ` : "";
|
|
6470
|
+
notes.push(`${what} body truncated: ${total}only the beginning was kept (max_captured_body_bytes).`);
|
|
6471
|
+
}
|
|
6472
|
+
};
|
|
6473
|
+
describe(
|
|
6474
|
+
"Request",
|
|
6475
|
+
profile.request_body_truncated ?? routeData.request_body_truncated,
|
|
6476
|
+
profile.request_body_size ?? routeData.request_body_size,
|
|
6477
|
+
profile.request_body_size_is_minimum ?? routeData.request_body_size_is_minimum
|
|
6478
|
+
);
|
|
6479
|
+
describe(
|
|
6480
|
+
"Response",
|
|
6481
|
+
profile.response_body_truncated ?? routeData.response_body_truncated,
|
|
6482
|
+
profile.response_body_size ?? routeData.response_body_size,
|
|
6483
|
+
profile.response_body_size_is_minimum ?? routeData.response_body_size_is_minimum
|
|
6484
|
+
);
|
|
6485
|
+
const releasedAfter = profile.collectors_released_after_seconds ?? routeData.collectors_released_after_seconds;
|
|
6486
|
+
if (releasedAfter != null) {
|
|
6487
|
+
notes.push(`Incomplete profile: the collectors of this stream were released after ${releasedAfter} s, before the server closed it; what it did later is missing.`);
|
|
6488
|
+
}
|
|
6489
|
+
return notes;
|
|
6490
|
+
}
|
|
6461
6491
|
function RequestTab({ profile }) {
|
|
6462
6492
|
const routeData = profile.collectors_data?.request ?? {};
|
|
6463
6493
|
const hasParams = profile.params && Object.keys(profile.params).length > 0;
|
|
6494
|
+
const notes = truncationNotes(profile, routeData);
|
|
6464
6495
|
return /* @__PURE__ */ u3("div", { class: "profiler-ajax-card profiler-ajax-card--success", style: "margin-bottom:8px;background:var(--profiler-bg-elevated);transform:translateX(3px);box-shadow:var(--profiler-shadow-sm);transition:none", children: [
|
|
6465
6496
|
/* @__PURE__ */ u3(
|
|
6466
6497
|
HttpCardHeader,
|
|
@@ -6480,6 +6511,7 @@
|
|
|
6480
6511
|
routeData.route_pattern
|
|
6481
6512
|
] })
|
|
6482
6513
|
] }),
|
|
6514
|
+
notes.map((note) => /* @__PURE__ */ u3("div", { class: "profiler-ajax-card__row profiler-text--xs profiler-text--warning", children: note }, note)),
|
|
6483
6515
|
/* @__PURE__ */ u3(
|
|
6484
6516
|
HttpReqRespDetail,
|
|
6485
6517
|
{
|
|
@@ -7381,7 +7413,9 @@
|
|
|
7381
7413
|
const [totalCount, setTotalCount] = d2(0);
|
|
7382
7414
|
const [fnSortKey, setFnSortKey] = d2("total_duration");
|
|
7383
7415
|
const [fnSortDir, setFnSortDir] = d2("desc");
|
|
7384
|
-
const [fnEnabled, setFnEnabled] = d2(
|
|
7416
|
+
const [fnEnabled, setFnEnabled] = d2(
|
|
7417
|
+
functionProfileData?.reason === "stackprof_missing" ? true : functionProfileData?.enabled ?? false
|
|
7418
|
+
);
|
|
7385
7419
|
const [fnMaxFrames, setFnMaxFrames] = d2(functionProfileData?.max_frames ?? 2e3);
|
|
7386
7420
|
const [fnMode, setFnMode] = d2(functionProfileData?.mode === "lite" ? "lite" : "full");
|
|
7387
7421
|
const [fnClock, setFnClock] = d2(functionProfileData?.clock ?? "wall");
|
|
@@ -7690,6 +7724,7 @@
|
|
|
7690
7724
|
const [hoveredFnName, setHoveredFnName] = d2(null);
|
|
7691
7725
|
const fnRendererRef = A2(null);
|
|
7692
7726
|
const hasData = enabled && data?.enabled && (data.functions?.length ?? 0) > 0;
|
|
7727
|
+
const stackprofMissing = data?.reason === "stackprof_missing" && mode === "lite";
|
|
7693
7728
|
const dataMode = data?.mode ?? "full";
|
|
7694
7729
|
const dataClock = data?.clock ?? "wall";
|
|
7695
7730
|
const isSampling = dataMode === "lite";
|
|
@@ -7782,7 +7817,7 @@
|
|
|
7782
7817
|
class: `profiler-fn-profiling__toggle${enabled ? " profiler-fn-profiling__toggle--active" : ""}`,
|
|
7783
7818
|
onClick: onToggle,
|
|
7784
7819
|
disabled: toggling,
|
|
7785
|
-
children: toggling ? "\u2026" : enabled ? "Enabled
|
|
7820
|
+
children: toggling ? "\u2026" : enabled ? "Enabled (click to disable)" : "Disabled (click to enable)"
|
|
7786
7821
|
}
|
|
7787
7822
|
)
|
|
7788
7823
|
] })
|
|
@@ -7791,12 +7826,25 @@
|
|
|
7791
7826
|
"Enable function profiling to see where your app spends time.",
|
|
7792
7827
|
" ",
|
|
7793
7828
|
/* @__PURE__ */ u3("strong", { children: "Lite" }),
|
|
7794
|
-
": 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.",
|
|
7795
7830
|
" ",
|
|
7796
7831
|
/* @__PURE__ */ u3("strong", { children: "Full" }),
|
|
7797
7832
|
": exhaustive TracePoint tracing with memory bytes \u2014 significant overhead."
|
|
7798
7833
|
] }),
|
|
7799
|
-
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." }),
|
|
7800
7848
|
hasData && /* @__PURE__ */ u3(k, { children: [
|
|
7801
7849
|
data.frame_cap_reached && /* @__PURE__ */ u3("div", { class: "profiler-fn-profiling__cap-warning", children: [
|
|
7802
7850
|
"\u26A0 Frame cap reached (",
|
|
@@ -8878,12 +8926,9 @@
|
|
|
8878
8926
|
"Status: ",
|
|
8879
8927
|
/* @__PURE__ */ u3("strong", { children: profile.status })
|
|
8880
8928
|
] }),
|
|
8881
|
-
profile
|
|
8882
|
-
"
|
|
8883
|
-
/* @__PURE__ */ u3("strong", { children:
|
|
8884
|
-
(profile.memory / 1024 / 1024).toFixed(2),
|
|
8885
|
-
" MB"
|
|
8886
|
-
] })
|
|
8929
|
+
allocatedObjects(profile) != null && /* @__PURE__ */ u3("span", { title: ALLOCATIONS_HINT, children: [
|
|
8930
|
+
"Allocations: ",
|
|
8931
|
+
/* @__PURE__ */ u3("strong", { children: formatAllocations(allocatedObjects(profile)) })
|
|
8887
8932
|
] }),
|
|
8888
8933
|
/* @__PURE__ */ u3("span", { style: "color:var(--profiler-text-muted)", children: new Date(profile.started_at).toLocaleString("en", { hour12: false, month: "short", day: "numeric", hour: "2-digit", minute: "2-digit", second: "2-digit" }) }),
|
|
8889
8934
|
profile.gem_version && /* @__PURE__ */ u3("span", { class: "profiler-version-badge", children: [
|
|
@@ -9038,12 +9083,9 @@
|
|
|
9038
9083
|
"Status: ",
|
|
9039
9084
|
/* @__PURE__ */ u3("strong", { children: /* @__PURE__ */ u3("span", { class: `badge-${isFailed ? "error" : "success"}`, children: isFailed ? "Failed" : "Completed" }) })
|
|
9040
9085
|
] }),
|
|
9041
|
-
profile
|
|
9042
|
-
"
|
|
9043
|
-
/* @__PURE__ */ u3("strong", { children:
|
|
9044
|
-
(profile.memory / 1024 / 1024).toFixed(2),
|
|
9045
|
-
" MB"
|
|
9046
|
-
] })
|
|
9086
|
+
allocatedObjects(profile) != null && /* @__PURE__ */ u3("span", { title: ALLOCATIONS_HINT, children: [
|
|
9087
|
+
"Allocations: ",
|
|
9088
|
+
/* @__PURE__ */ u3("strong", { children: formatAllocations(allocatedObjects(profile)) })
|
|
9047
9089
|
] }),
|
|
9048
9090
|
profile.gem_version && /* @__PURE__ */ u3("span", { class: "profiler-version-badge", children: [
|
|
9049
9091
|
"v",
|
|
@@ -9170,12 +9212,9 @@
|
|
|
9170
9212
|
"Status: ",
|
|
9171
9213
|
/* @__PURE__ */ u3("strong", { children: /* @__PURE__ */ u3("span", { class: `badge-${isFailed ? "error" : "success"}`, children: isFailed ? "Error" : "OK" }) })
|
|
9172
9214
|
] }),
|
|
9173
|
-
profile
|
|
9174
|
-
"
|
|
9175
|
-
/* @__PURE__ */ u3("strong", { children:
|
|
9176
|
-
(profile.memory / 1024 / 1024).toFixed(2),
|
|
9177
|
-
" MB"
|
|
9178
|
-
] })
|
|
9215
|
+
allocatedObjects(profile) != null && /* @__PURE__ */ u3("span", { title: ALLOCATIONS_HINT, children: [
|
|
9216
|
+
"Allocations: ",
|
|
9217
|
+
/* @__PURE__ */ u3("strong", { children: formatAllocations(allocatedObjects(profile)) })
|
|
9179
9218
|
] }),
|
|
9180
9219
|
profile.gem_version && /* @__PURE__ */ u3("span", { class: "profiler-version-badge", children: [
|
|
9181
9220
|
"v",
|
|
@@ -9300,12 +9339,9 @@
|
|
|
9300
9339
|
"Status: ",
|
|
9301
9340
|
/* @__PURE__ */ u3("strong", { children: /* @__PURE__ */ u3("span", { class: `badge-${isFailed ? "error" : testStatus === "pending" ? "warning" : "success"}`, children: testStatus === "failed" ? "\u2717 Failed" : testStatus === "pending" ? "\u23F8 Pending" : "\u2713 Passed" }) })
|
|
9302
9341
|
] }),
|
|
9303
|
-
profile
|
|
9304
|
-
"
|
|
9305
|
-
/* @__PURE__ */ u3("strong", { children:
|
|
9306
|
-
(profile.memory / 1024 / 1024).toFixed(2),
|
|
9307
|
-
" MB"
|
|
9308
|
-
] })
|
|
9342
|
+
allocatedObjects(profile) != null && /* @__PURE__ */ u3("span", { title: ALLOCATIONS_HINT, children: [
|
|
9343
|
+
"Allocations: ",
|
|
9344
|
+
/* @__PURE__ */ u3("strong", { children: formatAllocations(allocatedObjects(profile)) })
|
|
9309
9345
|
] })
|
|
9310
9346
|
] }),
|
|
9311
9347
|
testData?.test_file && /* @__PURE__ */ u3("div", { class: "profiler-text--xs profiler-text--muted profiler-mt-2", children: [
|
|
@@ -29,6 +29,8 @@ module Profiler
|
|
|
29
29
|
|
|
30
30
|
# Recalculate AJAX collector data (since AJAX requests happen after page load)
|
|
31
31
|
recalculate_ajax_data(profile)
|
|
32
|
+
# The route table and ENV are the process's, not stored in the profile
|
|
33
|
+
Profiler::ProcessSnapshot.hydrate(profile)
|
|
32
34
|
|
|
33
35
|
render json: profile.to_h.merge(
|
|
34
36
|
child_jobs: build_child_jobs(profile),
|
|
@@ -16,6 +16,8 @@ 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,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),
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Profiler
|
|
4
|
+
# What every profile reports as allocated_objects: the number of objects Ruby allocated
|
|
5
|
+
# between two readings. GC.stat has no byte count of allocations (no :total_allocated_size on
|
|
6
|
+
# the supported Rubies), so none is reported.
|
|
7
|
+
#
|
|
8
|
+
# The counter belongs to the process, not to the thread: on a multi-threaded server, or with
|
|
9
|
+
# jobs running alongside, the figure includes what the other threads allocated meanwhile.
|
|
10
|
+
module AllocationCounter
|
|
11
|
+
# The bytes per object that profiles saved by earlier versions multiplied the count by, as
|
|
12
|
+
# `memory`: kept to read them back, and to keep writing that field for older readers.
|
|
13
|
+
LEGACY_BYTES_PER_OBJECT = 40
|
|
14
|
+
|
|
15
|
+
module_function
|
|
16
|
+
|
|
17
|
+
def current
|
|
18
|
+
GC.stat(:total_allocated_objects)
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
@@ -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
|
|
@@ -71,6 +72,36 @@ module Profiler
|
|
|
71
72
|
# Override in subclasses to collect data
|
|
72
73
|
end
|
|
73
74
|
|
|
75
|
+
# Whether collect reads nothing from the request's thread (thread-local slots, the
|
|
76
|
+
# locale): only then can a streamed response keep the collector subscribed until the
|
|
77
|
+
# server closes its body, and collect it from whichever thread closes it. The default
|
|
78
|
+
# is the safe answer: collected on the request's thread when the application returns.
|
|
79
|
+
def collect_from_any_thread?
|
|
80
|
+
false
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# Gives back the thread-local slots subscribe took over, and nothing else: a streamed
|
|
84
|
+
# response does this on the request's thread when the application returns, and keeps the
|
|
85
|
+
# notification subscriptions until its body is closed. Idempotent, like unsubscribe.
|
|
86
|
+
def release_thread_slots
|
|
87
|
+
restore_thread_slots
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# Puts the slots subscribe claimed back, for a while, on the thread (or fiber) that
|
|
91
|
+
# iterates a streamed body: the logs, outbound HTTP calls and measures it makes belong to
|
|
92
|
+
# this profile. Returns what the slots held there, for return_thread_slots.
|
|
93
|
+
def lend_thread_slots
|
|
94
|
+
(@thread_slot_values || {}).to_h do |key, value|
|
|
95
|
+
previous = Thread.current[key]
|
|
96
|
+
Thread.current[key] = value
|
|
97
|
+
[key, previous]
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def return_thread_slots(previous)
|
|
102
|
+
previous&.each { |key, value| Thread.current[key] = value }
|
|
103
|
+
end
|
|
104
|
+
|
|
74
105
|
def toolbar_summary
|
|
75
106
|
# Override in subclasses to provide summary for toolbar
|
|
76
107
|
""
|
|
@@ -104,6 +135,7 @@ module Profiler
|
|
|
104
135
|
def claim_thread_slot(key, value)
|
|
105
136
|
@claimed_thread_slots ||= {}
|
|
106
137
|
@claimed_thread_slots[key] = Thread.current[key] unless @claimed_thread_slots.key?(key)
|
|
138
|
+
(@thread_slot_values ||= {})[key] = value
|
|
107
139
|
Thread.current[key] = value
|
|
108
140
|
end
|
|
109
141
|
|
|
@@ -112,10 +144,14 @@ module Profiler
|
|
|
112
144
|
@claimed_thread_slots = nil
|
|
113
145
|
end
|
|
114
146
|
|
|
115
|
-
|
|
116
|
-
|
|
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
|
|
117
152
|
|
|
118
|
-
|
|
153
|
+
def unsubscribe_notifications(subscriptions)
|
|
154
|
+
subscriptions.each { |handle| ScopedNotifications.unsubscribe(handle) }
|
|
119
155
|
subscriptions.clear
|
|
120
156
|
end
|
|
121
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],
|
|
@@ -82,6 +82,11 @@ module Profiler
|
|
|
82
82
|
store_data(data)
|
|
83
83
|
end
|
|
84
84
|
|
|
85
|
+
# Collect reads only what the collector gathered itself.
|
|
86
|
+
def collect_from_any_thread?
|
|
87
|
+
true
|
|
88
|
+
end
|
|
89
|
+
|
|
85
90
|
def unsubscribe
|
|
86
91
|
unsubscribe_notifications(@subscriptions)
|
|
87
92
|
end
|