rails-profiler 0.31.5 → 0.32.0

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 87fc21dec17ab7627d4430f49b042e53061efb33034d7309ea07aac6eb83ac5d
4
- data.tar.gz: 3e64027bb71fa5ce3d2a23a23cf1525f0e5a55c508c019f1398c35c705f20434
3
+ metadata.gz: 623e8531db783ddb27296b83dba2beae12f411e6b2c71630c3b14003b7e67f40
4
+ data.tar.gz: eebbf3cda8334b412ca1a4543cb81f7ef05e65c0f28672794fc9f0b7b57a250f
5
5
  SHA512:
6
- metadata.gz: 596fda2dad5ffa7e2a7c98a2b749c03901df7748d26776203a30d88a86b842e5956d92453b3847a3cf856a7afee5c4511f02ce83070a6dd26d0ef4d1e6784b5b
7
- data.tar.gz: b70a833918e15474ac410c182763f13e03e0352e39cd7a0ed066c68a47f2f86762e7ef01fcd5246bfe47d391d86c03556929f66dbb84f0098f0bc43c43e9b415
6
+ metadata.gz: 27241cbff0c9d09418f1d75a6757a55dba4b6474b822860f11e98b5149d2dda4b7fe7ad13a03e136bf9c1ca56dbd9fbdf4366dc394fa7127d3d9bba69cf23944
7
+ data.tar.gz: 25ad634237aecccf4a698f3ba7f221afbc53965a5b3a18845336c2ce8fef257f1d7fc8234b40545153fe0a9ba7b28fae92f52708994a50d4e81063d0a789116b
data/CHANGELOG.md CHANGED
@@ -19,6 +19,55 @@ every commit of every tag interval is accounted for one way or the other.
19
19
 
20
20
  ## [Unreleased]
21
21
 
22
+ ## [0.32.0] - 2026-10-06
23
+
24
+ <!-- stamped -->
25
+
26
+ ### Added
27
+
28
+ - **MCP:** The HTTP endpoint at `/_profiler/mcp` accepts the `Host` headers of a reverse proxy
29
+ without a monkey-patch: besides the loopback names, it now accepts the hosts the application lists
30
+ in `config.hosts` (when that list is not empty), so an application behind Traefik usually has
31
+ nothing to set, and the entries of the new `config.mcp_allowed_hosts` (default `[]`): a host name
32
+ or `host:port`, or a `Regexp` anchored with `\A` and `\z`, matched against the whole host name,
33
+ for example `["api.myapp.test", %r{\Amyapp[a-z0-9-]*\z}]`. With mcp 1.x the
34
+ transport only accepted the loopback names and answered `Forbidden: Invalid Host header` behind
35
+ a proxy. The profiler now makes the check itself, before the transport, for every kind of entry,
36
+ and keeps the transport's `Origin` check (same origin or `cors_allowed_origins`), whatever
37
+ `api_forgery_protection` says; the transport's own check, which only knows exact names, is turned
38
+ off. A `Regexp` that is not anchored, or that cannot match a whole host name, and a String
39
+ holding `*` raise an `ArgumentError` when assigned. The defaults keep the previous behaviour.
40
+
41
+ ## [0.31.6] - 2026-10-05
42
+
43
+ <!-- stamped -->
44
+
45
+ ### Fixed
46
+
47
+ - **Storage:** With `config.compress_bodies` (on by default), a text body larger than
48
+ `config.compress_body_threshold` (10 KB) is now stored gzip+base64, as the README said: the
49
+ profile was decompressed again before every save, so the file, Redis and memory stores kept the
50
+ body as text, next to the compressed copy the request collector keeps, and the compression only
51
+ cost time. A 132 KB HTML page now takes 86 KB in those stores instead of 171 KB (273 KB without
52
+ compression); SQLite, which only keeps that copy, 44 KB instead of 36 KB at the faster level.
53
+ Compressing it costs 1.6 ms on the request instead of 4.1 ms (the fastest zlib level), saving
54
+ the profile 0.2 ms instead of 1.2 ms; the bodies are decompressed only when a profile is shown,
55
+ about 0.7 ms. The profile page, its API, the job, console and test APIs, the lists the cluster
56
+ proxy reads and the MCP tools still give the bodies as text; the toolbar, which shows no body,
57
+ gets them as stored (86 KB instead of 171 KB for that page).
58
+ - **Storage:** The profile list summaries kept by the file, SQLite and Redis stores no longer
59
+ carry the first 500 characters of the request and response bodies. Summaries written by 0.31.4
60
+ and 0.31.5 keep them until their profiles are evicted; no list shows them.
61
+ - **MCP:** The `curl` command of `get_profile_detail` carries the request body as it was sent: a
62
+ body larger than `compress_body_threshold` came out as its compressed base64 form, and a binary
63
+ body as base64 text. A binary body is now left out of the command.
64
+ - **Upgrading:** the profiles already stored, all as text, are read as before, with no migration.
65
+ An earlier version reading a store written by this one shows the compressed bodies as text too,
66
+ and a cluster master and its slaves may run either version: the profile API they read still
67
+ gives the bodies as text. A body that cannot be decompressed is shown as it is stored, never as
68
+ an error. To keep the bodies readable as text in the stored files or Redis keys, set
69
+ `config.compress_bodies = false`.
70
+
22
71
  ## [0.31.5] - 2026-10-05
23
72
 
24
73
  <!-- stamped -->
@@ -14,7 +14,7 @@ module Profiler
14
14
  return render json: { error: "Console profile not found" }, status: :not_found
15
15
  end
16
16
 
17
- render json: profile.to_h
17
+ render json: profile.to_h(decode_bodies: true)
18
18
  end
19
19
 
20
20
  def destroy
@@ -14,7 +14,7 @@ module Profiler
14
14
  return render json: { error: "Job profile not found" }, status: :not_found
15
15
  end
16
16
 
17
- render json: profile.to_h.merge(
17
+ render json: profile.to_h(decode_bodies: true).merge(
18
18
  child_jobs: build_child_jobs(profile),
19
19
  parent_profile: build_parent_summary(profile)
20
20
  )
@@ -28,7 +28,7 @@ module Profiler
28
28
  # The route table and ENV are the process's, not stored in the profile
29
29
  Profiler::ProcessSnapshot.hydrate(profile)
30
30
 
31
- render json: profile.to_h.merge(
31
+ render json: profile.to_h(decode_bodies: true).merge(
32
32
  child_jobs: build_child_jobs(profile),
33
33
  parent_profile: build_parent_summary(profile)
34
34
  )
@@ -14,7 +14,7 @@ module Profiler
14
14
  return render json: { error: "Test profile not found" }, status: :not_found
15
15
  end
16
16
 
17
- render json: profile.to_h
17
+ render json: profile.to_h(decode_bodies: true)
18
18
  end
19
19
 
20
20
  def destroy
@@ -19,6 +19,7 @@ module Profiler
19
19
  # The route table and ENV are the process's, not stored in the profile
20
20
  Profiler::ProcessSnapshot.hydrate(profile)
21
21
 
22
+ # The bodies as stored (gzip+base64 past compress_body_threshold): the toolbar shows none.
22
23
  render json: {
23
24
  profile: profile.to_h.merge(child_jobs: build_child_jobs(profile)),
24
25
  events_cursor: events_cursor
@@ -74,9 +74,10 @@ module Profiler
74
74
  render_page(Profiler.storage.list(limit: limit + 1, offset: offset, type: type, summary: summary), limit, offset)
75
75
  end
76
76
 
77
+ # The bodies as text: the cluster proxy lists whole profiles (all_types), a summary has none.
77
78
  def render_page(profiles, limit, offset)
78
79
  render json: {
79
- profiles: profiles.first(limit).map(&:to_h),
80
+ profiles: profiles.first(limit).map { |profile| profile.to_h(decode_bodies: true) },
80
81
  limit: limit,
81
82
  offset: offset,
82
83
  has_more: profiles.size > limit
@@ -25,7 +25,7 @@ module Profiler
25
25
  # The route table and ENV are the process's, not stored in the profile
26
26
  Profiler::ProcessSnapshot.hydrate(@profile)
27
27
 
28
- @profile_data = @profile.to_h.merge(
28
+ @profile_data = @profile.to_h(decode_bodies: true).merge(
29
29
  child_jobs: build_child_jobs(@profile),
30
30
  parent_profile: build_parent_summary(@profile)
31
31
  )
@@ -132,6 +132,7 @@ module Profiler
132
132
  @cluster_secret = nil
133
133
  @cluster_require_secret = true
134
134
  @cluster_allowed_slave_urls = []
135
+ @mcp_allowed_hosts = []
135
136
  @cluster_allow_insecure_http = false
136
137
  # Where the profiler's own errors go (Profiler.log): Rails.logger when nil, or $stderr when
137
138
  # there is none.
@@ -147,7 +148,16 @@ module Profiler
147
148
  @tmp_path = value.nil? ? nil : Pathname.new(value)
148
149
  end
149
150
 
150
- attr_reader :cluster_allowed_slave_urls
151
+ attr_reader :cluster_allowed_slave_urls, :mcp_allowed_hosts
152
+
153
+ # Host names the MCP HTTP endpoint accepts beyond the loopback ones and config.hosts: a String
154
+ # (a host name, any port, or a host:port) or a Regexp anchored with \A and \z, matched
155
+ # against the host name without its port. A wildcard String is refused.
156
+ def mcp_allowed_hosts=(value)
157
+ require_relative "cluster/security"
158
+ Array(value).each { |entry| validate_mcp_allowed_host(entry) }
159
+ @mcp_allowed_hosts = Array(value)
160
+ end
151
161
 
152
162
  # Deprecated: the figure it was meant for was never a byte count (see AllocationCounter).
153
163
  # Read and written as allocated_objects_warning_threshold times the bytes per object the
@@ -300,5 +310,28 @@ module Profiler
300
310
  raise Error, "Unknown storage backend: #{@storage}"
301
311
  end
302
312
  end
313
+
314
+ def validate_mcp_allowed_host(entry)
315
+ case entry
316
+ when String
317
+ return unless entry.strip.empty? || entry.include?("*")
318
+
319
+ raise ArgumentError, "config.mcp_allowed_hosts: #{entry.inspect} is not a host name; list each host, " \
320
+ "or use an anchored Regexp"
321
+ when Regexp then validate_mcp_allowed_host_pattern(entry)
322
+ else raise ArgumentError, "config.mcp_allowed_hosts: #{entry.inspect} must be a String or a Regexp"
323
+ end
324
+ end
325
+
326
+ def validate_mcp_allowed_host_pattern(pattern)
327
+ unless Profiler::Cluster::Security.anchored_pattern?(pattern)
328
+ raise ArgumentError, "config.mcp_allowed_hosts: #{pattern.inspect} must start with \\A and end with \\z, " \
329
+ "so that it matches the whole host name"
330
+ end
331
+ Profiler::Cluster::Security.whole_url_pattern(pattern)
332
+ rescue RegexpError => e
333
+ raise ArgumentError, "config.mcp_allowed_hosts: #{pattern.inspect} cannot be matched against the whole " \
334
+ "host name (#{e.message})"
335
+ end
303
336
  end
304
337
  end
@@ -85,6 +85,18 @@ module Profiler
85
85
  false
86
86
  end
87
87
 
88
+ # When the application restricts config.hosts, ActionDispatch::HostAuthorization has
89
+ # already refused every other host; a host it lists is one the developer trusts.
90
+ def permitted_by_rails_hosts?(host)
91
+ return false unless defined?(Rails) && Rails.respond_to?(:application) && Rails.application
92
+ return false unless defined?(ActionDispatch::HostAuthorization::Permissions)
93
+
94
+ hosts = Rails.application.config.hosts
95
+ return false if hosts.nil? || hosts.empty?
96
+
97
+ ActionDispatch::HostAuthorization::Permissions.new(hosts).allows?(host)
98
+ end
99
+
88
100
  private
89
101
 
90
102
  def host_denial_reason(request)
@@ -143,17 +155,6 @@ module Profiler
143
155
  permitted_by_rails_hosts?(host)
144
156
  end
145
157
 
146
- # When the application restricts config.hosts, ActionDispatch::HostAuthorization has
147
- # already refused every other host; a host it lists is one the developer trusts.
148
- def permitted_by_rails_hosts?(host)
149
- return false unless defined?(Rails) && Rails.respond_to?(:application) && Rails.application
150
- return false unless defined?(ActionDispatch::HostAuthorization::Permissions)
151
-
152
- hosts = Rails.application.config.hosts
153
- return false if hosts.nil? || hosts.empty?
154
-
155
- ActionDispatch::HostAuthorization::Permissions.new(hosts).allows?(host)
156
- end
157
158
  end
158
159
  end
159
160
  end
@@ -1,7 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "base64"
4
- require "zlib"
3
+ require_relative "../models/profile"
5
4
 
6
5
  module Profiler
7
6
  module MCP
@@ -19,13 +18,7 @@ module Profiler
19
18
  return " *(binary, base64 encoded)*" if encoding == "base64"
20
19
  return nil if body.nil? || body.empty?
21
20
 
22
- if encoding == "gzip+base64"
23
- begin
24
- body = Zlib::Inflate.inflate(Base64.strict_decode64(body))
25
- rescue Zlib::Error, ArgumentError
26
- # fall through with original body
27
- end
28
- end
21
+ body, = Models::Profile.decode_body(body, encoding)
29
22
 
30
23
  if params["save_bodies"]
31
24
  path = FileCache.save(token, name, body)
@@ -2,6 +2,8 @@
2
2
 
3
3
  require "json"
4
4
  require "uri"
5
+ require_relative "../cluster/security"
6
+ require_relative "../local_request"
5
7
 
6
8
  module Profiler
7
9
  module MCP
@@ -25,20 +27,86 @@ module Profiler
25
27
  return deny("Profiler is disabled") unless config.enabled
26
28
 
27
29
  request = ActionDispatch::Request.new(env)
30
+ if (host_denial = host_denial(request, config))
31
+ return deny(host_denial)
32
+ end
28
33
  return deny("Not authorized to access the profiler") unless config.authorized?(request)
34
+ if (origin_denial = origin_denial(request, config))
35
+ return deny(origin_denial)
36
+ end
29
37
  return nil unless config.api_forgery_protection
30
38
 
31
39
  forgery_denial(request, config)
32
40
  end
33
41
 
42
+ # The DNS rebinding check of the MCP transport, done here so that one rule covers every
43
+ # host: the transport itself only knows exact names, and is created without its own check
44
+ # (see Profiler::MCP::Server#http_transport). A Host header is accepted when it names a
45
+ # loopback address, a host the application lists in config.hosts (when that list is not
46
+ # empty, Rails' HostAuthorization already refuses every other host), or an entry of
47
+ # config.mcp_allowed_hosts. A request without a Host header (HTTP/1.0) is let through, as
48
+ # the transport did: the rebinding vector always carries one.
49
+ def allowed_host?(raw_host, config = Profiler.configuration)
50
+ return true if raw_host.nil?
51
+
52
+ full = raw_host.to_s.strip.downcase
53
+ name = host_name(full)
54
+ return false if name.empty?
55
+ return true if LOOPBACK_HOSTS.include?(name)
56
+ return true if Array(config.mcp_allowed_hosts).any? { |entry| host_entry_allows?(entry, name, full) }
57
+
58
+ # Rails matches config.hosts against the whole Host header (an entry may carry a port);
59
+ # the bare name covers entries written without one.
60
+ Profiler::LocalRequest.permitted_by_rails_hosts?(full) ||
61
+ Profiler::LocalRequest.permitted_by_rails_hosts?(name)
62
+ end
63
+
34
64
  private
35
65
 
36
- def forgery_denial(request, config)
37
- origin = request.get_header("HTTP_ORIGIN")
38
- if origin && !origin.empty? && !allowed_origin?(origin, request, config)
39
- return deny("Cross-origin request refused: Origin #{origin} is not allowed")
66
+ LOOPBACK_HOSTS = %w[localhost 127.0.0.1 ::1].freeze
67
+
68
+ def host_denial(request, config)
69
+ raw = request.get_header("HTTP_HOST")
70
+ return nil if allowed_host?(raw, config)
71
+
72
+ "Host #{raw} is not allowed for the MCP endpoint: list it in config.hosts or " \
73
+ "config.mcp_allowed_hosts"
74
+ end
75
+
76
+ # A String matches the host name (any port) or the whole host:port, case-insensitively; a
77
+ # Regexp must match the whole host name, without the port.
78
+ def host_entry_allows?(entry, name, full)
79
+ case entry
80
+ when Regexp
81
+ Profiler::Cluster::Security.anchored_pattern?(entry) &&
82
+ Profiler::Cluster::Security.whole_url_pattern(entry).match?(name)
83
+ when String
84
+ allowed = entry.strip.downcase
85
+ allowed == name || allowed == full
86
+ else
87
+ false
40
88
  end
89
+ rescue RegexpError
90
+ false
91
+ end
92
+
93
+ # "[::1]:3000" gives "::1", "app.local:3000" gives "app.local". Anything else (two hosts, a
94
+ # port that is not a number, text after the brackets) gives "", which is refused.
95
+ def host_name(host)
96
+ match = host.match(/\A\[([^\]]+)\](?::\d+)?\z/) || host.match(/\A([^:\s,]+)(?::\d+)?\z/)
97
+ match ? match[1] : ""
98
+ end
41
99
 
100
+ # The Origin check of the transport, kept whatever api_forgery_protection says: a
101
+ # browser's Origin must be the profiler's own or one of cors_allowed_origins.
102
+ def origin_denial(request, config)
103
+ origin = request.get_header("HTTP_ORIGIN")
104
+ return nil if origin.nil? || origin.empty? || allowed_origin?(origin, request, config)
105
+
106
+ "Cross-origin request refused: Origin #{origin} is not allowed"
107
+ end
108
+
109
+ def forgery_denial(request, _config)
42
110
  if request.post? && media_type(request) != JSON_MEDIA_TYPE
43
111
  return deny("A POST to the MCP endpoint needs Content-Type: #{JSON_MEDIA_TYPE}")
44
112
  end
@@ -53,13 +121,20 @@ module Profiler
53
121
  def allowed_origin?(origin, request, config)
54
122
  return true if Array(config.cors_allowed_origins).include?(origin)
55
123
 
56
- same_origin?(origin, request.base_url)
124
+ same_origin?(origin, request)
57
125
  end
58
126
 
59
- def same_origin?(origin, base_url)
60
- a = URI.parse(origin)
61
- b = URI.parse(base_url)
62
- a.scheme.to_s.casecmp?(b.scheme.to_s) && a.host.to_s.casecmp?(b.host.to_s) && a.port == b.port
127
+ # The Origin's host and port against the Host header, as the transport compares them: the
128
+ # scheme is left out, since behind a proxy that ends TLS the request says http while the
129
+ # browser's Origin says https, and the default port of the Origin's scheme is dropped.
130
+ def same_origin?(origin, request)
131
+ uri = URI.parse(origin)
132
+ host = request.get_header("HTTP_HOST").to_s.downcase
133
+ return false if uri.host.nil? || host.empty?
134
+
135
+ authority = uri.port == uri.default_port ? uri.host.downcase : "#{uri.host.downcase}:#{uri.port}"
136
+ host = host.delete_suffix(":#{uri.default_port}")
137
+ authority == host
63
138
  rescue URI::InvalidURIError
64
139
  false
65
140
  end
@@ -46,12 +46,21 @@ module Profiler
46
46
 
47
47
  def http_transport
48
48
  @http_transport ||= begin
49
- t = ::MCP::Server::Transports::StreamableHTTPTransport.new(@server)
49
+ t = ::MCP::Server::Transports::StreamableHTTPTransport.new(@server, **http_transport_options)
50
50
  @server.transport = t
51
51
  t
52
52
  end
53
53
  end
54
54
 
55
+ # HttpGuard checks the Host and Origin headers of every request before the transport sees
56
+ # it, with config.hosts, config.mcp_allowed_hosts and Regexp entries, which the transport's
57
+ # own check (exact names only) cannot express. Older mcp versions have no such check, nor
58
+ # the option.
59
+ def http_transport_options
60
+ params = ::MCP::Server::Transports::StreamableHTTPTransport.instance_method(:initialize).parameters
61
+ params.any? { |_type, name| name == :dns_rebinding_protection } ? { dns_rebinding_protection: false } : {}
62
+ end
63
+
55
64
  def start(transport: :stdio)
56
65
  case transport
57
66
  when :stdio
@@ -2,6 +2,7 @@
2
2
 
3
3
  require_relative "../slave_support"
4
4
  require_relative "../../process_snapshot"
5
+ require_relative "../../models/profile"
5
6
 
6
7
  require "shellwords"
7
8
  require "cgi"
@@ -666,7 +667,10 @@ module Profiler
666
667
  def self.generate_curl(profile, req_data)
667
668
  headers = req_data&.dig("headers") || {}
668
669
  params = req_data&.dig("params") || {}
669
- req_body = req_data&.dig("request_body")
670
+ # As captured: a binary body cannot be typed on a command line.
671
+ req_body, req_encoding = Models::Profile.decode_body(req_data&.dig("request_body"),
672
+ req_data&.dig("request_body_encoding"))
673
+ req_body = nil unless req_encoding.nil? || req_encoding == "text"
670
674
 
671
675
  parts = ["curl -X #{profile.method}"]
672
676
 
@@ -117,9 +117,15 @@ module Profiler
117
117
  end
118
118
  private :collector_metadata
119
119
 
120
- def to_h
121
- req_body, req_enc = decode_body(@request_body, @request_body_encoding)
122
- resp_body, resp_enc = decode_body(@response_body, @response_body_encoding)
120
+ # The profile as the stores keep it: a body compressed by compress_bodies stays gzip+base64.
121
+ # decode_bodies: true gives the bodies as text, for the responses that show them.
122
+ def to_h(decode_bodies: false)
123
+ req_body, req_enc = @request_body, @request_body_encoding
124
+ resp_body, resp_enc = @response_body, @response_body_encoding
125
+ if decode_bodies
126
+ req_body, req_enc = self.class.decode_body(req_body, req_enc)
127
+ resp_body, resp_enc = self.class.decode_body(resp_body, resp_enc)
128
+ end
123
129
 
124
130
  {
125
131
  profile_type: @profile_type,
@@ -155,6 +161,7 @@ module Profiler
155
161
  }
156
162
  end
157
163
 
164
+ # The stored form, as #to_h.
158
165
  def to_json(*args)
159
166
  to_h.to_json(*args)
160
167
  end
@@ -212,6 +219,18 @@ module Profiler
212
219
  profile
213
220
  end
214
221
 
222
+ # A body kept gzip+base64 as text, with its encoding then "text"; any other body as it is.
223
+ # One that cannot be decompressed (cut, damaged) is returned as it is: unreadable at worst.
224
+ def self.decode_body(body, encoding)
225
+ return [body, encoding] unless encoding == "gzip+base64"
226
+ return [body, encoding] if body.nil? || body.empty?
227
+
228
+ decoded = Zlib::Inflate.inflate(Base64.strict_decode64(body))
229
+ [decoded.force_encoding(Encoding::UTF_8).scrub, "text"]
230
+ rescue Zlib::Error, ArgumentError
231
+ [body, encoding]
232
+ end
233
+
215
234
  def self.deep_stringify_keys(obj)
216
235
  case obj
217
236
  when Hash
@@ -235,7 +254,8 @@ module Profiler
235
254
  else
236
255
  text = Redaction.filter_body(raw, content_type).encode("UTF-8", invalid: :replace, undef: :replace)
237
256
  if compress_body?(text)
238
- { body: Base64.strict_encode64(Zlib::Deflate.deflate(text)), encoding: "gzip+base64" }
257
+ # The fastest level: paid on the request, a quarter larger than the default level.
258
+ { body: Base64.strict_encode64(Zlib::Deflate.deflate(text, Zlib::BEST_SPEED)), encoding: "gzip+base64" }
239
259
  else
240
260
  { body: text, encoding: "text" }
241
261
  end
@@ -253,16 +273,6 @@ module Profiler
253
273
  text.bytesize > Profiler.configuration.compress_body_threshold
254
274
  end
255
275
 
256
- def decode_body(body, encoding)
257
- return [body, encoding] unless encoding == "gzip+base64"
258
- return [body, encoding] if body.nil? || body.empty?
259
-
260
- decoded = Zlib::Inflate.inflate(Base64.strict_decode64(body))
261
- [decoded, "text"]
262
- rescue Zlib::Error, ArgumentError
263
- [body, encoding]
264
- end
265
-
266
276
  def binary_content_type?(ct)
267
277
  ct.to_s.match?(%r{image/(?!svg)|application/(?:pdf|octet-stream|zip)|audio/|video/})
268
278
  end
@@ -7,16 +7,19 @@ module Profiler
7
7
  # What the profile lists show of a profile, for the stores to keep apart from the profile
8
8
  # itself: the top-level fields without the bodies, params, headers and tabs, and for each
9
9
  # collector its scalar values only (database.total_queries, job.queue, test.test_name...),
10
- # nested lists and hashes left out, strings cut at MAX_SCALAR_LENGTH (a console return value,
11
- # an exception message). Built from Profile#to_h, after the collectors and the
12
- # redaction have run: a summary is a subset of the stored profile (a long string cut to its
13
- # start) and holds no value the profile does not.
10
+ # nested lists and hashes and the request collector's copy of the bodies left out, strings
11
+ # cut at MAX_SCALAR_LENGTH (a console return value, an exception message). Built from
12
+ # Profile#to_h, after the collectors and the redaction have run: a summary is a subset of the
13
+ # stored profile (a long string cut to its start) and holds no value the profile does not.
14
14
  module Summary
15
15
  MAX_SCALAR_LENGTH = 500
16
16
 
17
17
  DROPPED = %i[params headers response_headers request_body request_body_encoding
18
18
  response_body response_body_encoding tabs].freeze
19
19
 
20
+ # The request collector keeps a copy of the bodies: no more listed than the profile's own.
21
+ DROPPED_SCALARS = %w[request_body request_body_encoding response_body response_body_encoding].freeze
22
+
20
23
  module_function
21
24
 
22
25
  # From a Profile, or the Hash its #to_h returns.
@@ -37,6 +40,7 @@ module Profiler
37
40
  def scalars(values)
38
41
  values.each_with_object({}) do |(key, value), kept|
39
42
  next unless scalar?(value)
43
+ next if DROPPED_SCALARS.include?(key.to_s)
40
44
 
41
45
  kept[key.to_s] = value.is_a?(String) && value.length > MAX_SCALAR_LENGTH ? value[0, MAX_SCALAR_LENGTH] : value
42
46
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Profiler
4
- VERSION = "0.31.5"
4
+ VERSION = "0.32.0"
5
5
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: rails-profiler
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.31.5
4
+ version: 0.32.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Sébastien Duplessy
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2026-10-05 00:00:00.000000000 Z
11
+ date: 2026-10-06 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: rails