rails-profiler 0.31.4 → 0.31.6
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 +103 -0
- data/app/assets/builds/profiler.js +9 -1
- 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 +3 -3
- data/lib/profiler/cluster/master_client.rb +7 -15
- data/lib/profiler/cluster/security.rb +3 -3
- data/lib/profiler/cluster/slave_proxy.rb +7 -4
- 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 +15 -4
- 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/body_formatter.rb +2 -9
- data/lib/profiler/mcp/file_cache.rb +2 -1
- data/lib/profiler/mcp/server.rb +1 -1
- data/lib/profiler/mcp/tools/get_profile_detail.rb +5 -1
- data/lib/profiler/mcp/tools/get_profile_http.rb +6 -1
- data/lib/profiler/mcp/tools/query_console_profiles.rb +5 -1
- data/lib/profiler/mcp/tools/query_jobs.rb +5 -1
- data/lib/profiler/mcp/tools/query_mailers.rb +5 -1
- data/lib/profiler/mcp/tools/query_profiles.rb +5 -1
- data/lib/profiler/mcp/tools/query_test_profiles.rb +5 -1
- data/lib/profiler/mcp/tools/run_tests.rb +4 -1
- data/lib/profiler/middleware/profiler_middleware.rb +22 -19
- data/lib/profiler/middleware/streamed_profile.rb +1 -1
- data/lib/profiler/middleware/toolbar_injector.rb +21 -2
- data/lib/profiler/models/profile.rb +84 -16
- data/lib/profiler/process_snapshot.rb +8 -3
- data/lib/profiler/railtie.rb +1 -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 +8 -3
- data/lib/profiler/storage/private_files.rb +3 -3
- data/lib/profiler/storage/redis_store.rb +2 -2
- data/lib/profiler/storage/sqlite_store.rb +3 -3
- data/lib/profiler/storage/summary.rb +8 -4
- data/lib/profiler/storage/unavailable.rb +1 -2
- data/lib/profiler/test_helpers/reporter.rb +1 -1
- 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 +126 -3
- metadata +1 -1
|
@@ -19,7 +19,7 @@ module Profiler
|
|
|
19
19
|
:response_body, :response_body_encoding,
|
|
20
20
|
:request_body_size, :request_body_truncated, :request_body_size_is_minimum,
|
|
21
21
|
:response_body_size, :response_body_truncated, :response_body_size_is_minimum,
|
|
22
|
-
:collectors_released_after_seconds,
|
|
22
|
+
:collectors_released_after_seconds, :params_truncated,
|
|
23
23
|
:gem_version
|
|
24
24
|
|
|
25
25
|
def initialize(request = nil)
|
|
@@ -34,7 +34,8 @@ module Profiler
|
|
|
34
34
|
if request
|
|
35
35
|
@path = request.path
|
|
36
36
|
@method = request.request_method
|
|
37
|
-
@params = sanitize_params(request.params)
|
|
37
|
+
@params, @params_truncated = cap_params(sanitize_params(request.params),
|
|
38
|
+
Profiler.configuration.max_captured_body_bytes)
|
|
38
39
|
@headers = extract_headers(request.env)
|
|
39
40
|
end
|
|
40
41
|
end
|
|
@@ -116,9 +117,15 @@ module Profiler
|
|
|
116
117
|
end
|
|
117
118
|
private :collector_metadata
|
|
118
119
|
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
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
|
|
122
129
|
|
|
123
130
|
{
|
|
124
131
|
profile_type: @profile_type,
|
|
@@ -133,6 +140,7 @@ module Profiler
|
|
|
133
140
|
started_at: @started_at&.iso8601,
|
|
134
141
|
finished_at: @finished_at&.iso8601,
|
|
135
142
|
params: @params,
|
|
143
|
+
params_truncated: @params_truncated,
|
|
136
144
|
headers: @headers,
|
|
137
145
|
response_headers: @response_headers,
|
|
138
146
|
request_body: req_body,
|
|
@@ -153,6 +161,7 @@ module Profiler
|
|
|
153
161
|
}
|
|
154
162
|
end
|
|
155
163
|
|
|
164
|
+
# The stored form, as #to_h.
|
|
156
165
|
def to_json(*args)
|
|
157
166
|
to_h.to_json(*args)
|
|
158
167
|
end
|
|
@@ -178,6 +187,7 @@ module Profiler
|
|
|
178
187
|
profile.started_at = data[:started_at] ? Time.parse(data[:started_at]) : nil
|
|
179
188
|
profile.finished_at = data[:finished_at] ? Time.parse(data[:finished_at]) : nil
|
|
180
189
|
profile.params = data[:params]
|
|
190
|
+
profile.params_truncated = data[:params_truncated]
|
|
181
191
|
profile.headers = data[:headers]
|
|
182
192
|
profile.response_headers = data[:response_headers]
|
|
183
193
|
profile.request_body = data[:request_body]
|
|
@@ -209,6 +219,18 @@ module Profiler
|
|
|
209
219
|
profile
|
|
210
220
|
end
|
|
211
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
|
+
|
|
212
234
|
def self.deep_stringify_keys(obj)
|
|
213
235
|
case obj
|
|
214
236
|
when Hash
|
|
@@ -232,7 +254,8 @@ module Profiler
|
|
|
232
254
|
else
|
|
233
255
|
text = Redaction.filter_body(raw, content_type).encode("UTF-8", invalid: :replace, undef: :replace)
|
|
234
256
|
if compress_body?(text)
|
|
235
|
-
|
|
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" }
|
|
236
259
|
else
|
|
237
260
|
{ body: text, encoding: "text" }
|
|
238
261
|
end
|
|
@@ -250,22 +273,67 @@ module Profiler
|
|
|
250
273
|
text.bytesize > Profiler.configuration.compress_body_threshold
|
|
251
274
|
end
|
|
252
275
|
|
|
253
|
-
def decode_body(body, encoding)
|
|
254
|
-
return [body, encoding] unless encoding == "gzip+base64"
|
|
255
|
-
return [body, encoding] if body.nil? || body.empty?
|
|
256
|
-
|
|
257
|
-
decoded = Zlib::Inflate.inflate(Base64.strict_decode64(body))
|
|
258
|
-
[decoded, "text"]
|
|
259
|
-
rescue Zlib::Error, ArgumentError
|
|
260
|
-
[body, encoding]
|
|
261
|
-
end
|
|
262
|
-
|
|
263
276
|
def binary_content_type?(ct)
|
|
264
277
|
ct.to_s.match?(%r{image/(?!svg)|application/(?:pdf|octet-stream|zip)|audio/|video/})
|
|
265
278
|
end
|
|
266
279
|
|
|
267
280
|
LEGACY_FILTERED_PARAMS = %w[password password_confirmation token secret].freeze
|
|
268
281
|
|
|
282
|
+
# The key added to params cut at max_captured_body_bytes, so that the cut shows wherever
|
|
283
|
+
# the params are read.
|
|
284
|
+
PARAMS_TRUNCATED_KEY = "[profiler]"
|
|
285
|
+
|
|
286
|
+
# A copy of +params+ whose JSON stays within about +limit+ bytes: long strings are cut, and
|
|
287
|
+
# the entries past the limit left out. The application's own params are never touched.
|
|
288
|
+
# Returns the params and whether they were cut.
|
|
289
|
+
def cap_params(params, limit)
|
|
290
|
+
return [params, false] if limit.nil? || !params.is_a?(Hash)
|
|
291
|
+
|
|
292
|
+
budget = [limit]
|
|
293
|
+
cut = [false]
|
|
294
|
+
capped = cap_value(params, budget, cut)
|
|
295
|
+
return [capped, false] unless cut[0]
|
|
296
|
+
|
|
297
|
+
capped[PARAMS_TRUNCATED_KEY] = "params truncated at #{limit} bytes (max_captured_body_bytes)"
|
|
298
|
+
[capped, true]
|
|
299
|
+
end
|
|
300
|
+
|
|
301
|
+
def cap_value(value, budget, cut)
|
|
302
|
+
case value
|
|
303
|
+
when Hash
|
|
304
|
+
value.each_with_object({}) do |(key, item), out|
|
|
305
|
+
cost = key.to_s.bytesize + 4
|
|
306
|
+
if cost >= budget[0]
|
|
307
|
+
cut[0] = true
|
|
308
|
+
break out
|
|
309
|
+
end
|
|
310
|
+
budget[0] -= cost
|
|
311
|
+
out[key] = cap_value(item, budget, cut)
|
|
312
|
+
end
|
|
313
|
+
when Array
|
|
314
|
+
value.each_with_object([]) do |item, out|
|
|
315
|
+
if budget[0] <= 1
|
|
316
|
+
cut[0] = true
|
|
317
|
+
break out
|
|
318
|
+
end
|
|
319
|
+
budget[0] -= 1
|
|
320
|
+
out << cap_value(item, budget, cut)
|
|
321
|
+
end
|
|
322
|
+
when String
|
|
323
|
+
cost = value.bytesize + 2
|
|
324
|
+
if cost > budget[0]
|
|
325
|
+
cut[0] = true
|
|
326
|
+
value = Redaction.cut_bytes(value, [budget[0] - 2, 0].max).scrub("")
|
|
327
|
+
cost = value.bytesize + 2
|
|
328
|
+
end
|
|
329
|
+
budget[0] -= cost
|
|
330
|
+
value
|
|
331
|
+
else
|
|
332
|
+
budget[0] -= value.to_s.bytesize
|
|
333
|
+
value
|
|
334
|
+
end
|
|
335
|
+
end
|
|
336
|
+
|
|
269
337
|
def sanitize_params(params)
|
|
270
338
|
return {} unless params
|
|
271
339
|
|
|
@@ -36,7 +36,11 @@ module Profiler
|
|
|
36
36
|
candidates.find { |route| route[:verb].split("|").include?(verb) } ||
|
|
37
37
|
candidates.find { |route| route[:verb] == "ANY" } ||
|
|
38
38
|
candidates.first
|
|
39
|
-
rescue StandardError
|
|
39
|
+
rescue StandardError => e
|
|
40
|
+
# A path no route matches (a 404) has no route to show.
|
|
41
|
+
unless defined?(ActionController::RoutingError) && e.is_a?(ActionController::RoutingError)
|
|
42
|
+
Profiler.log_error_once(:snapshot_route, "could not find the route of a profile", e)
|
|
43
|
+
end
|
|
40
44
|
nil
|
|
41
45
|
end
|
|
42
46
|
|
|
@@ -61,7 +65,7 @@ module Profiler
|
|
|
61
65
|
end
|
|
62
66
|
profile
|
|
63
67
|
rescue StandardError => e
|
|
64
|
-
|
|
68
|
+
Profiler.log_error("could not add the routes and ENV to profile #{profile.token}", e)
|
|
65
69
|
profile
|
|
66
70
|
end
|
|
67
71
|
|
|
@@ -84,7 +88,8 @@ module Profiler
|
|
|
84
88
|
@routes = build_snapshot(fingerprint, list) unless @routes && @routes[0] == fingerprint
|
|
85
89
|
@routes
|
|
86
90
|
end
|
|
87
|
-
rescue StandardError
|
|
91
|
+
rescue StandardError => e
|
|
92
|
+
Profiler.log_error_once(:snapshot_routes, "could not read the application's routes", e)
|
|
88
93
|
[nil, [].freeze, {}.freeze]
|
|
89
94
|
end
|
|
90
95
|
|
data/lib/profiler/railtie.rb
CHANGED
|
@@ -23,7 +23,7 @@ module Profiler
|
|
|
23
23
|
if config.respond_to?("#{key}=")
|
|
24
24
|
config.public_send("#{key}=", value)
|
|
25
25
|
else
|
|
26
|
-
|
|
26
|
+
Profiler.log_warn("config.profiler.#{key} is not a profiler option, ignored")
|
|
27
27
|
end
|
|
28
28
|
end
|
|
29
29
|
end
|
data/lib/profiler/redaction.rb
CHANGED
|
@@ -518,9 +518,7 @@ module Profiler
|
|
|
518
518
|
return if reported.include?(error.class)
|
|
519
519
|
|
|
520
520
|
reported << error.class
|
|
521
|
-
|
|
522
|
-
logger = ::Rails.logger if defined?(::Rails) && ::Rails.respond_to?(:logger)
|
|
523
|
-
logger ? logger.warn(message) : Kernel.warn(message)
|
|
521
|
+
Profiler.log_warn("Redaction: #{error.class} while #{during}; the value was masked instead.")
|
|
524
522
|
rescue StandardError
|
|
525
523
|
nil
|
|
526
524
|
end
|
|
@@ -29,7 +29,8 @@ module Profiler
|
|
|
29
29
|
# The version of the last save of +token+, 0 when none is known or Redis cannot be read.
|
|
30
30
|
def version(token)
|
|
31
31
|
redis.get(key(token)).to_i
|
|
32
|
-
rescue StandardError
|
|
32
|
+
rescue StandardError => e
|
|
33
|
+
Profiler.log_error_once(:redis_event_bus_version, "RedisEventBus: could not read the version of a profile", e)
|
|
33
34
|
0
|
|
34
35
|
end
|
|
35
36
|
|
|
@@ -34,7 +34,8 @@ module Profiler
|
|
|
34
34
|
|
|
35
35
|
def exists?(token)
|
|
36
36
|
!load(token).nil?
|
|
37
|
-
rescue
|
|
37
|
+
rescue StandardError => e
|
|
38
|
+
Profiler.log_error_once(:store_exists, "#{self.class.name.split("::").last}: could not look up a profile", e)
|
|
38
39
|
false
|
|
39
40
|
end
|
|
40
41
|
|
|
@@ -54,8 +55,9 @@ module Profiler
|
|
|
54
55
|
|
|
55
56
|
def broadcast_event(token)
|
|
56
57
|
Profiler::SSE.current.broadcast(token)
|
|
57
|
-
rescue StandardError
|
|
58
|
+
rescue StandardError => e
|
|
58
59
|
# Never let a broadcast failure prevent profile persistence
|
|
60
|
+
Profiler.log_error_once(:store_broadcast, "#{self.class.name.split("::").last}: could not announce a saved profile", e)
|
|
59
61
|
end
|
|
60
62
|
end
|
|
61
63
|
end
|
|
@@ -100,7 +100,7 @@ module Profiler
|
|
|
100
100
|
json_data = File.read(file_path)
|
|
101
101
|
Models::Profile.from_json(json_data)
|
|
102
102
|
rescue StandardError => e
|
|
103
|
-
|
|
103
|
+
Profiler.log_error("FileStore: could not load profile #{token}", e)
|
|
104
104
|
nil
|
|
105
105
|
end
|
|
106
106
|
|
|
@@ -345,9 +345,14 @@ module Profiler
|
|
|
345
345
|
json = PrivateFiles.open_for_reading(profile_file_path(token), &:read)
|
|
346
346
|
record = put_record(token, Models::Profile.from_json(json).to_h, json.bytesize)
|
|
347
347
|
Entry.new(token, record["at"], record["type"], record["parent"], record["bytes"], record["summary"])
|
|
348
|
-
rescue StandardError
|
|
348
|
+
rescue StandardError => e
|
|
349
349
|
# Unreadable: kept in the count, so that it is evicted in turn; listed by no one.
|
|
350
|
-
|
|
350
|
+
Profiler.log_error_once(:file_store_entry, "FileStore: could not read profile #{token} to index it", e)
|
|
351
|
+
stat = begin
|
|
352
|
+
File.lstat(profile_file_path(token))
|
|
353
|
+
rescue SystemCallError
|
|
354
|
+
nil
|
|
355
|
+
end
|
|
351
356
|
Entry.new(token, stat ? stat.mtime.to_f : 0.0, nil, nil, stat ? stat.size : 0, nil)
|
|
352
357
|
end
|
|
353
358
|
|
|
@@ -125,9 +125,9 @@ module Profiler
|
|
|
125
125
|
first = @warn_mutex.synchronize { @warned[key] ? false : (@warned[key] = true) }
|
|
126
126
|
return unless first
|
|
127
127
|
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
128
|
+
Profiler.log_warn("tmp_path #{key} belongs to another user or is writable by group or others " \
|
|
129
|
+
"(mode #{format("%o", stat.mode & 0o777)}): other local users could place files there. Set " \
|
|
130
|
+
"config.tmp_path to a directory of your own, or run chmod 700 on it.")
|
|
131
131
|
rescue SystemCallError
|
|
132
132
|
nil
|
|
133
133
|
end
|
|
@@ -64,7 +64,7 @@ module Profiler
|
|
|
64
64
|
|
|
65
65
|
Models::Profile.from_json(json_data)
|
|
66
66
|
rescue => e
|
|
67
|
-
|
|
67
|
+
Profiler.log_error("RedisStore: could not load profile #{token}", e)
|
|
68
68
|
nil
|
|
69
69
|
end
|
|
70
70
|
|
|
@@ -270,7 +270,7 @@ module Profiler
|
|
|
270
270
|
data = JSON.parse(json, symbolize_names: true)
|
|
271
271
|
[token, data[:started_at] ? Time.parse(data[:started_at]).to_f : 0.0, Summary.build(data)]
|
|
272
272
|
rescue StandardError => e
|
|
273
|
-
|
|
273
|
+
Profiler.log_error("RedisStore: could not index profile #{token}", e)
|
|
274
274
|
nil
|
|
275
275
|
end
|
|
276
276
|
@redis.pipelined do |pipe|
|
|
@@ -102,7 +102,7 @@ module Profiler
|
|
|
102
102
|
|
|
103
103
|
row_to_profile(row)
|
|
104
104
|
rescue => e
|
|
105
|
-
|
|
105
|
+
Profiler.log_error("SqliteStore: could not load profile #{token}", e)
|
|
106
106
|
nil
|
|
107
107
|
end
|
|
108
108
|
|
|
@@ -191,7 +191,7 @@ module Profiler
|
|
|
191
191
|
|
|
192
192
|
Summary.to_profile(JSON.parse(row["summary"]))
|
|
193
193
|
rescue JSON::ParserError => e
|
|
194
|
-
|
|
194
|
+
Profiler.log_error("SqliteStore: could not read the summary of #{row["token"]}", e)
|
|
195
195
|
nil
|
|
196
196
|
end
|
|
197
197
|
|
|
@@ -308,7 +308,7 @@ module Profiler
|
|
|
308
308
|
collectors_data: collectors_data
|
|
309
309
|
)
|
|
310
310
|
rescue => e
|
|
311
|
-
|
|
311
|
+
Profiler.log_error("SqliteStore: could not deserialize profile #{row["token"]}", e)
|
|
312
312
|
nil
|
|
313
313
|
end
|
|
314
314
|
|
|
@@ -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
|
|
@@ -21,8 +21,7 @@ module Profiler
|
|
|
21
21
|
key = [Process.pid, error.class.name, error.message]
|
|
22
22
|
first = @mutex.synchronize { @said[key] ? false : (@said[key] = true) }
|
|
23
23
|
if first
|
|
24
|
-
|
|
25
|
-
"#{error.class}: #{error.message}"
|
|
24
|
+
Profiler.log_warn("storage unavailable, profiles are not saved until it is fixed", error)
|
|
26
25
|
end
|
|
27
26
|
new(error)
|
|
28
27
|
end
|
|
@@ -114,7 +114,7 @@ module Profiler
|
|
|
114
114
|
collector.collect if collector.respond_to?(:collect)
|
|
115
115
|
profile.add_collector_metadata(collector)
|
|
116
116
|
rescue => e
|
|
117
|
-
|
|
117
|
+
Profiler.log_error("TestProfiler: collector #{collector.class} failed", e)
|
|
118
118
|
end
|
|
119
119
|
end
|
|
120
120
|
|
|
@@ -51,7 +51,7 @@ module Profiler
|
|
|
51
51
|
root = project_root
|
|
52
52
|
allowed = if Profiler.configuration.test_runner_allow_undiscovered_files
|
|
53
53
|
warn_once(:undiscovered,
|
|
54
|
-
"
|
|
54
|
+
"config.test_runner_allow_undiscovered_files is enabled: the test runner " \
|
|
55
55
|
"runs any file under the Rails root, not only the discovered tests.")
|
|
56
56
|
nil
|
|
57
57
|
else
|
|
@@ -101,11 +101,7 @@ module Profiler
|
|
|
101
101
|
@warned[key] = true
|
|
102
102
|
end
|
|
103
103
|
|
|
104
|
-
|
|
105
|
-
Rails.logger.warn(message)
|
|
106
|
-
else
|
|
107
|
-
warn(message)
|
|
108
|
-
end
|
|
104
|
+
Profiler.log_warn(message)
|
|
109
105
|
end
|
|
110
106
|
|
|
111
107
|
def self.reset_warnings!
|
|
@@ -227,7 +223,7 @@ module Profiler
|
|
|
227
223
|
|
|
228
224
|
unless left_out.empty?
|
|
229
225
|
warn_once([:env, left_out.sort],
|
|
230
|
-
"
|
|
226
|
+
"Environment overrides not passed to the test runner, because they make it " \
|
|
231
227
|
"load other code: #{left_out.sort.join(", ")}.")
|
|
232
228
|
end
|
|
233
229
|
|
data/lib/profiler/version.rb
CHANGED
data/lib/profiler.rb
CHANGED
|
@@ -10,6 +10,10 @@ require_relative "profiler/allocation_counter"
|
|
|
10
10
|
module Profiler
|
|
11
11
|
class Error < StandardError; end
|
|
12
12
|
|
|
13
|
+
# The longest error message, in bytes, a log line carries: a parser error can quote what it
|
|
14
|
+
# was parsing.
|
|
15
|
+
LOG_ERROR_MESSAGE_LIMIT = 1000
|
|
16
|
+
|
|
13
17
|
class << self
|
|
14
18
|
attr_writer :configuration
|
|
15
19
|
attr_accessor :function_profiling_enabled
|
|
@@ -43,11 +47,75 @@ module Profiler
|
|
|
43
47
|
|
|
44
48
|
# Saves a profile from a path of the application (a job, a console command, a test, an
|
|
45
49
|
# outbound HTTP call): a storage error loses the profile, never the application's work.
|
|
50
|
+
# True when saved; false when the error was logged instead, or when the store is unavailable
|
|
51
|
+
# (Storage::Unavailable drops the save, and has said why once already).
|
|
46
52
|
def save_profile(profile, from:)
|
|
47
|
-
storage
|
|
53
|
+
target = storage
|
|
54
|
+
return false if target.is_a?(Storage::Unavailable)
|
|
55
|
+
|
|
56
|
+
target.save(profile.token, profile)
|
|
57
|
+
true
|
|
48
58
|
rescue StandardError => e
|
|
49
|
-
|
|
59
|
+
log_error("#{from}: could not save profile #{profile.token}", e)
|
|
60
|
+
false
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# The profiler's own messages, to config.logger when the application sets one (read on each
|
|
64
|
+
# message), else to +logger+ when given (Rails.logger at boot), else to Rails.logger, else to
|
|
65
|
+
# $stderr.
|
|
66
|
+
# Prefixed [Profiler], with the profiler's credentials masked. Never raises: a logger that
|
|
67
|
+
# fails sends the line to $stderr instead. While the line is written, the thread is marked
|
|
68
|
+
# (Thread.current[:profiler_logging]) so that the Logs tab of a profile being recorded does
|
|
69
|
+
# not take it for one of the application's lines.
|
|
70
|
+
def log(level, message, error = nil, backtrace: false, logger: nil)
|
|
71
|
+
line = log_line(message, error, backtrace)
|
|
72
|
+
logger = configured_logger || logger || current_logger
|
|
73
|
+
if logger
|
|
74
|
+
begin
|
|
75
|
+
return write_log(logger, level, line)
|
|
76
|
+
rescue StandardError
|
|
77
|
+
# Below, to $stderr: the message is not lost.
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
$stderr.write("#{line}\n")
|
|
50
81
|
nil
|
|
82
|
+
rescue StandardError
|
|
83
|
+
nil
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def log_error(message, error = nil, backtrace: false)
|
|
87
|
+
log(:error, message, error, backtrace: backtrace)
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def log_warn(message, error = nil, logger: nil)
|
|
91
|
+
log(:warn, message, error, logger: logger)
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def log_info(message)
|
|
95
|
+
log(:info, message)
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# Like log_error, once per +site+ and error class: for the paths called again and again (a
|
|
99
|
+
# store read on every poll), where the same failure would otherwise fill the log.
|
|
100
|
+
def log_error_once(site, message, error)
|
|
101
|
+
key = [site, error.class]
|
|
102
|
+
@logged_once_mutex.synchronize do
|
|
103
|
+
return nil if @logged_once.include?(key)
|
|
104
|
+
|
|
105
|
+
@logged_once << key
|
|
106
|
+
end
|
|
107
|
+
log_error(message, error)
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# Runs the block without recording its outgoing HTTP calls in the current profile: the
|
|
111
|
+
# profiler's own calls (the cluster's registration and heartbeats, the master's requests to
|
|
112
|
+
# its slaves) are not the application's.
|
|
113
|
+
def untracked_http
|
|
114
|
+
previous = Thread.current[:profiler_http_untracked]
|
|
115
|
+
Thread.current[:profiler_http_untracked] = true
|
|
116
|
+
yield
|
|
117
|
+
ensure
|
|
118
|
+
Thread.current[:profiler_http_untracked] = previous
|
|
51
119
|
end
|
|
52
120
|
|
|
53
121
|
def env_override_store
|
|
@@ -94,10 +162,62 @@ module Profiler
|
|
|
94
162
|
result
|
|
95
163
|
end
|
|
96
164
|
|
|
165
|
+
private
|
|
166
|
+
|
|
167
|
+
def configured_logger
|
|
168
|
+
configuration.logger
|
|
169
|
+
rescue StandardError
|
|
170
|
+
nil
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
def current_logger
|
|
174
|
+
configured = configuration.logger
|
|
175
|
+
return configured if configured
|
|
176
|
+
return nil unless defined?(::Rails) && ::Rails.respond_to?(:logger)
|
|
177
|
+
|
|
178
|
+
::Rails.logger
|
|
179
|
+
rescue StandardError
|
|
180
|
+
nil
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
def write_log(logger, level, line)
|
|
184
|
+
previous = Thread.current[:profiler_logging]
|
|
185
|
+
Thread.current[:profiler_logging] = true
|
|
186
|
+
logger.public_send(level, line)
|
|
187
|
+
ensure
|
|
188
|
+
Thread.current[:profiler_logging] = previous
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
# Masked whole, then cut: a cut first could leave the start of a credential, which the
|
|
192
|
+
# masking by value would no longer find.
|
|
193
|
+
def log_line(message, error, backtrace)
|
|
194
|
+
line = +"[Profiler] #{message}"
|
|
195
|
+
if error
|
|
196
|
+
line << ": #{error.class}"
|
|
197
|
+
text = error_text(error)
|
|
198
|
+
line << ": #{text}" unless text.empty?
|
|
199
|
+
line << "\n#{error.backtrace.join("\n")}" if backtrace && error.backtrace
|
|
200
|
+
end
|
|
201
|
+
Redaction.hide_credentials(line)
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
# The error's message, masked, then cut at LOG_ERROR_MESSAGE_LIMIT bytes. A message that
|
|
205
|
+
# raises leaves the class alone.
|
|
206
|
+
def error_text(error)
|
|
207
|
+
text = Redaction.hide_credentials(error.message.to_s)
|
|
208
|
+
return text if text.bytesize <= LOG_ERROR_MESSAGE_LIMIT
|
|
209
|
+
|
|
210
|
+
"#{Redaction.cut_bytes(text, LOG_ERROR_MESSAGE_LIMIT).scrub("")}..."
|
|
211
|
+
rescue StandardError
|
|
212
|
+
""
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
public
|
|
216
|
+
|
|
97
217
|
# Dump a variable to the profiler
|
|
98
218
|
# Usage: Profiler.dump(variable, "optional label")
|
|
99
219
|
def dump(value, label = nil)
|
|
100
|
-
return unless enabled?
|
|
220
|
+
return value unless enabled?
|
|
101
221
|
|
|
102
222
|
# The slot exists only while a DumpCollector profiles this thread: outside of one, nobody
|
|
103
223
|
# would ever read the dump, and the thread would keep it for good.
|
|
@@ -122,6 +242,9 @@ module Profiler
|
|
|
122
242
|
end
|
|
123
243
|
end
|
|
124
244
|
|
|
245
|
+
@logged_once = Set.new
|
|
246
|
+
@logged_once_mutex = Mutex.new
|
|
247
|
+
|
|
125
248
|
self.function_profiling_enabled = true
|
|
126
249
|
self.function_profiling_max_frames = 2000
|
|
127
250
|
self.function_profiling_mode = "lite"
|