rails-profiler 0.31.4 → 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 +73 -0
- data/app/assets/builds/profiler.js +9 -1
- data/app/controllers/profiler/profiles_controller.rb +2 -2
- data/lib/profiler/cluster/master_client.rb +7 -15
- data/lib/profiler/cluster/security.rb +3 -3
- data/lib/profiler/cluster/slave_proxy.rb +7 -4
- 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 +15 -4
- 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/server.rb +1 -1
- data/lib/profiler/mcp/tools/get_profile_http.rb +6 -1
- data/lib/profiler/mcp/tools/query_console_profiles.rb +5 -1
- data/lib/profiler/mcp/tools/query_jobs.rb +5 -1
- data/lib/profiler/mcp/tools/query_mailers.rb +5 -1
- data/lib/profiler/mcp/tools/query_profiles.rb +5 -1
- data/lib/profiler/mcp/tools/query_test_profiles.rb +5 -1
- data/lib/profiler/mcp/tools/run_tests.rb +4 -1
- data/lib/profiler/middleware/profiler_middleware.rb +22 -19
- 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 +1 -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 +8 -3
- data/lib/profiler/storage/private_files.rb +3 -3
- data/lib/profiler/storage/redis_store.rb +2 -2
- data/lib/profiler/storage/sqlite_store.rb +3 -3
- data/lib/profiler/storage/unavailable.rb +1 -2
- data/lib/profiler/test_helpers/reporter.rb +1 -1
- 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 +126 -3
- metadata +1 -1
|
@@ -6,6 +6,7 @@ require "zlib"
|
|
|
6
6
|
require "stringio"
|
|
7
7
|
require "securerandom"
|
|
8
8
|
require_relative "../redaction"
|
|
9
|
+
require_relative "../configuration"
|
|
9
10
|
|
|
10
11
|
module Profiler
|
|
11
12
|
module Instrumentation
|
|
@@ -17,36 +18,19 @@ module Profiler
|
|
|
17
18
|
# Re-entrancy guard: Net::HTTP#request calls itself recursively when
|
|
18
19
|
# the connection isn't started yet. Only record the outermost call.
|
|
19
20
|
return super if Thread.current[:profiler_http_recording]
|
|
21
|
+
# The profiler's own calls (Profiler.untracked_http).
|
|
22
|
+
return super if Thread.current[:profiler_http_untracked]
|
|
20
23
|
|
|
21
24
|
host = address.to_s
|
|
22
|
-
return super if NetHttpInstrumentation.skip_host?(host)
|
|
25
|
+
return super if NetHttpInstrumentation.skip_host?(host, port)
|
|
23
26
|
|
|
24
27
|
url = Redaction.filter_url(build_url(host, port, req.path, use_ssl?))
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
# to req inside super via req.set_body_internal(body)
|
|
28
|
-
req_body = body.to_s if req_body.empty? && body
|
|
29
|
-
# Fallback: libraries like RestClient set body_stream instead of body
|
|
30
|
-
# and pass nil as the 2nd argument to Net::HTTP#request.
|
|
31
|
-
# Read the stream content, then restore it so the actual request works.
|
|
32
|
-
# RestClient::Payload doesn't implement #rewind, so we fall back to
|
|
33
|
-
# replacing body_stream with a fresh StringIO.
|
|
34
|
-
if req_body.empty? && req.body_stream
|
|
35
|
-
begin
|
|
36
|
-
stream = req.body_stream
|
|
37
|
-
req_body = stream.read.to_s
|
|
38
|
-
if stream.respond_to?(:rewind)
|
|
39
|
-
stream.rewind
|
|
40
|
-
else
|
|
41
|
-
req.body_stream = StringIO.new(req_body)
|
|
42
|
-
end
|
|
43
|
-
rescue
|
|
44
|
-
req_body = ""
|
|
45
|
-
end
|
|
46
|
-
end
|
|
28
|
+
limit = Profiler.configuration.max_captured_body_bytes
|
|
29
|
+
captured = NetHttpInstrumentation.capture_request_body(req, body, limit)
|
|
47
30
|
req_headers = Redaction.filter_headers(req.to_hash.transform_values { |v| v.join(", ") })
|
|
48
31
|
req_content_type = req["content-type"].to_s
|
|
49
|
-
|
|
32
|
+
req_body = captured[:content]
|
|
33
|
+
processed_req = req_body.nil? || req_body.empty? ? { body: nil, encoding: "text" } : NetHttpInstrumentation.process_body(req_body, req_content_type)
|
|
50
34
|
|
|
51
35
|
request_id = SecureRandom.hex(8)
|
|
52
36
|
started_at = Time.now.iso8601(3)
|
|
@@ -63,7 +47,10 @@ module Profiler
|
|
|
63
47
|
request_headers: req_headers,
|
|
64
48
|
request_body: processed_req[:body],
|
|
65
49
|
request_body_encoding: processed_req[:encoding],
|
|
66
|
-
request_size:
|
|
50
|
+
request_size: captured[:size],
|
|
51
|
+
request_size_is_minimum: captured[:size_is_minimum],
|
|
52
|
+
request_body_truncated: captured[:truncated],
|
|
53
|
+
request_body_not_captured: captured[:not_captured],
|
|
67
54
|
backtrace: NetHttpInstrumentation.extract_backtrace
|
|
68
55
|
)
|
|
69
56
|
|
|
@@ -74,7 +61,9 @@ module Profiler
|
|
|
74
61
|
duration = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - t0) * 1000).round(2)
|
|
75
62
|
resp_body_raw = response.body.to_s
|
|
76
63
|
resp_content_encoding = response["content-encoding"].to_s.strip.downcase
|
|
77
|
-
resp_body = NetHttpInstrumentation.decompress_body(resp_body_raw, resp_content_encoding)
|
|
64
|
+
resp_body = NetHttpInstrumentation.decompress_body(resp_body_raw, resp_content_encoding, limit)
|
|
65
|
+
resp_truncated = limit ? resp_body.bytesize > limit : false
|
|
66
|
+
resp_body = Redaction.cut_bytes(resp_body, limit) if resp_truncated
|
|
78
67
|
resp_content_type = response["content-type"].to_s
|
|
79
68
|
processed_resp = NetHttpInstrumentation.process_body(resp_body, resp_content_type)
|
|
80
69
|
|
|
@@ -87,7 +76,8 @@ module Profiler
|
|
|
87
76
|
response_headers: Redaction.filter_headers(response.to_hash.transform_values { |v| v.join(", ") }),
|
|
88
77
|
response_body: processed_resp[:body],
|
|
89
78
|
response_body_encoding: processed_resp[:encoding],
|
|
90
|
-
response_size: resp_body_raw.bytesize
|
|
79
|
+
response_size: resp_body_raw.bytesize,
|
|
80
|
+
response_body_truncated: resp_truncated
|
|
91
81
|
)
|
|
92
82
|
|
|
93
83
|
fg = Thread.current[:profiler_flamegraph_collector]
|
|
@@ -113,7 +103,11 @@ module Profiler
|
|
|
113
103
|
end
|
|
114
104
|
end
|
|
115
105
|
|
|
106
|
+
# Deprecated, as it was: the hosts earlier versions always left out, kept for code that read
|
|
107
|
+
# them. They are left out only when listed in config.http_skip_hosts now
|
|
108
|
+
# (Profiler::Configuration::LOCAL_HTTP_HOSTS).
|
|
116
109
|
SKIP_HOSTS = %w[127.0.0.1 localhost ::1].freeze
|
|
110
|
+
deprecate_constant :SKIP_HOSTS
|
|
117
111
|
|
|
118
112
|
TEXT_CONTENT_TYPES = /\A(text\/|application\/(json|xml|xhtml|javascript|x-www-form-urlencoded)|image\/svg)/i
|
|
119
113
|
BINARY_CONTENT_TYPES = /\A(image\/|application\/pdf|application\/octet-stream|application\/zip|audio\/|video\/)/i
|
|
@@ -124,26 +118,126 @@ module Profiler
|
|
|
124
118
|
@installed = true
|
|
125
119
|
end
|
|
126
120
|
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
121
|
+
# The hosts of http_skip_hosts, and the cluster's master (host and port): a slave's calls to
|
|
122
|
+
# it are the profiler's, whichever code makes them.
|
|
123
|
+
def self.skip_host?(host, port = nil)
|
|
124
|
+
config = Profiler.configuration
|
|
125
|
+
return true if config.http_skip_hosts.any? { |pattern| host.match?(pattern) }
|
|
126
|
+
|
|
127
|
+
master = master_address(config.master_url)
|
|
128
|
+
!master.nil? && master[0].casecmp?(host) && (port.nil? || master[1] == port)
|
|
130
129
|
end
|
|
131
130
|
|
|
132
|
-
def self.
|
|
133
|
-
return
|
|
131
|
+
def self.master_address(url)
|
|
132
|
+
return nil if url.nil? || url.to_s.empty?
|
|
134
133
|
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
134
|
+
uri = URI(url.to_s)
|
|
135
|
+
uri.host ? [uri.host.delete_prefix("[").delete_suffix("]"), uri.port] : nil
|
|
136
|
+
rescue URI::Error
|
|
137
|
+
nil
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
# What a profile keeps of the body Net::HTTP is about to send: at most +limit+ bytes, and
|
|
141
|
+
# the whole size when known (size_is_minimum when only a lower bound is). A body_stream is
|
|
142
|
+
# read only when it can be put back where it was (a StringIO, a file), and no further than
|
|
143
|
+
# its Content-Length; a pipe, a socket or an object without pos (a multipart payload) is
|
|
144
|
+
# never touched, as reading it would leave nothing to send.
|
|
145
|
+
def self.capture_request_body(req, body, limit)
|
|
146
|
+
content = req.body
|
|
147
|
+
content = body if (content.nil? || content.empty?) && body
|
|
148
|
+
unless content.nil? || content.to_s.empty?
|
|
149
|
+
content = content.to_s
|
|
150
|
+
truncated = limit ? content.bytesize > limit : false
|
|
151
|
+
return { content: truncated ? Redaction.cut_bytes(content, limit) : content,
|
|
152
|
+
size: content.bytesize, size_is_minimum: false, truncated: truncated, not_captured: false }
|
|
142
153
|
end
|
|
143
|
-
|
|
154
|
+
|
|
155
|
+
stream = req.body_stream
|
|
156
|
+
return { content: nil, size: 0, size_is_minimum: false, truncated: false, not_captured: false } unless stream
|
|
157
|
+
|
|
158
|
+
declared = req["content-length"].to_s
|
|
159
|
+
declared = declared.match?(/\A\d+\z/) ? declared.to_i : nil
|
|
160
|
+
unless rewindable?(stream)
|
|
161
|
+
return { content: nil, size: declared, size_is_minimum: false, truncated: false, not_captured: true }
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
position = stream.pos
|
|
165
|
+
total = declared || remaining_size(stream, position)
|
|
166
|
+
want = [limit && limit + 1, total].compact.min
|
|
167
|
+
begin
|
|
168
|
+
read = (want ? stream.read(want) : stream.read).to_s
|
|
169
|
+
ensure
|
|
170
|
+
stream.pos = position
|
|
171
|
+
end
|
|
172
|
+
# Bytes as they were sent: labelled UTF-8, the encoding of text on the wire, for the text
|
|
173
|
+
# filter; a binary type is kept as bytes whatever the label.
|
|
174
|
+
read = read.dup.force_encoding(Encoding::UTF_8)
|
|
175
|
+
cut = limit ? read.bytesize > limit : false
|
|
176
|
+
content = cut ? Redaction.cut_bytes(read, limit) : read
|
|
177
|
+
size = total || read.bytesize
|
|
178
|
+
{ content: content, size: size, size_is_minimum: total.nil? && cut,
|
|
179
|
+
truncated: content.bytesize < size, not_captured: false }
|
|
180
|
+
rescue IOError, SystemCallError => e
|
|
181
|
+
Profiler.log_error_once(:net_http_body_stream, "NetHttpInstrumentation: could not read a request body_stream", e)
|
|
182
|
+
{ content: nil, size: declared, size_is_minimum: false, truncated: false, not_captured: true }
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
# What is left to read of a stream that knows its size (a StringIO, a file), else nil.
|
|
186
|
+
def self.remaining_size(stream, position)
|
|
187
|
+
size = stream.respond_to?(:size) ? stream.size : nil
|
|
188
|
+
size.is_a?(Integer) ? [size - position, 0].max : nil
|
|
189
|
+
rescue IOError, SystemCallError
|
|
190
|
+
nil
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
def self.rewindable?(stream)
|
|
194
|
+
return false unless stream.respond_to?(:pos) && stream.respond_to?(:pos=) && stream.respond_to?(:read)
|
|
195
|
+
return stream.stat.file? if stream.respond_to?(:stat)
|
|
196
|
+
|
|
197
|
+
true
|
|
198
|
+
rescue IOError, SystemCallError
|
|
199
|
+
false
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
# Inflated up to the limit, give or take one buffer of the inflater (16 KB): a small
|
|
203
|
+
# compressed answer can stand for a huge text, which a profile never keeps whole. Labelled
|
|
204
|
+
# UTF-8, as the text it is when its type is a text one.
|
|
205
|
+
def self.decompress_body(body, content_encoding, limit = nil)
|
|
206
|
+
return body if content_encoding.empty? || body.nil? || body.empty?
|
|
207
|
+
|
|
208
|
+
inflated =
|
|
209
|
+
case content_encoding
|
|
210
|
+
when "gzip", "x-gzip" then inflate_capped(body, limit, Zlib::MAX_WBITS + 16)
|
|
211
|
+
when "deflate" then inflate_capped(body, limit, Zlib::MAX_WBITS)
|
|
212
|
+
else return body
|
|
213
|
+
end
|
|
214
|
+
inflated.force_encoding(Encoding::UTF_8)
|
|
215
|
+
rescue Zlib::Error
|
|
144
216
|
body
|
|
145
217
|
end
|
|
146
218
|
|
|
219
|
+
# The input goes in by small slices, and the output is taken buffer by buffer: the
|
|
220
|
+
# inflating stops as soon as the limit is passed.
|
|
221
|
+
def self.inflate_capped(body, limit, window_bits)
|
|
222
|
+
inflater = Zlib::Inflate.new(window_bits)
|
|
223
|
+
return inflater.inflate(body) unless limit
|
|
224
|
+
|
|
225
|
+
out = +""
|
|
226
|
+
offset = 0
|
|
227
|
+
catch(:full) do
|
|
228
|
+
while offset < body.bytesize && !inflater.finished?
|
|
229
|
+
inflater.inflate(body.byteslice(offset, 1024)) do |chunk|
|
|
230
|
+
out << chunk
|
|
231
|
+
throw :full if out.bytesize > limit
|
|
232
|
+
end
|
|
233
|
+
offset += 1024
|
|
234
|
+
end
|
|
235
|
+
end
|
|
236
|
+
out
|
|
237
|
+
ensure
|
|
238
|
+
inflater&.close
|
|
239
|
+
end
|
|
240
|
+
|
|
147
241
|
def self.process_body(body, content_type)
|
|
148
242
|
return { body: nil, encoding: "text" } if body.nil? || body.empty?
|
|
149
243
|
|
|
@@ -153,8 +247,12 @@ module Profiler
|
|
|
153
247
|
# Masked on the raw bytes, before the encoding hides them from any later search.
|
|
154
248
|
{ body: Base64.strict_encode64(Redaction.hide_credentials(body.b)), encoding: "base64" }
|
|
155
249
|
else
|
|
250
|
+
# Bytes are read as UTF-8, as a text body on the wire is; what is not valid UTF-8 shows
|
|
251
|
+
# as "?".
|
|
252
|
+
body = body.dup.force_encoding(Encoding::UTF_8) if body.encoding == Encoding::BINARY
|
|
156
253
|
text = Redaction.filter_body(body, content_type)
|
|
157
254
|
.encode("UTF-8", invalid: :replace, undef: :replace, replace: "?")
|
|
255
|
+
text = text.scrub("?") unless text.valid_encoding?
|
|
158
256
|
{ body: text, encoding: "text" }
|
|
159
257
|
end
|
|
160
258
|
end
|
|
@@ -105,7 +105,7 @@ module Profiler
|
|
|
105
105
|
collector.collect if collector.respond_to?(:collect)
|
|
106
106
|
profile.add_collector_metadata(collector)
|
|
107
107
|
rescue => e
|
|
108
|
-
|
|
108
|
+
Profiler.log_error("JobProfiler: collector #{collector.class} failed", e)
|
|
109
109
|
end
|
|
110
110
|
end
|
|
111
111
|
|
|
@@ -57,18 +57,14 @@ module Profiler
|
|
|
57
57
|
@warned = true
|
|
58
58
|
end
|
|
59
59
|
|
|
60
|
-
message = "
|
|
60
|
+
message = "Request refused or not profiled by authorization_mode :allow_local: #{reason}. " \
|
|
61
61
|
"The profiler only serves and captures requests made from this machine. " \
|
|
62
62
|
"If the application runs in Docker or behind a remote proxy, either set " \
|
|
63
63
|
"config.authorization_mode = :allow_authorized with a config.authorize_with block " \
|
|
64
64
|
"that admits your network (see the Access control section of the README), or set " \
|
|
65
65
|
"config.authorization_mode = :allow_all, which offers no protection at all. " \
|
|
66
66
|
"This message is logged once per process."
|
|
67
|
-
|
|
68
|
-
Rails.logger.warn(message)
|
|
69
|
-
else
|
|
70
|
-
warn(message)
|
|
71
|
-
end
|
|
67
|
+
Profiler.log_warn(message)
|
|
72
68
|
end
|
|
73
69
|
|
|
74
70
|
def reset_warning!
|
|
@@ -26,7 +26,8 @@ module Profiler
|
|
|
26
26
|
|
|
27
27
|
dir = Storage::PrivateFiles.tmp_dir(DIR_NAME, token)
|
|
28
28
|
Storage::PrivateFiles.write(dir.join(name), content)
|
|
29
|
-
rescue Errno::EACCES, Errno::EROFS
|
|
29
|
+
rescue Errno::EACCES, Errno::EROFS => e
|
|
30
|
+
Profiler.log_error_once(:mcp_file_cache, "MCP: could not cache a body under tmp_path", e)
|
|
30
31
|
nil
|
|
31
32
|
end
|
|
32
33
|
|
data/lib/profiler/mcp/server.rb
CHANGED
|
@@ -57,7 +57,7 @@ module Profiler
|
|
|
57
57
|
when :stdio
|
|
58
58
|
::MCP::Server::Transports::StdioTransport.new(@server).open
|
|
59
59
|
when :http
|
|
60
|
-
|
|
60
|
+
Profiler.log_info("MCP: HTTP transport active, endpoint /_profiler/mcp")
|
|
61
61
|
else
|
|
62
62
|
raise Error, "Unknown transport: #{transport}"
|
|
63
63
|
end
|
|
@@ -85,8 +85,13 @@ module Profiler
|
|
|
85
85
|
lines << "- **URL:** #{req['url']}"
|
|
86
86
|
lines << "- **Status:** #{req['status'] == 0 ? 'connection error' : req['status']}"
|
|
87
87
|
lines << "- **Duration:** #{req['duration'].round(2)} ms"
|
|
88
|
-
lines << "- **Request Size:** #{req['request_size']} bytes"
|
|
88
|
+
lines << "- **Request Size:** #{req['request_size'].nil? ? 'unknown' : "#{'at least ' if req['request_size_is_minimum']}#{req['request_size']} bytes"}"
|
|
89
89
|
lines << "- **Response Size:** #{req['response_size']} bytes"
|
|
90
|
+
if req["request_body_not_captured"]
|
|
91
|
+
lines << "- _Request body not captured: a stream that cannot be rewound is sent unread._"
|
|
92
|
+
end
|
|
93
|
+
lines << "- _Request body truncated: only the first bytes were kept (max_captured_body_bytes)._" if req["request_body_truncated"]
|
|
94
|
+
lines << "- _Response body truncated: only the first bytes were kept (max_captured_body_bytes)._" if req["response_body_truncated"]
|
|
90
95
|
lines << "- **Error:** #{req['error']}" if req["error"]
|
|
91
96
|
|
|
92
97
|
if req["request_headers"] && !req["request_headers"].empty?
|
|
@@ -40,7 +40,11 @@ module Profiler
|
|
|
40
40
|
end
|
|
41
41
|
|
|
42
42
|
if params["cursor"]
|
|
43
|
-
cutoff =
|
|
43
|
+
cutoff = begin
|
|
44
|
+
Time.parse(params["cursor"])
|
|
45
|
+
rescue ArgumentError, TypeError
|
|
46
|
+
nil # a cursor that is not a time is ignored, as before
|
|
47
|
+
end
|
|
44
48
|
consoles = consoles.select { |p| p.started_at < cutoff } if cutoff
|
|
45
49
|
end
|
|
46
50
|
|
|
@@ -31,7 +31,11 @@ module Profiler
|
|
|
31
31
|
end
|
|
32
32
|
|
|
33
33
|
if params["cursor"]
|
|
34
|
-
cutoff =
|
|
34
|
+
cutoff = begin
|
|
35
|
+
Time.parse(params["cursor"])
|
|
36
|
+
rescue ArgumentError, TypeError
|
|
37
|
+
nil # a cursor that is not a time is ignored, as before
|
|
38
|
+
end
|
|
35
39
|
jobs = jobs.select { |p| p.started_at < cutoff } if cutoff
|
|
36
40
|
end
|
|
37
41
|
|
|
@@ -49,7 +49,11 @@ module Profiler
|
|
|
49
49
|
end
|
|
50
50
|
|
|
51
51
|
if params["cursor"]
|
|
52
|
-
cutoff =
|
|
52
|
+
cutoff = begin
|
|
53
|
+
Time.parse(params["cursor"])
|
|
54
|
+
rescue ArgumentError, TypeError
|
|
55
|
+
nil # a cursor that is not a time is ignored, as before
|
|
56
|
+
end
|
|
53
57
|
if cutoff
|
|
54
58
|
emails = emails.select do |e|
|
|
55
59
|
started = e["profile_started_at"]
|
|
@@ -23,7 +23,11 @@ module Profiler
|
|
|
23
23
|
profiles = profiles.select { |p| p.profile_type == params["profile_type"] } if params["profile_type"]
|
|
24
24
|
|
|
25
25
|
if params["cursor"]
|
|
26
|
-
cutoff =
|
|
26
|
+
cutoff = begin
|
|
27
|
+
Time.parse(params["cursor"])
|
|
28
|
+
rescue ArgumentError, TypeError
|
|
29
|
+
nil # a cursor that is not a time is ignored, as before
|
|
30
|
+
end
|
|
27
31
|
profiles = profiles.select { |p| p.started_at < cutoff } if cutoff
|
|
28
32
|
end
|
|
29
33
|
|
|
@@ -37,7 +37,11 @@ module Profiler
|
|
|
37
37
|
end
|
|
38
38
|
|
|
39
39
|
if params["cursor"]
|
|
40
|
-
cutoff =
|
|
40
|
+
cutoff = begin
|
|
41
|
+
Time.parse(params["cursor"])
|
|
42
|
+
rescue ArgumentError, TypeError
|
|
43
|
+
nil # a cursor that is not a time is ignored, as before
|
|
44
|
+
end
|
|
41
45
|
tests = tests.select { |p| p.started_at < cutoff } if cutoff
|
|
42
46
|
end
|
|
43
47
|
|
|
@@ -117,7 +117,10 @@ module Profiler
|
|
|
117
117
|
Profiler.storage.list(limit: 500, type: "test").select do |p|
|
|
118
118
|
p.profile_type == "test" && p.started_at && p.started_at >= since
|
|
119
119
|
end.map(&:token)
|
|
120
|
-
rescue
|
|
120
|
+
rescue Profiler::Storage::Unavailable::Error
|
|
121
|
+
raise # answered by the MCP server as an error
|
|
122
|
+
rescue StandardError => e
|
|
123
|
+
Profiler.log_error("MCP run_tests: could not list the profiles of the run", e)
|
|
121
124
|
[]
|
|
122
125
|
end
|
|
123
126
|
|
|
@@ -42,7 +42,7 @@ module Profiler
|
|
|
42
42
|
env["profiler.collectors"] = collectors
|
|
43
43
|
subscribed = Collectors::Lifecycle.subscribe_all(collectors, "ProfilerMiddleware")
|
|
44
44
|
rescue => e
|
|
45
|
-
|
|
45
|
+
Profiler.log_error("ProfilerMiddleware: could not start profiling the request", e, backtrace: true)
|
|
46
46
|
subscribed = false
|
|
47
47
|
end
|
|
48
48
|
|
|
@@ -104,10 +104,9 @@ module Profiler
|
|
|
104
104
|
record_response(env, profile, request_body, status, headers, content, content.bytesize, true)
|
|
105
105
|
collect_all(profile, collectors)
|
|
106
106
|
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
return [status, headers, body] if storage.is_a?(Profiler::Storage::Unavailable)
|
|
107
|
+
# A profile that was not saved (a store unavailable, a save that failed) gets no token and
|
|
108
|
+
# no toolbar: both would point to a profile that is not there.
|
|
109
|
+
return [status, headers, body] unless Profiler.save_profile(profile, from: "ProfilerMiddleware")
|
|
111
110
|
|
|
112
111
|
set_header(headers, TOKEN_HEADER, profile.token)
|
|
113
112
|
|
|
@@ -122,7 +121,7 @@ module Profiler
|
|
|
122
121
|
[status, headers, body]
|
|
123
122
|
rescue => e
|
|
124
123
|
# The application has run: its response goes out, profiled or not.
|
|
125
|
-
|
|
124
|
+
Profiler.log_error("ProfilerMiddleware: could not complete the profile", e, backtrace: true)
|
|
126
125
|
[status, headers, body || []]
|
|
127
126
|
end
|
|
128
127
|
|
|
@@ -140,7 +139,6 @@ module Profiler
|
|
|
140
139
|
collect_all(profile, now)
|
|
141
140
|
# Their tabs keep their place; what they hold is filled in when they are collected.
|
|
142
141
|
kept.each { |collector| profile.add_collector_metadata(collector) }
|
|
143
|
-
set_header(headers, TOKEN_HEADER, profile.token)
|
|
144
142
|
|
|
145
143
|
# A Rack 3 streaming body (call without each) writes to the socket itself: nothing to relay.
|
|
146
144
|
unless body.respond_to?(:each)
|
|
@@ -148,13 +146,18 @@ module Profiler
|
|
|
148
146
|
collector.collect if collector.respond_to?(:collect)
|
|
149
147
|
profile.refresh_collector_metadata(collector)
|
|
150
148
|
rescue => e
|
|
151
|
-
|
|
149
|
+
Profiler.log_error("ProfilerMiddleware: collector #{collector.class} failed", e)
|
|
152
150
|
end
|
|
153
151
|
record_response(env, profile, request_body, status, profiled_headers, "", nil, false)
|
|
154
|
-
|
|
152
|
+
# Saved before the headers leave: a profile that is not there gets no token.
|
|
153
|
+
set_header(headers, TOKEN_HEADER, profile.token) if Profiler.save_profile(profile, from: "ProfilerMiddleware")
|
|
155
154
|
return [[status, headers, body], nil]
|
|
156
155
|
end
|
|
157
156
|
|
|
157
|
+
# Saved when the server closes the body, after the headers have left: the token is sent
|
|
158
|
+
# unless the store is already known to be unavailable, where it would be dropped.
|
|
159
|
+
set_header(headers, TOKEN_HEADER, profile.token) unless Profiler.storage.is_a?(Profiler::Storage::Unavailable)
|
|
160
|
+
|
|
158
161
|
streamed = StreamedProfile.new(profile, kept) do |captured, size, complete, error|
|
|
159
162
|
finish_streamed(env, streamed, collectors, request_body, allocations_before, status, profiled_headers,
|
|
160
163
|
captured, size, complete, error)
|
|
@@ -166,7 +169,7 @@ module Profiler
|
|
|
166
169
|
StreamedProfile.register(streamed)
|
|
167
170
|
[[status, headers, streamed.body], kept]
|
|
168
171
|
rescue => e
|
|
169
|
-
|
|
172
|
+
Profiler.log_error("ProfilerMiddleware: could not profile a streamed response", e, backtrace: true)
|
|
170
173
|
[[status, headers, body], nil]
|
|
171
174
|
end
|
|
172
175
|
|
|
@@ -189,12 +192,12 @@ module Profiler
|
|
|
189
192
|
collector.collect
|
|
190
193
|
profile.refresh_collector_metadata(collector)
|
|
191
194
|
rescue => e
|
|
192
|
-
|
|
195
|
+
Profiler.log_error("ProfilerMiddleware: collector #{collector.class} failed", e)
|
|
193
196
|
end
|
|
194
197
|
|
|
195
|
-
Profiler.
|
|
198
|
+
Profiler.save_profile(profile, from: "ProfilerMiddleware")
|
|
196
199
|
rescue => e
|
|
197
|
-
|
|
200
|
+
Profiler.log_error("ProfilerMiddleware: could not finish the profile of a streamed response", e)
|
|
198
201
|
end
|
|
199
202
|
|
|
200
203
|
def collect_from_any_thread?(collector)
|
|
@@ -241,9 +244,9 @@ module Profiler
|
|
|
241
244
|
resp_content_type: ""
|
|
242
245
|
)
|
|
243
246
|
collect_all(profile, collectors)
|
|
244
|
-
Profiler.
|
|
247
|
+
Profiler.save_profile(profile, from: "ProfilerMiddleware")
|
|
245
248
|
rescue => e
|
|
246
|
-
|
|
249
|
+
Profiler.log_error("ProfilerMiddleware: could not record a failed request", e)
|
|
247
250
|
end
|
|
248
251
|
|
|
249
252
|
def collect_all(profile, collectors)
|
|
@@ -252,7 +255,7 @@ module Profiler
|
|
|
252
255
|
collector.collect if collector.respond_to?(:collect)
|
|
253
256
|
profile.add_collector_metadata(collector)
|
|
254
257
|
rescue => e
|
|
255
|
-
|
|
258
|
+
Profiler.log_error("ProfilerMiddleware: collector #{collector.class} failed", e)
|
|
256
259
|
end
|
|
257
260
|
end
|
|
258
261
|
end
|
|
@@ -262,7 +265,7 @@ module Profiler
|
|
|
262
265
|
kept&.each do |collector|
|
|
263
266
|
collector.release_thread_slots if collector.respond_to?(:release_thread_slots)
|
|
264
267
|
rescue => e
|
|
265
|
-
|
|
268
|
+
Profiler.log_error("ProfilerMiddleware: collector #{collector.class} release failed", e)
|
|
266
269
|
end
|
|
267
270
|
Profiler::CurrentContext.clear
|
|
268
271
|
end
|
|
@@ -379,7 +382,7 @@ module Profiler
|
|
|
379
382
|
begin
|
|
380
383
|
close_body(body)
|
|
381
384
|
rescue => e
|
|
382
|
-
|
|
385
|
+
Profiler.log_error("ProfilerMiddleware: closing a body that failed also failed", e)
|
|
383
386
|
end
|
|
384
387
|
raise
|
|
385
388
|
end
|
|
@@ -413,7 +416,7 @@ module Profiler
|
|
|
413
416
|
content = Redaction.cut_bytes(content, limit) if limit
|
|
414
417
|
{ content: content, size: size, size_is_minimum: size_is_minimum || false }
|
|
415
418
|
rescue => e
|
|
416
|
-
|
|
419
|
+
Profiler.log_error("ProfilerMiddleware: could not read the request body", e)
|
|
417
420
|
{ content: "", size: nil }
|
|
418
421
|
end
|
|
419
422
|
end
|
|
@@ -85,7 +85,7 @@ module Profiler
|
|
|
85
85
|
collector.collect if collector.respond_to?(:collect)
|
|
86
86
|
@profile.refresh_collector_metadata(collector)
|
|
87
87
|
rescue => e
|
|
88
|
-
|
|
88
|
+
Profiler.log_error("ProfilerMiddleware: collector #{collector.class} failed", e)
|
|
89
89
|
end
|
|
90
90
|
Collectors::Lifecycle.release_all(@collectors)
|
|
91
91
|
end
|
|
@@ -26,6 +26,17 @@ module Profiler
|
|
|
26
26
|
TAG_REST = %r{(?:#{WS}++|/|#{ATTRIBUTE})*+>}
|
|
27
27
|
# A whole start or end tag, read after its <: the slash and the name are captured.
|
|
28
28
|
TAG = %r{(/)?([a-z][^\t\n\f\r\ />]*+)#{TAG_REST}}i
|
|
29
|
+
# Text and complete tags that cannot hold the page's end, skipped in one match: every tag
|
|
30
|
+
# but </body>, a raw-text element, <plaintext>, a comment, a declaration or a tag left
|
|
31
|
+
# open, which the scan below reads one by one. A tag here is read with TAG_REST, as there.
|
|
32
|
+
NAME_END = "(?=[\\t\\n\\f\\r\\ />])"
|
|
33
|
+
PLAIN_MARKUP = %r{
|
|
34
|
+
(?:
|
|
35
|
+
[^<]++
|
|
36
|
+
| <(?:/(?!body#{NAME_END})|(?!(?:#{(RAW_TEXT_ELEMENTS.keys + [PLAINTEXT]).join("|")})#{NAME_END}))
|
|
37
|
+
[a-z][^\t\n\f\r\ />]*+#{TAG_REST}
|
|
38
|
+
)*+
|
|
39
|
+
}xi
|
|
29
40
|
|
|
30
41
|
def initialize(body, token, nonce = nil)
|
|
31
42
|
@body = body
|
|
@@ -58,7 +69,10 @@ module Profiler
|
|
|
58
69
|
scanner = StringScanner.new(bytes)
|
|
59
70
|
position = nil
|
|
60
71
|
|
|
61
|
-
|
|
72
|
+
loop do
|
|
73
|
+
skip_plain_markup(scanner)
|
|
74
|
+
break unless scanner.skip_until(/</)
|
|
75
|
+
|
|
62
76
|
start = scanner.pos - 1
|
|
63
77
|
|
|
64
78
|
if scanner.skip(TAG)
|
|
@@ -97,6 +111,11 @@ module Profiler
|
|
|
97
111
|
position
|
|
98
112
|
end
|
|
99
113
|
|
|
114
|
+
# A page is mostly ordinary text and tags: one match instead of one scan step per tag.
|
|
115
|
+
def skip_plain_markup(scanner)
|
|
116
|
+
scanner.skip(PLAIN_MARKUP)
|
|
117
|
+
end
|
|
118
|
+
|
|
100
119
|
# In a script, <!-- followed by <script enters a state where </script> does not close it.
|
|
101
120
|
# Only the script itself is searched, so that a page of many scripts is read in linear
|
|
102
121
|
# time.
|
|
@@ -124,7 +143,7 @@ module Profiler
|
|
|
124
143
|
path = File.join(__dir__, "..", "..", "..", "app", "assets", "javascript", "profiler-ajax-interceptor.js")
|
|
125
144
|
File.read(path)
|
|
126
145
|
rescue => e
|
|
127
|
-
|
|
146
|
+
Profiler.log_error("ToolbarInjector: could not load the AJAX interceptor script", e)
|
|
128
147
|
""
|
|
129
148
|
end
|
|
130
149
|
end
|