rails-profiler 0.31.0 → 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 +139 -0
- data/app/assets/builds/profiler-toolbar.js +13 -6
- data/app/assets/builds/profiler.js +53 -33
- data/app/controllers/profiler/api/ajax_controller.rb +6 -0
- data/app/controllers/profiler/api/env_vars_controller.rb +10 -0
- data/lib/profiler/allocation_counter.rb +21 -0
- data/lib/profiler/cluster/slave_proxy.rb +6 -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 +6 -1
- 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 +33 -7
- data/lib/profiler/console_profiler.rb +3 -18
- data/lib/profiler/env_override_store.rb +54 -43
- data/lib/profiler/job_profiler.rb +3 -16
- data/lib/profiler/mcp/body_formatter.rb +4 -4
- data/lib/profiler/mcp/file_cache.rb +19 -7
- data/lib/profiler/mcp/resources/recent_requests.rb +2 -0
- data/lib/profiler/mcp/tools/delete_env_var.rb +3 -1
- 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/mcp/tools/list_env_vars.rb +3 -0
- data/lib/profiler/mcp/tools/reset_all_env_vars.rb +3 -0
- data/lib/profiler/mcp/tools/reset_env_var.rb +3 -0
- data/lib/profiler/mcp/tools/set_env_var.rb +2 -0
- 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/storage/base_store.rb +5 -1
- data/lib/profiler/storage/blob_store.rb +28 -4
- data/lib/profiler/storage/file_store.rb +21 -15
- data/lib/profiler/storage/memory_store.rb +6 -0
- data/lib/profiler/storage/private_files.rb +135 -0
- data/lib/profiler/storage/redis_store.rb +6 -0
- data/lib/profiler/storage/sqlite_store.rb +26 -8
- data/lib/profiler/storage/token.rb +24 -0
- data/lib/profiler/test_profiler.rb +3 -18
- data/lib/profiler/version.rb +1 -1
- data/lib/profiler.rb +12 -0
- metadata +10 -5
|
@@ -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
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../collectors/lifecycle"
|
|
4
|
+
require_relative "../current_context"
|
|
5
|
+
|
|
6
|
+
module Profiler
|
|
7
|
+
module Middleware
|
|
8
|
+
# The profile of a streamed response between the moment the application returns and the
|
|
9
|
+
# moment the server closes the body. The collectors that read nothing from the request's
|
|
10
|
+
# thread (Collectors::BaseCollector#collect_from_any_thread?) stay subscribed meanwhile, so
|
|
11
|
+
# that the queries, views and logs of the stream are recorded.
|
|
12
|
+
#
|
|
13
|
+
# While the server iterates the body (enter, leave), its fiber carries the profile's token
|
|
14
|
+
# and the thread-local slots of the collectors kept, so that the logs, outbound HTTP calls
|
|
15
|
+
# and measures of the stream are recorded, and a filter by request can tell its events.
|
|
16
|
+
#
|
|
17
|
+
# Rack requires the server to close the body, but nothing here relies on it alone: a
|
|
18
|
+
# stream still open when the execution context that started it (its fiber) starts another
|
|
19
|
+
# profiled request was left behind, and its profile is finished then; one subscribed for
|
|
20
|
+
# longer than MAX_SUBSCRIBED_SECONDS has its collectors released by the next profiled
|
|
21
|
+
# request, from any thread, which the profile says. The body itself is closed only by the
|
|
22
|
+
# server. The context is the fiber, not the thread: a fiber-based server (Falcon) runs
|
|
23
|
+
# many requests at once on one thread, each in its own fiber, and Thread#[] slots are
|
|
24
|
+
# fiber-local too; a threaded server runs each request in its thread's root fiber.
|
|
25
|
+
class StreamedProfile
|
|
26
|
+
MAX_SUBSCRIBED_SECONDS = 300
|
|
27
|
+
|
|
28
|
+
@pending = {}
|
|
29
|
+
@lock = Mutex.new
|
|
30
|
+
|
|
31
|
+
class << self
|
|
32
|
+
def register(streamed)
|
|
33
|
+
@lock.synchronize { @pending[streamed.object_id] = streamed }
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def unregister(streamed)
|
|
37
|
+
@lock.synchronize { @pending.delete(streamed.object_id) }
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Run when a profiled request starts.
|
|
41
|
+
def sweep(context = Fiber.current)
|
|
42
|
+
now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
43
|
+
left, old = @lock.synchronize do
|
|
44
|
+
[@pending.values.select { |s| s.context.equal?(context) },
|
|
45
|
+
@pending.values.select { |s| !s.context.equal?(context) && now - s.started_at > MAX_SUBSCRIBED_SECONDS }]
|
|
46
|
+
end
|
|
47
|
+
left.each(&:abandon)
|
|
48
|
+
old.each do |streamed|
|
|
49
|
+
unregister(streamed)
|
|
50
|
+
streamed.release_collectors(after_seconds: MAX_SUBSCRIBED_SECONDS)
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Releases every stream still subscribed (specs, and a process shutting down).
|
|
55
|
+
def release_all_pending
|
|
56
|
+
@lock.synchronize { @pending.values.tap { @pending.clear } }.each(&:release_collectors)
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
attr_reader :profile, :context, :started_at
|
|
61
|
+
attr_accessor :body
|
|
62
|
+
|
|
63
|
+
# on_finish runs once, with what the body captured, when it is closed or abandoned.
|
|
64
|
+
def initialize(profile, collectors, &on_finish)
|
|
65
|
+
@profile = profile
|
|
66
|
+
@collectors = collectors
|
|
67
|
+
@on_finish = on_finish
|
|
68
|
+
@context = Fiber.current
|
|
69
|
+
@started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
70
|
+
@mutex = Mutex.new
|
|
71
|
+
@released = false
|
|
72
|
+
@finished = false
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# Collects the collectors kept for the stream and releases them, once, from any thread.
|
|
76
|
+
# +after_seconds+: released before the body was closed, after that long; the profile says
|
|
77
|
+
# so, as what came after is missing from it.
|
|
78
|
+
def release_collectors(after_seconds: nil)
|
|
79
|
+
@mutex.synchronize do
|
|
80
|
+
return if @released
|
|
81
|
+
|
|
82
|
+
@released = true
|
|
83
|
+
@profile.collectors_released_after_seconds = after_seconds if after_seconds
|
|
84
|
+
@collectors.each do |collector|
|
|
85
|
+
collector.collect if collector.respond_to?(:collect)
|
|
86
|
+
@profile.refresh_collector_metadata(collector)
|
|
87
|
+
rescue => e
|
|
88
|
+
warn "Collector #{collector.class} failed: #{e.message}"
|
|
89
|
+
end
|
|
90
|
+
Collectors::Lifecycle.release_all(@collectors)
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def finish(captured, size, complete, error)
|
|
95
|
+
@mutex.synchronize do
|
|
96
|
+
return if @finished
|
|
97
|
+
|
|
98
|
+
@finished = true
|
|
99
|
+
end
|
|
100
|
+
self.class.unregister(self)
|
|
101
|
+
@on_finish.call(captured, size, complete, error)
|
|
102
|
+
ensure
|
|
103
|
+
# Whatever on_finish did, nothing stays subscribed.
|
|
104
|
+
release_collectors
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# The server moved on without closing the body: its profile is finished with what it
|
|
108
|
+
# captured so far. The body stays the server's to close.
|
|
109
|
+
def abandon
|
|
110
|
+
body ? body.abandon : finish("", 0, false, nil)
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# Called by the body around its iteration, on the fiber that iterates it. Lends nothing
|
|
114
|
+
# once the collectors are released.
|
|
115
|
+
def enter
|
|
116
|
+
token = CurrentContext.token
|
|
117
|
+
CurrentContext.token = @profile.token
|
|
118
|
+
lent = @mutex.synchronize do
|
|
119
|
+
next [] if @released
|
|
120
|
+
|
|
121
|
+
@collectors.filter_map do |collector|
|
|
122
|
+
[collector, collector.lend_thread_slots] if collector.respond_to?(:lend_thread_slots)
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
[token, lent]
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def leave(state)
|
|
129
|
+
token, lent = state
|
|
130
|
+
lent.reverse_each { |collector, previous| collector.return_thread_slots(previous) }
|
|
131
|
+
CurrentContext.token = token
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
end
|
|
@@ -129,17 +129,10 @@ module Profiler
|
|
|
129
129
|
end
|
|
130
130
|
end
|
|
131
131
|
|
|
132
|
+
# The middleware hands over the page it has already read: an Array of parts, or a String.
|
|
133
|
+
# Never a body to iterate, which would drain a stream and close it.
|
|
132
134
|
def extract_content(body)
|
|
133
|
-
|
|
134
|
-
body.body
|
|
135
|
-
elsif body.respond_to?(:each)
|
|
136
|
-
parts = []
|
|
137
|
-
body.each { |part| parts << part }
|
|
138
|
-
body.close if body.respond_to?(:close)
|
|
139
|
-
parts.join
|
|
140
|
-
else
|
|
141
|
-
body.to_s
|
|
142
|
-
end
|
|
135
|
+
body.is_a?(String) ? body : Array(body).join
|
|
143
136
|
end
|
|
144
137
|
|
|
145
138
|
def toolbar_html
|