rails-profiler 0.35.1 → 0.35.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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +55 -0
- data/app/assets/builds/profiler-toolbar.js +2 -4454
- data/app/assets/builds/profiler.css +1 -3546
- data/app/assets/builds/profiler.js +14 -9699
- data/app/controllers/profiler/assets_controller.rb +14 -5
- data/app/views/layouts/profiler/application.html.erb +3 -3
- data/app/views/layouts/profiler/embedded.html.erb +7 -5
- data/exe/profiler-mcp +8 -1
- data/lib/profiler/assets.rb +61 -0
- data/lib/profiler/body_content.rb +58 -0
- data/lib/profiler/cluster/master_client.rb +45 -1
- data/lib/profiler/console_profiler.rb +1 -1
- data/lib/profiler/instrumentation/net_http_instrumentation.rb +20 -7
- data/lib/profiler/instrumentation/thread_context_propagation.rb +9 -2
- data/lib/profiler/job_profiler.rb +1 -1
- data/lib/profiler/mcp/server.rb +1 -1
- data/lib/profiler/mcp/tools/get_profile_detail.rb +2 -2
- data/lib/profiler/mcp/tools/get_profile_http.rb +3 -0
- data/lib/profiler/mcp/tools/query_jobs.rb +1 -1
- data/lib/profiler/mcp/tools/run_tests.rb +4 -1
- data/lib/profiler/middleware/profiler_middleware.rb +1 -1
- data/lib/profiler/middleware/toolbar_injector.rb +22 -10
- data/lib/profiler/models/profile.rb +2 -5
- data/lib/profiler/railtie.rb +7 -0
- data/lib/profiler/version.rb +27 -1
- data/lib/profiler.rb +1 -0
- metadata +3 -1
|
@@ -10,16 +10,21 @@ module Profiler
|
|
|
10
10
|
|
|
11
11
|
BUILD_MISSING = "Profiler assets are not built (%s is missing). Run `yarn build:full` in the profiler gem."
|
|
12
12
|
|
|
13
|
+
# A year: the URL a page uses carries the digest of the content (?v=), so a new version of
|
|
14
|
+
# the gem is a new URL. Without the digest, or with an old one, the file is only kept an
|
|
15
|
+
# hour, as it may be replaced under the same URL.
|
|
16
|
+
VERSIONED_CACHE_CONTROL = "public, max-age=#{365 * 24 * 3600}, immutable".freeze
|
|
17
|
+
|
|
13
18
|
def toolbar_js
|
|
14
|
-
serve
|
|
19
|
+
serve "profiler-toolbar.js", "application/javascript"
|
|
15
20
|
end
|
|
16
21
|
|
|
17
22
|
def main_js
|
|
18
|
-
serve
|
|
23
|
+
serve "profiler.js", "application/javascript"
|
|
19
24
|
end
|
|
20
25
|
|
|
21
26
|
def main_css
|
|
22
|
-
serve
|
|
27
|
+
serve "profiler.css", "text/css"
|
|
23
28
|
end
|
|
24
29
|
|
|
25
30
|
def icon
|
|
@@ -30,8 +35,12 @@ module Profiler
|
|
|
30
35
|
private
|
|
31
36
|
|
|
32
37
|
def serve(name, content_type)
|
|
33
|
-
body =
|
|
34
|
-
|
|
38
|
+
body = Profiler::Assets.read(name)
|
|
39
|
+
if Profiler::Assets.current?(name, params[:v])
|
|
40
|
+
response.headers["Cache-Control"] = VERSIONED_CACHE_CONTROL
|
|
41
|
+
else
|
|
42
|
+
expires_in 1.hour, public: true
|
|
43
|
+
end
|
|
35
44
|
render plain: body, content_type: content_type
|
|
36
45
|
rescue Errno::ENOENT
|
|
37
46
|
render plain: format(BUILD_MISSING, name), status: :not_found
|
|
@@ -6,12 +6,12 @@
|
|
|
6
6
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
7
7
|
<%= csrf_meta_tags %>
|
|
8
8
|
<%= csp_meta_tag %>
|
|
9
|
-
<meta name="profiler-version" content="<%= Profiler
|
|
9
|
+
<meta name="profiler-version" content="<%= Profiler.version %>">
|
|
10
10
|
<meta name="profiler-name" content="<%= Profiler.configuration.resolved_name %>">
|
|
11
11
|
<meta name="profiler-is-master" content="<%= Profiler.configuration.master? %>">
|
|
12
12
|
|
|
13
|
-
<link rel="stylesheet" href="
|
|
14
|
-
<script src="
|
|
13
|
+
<link rel="stylesheet" href="<%= Profiler::Assets.url("profiler.css") %>">
|
|
14
|
+
<script src="<%= Profiler::Assets.url("profiler.js") %>" defer></script>
|
|
15
15
|
</head>
|
|
16
16
|
|
|
17
17
|
<body>
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
<% nonce = content_security_policy_nonce %>
|
|
2
|
+
<% nonce_attr = nonce ? %( nonce="#{ERB::Util.html_escape(nonce)}").html_safe : "" %>
|
|
1
3
|
<!DOCTYPE html>
|
|
2
4
|
<html data-theme="<%= params[:theme].presence&.then { |t| %w[light dark].include?(t) ? t : 'dark' } || 'dark' %>">
|
|
3
5
|
<head>
|
|
@@ -6,18 +8,18 @@
|
|
|
6
8
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
7
9
|
<%= csrf_meta_tags %>
|
|
8
10
|
<%= csp_meta_tag %>
|
|
9
|
-
<meta name="profiler-version" content="<%= Profiler
|
|
11
|
+
<meta name="profiler-version" content="<%= Profiler.version %>">
|
|
10
12
|
|
|
11
|
-
<link rel="stylesheet" href="
|
|
12
|
-
<script src="
|
|
13
|
+
<link rel="stylesheet" href="<%= Profiler::Assets.url("profiler.css") %>">
|
|
14
|
+
<script src="<%= Profiler::Assets.url("profiler.js") %>" defer></script>
|
|
13
15
|
|
|
14
|
-
<style
|
|
16
|
+
<style<%= nonce_attr %>>
|
|
15
17
|
/* Embedded overrides, tighter layout, no page chrome */
|
|
16
18
|
body { overflow-y: auto; }
|
|
17
19
|
.container { padding: 16px 20px; }
|
|
18
20
|
</style>
|
|
19
21
|
|
|
20
|
-
<script
|
|
22
|
+
<script<%= nonce_attr %>>
|
|
21
23
|
// Receive theme changes from the Chrome extension panel via postMessage.
|
|
22
24
|
// The extension sends: { type: 'profiler:set-theme', theme: 'light' | 'dark' }
|
|
23
25
|
window.addEventListener('message', function(e) {
|
data/exe/profiler-mcp
CHANGED
|
@@ -4,10 +4,17 @@
|
|
|
4
4
|
# The MCP server over stdio, without the application: its initializers do not run here, so
|
|
5
5
|
# config.mcp_read_only set there has no effect. Set PROFILER_MCP_READ_ONLY=1 in the environment the
|
|
6
6
|
# MCP client gives this process to leave out the write tools (Profiler::MCP::Server.read_only?).
|
|
7
|
-
# `bundle exec rake profiler:mcp` boots the application and reads both.
|
|
7
|
+
# `bundle exec rake profiler:mcp` boots the application and reads both. It reads the profiles of
|
|
8
|
+
# the file storage under tmp/rails-profiler in the current directory: run it from the application's
|
|
9
|
+
# root. An application on another storage is read through `rake profiler:mcp` or the HTTP endpoint.
|
|
8
10
|
|
|
9
11
|
require "bundler/setup"
|
|
10
12
|
require "profiler"
|
|
11
13
|
require "profiler/mcp/server"
|
|
12
14
|
|
|
15
|
+
# Without the application there is no initializer to pick a storage, and the default is :memory:
|
|
16
|
+
# a store of this process alone, which never holds a profile the application wrote. Read the files
|
|
17
|
+
# the application's file storage wrote under ./tmp/rails-profiler, from the application's root.
|
|
18
|
+
Profiler.configure { |config| config.storage = :file }
|
|
19
|
+
|
|
13
20
|
Profiler::MCP::Server.new.start(transport: :stdio)
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "digest"
|
|
4
|
+
|
|
5
|
+
module Profiler
|
|
6
|
+
# The gem's built front end files. Each is read once and kept until the file changes on disk
|
|
7
|
+
# (a rebuild in development), and named by a digest of its content so that a browser that
|
|
8
|
+
# cached one never keeps it past a new version of the gem.
|
|
9
|
+
module Assets
|
|
10
|
+
NAMES = %w[profiler.js profiler.css profiler-toolbar.js].freeze
|
|
11
|
+
URL_PREFIX = "/_profiler/assets"
|
|
12
|
+
|
|
13
|
+
Entry = Struct.new(:mtime, :content, :digest)
|
|
14
|
+
|
|
15
|
+
@entries = {}
|
|
16
|
+
@mutex = Mutex.new
|
|
17
|
+
|
|
18
|
+
class << self
|
|
19
|
+
# Where the build writes the files; the gem's own, never the application's.
|
|
20
|
+
def builds_dir
|
|
21
|
+
File.expand_path("../../app/assets/builds", __dir__)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# The file's content, or raises Errno::ENOENT when the assets are not built.
|
|
25
|
+
def read(name)
|
|
26
|
+
entry(name).content
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Short digest of the content; the gem version when the file is not built.
|
|
30
|
+
def digest(name)
|
|
31
|
+
entry(name).digest
|
|
32
|
+
rescue Errno::ENOENT
|
|
33
|
+
Digest::SHA256.hexdigest(Profiler::VERSION)[0, 12]
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# The URL a page uses for the asset, carrying its digest.
|
|
37
|
+
def url(name)
|
|
38
|
+
"#{URL_PREFIX}/#{name}?v=#{digest(name)}"
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def current?(name, version)
|
|
42
|
+
version.is_a?(String) && !version.empty? && version == digest(name)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
private
|
|
46
|
+
|
|
47
|
+
def entry(name)
|
|
48
|
+
raise ArgumentError, "unknown asset: #{name}" unless NAMES.include?(name)
|
|
49
|
+
|
|
50
|
+
path = File.join(builds_dir, name)
|
|
51
|
+
mtime = File.mtime(path)
|
|
52
|
+
cached = @mutex.synchronize { @entries[name] }
|
|
53
|
+
return cached if cached && cached.mtime == mtime
|
|
54
|
+
|
|
55
|
+
content = File.read(path)
|
|
56
|
+
fresh = Entry.new(mtime, content, Digest::SHA256.hexdigest(content)[0, 12])
|
|
57
|
+
@mutex.synchronize { @entries[name] = fresh }
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Profiler
|
|
4
|
+
# Tells a body that is text from one that is not, from the bytes themselves and not only from
|
|
5
|
+
# the Content-Type the sender declared: a binary payload served as text/plain is common, and
|
|
6
|
+
# forcing it into UTF-8 loses bytes it cannot get back.
|
|
7
|
+
module BodyContent
|
|
8
|
+
BINARY_CONTENT_TYPES = %r{
|
|
9
|
+
\A(?:image/(?!svg)|audio/|video/|font/|
|
|
10
|
+
application/(?:pdf|octet-stream|zip|gzip|x-gzip|x-protobuf|protobuf|x-msgpack|msgpack|
|
|
11
|
+
wasm|x-font|font-|vnd\.ms-fontobject|x-tar|x-7z-compressed|x-bzip2|zstd))
|
|
12
|
+
}xi
|
|
13
|
+
|
|
14
|
+
# Enough to tell: the head of a body is where a magic number or a NUL byte shows up.
|
|
15
|
+
SAMPLE_BYTES = 8192
|
|
16
|
+
# A text holds few control characters: tab, newline, carriage return and escape set aside.
|
|
17
|
+
MAX_CONTROL_RATIO = 0.05
|
|
18
|
+
|
|
19
|
+
module_function
|
|
20
|
+
|
|
21
|
+
def binary_type?(content_type)
|
|
22
|
+
content_type.to_s.split(";").first.to_s.strip.match?(BINARY_CONTENT_TYPES)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# True for a body that cannot be kept as text: a NUL byte, bytes that are not UTF-8 (a cut
|
|
26
|
+
# in the middle of the last character excepted), or mostly control characters.
|
|
27
|
+
#
|
|
28
|
+
# A body whose Content-Type declares a charset other than UTF-8 is not judged on its UTF-8
|
|
29
|
+
# validity: its high bytes belong to that charset (ISO-8859-1, Windows-1252...) and are
|
|
30
|
+
# decoded from it afterwards. The NUL byte and control character tests still apply to it.
|
|
31
|
+
def binary_bytes?(body, content_type = nil)
|
|
32
|
+
return false if body.nil? || body.empty?
|
|
33
|
+
|
|
34
|
+
sample = body.byteslice(0, SAMPLE_BYTES).dup.force_encoding(Encoding::UTF_8)
|
|
35
|
+
return true if sample.include?("\0")
|
|
36
|
+
|
|
37
|
+
if !sample.valid_encoding? && !foreign_charset?(content_type)
|
|
38
|
+
# The sample may end inside a multi-byte character, whichever way it was cut.
|
|
39
|
+
sample = (1..3).filter_map { |n| sample.byteslice(0, sample.bytesize - n)&.force_encoding(Encoding::UTF_8) }
|
|
40
|
+
.find(&:valid_encoding?)
|
|
41
|
+
return true if sample.nil?
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
sample = sample.scrub("") unless sample.valid_encoding?
|
|
45
|
+
controls = sample.count("\x01-\x08\x0B\x0C\x0E-\x1A\x1C-\x1F\x7F")
|
|
46
|
+
sample.length.positive? && controls.fdiv(sample.length) > MAX_CONTROL_RATIO
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def binary?(body, content_type)
|
|
50
|
+
binary_type?(content_type) || binary_bytes?(body, content_type)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def foreign_charset?(content_type)
|
|
54
|
+
charset = content_type.to_s[/charset\s*=\s*"?([^\s";]+)/i, 1]
|
|
55
|
+
!charset.nil? && !charset.match?(/\A(?:utf-?8|us-ascii)\z/i)
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
@@ -8,7 +8,38 @@ require_relative "security"
|
|
|
8
8
|
module Profiler
|
|
9
9
|
module Cluster
|
|
10
10
|
class MasterClient
|
|
11
|
+
# Process._fork (Ruby 3.1+) runs in the parent and in the child alike. A thread does not
|
|
12
|
+
# survive a fork, so a worker forked from a process that had started the client (Puma in
|
|
13
|
+
# cluster mode with preload_app!, a preloaded Unicorn) would never heartbeat.
|
|
14
|
+
module ForkHook
|
|
15
|
+
def _fork
|
|
16
|
+
pid = super
|
|
17
|
+
MasterClient.restart_after_fork if pid.zero?
|
|
18
|
+
pid
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
class << self
|
|
23
|
+
# The client the railtie started in this process, for the fork hook to restart.
|
|
24
|
+
attr_accessor :current
|
|
25
|
+
|
|
26
|
+
def install_fork_hook!
|
|
27
|
+
return false if @fork_hook_installed || !Process.respond_to?(:_fork)
|
|
28
|
+
|
|
29
|
+
Process.singleton_class.prepend(ForkHook)
|
|
30
|
+
@fork_hook_installed = true
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def restart_after_fork
|
|
34
|
+
current&.restart_after_fork
|
|
35
|
+
rescue => e
|
|
36
|
+
Profiler.log_warn("Cluster: could not restart the heartbeat after a fork", e)
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
11
40
|
def start
|
|
41
|
+
self.class.current = self
|
|
42
|
+
self.class.install_fork_hook!
|
|
12
43
|
@registered = false
|
|
13
44
|
begin
|
|
14
45
|
register!
|
|
@@ -16,6 +47,17 @@ module Profiler
|
|
|
16
47
|
rescue => e
|
|
17
48
|
log_warn("could not register with master at startup, will retry in the heartbeat loop", e)
|
|
18
49
|
end
|
|
50
|
+
@started = true
|
|
51
|
+
start_heartbeat_thread
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# In the child of a fork: the heartbeat thread of the parent is gone and the parent's
|
|
55
|
+
# registration is not the child's own, so register again from the new thread.
|
|
56
|
+
# Does nothing while a heartbeat thread is alive, nor before start.
|
|
57
|
+
def restart_after_fork
|
|
58
|
+
return if @started.nil? || @thread&.alive?
|
|
59
|
+
|
|
60
|
+
@registered = false
|
|
19
61
|
start_heartbeat_thread
|
|
20
62
|
end
|
|
21
63
|
|
|
@@ -61,7 +103,9 @@ module Profiler
|
|
|
61
103
|
end
|
|
62
104
|
|
|
63
105
|
def start_heartbeat_thread
|
|
64
|
-
|
|
106
|
+
return if @thread&.alive?
|
|
107
|
+
|
|
108
|
+
@thread = Thread.new do
|
|
65
109
|
interval = Profiler.configuration.cluster_heartbeat_interval
|
|
66
110
|
loop do
|
|
67
111
|
sleep interval
|
|
@@ -38,7 +38,7 @@ module Profiler
|
|
|
38
38
|
def run(&block)
|
|
39
39
|
profile = Models::Profile.new
|
|
40
40
|
profile.profile_type = "console"
|
|
41
|
-
profile.gem_version = Profiler
|
|
41
|
+
profile.gem_version = Profiler.version
|
|
42
42
|
profile.path = Redaction.truncate(@expression, 200)
|
|
43
43
|
profile.method = "CONSOLE"
|
|
44
44
|
|
|
@@ -6,6 +6,7 @@ require "zlib"
|
|
|
6
6
|
require "stringio"
|
|
7
7
|
require "securerandom"
|
|
8
8
|
require_relative "../redaction"
|
|
9
|
+
require_relative "../body_content"
|
|
9
10
|
require_relative "../configuration"
|
|
10
11
|
|
|
11
12
|
module Profiler
|
|
@@ -59,7 +60,10 @@ module Profiler
|
|
|
59
60
|
response = super
|
|
60
61
|
|
|
61
62
|
duration = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - t0) * 1000).round(2)
|
|
62
|
-
|
|
63
|
+
# read_body with a block hands each chunk to the block and keeps none: the response then
|
|
64
|
+
# holds a Net::ReadAdapter, whose to_s is an inspect and whose bytes are gone.
|
|
65
|
+
streamed = NetHttpInstrumentation.streamed_body?(response)
|
|
66
|
+
resp_body_raw = streamed ? "" : response.body.to_s
|
|
63
67
|
resp_content_encoding = response["content-encoding"].to_s.strip.downcase
|
|
64
68
|
resp_body = NetHttpInstrumentation.decompress_body(resp_body_raw, resp_content_encoding, limit)
|
|
65
69
|
resp_truncated = limit ? resp_body.bytesize > limit : false
|
|
@@ -76,8 +80,9 @@ module Profiler
|
|
|
76
80
|
response_headers: Redaction.filter_headers(response.to_hash.transform_values { |v| v.join(", ") }),
|
|
77
81
|
response_body: processed_resp[:body],
|
|
78
82
|
response_body_encoding: processed_resp[:encoding],
|
|
79
|
-
response_size: resp_body_raw.bytesize,
|
|
80
|
-
response_body_truncated: resp_truncated
|
|
83
|
+
response_size: streamed ? (declared_length(response) || 0) : resp_body_raw.bytesize,
|
|
84
|
+
response_body_truncated: resp_truncated,
|
|
85
|
+
response_body_not_captured: streamed
|
|
81
86
|
)
|
|
82
87
|
|
|
83
88
|
fg = Thread.current[:profiler_flamegraph_collector]
|
|
@@ -96,6 +101,11 @@ module Profiler
|
|
|
96
101
|
|
|
97
102
|
private
|
|
98
103
|
|
|
104
|
+
def declared_length(response)
|
|
105
|
+
length = response["content-length"].to_s
|
|
106
|
+
length.match?(/\A\d+\z/) ? length.to_i : nil
|
|
107
|
+
end
|
|
108
|
+
|
|
99
109
|
def build_url(host, port, path, ssl)
|
|
100
110
|
scheme = ssl ? "https" : "http"
|
|
101
111
|
standard_port = (ssl && port == 443) || (!ssl && port == 80)
|
|
@@ -110,7 +120,7 @@ module Profiler
|
|
|
110
120
|
deprecate_constant :SKIP_HOSTS
|
|
111
121
|
|
|
112
122
|
TEXT_CONTENT_TYPES = /\A(text\/|application\/(json|xml|xhtml|javascript|x-www-form-urlencoded)|image\/svg)/i
|
|
113
|
-
BINARY_CONTENT_TYPES =
|
|
123
|
+
BINARY_CONTENT_TYPES = BodyContent::BINARY_CONTENT_TYPES
|
|
114
124
|
|
|
115
125
|
def self.install!
|
|
116
126
|
return if @installed
|
|
@@ -255,12 +265,15 @@ module Profiler
|
|
|
255
265
|
inflater&.close
|
|
256
266
|
end
|
|
257
267
|
|
|
268
|
+
# A response read with read_body(&block) was consumed as a stream: its body is gone.
|
|
269
|
+
def self.streamed_body?(response)
|
|
270
|
+
defined?(Net::ReadAdapter) && response.body.is_a?(Net::ReadAdapter)
|
|
271
|
+
end
|
|
272
|
+
|
|
258
273
|
def self.process_body(body, content_type)
|
|
259
274
|
return { body: nil, encoding: "text" } if body.nil? || body.empty?
|
|
260
275
|
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
if mime.match?(BINARY_CONTENT_TYPES)
|
|
276
|
+
if BodyContent.binary?(body, content_type)
|
|
264
277
|
# Masked on the raw bytes, before the encoding hides them from any later search.
|
|
265
278
|
{ body: Base64.strict_encode64(Redaction.hide_credentials(body.b)), encoding: "base64" }
|
|
266
279
|
else
|
|
@@ -37,8 +37,15 @@ module Profiler
|
|
|
37
37
|
super(*args, &wrapper)
|
|
38
38
|
end
|
|
39
39
|
ruby2_keywords :initialize
|
|
40
|
+
|
|
41
|
+
# Called by the railtie once the application has decided `enabled`: the patch is on
|
|
42
|
+
# Thread#initialize for every thread of the process, so a disabled profiler leaves it out.
|
|
43
|
+
def self.install!
|
|
44
|
+
return if @installed
|
|
45
|
+
|
|
46
|
+
Thread.prepend(self)
|
|
47
|
+
@installed = true
|
|
48
|
+
end
|
|
40
49
|
end
|
|
41
50
|
end
|
|
42
51
|
end
|
|
43
|
-
|
|
44
|
-
Thread.prepend(Profiler::Instrumentation::ThreadContextPropagation)
|
|
@@ -55,7 +55,7 @@ module Profiler
|
|
|
55
55
|
def run(&block)
|
|
56
56
|
profile = Models::Profile.new
|
|
57
57
|
profile.profile_type = "job"
|
|
58
|
-
profile.gem_version = Profiler
|
|
58
|
+
profile.gem_version = Profiler.version
|
|
59
59
|
profile.path = @job_class
|
|
60
60
|
profile.method = "JOB"
|
|
61
61
|
profile.parent_token = @parent_token if @parent_token
|
data/lib/profiler/mcp/server.rb
CHANGED
|
@@ -98,8 +98,8 @@ module Profiler
|
|
|
98
98
|
lines << "**Allocated objects:** #{profile.allocated_objects}" if profile.allocated_objects
|
|
99
99
|
lines << "**Time:** #{profile.started_at}"
|
|
100
100
|
if profile.gem_version
|
|
101
|
-
if profile.gem_version != Profiler
|
|
102
|
-
lines << "**Gem Version:** #{profile.gem_version} ⚠️ (current: #{Profiler
|
|
101
|
+
if profile.gem_version != Profiler.version
|
|
102
|
+
lines << "**Gem Version:** #{profile.gem_version} ⚠️ (current: #{Profiler.version})"
|
|
103
103
|
else
|
|
104
104
|
lines << "**Gem Version:** #{profile.gem_version}"
|
|
105
105
|
end
|
|
@@ -91,6 +91,9 @@ module Profiler
|
|
|
91
91
|
lines << "- _Request body not captured: a stream that cannot be rewound is sent unread._"
|
|
92
92
|
end
|
|
93
93
|
lines << "- _Request body truncated: only the first bytes were kept (max_captured_body_bytes)._" if req["request_body_truncated"]
|
|
94
|
+
if req["response_body_not_captured"]
|
|
95
|
+
lines << "- _Response body not captured: it was read as a stream (read_body with a block)._"
|
|
96
|
+
end
|
|
94
97
|
lines << "- _Response body truncated: only the first bytes were kept (max_captured_body_bytes)._" if req["response_body_truncated"]
|
|
95
98
|
lines << "- **Error:** #{req['error']}" if req["error"]
|
|
96
99
|
|
|
@@ -73,7 +73,7 @@ module Profiler
|
|
|
73
73
|
when "duration" then profile.duration ? "#{profile.duration.round(2)}ms" : "-"
|
|
74
74
|
when "gem_version"
|
|
75
75
|
v = profile.gem_version || "-"
|
|
76
|
-
v != Profiler
|
|
76
|
+
v != Profiler.version ? "#{v} ⚠️" : v
|
|
77
77
|
when "token" then profile.token.to_s
|
|
78
78
|
when "parent_token" then profile.parent_token || "-"
|
|
79
79
|
end
|
|
@@ -94,7 +94,10 @@ module Profiler
|
|
|
94
94
|
timed_out = false
|
|
95
95
|
|
|
96
96
|
loop do
|
|
97
|
-
|
|
97
|
+
if Time.now > deadline
|
|
98
|
+
timed_out = true
|
|
99
|
+
break
|
|
100
|
+
end
|
|
98
101
|
run_data = proxy.get_json("/_profiler/api/test_runner/runs/#{run_id}")
|
|
99
102
|
break if Profiler::TestRunner::RunStore::TERMINAL_STATUSES.include?(run_data["status"])
|
|
100
103
|
sleep 2
|
|
@@ -29,7 +29,7 @@ module Profiler
|
|
|
29
29
|
begin
|
|
30
30
|
begin
|
|
31
31
|
profile = Models::Profile.new(build_request(env), params: false)
|
|
32
|
-
profile.gem_version = Profiler
|
|
32
|
+
profile.gem_version = Profiler.version
|
|
33
33
|
Profiler::CurrentContext.token = profile.token
|
|
34
34
|
|
|
35
35
|
# Capture request body before app processes it
|
|
@@ -38,6 +38,24 @@ module Profiler
|
|
|
38
38
|
)*+
|
|
39
39
|
}xi
|
|
40
40
|
|
|
41
|
+
INTERCEPTOR_PATH = File.expand_path("../../../app/assets/javascript/profiler-ajax-interceptor.js", __dir__)
|
|
42
|
+
|
|
43
|
+
class << self
|
|
44
|
+
# The script is the same for every request of a version of the gem: read once. A failed
|
|
45
|
+
# read is not kept, so that the next request tries again.
|
|
46
|
+
def ajax_interceptor_code
|
|
47
|
+
@ajax_interceptor_code ||= File.read(INTERCEPTOR_PATH).freeze
|
|
48
|
+
rescue => e
|
|
49
|
+
Profiler.log_error("ToolbarInjector: could not load the AJAX interceptor script", e)
|
|
50
|
+
""
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# The toolbar CSS is a constant of the class too, built once.
|
|
54
|
+
def toolbar_styles
|
|
55
|
+
@toolbar_styles ||= build_toolbar_styles
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
41
59
|
def initialize(body, token, nonce = nil)
|
|
42
60
|
@body = body
|
|
43
61
|
@token = token
|
|
@@ -139,13 +157,7 @@ module Profiler
|
|
|
139
157
|
end
|
|
140
158
|
|
|
141
159
|
def ajax_interceptor_code
|
|
142
|
-
|
|
143
|
-
path = File.join(__dir__, "..", "..", "..", "app", "assets", "javascript", "profiler-ajax-interceptor.js")
|
|
144
|
-
File.read(path)
|
|
145
|
-
rescue => e
|
|
146
|
-
Profiler.log_error("ToolbarInjector: could not load the AJAX interceptor script", e)
|
|
147
|
-
""
|
|
148
|
-
end
|
|
160
|
+
self.class.ajax_interceptor_code
|
|
149
161
|
end
|
|
150
162
|
|
|
151
163
|
# The middleware hands over the page it has already read: an Array of parts, or a String.
|
|
@@ -157,11 +169,11 @@ module Profiler
|
|
|
157
169
|
def toolbar_html
|
|
158
170
|
<<~HTML
|
|
159
171
|
#{ajax_interceptor_script}
|
|
160
|
-
<style>#{toolbar_styles}</style>
|
|
172
|
+
<style#{nonce_attr}>#{self.class.toolbar_styles}</style>
|
|
161
173
|
<div id="profiler-toolbar" class="profiler-root" data-token="#{ERB::Util.html_escape(@token)}"></div>
|
|
162
174
|
<button id="profiler-toolbar-toggle" class="profiler-root" title="Toggle profiler (Alt+P)"><span class="profiler-toggle-icon">▶</span></button>
|
|
163
175
|
<script#{nonce_attr}>(function(){var c=localStorage.getItem('profiler-toolbar-collapsed')==='true',t=localStorage.getItem('profiler-theme'),theme=t==='light'?'light':t==='dark'?'dark':(window.matchMedia('(prefers-color-scheme:light)').matches?'light':'dark'),el=document.getElementById('profiler-toolbar'),tog=document.getElementById('profiler-toolbar-toggle');if(c){el.style.cssText='animation:none!important;transform:translateX(calc(100% + 44px))';tog.dataset.collapsed='true';}el.setAttribute('data-theme',theme);tog.setAttribute('data-theme',theme);})();</script>
|
|
164
|
-
<script src="
|
|
176
|
+
<script src="#{Profiler::Assets.url("profiler-toolbar.js")}" defer#{nonce_attr}></script>
|
|
165
177
|
HTML
|
|
166
178
|
end
|
|
167
179
|
|
|
@@ -176,7 +188,7 @@ module Profiler
|
|
|
176
188
|
|
|
177
189
|
# Thermal design system, self-contained CSS for the injected toolbar.
|
|
178
190
|
# Variables are defined on #profiler-toolbar to avoid polluting the host app.
|
|
179
|
-
def
|
|
191
|
+
def self.build_toolbar_styles
|
|
180
192
|
<<~'CSS'
|
|
181
193
|
.profiler-root {
|
|
182
194
|
--pf-bg: #080b10;
|
|
@@ -5,6 +5,7 @@ require "securerandom"
|
|
|
5
5
|
require "json"
|
|
6
6
|
require "zlib"
|
|
7
7
|
require_relative "../redaction"
|
|
8
|
+
require_relative "../body_content"
|
|
8
9
|
require_relative "../allocation_counter"
|
|
9
10
|
|
|
10
11
|
module Profiler
|
|
@@ -256,7 +257,7 @@ module Profiler
|
|
|
256
257
|
def process_body(raw, content_type)
|
|
257
258
|
return { body: nil, encoding: "text" } if raw.nil? || raw.empty?
|
|
258
259
|
|
|
259
|
-
if
|
|
260
|
+
if BodyContent.binary?(raw, content_type)
|
|
260
261
|
# Masked on the raw bytes, before the encoding hides them from any later search. A
|
|
261
262
|
# compressed format (zip, png, gzip...) does not hold the secret as it is.
|
|
262
263
|
{ body: Base64.strict_encode64(Redaction.hide_credentials(raw.b)), encoding: "base64" }
|
|
@@ -315,10 +316,6 @@ module Profiler
|
|
|
315
316
|
text.bytesize > Profiler.configuration.compress_body_threshold
|
|
316
317
|
end
|
|
317
318
|
|
|
318
|
-
def binary_content_type?(ct)
|
|
319
|
-
ct.to_s.match?(%r{image/(?!svg)|application/(?:pdf|octet-stream|zip)|audio/|video/})
|
|
320
|
-
end
|
|
321
|
-
|
|
322
319
|
LEGACY_FILTERED_PARAMS = %w[password password_confirmation token secret].freeze
|
|
323
320
|
|
|
324
321
|
# The key added to params cut at max_captured_body_bytes, so that the cut shows wherever
|
data/lib/profiler/railtie.rb
CHANGED
|
@@ -77,6 +77,13 @@ module Profiler
|
|
|
77
77
|
app.middleware.delete Profiler::Middleware::CorsMiddleware
|
|
78
78
|
end
|
|
79
79
|
|
|
80
|
+
# Thread#initialize is patched for every thread of the process: only when the profiler is on.
|
|
81
|
+
initializer "profiler.install_thread_context_propagation" do
|
|
82
|
+
next unless Profiler.configuration.enabled
|
|
83
|
+
|
|
84
|
+
Profiler::Instrumentation::ThreadContextPropagation.install!
|
|
85
|
+
end
|
|
86
|
+
|
|
80
87
|
initializer "profiler.setup_test_profiler" do
|
|
81
88
|
next unless Profiler.configuration.enabled && Profiler.configuration.track_tests
|
|
82
89
|
|
data/lib/profiler/version.rb
CHANGED
|
@@ -1,5 +1,31 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module Profiler
|
|
4
|
-
VERSION = "0.35.
|
|
4
|
+
VERSION = "0.35.3"
|
|
5
|
+
|
|
6
|
+
GEM_ROOT = File.expand_path("../..", __dir__)
|
|
7
|
+
|
|
8
|
+
# The version the gem reports (profiles, the MCP server). A published gem has it written into
|
|
9
|
+
# VERSION by the release job, and that is what this returns. VERSION stays at 0.0.0 on the
|
|
10
|
+
# default branch, so a gem mounted by path from a git checkout derives it from `git describe`
|
|
11
|
+
# instead; anything else (no git, no checkout, no tag, an installed gem) keeps VERSION.
|
|
12
|
+
def self.version
|
|
13
|
+
return @version if defined?(@version)
|
|
14
|
+
|
|
15
|
+
@version = VERSION == "0.0.0" ? (checkout_version || VERSION) : VERSION
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# "0.34.2" on a tag, "0.34.2-5-g1a2b3c4" five commits after it. Nil unless the gem lives in
|
|
19
|
+
# its own git checkout: the .git next to lib/ is what tells it from a copy someone else's
|
|
20
|
+
# repository happens to contain.
|
|
21
|
+
def self.checkout_version
|
|
22
|
+
return nil unless File.exist?(File.join(GEM_ROOT, ".git")) && File.directory?(File.join(GEM_ROOT, "lib"))
|
|
23
|
+
|
|
24
|
+
described = IO.popen(["git", "-C", GEM_ROOT, "describe", "--tags", "--match", "v[0-9]*"],
|
|
25
|
+
err: File::NULL, &:read)
|
|
26
|
+
described = described.to_s.strip.delete_prefix("v")
|
|
27
|
+
$?&.success? && described.match?(/\A\d+\.\d+\.\d+/) ? described : nil
|
|
28
|
+
rescue SystemCallError, IOError
|
|
29
|
+
nil
|
|
30
|
+
end
|
|
5
31
|
end
|
data/lib/profiler.rb
CHANGED
|
@@ -273,6 +273,7 @@ require_relative "profiler/collectors/mailer_collector"
|
|
|
273
273
|
require_relative "profiler/storage/token"
|
|
274
274
|
require_relative "profiler/storage/private_files"
|
|
275
275
|
require_relative "profiler/storage/unavailable"
|
|
276
|
+
require_relative "profiler/assets"
|
|
276
277
|
require_relative "profiler/ajax_data"
|
|
277
278
|
require_relative "profiler/env_override_store"
|
|
278
279
|
require_relative "profiler/instrumentation/thread_context_propagation"
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: rails-profiler
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.35.
|
|
4
|
+
version: 0.35.3
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Sébastien Duplessy
|
|
@@ -145,6 +145,8 @@ files:
|
|
|
145
145
|
- lib/profiler.rb
|
|
146
146
|
- lib/profiler/ajax_data.rb
|
|
147
147
|
- lib/profiler/allocation_counter.rb
|
|
148
|
+
- lib/profiler/assets.rb
|
|
149
|
+
- lib/profiler/body_content.rb
|
|
148
150
|
- lib/profiler/boot_env.rb
|
|
149
151
|
- lib/profiler/cluster/master_client.rb
|
|
150
152
|
- lib/profiler/cluster/security.rb
|