rails-profiler 0.33.0 → 0.33.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +58 -0
- data/app/assets/javascript/profiler-ajax-interceptor.js +193 -0
- data/lib/profiler/collectors/database_collector.rb +16 -2
- data/lib/profiler/collectors/dump_collector.rb +4 -2
- data/lib/profiler/collectors/log_collector.rb +15 -1
- data/lib/profiler/configuration.rb +35 -3
- data/lib/profiler/instrumentation/net_http_instrumentation.rb +18 -1
- data/lib/profiler/middleware/profiler_middleware.rb +17 -8
- data/lib/profiler/version.rb +1 -1
- 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: 8e1caccb1662a8f09108682f5933e943d41a0b36ed7d60930c60eb8c385b5159
|
|
4
|
+
data.tar.gz: c10d767853cc07caa36484f9693a4c4adeef415e0874d29ca817d8d7d1b17730
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 8aacfc0469f52d9f6a11a58356eebb853ff716f2729ca47833b9b0ca99197f767a0dad5904f8c9e7d8a8501869dd712a0c5e6e246fa8ea0e55976b546589d1ac
|
|
7
|
+
data.tar.gz: 3ce33e8db95c0ae4028a68e11d941bb72072b5a6edcf539cfc0fb918c2248d675c4df92e28788da0d59d942546458384b22ec5ac974bb58404be1d0049130627
|
data/CHANGELOG.md
CHANGED
|
@@ -19,6 +19,64 @@ every commit of every tag interval is accounted for one way or the other.
|
|
|
19
19
|
|
|
20
20
|
## [Unreleased]
|
|
21
21
|
|
|
22
|
+
## [0.33.2] - 2026-10-14
|
|
23
|
+
|
|
24
|
+
<!-- stamped -->
|
|
25
|
+
|
|
26
|
+
### Changed
|
|
27
|
+
|
|
28
|
+
- **Configuration:** A String in `config.http_skip_hosts` is now a host name, compared whole and
|
|
29
|
+
ignoring case, instead of a pattern: `"example.com"` no longer leaves out `notexample.com`,
|
|
30
|
+
`exampleXcom` or `example.com.attacker.net`. A String that starts with a dot is a domain whose
|
|
31
|
+
subdomains are left out (`".internal"` leaves out `api.internal`). A Regexp is matched as
|
|
32
|
+
before. An entry that is not a host name, a wildcard String (`"*.internal"`), an empty one, or
|
|
33
|
+
anything that is neither a String nor a Regexp (`nil`), is left out when the list is set, with
|
|
34
|
+
a warning in the log that says what to write, and the other entries are kept. It does not
|
|
35
|
+
raise: the gem is loaded in production too, where a mistake in this list must not stop the
|
|
36
|
+
application from booting, even with the profiler disabled.
|
|
37
|
+
- **Dumps:** A dump is stored as its formatted text only: the raw `value` field, which no tab
|
|
38
|
+
read and the API did not document, is no longer part of the dump data.
|
|
39
|
+
|
|
40
|
+
### Fixed
|
|
41
|
+
|
|
42
|
+
- **Configuration:** A String in `config.skip_paths` no longer fails every request with a
|
|
43
|
+
`TypeError`: it skips the path equal to it, and a Regexp is matched as before. A single
|
|
44
|
+
pattern, a String or a Regexp, set without a list is read as a list of one.
|
|
45
|
+
- **Outbound HTTP:** A String in `config.http_skip_hosts` that is not a valid Regexp, such as
|
|
46
|
+
`"*.internal"` added with `<<`, no longer fails every `Net::HTTP` call of a profiled request
|
|
47
|
+
with a `RegexpError`.
|
|
48
|
+
- **Database:** A query that binds bytes, a `blob` or `bytea` column such as an image, no longer
|
|
49
|
+
loses the profile of its request when it is saved: the bind shows as text when its bytes are
|
|
50
|
+
valid UTF-8, and as `[binary data, N bytes]` otherwise.
|
|
51
|
+
- **Logs:** A line logged with bytes that are not valid UTF-8 no longer raises an
|
|
52
|
+
`Encoding::CompatibilityError` in the application's own logger call (Rails 7.1 and later), and
|
|
53
|
+
a binary line no longer loses the profile when it is saved: the line is kept as UTF-8, what
|
|
54
|
+
cannot be read replaced with U+FFFD, and a line in another encoding is converted.
|
|
55
|
+
- **Dumps:** `Profiler.dump` of an object that refers to itself, directly or through an instance
|
|
56
|
+
variable, no longer fails the request with a `SystemStackError` (a 500 with the file store), and
|
|
57
|
+
an Array or a Hash holding itself no longer loses the profile. An object whose own `inspect`
|
|
58
|
+
recurses without end shows `[Error formatting value: stack level too deep]`.
|
|
59
|
+
- **Middleware:** A page whose body is made of fragments of different encodings, a binary one
|
|
60
|
+
among UTF-8 ones, no longer fails with an `Encoding::CompatibilityError` once profiled: its
|
|
61
|
+
bytes are joined as the application gave them, and it gets its toolbar.
|
|
62
|
+
|
|
63
|
+
## [0.33.1] - 2026-10-14
|
|
64
|
+
|
|
65
|
+
<!-- stamped -->
|
|
66
|
+
|
|
67
|
+
### Fixed
|
|
68
|
+
|
|
69
|
+
- **Packaging:** The published gem now contains `app/assets/javascript/profiler-ajax-interceptor.js`,
|
|
70
|
+
the script the toolbar injects to link the `fetch`, `XMLHttpRequest` and Turbo requests of a page
|
|
71
|
+
to that page. It was missing from every published version: with the gem installed from a
|
|
72
|
+
registry, those requests were never linked to their page, and the only trace was the log line
|
|
73
|
+
`ToolbarInjector: could not load the AJAX interceptor script`. A gem used from a git checkout or
|
|
74
|
+
a local path was not affected.
|
|
75
|
+
- **Release:** A version is now published only once both test suites (Ruby 3.3 and 3.4) have
|
|
76
|
+
passed, as written in the `needs` of the `release` and `canary` jobs, and once the built gem
|
|
77
|
+
has been installed and found to carry every file it reads at run time. The suites already held
|
|
78
|
+
publication back before, but only through the order of the pipeline stages.
|
|
79
|
+
|
|
22
80
|
## [0.33.0] - 2026-10-14
|
|
23
81
|
|
|
24
82
|
<!-- stamped -->
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
// Profiler AJAX Request Interceptor
|
|
2
|
+
// This script intercepts fetch() and XMLHttpRequest calls to link AJAX requests to their parent page
|
|
3
|
+
|
|
4
|
+
(function() {
|
|
5
|
+
'use strict';
|
|
6
|
+
|
|
7
|
+
// Get parent token from window variable set by the profiler
|
|
8
|
+
if (!window.__PROFILER_PARENT_TOKEN__) {
|
|
9
|
+
console.warn('[Profiler] Parent token not found, AJAX tracking disabled');
|
|
10
|
+
return;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
// Check if already intercepted (by Chrome extension or another script)
|
|
14
|
+
if (window.__PROFILER_INTERCEPTOR_ACTIVE__) {
|
|
15
|
+
console.debug('[Profiler] Interceptor already active, skipping');
|
|
16
|
+
return;
|
|
17
|
+
}
|
|
18
|
+
window.__PROFILER_INTERCEPTOR_ACTIVE__ = true;
|
|
19
|
+
|
|
20
|
+
// Save references to originals (they might already be wrapped by extension)
|
|
21
|
+
const originalFetch = window.fetch;
|
|
22
|
+
const originalXHROpen = XMLHttpRequest.prototype.open;
|
|
23
|
+
const originalXHRSend = XMLHttpRequest.prototype.send;
|
|
24
|
+
const pendingXHRRequests = new WeakMap();
|
|
25
|
+
// The fetches Turbo makes, by the AbortSignal it hands to fetch(): { frame: true } when the
|
|
26
|
+
// request loads a <turbo-frame>.
|
|
27
|
+
const turboRequests = new WeakMap();
|
|
28
|
+
const TURBO_STREAM_MIME = 'text/vnd.turbo-stream.html';
|
|
29
|
+
|
|
30
|
+
// The page the sub-requests belong to. Read at each link, not once: a Turbo Drive visit
|
|
31
|
+
// renders a new page, whose inline script sets the token again, without reloading this one.
|
|
32
|
+
function currentParentToken() {
|
|
33
|
+
return window.__PROFILER_PARENT_TOKEN__;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function isRequest(input) {
|
|
37
|
+
return typeof Request !== 'undefined' && input instanceof Request;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function hasHeader(headers, name) {
|
|
41
|
+
if (!headers) return false;
|
|
42
|
+
if (typeof headers.has === 'function') return headers.has(name);
|
|
43
|
+
const wanted = name.toLowerCase();
|
|
44
|
+
return Object.keys(headers).some(key => key.toLowerCase() === wanted && headers[key] != null && headers[key] !== '');
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// Turbo (7 and 8) announces each of its requests before calling fetch(), with the options it
|
|
48
|
+
// will pass to it, signal included.
|
|
49
|
+
document.addEventListener('turbo:before-fetch-request', function(event) {
|
|
50
|
+
const options = event.detail && event.detail.fetchOptions;
|
|
51
|
+
if (!options || !options.signal) return;
|
|
52
|
+
turboRequests.set(options.signal, { frame: hasHeader(options.headers, 'Turbo-Frame') });
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
// A Turbo Drive visit, a prefetch on hover, or a form submission answered with a page is the
|
|
56
|
+
// next page, which carries its own toolbar: not a sub-request of this one. A <turbo-frame>
|
|
57
|
+
// load, and any request answered with Turbo Stream actions, update this page: they are.
|
|
58
|
+
function isNavigation(args, response) {
|
|
59
|
+
const init = args[1];
|
|
60
|
+
const signal = (init && init.signal) || (isRequest(args[0]) ? args[0].signal : null);
|
|
61
|
+
const turbo = signal && turboRequests.get(signal);
|
|
62
|
+
if (!turbo || turbo.frame) return false;
|
|
63
|
+
|
|
64
|
+
const contentType = response.headers.get('Content-Type') || '';
|
|
65
|
+
return !contentType.startsWith(TURBO_STREAM_MIME);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Helper function to check if path should be skipped
|
|
69
|
+
function shouldSkipPath(url) {
|
|
70
|
+
try {
|
|
71
|
+
const urlObj = new URL(url, window.location.origin);
|
|
72
|
+
const path = urlObj.pathname;
|
|
73
|
+
|
|
74
|
+
// Skip profiler's own requests
|
|
75
|
+
if (path.startsWith('/_profiler')) {
|
|
76
|
+
return true;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
return false;
|
|
80
|
+
} catch (e) {
|
|
81
|
+
return false;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Helper function to link child profile to parent
|
|
86
|
+
function linkChildProfile(childToken) {
|
|
87
|
+
const parentToken = currentParentToken();
|
|
88
|
+
if (!childToken || !parentToken) return;
|
|
89
|
+
|
|
90
|
+
console.debug('[Profiler] Linking AJAX request:', { parent: parentToken, child: childToken });
|
|
91
|
+
|
|
92
|
+
// Post to the link API endpoint
|
|
93
|
+
const linkUrl = '/_profiler/api/ajax/link';
|
|
94
|
+
const body = JSON.stringify({
|
|
95
|
+
parent_token: parentToken,
|
|
96
|
+
child_token: childToken
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
// Use original fetch to avoid infinite loop
|
|
100
|
+
originalFetch.call(window, linkUrl, {
|
|
101
|
+
method: 'POST',
|
|
102
|
+
headers: {
|
|
103
|
+
'Content-Type': 'application/json',
|
|
104
|
+
'Accept': 'application/json',
|
|
105
|
+
'X-Profiler-Request': '1'
|
|
106
|
+
},
|
|
107
|
+
body: body
|
|
108
|
+
}).then(response => {
|
|
109
|
+
if (response.ok) {
|
|
110
|
+
// Refresh toolbar after successful link (with small delay to let backend process)
|
|
111
|
+
setTimeout(() => {
|
|
112
|
+
if (typeof window.__PROFILER_REFRESH_TOOLBAR__ === 'function') {
|
|
113
|
+
window.__PROFILER_REFRESH_TOOLBAR__();
|
|
114
|
+
}
|
|
115
|
+
}, 100);
|
|
116
|
+
}
|
|
117
|
+
}).catch(err => {
|
|
118
|
+
// Fail silently - don't break the page if profiler API fails
|
|
119
|
+
console.debug('[Profiler] Failed to link AJAX request:', err);
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// Listen for events from Chrome extension (if installed)
|
|
124
|
+
window.addEventListener('__profilerRequest', function(event) {
|
|
125
|
+
const { token } = event.detail;
|
|
126
|
+
if (token) {
|
|
127
|
+
console.debug('[Profiler] Received profiler event from extension:', token);
|
|
128
|
+
linkChildProfile(token);
|
|
129
|
+
}
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
// Override fetch()
|
|
133
|
+
window.fetch = async function(...args) {
|
|
134
|
+
const url = isRequest(args[0]) ? args[0].url : String(args[0]);
|
|
135
|
+
|
|
136
|
+
// Skip profiler paths
|
|
137
|
+
if (shouldSkipPath(url)) {
|
|
138
|
+
return originalFetch.apply(this, args);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
try {
|
|
142
|
+
const response = await originalFetch.apply(this, args);
|
|
143
|
+
|
|
144
|
+
// Extract profiler token from response headers
|
|
145
|
+
const profilerToken = response.headers.get('X-Profiler-Token');
|
|
146
|
+
|
|
147
|
+
if (profilerToken && !isNavigation(args, response)) {
|
|
148
|
+
linkChildProfile(profilerToken);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
return response;
|
|
152
|
+
} catch (error) {
|
|
153
|
+
throw error;
|
|
154
|
+
}
|
|
155
|
+
};
|
|
156
|
+
|
|
157
|
+
// Override XMLHttpRequest.open()
|
|
158
|
+
XMLHttpRequest.prototype.open = function(method, url, ...args) {
|
|
159
|
+
const urlString = typeof url === 'string' ? url : url.toString();
|
|
160
|
+
|
|
161
|
+
// Store request metadata
|
|
162
|
+
pendingXHRRequests.set(this, {
|
|
163
|
+
url: urlString,
|
|
164
|
+
method: method,
|
|
165
|
+
timestamp: Date.now()
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
return originalXHROpen.apply(this, [method, url, ...args]);
|
|
169
|
+
};
|
|
170
|
+
|
|
171
|
+
// Override XMLHttpRequest.send()
|
|
172
|
+
XMLHttpRequest.prototype.send = function(...args) {
|
|
173
|
+
const requestData = pendingXHRRequests.get(this);
|
|
174
|
+
|
|
175
|
+
// Skip profiler paths
|
|
176
|
+
if (requestData && shouldSkipPath(requestData.url)) {
|
|
177
|
+
return originalXHRSend.apply(this, args);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// Add load event listener to capture response
|
|
181
|
+
this.addEventListener('load', function() {
|
|
182
|
+
const profilerToken = this.getResponseHeader('X-Profiler-Token');
|
|
183
|
+
|
|
184
|
+
if (profilerToken && requestData) {
|
|
185
|
+
linkChildProfile(profilerToken);
|
|
186
|
+
}
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
return originalXHRSend.apply(this, args);
|
|
190
|
+
};
|
|
191
|
+
|
|
192
|
+
console.debug('[Profiler] AJAX interceptor initialized with parent token:', currentParentToken());
|
|
193
|
+
})();
|
|
@@ -9,6 +9,8 @@ module Profiler
|
|
|
9
9
|
BACKTRACE_DEPTH = 10
|
|
10
10
|
MAX_SCANNED_FRAMES = 100
|
|
11
11
|
FRAMES_PAST_APPLICATION = 15
|
|
12
|
+
# What a bind holding bytes that are not text shows instead (see #displayable_bind).
|
|
13
|
+
BINARY_BIND = "[binary data, %d bytes]"
|
|
12
14
|
|
|
13
15
|
|
|
14
16
|
# Frames between the application's code and the subscriber: the profiler itself, the
|
|
@@ -122,9 +124,9 @@ module Profiler
|
|
|
122
124
|
binds.map do |bind|
|
|
123
125
|
if bind.respond_to?(:value)
|
|
124
126
|
name = bind.name if bind.respond_to?(:name)
|
|
125
|
-
Profiler::Redaction.filter_named(name, bind.value)
|
|
127
|
+
displayable_bind(Profiler::Redaction.filter_named(name, bind.value))
|
|
126
128
|
else
|
|
127
|
-
bind
|
|
129
|
+
displayable_bind(bind)
|
|
128
130
|
end
|
|
129
131
|
end
|
|
130
132
|
rescue StandardError
|
|
@@ -132,6 +134,18 @@ module Profiler
|
|
|
132
134
|
binds.map { Profiler::Redaction::MASK }
|
|
133
135
|
end
|
|
134
136
|
|
|
137
|
+
# A blob or bytea value is bytes, not text: kept as it is, it fails the JSON of the whole
|
|
138
|
+
# profile when it is saved. Bytes that are valid UTF-8 are shown as text, others as their
|
|
139
|
+
# size. Text in another encoding stays as it is: JSON converts it.
|
|
140
|
+
def displayable_bind(value)
|
|
141
|
+
value = value.to_s if defined?(ActiveModel::Type::Binary::Data) && value.is_a?(ActiveModel::Type::Binary::Data)
|
|
142
|
+
return value unless value.is_a?(String)
|
|
143
|
+
return value if value.valid_encoding? && value.encoding != Encoding::BINARY
|
|
144
|
+
|
|
145
|
+
utf8 = value.dup.force_encoding(Encoding::UTF_8)
|
|
146
|
+
utf8.valid_encoding? ? utf8 : format(BINARY_BIND, value.bytesize)
|
|
147
|
+
end
|
|
148
|
+
|
|
135
149
|
# Capturing the caller costs tens of microseconds. The first run of a statement locates
|
|
136
150
|
# it, an N+1 loop included (its repeats are the same statement with other values); a slow
|
|
137
151
|
# query is always located.
|
|
@@ -33,9 +33,10 @@ module Profiler
|
|
|
33
33
|
dumps = @dumps || Thread.current[:profiler_dumps] || []
|
|
34
34
|
unsubscribe
|
|
35
35
|
|
|
36
|
+
# Only the formatted text is kept: the object itself can refer to itself, or hold what
|
|
37
|
+
# JSON cannot write, and would make the profile fail when it is saved.
|
|
36
38
|
formatted_dumps = dumps.map do |dump|
|
|
37
39
|
{
|
|
38
|
-
value: dump[:value],
|
|
39
40
|
formatted: format_value(dump[:value]),
|
|
40
41
|
file: dump[:file],
|
|
41
42
|
line: dump[:line],
|
|
@@ -97,7 +98,8 @@ module Profiler
|
|
|
97
98
|
formatted += ">"
|
|
98
99
|
formatted
|
|
99
100
|
end
|
|
100
|
-
|
|
101
|
+
# An inspect of the application's that recurses without end is said, like any other error.
|
|
102
|
+
rescue StandardError, SystemStackError => e
|
|
101
103
|
"[Error formatting value: #{e.message}]"
|
|
102
104
|
end
|
|
103
105
|
end
|
|
@@ -68,7 +68,7 @@ module Profiler
|
|
|
68
68
|
|
|
69
69
|
entry = {
|
|
70
70
|
level: SEVERITY_LABELS[severity] || "UNKNOWN",
|
|
71
|
-
message: msg.to_s.strip,
|
|
71
|
+
message: self.class.readable(msg.to_s).strip,
|
|
72
72
|
timestamp: Time.now.iso8601(3)
|
|
73
73
|
}
|
|
74
74
|
logs.respond_to?(:record) ? logs.record(entry) : logs << entry
|
|
@@ -78,6 +78,20 @@ module Profiler
|
|
|
78
78
|
def <<(msg)
|
|
79
79
|
add(0, msg.to_s)
|
|
80
80
|
end
|
|
81
|
+
|
|
82
|
+
# The line as valid UTF-8, a copy when it has to change: bytes that are not text in its
|
|
83
|
+
# encoding would raise in strip, inside the application's own logger call, or fail the
|
|
84
|
+
# JSON of the profile when it is saved. Binary bytes are read as UTF-8; a line in another
|
|
85
|
+
# encoding is converted. What cannot be read becomes U+FFFD.
|
|
86
|
+
def self.readable(text)
|
|
87
|
+
return text if text.encoding == Encoding::UTF_8 && text.valid_encoding?
|
|
88
|
+
|
|
89
|
+
if [Encoding::UTF_8, Encoding::BINARY, Encoding::US_ASCII].include?(text.encoding)
|
|
90
|
+
text.dup.force_encoding(Encoding::UTF_8).scrub
|
|
91
|
+
else
|
|
92
|
+
text.encode(Encoding::UTF_8, invalid: :replace, undef: :replace)
|
|
93
|
+
end
|
|
94
|
+
end
|
|
81
95
|
end
|
|
82
96
|
|
|
83
97
|
def icon
|
|
@@ -38,13 +38,13 @@ module Profiler
|
|
|
38
38
|
LOCAL_HTTP_HOSTS = [/\A127\.0\.0\.1\z/, /\Alocalhost\z/i, /\A::1\z/].freeze
|
|
39
39
|
|
|
40
40
|
attr_accessor :storage_options, :collectors,
|
|
41
|
-
:
|
|
41
|
+
:slow_query_threshold, :max_queries_warning, :sql_backtrace,
|
|
42
42
|
:track_memory, :allocated_objects_warning_threshold,
|
|
43
43
|
:mcp_enabled, :mcp_transport, :mcp_port,
|
|
44
44
|
:authorization_mode, :extension_cors_enabled,
|
|
45
45
|
:cors_allowed_origins, :api_forgery_protection, :frame_ancestors,
|
|
46
46
|
:track_ajax, :ajax_skip_paths,
|
|
47
|
-
:track_http, :slow_http_threshold, :
|
|
47
|
+
:track_http, :slow_http_threshold, :http_backtrace_depth,
|
|
48
48
|
:track_jobs,
|
|
49
49
|
:track_console,
|
|
50
50
|
:apply_env_overrides_when_disabled, :restrict_storage_permissions,
|
|
@@ -148,7 +148,23 @@ module Profiler
|
|
|
148
148
|
@tmp_path = value.nil? ? nil : Pathname.new(value)
|
|
149
149
|
end
|
|
150
150
|
|
|
151
|
-
attr_reader :cluster_allowed_slave_urls, :mcp_allowed_hosts
|
|
151
|
+
attr_reader :cluster_allowed_slave_urls, :mcp_allowed_hosts, :skip_paths, :http_skip_hosts
|
|
152
|
+
|
|
153
|
+
# Paths that are not profiled: a Regexp matched against the path, or a String equal to it.
|
|
154
|
+
# A single pattern is a list of one.
|
|
155
|
+
def skip_paths=(value)
|
|
156
|
+
@skip_paths = Array(value)
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
# Hosts whose outbound calls are left out: a String is a host name, matched whole and
|
|
160
|
+
# case-insensitively, or, when it starts with a dot (".internal"), a domain whose subdomains
|
|
161
|
+
# are left out; a Regexp is matched against the host name. Any other entry (a wildcard
|
|
162
|
+
# "*.internal", an empty String, nil) is left out with a warning in the log, the others kept:
|
|
163
|
+
# the gem is loaded in production too, where a mistake here must not stop the application
|
|
164
|
+
# from booting.
|
|
165
|
+
def http_skip_hosts=(value)
|
|
166
|
+
@http_skip_hosts = Array(value).select { |entry| valid_http_skip_host?(entry) }
|
|
167
|
+
end
|
|
152
168
|
|
|
153
169
|
# Host names the MCP HTTP endpoint accepts beyond the loopback ones and config.hosts: a String
|
|
154
170
|
# (a host name, any port, or a host:port) or a Regexp anchored with \A and \z, matched
|
|
@@ -323,6 +339,22 @@ module Profiler
|
|
|
323
339
|
end
|
|
324
340
|
end
|
|
325
341
|
|
|
342
|
+
def valid_http_skip_host?(entry)
|
|
343
|
+
problem =
|
|
344
|
+
case entry
|
|
345
|
+
when Regexp then nil
|
|
346
|
+
when String
|
|
347
|
+
if entry.strip.empty? || entry.match?(/[*\s]/)
|
|
348
|
+
"is not a host name; list each host, a domain as \".internal\" for its subdomains, or use a Regexp"
|
|
349
|
+
end
|
|
350
|
+
else "must be a String or a Regexp"
|
|
351
|
+
end
|
|
352
|
+
return true unless problem
|
|
353
|
+
|
|
354
|
+
Profiler.log_warn("config.http_skip_hosts: #{entry.inspect} #{problem}; ignored")
|
|
355
|
+
false
|
|
356
|
+
end
|
|
357
|
+
|
|
326
358
|
def validate_mcp_allowed_host_pattern(pattern)
|
|
327
359
|
unless Profiler::Cluster::Security.anchored_pattern?(pattern)
|
|
328
360
|
raise ArgumentError, "config.mcp_allowed_hosts: #{pattern.inspect} must start with \\A and end with \\z, " \
|
|
@@ -122,12 +122,29 @@ module Profiler
|
|
|
122
122
|
# it are the profiler's, whichever code makes them.
|
|
123
123
|
def self.skip_host?(host, port = nil)
|
|
124
124
|
config = Profiler.configuration
|
|
125
|
-
return true if config.http_skip_hosts.any? { |pattern|
|
|
125
|
+
return true if config.http_skip_hosts.any? { |pattern| skip_host_pattern?(pattern, host) }
|
|
126
126
|
|
|
127
127
|
master = master_address(config.master_url)
|
|
128
128
|
!master.nil? && master[0].casecmp?(host) && (port.nil? || master[1] == port)
|
|
129
129
|
end
|
|
130
130
|
|
|
131
|
+
# A String is never read as a Regexp: "example.com" would match notexample.com, and
|
|
132
|
+
# "*.internal" would raise on every call. It is the host name, or, starting with a dot, a
|
|
133
|
+
# domain whose subdomains match. A list changed in place (<<) is not validated: anything
|
|
134
|
+
# else matches nothing.
|
|
135
|
+
def self.skip_host_pattern?(pattern, host)
|
|
136
|
+
case pattern
|
|
137
|
+
when Regexp then pattern.match?(host)
|
|
138
|
+
when String
|
|
139
|
+
if pattern.start_with?(".")
|
|
140
|
+
host.length > pattern.length && host.downcase.end_with?(pattern.downcase)
|
|
141
|
+
else
|
|
142
|
+
pattern.casecmp?(host)
|
|
143
|
+
end
|
|
144
|
+
else false
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
|
|
131
148
|
def self.master_address(url)
|
|
132
149
|
return nil if url.nil? || url.to_s.empty?
|
|
133
150
|
|
|
@@ -278,7 +278,8 @@ module Profiler
|
|
|
278
278
|
|
|
279
279
|
# Skip profiler's own paths and static assets
|
|
280
280
|
path = env["PATH_INFO"]
|
|
281
|
-
|
|
281
|
+
# === matches a Regexp against the path and compares a String with it.
|
|
282
|
+
Profiler.configuration.skip_paths.none? { |pattern| pattern === path }
|
|
282
283
|
end
|
|
283
284
|
|
|
284
285
|
def build_request(env)
|
|
@@ -371,13 +372,7 @@ module Profiler
|
|
|
371
372
|
def read_buffered_body(body)
|
|
372
373
|
content =
|
|
373
374
|
begin
|
|
374
|
-
|
|
375
|
-
body.to_ary.join
|
|
376
|
-
else
|
|
377
|
-
parts = +""
|
|
378
|
-
body.each { |part| parts << part }
|
|
379
|
-
parts
|
|
380
|
-
end
|
|
375
|
+
join_parts(body.respond_to?(:to_ary) ? body.to_ary : body)
|
|
381
376
|
rescue Exception # rubocop:disable Lint/RescueException
|
|
382
377
|
begin
|
|
383
378
|
close_body(body)
|
|
@@ -390,6 +385,20 @@ module Profiler
|
|
|
390
385
|
content
|
|
391
386
|
end
|
|
392
387
|
|
|
388
|
+
# The fragments' bytes, joined as they are: fragments of different encodings (a binary one
|
|
389
|
+
# among UTF-8 ones) cannot be joined as text, and converting them would change the page.
|
|
390
|
+
# The result takes the encoding of the first fragment, as CapturingBody does.
|
|
391
|
+
def join_parts(parts)
|
|
392
|
+
content = String.new(encoding: Encoding::BINARY)
|
|
393
|
+
encoding = nil
|
|
394
|
+
parts.each do |part|
|
|
395
|
+
part = part.to_s
|
|
396
|
+
encoding ||= part.encoding
|
|
397
|
+
content << part.b
|
|
398
|
+
end
|
|
399
|
+
content.force_encoding(encoding || Encoding::UTF_8)
|
|
400
|
+
end
|
|
401
|
+
|
|
393
402
|
def close_body(body)
|
|
394
403
|
body.close if body.respond_to?(:close) && !(body.respond_to?(:closed?) && body.closed?)
|
|
395
404
|
end
|
data/lib/profiler/version.rb
CHANGED
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.33.
|
|
4
|
+
version: 0.33.2
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Sébastien Duplessy
|
|
@@ -109,6 +109,7 @@ files:
|
|
|
109
109
|
- app/assets/builds/profiler-toolbar.js
|
|
110
110
|
- app/assets/builds/profiler.css
|
|
111
111
|
- app/assets/builds/profiler.js
|
|
112
|
+
- app/assets/javascript/profiler-ajax-interceptor.js
|
|
112
113
|
- app/controllers/profiler/api/ajax_controller.rb
|
|
113
114
|
- app/controllers/profiler/api/cluster_controller.rb
|
|
114
115
|
- app/controllers/profiler/api/console_controller.rb
|