rails-profiler 0.31.1 → 0.31.2
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 +59 -0
- data/app/assets/builds/profiler-toolbar.js +13 -6
- data/app/assets/builds/profiler.js +53 -33
- data/lib/profiler/allocation_counter.rb +21 -0
- data/lib/profiler/collectors/base_collector.rb +31 -0
- data/lib/profiler/collectors/cache_collector.rb +5 -0
- data/lib/profiler/collectors/database_collector.rb +5 -0
- data/lib/profiler/collectors/exception_collector.rb +5 -0
- data/lib/profiler/collectors/flamegraph_collector.rb +5 -0
- data/lib/profiler/collectors/http_collector.rb +5 -0
- data/lib/profiler/collectors/log_collector.rb +5 -0
- data/lib/profiler/collectors/request_collector.rb +14 -11
- data/lib/profiler/collectors/view_collector.rb +5 -0
- data/lib/profiler/configuration.rb +22 -3
- data/lib/profiler/console_profiler.rb +2 -17
- data/lib/profiler/job_profiler.rb +2 -15
- data/lib/profiler/mcp/resources/recent_requests.rb +2 -0
- data/lib/profiler/mcp/tools/get_profile_detail.rb +13 -1
- 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/redaction.rb +10 -0
- data/lib/profiler/test_profiler.rb +2 -17
- data/lib/profiler/version.rb +1 -1
- data/lib/profiler.rb +1 -0
- metadata +8 -5
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: e53e69cbdde6c7e775444a738e0069cef86994f505f77d86673a15e25adc768d
|
|
4
|
+
data.tar.gz: c134819ed6bff52aa1d5ad7309b752e1ed6c6a6e1803510d4387462f1607b73a
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 1a277a3d46f4967dc8d875957e95b4d2df118c5a7a96dfcdac83e4a932c4aa4fbd73e1498ef5250bf714e96b5842d99814b617e7f530befcef2eac07984e571c
|
|
7
|
+
data.tar.gz: 978b1b85623419ca8fd8afe3c9550f24be03dbd5e1a8ef02f8ded99270cff218f2e2d68622b66b9edc7def30a46cb574c8b06bdb7b3cdb6f3502b91a919d7941
|
data/CHANGELOG.md
CHANGED
|
@@ -19,6 +19,65 @@ every commit of every tag interval is accounted for one way or the other.
|
|
|
19
19
|
|
|
20
20
|
## [Unreleased]
|
|
21
21
|
|
|
22
|
+
## [0.31.2] - 2026-10-05
|
|
23
|
+
|
|
24
|
+
<!-- stamped -->
|
|
25
|
+
|
|
26
|
+
### Fixed
|
|
27
|
+
|
|
28
|
+
- **Middleware:** Stream responses through instead of buffering them. The profiler read every
|
|
29
|
+
response body to the end before handing it to the server, so an `ActionController::Live`,
|
|
30
|
+
`response.stream` or enumerator response, or a server-sent events stream, left only once it was
|
|
31
|
+
over (a stream of 5 chunks 200 ms apart sent its first chunk after 1 s instead of 0.2 s, and an
|
|
32
|
+
endless event stream never answered), and a file sent with `send_file` lost `to_path`, so the
|
|
33
|
+
server could no longer send it by its path. A body that does not answer `to_ary`, a
|
|
34
|
+
`text/event-stream` or a body sent by its path now goes to the server chunk by chunk, keeps
|
|
35
|
+
`to_path` and `close`, and its profile is saved when the server closes it, also when the client
|
|
36
|
+
went away, with the body seen so far and the full duration. The collectors that only gather
|
|
37
|
+
what notifications and the logger hand them (SQL, views, cache, exceptions, timeline, logs,
|
|
38
|
+
outbound HTTP) stay subscribed until then, so the queries and views of a `render stream: true`
|
|
39
|
+
page or of an enumerator are still recorded; dumps, mailers, I18n and function profiling are
|
|
40
|
+
read when the application returns. While the server iterates the body, the log lines, outbound
|
|
41
|
+
HTTP calls and `Profiler.measure` blocks it runs are recorded as well. Until it is saved, a
|
|
42
|
+
streamed profile is not listed and its token answers 404: an endless event stream is never
|
|
43
|
+
listed. A body the server never closes is finished when the fiber that started it starts its
|
|
44
|
+
next profiled request, never cutting a stream that a fiber-based server such as Falcon runs
|
|
45
|
+
alongside, and its subscriptions are dropped after 5 minutes at the latest, which the profile
|
|
46
|
+
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`
|
|
47
|
+
controller renders whole still get the toolbar. The toolbar is only injected in pages returned
|
|
48
|
+
whole, so a `render stream: true` page has none.
|
|
49
|
+
- **Middleware:** Stop turning an error raised while a response body is iterated (a stream that
|
|
50
|
+
fails part way, a file sent with `send_file` that disappeared) into an empty `200`. The error now
|
|
51
|
+
reaches the server, as it does without the profiler, and the profile keeps it.
|
|
52
|
+
- **Middleware:** Keep the request and response bodies of a profile to the first
|
|
53
|
+
`config.max_captured_body_bytes` (256 KB by default) instead of copying them whole: a 2 MB upload
|
|
54
|
+
took 2.8 MB in its profile, plus transient copies. `rack.input` is read only up to that size, then
|
|
55
|
+
rewound for the application, and an input that cannot be rewound is no longer read at all. The
|
|
56
|
+
Request tab and the MCP profile detail say when a body was cut, with its whole size, or "at
|
|
57
|
+
least" that size when it is not known; the profile carries `request_body_truncated`,
|
|
58
|
+
`request_body_size`, `response_body_truncated`, `response_body_size` and their
|
|
59
|
+
`*_size_is_minimum`. Only the raw bodies are capped: the parsed `params` and the
|
|
60
|
+
`Parameters:` log line keep their full size, so a 5 MB JSON request still takes about 15 MB in
|
|
61
|
+
its profile. To keep whole bodies, as before, set `config.max_captured_body_bytes = nil`.
|
|
62
|
+
- **Profiles:** Report the number of objects allocated (`allocated_objects`) instead of a figure
|
|
63
|
+
shown as memory. `GC.stat` has no `:total_allocated_size` on the supported Rubies, so `memory`
|
|
64
|
+
was always that number times 40, never a measured byte count. The toolbar, the dashboards, the
|
|
65
|
+
profile list and the MCP tools show "Allocations". The counter belongs to the process: on a
|
|
66
|
+
multi-threaded server it includes what other threads allocated meanwhile. Nothing to change on
|
|
67
|
+
upgrade: the API and the stored profiles still carry `memory` (deprecated, the count times 40),
|
|
68
|
+
and profiles saved by earlier versions are read back with their count. The option
|
|
69
|
+
`memory_warning_threshold`, which nothing ever compared with, is deprecated for
|
|
70
|
+
`allocated_objects_warning_threshold`, a number of objects, still without effect; setting the
|
|
71
|
+
old one warns and converts it.
|
|
72
|
+
- **Toolbar:** Leave a response already framed with `Transfer-Encoding` untouched. Under Rails
|
|
73
|
+
7.0, a `render stream: true` page arrives chunked, and the toolbar was put among the chunks,
|
|
74
|
+
which broke the framing: the browser got an empty or cut page.
|
|
75
|
+
- **Toolbar:** Correct the `Content-Length` of a page the toolbar is injected into. It kept the
|
|
76
|
+
length of the page without the toolbar, so a server that honours it cut the page short.
|
|
77
|
+
- **Toolbar:** Inject the toolbar into a page whose headers are a plain `Hash` with a lower-case
|
|
78
|
+
`content-type`, the form Rack 3 requires; only `Content-Type` was looked up. Every response
|
|
79
|
+
header the profiler reads or writes now ignores the case.
|
|
80
|
+
|
|
22
81
|
## [0.31.1] - 2026-10-05
|
|
23
82
|
|
|
24
83
|
<!-- 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
|
{
|
|
@@ -8878,12 +8910,9 @@
|
|
|
8878
8910
|
"Status: ",
|
|
8879
8911
|
/* @__PURE__ */ u3("strong", { children: profile.status })
|
|
8880
8912
|
] }),
|
|
8881
|
-
profile
|
|
8882
|
-
"
|
|
8883
|
-
/* @__PURE__ */ u3("strong", { children:
|
|
8884
|
-
(profile.memory / 1024 / 1024).toFixed(2),
|
|
8885
|
-
" MB"
|
|
8886
|
-
] })
|
|
8913
|
+
allocatedObjects(profile) != null && /* @__PURE__ */ u3("span", { title: ALLOCATIONS_HINT, children: [
|
|
8914
|
+
"Allocations: ",
|
|
8915
|
+
/* @__PURE__ */ u3("strong", { children: formatAllocations(allocatedObjects(profile)) })
|
|
8887
8916
|
] }),
|
|
8888
8917
|
/* @__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
8918
|
profile.gem_version && /* @__PURE__ */ u3("span", { class: "profiler-version-badge", children: [
|
|
@@ -9038,12 +9067,9 @@
|
|
|
9038
9067
|
"Status: ",
|
|
9039
9068
|
/* @__PURE__ */ u3("strong", { children: /* @__PURE__ */ u3("span", { class: `badge-${isFailed ? "error" : "success"}`, children: isFailed ? "Failed" : "Completed" }) })
|
|
9040
9069
|
] }),
|
|
9041
|
-
profile
|
|
9042
|
-
"
|
|
9043
|
-
/* @__PURE__ */ u3("strong", { children:
|
|
9044
|
-
(profile.memory / 1024 / 1024).toFixed(2),
|
|
9045
|
-
" MB"
|
|
9046
|
-
] })
|
|
9070
|
+
allocatedObjects(profile) != null && /* @__PURE__ */ u3("span", { title: ALLOCATIONS_HINT, children: [
|
|
9071
|
+
"Allocations: ",
|
|
9072
|
+
/* @__PURE__ */ u3("strong", { children: formatAllocations(allocatedObjects(profile)) })
|
|
9047
9073
|
] }),
|
|
9048
9074
|
profile.gem_version && /* @__PURE__ */ u3("span", { class: "profiler-version-badge", children: [
|
|
9049
9075
|
"v",
|
|
@@ -9170,12 +9196,9 @@
|
|
|
9170
9196
|
"Status: ",
|
|
9171
9197
|
/* @__PURE__ */ u3("strong", { children: /* @__PURE__ */ u3("span", { class: `badge-${isFailed ? "error" : "success"}`, children: isFailed ? "Error" : "OK" }) })
|
|
9172
9198
|
] }),
|
|
9173
|
-
profile
|
|
9174
|
-
"
|
|
9175
|
-
/* @__PURE__ */ u3("strong", { children:
|
|
9176
|
-
(profile.memory / 1024 / 1024).toFixed(2),
|
|
9177
|
-
" MB"
|
|
9178
|
-
] })
|
|
9199
|
+
allocatedObjects(profile) != null && /* @__PURE__ */ u3("span", { title: ALLOCATIONS_HINT, children: [
|
|
9200
|
+
"Allocations: ",
|
|
9201
|
+
/* @__PURE__ */ u3("strong", { children: formatAllocations(allocatedObjects(profile)) })
|
|
9179
9202
|
] }),
|
|
9180
9203
|
profile.gem_version && /* @__PURE__ */ u3("span", { class: "profiler-version-badge", children: [
|
|
9181
9204
|
"v",
|
|
@@ -9300,12 +9323,9 @@
|
|
|
9300
9323
|
"Status: ",
|
|
9301
9324
|
/* @__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
9325
|
] }),
|
|
9303
|
-
profile
|
|
9304
|
-
"
|
|
9305
|
-
/* @__PURE__ */ u3("strong", { children:
|
|
9306
|
-
(profile.memory / 1024 / 1024).toFixed(2),
|
|
9307
|
-
" MB"
|
|
9308
|
-
] })
|
|
9326
|
+
allocatedObjects(profile) != null && /* @__PURE__ */ u3("span", { title: ALLOCATIONS_HINT, children: [
|
|
9327
|
+
"Allocations: ",
|
|
9328
|
+
/* @__PURE__ */ u3("strong", { children: formatAllocations(allocatedObjects(profile)) })
|
|
9309
9329
|
] })
|
|
9310
9330
|
] }),
|
|
9311
9331
|
testData?.test_file && /* @__PURE__ */ u3("div", { class: "profiler-text--xs profiler-text--muted profiler-mt-2", children: [
|
|
@@ -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
|
|
@@ -71,6 +71,36 @@ module Profiler
|
|
|
71
71
|
# Override in subclasses to collect data
|
|
72
72
|
end
|
|
73
73
|
|
|
74
|
+
# Whether collect reads nothing from the request's thread (thread-local slots, the
|
|
75
|
+
# locale): only then can a streamed response keep the collector subscribed until the
|
|
76
|
+
# server closes its body, and collect it from whichever thread closes it. The default
|
|
77
|
+
# is the safe answer: collected on the request's thread when the application returns.
|
|
78
|
+
def collect_from_any_thread?
|
|
79
|
+
false
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# Gives back the thread-local slots subscribe took over, and nothing else: a streamed
|
|
83
|
+
# response does this on the request's thread when the application returns, and keeps the
|
|
84
|
+
# notification subscriptions until its body is closed. Idempotent, like unsubscribe.
|
|
85
|
+
def release_thread_slots
|
|
86
|
+
restore_thread_slots
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# Puts the slots subscribe claimed back, for a while, on the thread (or fiber) that
|
|
90
|
+
# iterates a streamed body: the logs, outbound HTTP calls and measures it makes belong to
|
|
91
|
+
# this profile. Returns what the slots held there, for return_thread_slots.
|
|
92
|
+
def lend_thread_slots
|
|
93
|
+
(@thread_slot_values || {}).to_h do |key, value|
|
|
94
|
+
previous = Thread.current[key]
|
|
95
|
+
Thread.current[key] = value
|
|
96
|
+
[key, previous]
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def return_thread_slots(previous)
|
|
101
|
+
previous&.each { |key, value| Thread.current[key] = value }
|
|
102
|
+
end
|
|
103
|
+
|
|
74
104
|
def toolbar_summary
|
|
75
105
|
# Override in subclasses to provide summary for toolbar
|
|
76
106
|
""
|
|
@@ -104,6 +134,7 @@ module Profiler
|
|
|
104
134
|
def claim_thread_slot(key, value)
|
|
105
135
|
@claimed_thread_slots ||= {}
|
|
106
136
|
@claimed_thread_slots[key] = Thread.current[key] unless @claimed_thread_slots.key?(key)
|
|
137
|
+
(@thread_slot_values ||= {})[key] = value
|
|
107
138
|
Thread.current[key] = value
|
|
108
139
|
end
|
|
109
140
|
|
|
@@ -39,6 +39,11 @@ module Profiler
|
|
|
39
39
|
claim_thread_slot(:profiler_http_collector, self)
|
|
40
40
|
end
|
|
41
41
|
|
|
42
|
+
# Collect reads only what the collector gathered itself.
|
|
43
|
+
def collect_from_any_thread?
|
|
44
|
+
true
|
|
45
|
+
end
|
|
46
|
+
|
|
42
47
|
# The Net::HTTP patch installed by subscribe stays: it is installed once per process and
|
|
43
48
|
# records nothing when no collector holds the thread-local slot.
|
|
44
49
|
def unsubscribe
|
|
@@ -93,6 +93,11 @@ module Profiler
|
|
|
93
93
|
warn "LogCollector#subscribe failed: #{e.message}"
|
|
94
94
|
end
|
|
95
95
|
|
|
96
|
+
# Collect reads only what the collector gathered itself.
|
|
97
|
+
def collect_from_any_thread?
|
|
98
|
+
true
|
|
99
|
+
end
|
|
100
|
+
|
|
96
101
|
def unsubscribe
|
|
97
102
|
if @capture_logger
|
|
98
103
|
@broadcaster.stop_broadcasting_to(@capture_logger) if @broadcaster.respond_to?(:stop_broadcasting_to)
|
|
@@ -30,7 +30,8 @@ module Profiler
|
|
|
30
30
|
method: @profile.method,
|
|
31
31
|
status: @profile.status,
|
|
32
32
|
duration: @profile.duration,
|
|
33
|
-
|
|
33
|
+
allocated_objects: @profile.allocated_objects,
|
|
34
|
+
memory: @profile.memory, # deprecated, see Profile#memory
|
|
34
35
|
params: @profile.params,
|
|
35
36
|
headers: @profile.headers,
|
|
36
37
|
response_headers: @profile.response_headers,
|
|
@@ -38,6 +39,14 @@ module Profiler
|
|
|
38
39
|
request_body_encoding: @profile.request_body_encoding,
|
|
39
40
|
response_body: @profile.response_body,
|
|
40
41
|
response_body_encoding: @profile.response_body_encoding,
|
|
42
|
+
request_body_size: @profile.request_body_size,
|
|
43
|
+
request_body_truncated: @profile.request_body_truncated || false,
|
|
44
|
+
request_body_size_is_minimum: @profile.request_body_size_is_minimum || false,
|
|
45
|
+
response_body_size: @profile.response_body_size,
|
|
46
|
+
response_body_truncated: @profile.response_body_truncated || false,
|
|
47
|
+
response_body_size_is_minimum: @profile.response_body_size_is_minimum || false,
|
|
48
|
+
# A streamed response whose collectors were released before its body was closed.
|
|
49
|
+
collectors_released_after_seconds: @profile.collectors_released_after_seconds,
|
|
41
50
|
started_at: @profile.started_at&.iso8601,
|
|
42
51
|
finished_at: @profile.finished_at&.iso8601
|
|
43
52
|
}
|
|
@@ -59,7 +68,7 @@ module Profiler
|
|
|
59
68
|
text: "#{@profile.method} #{@profile.status}",
|
|
60
69
|
color: status_color,
|
|
61
70
|
duration: @profile.duration,
|
|
62
|
-
|
|
71
|
+
allocated_objects: format_allocations(@profile.allocated_objects)
|
|
63
72
|
}
|
|
64
73
|
end
|
|
65
74
|
|
|
@@ -98,16 +107,10 @@ module Profiler
|
|
|
98
107
|
{}
|
|
99
108
|
end
|
|
100
109
|
|
|
101
|
-
def
|
|
102
|
-
return "0
|
|
110
|
+
def format_allocations(count)
|
|
111
|
+
return "0 objects" unless count
|
|
103
112
|
|
|
104
|
-
|
|
105
|
-
"#{bytes} B"
|
|
106
|
-
elsif bytes < 1024 * 1024
|
|
107
|
-
"#{(bytes / 1024.0).round(2)} KB"
|
|
108
|
-
else
|
|
109
|
-
"#{(bytes / 1024.0 / 1024.0).round(2)} MB"
|
|
110
|
-
end
|
|
113
|
+
"#{count.to_s.reverse.scan(/\d{1,3}/).join(",").reverse} objects"
|
|
111
114
|
end
|
|
112
115
|
end
|
|
113
116
|
end
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
require "pathname"
|
|
4
4
|
require_relative "local_request"
|
|
5
|
+
require_relative "allocation_counter"
|
|
5
6
|
|
|
6
7
|
module Profiler
|
|
7
8
|
# Header the profiler's own clients send on every request. A page on another origin cannot
|
|
@@ -32,7 +33,7 @@ module Profiler
|
|
|
32
33
|
|
|
33
34
|
attr_accessor :storage_options, :collectors,
|
|
34
35
|
:skip_paths, :slow_query_threshold, :max_queries_warning,
|
|
35
|
-
:track_memory, :
|
|
36
|
+
:track_memory, :allocated_objects_warning_threshold,
|
|
36
37
|
:mcp_enabled, :mcp_transport, :mcp_port,
|
|
37
38
|
:authorization_mode, :max_profiles, :extension_cors_enabled,
|
|
38
39
|
:cors_allowed_origins, :api_forgery_protection, :frame_ancestors,
|
|
@@ -43,7 +44,7 @@ module Profiler
|
|
|
43
44
|
:apply_env_overrides_when_disabled, :restrict_storage_permissions,
|
|
44
45
|
:test_runner_allow_undiscovered_files,
|
|
45
46
|
:track_mailers, :capture_mail_body, :sanitize_mailer_recipients, :mailer_skip_actions,
|
|
46
|
-
:compress_bodies, :compress_body_threshold,
|
|
47
|
+
:compress_bodies, :compress_body_threshold, :max_captured_body_bytes,
|
|
47
48
|
:redact_sensitive_data, :filter_parameters, :env_allowlist,
|
|
48
49
|
:name, :master_url, :self_url,
|
|
49
50
|
:cluster_heartbeat_interval, :cluster_offline_threshold,
|
|
@@ -62,7 +63,9 @@ module Profiler
|
|
|
62
63
|
@slow_query_threshold = 100 # milliseconds
|
|
63
64
|
@max_queries_warning = 50
|
|
64
65
|
@track_memory = true
|
|
65
|
-
|
|
66
|
+
# Not compared with anything yet. The default is the former 100 MB memory threshold read
|
|
67
|
+
# as the objects it stood for.
|
|
68
|
+
@allocated_objects_warning_threshold = 100 * 1024 * 1024 / AllocationCounter::LEGACY_BYTES_PER_OBJECT
|
|
66
69
|
@mcp_enabled = false
|
|
67
70
|
@mcp_transport = :stdio
|
|
68
71
|
@mcp_port = 3001
|
|
@@ -95,6 +98,9 @@ module Profiler
|
|
|
95
98
|
@mailer_skip_actions = []
|
|
96
99
|
@compress_bodies = true
|
|
97
100
|
@compress_body_threshold = 10 * 1024 # 10 KB
|
|
101
|
+
# The request and response bodies kept in a profile stop at this many bytes; the
|
|
102
|
+
# application still reads and sends all of them. nil keeps whole bodies.
|
|
103
|
+
@max_captured_body_bytes = 256 * 1024
|
|
98
104
|
@redact_sensitive_data = true
|
|
99
105
|
@filter_parameters = DEFAULT_FILTER_PARAMETERS.dup
|
|
100
106
|
@env_allowlist = DEFAULT_ENV_ALLOWLIST.dup
|
|
@@ -126,6 +132,19 @@ module Profiler
|
|
|
126
132
|
|
|
127
133
|
attr_reader :cluster_allowed_slave_urls
|
|
128
134
|
|
|
135
|
+
# Deprecated: the figure it was meant for was never a byte count (see AllocationCounter).
|
|
136
|
+
# Read and written as allocated_objects_warning_threshold times the bytes per object the
|
|
137
|
+
# former figure used.
|
|
138
|
+
def memory_warning_threshold
|
|
139
|
+
@allocated_objects_warning_threshold&.*(AllocationCounter::LEGACY_BYTES_PER_OBJECT)
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def memory_warning_threshold=(bytes)
|
|
143
|
+
warn "[Profiler] memory_warning_threshold is deprecated, set allocated_objects_warning_threshold " \
|
|
144
|
+
"(a number of objects) instead"
|
|
145
|
+
@allocated_objects_warning_threshold = bytes && bytes / AllocationCounter::LEGACY_BYTES_PER_OBJECT
|
|
146
|
+
end
|
|
147
|
+
|
|
129
148
|
# :any, or a list of slave URLs and anchored Regexp patterns (see Profiler::Cluster::Security).
|
|
130
149
|
# A Regexp that is not anchored with \A and \z is refused here, at boot, rather than ignored.
|
|
131
150
|
def cluster_allowed_slave_urls=(value)
|
|
@@ -48,7 +48,7 @@ module Profiler
|
|
|
48
48
|
|
|
49
49
|
exception_collector = collectors.find { |c| c.is_a?(Collectors::ExceptionCollector) }
|
|
50
50
|
|
|
51
|
-
|
|
51
|
+
allocations_before = AllocationCounter.current if Profiler.configuration.track_memory
|
|
52
52
|
|
|
53
53
|
console_status = "completed"
|
|
54
54
|
|
|
@@ -66,7 +66,7 @@ module Profiler
|
|
|
66
66
|
ensure
|
|
67
67
|
Profiler::CurrentContext.token = previous_token
|
|
68
68
|
if Profiler.configuration.track_memory
|
|
69
|
-
profile.
|
|
69
|
+
profile.allocated_objects = AllocationCounter.current - allocations_before
|
|
70
70
|
end
|
|
71
71
|
|
|
72
72
|
profile.finish(console_status == "completed" ? 200 : 500)
|
|
@@ -86,20 +86,5 @@ module Profiler
|
|
|
86
86
|
# After collect, and also when collect or the storage failed.
|
|
87
87
|
Collectors::Lifecycle.release_all(collectors)
|
|
88
88
|
end
|
|
89
|
-
|
|
90
|
-
private
|
|
91
|
-
|
|
92
|
-
def current_memory
|
|
93
|
-
return 0 unless defined?(GC.stat)
|
|
94
|
-
|
|
95
|
-
stats = GC.stat
|
|
96
|
-
if stats.key?(:total_allocated_size)
|
|
97
|
-
stats[:total_allocated_size]
|
|
98
|
-
elsif stats.key?(:total_allocated_objects)
|
|
99
|
-
stats[:total_allocated_objects] * 40
|
|
100
|
-
else
|
|
101
|
-
0
|
|
102
|
-
end
|
|
103
|
-
end
|
|
104
89
|
end
|
|
105
90
|
end
|
|
@@ -71,7 +71,7 @@ module Profiler
|
|
|
71
71
|
|
|
72
72
|
exception_collector = collectors.find { |c| c.is_a?(Collectors::ExceptionCollector) }
|
|
73
73
|
|
|
74
|
-
|
|
74
|
+
allocations_before = AllocationCounter.current if Profiler.configuration.track_memory
|
|
75
75
|
|
|
76
76
|
job_status = "completed"
|
|
77
77
|
error_message = nil
|
|
@@ -92,7 +92,7 @@ module Profiler
|
|
|
92
92
|
Thread.current[:profiler_current_job_class] = previous_job_class
|
|
93
93
|
Profiler::CurrentContext.token = previous_token
|
|
94
94
|
if Profiler.configuration.track_memory
|
|
95
|
-
profile.
|
|
95
|
+
profile.allocated_objects = AllocationCounter.current - allocations_before
|
|
96
96
|
end
|
|
97
97
|
|
|
98
98
|
job_collector.update_status(job_status, error_message)
|
|
@@ -131,18 +131,5 @@ module Profiler
|
|
|
131
131
|
arg.to_s
|
|
132
132
|
end
|
|
133
133
|
end
|
|
134
|
-
|
|
135
|
-
def current_memory
|
|
136
|
-
return 0 unless defined?(GC.stat)
|
|
137
|
-
|
|
138
|
-
stats = GC.stat
|
|
139
|
-
if stats.key?(:total_allocated_size)
|
|
140
|
-
stats[:total_allocated_size]
|
|
141
|
-
elsif stats.key?(:total_allocated_objects)
|
|
142
|
-
stats[:total_allocated_objects] * 40
|
|
143
|
-
else
|
|
144
|
-
0
|
|
145
|
-
end
|
|
146
|
-
end
|
|
147
134
|
end
|
|
148
135
|
end
|
|
@@ -14,6 +14,8 @@ module Profiler
|
|
|
14
14
|
method: profile.method,
|
|
15
15
|
status: profile.status,
|
|
16
16
|
duration: profile.duration&.round(2),
|
|
17
|
+
allocated_objects: profile.allocated_objects,
|
|
18
|
+
# Deprecated, kept for the readers that expect it: allocated_objects times 40, in MB.
|
|
17
19
|
memory: profile.memory ? (profile.memory / 1024.0 / 1024.0).round(2) : nil,
|
|
18
20
|
timestamp: profile.started_at&.iso8601,
|
|
19
21
|
query_count: profile.collector_data("database")&.dig("total_queries") || 0,
|