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 +4 -4
- data/CHANGELOG.md +49 -0
- data/app/controllers/profiler/api/console_controller.rb +1 -1
- data/app/controllers/profiler/api/jobs_controller.rb +1 -1
- data/app/controllers/profiler/api/profiles_controller.rb +1 -1
- data/app/controllers/profiler/api/tests_controller.rb +1 -1
- data/app/controllers/profiler/api/toolbar_controller.rb +1 -0
- data/app/controllers/profiler/application_controller.rb +2 -1
- data/app/controllers/profiler/profiles_controller.rb +1 -1
- data/lib/profiler/configuration.rb +34 -1
- data/lib/profiler/local_request.rb +12 -11
- data/lib/profiler/mcp/body_formatter.rb +2 -9
- data/lib/profiler/mcp/http_guard.rb +84 -9
- data/lib/profiler/mcp/server.rb +10 -1
- data/lib/profiler/mcp/tools/get_profile_detail.rb +5 -1
- data/lib/profiler/models/profile.rb +24 -14
- data/lib/profiler/storage/summary.rb +8 -4
- data/lib/profiler/version.rb +1 -1
- metadata +2 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 623e8531db783ddb27296b83dba2beae12f411e6b2c71630c3b14003b7e67f40
|
|
4
|
+
data.tar.gz: eebbf3cda8334b412ca1a4543cb81f7ef05e65c0f28672794fc9f0b7b57a250f
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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: "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
|
)
|
|
@@ -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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
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
|
|
124
|
+
same_origin?(origin, request)
|
|
57
125
|
end
|
|
58
126
|
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
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
|
data/lib/profiler/mcp/server.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
121
|
-
|
|
122
|
-
|
|
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
|
-
|
|
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
|
|
11
|
-
# an exception message). Built from
|
|
12
|
-
# redaction have run: a summary is a subset of the
|
|
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
|
data/lib/profiler/version.rb
CHANGED
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.
|
|
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-
|
|
11
|
+
date: 2026-10-06 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: rails
|