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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +129 -0
- data/app/assets/builds/profiler.js +9 -1
- data/app/controllers/profiler/api/console_controller.rb +1 -11
- data/app/controllers/profiler/api/jobs_controller.rb +1 -11
- data/app/controllers/profiler/api/profiles_controller.rb +18 -28
- data/app/controllers/profiler/api/tests_controller.rb +1 -11
- data/app/controllers/profiler/api/toolbar_controller.rb +0 -20
- data/app/controllers/profiler/application_controller.rb +55 -15
- data/app/controllers/profiler/profiles_controller.rb +2 -18
- data/lib/profiler/ajax_data.rb +40 -0
- data/lib/profiler/cluster/master_client.rb +7 -15
- data/lib/profiler/cluster/security.rb +3 -3
- data/lib/profiler/cluster/slave_proxy.rb +14 -9
- data/lib/profiler/collectors/lifecycle.rb +2 -2
- data/lib/profiler/collectors/log_collector.rb +54 -5
- data/lib/profiler/collectors/mailer_collector.rb +8 -4
- data/lib/profiler/collectors/request_collector.rb +6 -1
- data/lib/profiler/collectors/scoped_notifications.rb +1 -1
- data/lib/profiler/configuration.rb +30 -8
- data/lib/profiler/console_profiler.rb +1 -1
- data/lib/profiler/engine.rb +2 -1
- data/lib/profiler/env_override_store.rb +4 -4
- data/lib/profiler/explain_runner.rb +5 -1
- data/lib/profiler/instrumentation/net_http_instrumentation.rb +138 -40
- data/lib/profiler/job_profiler.rb +1 -1
- data/lib/profiler/local_request.rb +2 -6
- data/lib/profiler/mcp/file_cache.rb +2 -1
- data/lib/profiler/mcp/resources/failing_tests.rb +1 -1
- data/lib/profiler/mcp/resources/recent_console.rb +2 -2
- data/lib/profiler/mcp/resources/recent_jobs.rb +2 -2
- data/lib/profiler/mcp/resources/slow_tests.rb +1 -1
- data/lib/profiler/mcp/server.rb +9 -3
- data/lib/profiler/mcp/tools/get_profile_ajax.rb +2 -0
- data/lib/profiler/mcp/tools/get_profile_http.rb +6 -1
- data/lib/profiler/mcp/tools/get_test_profile_detail.rb +1 -1
- data/lib/profiler/mcp/tools/query_console_profiles.rb +6 -2
- data/lib/profiler/mcp/tools/query_jobs.rb +6 -2
- data/lib/profiler/mcp/tools/query_mailers.rb +5 -1
- data/lib/profiler/mcp/tools/query_profiles.rb +6 -2
- data/lib/profiler/mcp/tools/query_test_profiles.rb +6 -2
- data/lib/profiler/mcp/tools/run_tests.rb +5 -2
- data/lib/profiler/middleware/profiler_middleware.rb +22 -16
- data/lib/profiler/middleware/streamed_profile.rb +1 -1
- data/lib/profiler/middleware/toolbar_injector.rb +21 -2
- data/lib/profiler/models/profile.rb +60 -2
- data/lib/profiler/process_snapshot.rb +8 -3
- data/lib/profiler/railtie.rb +4 -1
- data/lib/profiler/redaction.rb +1 -3
- data/lib/profiler/sse/redis_event_bus.rb +2 -1
- data/lib/profiler/storage/base_store.rb +4 -2
- data/lib/profiler/storage/blob_store.rb +1 -1
- data/lib/profiler/storage/file_store.rb +337 -58
- data/lib/profiler/storage/memory_store.rb +60 -31
- data/lib/profiler/storage/private_files.rb +16 -3
- data/lib/profiler/storage/redis_store.rb +252 -33
- data/lib/profiler/storage/sqlite_store.rb +59 -11
- data/lib/profiler/storage/summary.rb +50 -0
- data/lib/profiler/storage/unavailable.rb +57 -0
- data/lib/profiler/test_helpers/reporter.rb +2 -2
- data/lib/profiler/test_profiler.rb +1 -1
- data/lib/profiler/test_runner/runner.rb +3 -7
- data/lib/profiler/version.rb +1 -1
- data/lib/profiler.rb +138 -4
- metadata +4 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 87fc21dec17ab7627d4430f49b042e53061efb33034d7309ea07aac6eb83ac5d
|
|
4
|
+
data.tar.gz: 3e64027bb71fa5ce3d2a23a23cf1525f0e5a55c508c019f1398c35c705f20434
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
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
|
|
61
|
-
|
|
62
|
-
|
|
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
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
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
|
-
|
|
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
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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("
|
|
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("
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
48
|
-
|
|
49
|
-
|
|
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
|
|
39
|
-
|
|
40
|
-
|
|
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
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
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
|
-
|
|
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
|
-
|
|
26
|
+
Profiler.log_error("collector #{collector.class} release failed", e)
|
|
27
27
|
end
|
|
28
28
|
end
|
|
29
29
|
end
|