rails-profiler 0.30.11 → 0.30.13
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 +75 -0
- data/app/assets/builds/profiler.js +20 -65
- data/app/controllers/profiler/api/toolbar_controller.rb +0 -2
- data/app/controllers/profiler/application_controller.rb +1 -3
- data/app/views/profiler/profiles/show.html.erb +3 -1
- data/lib/profiler/collectors/base_collector.rb +29 -0
- data/lib/profiler/collectors/cache_collector.rb +5 -1
- data/lib/profiler/collectors/database_collector.rb +8 -2
- data/lib/profiler/collectors/dump_collector.rb +10 -3
- data/lib/profiler/collectors/exception_collector.rb +8 -1
- data/lib/profiler/collectors/flamegraph_collector.rb +7 -3
- data/lib/profiler/collectors/function_profiler_collector.rb +43 -18
- data/lib/profiler/collectors/http_collector.rb +8 -2
- data/lib/profiler/collectors/i18n_collector.rb +8 -2
- data/lib/profiler/collectors/lifecycle.rb +31 -0
- data/lib/profiler/collectors/log_collector.rb +41 -16
- data/lib/profiler/collectors/mailer_collector.rb +12 -6
- data/lib/profiler/collectors/view_collector.rb +5 -1
- data/lib/profiler/console_profiler.rb +4 -1
- data/lib/profiler/job_profiler.rb +4 -1
- data/lib/profiler/middleware/profiler_middleware.rb +88 -33
- data/lib/profiler/middleware/toolbar_injector.rb +94 -6
- data/lib/profiler/test_profiler.rb +4 -1
- data/lib/profiler/version.rb +1 -1
- data/lib/profiler.rb +6 -4
- metadata +2 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 2613fd14e9adfbb7911645a1bd23fcd1043d4424f532e64c267601d021db7ad0
|
|
4
|
+
data.tar.gz: 1f474b73d65dd2aac23151a8ea4f61aba98399f7182cd7db9dda39947afb7b28
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 67dd8e4b429926a3fdef908c1491650c796497c177cb263cfdd94e7b14a5587a7a6ef48ea09c33b430ef5bca1ac0c921a4294d16c093127a39b1eac9db5c5cd3
|
|
7
|
+
data.tar.gz: 004f12ec514d606d212f49b0ac5c8c1f593437d62cf58e2a2b1242eef67fd144caa1ef165e8371947db4c9d8ae291a9bbdb83a2acf40058a8b53e5e50a78be71
|
data/CHANGELOG.md
CHANGED
|
@@ -19,6 +19,81 @@ every commit of every tag interval is accounted for one way or the other.
|
|
|
19
19
|
|
|
20
20
|
## [Unreleased]
|
|
21
21
|
|
|
22
|
+
## [0.30.13] - 2026-10-04
|
|
23
|
+
|
|
24
|
+
<!-- stamped -->
|
|
25
|
+
|
|
26
|
+
### Fixed
|
|
27
|
+
|
|
28
|
+
- **Middleware:** A request that raises below the profiler no longer runs your application a
|
|
29
|
+
second time, and no longer leaves the collectors installed. Until now the exception made the
|
|
30
|
+
profiler call the application again, duplicating its side effects (two emails sent, two
|
|
31
|
+
records written), and every SQL, view, cache, mailer and controller subscriber, the log sink on
|
|
32
|
+
`Rails.logger`, the StackProf sampler or the `TracePoint` of the function profiler stayed active
|
|
33
|
+
for the life of the process: memory and request time grew with each failed request until the
|
|
34
|
+
process was killed. Any exception between the profiler and `ShowExceptions` triggers it, for
|
|
35
|
+
example `ActionDispatch::RemoteIp::IpSpoofAttackError` on contradictory `Client-IP` and
|
|
36
|
+
`X-Forwarded-For` headers, and in tests every exception a controller raises. The exception now
|
|
37
|
+
goes on to the server unchanged, and the profile of the failed request is kept, with status 500
|
|
38
|
+
and the exception in the Exception tab.
|
|
39
|
+
- **Collectors:** Collectors gain `unsubscribe`, which releases what `subscribe` installed and runs
|
|
40
|
+
after `collect` and on every error path, for requests, jobs, console commands and tests alike.
|
|
41
|
+
A collector that fails to subscribe no longer leaves the others installed: the work runs once,
|
|
42
|
+
unprofiled. A custom collector that subscribes to something should implement `unsubscribe`, as
|
|
43
|
+
the README example now does.
|
|
44
|
+
- **Logs:** On Rails 7.0, the log collector no longer extends `Rails.logger` with a new module on
|
|
45
|
+
every request; one shared sink is attached once, and only records while a profile runs on the
|
|
46
|
+
thread.
|
|
47
|
+
- **Collectors:** A job performed inline during a profiled request (`perform_now`, or the
|
|
48
|
+
`:inline` adapter) no longer takes the request's records with it. The job's collectors now hand
|
|
49
|
+
back the thread-local slots they borrow, so the request keeps its logs, dumps, outbound HTTP
|
|
50
|
+
calls and timeline events from before and after the job, and the job's profile gets only what
|
|
51
|
+
the job did. Until now the job erased the request's logs and dumps recorded before it, and the
|
|
52
|
+
request lost its HTTP calls and timeline events from after it.
|
|
53
|
+
- **Middleware:** A request cut short by a timeout (`Timeout::ExitException`,
|
|
54
|
+
`Rack::Timeout::RequestTimeoutException`) keeps its profile too, with status 500. A request
|
|
55
|
+
stopped by a signal or by `exit` keeps none. An exception outside `StandardError` raised while
|
|
56
|
+
the collectors are being set up no longer leaves them installed.
|
|
57
|
+
- **Function profiler:** In sampling (`lite`) mode, a request no longer stops the StackProf
|
|
58
|
+
sampler that a concurrent request started, nor takes its samples: its profile says the sampler
|
|
59
|
+
was busy instead.
|
|
60
|
+
- **Dumps:** `Profiler.dump` called outside a profile that collects dumps (a request the profiler
|
|
61
|
+
skips, a test profile) no longer keeps the value on the thread. Nothing ever read those dumps,
|
|
62
|
+
and the thread held on to them, and to everything they referenced, for good.
|
|
63
|
+
|
|
64
|
+
## [0.30.12] - 2026-10-04
|
|
65
|
+
|
|
66
|
+
<!-- stamped -->
|
|
67
|
+
|
|
68
|
+
### Security
|
|
69
|
+
|
|
70
|
+
- **Toolbar:** The toolbar endpoint (`/_profiler/api/toolbar/:token`) now answers `403` when the
|
|
71
|
+
profiler is disabled, like the rest of the API. It used to keep serving any profile left in
|
|
72
|
+
storage, request headers and environment included, after `enabled = false`. The toolbar is only
|
|
73
|
+
injected while the profiler is enabled, so it is not affected.
|
|
74
|
+
- **Profile page:** The profile embedded as JSON in the profile page is now escaped explicitly
|
|
75
|
+
(`<`, `>`, `&`, U+2028 and U+2029), whatever the application sets for
|
|
76
|
+
`ActiveSupport.escape_html_entities_in_json`. An application that turned that setting off let a
|
|
77
|
+
captured value (a parameter, a header, a body) close the `<script>` element and run JavaScript
|
|
78
|
+
in the application's origin. The token and the CSP nonce written into the application's pages
|
|
79
|
+
by the toolbar injector are escaped too.
|
|
80
|
+
- **Dashboard:** Captured bodies offered for download are no longer typed with their captured
|
|
81
|
+
content type: opened in a tab, a `text/html` or `image/svg+xml` body ran its scripts in the
|
|
82
|
+
application's origin. Previews keep their type for raster images and PDF only. Email previews
|
|
83
|
+
are rendered in a fully sandboxed iframe, with an opaque origin. The unused SQL highlighter,
|
|
84
|
+
which wrote captured SQL into the page as HTML, is removed.
|
|
85
|
+
- **Access control:** A disabled profiler now answers its API requests with a JSON error, like
|
|
86
|
+
any other refusal of the API, instead of plain text.
|
|
87
|
+
- **Toolbar:** The toolbar is now injected before the `</body>` that closes the page, looked for
|
|
88
|
+
outside comments, tags (attributes read as the browser reads them, quoted values included) and
|
|
89
|
+
the elements whose content is not markup (`<script>`, `<style>`, `<textarea>`, `<title>`,
|
|
90
|
+
`<xmp>`, `<iframe>`, `<noembed>`, `<noframes>`, `<noscript>`), instead of the first `</body>`
|
|
91
|
+
found. A `</body>` inside a script string of the page used to receive it, and the toolbar's own
|
|
92
|
+
`</script>` then turned the rest of that string into live markup. A page whose only `</body>`
|
|
93
|
+
sits in one of those, that leaves a comment, a tag, a quoted value or one of those elements
|
|
94
|
+
open, or that holds a `<plaintext>`, a double-escaped script or a CDATA section with a `>`
|
|
95
|
+
before its end, gets no toolbar.
|
|
96
|
+
|
|
22
97
|
## [0.30.11] - 2026-10-04
|
|
23
98
|
|
|
24
99
|
<!-- stamped -->
|
|
@@ -4054,7 +4054,7 @@
|
|
|
4054
4054
|
function DownloadTextButton({ text, mime }) {
|
|
4055
4055
|
const [url, setUrl] = d2(null);
|
|
4056
4056
|
y2(() => {
|
|
4057
|
-
const objectUrl = URL.createObjectURL(new Blob([text], { type:
|
|
4057
|
+
const objectUrl = URL.createObjectURL(new Blob([text], { type: "application/octet-stream" }));
|
|
4058
4058
|
setUrl(objectUrl);
|
|
4059
4059
|
return () => URL.revokeObjectURL(objectUrl);
|
|
4060
4060
|
}, [text, mime]);
|
|
@@ -4142,11 +4142,17 @@
|
|
|
4142
4142
|
};
|
|
4143
4143
|
return map[category] || "text/plain";
|
|
4144
4144
|
}
|
|
4145
|
+
var INERT_PREVIEW_TYPES = ["image/png", "image/jpeg", "image/gif", "image/webp", "image/avif", "application/pdf"];
|
|
4146
|
+
function inertPreviewType(mime) {
|
|
4147
|
+
const m3 = mime.toLowerCase();
|
|
4148
|
+
return INERT_PREVIEW_TYPES.includes(m3) ? m3 : "application/octet-stream";
|
|
4149
|
+
}
|
|
4145
4150
|
var PREVIEW_LIMIT = 500;
|
|
4146
4151
|
var CSV_ROW_LIMIT = 10;
|
|
4147
4152
|
function SmartBodyPreview({ body, encoding, headers }) {
|
|
4148
4153
|
const [expanded, setExpanded] = d2(false);
|
|
4149
4154
|
const [objectUrl, setObjectUrl] = d2(null);
|
|
4155
|
+
const [downloadUrl, setDownloadUrl] = d2(null);
|
|
4150
4156
|
const category = detectContentType(headers);
|
|
4151
4157
|
y2(() => {
|
|
4152
4158
|
if (encoding !== "base64" || !body) return;
|
|
@@ -4154,19 +4160,27 @@
|
|
|
4154
4160
|
const raw = atob(body);
|
|
4155
4161
|
const bytes = new Uint8Array(raw.length);
|
|
4156
4162
|
for (let i3 = 0; i3 < raw.length; i3++) bytes[i3] = raw.charCodeAt(i3);
|
|
4157
|
-
const url = URL.createObjectURL(new Blob([bytes], { type: mime }));
|
|
4163
|
+
const url = URL.createObjectURL(new Blob([bytes], { type: inertPreviewType(mime) }));
|
|
4164
|
+
const download = URL.createObjectURL(new Blob([bytes], { type: "application/octet-stream" }));
|
|
4158
4165
|
setObjectUrl(url);
|
|
4159
|
-
|
|
4166
|
+
setDownloadUrl(download);
|
|
4167
|
+
return () => {
|
|
4168
|
+
URL.revokeObjectURL(url);
|
|
4169
|
+
URL.revokeObjectURL(download);
|
|
4170
|
+
};
|
|
4160
4171
|
}, [body, encoding, headers]);
|
|
4161
4172
|
if (!body) return /* @__PURE__ */ u3("span", { class: "profiler-text--muted profiler-text--xs", children: "empty" });
|
|
4162
4173
|
if (encoding === "base64") {
|
|
4163
4174
|
const mime = Object.entries(headers).find(([k3]) => k3.toLowerCase() === "content-type")?.[1]?.split(";")[0].trim() || "application/octet-stream";
|
|
4164
4175
|
const filename = `body${mimeToExt(mime)}`;
|
|
4165
4176
|
return /* @__PURE__ */ u3("div", { class: "profiler-body-binary", children: [
|
|
4166
|
-
objectUrl &&
|
|
4177
|
+
objectUrl && category === "image" && /* @__PURE__ */ u3("img", { src: objectUrl, alt: "response preview", style: "max-width:100%;max-height:300px;display:block;margin-bottom:8px;border-radius:4px;border:1px solid var(--profiler-border)" }),
|
|
4178
|
+
category === "svg" && // A data: URL rather than a blob: one: an <img> runs no script, and browsers refuse
|
|
4179
|
+
// to open a data: URL in a tab of its own.
|
|
4180
|
+
/* @__PURE__ */ u3("img", { src: `data:image/svg+xml;base64,${body}`, alt: "response preview", style: "max-width:100%;max-height:300px;display:block;margin-bottom:8px;border-radius:4px;border:1px solid var(--profiler-border)" }),
|
|
4167
4181
|
objectUrl && category === "pdf" && /* @__PURE__ */ u3("iframe", { src: objectUrl, class: "profiler-body-preview-frame", title: "PDF preview" }),
|
|
4168
4182
|
/* @__PURE__ */ u3("div", { style: "display:flex;gap:8px;margin-top:4px", children: [
|
|
4169
|
-
|
|
4183
|
+
downloadUrl && /* @__PURE__ */ u3("a", { href: downloadUrl, download: filename, class: "profiler-body-download-btn profiler-text--xs", children: [
|
|
4170
4184
|
"Download ",
|
|
4171
4185
|
mime
|
|
4172
4186
|
] }),
|
|
@@ -8615,7 +8629,7 @@
|
|
|
8615
8629
|
"iframe",
|
|
8616
8630
|
{
|
|
8617
8631
|
srcdoc: email.body_html ?? void 0,
|
|
8618
|
-
sandbox: "
|
|
8632
|
+
sandbox: "",
|
|
8619
8633
|
style: { width: "100%", height: "300px", border: "1px solid var(--profiler-border)", borderRadius: "var(--profiler-radius-md)", background: "#fff", display: "block" }
|
|
8620
8634
|
}
|
|
8621
8635
|
),
|
|
@@ -9421,62 +9435,6 @@ Duration: ${event.duration.toFixed(2)}ms`;
|
|
|
9421
9435
|
return "#dcdcaa";
|
|
9422
9436
|
}
|
|
9423
9437
|
|
|
9424
|
-
// app/assets/typescript/profiler/sql-formatter.ts
|
|
9425
|
-
var SQL_KEYWORDS = [
|
|
9426
|
-
"SELECT",
|
|
9427
|
-
"FROM",
|
|
9428
|
-
"WHERE",
|
|
9429
|
-
"INSERT",
|
|
9430
|
-
"UPDATE",
|
|
9431
|
-
"DELETE",
|
|
9432
|
-
"JOIN",
|
|
9433
|
-
"LEFT",
|
|
9434
|
-
"RIGHT",
|
|
9435
|
-
"INNER",
|
|
9436
|
-
"OUTER",
|
|
9437
|
-
"ON",
|
|
9438
|
-
"AS",
|
|
9439
|
-
"AND",
|
|
9440
|
-
"OR",
|
|
9441
|
-
"NOT",
|
|
9442
|
-
"IN",
|
|
9443
|
-
"EXISTS",
|
|
9444
|
-
"LIKE",
|
|
9445
|
-
"ORDER",
|
|
9446
|
-
"BY",
|
|
9447
|
-
"GROUP",
|
|
9448
|
-
"HAVING",
|
|
9449
|
-
"LIMIT",
|
|
9450
|
-
"OFFSET",
|
|
9451
|
-
"DISTINCT",
|
|
9452
|
-
"COUNT",
|
|
9453
|
-
"SUM",
|
|
9454
|
-
"AVG",
|
|
9455
|
-
"MAX",
|
|
9456
|
-
"MIN",
|
|
9457
|
-
"CASE",
|
|
9458
|
-
"WHEN",
|
|
9459
|
-
"THEN",
|
|
9460
|
-
"ELSE",
|
|
9461
|
-
"END"
|
|
9462
|
-
];
|
|
9463
|
-
function formatSQL(element) {
|
|
9464
|
-
const sql = element.textContent || "";
|
|
9465
|
-
const formatted = highlightSQL(sql);
|
|
9466
|
-
element.innerHTML = formatted;
|
|
9467
|
-
}
|
|
9468
|
-
function highlightSQL(sql) {
|
|
9469
|
-
let highlighted = sql;
|
|
9470
|
-
SQL_KEYWORDS.forEach((keyword) => {
|
|
9471
|
-
const regex = new RegExp(`\\b${keyword}\\b`, "gi");
|
|
9472
|
-
highlighted = highlighted.replace(regex, `<span class="sql-keyword">${keyword}</span>`);
|
|
9473
|
-
});
|
|
9474
|
-
highlighted = highlighted.replace(/'([^']*)'/g, `<span class="sql-string">'$1'</span>`);
|
|
9475
|
-
highlighted = highlighted.replace(/\b(\d+)\b/g, '<span class="sql-number">$1</span>');
|
|
9476
|
-
highlighted = highlighted.replace(/--([^\n]*)/g, '<span class="sql-comment">--$1</span>');
|
|
9477
|
-
return highlighted;
|
|
9478
|
-
}
|
|
9479
|
-
|
|
9480
9438
|
// app/assets/typescript/profiler/theme.ts
|
|
9481
9439
|
var STORAGE_KEY2 = "profiler-theme";
|
|
9482
9440
|
var THEME_ATTRIBUTE = "data-theme";
|
|
@@ -9663,8 +9621,5 @@ Duration: ${event.duration.toFixed(2)}ms`;
|
|
|
9663
9621
|
if (timeline) {
|
|
9664
9622
|
initTimeline(timeline);
|
|
9665
9623
|
}
|
|
9666
|
-
document.querySelectorAll('pre[data-language="sql"]').forEach((block) => {
|
|
9667
|
-
formatSQL(block);
|
|
9668
|
-
});
|
|
9669
9624
|
});
|
|
9670
9625
|
})();
|
|
@@ -14,9 +14,7 @@ module Profiler
|
|
|
14
14
|
private
|
|
15
15
|
|
|
16
16
|
def check_authorization
|
|
17
|
-
unless Profiler.configuration.enabled
|
|
18
|
-
render plain: "Profiler is disabled", status: :forbidden
|
|
19
|
-
end
|
|
17
|
+
deny("Profiler is disabled") unless Profiler.configuration.enabled
|
|
20
18
|
end
|
|
21
19
|
|
|
22
20
|
def authorize_request
|
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
<div id="profiler-show" data-embedded="<%= @embedded %>"></div>
|
|
2
|
+
<%# json_escape turns <, >, & and U+2028/U+2029 into \u escapes, so that no captured value can
|
|
3
|
+
close the element, whatever the application sets for escape_html_entities_in_json. %>
|
|
2
4
|
<script type="application/json" id="profiler-show-data">
|
|
3
|
-
<%= @profile_data.to_json
|
|
5
|
+
<%= raw json_escape(@profile_data.to_json) %>
|
|
4
6
|
</script>
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require_relative "../redaction"
|
|
4
|
+
require_relative "lifecycle"
|
|
4
5
|
|
|
5
6
|
module Profiler
|
|
6
7
|
module Collectors
|
|
@@ -59,6 +60,13 @@ module Profiler
|
|
|
59
60
|
# Override in subclasses to subscribe to ActiveSupport::Notifications
|
|
60
61
|
end
|
|
61
62
|
|
|
63
|
+
# Releases whatever subscribe installed: notification subscribers, samplers, log sinks,
|
|
64
|
+
# thread-local slots. Called after collect, and on every path where collect never runs,
|
|
65
|
+
# so it has to be idempotent and safe when subscribe failed part way or never ran.
|
|
66
|
+
def unsubscribe
|
|
67
|
+
# Override in subclasses that install something in subscribe
|
|
68
|
+
end
|
|
69
|
+
|
|
62
70
|
def collect
|
|
63
71
|
# Override in subclasses to collect data
|
|
64
72
|
end
|
|
@@ -89,6 +97,27 @@ module Profiler
|
|
|
89
97
|
@data = data
|
|
90
98
|
@profile.add_collector_data(name, data)
|
|
91
99
|
end
|
|
100
|
+
|
|
101
|
+
# Thread-local slots follow a stack: subscribe takes one over and remembers what it held,
|
|
102
|
+
# unsubscribe hands it back. A job performed inline during a request thus records into
|
|
103
|
+
# its own slots, and the request gets its own back, with what it recorded before the job.
|
|
104
|
+
def claim_thread_slot(key, value)
|
|
105
|
+
@claimed_thread_slots ||= {}
|
|
106
|
+
@claimed_thread_slots[key] = Thread.current[key] unless @claimed_thread_slots.key?(key)
|
|
107
|
+
Thread.current[key] = value
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def restore_thread_slots
|
|
111
|
+
@claimed_thread_slots&.each { |key, previous| Thread.current[key] = previous }
|
|
112
|
+
@claimed_thread_slots = nil
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def unsubscribe_notifications(subscriptions)
|
|
116
|
+
return unless defined?(ActiveSupport::Notifications)
|
|
117
|
+
|
|
118
|
+
subscriptions.each { |sub| ActiveSupport::Notifications.unsubscribe(sub) }
|
|
119
|
+
subscriptions.clear
|
|
120
|
+
end
|
|
92
121
|
end
|
|
93
122
|
end
|
|
94
123
|
end
|
|
@@ -62,7 +62,7 @@ module Profiler
|
|
|
62
62
|
end
|
|
63
63
|
|
|
64
64
|
def collect
|
|
65
|
-
|
|
65
|
+
unsubscribe
|
|
66
66
|
|
|
67
67
|
hits = @cache_reads.count { |r| r[:hit] }
|
|
68
68
|
misses = @cache_reads.count { |r| !r[:hit] }
|
|
@@ -82,6 +82,10 @@ module Profiler
|
|
|
82
82
|
store_data(data)
|
|
83
83
|
end
|
|
84
84
|
|
|
85
|
+
def unsubscribe
|
|
86
|
+
unsubscribe_notifications(@subscriptions)
|
|
87
|
+
end
|
|
88
|
+
|
|
85
89
|
def toolbar_summary
|
|
86
90
|
hits = @cache_reads.count { |r| r[:hit] }
|
|
87
91
|
total = @cache_reads.size
|
|
@@ -55,8 +55,7 @@ module Profiler
|
|
|
55
55
|
end
|
|
56
56
|
|
|
57
57
|
def collect
|
|
58
|
-
|
|
59
|
-
ActiveSupport::Notifications.unsubscribe(@subscription) if @subscription
|
|
58
|
+
unsubscribe
|
|
60
59
|
|
|
61
60
|
data = {
|
|
62
61
|
total_queries: @queries.size,
|
|
@@ -69,6 +68,13 @@ module Profiler
|
|
|
69
68
|
store_data(data)
|
|
70
69
|
end
|
|
71
70
|
|
|
71
|
+
def unsubscribe
|
|
72
|
+
return unless @subscription
|
|
73
|
+
|
|
74
|
+
ActiveSupport::Notifications.unsubscribe(@subscription)
|
|
75
|
+
@subscription = nil
|
|
76
|
+
end
|
|
77
|
+
|
|
72
78
|
def toolbar_summary
|
|
73
79
|
total = @queries.size
|
|
74
80
|
slow = @queries.select { |q| q.slow?(Profiler.configuration.slow_query_threshold) }.size
|
|
@@ -30,7 +30,8 @@ module Profiler
|
|
|
30
30
|
end
|
|
31
31
|
|
|
32
32
|
def collect
|
|
33
|
-
dumps = Thread.current[:profiler_dumps] || []
|
|
33
|
+
dumps = @dumps || Thread.current[:profiler_dumps] || []
|
|
34
|
+
unsubscribe
|
|
34
35
|
|
|
35
36
|
formatted_dumps = dumps.map do |dump|
|
|
36
37
|
{
|
|
@@ -47,9 +48,15 @@ module Profiler
|
|
|
47
48
|
count: formatted_dumps.size,
|
|
48
49
|
dumps: formatted_dumps
|
|
49
50
|
})
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Profiler.dump fills this slot while the profile runs.
|
|
54
|
+
def subscribe
|
|
55
|
+
@dumps = claim_thread_slot(:profiler_dumps, [])
|
|
56
|
+
end
|
|
50
57
|
|
|
51
|
-
|
|
52
|
-
|
|
58
|
+
def unsubscribe
|
|
59
|
+
restore_thread_slots
|
|
53
60
|
end
|
|
54
61
|
|
|
55
62
|
def toolbar_summary
|
|
@@ -43,11 +43,18 @@ module Profiler
|
|
|
43
43
|
end
|
|
44
44
|
|
|
45
45
|
def collect
|
|
46
|
-
|
|
46
|
+
unsubscribe
|
|
47
47
|
|
|
48
48
|
store_data(@exception_data || {})
|
|
49
49
|
end
|
|
50
50
|
|
|
51
|
+
def unsubscribe
|
|
52
|
+
return unless @subscriber
|
|
53
|
+
|
|
54
|
+
ActiveSupport::Notifications.unsubscribe(@subscriber)
|
|
55
|
+
@subscriber = nil
|
|
56
|
+
end
|
|
57
|
+
|
|
51
58
|
def has_data?
|
|
52
59
|
!@exception_data.nil? && !@exception_data.empty?
|
|
53
60
|
end
|
|
@@ -35,7 +35,7 @@ module Profiler
|
|
|
35
35
|
def subscribe
|
|
36
36
|
return unless defined?(ActiveSupport::Notifications)
|
|
37
37
|
|
|
38
|
-
|
|
38
|
+
claim_thread_slot(:profiler_flamegraph_collector, self)
|
|
39
39
|
|
|
40
40
|
# Controller action
|
|
41
41
|
@subscriptions << ActiveSupport::Notifications.monotonic_subscribe("process_action.action_controller") do |_name, started, finished, _unique_id, payload|
|
|
@@ -133,8 +133,7 @@ module Profiler
|
|
|
133
133
|
end
|
|
134
134
|
|
|
135
135
|
def collect
|
|
136
|
-
|
|
137
|
-
Thread.current[:profiler_flamegraph_collector] = nil
|
|
136
|
+
unsubscribe
|
|
138
137
|
|
|
139
138
|
root_events = build_hierarchy(@events)
|
|
140
139
|
|
|
@@ -145,6 +144,11 @@ module Profiler
|
|
|
145
144
|
})
|
|
146
145
|
end
|
|
147
146
|
|
|
147
|
+
def unsubscribe
|
|
148
|
+
unsubscribe_notifications(@subscriptions)
|
|
149
|
+
restore_thread_slots
|
|
150
|
+
end
|
|
151
|
+
|
|
148
152
|
def toolbar_summary
|
|
149
153
|
{
|
|
150
154
|
text: "#{@events.size} events",
|
|
@@ -55,42 +55,66 @@ module Profiler
|
|
|
55
55
|
|
|
56
56
|
mode = Profiler.function_profiling_mode
|
|
57
57
|
clock = Profiler.function_profiling_clock
|
|
58
|
-
|
|
59
|
-
|
|
58
|
+
@subscribed = true
|
|
59
|
+
claim_thread_slot(:fn_profiler_mode, mode)
|
|
60
|
+
claim_thread_slot(:fn_profiler_clock, clock)
|
|
60
61
|
|
|
61
62
|
if mode == "lite" && defined?(StackProf)
|
|
62
63
|
# Always record wall + cpu at request level for the comparison stat
|
|
63
|
-
|
|
64
|
-
|
|
64
|
+
claim_thread_slot(:fn_profiler_wall_start, Process.clock_gettime(Process::CLOCK_MONOTONIC))
|
|
65
|
+
claim_thread_slot(:fn_profiler_cpu_start, Process.clock_gettime(Process::CLOCK_PROCESS_CPUTIME_ID))
|
|
65
66
|
|
|
66
67
|
sp_mode = case clock
|
|
67
68
|
when "cpu" then :cpu
|
|
68
69
|
when "object" then :object
|
|
69
70
|
else :wall
|
|
70
71
|
end
|
|
71
|
-
|
|
72
|
+
# false when another request already runs the process-wide sampler: then it is not
|
|
73
|
+
# this collector's to stop on release.
|
|
74
|
+
@stackprof_started = StackProf.start(mode: sp_mode, interval: 1000, raw: true)
|
|
72
75
|
else
|
|
73
76
|
subscribe_tracepoint(mode)
|
|
74
77
|
end
|
|
75
78
|
end
|
|
76
79
|
|
|
77
80
|
def collect
|
|
81
|
+
# Disabled: subscribe stored the "off" data, and there is no sampler of ours to read.
|
|
82
|
+
return unless @subscribed
|
|
83
|
+
|
|
78
84
|
mode = Thread.current[:fn_profiler_mode] || Profiler.function_profiling_mode
|
|
79
85
|
clock = Thread.current[:fn_profiler_clock] || Profiler.function_profiling_clock
|
|
80
|
-
Thread.current[:fn_profiler_mode] = nil
|
|
81
|
-
Thread.current[:fn_profiler_clock] = nil
|
|
82
86
|
|
|
83
87
|
if mode == "lite" && defined?(StackProf)
|
|
88
|
+
# The sampler is process-wide: when another request started it, its samples are not
|
|
89
|
+
# this request's to take, nor is it this collector's to stop.
|
|
90
|
+
return store_sampler_busy(mode, clock) unless @stackprof_started
|
|
91
|
+
|
|
84
92
|
StackProf.stop
|
|
93
|
+
@stackprof_started = false
|
|
85
94
|
result = StackProf.results
|
|
86
95
|
wall_ms = (Process.clock_gettime(Process::CLOCK_MONOTONIC) - (Thread.current[:fn_profiler_wall_start] || 0)) * 1000
|
|
87
96
|
cpu_ms = (Process.clock_gettime(Process::CLOCK_PROCESS_CPUTIME_ID) - (Thread.current[:fn_profiler_cpu_start] || 0)) * 1000
|
|
88
|
-
Thread.current[:fn_profiler_wall_start] = nil
|
|
89
|
-
Thread.current[:fn_profiler_cpu_start] = nil
|
|
90
97
|
collect_stackprof(result, mode, clock, wall_ms, cpu_ms)
|
|
91
98
|
else
|
|
92
99
|
collect_tracepoint(mode)
|
|
93
100
|
end
|
|
101
|
+
ensure
|
|
102
|
+
unsubscribe
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def unsubscribe
|
|
106
|
+
if @stackprof_started
|
|
107
|
+
@stackprof_started = false
|
|
108
|
+
if StackProf.running?
|
|
109
|
+
StackProf.stop
|
|
110
|
+
StackProf.results # discards the samples and frees the sampler's buffers
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
@trace&.disable
|
|
115
|
+
@trace = nil
|
|
116
|
+
|
|
117
|
+
restore_thread_slots
|
|
94
118
|
end
|
|
95
119
|
|
|
96
120
|
def toolbar_summary
|
|
@@ -105,6 +129,12 @@ module Profiler
|
|
|
105
129
|
|
|
106
130
|
# ── StackProf (lite mode) ──────────────────────────────────────────────────
|
|
107
131
|
|
|
132
|
+
def store_sampler_busy(mode, clock)
|
|
133
|
+
store_data({ enabled: true, mode: mode, clock: clock, sampler_busy: true, max_frames: 0,
|
|
134
|
+
frame_cap_reached: false, total_calls: 0, total_duration: 0,
|
|
135
|
+
functions: [], root_calls: [] })
|
|
136
|
+
end
|
|
137
|
+
|
|
108
138
|
def collect_stackprof(result, mode, clock, wall_ms, cpu_ms)
|
|
109
139
|
unless result
|
|
110
140
|
store_data({ enabled: true, mode: mode, clock: clock, max_frames: 0,
|
|
@@ -261,10 +291,10 @@ module Profiler
|
|
|
261
291
|
app_root = app_root_path
|
|
262
292
|
max_frames = Profiler.function_profiling_max_frames
|
|
263
293
|
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
294
|
+
claim_thread_slot(:fn_profiler_stack, [])
|
|
295
|
+
claim_thread_slot(:fn_profiler_roots, [])
|
|
296
|
+
claim_thread_slot(:fn_profiler_count, 0)
|
|
297
|
+
claim_thread_slot(:fn_profiler_depth, Hash.new(0))
|
|
268
298
|
|
|
269
299
|
@trace = TracePoint.new(:call, :return) do |tp|
|
|
270
300
|
next unless tp.path&.start_with?(app_root)
|
|
@@ -336,11 +366,6 @@ module Profiler
|
|
|
336
366
|
end
|
|
337
367
|
end
|
|
338
368
|
|
|
339
|
-
Thread.current[:fn_profiler_stack] = nil
|
|
340
|
-
Thread.current[:fn_profiler_roots] = nil
|
|
341
|
-
Thread.current[:fn_profiler_count] = nil
|
|
342
|
-
Thread.current[:fn_profiler_depth] = nil
|
|
343
|
-
|
|
344
369
|
stats = {}
|
|
345
370
|
aggregate(roots, stats)
|
|
346
371
|
sorted_functions = stats.values.sort_by { |s| -s[:total_duration] }
|
|
@@ -36,11 +36,17 @@ module Profiler
|
|
|
36
36
|
return unless Profiler.configuration.track_http
|
|
37
37
|
|
|
38
38
|
Profiler::Instrumentation::NetHttpInstrumentation.install!
|
|
39
|
-
|
|
39
|
+
claim_thread_slot(:profiler_http_collector, self)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# The Net::HTTP patch installed by subscribe stays: it is installed once per process and
|
|
43
|
+
# records nothing when no collector holds the thread-local slot.
|
|
44
|
+
def unsubscribe
|
|
45
|
+
restore_thread_slots
|
|
40
46
|
end
|
|
41
47
|
|
|
42
48
|
def collect
|
|
43
|
-
|
|
49
|
+
unsubscribe
|
|
44
50
|
|
|
45
51
|
data = @mutex.synchronize do
|
|
46
52
|
@collected = true
|
|
@@ -53,11 +53,17 @@ module Profiler
|
|
|
53
53
|
I18n.singleton_class.prepend(Profiler::I18nLookupTracker)
|
|
54
54
|
end
|
|
55
55
|
|
|
56
|
-
|
|
56
|
+
claim_thread_slot(:profiler_i18n_collector, self)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# The I18n patch installed by subscribe stays: it is prepended once per process and
|
|
60
|
+
# records nothing when no collector holds the thread-local slot.
|
|
61
|
+
def unsubscribe
|
|
62
|
+
restore_thread_slots
|
|
57
63
|
end
|
|
58
64
|
|
|
59
65
|
def collect
|
|
60
|
-
|
|
66
|
+
unsubscribe
|
|
61
67
|
|
|
62
68
|
missing_count = @lookups.count { |l| l[:missing] }
|
|
63
69
|
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Profiler
|
|
4
|
+
module Collectors
|
|
5
|
+
# Subscribes and releases the collectors of one profile, for every entry point that runs
|
|
6
|
+
# them (the request middleware, jobs, console commands, tests).
|
|
7
|
+
module Lifecycle
|
|
8
|
+
module_function
|
|
9
|
+
|
|
10
|
+
# Subscribes each collector in turn. When one fails, releases them all, the ones that
|
|
11
|
+
# never subscribed included, and returns false: the caller then runs the work unprofiled.
|
|
12
|
+
def subscribe_all(collectors, label)
|
|
13
|
+
collectors.each { |collector| collector.subscribe if collector.respond_to?(:subscribe) }
|
|
14
|
+
true
|
|
15
|
+
rescue => e
|
|
16
|
+
warn "Profiler #{label}: Collector subscribe failed: #{e.message}"
|
|
17
|
+
release_all(collectors)
|
|
18
|
+
false
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# Releases every collector, whatever happened before, one failure not stopping the others.
|
|
22
|
+
def release_all(collectors)
|
|
23
|
+
collectors&.each do |collector|
|
|
24
|
+
collector.unsubscribe if collector.respond_to?(:unsubscribe)
|
|
25
|
+
rescue => e
|
|
26
|
+
warn "Profiler: Collector #{collector.class} release failed: #{e.message}"
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
@@ -8,18 +8,26 @@ module Profiler
|
|
|
8
8
|
class LogCollector < BaseCollector
|
|
9
9
|
SEVERITY_LABELS = %w[DEBUG INFO WARN ERROR FATAL UNKNOWN].freeze
|
|
10
10
|
|
|
11
|
+
# Records into the thread's buffer, which only exists while a LogCollector of this thread
|
|
12
|
+
# is subscribed: lines logged by other threads, or after release, are not kept. A logger
|
|
13
|
+
# bound to one collector's buffer records only while that buffer is the thread's current
|
|
14
|
+
# one, so a job performed inline during a request does not log twice into the job.
|
|
11
15
|
class CaptureLogger < ::Logger
|
|
12
|
-
def initialize
|
|
16
|
+
def initialize(buffer = nil)
|
|
13
17
|
super(File::NULL)
|
|
18
|
+
@buffer = buffer
|
|
14
19
|
end
|
|
15
20
|
|
|
16
21
|
def add(severity, message = nil, progname = nil)
|
|
22
|
+
logs = Thread.current[:profiler_logs]
|
|
23
|
+
return true unless logs
|
|
24
|
+
return true if @buffer && !logs.equal?(@buffer)
|
|
25
|
+
|
|
17
26
|
msg = message || progname
|
|
18
27
|
msg = yield if block_given? && msg.nil?
|
|
19
28
|
return true if msg.nil?
|
|
20
29
|
|
|
21
|
-
|
|
22
|
-
Thread.current[:profiler_logs] << {
|
|
30
|
+
logs << {
|
|
23
31
|
level: SEVERITY_LABELS[severity] || "UNKNOWN",
|
|
24
32
|
message: msg.to_s.strip,
|
|
25
33
|
timestamp: Time.now.iso8601(3)
|
|
@@ -55,31 +63,48 @@ module Profiler
|
|
|
55
63
|
}
|
|
56
64
|
end
|
|
57
65
|
|
|
66
|
+
# Rails 7.0 broadcasts by extending the logger with a module, which cannot be taken off
|
|
67
|
+
# again: the logger gets one shared CaptureLogger, once, instead of one per request.
|
|
68
|
+
LEGACY_CAPTURE_LOGGER = CaptureLogger.new
|
|
69
|
+
LEGACY_BROADCAST_MUTEX = Mutex.new
|
|
70
|
+
|
|
71
|
+
def self.attach_legacy_broadcast(logger)
|
|
72
|
+
LEGACY_BROADCAST_MUTEX.synchronize do
|
|
73
|
+
return if logger.instance_variable_get(:@profiler_capture_attached)
|
|
74
|
+
|
|
75
|
+
logger.extend(ActiveSupport::Logger.broadcast(LEGACY_CAPTURE_LOGGER))
|
|
76
|
+
logger.instance_variable_set(:@profiler_capture_attached, true)
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
|
|
58
80
|
def subscribe
|
|
59
|
-
|
|
60
|
-
@capture_logger = CaptureLogger.new
|
|
81
|
+
@logs = claim_thread_slot(:profiler_logs, [])
|
|
61
82
|
|
|
62
83
|
if defined?(Rails) && Rails.logger
|
|
63
84
|
if Rails.logger.respond_to?(:broadcast_to)
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
85
|
+
@capture_logger = CaptureLogger.new(@logs)
|
|
86
|
+
@broadcaster = Rails.logger
|
|
87
|
+
@broadcaster.broadcast_to(@capture_logger)
|
|
88
|
+
elsif defined?(ActiveSupport::Logger) && ActiveSupport::Logger.respond_to?(:broadcast)
|
|
89
|
+
self.class.attach_legacy_broadcast(Rails.logger)
|
|
67
90
|
end
|
|
68
91
|
end
|
|
69
92
|
rescue => e
|
|
70
93
|
warn "LogCollector#subscribe failed: #{e.message}"
|
|
71
94
|
end
|
|
72
95
|
|
|
73
|
-
def
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
Rails.logger.stop_broadcasting_to(@capture_logger)
|
|
79
|
-
end
|
|
96
|
+
def unsubscribe
|
|
97
|
+
if @capture_logger
|
|
98
|
+
@broadcaster.stop_broadcasting_to(@capture_logger) if @broadcaster.respond_to?(:stop_broadcasting_to)
|
|
99
|
+
@capture_logger = nil
|
|
100
|
+
@broadcaster = nil
|
|
80
101
|
end
|
|
102
|
+
restore_thread_slots
|
|
103
|
+
end
|
|
81
104
|
|
|
82
|
-
|
|
105
|
+
def collect
|
|
106
|
+
logs = @logs || Thread.current[:profiler_logs] || []
|
|
107
|
+
unsubscribe
|
|
83
108
|
|
|
84
109
|
errors = logs.count { |l| %w[ERROR FATAL].include?(l[:level]) }
|
|
85
110
|
warnings = logs.count { |l| l[:level] == "WARN" }
|
|
@@ -45,7 +45,7 @@ module Profiler
|
|
|
45
45
|
@subscriber_thread = Thread.current
|
|
46
46
|
|
|
47
47
|
# Use a stack so multiple deliver_later calls in the same request are all tracked.
|
|
48
|
-
|
|
48
|
+
claim_thread_slot(:profiler_pending_processes, [])
|
|
49
49
|
|
|
50
50
|
@subscriptions << ActiveSupport::Notifications.monotonic_subscribe("process.action_mailer") do |_name, started, finished, _id, payload|
|
|
51
51
|
next unless Thread.current.equal?(@subscriber_thread)
|
|
@@ -53,7 +53,10 @@ module Profiler
|
|
|
53
53
|
|
|
54
54
|
mailer_class = payload[:mailer].to_s
|
|
55
55
|
action = payload[:action].to_s
|
|
56
|
-
|
|
56
|
+
pending = Thread.current[:profiler_pending_processes]
|
|
57
|
+
next unless pending
|
|
58
|
+
|
|
59
|
+
pending << {
|
|
57
60
|
mailer_class: mailer_class,
|
|
58
61
|
action: action,
|
|
59
62
|
duration_ms: ((finished - started) * 1000).round(2),
|
|
@@ -67,7 +70,7 @@ module Profiler
|
|
|
67
70
|
next if rails_preview_request?
|
|
68
71
|
|
|
69
72
|
delivery_ms = ((finished - started) * 1000).round(2)
|
|
70
|
-
process_info =
|
|
73
|
+
process_info = Thread.current[:profiler_pending_processes]&.pop || {}
|
|
71
74
|
mail = payload[:mail]
|
|
72
75
|
|
|
73
76
|
mailer_class = process_info[:mailer_class] || payload[:mailer_class].to_s
|
|
@@ -87,7 +90,7 @@ module Profiler
|
|
|
87
90
|
# Any remaining pending process entries had no matching deliver event:
|
|
88
91
|
# they were enqueued via deliver_later in the HTTP context.
|
|
89
92
|
pending = Thread.current[:profiler_pending_processes] || []
|
|
90
|
-
|
|
93
|
+
unsubscribe
|
|
91
94
|
|
|
92
95
|
pending.each do |info|
|
|
93
96
|
@queued << {
|
|
@@ -102,8 +105,6 @@ module Profiler
|
|
|
102
105
|
}
|
|
103
106
|
end
|
|
104
107
|
|
|
105
|
-
@subscriptions.each { |sub| ActiveSupport::Notifications.unsubscribe(sub) }
|
|
106
|
-
|
|
107
108
|
detect_loops
|
|
108
109
|
|
|
109
110
|
truncated = @emails.size > MAX_EMAILS
|
|
@@ -124,6 +125,11 @@ module Profiler
|
|
|
124
125
|
)
|
|
125
126
|
end
|
|
126
127
|
|
|
128
|
+
def unsubscribe
|
|
129
|
+
unsubscribe_notifications(@subscriptions)
|
|
130
|
+
restore_thread_slots
|
|
131
|
+
end
|
|
132
|
+
|
|
127
133
|
def has_data?
|
|
128
134
|
@emails.any? || @errors.any? || @queued.any?
|
|
129
135
|
end
|
|
@@ -53,7 +53,7 @@ module Profiler
|
|
|
53
53
|
end
|
|
54
54
|
|
|
55
55
|
def collect
|
|
56
|
-
|
|
56
|
+
unsubscribe
|
|
57
57
|
|
|
58
58
|
data = {
|
|
59
59
|
views: @views,
|
|
@@ -66,6 +66,10 @@ module Profiler
|
|
|
66
66
|
store_data(data)
|
|
67
67
|
end
|
|
68
68
|
|
|
69
|
+
def unsubscribe
|
|
70
|
+
unsubscribe_notifications(@subscriptions)
|
|
71
|
+
end
|
|
72
|
+
|
|
69
73
|
def toolbar_summary
|
|
70
74
|
total_duration = (@views + @partials).sum { |v| v[:duration] }.round(2)
|
|
71
75
|
|
|
@@ -44,7 +44,7 @@ module Profiler
|
|
|
44
44
|
|
|
45
45
|
console_collector = Collectors::ConsoleCollector.new(profile, expression: @expression)
|
|
46
46
|
collectors = [console_collector] + CONSOLE_COLLECTOR_CLASSES.map { |klass| klass.new(profile) }
|
|
47
|
-
|
|
47
|
+
return block.call unless Collectors::Lifecycle.subscribe_all(collectors, "ConsoleProfiler")
|
|
48
48
|
|
|
49
49
|
exception_collector = collectors.find { |c| c.is_a?(Collectors::ExceptionCollector) }
|
|
50
50
|
|
|
@@ -82,6 +82,9 @@ module Profiler
|
|
|
82
82
|
|
|
83
83
|
Profiler.storage.save(profile.token, profile)
|
|
84
84
|
end
|
|
85
|
+
ensure
|
|
86
|
+
# After collect, and also when collect or the storage failed.
|
|
87
|
+
Collectors::Lifecycle.release_all(collectors)
|
|
85
88
|
end
|
|
86
89
|
|
|
87
90
|
private
|
|
@@ -67,7 +67,7 @@ module Profiler
|
|
|
67
67
|
})
|
|
68
68
|
|
|
69
69
|
collectors = [job_collector] + JOB_COLLECTOR_CLASSES.map { |klass| klass.new(profile) }
|
|
70
|
-
|
|
70
|
+
return block.call unless Collectors::Lifecycle.subscribe_all(collectors, "JobProfiler")
|
|
71
71
|
|
|
72
72
|
exception_collector = collectors.find { |c| c.is_a?(Collectors::ExceptionCollector) }
|
|
73
73
|
|
|
@@ -109,6 +109,9 @@ module Profiler
|
|
|
109
109
|
|
|
110
110
|
Profiler.storage.save(profile.token, profile)
|
|
111
111
|
end
|
|
112
|
+
ensure
|
|
113
|
+
# After collect, and also when collect or the storage failed.
|
|
114
|
+
Collectors::Lifecycle.release_all(collectors)
|
|
112
115
|
end
|
|
113
116
|
|
|
114
117
|
private
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
require_relative "../models/profile"
|
|
4
4
|
require_relative "../current_context"
|
|
5
|
+
require_relative "../collectors/lifecycle"
|
|
5
6
|
require_relative "toolbar_injector"
|
|
6
7
|
|
|
7
8
|
module Profiler
|
|
@@ -14,28 +15,55 @@ module Profiler
|
|
|
14
15
|
def call(env)
|
|
15
16
|
return @app.call(env) unless should_profile?(env)
|
|
16
17
|
|
|
17
|
-
status = nil
|
|
18
|
-
headers = nil
|
|
19
|
-
body = nil
|
|
20
18
|
collectors = nil
|
|
19
|
+
begin
|
|
20
|
+
begin
|
|
21
|
+
profile = Models::Profile.new(build_request(env))
|
|
22
|
+
profile.gem_version = Profiler::VERSION
|
|
23
|
+
Profiler::CurrentContext.token = profile.token
|
|
24
|
+
|
|
25
|
+
# Capture request body before app processes it
|
|
26
|
+
req_body_raw = read_rack_input(env)
|
|
27
|
+
|
|
28
|
+
# Store profile in env for collectors
|
|
29
|
+
env["profiler.profile"] = profile
|
|
30
|
+
|
|
31
|
+
collectors = create_collectors(profile)
|
|
32
|
+
env["profiler.collectors"] = collectors
|
|
33
|
+
subscribed = Collectors::Lifecycle.subscribe_all(collectors, "ProfilerMiddleware")
|
|
34
|
+
rescue => e
|
|
35
|
+
warn "Profiler error: #{e.message}\n#{e.backtrace.join("\n")}"
|
|
36
|
+
subscribed = false
|
|
37
|
+
end
|
|
21
38
|
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
39
|
+
unless subscribed
|
|
40
|
+
# The application has not run yet: serve the request once, unprofiled.
|
|
41
|
+
release(collectors)
|
|
42
|
+
return @app.call(env)
|
|
43
|
+
end
|
|
25
44
|
|
|
26
|
-
|
|
27
|
-
|
|
45
|
+
# Measure memory before
|
|
46
|
+
memory_before = current_memory if Profiler.configuration.track_memory
|
|
28
47
|
|
|
29
|
-
|
|
30
|
-
|
|
48
|
+
begin
|
|
49
|
+
response = @app.call(env)
|
|
50
|
+
rescue Exception => e # rubocop:disable Lint/RescueException
|
|
51
|
+
record_failed_request(env, profile, collectors, req_body_raw, memory_before, e) if failed_request?(e)
|
|
52
|
+
raise
|
|
53
|
+
end
|
|
31
54
|
|
|
32
|
-
|
|
33
|
-
|
|
55
|
+
complete_profile(env, profile, collectors, req_body_raw, memory_before, response)
|
|
56
|
+
ensure
|
|
57
|
+
# Opened before the first subscribe: whatever happens from there on, an exception
|
|
58
|
+
# outside StandardError included, nothing a collector installed outlives the request.
|
|
59
|
+
release(collectors)
|
|
60
|
+
end
|
|
61
|
+
end
|
|
34
62
|
|
|
35
|
-
|
|
36
|
-
memory_before = current_memory if Profiler.configuration.track_memory
|
|
63
|
+
private
|
|
37
64
|
|
|
38
|
-
|
|
65
|
+
def complete_profile(env, profile, collectors, req_body_raw, memory_before, response)
|
|
66
|
+
status, headers, body = response
|
|
39
67
|
headers = headers.dup
|
|
40
68
|
|
|
41
69
|
# Measure memory after
|
|
@@ -56,17 +84,9 @@ module Profiler
|
|
|
56
84
|
resp_content_type: (headers["content-type"] || headers["Content-Type"]).to_s
|
|
57
85
|
)
|
|
58
86
|
|
|
59
|
-
collectors
|
|
60
|
-
begin
|
|
61
|
-
collector.collect if collector.respond_to?(:collect)
|
|
62
|
-
profile.add_collector_metadata(collector)
|
|
63
|
-
rescue => e
|
|
64
|
-
warn "Collector #{collector.class} failed: #{e.message}"
|
|
65
|
-
end
|
|
66
|
-
end
|
|
87
|
+
collect_all(profile, collectors)
|
|
67
88
|
|
|
68
89
|
Profiler.storage.save(profile.token, profile)
|
|
69
|
-
Profiler::CurrentContext.clear
|
|
70
90
|
|
|
71
91
|
headers["X-Profiler-Token"] = profile.token
|
|
72
92
|
|
|
@@ -77,13 +97,50 @@ module Profiler
|
|
|
77
97
|
|
|
78
98
|
[status, headers, body]
|
|
79
99
|
rescue => e
|
|
100
|
+
# The application has run: its response goes out, profiled or not.
|
|
80
101
|
warn "Profiler error: #{e.message}\n#{e.backtrace.join("\n")}"
|
|
81
|
-
|
|
82
|
-
Profiler::CurrentContext.clear
|
|
83
|
-
status ? [status, headers, body || []] : @app.call(env)
|
|
102
|
+
[status, headers, body || []]
|
|
84
103
|
end
|
|
85
104
|
|
|
86
|
-
|
|
105
|
+
# A request cut short by a timeout (Timeout::ExitException, Rack::Timeout) is profiled like
|
|
106
|
+
# one that raised; one stopped by a signal or an exit is not, the process is going away.
|
|
107
|
+
def failed_request?(error)
|
|
108
|
+
!error.is_a?(SignalException) && !error.is_a?(SystemExit)
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# The profile of a request that raised is kept: status 500, which the server answers once
|
|
112
|
+
# the exception leaves the stack, and the exception itself. Never masks the exception.
|
|
113
|
+
def record_failed_request(env, profile, collectors, req_body_raw, memory_before, error)
|
|
114
|
+
collectors.each { |collector| collector.capture(error) if collector.respond_to?(:capture) }
|
|
115
|
+
profile.memory = current_memory - memory_before if Profiler.configuration.track_memory
|
|
116
|
+
profile.finish(500)
|
|
117
|
+
profile.set_bodies(
|
|
118
|
+
request_body: req_body_raw,
|
|
119
|
+
response_body: "",
|
|
120
|
+
req_content_type: env["CONTENT_TYPE"].to_s,
|
|
121
|
+
resp_content_type: ""
|
|
122
|
+
)
|
|
123
|
+
collect_all(profile, collectors)
|
|
124
|
+
Profiler.storage.save(profile.token, profile)
|
|
125
|
+
rescue => e
|
|
126
|
+
warn "Profiler error while recording a failed request: #{e.message}"
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def collect_all(profile, collectors)
|
|
130
|
+
collectors.each do |collector|
|
|
131
|
+
begin
|
|
132
|
+
collector.collect if collector.respond_to?(:collect)
|
|
133
|
+
profile.add_collector_metadata(collector)
|
|
134
|
+
rescue => e
|
|
135
|
+
warn "Collector #{collector.class} failed: #{e.message}"
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def release(collectors)
|
|
141
|
+
Collectors::Lifecycle.release_all(collectors)
|
|
142
|
+
Profiler::CurrentContext.clear
|
|
143
|
+
end
|
|
87
144
|
|
|
88
145
|
def should_profile?(env)
|
|
89
146
|
return false unless Profiler.enabled?
|
|
@@ -104,12 +161,10 @@ module Profiler
|
|
|
104
161
|
end
|
|
105
162
|
end
|
|
106
163
|
|
|
164
|
+
# Builds the collectors without subscribing them: Lifecycle.subscribe_all does, so that
|
|
165
|
+
# a failure part way leaves every collector reachable for release.
|
|
107
166
|
def create_collectors(profile)
|
|
108
|
-
Profiler.configuration.collectors.map
|
|
109
|
-
collector = collector_class.new(profile)
|
|
110
|
-
collector.subscribe if collector.respond_to?(:subscribe)
|
|
111
|
-
collector
|
|
112
|
-
end
|
|
167
|
+
Profiler.configuration.collectors.map { |collector_class| collector_class.new(profile) }
|
|
113
168
|
end
|
|
114
169
|
|
|
115
170
|
def html_response?(headers)
|
|
@@ -1,9 +1,31 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "json"
|
|
4
|
+
require "strscan"
|
|
5
|
+
require "active_support/core_ext/string/output_safety"
|
|
6
|
+
|
|
3
7
|
module Profiler
|
|
4
8
|
module Middleware
|
|
5
9
|
class ToolbarInjector
|
|
6
|
-
|
|
10
|
+
# The only characters HTML counts as white space between attributes (\s also takes \v).
|
|
11
|
+
WS = "[\\t\\n\\f\\r ]"
|
|
12
|
+
# Elements whose content the browser does not parse as markup: a </body> inside one of
|
|
13
|
+
# them is text, not the end of the page. Each maps to the end tag that closes it.
|
|
14
|
+
RAW_TEXT_ELEMENTS = %w[script style textarea title xmp iframe noembed noframes noscript]
|
|
15
|
+
.to_h { |name| [name, %r{</#{name}(?=#{WS}|[/>])}i] }.freeze
|
|
16
|
+
# Never closed: whatever follows it is text.
|
|
17
|
+
PLAINTEXT = "plaintext"
|
|
18
|
+
# An attribute as the HTML tokenizer reads it: the name may start with = (or a quote),
|
|
19
|
+
# and a value only follows a name, after =. A quoted value may hold a > or a </body>.
|
|
20
|
+
ATTRIBUTE = %r{
|
|
21
|
+
[^\t\n\f\r\ />][^\t\n\f\r\ />=]*+
|
|
22
|
+
(?:#{WS}*+(?:=#{WS}*+(?:"[^"]*+"|'[^']*+'|[^\t\n\f\r\ >"'][^\t\n\f\r\ >]*+|(?=>))|(?!=)))
|
|
23
|
+
}x
|
|
24
|
+
# The rest of a tag, up to its >. A quoted value that is never closed fails the match,
|
|
25
|
+
# as does a tag without its >. Possessive, so that a failure costs no backtracking.
|
|
26
|
+
TAG_REST = %r{(?:#{WS}++|/|#{ATTRIBUTE})*+>}
|
|
27
|
+
# A whole start or end tag, read after its <: the slash and the name are captured.
|
|
28
|
+
TAG = %r{(/)?([a-z][^\t\n\f\r\ />]*+)#{TAG_REST}}i
|
|
7
29
|
|
|
8
30
|
def initialize(body, token, nonce = nil)
|
|
9
31
|
@body = body
|
|
@@ -13,9 +35,12 @@ module Profiler
|
|
|
13
35
|
|
|
14
36
|
def inject
|
|
15
37
|
content = extract_content(@body)
|
|
16
|
-
|
|
38
|
+
# Bytes, so that a page that is not valid UTF-8 can still be scanned.
|
|
39
|
+
bytes = content.b
|
|
40
|
+
position = closing_body_position(bytes)
|
|
41
|
+
return @body unless position
|
|
17
42
|
|
|
18
|
-
injected_content =
|
|
43
|
+
injected_content = bytes.insert(position, toolbar_html.b).force_encoding(content.encoding)
|
|
19
44
|
|
|
20
45
|
# Return as array for Rack compatibility
|
|
21
46
|
[injected_content]
|
|
@@ -23,12 +48,70 @@ module Profiler
|
|
|
23
48
|
|
|
24
49
|
private
|
|
25
50
|
|
|
51
|
+
# Byte offset of the </body> that closes the page: the last one outside comments, tags
|
|
52
|
+
# (an attribute value) and raw-text elements, where a page can carry the string
|
|
53
|
+
# "</body>" (a script building markup, a cached fragment in a final comment). nil when
|
|
54
|
+
# there is none, or when the page leaves a comment, a tag, a quoted value or a raw-text
|
|
55
|
+
# element open, or holds a <plaintext> or a double-escaped script: no toolbar rather than
|
|
56
|
+
# a toolbar in the wrong place.
|
|
57
|
+
def closing_body_position(bytes)
|
|
58
|
+
scanner = StringScanner.new(bytes)
|
|
59
|
+
position = nil
|
|
60
|
+
|
|
61
|
+
while scanner.skip_until(/</)
|
|
62
|
+
start = scanner.pos - 1
|
|
63
|
+
|
|
64
|
+
if scanner.skip(TAG)
|
|
65
|
+
name = scanner[2]
|
|
66
|
+
name = name.downcase if name.match?(/[A-Z]/)
|
|
67
|
+
|
|
68
|
+
if scanner[1]
|
|
69
|
+
position = start if name == "body"
|
|
70
|
+
next
|
|
71
|
+
end
|
|
72
|
+
return nil if name == PLAINTEXT
|
|
73
|
+
next unless (end_tag = RAW_TEXT_ELEMENTS[name])
|
|
74
|
+
|
|
75
|
+
text_start = scanner.pos
|
|
76
|
+
return nil unless scanner.skip_until(end_tag)
|
|
77
|
+
return nil if name == "script" && double_escaped?(bytes, text_start, scanner.pos - scanner.matched_size)
|
|
78
|
+
return nil unless scanner.skip(TAG_REST)
|
|
79
|
+
elsif scanner.match?(%r{/?[a-z]}i)
|
|
80
|
+
# A tag, or a quoted value in it, that is never closed.
|
|
81
|
+
return nil
|
|
82
|
+
elsif scanner.skip(/!--/)
|
|
83
|
+
# <!--> and <!---> are complete comments.
|
|
84
|
+
next if scanner.skip(/-?>/) || scanner.skip_until(/-->/)
|
|
85
|
+
|
|
86
|
+
return nil
|
|
87
|
+
elsif scanner.skip(/!\[CDATA\[/)
|
|
88
|
+
# A bogus comment up to the first > in HTML, a CDATA section up to ]]> in <svg> or
|
|
89
|
+
# <math>: go on only when both end at the same place.
|
|
90
|
+
return nil unless scanner.skip_until(/>/) && bytes.byteslice(scanner.pos - 3, 3) == "]]>"
|
|
91
|
+
elsif scanner.skip(%r{[!?/]})
|
|
92
|
+
# <!DOCTYPE>, <?xml ?>, </ not followed by a letter: a bogus comment, up to its >.
|
|
93
|
+
return nil unless scanner.skip_until(/>/)
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
position
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# In a script, <!-- followed by <script enters a state where </script> does not close it.
|
|
101
|
+
# Only the script itself is searched, so that a page of many scripts is read in linear
|
|
102
|
+
# time.
|
|
103
|
+
def double_escaped?(bytes, from, to)
|
|
104
|
+
text = bytes.byteslice(from, to - from)
|
|
105
|
+
comment = text.index("<!--")
|
|
106
|
+
comment ? text.match?(%r{<script(?:#{WS}|[/>])}i, comment) : false
|
|
107
|
+
end
|
|
108
|
+
|
|
26
109
|
def ajax_interceptor_script
|
|
27
110
|
return "" unless Profiler.configuration.track_ajax
|
|
28
111
|
|
|
29
112
|
<<~HTML
|
|
30
113
|
<script#{nonce_attr}>
|
|
31
|
-
window.__PROFILER_PARENT_TOKEN__ =
|
|
114
|
+
window.__PROFILER_PARENT_TOKEN__ = #{js_token};
|
|
32
115
|
</script>
|
|
33
116
|
<script#{nonce_attr}>
|
|
34
117
|
#{ajax_interceptor_code}
|
|
@@ -63,15 +146,20 @@ module Profiler
|
|
|
63
146
|
<<~HTML
|
|
64
147
|
#{ajax_interceptor_script}
|
|
65
148
|
<style>#{toolbar_styles}</style>
|
|
66
|
-
<div id="profiler-toolbar" class="profiler-root" data-token="#{@token}"></div>
|
|
149
|
+
<div id="profiler-toolbar" class="profiler-root" data-token="#{ERB::Util.html_escape(@token)}"></div>
|
|
67
150
|
<button id="profiler-toolbar-toggle" class="profiler-root" title="Toggle profiler (Alt+P)"><span class="profiler-toggle-icon">▶</span></button>
|
|
68
151
|
<script#{nonce_attr}>(function(){var c=localStorage.getItem('profiler-toolbar-collapsed')==='true',t=localStorage.getItem('profiler-theme'),theme=t==='light'?'light':t==='dark'?'dark':(window.matchMedia('(prefers-color-scheme:light)').matches?'light':'dark'),el=document.getElementById('profiler-toolbar'),tog=document.getElementById('profiler-toolbar-toggle');if(c){el.style.cssText='animation:none!important;transform:translateX(calc(100% + 44px))';tog.dataset.collapsed='true';}el.setAttribute('data-theme',theme);tog.setAttribute('data-theme',theme);})();</script>
|
|
69
152
|
<script src="/_profiler/assets/profiler-toolbar.js" defer#{nonce_attr}></script>
|
|
70
153
|
HTML
|
|
71
154
|
end
|
|
72
155
|
|
|
156
|
+
# The token as a JavaScript string literal that cannot close the <script> element.
|
|
157
|
+
def js_token
|
|
158
|
+
ERB::Util.json_escape(@token.to_s.to_json)
|
|
159
|
+
end
|
|
160
|
+
|
|
73
161
|
def nonce_attr
|
|
74
|
-
@nonce ? " nonce=\"#{@nonce}\"" : ""
|
|
162
|
+
@nonce ? " nonce=\"#{ERB::Util.html_escape(@nonce)}\"" : ""
|
|
75
163
|
end
|
|
76
164
|
|
|
77
165
|
# Thermal design system — self-contained CSS for the injected toolbar.
|
|
@@ -52,7 +52,7 @@ module Profiler
|
|
|
52
52
|
)
|
|
53
53
|
|
|
54
54
|
collectors = [test_collector] + TEST_COLLECTOR_CLASSES.map { |klass| klass.new(profile) }
|
|
55
|
-
|
|
55
|
+
return block.call unless Collectors::Lifecycle.subscribe_all(collectors, "TestProfiler")
|
|
56
56
|
|
|
57
57
|
exception_collector = collectors.find { |c| c.is_a?(Collectors::ExceptionCollector) }
|
|
58
58
|
|
|
@@ -120,6 +120,9 @@ module Profiler
|
|
|
120
120
|
|
|
121
121
|
Profiler.storage.save(profile.token, profile)
|
|
122
122
|
end
|
|
123
|
+
ensure
|
|
124
|
+
# After collect, and also when collect or the storage failed.
|
|
125
|
+
Collectors::Lifecycle.release_all(collectors)
|
|
123
126
|
end
|
|
124
127
|
|
|
125
128
|
private
|
data/lib/profiler/version.rb
CHANGED
data/lib/profiler.rb
CHANGED
|
@@ -77,16 +77,18 @@ module Profiler
|
|
|
77
77
|
def dump(value, label = nil)
|
|
78
78
|
return unless enabled?
|
|
79
79
|
|
|
80
|
+
# The slot exists only while a DumpCollector profiles this thread: outside of one, nobody
|
|
81
|
+
# would ever read the dump, and the thread would keep it for good.
|
|
82
|
+
dumps = Thread.current[:profiler_dumps]
|
|
83
|
+
return value unless dumps
|
|
84
|
+
|
|
80
85
|
# Get caller location
|
|
81
86
|
caller_location = caller_locations(1, 1).first
|
|
82
87
|
file = caller_location.path
|
|
83
88
|
line = caller_location.lineno
|
|
84
89
|
|
|
85
|
-
# Initialize dumps array if needed
|
|
86
|
-
Thread.current[:profiler_dumps] ||= []
|
|
87
|
-
|
|
88
90
|
# Store the dump
|
|
89
|
-
|
|
91
|
+
dumps << {
|
|
90
92
|
value: value,
|
|
91
93
|
label: label,
|
|
92
94
|
file: file,
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: rails-profiler
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.30.
|
|
4
|
+
version: 0.30.13
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Sébastien Duplessy
|
|
@@ -152,6 +152,7 @@ files:
|
|
|
152
152
|
- lib/profiler/collectors/http_collector.rb
|
|
153
153
|
- lib/profiler/collectors/i18n_collector.rb
|
|
154
154
|
- lib/profiler/collectors/job_collector.rb
|
|
155
|
+
- lib/profiler/collectors/lifecycle.rb
|
|
155
156
|
- lib/profiler/collectors/log_collector.rb
|
|
156
157
|
- lib/profiler/collectors/mailer_collector.rb
|
|
157
158
|
- lib/profiler/collectors/request_collector.rb
|