rails-profiler 0.31.1 → 0.31.3

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.
Files changed (42) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +122 -0
  3. data/app/assets/builds/profiler-toolbar.js +13 -6
  4. data/app/assets/builds/profiler.js +73 -37
  5. data/app/controllers/profiler/api/profiles_controller.rb +2 -0
  6. data/app/controllers/profiler/api/toolbar_controller.rb +2 -0
  7. data/app/controllers/profiler/profiles_controller.rb +2 -0
  8. data/lib/profiler/allocation_counter.rb +21 -0
  9. data/lib/profiler/collectors/base_collector.rb +39 -3
  10. data/lib/profiler/collectors/cache_collector.rb +8 -3
  11. data/lib/profiler/collectors/database_collector.rb +133 -10
  12. data/lib/profiler/collectors/env_collector.rb +12 -4
  13. data/lib/profiler/collectors/exception_collector.rb +12 -9
  14. data/lib/profiler/collectors/flamegraph_collector.rb +10 -5
  15. data/lib/profiler/collectors/function_profiler_collector.rb +28 -4
  16. data/lib/profiler/collectors/http_collector.rb +5 -0
  17. data/lib/profiler/collectors/log_collector.rb +5 -0
  18. data/lib/profiler/collectors/mailer_collector.rb +2 -8
  19. data/lib/profiler/collectors/request_collector.rb +14 -11
  20. data/lib/profiler/collectors/routes_collector.rb +7 -50
  21. data/lib/profiler/collectors/scoped_notifications.rb +229 -0
  22. data/lib/profiler/collectors/view_collector.rb +7 -2
  23. data/lib/profiler/configuration.rb +27 -4
  24. data/lib/profiler/console_profiler.rb +2 -17
  25. data/lib/profiler/instrumentation/executor_context_propagation.rb +47 -0
  26. data/lib/profiler/instrumentation/request_context.rb +77 -0
  27. data/lib/profiler/instrumentation/thread_context_propagation.rb +11 -16
  28. data/lib/profiler/job_profiler.rb +6 -17
  29. data/lib/profiler/mcp/resources/recent_requests.rb +2 -0
  30. data/lib/profiler/mcp/tools/get_profile_detail.rb +21 -2
  31. data/lib/profiler/mcp/tools/get_test_profile_detail.rb +1 -1
  32. data/lib/profiler/middleware/capturing_body.rb +112 -0
  33. data/lib/profiler/middleware/profiler_middleware.rb +261 -53
  34. data/lib/profiler/middleware/streamed_profile.rb +135 -0
  35. data/lib/profiler/middleware/toolbar_injector.rb +3 -10
  36. data/lib/profiler/models/profile.rb +69 -5
  37. data/lib/profiler/process_snapshot.rb +157 -0
  38. data/lib/profiler/redaction.rb +10 -0
  39. data/lib/profiler/test_profiler.rb +2 -17
  40. data/lib/profiler/version.rb +1 -1
  41. data/lib/profiler.rb +6 -0
  42. metadata +12 -5
@@ -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 << "| Memory delta | #{profile.memory ? "#{(profile.memory.to_f / 1024 / 1024).round(2)} MB" : "-"} |"
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
- req_body_raw = read_rack_input(env)
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
- # Measure memory before
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, req_body_raw, memory_before, e) if failed_request?(e)
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, req_body_raw, memory_before, response)
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
- release(collectors)
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, req_body_raw, memory_before, response)
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
- memory_after = current_memory
72
- profile.memory = memory_after - memory_before
81
+ profile.allocated_objects = AllocationCounter.current - allocations_before
73
82
  end
74
83
 
75
- body_content = collect_body(body)
76
- body = [body_content]
84
+ unless buffered_body?(headers, body)
85
+ return stream_through(env, profile, collectors, request_body, allocations_before, response)
86
+ end
77
87
 
78
- profile.finish(status, headers)
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.set_bodies(
81
- request_body: req_body_raw,
82
- response_body: body_content,
83
- req_content_type: env["CONTENT_TYPE"].to_s,
84
- resp_content_type: (headers["content-type"] || headers["Content-Type"]).to_s
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["X-Profiler-Token"] = profile.token
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
- body = ToolbarInjector.new(body, profile.token, nonce).inject
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, req_body_raw, memory_before, error)
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
- profile.memory = current_memory - memory_before if Profiler.configuration.track_memory
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: req_body_raw,
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 = headers["Content-Type"]
172
- content_type && content_type.include?("text/html")
293
+ content_type(headers).include?("text/html")
173
294
  end
174
295
 
175
- def read_rack_input(env)
176
- input = env["rack.input"]
177
- return "" unless input
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
- input.rewind
180
- content = input.read
181
- input.rewind
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 collect_body(body)
188
- parts = []
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
- def current_memory
197
- return 0 unless defined?(GC.stat)
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
- stats = GC.stat
200
- if stats.key?(:total_allocated_size)
201
- stats[:total_allocated_size]
202
- elsif stats.key?(:total_allocated_objects)
203
- stats[:total_allocated_objects] * 40
204
- else
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