rails-profiler 0.31.1 → 0.31.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 +59 -0
- data/app/assets/builds/profiler-toolbar.js +13 -6
- data/app/assets/builds/profiler.js +53 -33
- data/lib/profiler/allocation_counter.rb +21 -0
- data/lib/profiler/collectors/base_collector.rb +31 -0
- data/lib/profiler/collectors/cache_collector.rb +5 -0
- data/lib/profiler/collectors/database_collector.rb +5 -0
- data/lib/profiler/collectors/exception_collector.rb +5 -0
- data/lib/profiler/collectors/flamegraph_collector.rb +5 -0
- data/lib/profiler/collectors/http_collector.rb +5 -0
- data/lib/profiler/collectors/log_collector.rb +5 -0
- data/lib/profiler/collectors/request_collector.rb +14 -11
- data/lib/profiler/collectors/view_collector.rb +5 -0
- data/lib/profiler/configuration.rb +22 -3
- data/lib/profiler/console_profiler.rb +2 -17
- data/lib/profiler/job_profiler.rb +2 -15
- data/lib/profiler/mcp/resources/recent_requests.rb +2 -0
- data/lib/profiler/mcp/tools/get_profile_detail.rb +13 -1
- data/lib/profiler/mcp/tools/get_test_profile_detail.rb +1 -1
- data/lib/profiler/middleware/capturing_body.rb +112 -0
- data/lib/profiler/middleware/profiler_middleware.rb +261 -53
- data/lib/profiler/middleware/streamed_profile.rb +135 -0
- data/lib/profiler/middleware/toolbar_injector.rb +3 -10
- data/lib/profiler/models/profile.rb +69 -5
- data/lib/profiler/redaction.rb +10 -0
- data/lib/profiler/test_profiler.rb +2 -17
- data/lib/profiler/version.rb +1 -1
- data/lib/profiler.rb +1 -0
- metadata +8 -5
|
@@ -87,7 +87,7 @@ module Profiler
|
|
|
87
87
|
lines << "**Request:** #{profile.method} #{profile.path}"
|
|
88
88
|
lines << "**Status:** #{profile.status}"
|
|
89
89
|
lines << "**Duration:** #{profile.duration.round(2)} ms"
|
|
90
|
-
lines << "**
|
|
90
|
+
lines << "**Allocated objects:** #{profile.allocated_objects}" if profile.allocated_objects
|
|
91
91
|
lines << "**Time:** #{profile.started_at}"
|
|
92
92
|
if profile.gem_version
|
|
93
93
|
if profile.gem_version != Profiler::VERSION
|
|
@@ -184,6 +184,9 @@ module Profiler
|
|
|
184
184
|
req_body = req_data["request_body"]
|
|
185
185
|
if req_body && !req_body.empty?
|
|
186
186
|
lines << "## Request Body"
|
|
187
|
+
if req_data["request_body_truncated"]
|
|
188
|
+
lines << truncation_note(req_data["request_body_size"], req_data["request_body_size_is_minimum"])
|
|
189
|
+
end
|
|
187
190
|
formatted = BodyFormatter.format_body(
|
|
188
191
|
profile.token,
|
|
189
192
|
"request_body",
|
|
@@ -209,6 +212,10 @@ module Profiler
|
|
|
209
212
|
resp_body = profile.response_body
|
|
210
213
|
if resp_body && !resp_body.empty?
|
|
211
214
|
lines << "## Response Body"
|
|
215
|
+
response_data = profile.collector_data("request") || {}
|
|
216
|
+
if response_data["response_body_truncated"]
|
|
217
|
+
lines << truncation_note(response_data["response_body_size"], response_data["response_body_size_is_minimum"])
|
|
218
|
+
end
|
|
212
219
|
formatted = BodyFormatter.format_body(
|
|
213
220
|
profile.token,
|
|
214
221
|
"response_body",
|
|
@@ -222,6 +229,11 @@ module Profiler
|
|
|
222
229
|
lines
|
|
223
230
|
end
|
|
224
231
|
|
|
232
|
+
def self.truncation_note(size, minimum)
|
|
233
|
+
"_Truncated: the body was #{"at least " if minimum}#{size || "?"} bytes, only its beginning was kept " \
|
|
234
|
+
"(max_captured_body_bytes)._"
|
|
235
|
+
end
|
|
236
|
+
|
|
225
237
|
def self.section_curl(profile)
|
|
226
238
|
req_data = profile.collector_data("request")
|
|
227
239
|
lines = []
|
|
@@ -52,7 +52,7 @@ module Profiler
|
|
|
52
52
|
lines << "| File | #{test_data["test_file"]}:#{test_data["test_line"]} |"
|
|
53
53
|
lines << "| Duration | #{profile.duration&.round(2)}ms |"
|
|
54
54
|
lines << "| Assertions | #{test_data["assertions"] || "-"} |"
|
|
55
|
-
lines << "|
|
|
55
|
+
lines << "| Allocated objects | #{profile.allocated_objects || "-"} |"
|
|
56
56
|
lines << "| Time | #{profile.started_at&.strftime("%H:%M:%S")} |"
|
|
57
57
|
|
|
58
58
|
# Exception / skip
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../redaction"
|
|
4
|
+
|
|
5
|
+
module Profiler
|
|
6
|
+
module Middleware
|
|
7
|
+
# The body a streamed response leaves the profiler with: it hands the server each chunk as
|
|
8
|
+
# the application yields it, keeps a copy up to a byte limit, and finishes the profile when
|
|
9
|
+
# the server closes it. It answers to_path when the application's body does, so a file can
|
|
10
|
+
# still be sent by its path, and never to_ary, so nothing above it buffers the stream.
|
|
11
|
+
#
|
|
12
|
+
# An error raised while the application's body is iterated goes on to the server, which
|
|
13
|
+
# knows what to do with it; it is only noted for the profile. One raised by the server
|
|
14
|
+
# itself in the block (the client went away) is the server's, not the application's.
|
|
15
|
+
class CapturingBody
|
|
16
|
+
# on_close receives the captured bytes, the size of the body seen so far, whether the
|
|
17
|
+
# body was iterated to its end (if not, the size is only a minimum), and the
|
|
18
|
+
# application's error if there was one. It runs once, after the body has been closed.
|
|
19
|
+
#
|
|
20
|
+
# +context+ (StreamedProfile) is entered while the server iterates the body: what the body
|
|
21
|
+
# does then, a template streamed by `render stream: true` for one, belongs to this profile.
|
|
22
|
+
def initialize(body, limit:, context: nil, &on_close)
|
|
23
|
+
@body = body
|
|
24
|
+
@limit = limit
|
|
25
|
+
@context = context
|
|
26
|
+
@on_close = on_close
|
|
27
|
+
@complete = false
|
|
28
|
+
@profile_finished = false
|
|
29
|
+
@captured = String.new(encoding: Encoding::BINARY)
|
|
30
|
+
@encoding = nil
|
|
31
|
+
@size = 0
|
|
32
|
+
@error = nil
|
|
33
|
+
@closed = false
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def each
|
|
37
|
+
state = @context&.enter
|
|
38
|
+
begin
|
|
39
|
+
@body.each do |chunk|
|
|
40
|
+
capture(chunk)
|
|
41
|
+
begin
|
|
42
|
+
yield chunk
|
|
43
|
+
rescue Exception => e # rubocop:disable Lint/RescueException
|
|
44
|
+
@server_error = e
|
|
45
|
+
raise
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
@complete = true
|
|
49
|
+
rescue => e
|
|
50
|
+
@error ||= e unless e.equal?(@server_error)
|
|
51
|
+
raise
|
|
52
|
+
ensure
|
|
53
|
+
@context&.leave(state)
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def to_path
|
|
58
|
+
@body.to_path
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def respond_to?(name, include_all = false)
|
|
62
|
+
return @body.respond_to?(name, include_all) if name.to_sym == :to_path
|
|
63
|
+
|
|
64
|
+
super
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def close
|
|
68
|
+
return if @closed
|
|
69
|
+
|
|
70
|
+
@closed = true
|
|
71
|
+
begin
|
|
72
|
+
@body.close if @body.respond_to?(:close)
|
|
73
|
+
ensure
|
|
74
|
+
finish_profile
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# The server seems to have left the body: the profile is finished with what was captured.
|
|
79
|
+
# The body is not closed here: a close that comes later still closes it, once.
|
|
80
|
+
def abandon
|
|
81
|
+
finish_profile
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def closed?
|
|
85
|
+
@closed
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
private
|
|
89
|
+
|
|
90
|
+
def finish_profile
|
|
91
|
+
return if @profile_finished
|
|
92
|
+
|
|
93
|
+
@profile_finished = true
|
|
94
|
+
@on_close.call(captured, @size, @complete, @error)
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def capture(chunk)
|
|
98
|
+
chunk = chunk.to_s
|
|
99
|
+
@encoding ||= chunk.encoding
|
|
100
|
+
@size += chunk.bytesize
|
|
101
|
+
# One byte past the limit, so that Redaction.cut_bytes knows the body goes on.
|
|
102
|
+
room = @limit ? @limit + 1 - @captured.bytesize : chunk.bytesize
|
|
103
|
+
@captured << chunk.byteslice(0, room).b if room.positive?
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def captured
|
|
107
|
+
text = @captured.dup.force_encoding(@encoding || Encoding::UTF_8)
|
|
108
|
+
@limit ? Redaction.cut_bytes(text, @limit) : text
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
end
|
|
@@ -1,13 +1,19 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "rack"
|
|
3
4
|
require_relative "../models/profile"
|
|
4
5
|
require_relative "../current_context"
|
|
5
6
|
require_relative "../collectors/lifecycle"
|
|
6
7
|
require_relative "toolbar_injector"
|
|
8
|
+
require_relative "capturing_body"
|
|
9
|
+
require_relative "streamed_profile"
|
|
7
10
|
|
|
8
11
|
module Profiler
|
|
9
12
|
module Middleware
|
|
10
13
|
class ProfilerMiddleware
|
|
14
|
+
# Rack 3 wants header names in lower case; Rack::Headers takes either.
|
|
15
|
+
TOKEN_HEADER = defined?(Rack::Headers) ? "x-profiler-token" : "X-Profiler-Token"
|
|
16
|
+
|
|
11
17
|
def initialize(app)
|
|
12
18
|
@app = app
|
|
13
19
|
end
|
|
@@ -15,7 +21,11 @@ module Profiler
|
|
|
15
21
|
def call(env)
|
|
16
22
|
return @app.call(env) unless should_profile?(env)
|
|
17
23
|
|
|
24
|
+
# A stream this thread left open, and any held for too long, release what they hold.
|
|
25
|
+
StreamedProfile.sweep
|
|
26
|
+
|
|
18
27
|
collectors = nil
|
|
28
|
+
kept = nil
|
|
19
29
|
begin
|
|
20
30
|
begin
|
|
21
31
|
profile = Models::Profile.new(build_request(env))
|
|
@@ -23,7 +33,7 @@ module Profiler
|
|
|
23
33
|
Profiler::CurrentContext.token = profile.token
|
|
24
34
|
|
|
25
35
|
# Capture request body before app processes it
|
|
26
|
-
|
|
36
|
+
request_body = read_request_body(env)
|
|
27
37
|
|
|
28
38
|
# Store profile in env for collectors
|
|
29
39
|
env["profiler.profile"] = profile
|
|
@@ -42,57 +52,68 @@ module Profiler
|
|
|
42
52
|
return @app.call(env)
|
|
43
53
|
end
|
|
44
54
|
|
|
45
|
-
|
|
46
|
-
memory_before = current_memory if Profiler.configuration.track_memory
|
|
55
|
+
allocations_before = AllocationCounter.current if Profiler.configuration.track_memory
|
|
47
56
|
|
|
48
57
|
begin
|
|
49
58
|
response = @app.call(env)
|
|
50
59
|
rescue Exception => e # rubocop:disable Lint/RescueException
|
|
51
|
-
record_failed_request(env, profile, collectors,
|
|
60
|
+
record_failed_request(env, profile, collectors, request_body, allocations_before, e) if failed_request?(e)
|
|
52
61
|
raise
|
|
53
62
|
end
|
|
54
63
|
|
|
55
|
-
complete_profile(env, profile, collectors,
|
|
64
|
+
response, kept = complete_profile(env, profile, collectors, request_body, allocations_before, response)
|
|
65
|
+
response
|
|
56
66
|
ensure
|
|
57
67
|
# Opened before the first subscribe: whatever happens from there on, an exception
|
|
58
68
|
# outside StandardError included, nothing a collector installed outlives the request.
|
|
59
|
-
|
|
69
|
+
# The collectors kept for a streamed body only give their thread-local slots back
|
|
70
|
+
# here; StreamedProfile releases the rest.
|
|
71
|
+
release(collectors, kept)
|
|
60
72
|
end
|
|
61
73
|
end
|
|
62
74
|
|
|
63
75
|
private
|
|
64
76
|
|
|
65
|
-
def complete_profile(env, profile, collectors,
|
|
77
|
+
def complete_profile(env, profile, collectors, request_body, allocations_before, response)
|
|
66
78
|
status, headers, body = response
|
|
67
|
-
headers = headers.dup
|
|
68
79
|
|
|
69
|
-
# Measure memory after
|
|
70
80
|
if Profiler.configuration.track_memory
|
|
71
|
-
|
|
72
|
-
profile.memory = memory_after - memory_before
|
|
81
|
+
profile.allocated_objects = AllocationCounter.current - allocations_before
|
|
73
82
|
end
|
|
74
83
|
|
|
75
|
-
|
|
76
|
-
|
|
84
|
+
unless buffered_body?(headers, body)
|
|
85
|
+
return stream_through(env, profile, collectors, request_body, allocations_before, response)
|
|
86
|
+
end
|
|
77
87
|
|
|
78
|
-
|
|
88
|
+
begin
|
|
89
|
+
content = read_buffered_body(body)
|
|
90
|
+
rescue Exception => e # rubocop:disable Lint/RescueException
|
|
91
|
+
# Raised while the body was read: the server answers it, as it would without the profiler.
|
|
92
|
+
record_failed_request(env, profile, collectors, request_body, allocations_before, e) if failed_request?(e)
|
|
93
|
+
raise
|
|
94
|
+
end
|
|
79
95
|
|
|
80
|
-
profile
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
96
|
+
[complete_buffered(env, profile, collectors, request_body, status, headers, content), nil]
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# The whole body is in memory already: profiled at once, and the toolbar goes into a page.
|
|
100
|
+
def complete_buffered(env, profile, collectors, request_body, status, headers, content)
|
|
101
|
+
body = [content]
|
|
102
|
+
headers = headers.dup
|
|
86
103
|
|
|
104
|
+
record_response(env, profile, request_body, status, headers, content, content.bytesize, true)
|
|
87
105
|
collect_all(profile, collectors)
|
|
88
106
|
|
|
89
107
|
Profiler.storage.save(profile.token, profile)
|
|
90
108
|
|
|
91
|
-
headers
|
|
109
|
+
set_header(headers, TOKEN_HEADER, profile.token)
|
|
92
110
|
|
|
93
111
|
if html_response?(headers)
|
|
94
112
|
nonce = env['action_dispatch.content_security_policy_nonce']
|
|
95
|
-
|
|
113
|
+
injected = ToolbarInjector.new(body, profile.token, nonce).inject
|
|
114
|
+
# A HEAD answer, or a page without </body>, keeps the length it announced.
|
|
115
|
+
update_content_length(headers, injected) unless injected.equal?(body)
|
|
116
|
+
body = injected
|
|
96
117
|
end
|
|
97
118
|
|
|
98
119
|
[status, headers, body]
|
|
@@ -102,6 +123,99 @@ module Profiler
|
|
|
102
123
|
[status, headers, body || []]
|
|
103
124
|
end
|
|
104
125
|
|
|
126
|
+
# A stream leaves at once, chunk by chunk, and its profile is saved when the server closes
|
|
127
|
+
# the body. The collectors that read the request's thread are collected now, on it; the
|
|
128
|
+
# others stay subscribed until then (StreamedProfile), so that what the stream does is
|
|
129
|
+
# recorded. Returns the response and the collectors kept.
|
|
130
|
+
def stream_through(env, profile, collectors, request_body, allocations_before, response)
|
|
131
|
+
status, headers, body = response
|
|
132
|
+
profiled_headers = headers.dup
|
|
133
|
+
headers = headers.dup
|
|
134
|
+
|
|
135
|
+
profile.finish(status, profiled_headers)
|
|
136
|
+
kept, now = collectors.partition { |collector| collect_from_any_thread?(collector) }
|
|
137
|
+
collect_all(profile, now)
|
|
138
|
+
# Their tabs keep their place; what they hold is filled in when they are collected.
|
|
139
|
+
kept.each { |collector| profile.add_collector_metadata(collector) }
|
|
140
|
+
set_header(headers, TOKEN_HEADER, profile.token)
|
|
141
|
+
|
|
142
|
+
# A Rack 3 streaming body (call without each) writes to the socket itself: nothing to relay.
|
|
143
|
+
unless body.respond_to?(:each)
|
|
144
|
+
kept.each do |collector|
|
|
145
|
+
collector.collect if collector.respond_to?(:collect)
|
|
146
|
+
profile.refresh_collector_metadata(collector)
|
|
147
|
+
rescue => e
|
|
148
|
+
warn "Collector #{collector.class} failed: #{e.message}"
|
|
149
|
+
end
|
|
150
|
+
record_response(env, profile, request_body, status, profiled_headers, "", nil, false)
|
|
151
|
+
Profiler.storage.save(profile.token, profile)
|
|
152
|
+
return [[status, headers, body], nil]
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
streamed = StreamedProfile.new(profile, kept) do |captured, size, complete, error|
|
|
156
|
+
finish_streamed(env, streamed, collectors, request_body, allocations_before, status, profiled_headers,
|
|
157
|
+
captured, size, complete, error)
|
|
158
|
+
end
|
|
159
|
+
limit = Profiler.configuration.max_captured_body_bytes
|
|
160
|
+
streamed.body = CapturingBody.new(body, limit: limit, context: streamed) do |captured, size, complete, error|
|
|
161
|
+
streamed.finish(captured, size, complete, error)
|
|
162
|
+
end
|
|
163
|
+
StreamedProfile.register(streamed)
|
|
164
|
+
[[status, headers, streamed.body], kept]
|
|
165
|
+
rescue => e
|
|
166
|
+
warn "Profiler error: #{e.message}\n#{e.backtrace.join("\n")}"
|
|
167
|
+
[[status, headers, body], nil]
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
def finish_streamed(env, streamed, collectors, request_body, allocations_before, status, headers,
|
|
171
|
+
captured, size, complete, error)
|
|
172
|
+
profile = streamed.profile
|
|
173
|
+
if Profiler.configuration.track_memory
|
|
174
|
+
profile.allocated_objects = AllocationCounter.current - allocations_before
|
|
175
|
+
end
|
|
176
|
+
collectors.each { |collector| collector.capture(error) if collector.respond_to?(:capture) } if error
|
|
177
|
+
streamed.release_collectors
|
|
178
|
+
record_response(env, profile, request_body, status, headers, captured, size, complete)
|
|
179
|
+
|
|
180
|
+
# Collected again: the request collector, which reads the profile, and a collector
|
|
181
|
+
# collected when the application returned that has an error to add.
|
|
182
|
+
collectors.each do |collector|
|
|
183
|
+
next unless collector.is_a?(Collectors::RequestCollector) ||
|
|
184
|
+
(error && collector.respond_to?(:capture) && !collect_from_any_thread?(collector))
|
|
185
|
+
|
|
186
|
+
collector.collect
|
|
187
|
+
profile.refresh_collector_metadata(collector)
|
|
188
|
+
rescue => e
|
|
189
|
+
warn "Collector #{collector.class} failed: #{e.message}"
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
Profiler.storage.save(profile.token, profile)
|
|
193
|
+
rescue => e
|
|
194
|
+
warn "Profiler error while finishing a streamed response: #{e.message}"
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
def collect_from_any_thread?(collector)
|
|
198
|
+
collector.respond_to?(:collect_from_any_thread?) && collector.collect_from_any_thread?
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
# +complete+: whether +size+ is the whole body's, or only what was seen before it stopped.
|
|
202
|
+
def record_response(env, profile, request_body, status, headers, content, size, complete)
|
|
203
|
+
limit = Profiler.configuration.max_captured_body_bytes
|
|
204
|
+
content = Redaction.cut_bytes(content, limit) if limit
|
|
205
|
+
|
|
206
|
+
profile.finish(status, headers)
|
|
207
|
+
profile.set_bodies(
|
|
208
|
+
request_body: request_body[:content],
|
|
209
|
+
request_body_size: request_body[:size],
|
|
210
|
+
request_body_size_is_minimum: request_body[:size_is_minimum],
|
|
211
|
+
response_body: content,
|
|
212
|
+
response_body_size: size,
|
|
213
|
+
response_body_size_is_minimum: !complete,
|
|
214
|
+
req_content_type: env["CONTENT_TYPE"].to_s,
|
|
215
|
+
resp_content_type: content_type(headers)
|
|
216
|
+
)
|
|
217
|
+
end
|
|
218
|
+
|
|
105
219
|
# A request cut short by a timeout (Timeout::ExitException, Rack::Timeout) is profiled like
|
|
106
220
|
# one that raised; one stopped by a signal or an exit is not, the process is going away.
|
|
107
221
|
def failed_request?(error)
|
|
@@ -110,12 +224,15 @@ module Profiler
|
|
|
110
224
|
|
|
111
225
|
# The profile of a request that raised is kept: status 500, which the server answers once
|
|
112
226
|
# the exception leaves the stack, and the exception itself. Never masks the exception.
|
|
113
|
-
def record_failed_request(env, profile, collectors,
|
|
227
|
+
def record_failed_request(env, profile, collectors, request_body, allocations_before, error)
|
|
114
228
|
collectors.each { |collector| collector.capture(error) if collector.respond_to?(:capture) }
|
|
115
|
-
|
|
229
|
+
if Profiler.configuration.track_memory
|
|
230
|
+
profile.allocated_objects = AllocationCounter.current - allocations_before
|
|
231
|
+
end
|
|
116
232
|
profile.finish(500)
|
|
117
233
|
profile.set_bodies(
|
|
118
|
-
request_body:
|
|
234
|
+
request_body: request_body[:content],
|
|
235
|
+
request_body_size: request_body[:size],
|
|
119
236
|
response_body: "",
|
|
120
237
|
req_content_type: env["CONTENT_TYPE"].to_s,
|
|
121
238
|
resp_content_type: ""
|
|
@@ -137,8 +254,13 @@ module Profiler
|
|
|
137
254
|
end
|
|
138
255
|
end
|
|
139
256
|
|
|
140
|
-
def release(collectors)
|
|
141
|
-
Collectors::Lifecycle.release_all(collectors)
|
|
257
|
+
def release(collectors, kept = nil)
|
|
258
|
+
Collectors::Lifecycle.release_all(kept ? collectors - kept : collectors)
|
|
259
|
+
kept&.each do |collector|
|
|
260
|
+
collector.release_thread_slots if collector.respond_to?(:release_thread_slots)
|
|
261
|
+
rescue => e
|
|
262
|
+
warn "Profiler: Collector #{collector.class} release failed: #{e.message}"
|
|
263
|
+
end
|
|
142
264
|
Profiler::CurrentContext.clear
|
|
143
265
|
end
|
|
144
266
|
|
|
@@ -168,42 +290,128 @@ module Profiler
|
|
|
168
290
|
end
|
|
169
291
|
|
|
170
292
|
def html_response?(headers)
|
|
171
|
-
content_type
|
|
172
|
-
content_type && content_type.include?("text/html")
|
|
293
|
+
content_type(headers).include?("text/html")
|
|
173
294
|
end
|
|
174
295
|
|
|
175
|
-
def
|
|
176
|
-
|
|
177
|
-
|
|
296
|
+
def content_type(headers)
|
|
297
|
+
header(headers, "content-type").to_s
|
|
298
|
+
end
|
|
299
|
+
|
|
300
|
+
# Response headers come as a Rack::Headers (any case), or as a plain Hash whose names are
|
|
301
|
+
# lower case under Rack 3 and of any case under Rack 2: every read and write here ignores
|
|
302
|
+
# the case.
|
|
303
|
+
def header(headers, name)
|
|
304
|
+
return headers[name] if defined?(Rack::Headers) && headers.is_a?(Rack::Headers)
|
|
305
|
+
|
|
306
|
+
headers.each { |key, value| return value if key.to_s.casecmp?(name) }
|
|
307
|
+
nil
|
|
308
|
+
end
|
|
178
309
|
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
310
|
+
# Keeps the spelling of a name the application already used, for a Rack 2 middleware
|
|
311
|
+
# above that looks it up as written.
|
|
312
|
+
def set_header(headers, name, value)
|
|
313
|
+
existing = headers.keys.select { |key| key.to_s.casecmp?(name) }
|
|
314
|
+
existing.each { |key| headers.delete(key) }
|
|
315
|
+
headers[existing.first || name] = value
|
|
316
|
+
end
|
|
317
|
+
|
|
318
|
+
# Only a length the application announced is corrected: a chunked or unsized answer
|
|
319
|
+
# stays without one.
|
|
320
|
+
def update_content_length(headers, body)
|
|
321
|
+
return if header(headers, "content-length").nil?
|
|
322
|
+
|
|
323
|
+
set_header(headers, "content-length", body.sum(&:bytesize).to_s)
|
|
324
|
+
end
|
|
325
|
+
|
|
326
|
+
# Read whole only when it is already in memory: a body that answers to_ary (Rack 3 lets a
|
|
327
|
+
# middleware call it), or the buffered body of Rails 7.0, which has no to_ary. A file sent
|
|
328
|
+
# by its path and an event stream are never read, even when they could be.
|
|
329
|
+
# A body already framed for the wire (Transfer-Encoding: chunked, as Rails 7.0 streams a
|
|
330
|
+
# template) is never read either: a toolbar put into it would break the framing.
|
|
331
|
+
def buffered_body?(headers, body)
|
|
332
|
+
return false if body.respond_to?(:to_path)
|
|
333
|
+
return false if content_type(headers).include?("text/event-stream")
|
|
334
|
+
return false if header(headers, "transfer-encoding")
|
|
335
|
+
|
|
336
|
+
body.respond_to?(:to_ary) || buffered_rails_body?(body)
|
|
337
|
+
end
|
|
338
|
+
|
|
339
|
+
# Rails' own body, read whole when it is in memory although it has no to_ary:
|
|
340
|
+
# - Rails 7.0, whose RackBody has no to_ary at all: without this, a page that sets its own
|
|
341
|
+
# ETag, which Rack::ETag then leaves alone, would lose the toolbar;
|
|
342
|
+
# - a controller that includes ActionController::Live answers every action through a
|
|
343
|
+
# Live::Buffer, a queue fed by the action's thread with no to_ary: when the action
|
|
344
|
+
# rendered its page whole, the buffer is already closed when the application returns,
|
|
345
|
+
# and reading it does not wait. One still open is a stream.
|
|
346
|
+
# A sent file (FileBody) is a stream. This reads Rails' internals: RackBody keeps its
|
|
347
|
+
# response in @response, and Response::Buffer answers closed?, in Rails 7.0, 7.1, 7.2, 8.0
|
|
348
|
+
# and 8.1 (checked in their sources); spec/middleware/rails_body_internals_spec.rb fails
|
|
349
|
+
# if that changes.
|
|
350
|
+
def buffered_rails_body?(body)
|
|
351
|
+
return false unless defined?(ActionDispatch::Response::RackBody)
|
|
352
|
+
|
|
353
|
+
body = body.instance_variable_get(:@body) while body.is_a?(Rack::BodyProxy)
|
|
354
|
+
return false unless body.is_a?(ActionDispatch::Response::RackBody)
|
|
355
|
+
|
|
356
|
+
stream = body.instance_variable_get(:@response)&.stream
|
|
357
|
+
return true if stream.instance_of?(ActionDispatch::Response::Buffer)
|
|
358
|
+
|
|
359
|
+
defined?(ActionController::Live::Buffer) && stream.instance_of?(ActionController::Live::Buffer) && stream.closed?
|
|
360
|
+
end
|
|
361
|
+
|
|
362
|
+
# No rescue: an error raised by the body is the application's, for the server to answer.
|
|
363
|
+
# Rack 3 has to_ary close the body, Rails' own body and Rack 2 do not: closed here unless
|
|
364
|
+
# it says it is. An error from close does not hide one raised while reading.
|
|
365
|
+
def read_buffered_body(body)
|
|
366
|
+
content =
|
|
367
|
+
begin
|
|
368
|
+
if body.respond_to?(:to_ary)
|
|
369
|
+
body.to_ary.join
|
|
370
|
+
else
|
|
371
|
+
parts = +""
|
|
372
|
+
body.each { |part| parts << part }
|
|
373
|
+
parts
|
|
374
|
+
end
|
|
375
|
+
rescue Exception # rubocop:disable Lint/RescueException
|
|
376
|
+
begin
|
|
377
|
+
close_body(body)
|
|
378
|
+
rescue => e
|
|
379
|
+
warn "Profiler: closing a body that failed also failed: #{e.message}"
|
|
380
|
+
end
|
|
381
|
+
raise
|
|
382
|
+
end
|
|
383
|
+
close_body(body)
|
|
182
384
|
content
|
|
183
|
-
rescue
|
|
184
|
-
""
|
|
185
385
|
end
|
|
186
386
|
|
|
187
|
-
def
|
|
188
|
-
|
|
189
|
-
body.each { |part| parts << part }
|
|
190
|
-
body.close if body.respond_to?(:close)
|
|
191
|
-
parts.join
|
|
192
|
-
rescue
|
|
193
|
-
""
|
|
387
|
+
def close_body(body)
|
|
388
|
+
body.close if body.respond_to?(:close) && !(body.respond_to?(:closed?) && body.closed?)
|
|
194
389
|
end
|
|
195
390
|
|
|
196
|
-
|
|
197
|
-
|
|
391
|
+
# At most max_captured_body_bytes of rack.input, given back rewound to the application.
|
|
392
|
+
# An input that cannot be rewound is not read at all: the application would lose it.
|
|
393
|
+
def read_request_body(env)
|
|
394
|
+
input = env["rack.input"]
|
|
395
|
+
return { content: "", size: nil } unless input.respond_to?(:rewind)
|
|
198
396
|
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
0
|
|
397
|
+
limit = Profiler.configuration.max_captured_body_bytes
|
|
398
|
+
begin
|
|
399
|
+
input.rewind
|
|
400
|
+
content = (limit ? input.read(limit + 1) : input.read).to_s
|
|
401
|
+
ensure
|
|
402
|
+
input.rewind
|
|
206
403
|
end
|
|
404
|
+
|
|
405
|
+
declared = env["CONTENT_LENGTH"].to_s
|
|
406
|
+
known = declared.match?(/\A\d+\z/)
|
|
407
|
+
# Without a Content-Length, the size of a cut body is only known to exceed the limit.
|
|
408
|
+
size = known ? declared.to_i : content.bytesize
|
|
409
|
+
size_is_minimum = !known && limit && content.bytesize > limit
|
|
410
|
+
content = Redaction.cut_bytes(content, limit) if limit
|
|
411
|
+
{ content: content, size: size, size_is_minimum: size_is_minimum || false }
|
|
412
|
+
rescue => e
|
|
413
|
+
warn "Profiler: could not read the request body: #{e.message}"
|
|
414
|
+
{ content: "", size: nil }
|
|
207
415
|
end
|
|
208
416
|
end
|
|
209
417
|
end
|