rails-profiler 0.31.3 → 0.31.5

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 (65) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +129 -0
  3. data/app/assets/builds/profiler.js +9 -1
  4. data/app/controllers/profiler/api/console_controller.rb +1 -11
  5. data/app/controllers/profiler/api/jobs_controller.rb +1 -11
  6. data/app/controllers/profiler/api/profiles_controller.rb +18 -28
  7. data/app/controllers/profiler/api/tests_controller.rb +1 -11
  8. data/app/controllers/profiler/api/toolbar_controller.rb +0 -20
  9. data/app/controllers/profiler/application_controller.rb +55 -15
  10. data/app/controllers/profiler/profiles_controller.rb +2 -18
  11. data/lib/profiler/ajax_data.rb +40 -0
  12. data/lib/profiler/cluster/master_client.rb +7 -15
  13. data/lib/profiler/cluster/security.rb +3 -3
  14. data/lib/profiler/cluster/slave_proxy.rb +14 -9
  15. data/lib/profiler/collectors/lifecycle.rb +2 -2
  16. data/lib/profiler/collectors/log_collector.rb +54 -5
  17. data/lib/profiler/collectors/mailer_collector.rb +8 -4
  18. data/lib/profiler/collectors/request_collector.rb +6 -1
  19. data/lib/profiler/collectors/scoped_notifications.rb +1 -1
  20. data/lib/profiler/configuration.rb +30 -8
  21. data/lib/profiler/console_profiler.rb +1 -1
  22. data/lib/profiler/engine.rb +2 -1
  23. data/lib/profiler/env_override_store.rb +4 -4
  24. data/lib/profiler/explain_runner.rb +5 -1
  25. data/lib/profiler/instrumentation/net_http_instrumentation.rb +138 -40
  26. data/lib/profiler/job_profiler.rb +1 -1
  27. data/lib/profiler/local_request.rb +2 -6
  28. data/lib/profiler/mcp/file_cache.rb +2 -1
  29. data/lib/profiler/mcp/resources/failing_tests.rb +1 -1
  30. data/lib/profiler/mcp/resources/recent_console.rb +2 -2
  31. data/lib/profiler/mcp/resources/recent_jobs.rb +2 -2
  32. data/lib/profiler/mcp/resources/slow_tests.rb +1 -1
  33. data/lib/profiler/mcp/server.rb +9 -3
  34. data/lib/profiler/mcp/tools/get_profile_ajax.rb +2 -0
  35. data/lib/profiler/mcp/tools/get_profile_http.rb +6 -1
  36. data/lib/profiler/mcp/tools/get_test_profile_detail.rb +1 -1
  37. data/lib/profiler/mcp/tools/query_console_profiles.rb +6 -2
  38. data/lib/profiler/mcp/tools/query_jobs.rb +6 -2
  39. data/lib/profiler/mcp/tools/query_mailers.rb +5 -1
  40. data/lib/profiler/mcp/tools/query_profiles.rb +6 -2
  41. data/lib/profiler/mcp/tools/query_test_profiles.rb +6 -2
  42. data/lib/profiler/mcp/tools/run_tests.rb +5 -2
  43. data/lib/profiler/middleware/profiler_middleware.rb +22 -16
  44. data/lib/profiler/middleware/streamed_profile.rb +1 -1
  45. data/lib/profiler/middleware/toolbar_injector.rb +21 -2
  46. data/lib/profiler/models/profile.rb +60 -2
  47. data/lib/profiler/process_snapshot.rb +8 -3
  48. data/lib/profiler/railtie.rb +4 -1
  49. data/lib/profiler/redaction.rb +1 -3
  50. data/lib/profiler/sse/redis_event_bus.rb +2 -1
  51. data/lib/profiler/storage/base_store.rb +4 -2
  52. data/lib/profiler/storage/blob_store.rb +1 -1
  53. data/lib/profiler/storage/file_store.rb +337 -58
  54. data/lib/profiler/storage/memory_store.rb +60 -31
  55. data/lib/profiler/storage/private_files.rb +16 -3
  56. data/lib/profiler/storage/redis_store.rb +252 -33
  57. data/lib/profiler/storage/sqlite_store.rb +59 -11
  58. data/lib/profiler/storage/summary.rb +50 -0
  59. data/lib/profiler/storage/unavailable.rb +57 -0
  60. data/lib/profiler/test_helpers/reporter.rb +2 -2
  61. data/lib/profiler/test_profiler.rb +1 -1
  62. data/lib/profiler/test_runner/runner.rb +3 -7
  63. data/lib/profiler/version.rb +1 -1
  64. data/lib/profiler.rb +138 -4
  65. metadata +4 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: '09422d8df091081147998c5e266018f8156874c3f2590b4269dfc1bab5b2efcf'
4
- data.tar.gz: 3f0ac06620cf6d1f0297528cdce39e80962b736e835d127538968a2aff8b748f
3
+ metadata.gz: 87fc21dec17ab7627d4430f49b042e53061efb33034d7309ea07aac6eb83ac5d
4
+ data.tar.gz: 3e64027bb71fa5ce3d2a23a23cf1525f0e5a55c508c019f1398c35c705f20434
5
5
  SHA512:
6
- metadata.gz: 7d74679898828ec69cea3718945c08e8c8ce4aec93d1ccb8942de28617a888ff2dfc2fbde4e4c01892f9a05a8723f713907a87b378b3be5dd11d5cb7cec7500b
7
- data.tar.gz: 918333cbc7efbb6c61217adb4c52ced40be60a62b621fb828e942878598defd92d69bd36672bf515bcd5b6f9e4ed82869268fe704f64b674c309e2f61846fe6a
6
+ metadata.gz: 596fda2dad5ffa7e2a7c98a2b749c03901df7748d26776203a30d88a86b842e5956d92453b3847a3cf856a7afee5c4511f02ce83070a6dd26d0ef4d1e6784b5b
7
+ data.tar.gz: b70a833918e15474ac410c182763f13e03e0352e39cd7a0ed066c68a47f2f86762e7ef01fcd5246bfe47d391d86c03556929f66dbb84f0098f0bc43c43e9b415
data/CHANGELOG.md CHANGED
@@ -19,6 +19,135 @@ every commit of every tag interval is accounted for one way or the other.
19
19
 
20
20
  ## [Unreleased]
21
21
 
22
+ ## [0.31.5] - 2026-10-05
23
+
24
+ <!-- stamped -->
25
+
26
+ ### Fixed
27
+
28
+ - **Logging:** Send the profiler's own errors and warnings to the application's log instead of
29
+ `$stderr`. A profile that could not be saved, a collector that failed, a store that could not be
30
+ read, a cluster node out of reach: about thirty places wrote to `$stderr`, which is not where
31
+ `log/development.log` is, so these errors went unseen. They now go through one entry point,
32
+ `Profiler.log_error`, to `config.logger` when the application sets one (read on each message),
33
+ otherwise `Rails.logger`, otherwise `$stderr`. Every line starts with `[Profiler]` (the cluster's
34
+ lines, `[Profiler Cluster]` before, now start with `[Profiler] Cluster:`), carries the error's
35
+ class and message, masked then cut at 1,000 bytes, and has the cluster secret masked. A logger that
36
+ raises never fails a request, a job or the boot: the line then goes to `$stderr`. A slave whose
37
+ `Rails.logger` was still nil raised instead of logging. Several failures that left no trace at
38
+ all are now logged, once per place and error class for the life of the process: a store lookup or broadcast, the Redis
39
+ version read of the toolbar, the MCP body cache, the mailer collector reading a mail, the route
40
+ lookup of the Request tab, a file profile that cannot be read to rebuild the index. The request middleware saves its
41
+ profiles through `Profiler.save_profile`, like jobs, console commands and tests; a page whose
42
+ profile could not be saved gets neither the `X-Profiler-Token` header nor the toolbar, which
43
+ would point to a missing profile. The profiler's own lines no longer appear in the Logs tab of
44
+ the request during which they are written, nor count as its errors. The same holds while the store is
45
+ unavailable, for pages and for streamed responses, which no longer send `X-Profiler-Token` for a
46
+ profile that will be dropped. The storage warnings (a store that cannot be created, said once
47
+ per cause; a Redis profile that cannot be indexed; a SQLite summary that cannot be read) go to
48
+ the application's log too, and the `run_tests` MCP tool answers an unavailable store as an error
49
+ instead of a run without profiles.
50
+ - **Outbound HTTP:** Record the calls to services on the same machine. `127.0.0.1`, `localhost`
51
+ and `::1` were always left out, so in development the calls to a neighbouring service never
52
+ showed. `config.http_skip_hosts` is now the whole list, empty by default. The profiler's own
53
+ calls are never recorded, whatever the list says: a slave's registration and heartbeats, and the
54
+ master's requests to its slaves (the slave proxy, the fan-out of the profile pages and the MCP
55
+ tools that query a slave), marked where they are made, and any call to the host and port of
56
+ `config.master_url`. `NetHttpInstrumentation::SKIP_HOSTS` stays, deprecated, with its former value
57
+ (`%w[127.0.0.1 localhost ::1]`); nothing reads it any more.
58
+ - **Outbound HTTP:** Keep the bodies of outbound `Net::HTTP` calls to the first
59
+ `config.max_captured_body_bytes`, sent and received, instead of copying them whole; a compressed
60
+ answer is inflated only up to that size, give or take one 16 KB buffer. A `body_stream` is read
61
+ only when it can be put back where it was (a `StringIO`, a file), up to that size and no further
62
+ than its `Content-Length`: a pipe or a socket was read whole and then sent empty, it is now sent
63
+ unread and its body is not captured. A `body_stream` without `pos`, such as a Faraday multipart
64
+ body or a `RestClient::Payload`, is not captured either, where earlier versions replaced it with
65
+ a copy read whole. The size is the `Content-Length` or the stream's own size, otherwise a
66
+ minimum. The HTTP tab and the `get_profile_http` MCP tool say when a body was cut or not
67
+ captured; each call carries `request_body_truncated`, `response_body_truncated`,
68
+ `request_body_not_captured` and `request_size_is_minimum`. Text bodies read in part stay UTF-8.
69
+ - **Middleware:** Cap the `params` a profile keeps at `config.max_captured_body_bytes`: long values
70
+ are cut and the entries past it left out, with a `"[profiler]"` key saying so, and the profile
71
+ carries `params_truncated`. The application still gets all of its params. With the log line
72
+ capped below, a 5 MB JSON request no longer takes about 15 MB in its profile.
73
+ - **Logs:** Cap what a profile keeps of its request's log lines at the new
74
+ `config.max_captured_log_bytes` (1 MB by default). A request, or a stream held open, that logged
75
+ without end grew its thread's buffer without bound; a single line, such as the
76
+ `Parameters: ...` line of a large request, is cut at the cap. The Logs tab ends with the number
77
+ of lines left out, and the collected data carries `truncated` and `dropped`.
78
+ - **Dump:** `Profiler.dump` returns its argument when the profiler is disabled too; it returned
79
+ `nil`, so a call left in the code changed the value of the expression in production.
80
+ - **Engine:** Stop failing the boot of an application that loads `ActionController::API` (an
81
+ `api_only` application, or a single API controller): the engine called `helper` on it, which
82
+ `ActionController::API` does not have (`NoMethodError`).
83
+ - **Toolbar:** Find where the toolbar goes about four times faster on a page of many tags (0.8 ms
84
+ instead of 4 ms for a page of 20 KB and 4,000 tags, 10 ms instead of 25 ms for 320 KB of tags
85
+ with attributes): ordinary text and tags are skipped in one pass. The place is the same, and a
86
+ `</body>` in an attribute, a comment, a script or a CDATA section, an unclosed quote or a
87
+ `<plaintext>` are handled as before.
88
+ - **Upgrading:** to get the previous behaviour back, in `config/initializers/profiler.rb`:
89
+ - the profiler's messages on `$stderr`: `config.logger = Logger.new($stderr)`;
90
+ - the local hosts left out of the HTTP tab:
91
+ `config.http_skip_hosts += Profiler::Configuration::LOCAL_HTTP_HOSTS`;
92
+ - whole outbound bodies and params: `config.max_captured_body_bytes = nil` (also keeps the
93
+ incoming bodies whole); every log line: `config.max_captured_log_bytes = nil`.
94
+
95
+ ## [0.31.4] - 2026-10-05
96
+
97
+ <!-- stamped -->
98
+
99
+ ### Fixed
100
+
101
+ - **Storage:** A save into the file store costs the same whatever the number of profiles on disk:
102
+ it listed the directory and read the size of every file on each save, about 5 ms with 500
103
+ profiles and 100 ms with 10,000, now under 1 ms. The store keeps an index next to the profiles
104
+ (`.profiles-index.jsonl`, with its lock `.profiles-index.lock`), which the processes writing to
105
+ the directory share; a missing or damaged one is rebuilt from the profile files, so the profiles
106
+ already saved are found without any migration. Temporary files left by a killed process are now
107
+ removed. The index relies on `flock`: a file store shared between hosts over NFS is not
108
+ supported, use Redis there.
109
+ - **Storage:** `config.max_profiles` (100) is honoured by every backend, the first saved profiles
110
+ evicted first (a job saved when it ends counts as new, whenever it started): only the memory
111
+ store applied it, so the file store grew to `max_size` (100 MB,
112
+ tens of thousands of profiles), SQLite without limit and Redis up to its TTL. In the test
113
+ environment, where every example saves a profile, there is no cap on the count unless the
114
+ application sets one.
115
+ - **Storage:** Finding the children of a page (its AJAX sub-requests and its jobs) reads only
116
+ them, through an index of their parent, instead of reading and parsing every stored profile
117
+ (about 260 ms with 1,500 profiles of 17 KB in the file store, now under 1 ms). Every profile page
118
+ and toolbar paid it, once for the child jobs and once more for the AJAX tab; they now read the
119
+ children once.
120
+ - **Dashboard:** The HTTP, job, console and test lists are filtered and paged by the storage, and
121
+ list summaries of the profiles (without the bodies, params, headers and nested collector data):
122
+ they loaded the 1,000 newest profiles in full on every page (about 200 ms with 1,500 profiles of
123
+ 17 KB, now under 10 ms), showed nothing past the thousandth, said there was no next page there,
124
+ and missed the profiles of one type behind 1,000 newer ones of another. The MCP tools and
125
+ resources that list one type of profile (jobs, console, tests) and the test report ask the
126
+ storage for that type too, and found nothing behind 500 newer profiles of another type.
127
+ - **AJAX:** The AJAX tab shows the sub-requests of a page whatever `config.collectors` holds: it
128
+ only appeared with `AjaxCollector` in the list, which the default list does not have. The jobs a
129
+ page enqueued are no longer counted as AJAX requests: they stay under its child jobs. The MCP
130
+ tool `get_profile_ajax` finds the sub-requests too: it read the AJAX data saved with the page,
131
+ before any sub-request.
132
+ - **Storage:** The storage is created once, under a lock: the first requests of a threaded server
133
+ could each create their own store (16 for 16 threads in the spec), and with the memory store the
134
+ profiles saved in all but one were lost. Several file, SQLite or Redis stores share their data
135
+ and lost nothing.
136
+ - **Storage:** A store that cannot be created (its index or lock replaced by a symbolic link, say)
137
+ is reported once per process instead of with a backtrace on every request: the profiles are not
138
+ saved and no toolbar is added, the dashboard and its API answer `503` with the cause in one line
139
+ (no path) instead of a `500`, the MCP tools an error with it, and the store is created again
140
+ once the cause is gone.
141
+ - **Upgrading:** the file, SQLite and Redis stores now keep 100 profiles outside the test
142
+ environment, and **the first save after the upgrade removes the profiles past the cap**, the
143
+ first saved first: of 3,000 profiles written by 0.31.1 in the file store, about 80 remain, and
144
+ that first save takes about a second, during which the other processes writing to the store
145
+ wait. To keep them, set `config.max_profiles` (or `storage_options[:max_profiles]`) **before**
146
+ upgrading: to a higher number, or to `nil` for the earlier behaviour, no cap on the count (the
147
+ file store then keeps up to `max_size`, Redis up to its TTL). The memory store keeps 100 with
148
+ `nil`, as before. A Redis store written by an earlier version is indexed once, by one process,
149
+ on first use, which also removes the profiles past the cap then (about 0.4 s for 20,000).
150
+
22
151
  ## [0.31.3] - 2026-10-05
23
152
 
24
153
  <!-- stamped -->
@@ -4414,6 +4414,13 @@
4414
4414
  parts.push(` ${JSON.stringify(req.url)}`);
4415
4415
  return parts.join(" \\\n");
4416
4416
  }
4417
+ function bodyNotes(req) {
4418
+ const notes = [];
4419
+ if (req.request_body_not_captured) notes.push("Request body not captured: a stream that cannot be rewound is sent unread.");
4420
+ if (req.request_body_truncated) notes.push("Request body truncated: only the beginning was kept (max_captured_body_bytes).");
4421
+ if (req.response_body_truncated) notes.push("Response body truncated: only the beginning was kept (max_captured_body_bytes).");
4422
+ return notes;
4423
+ }
4417
4424
  function HttpRequestDetail({ req, index, threshold }) {
4418
4425
  const [open, setOpen] = d2(false);
4419
4426
  const isError = req.status >= 400 || req.status === 0;
@@ -4436,12 +4443,13 @@
4436
4443
  /* @__PURE__ */ u3("div", { class: "profiler-ajax-card__row", children: [
4437
4444
  /* @__PURE__ */ u3("span", { class: "profiler-text--xs profiler-text--muted", children: [
4438
4445
  "\u2191 ",
4439
- formatBytes(req.request_size),
4446
+ req.request_size == null ? "size unknown" : `${req.request_size_is_minimum ? "at least " : ""}${formatBytes(req.request_size)}`,
4440
4447
  " \xB7 \u2193 ",
4441
4448
  formatBytes(req.response_size)
4442
4449
  ] }),
4443
4450
  req.backtrace && req.backtrace.length > 0 && /* @__PURE__ */ u3("span", { class: "profiler-text--xs profiler-text--muted", style: "margin-left:12px", children: req.backtrace[0] })
4444
4451
  ] }),
4452
+ bodyNotes(req).map((note) => /* @__PURE__ */ u3("div", { class: "profiler-ajax-card__row", children: /* @__PURE__ */ u3("span", { class: "profiler-text--xs profiler-text--muted", children: note }) })),
4445
4453
  req.error && /* @__PURE__ */ u3("div", { class: "profiler-ajax-card__row", children: /* @__PURE__ */ u3("span", { class: "profiler-text--xs profiler-text--error", children: req.error }) }),
4446
4454
  open && /* @__PURE__ */ u3(
4447
4455
  HttpReqRespDetail,
@@ -4,17 +4,7 @@ module Profiler
4
4
  module Api
5
5
  class ConsoleController < ApplicationController
6
6
  def index
7
- limit = (params[:limit] || 50).to_i
8
- offset = (params[:offset] || 0).to_i
9
- all = Profiler.storage.list(limit: 1000, offset: 0)
10
- console = all.select { |p| p.profile_type == "console" }
11
- page = console.drop(offset).first(limit + 1)
12
- render json: {
13
- profiles: page.first(limit).map(&:to_h),
14
- limit: limit,
15
- offset: offset,
16
- has_more: page.size > limit
17
- }
7
+ render_profile_page(type: "console")
18
8
  end
19
9
 
20
10
  def show
@@ -4,17 +4,7 @@ module Profiler
4
4
  module Api
5
5
  class JobsController < ApplicationController
6
6
  def index
7
- limit = (params[:limit] || 50).to_i
8
- offset = (params[:offset] || 0).to_i
9
- all = Profiler.storage.list(limit: 1000, offset: 0)
10
- jobs = all.select { |p| p.profile_type == "job" }
11
- page = jobs.drop(offset).first(limit + 1)
12
- render json: {
13
- profiles: page.first(limit).map(&:to_h),
14
- limit: limit,
15
- offset: offset,
16
- has_more: page.size > limit
17
- }
7
+ render_profile_page(type: "job")
18
8
  end
19
9
 
20
10
  def show
@@ -4,20 +4,16 @@ module Profiler
4
4
  module Api
5
5
  class ProfilesController < ApplicationController
6
6
  def index
7
- limit = (params[:limit] || 50).to_i
8
- offset = (params[:offset] || 0).to_i
9
- all = Profiler.storage.list(limit: 1000, offset: 0)
10
7
  # all_types is an opt-in used by the cluster proxy so it can mirror the full
11
- # storage.list contract; the dashboard relies on the default http-only filter.
12
- scope = all_types? ? all : all.select { |p| p.profile_type == "http" }
13
- scope = scope.select { |p| p.parent_token == params[:parent_token] } if params[:parent_token].present?
14
- page = scope.drop(offset).first(limit + 1)
15
- render json: {
16
- profiles: page.first(limit).map(&:to_h),
17
- limit: limit,
18
- offset: offset,
19
- has_more: page.size > limit
20
- }
8
+ # storage.list contract (every type, or the one given as type, full profiles); the
9
+ # dashboard relies on the default: http profiles, as summaries.
10
+ if params[:parent_token].present?
11
+ render_children_page
12
+ elsif all_types?
13
+ render_profile_page(type: profile_type_param, summary: false)
14
+ else
15
+ render_profile_page(type: "http")
16
+ end
21
17
  end
22
18
 
23
19
  def show
@@ -57,22 +53,16 @@ module Profiler
57
53
  %w[1 true].include?(params[:all_types].to_s)
58
54
  end
59
55
 
60
- def recalculate_ajax_data(profile)
61
- # Find AJAX collector in the configured collectors
62
- ajax_collector_class = Profiler::Collectors::AjaxCollector
63
-
64
- if Profiler.configuration.collectors.include?(ajax_collector_class)
65
- collector = ajax_collector_class.new(profile)
66
- collector.collect
56
+ def profile_type_param
57
+ params[:type].to_s.match?(/\A[a-z_]{1,32}\z/) ? params[:type].to_s : nil
58
+ end
67
59
 
68
- # Update tab metadata to reflect has_data status
69
- if profile.instance_variable_get(:@collectors_metadata)
70
- ajax_tab = profile.instance_variable_get(:@collectors_metadata).find { |tab| tab[:key] == 'ajax' }
71
- if ajax_tab
72
- ajax_tab[:has_data] = collector.has_data?
73
- end
74
- end
75
- end
60
+ # The children of a page, oldest last, read through the store's parent index.
61
+ def render_children_page
62
+ limit, offset = page_params
63
+ children = Profiler.storage.find_by_parent(params[:parent_token].to_s).reverse
64
+ children = children.select { |p| p.profile_type == "http" } unless all_types?
65
+ render_page(children.drop(offset).first(limit + 1), limit, offset)
76
66
  end
77
67
  end
78
68
  end
@@ -4,17 +4,7 @@ module Profiler
4
4
  module Api
5
5
  class TestsController < ApplicationController
6
6
  def index
7
- limit = (params[:limit] || 50).to_i
8
- offset = (params[:offset] || 0).to_i
9
- all = Profiler.storage.list(limit: 1000, offset: 0)
10
- tests = all.select { |p| p.profile_type == "test" }
11
- page = tests.drop(offset).first(limit + 1)
12
- render json: {
13
- profiles: page.first(limit).map(&:to_h),
14
- limit: limit,
15
- offset: offset,
16
- has_more: page.size > limit
17
- }
7
+ render_profile_page(type: "test")
18
8
  end
19
9
 
20
10
  def show
@@ -24,26 +24,6 @@ module Profiler
24
24
  events_cursor: events_cursor
25
25
  }
26
26
  end
27
-
28
- private
29
-
30
- def recalculate_ajax_data(profile)
31
- # Find AJAX collector in the configured collectors
32
- ajax_collector_class = Profiler::Collectors::AjaxCollector
33
-
34
- if Profiler.configuration.collectors.include?(ajax_collector_class)
35
- collector = ajax_collector_class.new(profile)
36
- collector.collect
37
-
38
- # Update tab metadata to reflect has_data status
39
- if profile.instance_variable_get(:@collectors_metadata)
40
- ajax_tab = profile.instance_variable_get(:@collectors_metadata).find { |tab| tab[:key] == 'ajax' }
41
- if ajax_tab
42
- ajax_tab[:has_data] = collector.has_data?
43
- end
44
- end
45
- end
46
- end
47
27
  end
48
28
  end
49
29
  end
@@ -6,6 +6,9 @@ module Profiler
6
6
  class ApplicationController < ActionController::Base
7
7
  layout "profiler/application"
8
8
 
9
+ # A store that cannot be created: the cause in one line, as a 503, for every page and API.
10
+ rescue_from Profiler::Storage::Unavailable::Error, with: :storage_unavailable
11
+
9
12
  before_action :check_authorization
10
13
  before_action :authorize_request
11
14
  # Declared after the access checks, so that an unauthorized request gets its 403 first.
@@ -48,6 +51,14 @@ module Profiler
48
51
  deny("Missing #{Profiler::FORGERY_PROTECTION_HEADER} header or CSRF token")
49
52
  end
50
53
 
54
+ def storage_unavailable(error)
55
+ if controller_path.start_with?("profiler/api/")
56
+ render json: { error: error.message }, status: :service_unavailable
57
+ else
58
+ render plain: error.message, status: :service_unavailable
59
+ end
60
+ end
61
+
51
62
  def deny(message)
52
63
  if controller_path.start_with?("profiler/api/")
53
64
  render json: { error: message }, status: :forbidden
@@ -56,22 +67,51 @@ module Profiler
56
67
  end
57
68
  end
58
69
 
70
+ # A page of one type of profile, filtered, ordered and paged by the store: one profile more
71
+ # than the limit tells whether there is a next page, at any offset.
72
+ def render_profile_page(type:, summary: true)
73
+ limit, offset = page_params
74
+ render_page(Profiler.storage.list(limit: limit + 1, offset: offset, type: type, summary: summary), limit, offset)
75
+ end
76
+
77
+ def render_page(profiles, limit, offset)
78
+ render json: {
79
+ profiles: profiles.first(limit).map(&:to_h),
80
+ limit: limit,
81
+ offset: offset,
82
+ has_more: profiles.size > limit
83
+ }
84
+ end
85
+
86
+ def page_params
87
+ [[(params[:limit] || 50).to_i, 0].max, [(params[:offset] || 0).to_i, 0].max]
88
+ end
89
+
90
+ # The children of a page, read once per request: the child jobs and the AJAX tab share them.
91
+ def child_profiles(profile)
92
+ @child_profiles ||= {}
93
+ @child_profiles[profile.token] ||= (@resolved_storage || Profiler.storage).find_by_parent(profile.token)
94
+ end
95
+
96
+ # The AJAX sub-requests are saved after the page: their tab is computed here, whatever the
97
+ # configured collector list (Profiler::AjaxData).
98
+ def recalculate_ajax_data(profile)
99
+ Profiler::AjaxData.attach(profile, child_profiles(profile))
100
+ end
101
+
59
102
  def build_child_jobs(profile)
60
- storage = @resolved_storage || Profiler.storage
61
- storage.find_by_parent(profile.token)
62
- .select { |p| p.profile_type == "job" }
63
- .map do |j|
64
- job_data = j.collector_data("job") || {}
65
- {
66
- token: j.token,
67
- job_class: j.path,
68
- job_id: job_data["job_id"],
69
- queue: job_data["queue"],
70
- status: job_data["status"],
71
- duration: j.duration,
72
- started_at: j.started_at&.iso8601
73
- }
74
- end
103
+ child_profiles(profile).select { |p| p.profile_type == "job" }.map do |j|
104
+ job_data = j.collector_data("job") || {}
105
+ {
106
+ token: j.token,
107
+ job_class: j.path,
108
+ job_id: job_data["job_id"],
109
+ queue: job_data["queue"],
110
+ status: job_data["status"],
111
+ duration: j.duration,
112
+ started_at: j.started_at&.iso8601
113
+ }
114
+ end
75
115
  end
76
116
 
77
117
  def build_parent_summary(profile)
@@ -100,7 +100,7 @@ module Profiler
100
100
  return profile
101
101
  end
102
102
  rescue => e
103
- Rails.logger.warn("[Profiler] Cached slave #{cached_slave} failed for #{token}: #{e.message}")
103
+ Profiler.log_warn("Cluster: cached slave #{cached_slave} failed for #{token}", e)
104
104
  end
105
105
  end
106
106
  Profiler.token_cache.invalidate(token)
@@ -130,7 +130,7 @@ module Profiler
130
130
  end
131
131
  end
132
132
  rescue => e
133
- Rails.logger.warn("[Profiler] Fan-out error for slave #{slave_name} (#{token}): #{e.message}")
133
+ Profiler.log_warn("Cluster: fan-out error for slave #{slave_name} (#{token})", e)
134
134
  end
135
135
  end
136
136
  end
@@ -144,22 +144,6 @@ module Profiler
144
144
  end
145
145
  end
146
146
 
147
- def recalculate_ajax_data(profile)
148
- ajax_collector_class = Profiler::Collectors::AjaxCollector
149
-
150
- if Profiler.configuration.collectors.include?(ajax_collector_class)
151
- collector = ajax_collector_class.new(profile, storage: @resolved_storage || Profiler.storage)
152
- collector.collect
153
-
154
- if profile.instance_variable_get(:@collectors_metadata)
155
- ajax_tab = profile.instance_variable_get(:@collectors_metadata).find { |tab| tab[:key] == 'ajax' }
156
- if ajax_tab
157
- ajax_tab[:has_data] = collector.has_data?
158
- end
159
- end
160
- end
161
- end
162
-
163
147
  def filter_profiles(profiles)
164
148
  filtered = profiles
165
149
 
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "collectors/ajax_collector"
4
+
5
+ module Profiler
6
+ # The AJAX tab of a page: its sub-requests are saved after the page itself, so the tab is
7
+ # computed when the page is shown, from its children, whatever the configured collector list.
8
+ # The children are the ones the caller already read (the same find_by_parent gives the child
9
+ # jobs): only the http ones are sub-requests, the jobs stay in child_jobs.
10
+ module AjaxData
11
+ # Hands the children already read to the AjaxCollector in place of the storage.
12
+ Children = Struct.new(:profiles) do
13
+ def find_by_parent(_parent_token)
14
+ profiles
15
+ end
16
+ end
17
+
18
+ module_function
19
+
20
+ # Adds the ajax collector data to profile, and its tab when it has none: a page without
21
+ # sub-requests and without the tab is left as it is.
22
+ def attach(profile, children)
23
+ requests = children.select { |child| child.profile_type == "http" }
24
+ # A copy: the memory store hands out the very tabs it keeps.
25
+ tabs = profile.collectors_metadata = (profile.collectors_metadata || []).map(&:dup)
26
+ tab = tabs.find { |entry| (entry[:key] || entry["key"]).to_s == "ajax" }
27
+ return if requests.empty? && tab.nil?
28
+
29
+ collector = Collectors::AjaxCollector.new(profile, storage: Children.new(requests))
30
+ collector.collect
31
+ if tab.nil?
32
+ profile.add_collector_metadata(collector)
33
+ elsif tab.key?("key")
34
+ tab["has_data"] = collector.has_data?
35
+ else
36
+ tab[:has_data] = collector.has_data?
37
+ end
38
+ end
39
+ end
40
+ end
@@ -14,7 +14,7 @@ module Profiler
14
14
  register!
15
15
  @registered = true
16
16
  rescue => e
17
- log_warn("Could not register with master at startup: #{e.message} — will retry in heartbeat loop")
17
+ log_warn("could not register with master at startup, will retry in the heartbeat loop", e)
18
18
  end
19
19
  start_heartbeat_thread
20
20
  end
@@ -25,7 +25,7 @@ module Profiler
25
25
  config = Profiler.configuration
26
26
  uri = master_uri("register")
27
27
  body = { name: config.resolved_name, url: config.self_url }.to_json
28
- resp = Net::HTTP.post(uri, body, request_headers)
28
+ resp = Profiler.untracked_http { Net::HTTP.post(uri, body, request_headers) }
29
29
  unless resp.code.to_i.between?(200, 299)
30
30
  raise "Master returned #{resp.code}: #{resp.body.to_s.slice(0, 200)}"
31
31
  end
@@ -37,7 +37,7 @@ module Profiler
37
37
  config = Profiler.configuration
38
38
  uri = master_uri("heartbeat")
39
39
  body = { name: config.resolved_name }.to_json
40
- resp = Net::HTTP.post(uri, body, request_headers)
40
+ resp = Profiler.untracked_http { Net::HTTP.post(uri, body, request_headers) }
41
41
  raise "Heartbeat rejected #{resp.code}" unless resp.code.to_i.between?(200, 299)
42
42
  end
43
43
 
@@ -74,25 +74,17 @@ module Profiler
74
74
  end
75
75
  rescue => e
76
76
  @registered = false
77
- log_warn("Cluster communication failed: #{e.message} — will retry")
77
+ log_warn("communication with the master failed, will retry", e)
78
78
  end
79
79
  end
80
80
  end
81
81
 
82
82
  def log_info(msg)
83
- if defined?(Rails)
84
- Rails.logger.info("[Profiler Cluster] #{msg}")
85
- else
86
- $stderr.puts("[Profiler Cluster] #{msg}")
87
- end
83
+ Profiler.log_info("Cluster: #{msg}")
88
84
  end
89
85
 
90
- def log_warn(msg)
91
- if defined?(Rails)
92
- Rails.logger.warn("[Profiler Cluster] #{msg}")
93
- else
94
- $stderr.puts("[Profiler Cluster] WARN #{msg}")
95
- end
86
+ def log_warn(msg, error = nil)
87
+ Profiler.log_warn("Cluster: #{msg}", error)
96
88
  end
97
89
  end
98
90
  end
@@ -44,9 +44,9 @@ module Profiler
44
44
  return unless config.cluster_master? || config.slave?
45
45
  return unless (problem = secret_problem) && secret_required?
46
46
 
47
- logger.warn("[Profiler Cluster] #{problem}: registrations, heartbeats and proxied requests " \
48
- "are refused. Generate one with `ruby -rsecurerandom -e 'puts SecureRandom.hex(32)'` " \
49
- "and give the same value to every node.")
47
+ Profiler.log_warn("Cluster: #{problem}: registrations, heartbeats and proxied requests " \
48
+ "are refused. Generate one with `ruby -rsecurerandom -e 'puts SecureRandom.hex(32)'` " \
49
+ "and give the same value to every node.", logger: logger)
50
50
  end
51
51
 
52
52
  # Constant-time comparison of the secret a request carries with the configured one.
@@ -33,11 +33,13 @@ module Profiler
33
33
 
34
34
  # Storage-compatible interface for MCP query tools
35
35
 
36
- def list(limit: 50, offset: 0, **)
37
- # all_types mirrors Profiler.storage.list, which returns every profile type;
38
- # without it the proxy would only ever see http profiles (see ProfilesController#index).
39
- data = get_json("/_profiler/api/profiles", limit: limit, offset: offset, all_types: true)
40
- Array(data["profiles"]).map { |h| profile_from_api(h) }
36
+ def list(limit: 50, offset: 0, type: nil, **)
37
+ # all_types mirrors Profiler.storage.list, which returns every profile type, or the one
38
+ # given as type; without it the proxy would only ever see http profiles (see
39
+ # ProfilesController#index). A slave of an earlier version ignores type: filtered here too.
40
+ data = get_json("/_profiler/api/profiles", limit: limit, offset: offset, all_types: true, type: type)
41
+ profiles = Array(data["profiles"]).map { |h| profile_from_api(h) }
42
+ type ? profiles.select { |p| p.profile_type == type.to_s } : profiles
41
43
  end
42
44
 
43
45
  # A token that is not one the gem issues is not found, without asking the slave.
@@ -113,10 +115,13 @@ module Profiler
113
115
  Security.outgoing_headers.each { |name, value| req[name] = value }
114
116
  # Net::HTTP never follows a redirect; a 3xx is reported, without its body, rather than
115
117
  # handed back as if the slave had answered.
116
- resp = Net::HTTP.start(uri.hostname, uri.port,
117
- open_timeout: @open_timeout, read_timeout: @read_timeout,
118
- use_ssl: uri.scheme == "https") do |http|
119
- http.request(req)
118
+ # The profiler's own call, never recorded in the profile of the request that makes it.
119
+ resp = Profiler.untracked_http do
120
+ Net::HTTP.start(uri.hostname, uri.port,
121
+ open_timeout: @open_timeout, read_timeout: @read_timeout,
122
+ use_ssl: uri.scheme == "https") do |http|
123
+ http.request(req)
124
+ end
120
125
  end
121
126
  return {} if resp.code == "204"
122
127
  if resp.code.to_s.start_with?("3")
@@ -13,7 +13,7 @@ module Profiler
13
13
  collectors.each { |collector| collector.subscribe if collector.respond_to?(:subscribe) }
14
14
  true
15
15
  rescue => e
16
- warn "Profiler #{label}: Collector subscribe failed: #{e.message}"
16
+ Profiler.log_error("#{label}: collector subscribe failed", e)
17
17
  release_all(collectors)
18
18
  false
19
19
  end
@@ -23,7 +23,7 @@ module Profiler
23
23
  collectors&.each do |collector|
24
24
  collector.unsubscribe if collector.respond_to?(:unsubscribe)
25
25
  rescue => e
26
- warn "Profiler: Collector #{collector.class} release failed: #{e.message}"
26
+ Profiler.log_error("collector #{collector.class} release failed", e)
27
27
  end
28
28
  end
29
29
  end