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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: f57ca8fcbd7fef2ddd81f41ab6c1b7b254e814ec986692ed906533f0c9042d4b
4
- data.tar.gz: 66e0072789af17ddb711b3911c60d5ebaa683d040117ee44f2dba7b30e126ce7
3
+ metadata.gz: e53e69cbdde6c7e775444a738e0069cef86994f505f77d86673a15e25adc768d
4
+ data.tar.gz: c134819ed6bff52aa1d5ad7309b752e1ed6c6a6e1803510d4387462f1607b73a
5
5
  SHA512:
6
- metadata.gz: 9378049525fb7fa8127ddcbe51be0f02a2fe27915cae7aad5cdbce22ce80ba6a5c2ee69b67980366f51f32cb29306c6d14d41878b3773aadaff4a7c5df0fe661
7
- data.tar.gz: 1b881dbe5033362810063c9d86946978c94e7dcb95cad19130a729e80f1e6bdbfff20e754feb6f8c9f37dcb3d7bc3dd2571e282463824cd918fa0ea201f1087e
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.memory && /* @__PURE__ */ u3("div", { class: "profiler-toolbar-panel-row", children: [
3907
- /* @__PURE__ */ u3("span", { children: "Memory" }),
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.memory ?? 0;
5801
- bv = b.memory ?? 0;
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
- "Memory ",
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: formatMemory(p3.memory ?? void 0) }),
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.memory && /* @__PURE__ */ u3("span", { children: [
8882
- "Memory: ",
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.memory != null && /* @__PURE__ */ u3("span", { children: [
9042
- "Memory: ",
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.memory != null && /* @__PURE__ */ u3("span", { children: [
9174
- "Memory: ",
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.memory != null && /* @__PURE__ */ u3("span", { children: [
9304
- "Memory: ",
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
 
@@ -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
@@ -68,6 +68,11 @@ module Profiler
68
68
  store_data(data)
69
69
  end
70
70
 
71
+ # Collect reads only what the collector gathered itself.
72
+ def collect_from_any_thread?
73
+ true
74
+ end
75
+
71
76
  def unsubscribe
72
77
  return unless @subscription
73
78
 
@@ -48,6 +48,11 @@ module Profiler
48
48
  store_data(@exception_data || {})
49
49
  end
50
50
 
51
+ # Collect reads only what the collector gathered itself.
52
+ def collect_from_any_thread?
53
+ true
54
+ end
55
+
51
56
  def unsubscribe
52
57
  return unless @subscriber
53
58
 
@@ -144,6 +144,11 @@ module Profiler
144
144
  })
145
145
  end
146
146
 
147
+ # Collect reads only what the collector gathered itself.
148
+ def collect_from_any_thread?
149
+ true
150
+ end
151
+
147
152
  def unsubscribe
148
153
  unsubscribe_notifications(@subscriptions)
149
154
  restore_thread_slots
@@ -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
- memory: @profile.memory,
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
- memory: format_memory(@profile.memory)
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 format_memory(bytes)
102
- return "0 B" unless bytes
110
+ def format_allocations(count)
111
+ return "0 objects" unless count
103
112
 
104
- if bytes < 1024
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
@@ -66,6 +66,11 @@ module Profiler
66
66
  store_data(data)
67
67
  end
68
68
 
69
+ # Collect reads only what the collector gathered itself.
70
+ def collect_from_any_thread?
71
+ true
72
+ end
73
+
69
74
  def unsubscribe
70
75
  unsubscribe_notifications(@subscriptions)
71
76
  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, :memory_warning_threshold,
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
- @memory_warning_threshold = 100 * 1024 * 1024 # 100 MB
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
- memory_before = current_memory if Profiler.configuration.track_memory
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.memory = current_memory - memory_before
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
- memory_before = current_memory if Profiler.configuration.track_memory
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.memory = current_memory - memory_before
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,