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.
@@ -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 << "**Memory:** #{(profile.memory / 1024.0 / 1024.0).round(2)} MB" if profile.memory
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 << "| 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