rails-profiler 0.31.3 → 0.31.5
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 +129 -0
- data/app/assets/builds/profiler.js +9 -1
- data/app/controllers/profiler/api/console_controller.rb +1 -11
- data/app/controllers/profiler/api/jobs_controller.rb +1 -11
- data/app/controllers/profiler/api/profiles_controller.rb +18 -28
- data/app/controllers/profiler/api/tests_controller.rb +1 -11
- data/app/controllers/profiler/api/toolbar_controller.rb +0 -20
- data/app/controllers/profiler/application_controller.rb +55 -15
- data/app/controllers/profiler/profiles_controller.rb +2 -18
- data/lib/profiler/ajax_data.rb +40 -0
- data/lib/profiler/cluster/master_client.rb +7 -15
- data/lib/profiler/cluster/security.rb +3 -3
- data/lib/profiler/cluster/slave_proxy.rb +14 -9
- data/lib/profiler/collectors/lifecycle.rb +2 -2
- data/lib/profiler/collectors/log_collector.rb +54 -5
- data/lib/profiler/collectors/mailer_collector.rb +8 -4
- data/lib/profiler/collectors/request_collector.rb +6 -1
- data/lib/profiler/collectors/scoped_notifications.rb +1 -1
- data/lib/profiler/configuration.rb +30 -8
- data/lib/profiler/console_profiler.rb +1 -1
- data/lib/profiler/engine.rb +2 -1
- data/lib/profiler/env_override_store.rb +4 -4
- data/lib/profiler/explain_runner.rb +5 -1
- data/lib/profiler/instrumentation/net_http_instrumentation.rb +138 -40
- data/lib/profiler/job_profiler.rb +1 -1
- data/lib/profiler/local_request.rb +2 -6
- data/lib/profiler/mcp/file_cache.rb +2 -1
- data/lib/profiler/mcp/resources/failing_tests.rb +1 -1
- data/lib/profiler/mcp/resources/recent_console.rb +2 -2
- data/lib/profiler/mcp/resources/recent_jobs.rb +2 -2
- data/lib/profiler/mcp/resources/slow_tests.rb +1 -1
- data/lib/profiler/mcp/server.rb +9 -3
- data/lib/profiler/mcp/tools/get_profile_ajax.rb +2 -0
- data/lib/profiler/mcp/tools/get_profile_http.rb +6 -1
- data/lib/profiler/mcp/tools/get_test_profile_detail.rb +1 -1
- data/lib/profiler/mcp/tools/query_console_profiles.rb +6 -2
- data/lib/profiler/mcp/tools/query_jobs.rb +6 -2
- data/lib/profiler/mcp/tools/query_mailers.rb +5 -1
- data/lib/profiler/mcp/tools/query_profiles.rb +6 -2
- data/lib/profiler/mcp/tools/query_test_profiles.rb +6 -2
- data/lib/profiler/mcp/tools/run_tests.rb +5 -2
- data/lib/profiler/middleware/profiler_middleware.rb +22 -16
- data/lib/profiler/middleware/streamed_profile.rb +1 -1
- data/lib/profiler/middleware/toolbar_injector.rb +21 -2
- data/lib/profiler/models/profile.rb +60 -2
- data/lib/profiler/process_snapshot.rb +8 -3
- data/lib/profiler/railtie.rb +4 -1
- data/lib/profiler/redaction.rb +1 -3
- data/lib/profiler/sse/redis_event_bus.rb +2 -1
- data/lib/profiler/storage/base_store.rb +4 -2
- data/lib/profiler/storage/blob_store.rb +1 -1
- data/lib/profiler/storage/file_store.rb +337 -58
- data/lib/profiler/storage/memory_store.rb +60 -31
- data/lib/profiler/storage/private_files.rb +16 -3
- data/lib/profiler/storage/redis_store.rb +252 -33
- data/lib/profiler/storage/sqlite_store.rb +59 -11
- data/lib/profiler/storage/summary.rb +50 -0
- data/lib/profiler/storage/unavailable.rb +57 -0
- data/lib/profiler/test_helpers/reporter.rb +2 -2
- data/lib/profiler/test_profiler.rb +1 -1
- data/lib/profiler/test_runner/runner.rb +3 -7
- data/lib/profiler/version.rb +1 -1
- data/lib/profiler.rb +138 -4
- metadata +4 -1
|
@@ -8,6 +8,43 @@ module Profiler
|
|
|
8
8
|
class LogCollector < BaseCollector
|
|
9
9
|
SEVERITY_LABELS = %w[DEBUG INFO WARN ERROR FATAL UNKNOWN].freeze
|
|
10
10
|
|
|
11
|
+
# The lines of one request, up to max_captured_log_bytes of messages: past it, a line is cut
|
|
12
|
+
# to what is left, and the next ones are only counted. A request, or a stream held open,
|
|
13
|
+
# that logs without end no longer grows its thread's buffer without bound.
|
|
14
|
+
class Buffer < Array
|
|
15
|
+
attr_reader :bytes, :dropped
|
|
16
|
+
|
|
17
|
+
def initialize(limit)
|
|
18
|
+
super()
|
|
19
|
+
@limit = limit
|
|
20
|
+
@bytes = 0
|
|
21
|
+
@dropped = 0
|
|
22
|
+
@truncated = false
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def truncated?
|
|
26
|
+
@truncated
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def record(entry)
|
|
30
|
+
message = entry[:message]
|
|
31
|
+
if @limit
|
|
32
|
+
room = @limit - @bytes
|
|
33
|
+
if room <= 0
|
|
34
|
+
@dropped += 1
|
|
35
|
+
@truncated = true
|
|
36
|
+
return
|
|
37
|
+
end
|
|
38
|
+
if message.bytesize > room
|
|
39
|
+
message = Redaction.cut_bytes(message, room).scrub("")
|
|
40
|
+
@truncated = true
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
@bytes += message.bytesize
|
|
44
|
+
self << entry.merge(message: message)
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
11
48
|
# Records into the thread's buffer, which only exists while a LogCollector of this thread
|
|
12
49
|
# is subscribed: lines logged by other threads, or after release, are not kept. A logger
|
|
13
50
|
# bound to one collector's buffer records only while that buffer is the thread's current
|
|
@@ -21,17 +58,20 @@ module Profiler
|
|
|
21
58
|
def add(severity, message = nil, progname = nil)
|
|
22
59
|
logs = Thread.current[:profiler_logs]
|
|
23
60
|
return true unless logs
|
|
61
|
+
# The profiler's own messages (Profiler.log) are not the application's.
|
|
62
|
+
return true if Thread.current[:profiler_logging]
|
|
24
63
|
return true if @buffer && !logs.equal?(@buffer)
|
|
25
64
|
|
|
26
65
|
msg = message || progname
|
|
27
66
|
msg = yield if block_given? && msg.nil?
|
|
28
67
|
return true if msg.nil?
|
|
29
68
|
|
|
30
|
-
|
|
69
|
+
entry = {
|
|
31
70
|
level: SEVERITY_LABELS[severity] || "UNKNOWN",
|
|
32
71
|
message: msg.to_s.strip,
|
|
33
72
|
timestamp: Time.now.iso8601(3)
|
|
34
73
|
}
|
|
74
|
+
logs.respond_to?(:record) ? logs.record(entry) : logs << entry
|
|
35
75
|
true
|
|
36
76
|
end
|
|
37
77
|
|
|
@@ -78,7 +118,7 @@ module Profiler
|
|
|
78
118
|
end
|
|
79
119
|
|
|
80
120
|
def subscribe
|
|
81
|
-
@logs = claim_thread_slot(:profiler_logs,
|
|
121
|
+
@logs = claim_thread_slot(:profiler_logs, Buffer.new(Profiler.configuration.max_captured_log_bytes))
|
|
82
122
|
|
|
83
123
|
if defined?(Rails) && Rails.logger
|
|
84
124
|
if Rails.logger.respond_to?(:broadcast_to)
|
|
@@ -90,7 +130,7 @@ module Profiler
|
|
|
90
130
|
end
|
|
91
131
|
end
|
|
92
132
|
rescue => e
|
|
93
|
-
|
|
133
|
+
Profiler.log_error("LogCollector: subscribe failed", e)
|
|
94
134
|
end
|
|
95
135
|
|
|
96
136
|
# Collect reads only what the collector gathered itself.
|
|
@@ -113,12 +153,21 @@ module Profiler
|
|
|
113
153
|
|
|
114
154
|
errors = logs.count { |l| %w[ERROR FATAL].include?(l[:level]) }
|
|
115
155
|
warnings = logs.count { |l| l[:level] == "WARN" }
|
|
156
|
+
dropped = logs.respond_to?(:dropped) ? logs.dropped : 0
|
|
157
|
+
truncated = logs.respond_to?(:truncated?) && logs.truncated?
|
|
158
|
+
lines = logs.to_a
|
|
159
|
+
if dropped.positive?
|
|
160
|
+
lines += [{ level: "WARN", timestamp: Time.now.iso8601(3),
|
|
161
|
+
message: "[Profiler] #{dropped} more log lines were not recorded (max_captured_log_bytes)" }]
|
|
162
|
+
end
|
|
116
163
|
|
|
117
164
|
store_data({
|
|
118
|
-
count: logs.size,
|
|
165
|
+
count: logs.size + dropped,
|
|
119
166
|
errors: errors,
|
|
120
167
|
warnings: warnings,
|
|
121
|
-
logs:
|
|
168
|
+
logs: lines,
|
|
169
|
+
truncated: truncated,
|
|
170
|
+
dropped: dropped
|
|
122
171
|
})
|
|
123
172
|
end
|
|
124
173
|
|
|
@@ -213,7 +213,8 @@ module Profiler
|
|
|
213
213
|
serialize_assign(filtered)
|
|
214
214
|
end
|
|
215
215
|
end
|
|
216
|
-
rescue StandardError
|
|
216
|
+
rescue StandardError => e
|
|
217
|
+
Profiler.log_error_once(:mailer_assigns, "MailerCollector: could not read the mailer's assigns", e)
|
|
217
218
|
{}
|
|
218
219
|
end
|
|
219
220
|
|
|
@@ -232,7 +233,8 @@ module Profiler
|
|
|
232
233
|
text = Profiler::Redaction.truncate(mail.body.decoded, MAX_BODY_SIZE, "")
|
|
233
234
|
end
|
|
234
235
|
[html.nil? || html.empty? ? nil : html, text.nil? || text.empty? ? nil : text]
|
|
235
|
-
rescue StandardError
|
|
236
|
+
rescue StandardError => e
|
|
237
|
+
Profiler.log_error_once(:mailer_body, "MailerCollector: could not read the mail's body", e)
|
|
236
238
|
[nil, nil]
|
|
237
239
|
end
|
|
238
240
|
|
|
@@ -250,7 +252,8 @@ module Profiler
|
|
|
250
252
|
return nil unless mail.is_a?(String)
|
|
251
253
|
|
|
252
254
|
Mail.new(mail)
|
|
253
|
-
rescue StandardError
|
|
255
|
+
rescue StandardError => e
|
|
256
|
+
Profiler.log_error_once(:mailer_parse, "MailerCollector: could not parse the mail", e)
|
|
254
257
|
nil
|
|
255
258
|
end
|
|
256
259
|
|
|
@@ -291,7 +294,8 @@ module Profiler
|
|
|
291
294
|
"filename" => att.filename.to_s,
|
|
292
295
|
"size" => att.body.decoded.bytesize
|
|
293
296
|
}
|
|
294
|
-
rescue StandardError
|
|
297
|
+
rescue StandardError => e
|
|
298
|
+
Profiler.log_error_once(:mailer_attachment, "MailerCollector: could not read an attachment", e)
|
|
295
299
|
{ "filename" => att.filename.to_s, "size" => 0 }
|
|
296
300
|
end
|
|
297
301
|
end
|
|
@@ -33,6 +33,7 @@ module Profiler
|
|
|
33
33
|
allocated_objects: @profile.allocated_objects,
|
|
34
34
|
memory: @profile.memory, # deprecated, see Profile#memory
|
|
35
35
|
params: @profile.params,
|
|
36
|
+
params_truncated: @profile.params_truncated || false,
|
|
36
37
|
headers: @profile.headers,
|
|
37
38
|
response_headers: @profile.response_headers,
|
|
38
39
|
request_body: @profile.request_body,
|
|
@@ -103,7 +104,11 @@ module Profiler
|
|
|
103
104
|
route_params: Profiler::Redaction.filter_hash(route_params),
|
|
104
105
|
controller_action: controller_action
|
|
105
106
|
}
|
|
106
|
-
rescue StandardError
|
|
107
|
+
rescue StandardError => e
|
|
108
|
+
# A path no route matches (a 404) has no route to show.
|
|
109
|
+
unless defined?(ActionController::RoutingError) && e.is_a?(ActionController::RoutingError)
|
|
110
|
+
Profiler.log_error_once(:request_route, "RequestCollector: could not read the matched route", e)
|
|
111
|
+
end
|
|
107
112
|
{}
|
|
108
113
|
end
|
|
109
114
|
|
|
@@ -219,7 +219,7 @@ module Profiler
|
|
|
219
219
|
handler.call(name, started, finished, id, payload)
|
|
220
220
|
rescue StandardError => e
|
|
221
221
|
# Never into the application's query, nor at the expense of the other collectors.
|
|
222
|
-
|
|
222
|
+
Profiler.log_error("collector failed on #{event}", e)
|
|
223
223
|
end
|
|
224
224
|
end
|
|
225
225
|
end
|
|
@@ -29,13 +29,19 @@ module Profiler
|
|
|
29
29
|
|
|
30
30
|
# Options whose default depends on the Rails environment: the railtie sets them only when
|
|
31
31
|
# the application has not assigned them itself (see #default).
|
|
32
|
-
RAILS_DEFAULTED = %i[enabled storage track_tests].freeze
|
|
32
|
+
RAILS_DEFAULTED = %i[enabled storage track_tests max_profiles].freeze
|
|
33
|
+
|
|
34
|
+
BACKEND_LOCK = Mutex.new
|
|
35
|
+
|
|
36
|
+
# The hosts earlier versions always left out of the outbound HTTP tab. Add them back with
|
|
37
|
+
# config.http_skip_hosts += Profiler::Configuration::LOCAL_HTTP_HOSTS.
|
|
38
|
+
LOCAL_HTTP_HOSTS = [/\A127\.0\.0\.1\z/, /\Alocalhost\z/i, /\A::1\z/].freeze
|
|
33
39
|
|
|
34
40
|
attr_accessor :storage_options, :collectors,
|
|
35
41
|
:skip_paths, :slow_query_threshold, :max_queries_warning, :sql_backtrace,
|
|
36
42
|
:track_memory, :allocated_objects_warning_threshold,
|
|
37
43
|
:mcp_enabled, :mcp_transport, :mcp_port,
|
|
38
|
-
:authorization_mode, :
|
|
44
|
+
:authorization_mode, :extension_cors_enabled,
|
|
39
45
|
:cors_allowed_origins, :api_forgery_protection, :frame_ancestors,
|
|
40
46
|
:track_ajax, :ajax_skip_paths,
|
|
41
47
|
:track_http, :slow_http_threshold, :http_skip_hosts, :http_backtrace_depth,
|
|
@@ -44,14 +50,14 @@ module Profiler
|
|
|
44
50
|
:apply_env_overrides_when_disabled, :restrict_storage_permissions,
|
|
45
51
|
:test_runner_allow_undiscovered_files,
|
|
46
52
|
:track_mailers, :capture_mail_body, :sanitize_mailer_recipients, :mailer_skip_actions,
|
|
47
|
-
:compress_bodies, :compress_body_threshold, :max_captured_body_bytes,
|
|
53
|
+
:compress_bodies, :compress_body_threshold, :max_captured_body_bytes, :max_captured_log_bytes,
|
|
48
54
|
:redact_sensitive_data, :filter_parameters, :env_allowlist,
|
|
49
55
|
:name, :master_url, :self_url,
|
|
50
56
|
:cluster_heartbeat_interval, :cluster_offline_threshold,
|
|
51
57
|
:cluster_master, :cluster_secret, :cluster_require_secret,
|
|
52
|
-
:cluster_allow_insecure_http
|
|
58
|
+
:cluster_allow_insecure_http, :logger
|
|
53
59
|
|
|
54
|
-
attr_reader :authorize_block, :enabled, :track_tests
|
|
60
|
+
attr_reader :authorize_block, :enabled, :track_tests, :max_profiles
|
|
55
61
|
|
|
56
62
|
def initialize
|
|
57
63
|
@assigned = []
|
|
@@ -87,6 +93,8 @@ module Profiler
|
|
|
87
93
|
@ajax_skip_paths = [/^\/_profiler/]
|
|
88
94
|
@track_http = true
|
|
89
95
|
@slow_http_threshold = 500 # milliseconds
|
|
96
|
+
# Every host whose outbound calls are left out (Strings and Regexps matched against the host).
|
|
97
|
+
# The profiler's own calls (cluster, slave proxy) are never recorded, whatever this says.
|
|
90
98
|
@http_skip_hosts = []
|
|
91
99
|
@http_backtrace_depth = 40
|
|
92
100
|
@track_jobs = true
|
|
@@ -105,6 +113,8 @@ module Profiler
|
|
|
105
113
|
# The request and response bodies kept in a profile stop at this many bytes; the
|
|
106
114
|
# application still reads and sends all of them. nil keeps whole bodies.
|
|
107
115
|
@max_captured_body_bytes = 256 * 1024
|
|
116
|
+
# What a profile keeps of the lines its request logs, in bytes. nil keeps every line.
|
|
117
|
+
@max_captured_log_bytes = 1024 * 1024
|
|
108
118
|
@redact_sensitive_data = true
|
|
109
119
|
@filter_parameters = DEFAULT_FILTER_PARAMETERS.dup
|
|
110
120
|
@env_allowlist = DEFAULT_ENV_ALLOWLIST.dup
|
|
@@ -123,6 +133,9 @@ module Profiler
|
|
|
123
133
|
@cluster_require_secret = true
|
|
124
134
|
@cluster_allowed_slave_urls = []
|
|
125
135
|
@cluster_allow_insecure_http = false
|
|
136
|
+
# Where the profiler's own errors go (Profiler.log): Rails.logger when nil, or $stderr when
|
|
137
|
+
# there is none.
|
|
138
|
+
@logger = nil
|
|
126
139
|
end
|
|
127
140
|
|
|
128
141
|
# Always a Pathname, whether set from a String or left to its default.
|
|
@@ -144,8 +157,8 @@ module Profiler
|
|
|
144
157
|
end
|
|
145
158
|
|
|
146
159
|
def memory_warning_threshold=(bytes)
|
|
147
|
-
|
|
148
|
-
|
|
160
|
+
Profiler.log_warn("memory_warning_threshold is deprecated, set allocated_objects_warning_threshold " \
|
|
161
|
+
"(a number of objects) instead")
|
|
149
162
|
@allocated_objects_warning_threshold = bytes && bytes / AllocationCounter::LEGACY_BYTES_PER_OBJECT
|
|
150
163
|
end
|
|
151
164
|
|
|
@@ -181,6 +194,13 @@ module Profiler
|
|
|
181
194
|
@track_tests = value
|
|
182
195
|
end
|
|
183
196
|
|
|
197
|
+
# How many profiles a store keeps, the oldest evicted first; nil for no cap on the count (the
|
|
198
|
+
# memory store still keeps 100). The railtie leaves it nil in the test environment.
|
|
199
|
+
def max_profiles=(value)
|
|
200
|
+
@assigned |= [:max_profiles]
|
|
201
|
+
@max_profiles = value
|
|
202
|
+
end
|
|
203
|
+
|
|
184
204
|
# Sets one of RAILS_DEFAULTED to value unless the application assigned it, in
|
|
185
205
|
# config/application.rb for instance, which runs before the railtie's initializers.
|
|
186
206
|
def default(option, value)
|
|
@@ -246,8 +266,10 @@ module Profiler
|
|
|
246
266
|
@storage_backend = nil
|
|
247
267
|
end
|
|
248
268
|
|
|
269
|
+
# Under a lock of its own, for a caller that asks the configuration directly; Profiler.storage
|
|
270
|
+
# takes it inside its own, always in that order.
|
|
249
271
|
def storage_backend
|
|
250
|
-
@storage_backend ||= build_storage_backend
|
|
272
|
+
@storage_backend || BACKEND_LOCK.synchronize { @storage_backend ||= build_storage_backend }
|
|
251
273
|
end
|
|
252
274
|
|
|
253
275
|
private
|
|
@@ -76,7 +76,7 @@ module Profiler
|
|
|
76
76
|
collector.collect if collector.respond_to?(:collect)
|
|
77
77
|
profile.add_collector_metadata(collector)
|
|
78
78
|
rescue => e
|
|
79
|
-
|
|
79
|
+
Profiler.log_error("ConsoleProfiler: collector #{collector.class} failed", e)
|
|
80
80
|
end
|
|
81
81
|
end
|
|
82
82
|
|
data/lib/profiler/engine.rb
CHANGED
|
@@ -9,8 +9,9 @@ module Profiler
|
|
|
9
9
|
config.profiler = ActiveSupport::OrderedOptions.new
|
|
10
10
|
|
|
11
11
|
initializer "profiler.helpers" do
|
|
12
|
+
# The hook runs for ActionController::API as well, which has no view helpers.
|
|
12
13
|
ActiveSupport.on_load(:action_controller) do
|
|
13
|
-
helper Profiler::Engine.helpers
|
|
14
|
+
helper Profiler::Engine.helpers if respond_to?(:helper)
|
|
14
15
|
end
|
|
15
16
|
end
|
|
16
17
|
end
|
|
@@ -108,10 +108,10 @@ module Profiler
|
|
|
108
108
|
|
|
109
109
|
@boot_warning_logged = true
|
|
110
110
|
noun, verb = count == 1 ? %w[override was] : %w[overrides were]
|
|
111
|
-
|
|
112
|
-
|
|
111
|
+
Profiler.log_warn("#{count} persisted environment #{noun} in #{override_file_path} " \
|
|
112
|
+
"#{verb} not applied: #{BLOCKED_REASONS.fetch(reason)}.", logger: logger)
|
|
113
113
|
rescue => e
|
|
114
|
-
|
|
114
|
+
Profiler.log_error("EnvOverrideStore: failed to check overrides at boot", e)
|
|
115
115
|
end
|
|
116
116
|
|
|
117
117
|
# Set by the test runner in the environment of the test process, to which it already gave
|
|
@@ -158,7 +158,7 @@ module Profiler
|
|
|
158
158
|
end
|
|
159
159
|
end
|
|
160
160
|
rescue => e
|
|
161
|
-
|
|
161
|
+
Profiler.log_error("EnvOverrideStore: failed to apply overrides", e)
|
|
162
162
|
end
|
|
163
163
|
|
|
164
164
|
# Returns active overrides (excludes RESTORE entries, already being reverted)
|
|
@@ -61,7 +61,11 @@ module Profiler
|
|
|
61
61
|
result = if format == "json"
|
|
62
62
|
# PostgreSQL / MySQL return JSON in rows[0]["QUERY PLAN"] or rows[0]["EXPLAIN"]
|
|
63
63
|
raw = rows.first&.values&.first.to_s
|
|
64
|
-
|
|
64
|
+
begin
|
|
65
|
+
JSON.parse(raw)
|
|
66
|
+
rescue JSON::ParserError
|
|
67
|
+
raw
|
|
68
|
+
end
|
|
65
69
|
else
|
|
66
70
|
rows.map { |r| r.values.join("\t") }.join("\n")
|
|
67
71
|
end
|
|
@@ -6,6 +6,7 @@ require "zlib"
|
|
|
6
6
|
require "stringio"
|
|
7
7
|
require "securerandom"
|
|
8
8
|
require_relative "../redaction"
|
|
9
|
+
require_relative "../configuration"
|
|
9
10
|
|
|
10
11
|
module Profiler
|
|
11
12
|
module Instrumentation
|
|
@@ -17,36 +18,19 @@ module Profiler
|
|
|
17
18
|
# Re-entrancy guard: Net::HTTP#request calls itself recursively when
|
|
18
19
|
# the connection isn't started yet. Only record the outermost call.
|
|
19
20
|
return super if Thread.current[:profiler_http_recording]
|
|
21
|
+
# The profiler's own calls (Profiler.untracked_http).
|
|
22
|
+
return super if Thread.current[:profiler_http_untracked]
|
|
20
23
|
|
|
21
24
|
host = address.to_s
|
|
22
|
-
return super if NetHttpInstrumentation.skip_host?(host)
|
|
25
|
+
return super if NetHttpInstrumentation.skip_host?(host, port)
|
|
23
26
|
|
|
24
27
|
url = Redaction.filter_url(build_url(host, port, req.path, use_ssl?))
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
# to req inside super via req.set_body_internal(body)
|
|
28
|
-
req_body = body.to_s if req_body.empty? && body
|
|
29
|
-
# Fallback: libraries like RestClient set body_stream instead of body
|
|
30
|
-
# and pass nil as the 2nd argument to Net::HTTP#request.
|
|
31
|
-
# Read the stream content, then restore it so the actual request works.
|
|
32
|
-
# RestClient::Payload doesn't implement #rewind, so we fall back to
|
|
33
|
-
# replacing body_stream with a fresh StringIO.
|
|
34
|
-
if req_body.empty? && req.body_stream
|
|
35
|
-
begin
|
|
36
|
-
stream = req.body_stream
|
|
37
|
-
req_body = stream.read.to_s
|
|
38
|
-
if stream.respond_to?(:rewind)
|
|
39
|
-
stream.rewind
|
|
40
|
-
else
|
|
41
|
-
req.body_stream = StringIO.new(req_body)
|
|
42
|
-
end
|
|
43
|
-
rescue
|
|
44
|
-
req_body = ""
|
|
45
|
-
end
|
|
46
|
-
end
|
|
28
|
+
limit = Profiler.configuration.max_captured_body_bytes
|
|
29
|
+
captured = NetHttpInstrumentation.capture_request_body(req, body, limit)
|
|
47
30
|
req_headers = Redaction.filter_headers(req.to_hash.transform_values { |v| v.join(", ") })
|
|
48
31
|
req_content_type = req["content-type"].to_s
|
|
49
|
-
|
|
32
|
+
req_body = captured[:content]
|
|
33
|
+
processed_req = req_body.nil? || req_body.empty? ? { body: nil, encoding: "text" } : NetHttpInstrumentation.process_body(req_body, req_content_type)
|
|
50
34
|
|
|
51
35
|
request_id = SecureRandom.hex(8)
|
|
52
36
|
started_at = Time.now.iso8601(3)
|
|
@@ -63,7 +47,10 @@ module Profiler
|
|
|
63
47
|
request_headers: req_headers,
|
|
64
48
|
request_body: processed_req[:body],
|
|
65
49
|
request_body_encoding: processed_req[:encoding],
|
|
66
|
-
request_size:
|
|
50
|
+
request_size: captured[:size],
|
|
51
|
+
request_size_is_minimum: captured[:size_is_minimum],
|
|
52
|
+
request_body_truncated: captured[:truncated],
|
|
53
|
+
request_body_not_captured: captured[:not_captured],
|
|
67
54
|
backtrace: NetHttpInstrumentation.extract_backtrace
|
|
68
55
|
)
|
|
69
56
|
|
|
@@ -74,7 +61,9 @@ module Profiler
|
|
|
74
61
|
duration = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - t0) * 1000).round(2)
|
|
75
62
|
resp_body_raw = response.body.to_s
|
|
76
63
|
resp_content_encoding = response["content-encoding"].to_s.strip.downcase
|
|
77
|
-
resp_body = NetHttpInstrumentation.decompress_body(resp_body_raw, resp_content_encoding)
|
|
64
|
+
resp_body = NetHttpInstrumentation.decompress_body(resp_body_raw, resp_content_encoding, limit)
|
|
65
|
+
resp_truncated = limit ? resp_body.bytesize > limit : false
|
|
66
|
+
resp_body = Redaction.cut_bytes(resp_body, limit) if resp_truncated
|
|
78
67
|
resp_content_type = response["content-type"].to_s
|
|
79
68
|
processed_resp = NetHttpInstrumentation.process_body(resp_body, resp_content_type)
|
|
80
69
|
|
|
@@ -87,7 +76,8 @@ module Profiler
|
|
|
87
76
|
response_headers: Redaction.filter_headers(response.to_hash.transform_values { |v| v.join(", ") }),
|
|
88
77
|
response_body: processed_resp[:body],
|
|
89
78
|
response_body_encoding: processed_resp[:encoding],
|
|
90
|
-
response_size: resp_body_raw.bytesize
|
|
79
|
+
response_size: resp_body_raw.bytesize,
|
|
80
|
+
response_body_truncated: resp_truncated
|
|
91
81
|
)
|
|
92
82
|
|
|
93
83
|
fg = Thread.current[:profiler_flamegraph_collector]
|
|
@@ -113,7 +103,11 @@ module Profiler
|
|
|
113
103
|
end
|
|
114
104
|
end
|
|
115
105
|
|
|
106
|
+
# Deprecated, as it was: the hosts earlier versions always left out, kept for code that read
|
|
107
|
+
# them. They are left out only when listed in config.http_skip_hosts now
|
|
108
|
+
# (Profiler::Configuration::LOCAL_HTTP_HOSTS).
|
|
116
109
|
SKIP_HOSTS = %w[127.0.0.1 localhost ::1].freeze
|
|
110
|
+
deprecate_constant :SKIP_HOSTS
|
|
117
111
|
|
|
118
112
|
TEXT_CONTENT_TYPES = /\A(text\/|application\/(json|xml|xhtml|javascript|x-www-form-urlencoded)|image\/svg)/i
|
|
119
113
|
BINARY_CONTENT_TYPES = /\A(image\/|application\/pdf|application\/octet-stream|application\/zip|audio\/|video\/)/i
|
|
@@ -124,26 +118,126 @@ module Profiler
|
|
|
124
118
|
@installed = true
|
|
125
119
|
end
|
|
126
120
|
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
121
|
+
# The hosts of http_skip_hosts, and the cluster's master (host and port): a slave's calls to
|
|
122
|
+
# it are the profiler's, whichever code makes them.
|
|
123
|
+
def self.skip_host?(host, port = nil)
|
|
124
|
+
config = Profiler.configuration
|
|
125
|
+
return true if config.http_skip_hosts.any? { |pattern| host.match?(pattern) }
|
|
126
|
+
|
|
127
|
+
master = master_address(config.master_url)
|
|
128
|
+
!master.nil? && master[0].casecmp?(host) && (port.nil? || master[1] == port)
|
|
130
129
|
end
|
|
131
130
|
|
|
132
|
-
def self.
|
|
133
|
-
return
|
|
131
|
+
def self.master_address(url)
|
|
132
|
+
return nil if url.nil? || url.to_s.empty?
|
|
134
133
|
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
134
|
+
uri = URI(url.to_s)
|
|
135
|
+
uri.host ? [uri.host.delete_prefix("[").delete_suffix("]"), uri.port] : nil
|
|
136
|
+
rescue URI::Error
|
|
137
|
+
nil
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
# What a profile keeps of the body Net::HTTP is about to send: at most +limit+ bytes, and
|
|
141
|
+
# the whole size when known (size_is_minimum when only a lower bound is). A body_stream is
|
|
142
|
+
# read only when it can be put back where it was (a StringIO, a file), and no further than
|
|
143
|
+
# its Content-Length; a pipe, a socket or an object without pos (a multipart payload) is
|
|
144
|
+
# never touched, as reading it would leave nothing to send.
|
|
145
|
+
def self.capture_request_body(req, body, limit)
|
|
146
|
+
content = req.body
|
|
147
|
+
content = body if (content.nil? || content.empty?) && body
|
|
148
|
+
unless content.nil? || content.to_s.empty?
|
|
149
|
+
content = content.to_s
|
|
150
|
+
truncated = limit ? content.bytesize > limit : false
|
|
151
|
+
return { content: truncated ? Redaction.cut_bytes(content, limit) : content,
|
|
152
|
+
size: content.bytesize, size_is_minimum: false, truncated: truncated, not_captured: false }
|
|
142
153
|
end
|
|
143
|
-
|
|
154
|
+
|
|
155
|
+
stream = req.body_stream
|
|
156
|
+
return { content: nil, size: 0, size_is_minimum: false, truncated: false, not_captured: false } unless stream
|
|
157
|
+
|
|
158
|
+
declared = req["content-length"].to_s
|
|
159
|
+
declared = declared.match?(/\A\d+\z/) ? declared.to_i : nil
|
|
160
|
+
unless rewindable?(stream)
|
|
161
|
+
return { content: nil, size: declared, size_is_minimum: false, truncated: false, not_captured: true }
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
position = stream.pos
|
|
165
|
+
total = declared || remaining_size(stream, position)
|
|
166
|
+
want = [limit && limit + 1, total].compact.min
|
|
167
|
+
begin
|
|
168
|
+
read = (want ? stream.read(want) : stream.read).to_s
|
|
169
|
+
ensure
|
|
170
|
+
stream.pos = position
|
|
171
|
+
end
|
|
172
|
+
# Bytes as they were sent: labelled UTF-8, the encoding of text on the wire, for the text
|
|
173
|
+
# filter; a binary type is kept as bytes whatever the label.
|
|
174
|
+
read = read.dup.force_encoding(Encoding::UTF_8)
|
|
175
|
+
cut = limit ? read.bytesize > limit : false
|
|
176
|
+
content = cut ? Redaction.cut_bytes(read, limit) : read
|
|
177
|
+
size = total || read.bytesize
|
|
178
|
+
{ content: content, size: size, size_is_minimum: total.nil? && cut,
|
|
179
|
+
truncated: content.bytesize < size, not_captured: false }
|
|
180
|
+
rescue IOError, SystemCallError => e
|
|
181
|
+
Profiler.log_error_once(:net_http_body_stream, "NetHttpInstrumentation: could not read a request body_stream", e)
|
|
182
|
+
{ content: nil, size: declared, size_is_minimum: false, truncated: false, not_captured: true }
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
# What is left to read of a stream that knows its size (a StringIO, a file), else nil.
|
|
186
|
+
def self.remaining_size(stream, position)
|
|
187
|
+
size = stream.respond_to?(:size) ? stream.size : nil
|
|
188
|
+
size.is_a?(Integer) ? [size - position, 0].max : nil
|
|
189
|
+
rescue IOError, SystemCallError
|
|
190
|
+
nil
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
def self.rewindable?(stream)
|
|
194
|
+
return false unless stream.respond_to?(:pos) && stream.respond_to?(:pos=) && stream.respond_to?(:read)
|
|
195
|
+
return stream.stat.file? if stream.respond_to?(:stat)
|
|
196
|
+
|
|
197
|
+
true
|
|
198
|
+
rescue IOError, SystemCallError
|
|
199
|
+
false
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
# Inflated up to the limit, give or take one buffer of the inflater (16 KB): a small
|
|
203
|
+
# compressed answer can stand for a huge text, which a profile never keeps whole. Labelled
|
|
204
|
+
# UTF-8, as the text it is when its type is a text one.
|
|
205
|
+
def self.decompress_body(body, content_encoding, limit = nil)
|
|
206
|
+
return body if content_encoding.empty? || body.nil? || body.empty?
|
|
207
|
+
|
|
208
|
+
inflated =
|
|
209
|
+
case content_encoding
|
|
210
|
+
when "gzip", "x-gzip" then inflate_capped(body, limit, Zlib::MAX_WBITS + 16)
|
|
211
|
+
when "deflate" then inflate_capped(body, limit, Zlib::MAX_WBITS)
|
|
212
|
+
else return body
|
|
213
|
+
end
|
|
214
|
+
inflated.force_encoding(Encoding::UTF_8)
|
|
215
|
+
rescue Zlib::Error
|
|
144
216
|
body
|
|
145
217
|
end
|
|
146
218
|
|
|
219
|
+
# The input goes in by small slices, and the output is taken buffer by buffer: the
|
|
220
|
+
# inflating stops as soon as the limit is passed.
|
|
221
|
+
def self.inflate_capped(body, limit, window_bits)
|
|
222
|
+
inflater = Zlib::Inflate.new(window_bits)
|
|
223
|
+
return inflater.inflate(body) unless limit
|
|
224
|
+
|
|
225
|
+
out = +""
|
|
226
|
+
offset = 0
|
|
227
|
+
catch(:full) do
|
|
228
|
+
while offset < body.bytesize && !inflater.finished?
|
|
229
|
+
inflater.inflate(body.byteslice(offset, 1024)) do |chunk|
|
|
230
|
+
out << chunk
|
|
231
|
+
throw :full if out.bytesize > limit
|
|
232
|
+
end
|
|
233
|
+
offset += 1024
|
|
234
|
+
end
|
|
235
|
+
end
|
|
236
|
+
out
|
|
237
|
+
ensure
|
|
238
|
+
inflater&.close
|
|
239
|
+
end
|
|
240
|
+
|
|
147
241
|
def self.process_body(body, content_type)
|
|
148
242
|
return { body: nil, encoding: "text" } if body.nil? || body.empty?
|
|
149
243
|
|
|
@@ -153,8 +247,12 @@ module Profiler
|
|
|
153
247
|
# Masked on the raw bytes, before the encoding hides them from any later search.
|
|
154
248
|
{ body: Base64.strict_encode64(Redaction.hide_credentials(body.b)), encoding: "base64" }
|
|
155
249
|
else
|
|
250
|
+
# Bytes are read as UTF-8, as a text body on the wire is; what is not valid UTF-8 shows
|
|
251
|
+
# as "?".
|
|
252
|
+
body = body.dup.force_encoding(Encoding::UTF_8) if body.encoding == Encoding::BINARY
|
|
156
253
|
text = Redaction.filter_body(body, content_type)
|
|
157
254
|
.encode("UTF-8", invalid: :replace, undef: :replace, replace: "?")
|
|
255
|
+
text = text.scrub("?") unless text.valid_encoding?
|
|
158
256
|
{ body: text, encoding: "text" }
|
|
159
257
|
end
|
|
160
258
|
end
|
|
@@ -105,7 +105,7 @@ module Profiler
|
|
|
105
105
|
collector.collect if collector.respond_to?(:collect)
|
|
106
106
|
profile.add_collector_metadata(collector)
|
|
107
107
|
rescue => e
|
|
108
|
-
|
|
108
|
+
Profiler.log_error("JobProfiler: collector #{collector.class} failed", e)
|
|
109
109
|
end
|
|
110
110
|
end
|
|
111
111
|
|
|
@@ -57,18 +57,14 @@ module Profiler
|
|
|
57
57
|
@warned = true
|
|
58
58
|
end
|
|
59
59
|
|
|
60
|
-
message = "
|
|
60
|
+
message = "Request refused or not profiled by authorization_mode :allow_local: #{reason}. " \
|
|
61
61
|
"The profiler only serves and captures requests made from this machine. " \
|
|
62
62
|
"If the application runs in Docker or behind a remote proxy, either set " \
|
|
63
63
|
"config.authorization_mode = :allow_authorized with a config.authorize_with block " \
|
|
64
64
|
"that admits your network (see the Access control section of the README), or set " \
|
|
65
65
|
"config.authorization_mode = :allow_all, which offers no protection at all. " \
|
|
66
66
|
"This message is logged once per process."
|
|
67
|
-
|
|
68
|
-
Rails.logger.warn(message)
|
|
69
|
-
else
|
|
70
|
-
warn(message)
|
|
71
|
-
end
|
|
67
|
+
Profiler.log_warn(message)
|
|
72
68
|
end
|
|
73
69
|
|
|
74
70
|
def reset_warning!
|
|
@@ -26,7 +26,8 @@ module Profiler
|
|
|
26
26
|
|
|
27
27
|
dir = Storage::PrivateFiles.tmp_dir(DIR_NAME, token)
|
|
28
28
|
Storage::PrivateFiles.write(dir.join(name), content)
|
|
29
|
-
rescue Errno::EACCES, Errno::EROFS
|
|
29
|
+
rescue Errno::EACCES, Errno::EROFS => e
|
|
30
|
+
Profiler.log_error_once(:mcp_file_cache, "MCP: could not cache a body under tmp_path", e)
|
|
30
31
|
nil
|
|
31
32
|
end
|
|
32
33
|
|
|
@@ -5,7 +5,7 @@ module Profiler
|
|
|
5
5
|
module Resources
|
|
6
6
|
class FailingTests
|
|
7
7
|
def self.call
|
|
8
|
-
profiles = Profiler.storage.list(limit: 500)
|
|
8
|
+
profiles = Profiler.storage.list(limit: 500, type: "test")
|
|
9
9
|
failing = profiles.select do |p|
|
|
10
10
|
next false unless p.profile_type == "test"
|
|
11
11
|
test_data = p.collector_data("test") || {}
|
|
@@ -5,8 +5,8 @@ module Profiler
|
|
|
5
5
|
module Resources
|
|
6
6
|
class RecentConsole
|
|
7
7
|
def self.call
|
|
8
|
-
profiles = Profiler.storage.list(limit:
|
|
9
|
-
consoles = profiles.select { |p| p.profile_type == "console" }
|
|
8
|
+
profiles = Profiler.storage.list(limit: 50, type: "console")
|
|
9
|
+
consoles = profiles.select { |p| p.profile_type == "console" }
|
|
10
10
|
|
|
11
11
|
data = consoles.map do |profile|
|
|
12
12
|
console_data = profile.collector_data("console") || {}
|
|
@@ -5,8 +5,8 @@ module Profiler
|
|
|
5
5
|
module Resources
|
|
6
6
|
class RecentJobs
|
|
7
7
|
def self.call
|
|
8
|
-
profiles = Profiler.storage.list(limit:
|
|
9
|
-
jobs = profiles.select { |p| p.profile_type == "job" }
|
|
8
|
+
profiles = Profiler.storage.list(limit: 50, type: "job")
|
|
9
|
+
jobs = profiles.select { |p| p.profile_type == "job" }
|
|
10
10
|
|
|
11
11
|
data = jobs.map do |profile|
|
|
12
12
|
job_data = profile.collector_data("job") || {}
|