rails-profiler 0.31.1 → 0.31.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +122 -0
  3. data/app/assets/builds/profiler-toolbar.js +13 -6
  4. data/app/assets/builds/profiler.js +73 -37
  5. data/app/controllers/profiler/api/profiles_controller.rb +2 -0
  6. data/app/controllers/profiler/api/toolbar_controller.rb +2 -0
  7. data/app/controllers/profiler/profiles_controller.rb +2 -0
  8. data/lib/profiler/allocation_counter.rb +21 -0
  9. data/lib/profiler/collectors/base_collector.rb +39 -3
  10. data/lib/profiler/collectors/cache_collector.rb +8 -3
  11. data/lib/profiler/collectors/database_collector.rb +133 -10
  12. data/lib/profiler/collectors/env_collector.rb +12 -4
  13. data/lib/profiler/collectors/exception_collector.rb +12 -9
  14. data/lib/profiler/collectors/flamegraph_collector.rb +10 -5
  15. data/lib/profiler/collectors/function_profiler_collector.rb +28 -4
  16. data/lib/profiler/collectors/http_collector.rb +5 -0
  17. data/lib/profiler/collectors/log_collector.rb +5 -0
  18. data/lib/profiler/collectors/mailer_collector.rb +2 -8
  19. data/lib/profiler/collectors/request_collector.rb +14 -11
  20. data/lib/profiler/collectors/routes_collector.rb +7 -50
  21. data/lib/profiler/collectors/scoped_notifications.rb +229 -0
  22. data/lib/profiler/collectors/view_collector.rb +7 -2
  23. data/lib/profiler/configuration.rb +27 -4
  24. data/lib/profiler/console_profiler.rb +2 -17
  25. data/lib/profiler/instrumentation/executor_context_propagation.rb +47 -0
  26. data/lib/profiler/instrumentation/request_context.rb +77 -0
  27. data/lib/profiler/instrumentation/thread_context_propagation.rb +11 -16
  28. data/lib/profiler/job_profiler.rb +6 -17
  29. data/lib/profiler/mcp/resources/recent_requests.rb +2 -0
  30. data/lib/profiler/mcp/tools/get_profile_detail.rb +21 -2
  31. data/lib/profiler/mcp/tools/get_test_profile_detail.rb +1 -1
  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/process_snapshot.rb +157 -0
  38. data/lib/profiler/redaction.rb +10 -0
  39. data/lib/profiler/test_profiler.rb +2 -17
  40. data/lib/profiler/version.rb +1 -1
  41. data/lib/profiler.rb +6 -0
  42. metadata +12 -5
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: f57ca8fcbd7fef2ddd81f41ab6c1b7b254e814ec986692ed906533f0c9042d4b
4
- data.tar.gz: 66e0072789af17ddb711b3911c60d5ebaa683d040117ee44f2dba7b30e126ce7
3
+ metadata.gz: '09422d8df091081147998c5e266018f8156874c3f2590b4269dfc1bab5b2efcf'
4
+ data.tar.gz: 3f0ac06620cf6d1f0297528cdce39e80962b736e835d127538968a2aff8b748f
5
5
  SHA512:
6
- metadata.gz: 9378049525fb7fa8127ddcbe51be0f02a2fe27915cae7aad5cdbce22ce80ba6a5c2ee69b67980366f51f32cb29306c6d14d41878b3773aadaff4a7c5df0fe661
7
- data.tar.gz: 1b881dbe5033362810063c9d86946978c94e7dcb95cad19130a729e80f1e6bdbfff20e754feb6f8c9f37dcb3d7bc3dd2571e282463824cd918fa0ea201f1087e
6
+ metadata.gz: 7d74679898828ec69cea3718945c08e8c8ce4aec93d1ccb8942de28617a888ff2dfc2fbde4e4c01892f9a05a8723f713907a87b378b3be5dd11d5cb7cec7500b
7
+ data.tar.gz: 918333cbc7efbb6c61217adb4c52ced40be60a62b621fb828e942878598defd92d69bd36672bf515bcd5b6f9e4ed82869268fe704f64b674c309e2f61846fe6a
data/CHANGELOG.md CHANGED
@@ -19,6 +19,128 @@ every commit of every tag interval is accounted for one way or the other.
19
19
 
20
20
  ## [Unreleased]
21
21
 
22
+ ## [0.31.3] - 2026-10-05
23
+
24
+ <!-- stamped -->
25
+
26
+ ### Fixed
27
+
28
+ - **Collectors:** a profile no longer records the SQL queries, views, cache operations, timeline
29
+ events, exceptions and mails of other requests. The collectors subscribed to the process-wide
30
+ `ActiveSupport::Notifications` bus without looking at the thread, so on a multi-threaded server
31
+ (Puma, Sidekiq) a profile could show the queries of a concurrent request, another user's
32
+ included. Each collector now records the events of its own request: its thread, the threads it
33
+ starts with `Thread.new`, the thread `ActionController::Live` runs the action in, the fibers
34
+ it creates (Ruby 3.2+, the layout of `render stream: true` under Falcon), the server thread
35
+ iterating a streamed body, and the queries of `load_async`, which Rails reports on the
36
+ request's thread. The process holds one subscriber per event instead of one per profiled request,
37
+ so an SQL event costs the same however many requests are profiled at once (6 us with 8 requests
38
+ profiled, 260 us before, measured). A job performed inline still shows in both profiles; one
39
+ performed by ActiveJob's `:async` adapter is profiled apart from the request that enqueued it,
40
+ even while that request still runs. A test profile no longer shows the queries the application
41
+ server runs for a system test in its own thread: they are in the profile of that HTTP request.
42
+ - **Instrumentation:** a thread created by a pool while a request ran (a concurrent-ruby worker
43
+ behind `Concurrent::Promises`, ActiveJob's `:async` adapter or `ActionController::Live`, or the
44
+ thread Puma starts for a request marked as IO bound) no longer keeps that request's HTTP and
45
+ timeline collectors for good, which put the outgoing HTTP calls of other requests' tasks in its
46
+ profile. A task posted to a concurrent-ruby executor now runs in the context of the request that
47
+ posted it, and a pool thread inherits nothing. On Puma with 8 threads, 40 concurrent requests
48
+ each running a query and an HTTP call in a `Concurrent::Promises.future`: 0 queries and 0 HTTP
49
+ calls of another request in a profile, none lost, against 560 foreign queries, 35 misattributed
50
+ and 36 lost HTTP calls before (measured, `script/bench/puma_attribution.rb`).
51
+ - **Function profiler:** without the `stackprof` gem, which the gem does not depend on, the
52
+ function profiler stays off instead of falling back to a `TracePoint` enabled on every thread of
53
+ the process, which called `GC.stat` four times per method and tripled the cost of a request, for
54
+ a tab that is not shown. To trace again, set `Profiler.function_profiling_tracepoint_fallback =
55
+ true`; the `TracePoint`, like the one of the `full` mode chosen from the dashboard, is now
56
+ enabled on the request's thread only. The Timeline tab now says that stackprof is missing and
57
+ how to add it, instead of a button to enable function profiling that changed nothing, and no
58
+ longer says that sampling is on by default whatever the Gemfile.
59
+ - **Database:** the caller of a query is captured the first time a statement runs in the request
60
+ (values left out, as the N+1 detection groups them) and for every slow query, instead of for
61
+ every query, at about 0.1 ms each. The N+1 groups of the Database tab and of the MCP
62
+ `n1-patterns` resource keep their location. The backtrace now starts at the application's code
63
+ that ran the query, cleaned by `Rails.backtrace_cleaner` as in the Exception tab, or without
64
+ Rails, past the frames of gems and of Ruby; it often started inside Active Support, with the
65
+ application's first frame too far down for the three frames `n1-patterns` shows.
66
+ `config.sql_backtrace = :all` captures every query again, `:none` none.
67
+ - **Routes and Env tabs:** a profile no longer stores the whole route table and `ENV`, rebuilt and
68
+ sorted on every request (the route table alone cost 7.7 ms per request with 210 routes, and 29 KB
69
+ per profile, measured). It keeps the route its request matched, the one of the request's verb
70
+ (a `PUT` showed the `PATCH` route of the same action). The Routes tab, the toolbar and the MCP
71
+ `get_profile` tool list the route table of the process that serves them, rebuilt when the
72
+ routes are reloaded in development; the Env tab of an HTTP profile shows the current `ENV` of that
73
+ process, not the `ENV` as it was during the request, masked as before. The profiles of jobs,
74
+ console expressions and tests, which run in another process, keep that process's `ENV`, masked,
75
+ as before. Profiles saved by an earlier version keep
76
+ showing their own table and variables. With both changes, the profiler adds about 10 to 13 ms to
77
+ the reference page of `script/bench/request_overhead.rb` instead of 21 to 22 ms with stackprof
78
+ and 26 ms without it in 0.31.2 (Ruby 3.3, measured); the README gives this measured cost instead
79
+ of "< 5ms per request".
80
+ - **Upgrading:** to get the previous behaviours back, set `config.sql_backtrace = :all` and
81
+ `Profiler.function_profiling_tracepoint_fallback = true` in `config/initializers/profiler.rb`.
82
+ The Routes and Env tabs cannot show the table and the variables of the request's time any more:
83
+ they show the process's current ones.
84
+
85
+ ## [0.31.2] - 2026-10-05
86
+
87
+ <!-- stamped -->
88
+
89
+ ### Fixed
90
+
91
+ - **Middleware:** Stream responses through instead of buffering them. The profiler read every
92
+ response body to the end before handing it to the server, so an `ActionController::Live`,
93
+ `response.stream` or enumerator response, or a server-sent events stream, left only once it was
94
+ over (a stream of 5 chunks 200 ms apart sent its first chunk after 1 s instead of 0.2 s, and an
95
+ endless event stream never answered), and a file sent with `send_file` lost `to_path`, so the
96
+ server could no longer send it by its path. A body that does not answer `to_ary`, a
97
+ `text/event-stream` or a body sent by its path now goes to the server chunk by chunk, keeps
98
+ `to_path` and `close`, and its profile is saved when the server closes it, also when the client
99
+ went away, with the body seen so far and the full duration. The collectors that only gather
100
+ what notifications and the logger hand them (SQL, views, cache, exceptions, timeline, logs,
101
+ outbound HTTP) stay subscribed until then, so the queries and views of a `render stream: true`
102
+ page or of an enumerator are still recorded; dumps, mailers, I18n and function profiling are
103
+ read when the application returns. While the server iterates the body, the log lines, outbound
104
+ HTTP calls and `Profiler.measure` blocks it runs are recorded as well. Until it is saved, a
105
+ streamed profile is not listed and its token answers 404: an endless event stream is never
106
+ listed. A body the server never closes is finished when the fiber that started it starts its
107
+ next profiled request, never cutting a stream that a fiber-based server such as Falcon runs
108
+ alongside, and its subscriptions are dropped after 5 minutes at the latest, which the profile
109
+ and the Request tab then say; the server still closes the application's body. Rails 7.0 pages, whose body has no `to_ary`, and pages a `Live`
110
+ controller renders whole still get the toolbar. The toolbar is only injected in pages returned
111
+ whole, so a `render stream: true` page has none.
112
+ - **Middleware:** Stop turning an error raised while a response body is iterated (a stream that
113
+ fails part way, a file sent with `send_file` that disappeared) into an empty `200`. The error now
114
+ reaches the server, as it does without the profiler, and the profile keeps it.
115
+ - **Middleware:** Keep the request and response bodies of a profile to the first
116
+ `config.max_captured_body_bytes` (256 KB by default) instead of copying them whole: a 2 MB upload
117
+ took 2.8 MB in its profile, plus transient copies. `rack.input` is read only up to that size, then
118
+ rewound for the application, and an input that cannot be rewound is no longer read at all. The
119
+ Request tab and the MCP profile detail say when a body was cut, with its whole size, or "at
120
+ least" that size when it is not known; the profile carries `request_body_truncated`,
121
+ `request_body_size`, `response_body_truncated`, `response_body_size` and their
122
+ `*_size_is_minimum`. Only the raw bodies are capped: the parsed `params` and the
123
+ `Parameters:` log line keep their full size, so a 5 MB JSON request still takes about 15 MB in
124
+ its profile. To keep whole bodies, as before, set `config.max_captured_body_bytes = nil`.
125
+ - **Profiles:** Report the number of objects allocated (`allocated_objects`) instead of a figure
126
+ shown as memory. `GC.stat` has no `:total_allocated_size` on the supported Rubies, so `memory`
127
+ was always that number times 40, never a measured byte count. The toolbar, the dashboards, the
128
+ profile list and the MCP tools show "Allocations". The counter belongs to the process: on a
129
+ multi-threaded server it includes what other threads allocated meanwhile. Nothing to change on
130
+ upgrade: the API and the stored profiles still carry `memory` (deprecated, the count times 40),
131
+ and profiles saved by earlier versions are read back with their count. The option
132
+ `memory_warning_threshold`, which nothing ever compared with, is deprecated for
133
+ `allocated_objects_warning_threshold`, a number of objects, still without effect; setting the
134
+ old one warns and converts it.
135
+ - **Toolbar:** Leave a response already framed with `Transfer-Encoding` untouched. Under Rails
136
+ 7.0, a `render stream: true` page arrives chunked, and the toolbar was put among the chunks,
137
+ which broke the framing: the browser got an empty or cut page.
138
+ - **Toolbar:** Correct the `Content-Length` of a page the toolbar is injected into. It kept the
139
+ length of the page without the toolbar, so a server that honours it cut the page short.
140
+ - **Toolbar:** Inject the toolbar into a page whose headers are a plain `Hash` with a lower-case
141
+ `content-type`, the form Rack 3 requires; only `Content-Type` was looked up. Every response
142
+ header the profiler reads or writes now ignores the case.
143
+
22
144
  ## [0.31.1] - 2026-10-05
23
145
 
24
146
  <!-- stamped -->
@@ -3831,6 +3831,16 @@
3831
3831
  ] });
3832
3832
  }
3833
3833
 
3834
+ // app/assets/typescript/profiler/dashboard/utils.ts
3835
+ function allocatedObjects(profile) {
3836
+ if (profile.allocated_objects != null) return profile.allocated_objects;
3837
+ return profile.memory != null ? Math.round(profile.memory / 40) : null;
3838
+ }
3839
+ function formatAllocations(count) {
3840
+ return count == null ? "-" : `${count.toLocaleString("en")} objects`;
3841
+ }
3842
+ var ALLOCATIONS_HINT = "Objects allocated by the whole process while this ran: on a multi-threaded server, it includes what other threads allocated meanwhile.";
3843
+
3834
3844
  // app/assets/typescript/profiler/components/toolbar/ToolbarApp.tsx
3835
3845
  function statusClass2(status) {
3836
3846
  if (status >= 200 && status < 300) return "profiler-text--success";
@@ -3903,12 +3913,9 @@
3903
3913
  " ms"
3904
3914
  ] })
3905
3915
  ] }),
3906
- profile.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
  {
@@ -7381,7 +7413,9 @@
7381
7413
  const [totalCount, setTotalCount] = d2(0);
7382
7414
  const [fnSortKey, setFnSortKey] = d2("total_duration");
7383
7415
  const [fnSortDir, setFnSortDir] = d2("desc");
7384
- const [fnEnabled, setFnEnabled] = d2(functionProfileData?.enabled ?? false);
7416
+ const [fnEnabled, setFnEnabled] = d2(
7417
+ functionProfileData?.reason === "stackprof_missing" ? true : functionProfileData?.enabled ?? false
7418
+ );
7385
7419
  const [fnMaxFrames, setFnMaxFrames] = d2(functionProfileData?.max_frames ?? 2e3);
7386
7420
  const [fnMode, setFnMode] = d2(functionProfileData?.mode === "lite" ? "lite" : "full");
7387
7421
  const [fnClock, setFnClock] = d2(functionProfileData?.clock ?? "wall");
@@ -7690,6 +7724,7 @@
7690
7724
  const [hoveredFnName, setHoveredFnName] = d2(null);
7691
7725
  const fnRendererRef = A2(null);
7692
7726
  const hasData = enabled && data?.enabled && (data.functions?.length ?? 0) > 0;
7727
+ const stackprofMissing = data?.reason === "stackprof_missing" && mode === "lite";
7693
7728
  const dataMode = data?.mode ?? "full";
7694
7729
  const dataClock = data?.clock ?? "wall";
7695
7730
  const isSampling = dataMode === "lite";
@@ -7782,7 +7817,7 @@
7782
7817
  class: `profiler-fn-profiling__toggle${enabled ? " profiler-fn-profiling__toggle--active" : ""}`,
7783
7818
  onClick: onToggle,
7784
7819
  disabled: toggling,
7785
- children: toggling ? "\u2026" : enabled ? "Enabled \u2014 click to disable" : "Disabled \u2014 click to enable"
7820
+ children: toggling ? "\u2026" : enabled ? "Enabled (click to disable)" : "Disabled (click to enable)"
7786
7821
  }
7787
7822
  )
7788
7823
  ] })
@@ -7791,12 +7826,25 @@
7791
7826
  "Enable function profiling to see where your app spends time.",
7792
7827
  " ",
7793
7828
  /* @__PURE__ */ u3("strong", { children: "Lite" }),
7794
- ": statistical sampling via stackprof \u2014 very low overhead (<1%), enabled by default.",
7829
+ ": statistical sampling with the stackprof gem, to add to the application's Gemfile; very low overhead (<1%), on by default once stackprof is installed.",
7795
7830
  " ",
7796
7831
  /* @__PURE__ */ u3("strong", { children: "Full" }),
7797
7832
  ": exhaustive TracePoint tracing with memory bytes \u2014 significant overhead."
7798
7833
  ] }),
7799
- enabled && !hasData && /* @__PURE__ */ u3("p", { class: "profiler-fn-profiling__hint", children: "Function profiling is active. Data will appear on the next request." }),
7834
+ enabled && stackprofMissing && /* @__PURE__ */ u3("p", { class: "profiler-fn-profiling__hint", children: [
7835
+ "No data: sampling needs the ",
7836
+ /* @__PURE__ */ u3("code", { children: "stackprof" }),
7837
+ " gem, which is not installed. Add",
7838
+ " ",
7839
+ /* @__PURE__ */ u3("code", { children: 'gem "stackprof"' }),
7840
+ " to the application's Gemfile. To trace every method call instead, at a much higher cost, choose ",
7841
+ /* @__PURE__ */ u3("strong", { children: "Full" }),
7842
+ ", or set",
7843
+ " ",
7844
+ /* @__PURE__ */ u3("code", { children: "Profiler.function_profiling_tracepoint_fallback = true" }),
7845
+ " in an initializer."
7846
+ ] }),
7847
+ enabled && !hasData && !stackprofMissing && /* @__PURE__ */ u3("p", { class: "profiler-fn-profiling__hint", children: "Function profiling is active. Data will appear on the next request." }),
7800
7848
  hasData && /* @__PURE__ */ u3(k, { children: [
7801
7849
  data.frame_cap_reached && /* @__PURE__ */ u3("div", { class: "profiler-fn-profiling__cap-warning", children: [
7802
7850
  "\u26A0 Frame cap reached (",
@@ -8878,12 +8926,9 @@
8878
8926
  "Status: ",
8879
8927
  /* @__PURE__ */ u3("strong", { children: profile.status })
8880
8928
  ] }),
8881
- profile.memory && /* @__PURE__ */ u3("span", { children: [
8882
- "Memory: ",
8883
- /* @__PURE__ */ u3("strong", { children: [
8884
- (profile.memory / 1024 / 1024).toFixed(2),
8885
- " MB"
8886
- ] })
8929
+ allocatedObjects(profile) != null && /* @__PURE__ */ u3("span", { title: ALLOCATIONS_HINT, children: [
8930
+ "Allocations: ",
8931
+ /* @__PURE__ */ u3("strong", { children: formatAllocations(allocatedObjects(profile)) })
8887
8932
  ] }),
8888
8933
  /* @__PURE__ */ u3("span", { style: "color:var(--profiler-text-muted)", children: new Date(profile.started_at).toLocaleString("en", { hour12: false, month: "short", day: "numeric", hour: "2-digit", minute: "2-digit", second: "2-digit" }) }),
8889
8934
  profile.gem_version && /* @__PURE__ */ u3("span", { class: "profiler-version-badge", children: [
@@ -9038,12 +9083,9 @@
9038
9083
  "Status: ",
9039
9084
  /* @__PURE__ */ u3("strong", { children: /* @__PURE__ */ u3("span", { class: `badge-${isFailed ? "error" : "success"}`, children: isFailed ? "Failed" : "Completed" }) })
9040
9085
  ] }),
9041
- profile.memory != null && /* @__PURE__ */ u3("span", { children: [
9042
- "Memory: ",
9043
- /* @__PURE__ */ u3("strong", { children: [
9044
- (profile.memory / 1024 / 1024).toFixed(2),
9045
- " MB"
9046
- ] })
9086
+ allocatedObjects(profile) != null && /* @__PURE__ */ u3("span", { title: ALLOCATIONS_HINT, children: [
9087
+ "Allocations: ",
9088
+ /* @__PURE__ */ u3("strong", { children: formatAllocations(allocatedObjects(profile)) })
9047
9089
  ] }),
9048
9090
  profile.gem_version && /* @__PURE__ */ u3("span", { class: "profiler-version-badge", children: [
9049
9091
  "v",
@@ -9170,12 +9212,9 @@
9170
9212
  "Status: ",
9171
9213
  /* @__PURE__ */ u3("strong", { children: /* @__PURE__ */ u3("span", { class: `badge-${isFailed ? "error" : "success"}`, children: isFailed ? "Error" : "OK" }) })
9172
9214
  ] }),
9173
- profile.memory != null && /* @__PURE__ */ u3("span", { children: [
9174
- "Memory: ",
9175
- /* @__PURE__ */ u3("strong", { children: [
9176
- (profile.memory / 1024 / 1024).toFixed(2),
9177
- " MB"
9178
- ] })
9215
+ allocatedObjects(profile) != null && /* @__PURE__ */ u3("span", { title: ALLOCATIONS_HINT, children: [
9216
+ "Allocations: ",
9217
+ /* @__PURE__ */ u3("strong", { children: formatAllocations(allocatedObjects(profile)) })
9179
9218
  ] }),
9180
9219
  profile.gem_version && /* @__PURE__ */ u3("span", { class: "profiler-version-badge", children: [
9181
9220
  "v",
@@ -9300,12 +9339,9 @@
9300
9339
  "Status: ",
9301
9340
  /* @__PURE__ */ u3("strong", { children: /* @__PURE__ */ u3("span", { class: `badge-${isFailed ? "error" : testStatus === "pending" ? "warning" : "success"}`, children: testStatus === "failed" ? "\u2717 Failed" : testStatus === "pending" ? "\u23F8 Pending" : "\u2713 Passed" }) })
9302
9341
  ] }),
9303
- profile.memory != null && /* @__PURE__ */ u3("span", { children: [
9304
- "Memory: ",
9305
- /* @__PURE__ */ u3("strong", { children: [
9306
- (profile.memory / 1024 / 1024).toFixed(2),
9307
- " MB"
9308
- ] })
9342
+ allocatedObjects(profile) != null && /* @__PURE__ */ u3("span", { title: ALLOCATIONS_HINT, children: [
9343
+ "Allocations: ",
9344
+ /* @__PURE__ */ u3("strong", { children: formatAllocations(allocatedObjects(profile)) })
9309
9345
  ] })
9310
9346
  ] }),
9311
9347
  testData?.test_file && /* @__PURE__ */ u3("div", { class: "profiler-text--xs profiler-text--muted profiler-mt-2", children: [
@@ -29,6 +29,8 @@ module Profiler
29
29
 
30
30
  # Recalculate AJAX collector data (since AJAX requests happen after page load)
31
31
  recalculate_ajax_data(profile)
32
+ # The route table and ENV are the process's, not stored in the profile
33
+ Profiler::ProcessSnapshot.hydrate(profile)
32
34
 
33
35
  render json: profile.to_h.merge(
34
36
  child_jobs: build_child_jobs(profile),
@@ -16,6 +16,8 @@ module Profiler
16
16
 
17
17
  # Recalculate AJAX collector data (since AJAX requests happen after page load)
18
18
  recalculate_ajax_data(profile)
19
+ # The route table and ENV are the process's, not stored in the profile
20
+ Profiler::ProcessSnapshot.hydrate(profile)
19
21
 
20
22
  render json: {
21
23
  profile: profile.to_h.merge(child_jobs: build_child_jobs(profile)),
@@ -22,6 +22,8 @@ module Profiler
22
22
 
23
23
  # Recalculate AJAX collector data (since AJAX requests happen after page load)
24
24
  recalculate_ajax_data(@profile)
25
+ # The route table and ENV are the process's, not stored in the profile
26
+ Profiler::ProcessSnapshot.hydrate(@profile)
25
27
 
26
28
  @profile_data = @profile.to_h.merge(
27
29
  child_jobs: build_child_jobs(@profile),
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Profiler
4
+ # What every profile reports as allocated_objects: the number of objects Ruby allocated
5
+ # between two readings. GC.stat has no byte count of allocations (no :total_allocated_size on
6
+ # the supported Rubies), so none is reported.
7
+ #
8
+ # The counter belongs to the process, not to the thread: on a multi-threaded server, or with
9
+ # jobs running alongside, the figure includes what the other threads allocated meanwhile.
10
+ module AllocationCounter
11
+ # The bytes per object that profiles saved by earlier versions multiplied the count by, as
12
+ # `memory`: kept to read them back, and to keep writing that field for older readers.
13
+ LEGACY_BYTES_PER_OBJECT = 40
14
+
15
+ module_function
16
+
17
+ def current
18
+ GC.stat(:total_allocated_objects)
19
+ end
20
+ end
21
+ end
@@ -2,6 +2,7 @@
2
2
 
3
3
  require_relative "../redaction"
4
4
  require_relative "lifecycle"
5
+ require_relative "scoped_notifications"
5
6
 
6
7
  module Profiler
7
8
  module Collectors
@@ -71,6 +72,36 @@ module Profiler
71
72
  # Override in subclasses to collect data
72
73
  end
73
74
 
75
+ # Whether collect reads nothing from the request's thread (thread-local slots, the
76
+ # locale): only then can a streamed response keep the collector subscribed until the
77
+ # server closes its body, and collect it from whichever thread closes it. The default
78
+ # is the safe answer: collected on the request's thread when the application returns.
79
+ def collect_from_any_thread?
80
+ false
81
+ end
82
+
83
+ # Gives back the thread-local slots subscribe took over, and nothing else: a streamed
84
+ # response does this on the request's thread when the application returns, and keeps the
85
+ # notification subscriptions until its body is closed. Idempotent, like unsubscribe.
86
+ def release_thread_slots
87
+ restore_thread_slots
88
+ end
89
+
90
+ # Puts the slots subscribe claimed back, for a while, on the thread (or fiber) that
91
+ # iterates a streamed body: the logs, outbound HTTP calls and measures it makes belong to
92
+ # this profile. Returns what the slots held there, for return_thread_slots.
93
+ def lend_thread_slots
94
+ (@thread_slot_values || {}).to_h do |key, value|
95
+ previous = Thread.current[key]
96
+ Thread.current[key] = value
97
+ [key, previous]
98
+ end
99
+ end
100
+
101
+ def return_thread_slots(previous)
102
+ previous&.each { |key, value| Thread.current[key] = value }
103
+ end
104
+
74
105
  def toolbar_summary
75
106
  # Override in subclasses to provide summary for toolbar
76
107
  ""
@@ -104,6 +135,7 @@ module Profiler
104
135
  def claim_thread_slot(key, value)
105
136
  @claimed_thread_slots ||= {}
106
137
  @claimed_thread_slots[key] = Thread.current[key] unless @claimed_thread_slots.key?(key)
138
+ (@thread_slot_values ||= {})[key] = value
107
139
  Thread.current[key] = value
108
140
  end
109
141
 
@@ -112,10 +144,14 @@ module Profiler
112
144
  @claimed_thread_slots = nil
113
145
  end
114
146
 
115
- def unsubscribe_notifications(subscriptions)
116
- return unless defined?(ActiveSupport::Notifications)
147
+ # Subscribes to +event+ for the request being profiled only: the block never sees what
148
+ # another request's thread emits (see ScopedNotifications). Returns the handle to release.
149
+ def subscribe_notification(event, &block)
150
+ ScopedNotifications.subscribe(event, &block)
151
+ end
117
152
 
118
- subscriptions.each { |sub| ActiveSupport::Notifications.unsubscribe(sub) }
153
+ def unsubscribe_notifications(subscriptions)
154
+ subscriptions.each { |handle| ScopedNotifications.unsubscribe(handle) }
119
155
  subscriptions.clear
120
156
  end
121
157
  end
@@ -35,7 +35,7 @@ module Profiler
35
35
  def subscribe
36
36
  return unless defined?(ActiveSupport::Notifications)
37
37
 
38
- @subscriptions << ActiveSupport::Notifications.monotonic_subscribe("cache_read.active_support") do |name, started, finished, unique_id, payload|
38
+ @subscriptions << subscribe_notification("cache_read.active_support") do |name, started, finished, unique_id, payload|
39
39
  duration = ((finished - started) * 1000).round(2)
40
40
  @cache_reads << {
41
41
  key: payload[:key],
@@ -44,7 +44,7 @@ module Profiler
44
44
  }
45
45
  end
46
46
 
47
- @subscriptions << ActiveSupport::Notifications.monotonic_subscribe("cache_write.active_support") do |name, started, finished, unique_id, payload|
47
+ @subscriptions << subscribe_notification("cache_write.active_support") do |name, started, finished, unique_id, payload|
48
48
  duration = ((finished - started) * 1000).round(2)
49
49
  @cache_writes << {
50
50
  key: payload[:key],
@@ -52,7 +52,7 @@ module Profiler
52
52
  }
53
53
  end
54
54
 
55
- @subscriptions << ActiveSupport::Notifications.monotonic_subscribe("cache_delete.active_support") do |name, started, finished, unique_id, payload|
55
+ @subscriptions << subscribe_notification("cache_delete.active_support") do |name, started, finished, unique_id, payload|
56
56
  duration = ((finished - started) * 1000).round(2)
57
57
  @cache_deletes << {
58
58
  key: payload[:key],
@@ -82,6 +82,11 @@ module Profiler
82
82
  store_data(data)
83
83
  end
84
84
 
85
+ # Collect reads only what the collector gathered itself.
86
+ def collect_from_any_thread?
87
+ true
88
+ end
89
+
85
90
  def unsubscribe
86
91
  unsubscribe_notifications(@subscriptions)
87
92
  end