rails-profiler 0.31.0 → 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.
Files changed (49) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +139 -0
  3. data/app/assets/builds/profiler-toolbar.js +13 -6
  4. data/app/assets/builds/profiler.js +53 -33
  5. data/app/controllers/profiler/api/ajax_controller.rb +6 -0
  6. data/app/controllers/profiler/api/env_vars_controller.rb +10 -0
  7. data/lib/profiler/allocation_counter.rb +21 -0
  8. data/lib/profiler/cluster/slave_proxy.rb +6 -0
  9. data/lib/profiler/collectors/base_collector.rb +31 -0
  10. data/lib/profiler/collectors/cache_collector.rb +5 -0
  11. data/lib/profiler/collectors/database_collector.rb +5 -0
  12. data/lib/profiler/collectors/exception_collector.rb +5 -0
  13. data/lib/profiler/collectors/flamegraph_collector.rb +5 -0
  14. data/lib/profiler/collectors/http_collector.rb +6 -1
  15. data/lib/profiler/collectors/log_collector.rb +5 -0
  16. data/lib/profiler/collectors/request_collector.rb +14 -11
  17. data/lib/profiler/collectors/view_collector.rb +5 -0
  18. data/lib/profiler/configuration.rb +33 -7
  19. data/lib/profiler/console_profiler.rb +3 -18
  20. data/lib/profiler/env_override_store.rb +54 -43
  21. data/lib/profiler/job_profiler.rb +3 -16
  22. data/lib/profiler/mcp/body_formatter.rb +4 -4
  23. data/lib/profiler/mcp/file_cache.rb +19 -7
  24. data/lib/profiler/mcp/resources/recent_requests.rb +2 -0
  25. data/lib/profiler/mcp/tools/delete_env_var.rb +3 -1
  26. data/lib/profiler/mcp/tools/get_profile_detail.rb +13 -1
  27. data/lib/profiler/mcp/tools/get_test_profile_detail.rb +1 -1
  28. data/lib/profiler/mcp/tools/list_env_vars.rb +3 -0
  29. data/lib/profiler/mcp/tools/reset_all_env_vars.rb +3 -0
  30. data/lib/profiler/mcp/tools/reset_env_var.rb +3 -0
  31. data/lib/profiler/mcp/tools/set_env_var.rb +2 -0
  32. data/lib/profiler/middleware/capturing_body.rb +112 -0
  33. data/lib/profiler/middleware/profiler_middleware.rb +261 -53
  34. data/lib/profiler/middleware/streamed_profile.rb +135 -0
  35. data/lib/profiler/middleware/toolbar_injector.rb +3 -10
  36. data/lib/profiler/models/profile.rb +69 -5
  37. data/lib/profiler/redaction.rb +10 -0
  38. data/lib/profiler/storage/base_store.rb +5 -1
  39. data/lib/profiler/storage/blob_store.rb +28 -4
  40. data/lib/profiler/storage/file_store.rb +21 -15
  41. data/lib/profiler/storage/memory_store.rb +6 -0
  42. data/lib/profiler/storage/private_files.rb +135 -0
  43. data/lib/profiler/storage/redis_store.rb +6 -0
  44. data/lib/profiler/storage/sqlite_store.rb +26 -8
  45. data/lib/profiler/storage/token.rb +24 -0
  46. data/lib/profiler/test_profiler.rb +3 -18
  47. data/lib/profiler/version.rb +1 -1
  48. data/lib/profiler.rb +12 -0
  49. metadata +10 -5
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 1c74753b10258047c15aafb6de0c2b5900bc16f01f4b499405b5b72d0d6d3b9e
4
- data.tar.gz: 55ad17c8d51cf4eb8e2ed9ec3fd7158a15e23becba666b204b11d7b7c81f0e47
3
+ metadata.gz: e53e69cbdde6c7e775444a738e0069cef86994f505f77d86673a15e25adc768d
4
+ data.tar.gz: c134819ed6bff52aa1d5ad7309b752e1ed6c6a6e1803510d4387462f1607b73a
5
5
  SHA512:
6
- metadata.gz: f1984f7c3ef795d9f318ddc33b64e964a8ed0171878fd913a927ad7c7d4479ba4523159598e75ddd3f52a8985bdaca9cb24bd49a8e0a0f6e1fbc025b904254ba
7
- data.tar.gz: 855f08439f3cd174f3686150eed1f813b349e7591dd731b554941be172b9557ff835229268a3dce9605825e161b353a4456e444f1680ed239f40ebf1a6c37abc
6
+ metadata.gz: 1a277a3d46f4967dc8d875957e95b4d2df118c5a7a96dfcdac83e4a932c4aa4fbd73e1498ef5250bf714e96b5842d99814b617e7f530befcef2eac07984e571c
7
+ data.tar.gz: 978b1b85623419ca8fd8afe3c9550f24be03dbd5e1a8ef02f8ded99270cff218f2e2d68622b66b9edc7def30a46cb574c8b06bdb7b3cdb6f3502b91a919d7941
data/CHANGELOG.md CHANGED
@@ -19,6 +19,145 @@ 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
+
81
+ ## [0.31.1] - 2026-10-05
82
+
83
+ <!-- stamped -->
84
+
85
+ ### Fixed
86
+
87
+ - **Storage:** Keep the file store's profiles in `tmp_path/profiles` by default, and take only the
88
+ files named after a profile token for profiles. Profiles shared `tmp_path` with
89
+ `env_overrides.json`, which the store listed as a profile with no token and deleted when the
90
+ profiles were cleared. An application that sets `storage_options[:path]` keeps its directory,
91
+ used as given (no subdirectory is added).
92
+ - **MCP:** Keep the bodies saved by the MCP tools in `tmp_path/mcp-cache`, and let their hourly
93
+ cleanup remove only the token directories it wrote there. It removed every directory of
94
+ `tmp_path` older than an hour, the SQLite store's blobs included, at random (one save in
95
+ twenty), which lost the stored HTTP response bodies.
96
+ - **Configuration:** `config.tmp_path` is always a `Pathname`: it was a `String` outside Rails, and
97
+ kept a `String` assigned to it, which made every env override operation fail with only a
98
+ warning. A failure to read or write the env overrides now reaches the Env tab (`500` with the
99
+ error, `ENV` left unchanged) and the MCP env tools (an error answer) instead of reporting a
100
+ success; at boot, before a Sidekiq job and before a console evaluation it is still a warning,
101
+ so an override never makes the application fail.
102
+ - **Jobs:** A storage error while saving a profile no longer makes the application fail: a job,
103
+ a console command, a test, or an outbound HTTP call that ends after its profile was collected
104
+ now goes on, with its own result or its own exception, and the error is printed as a warning.
105
+ The save of a job, console or test profile ran with nothing to catch it, so a full disk, a
106
+ Redis that went away or, from this version, a symbolic link refused in place of the SQLite
107
+ database made the job fail, or replaced the job's own exception. The HTTP middleware already
108
+ kept requests going.
109
+ - **Upgrading:** for the default `tmp_path`; adapt the paths if you set one.
110
+ - Profiles saved by the file store in `tmp/rails-profiler` itself are no longer listed, and are
111
+ not moved. To remove them, with the old MCP body cache, run from the application root:
112
+ ```sh
113
+ LC_ALL=C find tmp/rails-profiler -maxdepth 1 -type f -name '[0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f].json' -delete
114
+ LC_ALL=C find tmp/rails-profiler -mindepth 1 -maxdepth 1 -type d -name '[0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f]' -exec rm -rf {} +
115
+ ```
116
+ Both only match names made of 32 lower-case hexadecimal digits, a profile token (`LC_ALL=C`
117
+ keeps `[0-9a-f]` from matching upper-case letters in some locales): `env_overrides.json`, the
118
+ SQLite database, `blobs`, `profiles` and `mcp-cache` are left alone.
119
+ - To keep reading the old profiles instead, set
120
+ `config.storage_options = { path: Rails.root.join("tmp", "rails-profiler") }`, and then do
121
+ **not** run the first command: it would delete the current profiles.
122
+ - The directory `tmp/rails-profiler` created by an earlier version keeps its mode: run
123
+ `chmod 700 tmp/rails-profiler` to restrict it.
124
+ - If the web process and the workers reading the env overrides or a shared file or SQLite store
125
+ run as two different users, set `config.restrict_storage_permissions = false` to create new
126
+ files with the modes of the umask, as before. It does not reopen what was already created
127
+ `0700` and `0600`: run `chmod -R u=rwX,go=rX tmp/rails-profiler` for the modes earlier
128
+ versions created with the usual umask (`0755` and `0644`). A worker under another user that
129
+ also writes there (it opens `env_overrides.json.lock` for writing) needs the
130
+ files belonging to a group of both users (the same primary group for both, or
131
+ `chgrp -R <group> tmp/rails-profiler` then `find tmp/rails-profiler -type d -exec chmod g+s {} +`
132
+ so that new files inherit it), a umask of `002` for both processes, and
133
+ `chmod -R ug=rwX,o=rX tmp/rails-profiler`. The option also follows a symbolic link in place
134
+ of the SQLite database, its `-wal` or `-shm` file or the lock of the env overrides, and drops
135
+ the warning about a shared `tmp_path`, as before.
136
+
137
+ ### Security
138
+
139
+ - **Storage:** Check every profile token against the format the profiler issues (32 hexadecimal
140
+ characters) in every storage backend (file, memory, Redis, SQLite and its blobs), before it
141
+ becomes a file name, a directory or a key. A token such as `../../config/important` reached the
142
+ file store as a path: `DELETE /_profiler/api/profiles/:id` deleted, and
143
+ `POST /_profiler/api/ajax/link` overwrote, a `.json` file outside the storage directory. A
144
+ malformed token is now not found (`404` from the API, `nil` from the storage, no exception),
145
+ whoever asks: the controllers, the cluster proxy, which no longer forwards it to a slave, or the
146
+ MCP tools. Saving under one raises an `ArgumentError`. `POST /_profiler/api/ajax/link` answers
147
+ `400` for a malformed `parent_token` instead of storing it. The MCP tools no longer save a body
148
+ (`save_bodies`) under a token sent by a slave that is not a profile token. There is no option to
149
+ accept other tokens: the profiler never issues them, and accepting them would reopen the path.
150
+ - **Storage:** Create the profiler's directories `0700` and its files `0600`, whatever the umask:
151
+ the file store's profiles, the SQLite database and its `-wal` and `-shm` files, the blobs, the
152
+ env overrides with their lock and temporary files, and the MCP body cache. They were created
153
+ with the process defaults, usually `0755` and `0644`, readable by every local user although
154
+ profiles hold cookies, tokens and environment values. A file the profiler writes again (a
155
+ profile, the SQLite database at startup) is brought back to `0600`; a directory that already
156
+ exists keeps its mode. Writing a profile now goes through a temporary file renamed over it. A
157
+ symbolic link placed where the profiler keeps its SQLite database, its `-wal` or `-shm` file or
158
+ the lock of the env overrides is refused instead of followed, and an existing `tmp_path` that
159
+ belongs to another user or that group or others can write to is reported once with a warning.
160
+
22
161
  ## [0.31.0] - 2026-10-05
23
162
 
24
163
  <!-- 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: [
@@ -12,6 +12,12 @@ module Profiler
12
12
  return render json: { error: "Missing parent_token or child_token" }, status: :bad_request
13
13
  end
14
14
 
15
+ # The parent token is written into the child profile: a value the gem never issues is
16
+ # refused rather than stored. A malformed child token is not found by the storage.
17
+ unless Profiler::Storage::Token.valid?(parent_token)
18
+ return render json: { error: "Invalid parent_token" }, status: :bad_request
19
+ end
20
+
15
21
  # Load child profile
16
22
  child_profile = Profiler.storage.load(child_token)
17
23
  unless child_profile
@@ -3,6 +3,13 @@
3
3
  module Profiler
4
4
  module Api
5
5
  class EnvVarsController < ApplicationController
6
+ # The overrides file could not be read or written: nothing was changed, ENV included.
7
+ rescue_from Profiler::EnvOverrideStore::Error do |error|
8
+ key = params[:key].to_s.strip
9
+ message = key.empty? ? "#{error.message}; nothing was changed" : "#{error.message}; #{key} was left unchanged"
10
+ render json: { error: message }, status: :internal_server_error
11
+ end
12
+
6
13
  def show
7
14
  variables = Profiler::Redaction.env_snapshot
8
15
  overrides = Profiler::Redaction.env_overrides(Profiler.env_override_store.all_overrides)
@@ -72,8 +79,11 @@ module Profiler
72
79
  ENV[key].nil? ? nil : Profiler::Redaction.env_value(key, ENV[key])
73
80
  end
74
81
 
82
+ # Read back after a change that succeeded: a failure here must not answer that nothing changed.
75
83
  def redacted_override(key)
76
84
  Profiler::Redaction.env_overrides(Profiler.env_override_store.all_overrides)[key]
85
+ rescue Profiler::EnvOverrideStore::Error
86
+ nil
77
87
  end
78
88
  end
79
89
  end
@@ -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
@@ -4,6 +4,7 @@ require "net/http"
4
4
  require "json"
5
5
  require "uri"
6
6
  require_relative "security"
7
+ require_relative "../storage/token"
7
8
 
8
9
  module Profiler
9
10
  module Cluster
@@ -39,7 +40,10 @@ module Profiler
39
40
  Array(data["profiles"]).map { |h| profile_from_api(h) }
40
41
  end
41
42
 
43
+ # A token that is not one the gem issues is not found, without asking the slave.
42
44
  def load(token)
45
+ return nil unless Storage::Token.valid?(token)
46
+
43
47
  data = get_json("/_profiler/api/profiles/#{Security.escape_segment(token)}")
44
48
  return nil unless data["token"] || data["profile"]
45
49
 
@@ -48,6 +52,8 @@ module Profiler
48
52
  end
49
53
 
50
54
  def find_by_parent(parent_token)
55
+ return [] unless Storage::Token.valid?(parent_token)
56
+
51
57
  data = get_json("/_profiler/api/profiles", parent_token: parent_token, all_types: true)
52
58
  Array(data["profiles"]).map { |h| profile_from_api(h) }
53
59
  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
@@ -125,7 +130,7 @@ module Profiler
125
130
  build_data(@requests)
126
131
  end
127
132
  store_data(data)
128
- Profiler.storage.save(@profile.token, @profile)
133
+ Profiler.save_profile(@profile, from: "HttpCollector")
129
134
  end
130
135
 
131
136
  def group_by_host(requests)
@@ -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