rails-profiler 0.33.1 → 0.33.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 +70 -0
- data/lib/profiler/collectors/database_collector.rb +16 -2
- data/lib/profiler/collectors/dump_collector.rb +4 -2
- data/lib/profiler/collectors/log_collector.rb +15 -1
- data/lib/profiler/configuration.rb +35 -3
- data/lib/profiler/instrumentation/net_http_instrumentation.rb +18 -1
- data/lib/profiler/middleware/profiler_middleware.rb +17 -8
- data/lib/profiler/storage/blob_store.rb +33 -0
- data/lib/profiler/storage/redis_store.rb +54 -10
- data/lib/profiler/storage/sqlite_store.rb +63 -9
- data/lib/profiler/tasks/profiler.rake +20 -4
- data/lib/profiler/version.rb +1 -1
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 533fac499cbab38112f1be4ab7d066761f0f0cea1b22d76e4d355059ef03d9dd
|
|
4
|
+
data.tar.gz: b8d37a14501188a671be2ee4876b8ce9678641cca8acbd836e456ea62249ef48
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 7daad08c5c0b6a119933341e7b1a1ba28d746c2801eef525a83dd54388c85a30bfa68a7240d2e99705992e622929df012b98676f0ec396a028fff759690910da
|
|
7
|
+
data.tar.gz: d205ba6d72f09ca00d1eb714db57f99052e64297caddb33999c8c6ae87ae380874cf015af986d0065d7b76c949388a813f3f397adae3fe23b7f5a1e6bdbcbc48
|
data/CHANGELOG.md
CHANGED
|
@@ -19,6 +19,76 @@ every commit of every tag interval is accounted for one way or the other.
|
|
|
19
19
|
|
|
20
20
|
## [Unreleased]
|
|
21
21
|
|
|
22
|
+
## [0.33.3] - 2026-10-14
|
|
23
|
+
|
|
24
|
+
<!-- stamped -->
|
|
25
|
+
|
|
26
|
+
### Fixed
|
|
27
|
+
|
|
28
|
+
- **Rake tasks:** `rake profiler:cleanup` with an empty or non-numeric `OLDER_THAN` (`""`,
|
|
29
|
+
`abc`) no longer removes every profile, and `OLDER_THAN=1h` is no longer read as one second.
|
|
30
|
+
`OLDER_THAN` and `LIMIT` take a whole number only; any other value stops the task with an error
|
|
31
|
+
and removes nothing. `OLDER_THAN=0` still removes every profile, when written so.
|
|
32
|
+
- **Storage:** The SQLite store compares the start times of the profiles in UTC. Outside UTC,
|
|
33
|
+
`rake profiler:cleanup` and `cleanup` removed recent profiles (America/New_York) or kept old
|
|
34
|
+
ones (Asia/Tokyo), and the lists were out of order across a clock change. The profiles already
|
|
35
|
+
in the database are converted once, when the store opens it; the API shows the same times.
|
|
36
|
+
- **Storage:** The Redis store takes expired profiles out of its lists at each save, and its list
|
|
37
|
+
keys now expire a TTL after the last save. Without `max_profiles`, or between two evictions,
|
|
38
|
+
the tokens of expired profiles stayed listed until a manual cleanup, and a page of the list
|
|
39
|
+
came back with fewer profiles than asked.
|
|
40
|
+
- **Storage:** With the SQLite store, a profile saved again without a response body no longer
|
|
41
|
+
shows the response body of its previous save. The response bodies left on disk by profiles
|
|
42
|
+
removed outside the store are removed by `cleanup` and when the oldest profiles are evicted.
|
|
43
|
+
|
|
44
|
+
### Limitations
|
|
45
|
+
|
|
46
|
+
- **Storage:** Two SQLite stores that share a `blob_path` remove each other's response bodies:
|
|
47
|
+
give each `database` its own `blob_path` (see `docs/storage.md`).
|
|
48
|
+
- **Storage:** On the first save after an upgrade, the Redis store can drop from its lists a job
|
|
49
|
+
that started more than a TTL ago and was saved recently. It happens once.
|
|
50
|
+
|
|
51
|
+
## [0.33.2] - 2026-10-14
|
|
52
|
+
|
|
53
|
+
<!-- stamped -->
|
|
54
|
+
|
|
55
|
+
### Changed
|
|
56
|
+
|
|
57
|
+
- **Configuration:** A String in `config.http_skip_hosts` is now a host name, compared whole and
|
|
58
|
+
ignoring case, instead of a pattern: `"example.com"` no longer leaves out `notexample.com`,
|
|
59
|
+
`exampleXcom` or `example.com.attacker.net`. A String that starts with a dot is a domain whose
|
|
60
|
+
subdomains are left out (`".internal"` leaves out `api.internal`). A Regexp is matched as
|
|
61
|
+
before. An entry that is not a host name, a wildcard String (`"*.internal"`), an empty one, or
|
|
62
|
+
anything that is neither a String nor a Regexp (`nil`), is left out when the list is set, with
|
|
63
|
+
a warning in the log that says what to write, and the other entries are kept. It does not
|
|
64
|
+
raise: the gem is loaded in production too, where a mistake in this list must not stop the
|
|
65
|
+
application from booting, even with the profiler disabled.
|
|
66
|
+
- **Dumps:** A dump is stored as its formatted text only: the raw `value` field, which no tab
|
|
67
|
+
read and the API did not document, is no longer part of the dump data.
|
|
68
|
+
|
|
69
|
+
### Fixed
|
|
70
|
+
|
|
71
|
+
- **Configuration:** A String in `config.skip_paths` no longer fails every request with a
|
|
72
|
+
`TypeError`: it skips the path equal to it, and a Regexp is matched as before. A single
|
|
73
|
+
pattern, a String or a Regexp, set without a list is read as a list of one.
|
|
74
|
+
- **Outbound HTTP:** A String in `config.http_skip_hosts` that is not a valid Regexp, such as
|
|
75
|
+
`"*.internal"` added with `<<`, no longer fails every `Net::HTTP` call of a profiled request
|
|
76
|
+
with a `RegexpError`.
|
|
77
|
+
- **Database:** A query that binds bytes, a `blob` or `bytea` column such as an image, no longer
|
|
78
|
+
loses the profile of its request when it is saved: the bind shows as text when its bytes are
|
|
79
|
+
valid UTF-8, and as `[binary data, N bytes]` otherwise.
|
|
80
|
+
- **Logs:** A line logged with bytes that are not valid UTF-8 no longer raises an
|
|
81
|
+
`Encoding::CompatibilityError` in the application's own logger call (Rails 7.1 and later), and
|
|
82
|
+
a binary line no longer loses the profile when it is saved: the line is kept as UTF-8, what
|
|
83
|
+
cannot be read replaced with U+FFFD, and a line in another encoding is converted.
|
|
84
|
+
- **Dumps:** `Profiler.dump` of an object that refers to itself, directly or through an instance
|
|
85
|
+
variable, no longer fails the request with a `SystemStackError` (a 500 with the file store), and
|
|
86
|
+
an Array or a Hash holding itself no longer loses the profile. An object whose own `inspect`
|
|
87
|
+
recurses without end shows `[Error formatting value: stack level too deep]`.
|
|
88
|
+
- **Middleware:** A page whose body is made of fragments of different encodings, a binary one
|
|
89
|
+
among UTF-8 ones, no longer fails with an `Encoding::CompatibilityError` once profiled: its
|
|
90
|
+
bytes are joined as the application gave them, and it gets its toolbar.
|
|
91
|
+
|
|
22
92
|
## [0.33.1] - 2026-10-14
|
|
23
93
|
|
|
24
94
|
<!-- stamped -->
|
|
@@ -9,6 +9,8 @@ module Profiler
|
|
|
9
9
|
BACKTRACE_DEPTH = 10
|
|
10
10
|
MAX_SCANNED_FRAMES = 100
|
|
11
11
|
FRAMES_PAST_APPLICATION = 15
|
|
12
|
+
# What a bind holding bytes that are not text shows instead (see #displayable_bind).
|
|
13
|
+
BINARY_BIND = "[binary data, %d bytes]"
|
|
12
14
|
|
|
13
15
|
|
|
14
16
|
# Frames between the application's code and the subscriber: the profiler itself, the
|
|
@@ -122,9 +124,9 @@ module Profiler
|
|
|
122
124
|
binds.map do |bind|
|
|
123
125
|
if bind.respond_to?(:value)
|
|
124
126
|
name = bind.name if bind.respond_to?(:name)
|
|
125
|
-
Profiler::Redaction.filter_named(name, bind.value)
|
|
127
|
+
displayable_bind(Profiler::Redaction.filter_named(name, bind.value))
|
|
126
128
|
else
|
|
127
|
-
bind
|
|
129
|
+
displayable_bind(bind)
|
|
128
130
|
end
|
|
129
131
|
end
|
|
130
132
|
rescue StandardError
|
|
@@ -132,6 +134,18 @@ module Profiler
|
|
|
132
134
|
binds.map { Profiler::Redaction::MASK }
|
|
133
135
|
end
|
|
134
136
|
|
|
137
|
+
# A blob or bytea value is bytes, not text: kept as it is, it fails the JSON of the whole
|
|
138
|
+
# profile when it is saved. Bytes that are valid UTF-8 are shown as text, others as their
|
|
139
|
+
# size. Text in another encoding stays as it is: JSON converts it.
|
|
140
|
+
def displayable_bind(value)
|
|
141
|
+
value = value.to_s if defined?(ActiveModel::Type::Binary::Data) && value.is_a?(ActiveModel::Type::Binary::Data)
|
|
142
|
+
return value unless value.is_a?(String)
|
|
143
|
+
return value if value.valid_encoding? && value.encoding != Encoding::BINARY
|
|
144
|
+
|
|
145
|
+
utf8 = value.dup.force_encoding(Encoding::UTF_8)
|
|
146
|
+
utf8.valid_encoding? ? utf8 : format(BINARY_BIND, value.bytesize)
|
|
147
|
+
end
|
|
148
|
+
|
|
135
149
|
# Capturing the caller costs tens of microseconds. The first run of a statement locates
|
|
136
150
|
# it, an N+1 loop included (its repeats are the same statement with other values); a slow
|
|
137
151
|
# query is always located.
|
|
@@ -33,9 +33,10 @@ module Profiler
|
|
|
33
33
|
dumps = @dumps || Thread.current[:profiler_dumps] || []
|
|
34
34
|
unsubscribe
|
|
35
35
|
|
|
36
|
+
# Only the formatted text is kept: the object itself can refer to itself, or hold what
|
|
37
|
+
# JSON cannot write, and would make the profile fail when it is saved.
|
|
36
38
|
formatted_dumps = dumps.map do |dump|
|
|
37
39
|
{
|
|
38
|
-
value: dump[:value],
|
|
39
40
|
formatted: format_value(dump[:value]),
|
|
40
41
|
file: dump[:file],
|
|
41
42
|
line: dump[:line],
|
|
@@ -97,7 +98,8 @@ module Profiler
|
|
|
97
98
|
formatted += ">"
|
|
98
99
|
formatted
|
|
99
100
|
end
|
|
100
|
-
|
|
101
|
+
# An inspect of the application's that recurses without end is said, like any other error.
|
|
102
|
+
rescue StandardError, SystemStackError => e
|
|
101
103
|
"[Error formatting value: #{e.message}]"
|
|
102
104
|
end
|
|
103
105
|
end
|
|
@@ -68,7 +68,7 @@ module Profiler
|
|
|
68
68
|
|
|
69
69
|
entry = {
|
|
70
70
|
level: SEVERITY_LABELS[severity] || "UNKNOWN",
|
|
71
|
-
message: msg.to_s.strip,
|
|
71
|
+
message: self.class.readable(msg.to_s).strip,
|
|
72
72
|
timestamp: Time.now.iso8601(3)
|
|
73
73
|
}
|
|
74
74
|
logs.respond_to?(:record) ? logs.record(entry) : logs << entry
|
|
@@ -78,6 +78,20 @@ module Profiler
|
|
|
78
78
|
def <<(msg)
|
|
79
79
|
add(0, msg.to_s)
|
|
80
80
|
end
|
|
81
|
+
|
|
82
|
+
# The line as valid UTF-8, a copy when it has to change: bytes that are not text in its
|
|
83
|
+
# encoding would raise in strip, inside the application's own logger call, or fail the
|
|
84
|
+
# JSON of the profile when it is saved. Binary bytes are read as UTF-8; a line in another
|
|
85
|
+
# encoding is converted. What cannot be read becomes U+FFFD.
|
|
86
|
+
def self.readable(text)
|
|
87
|
+
return text if text.encoding == Encoding::UTF_8 && text.valid_encoding?
|
|
88
|
+
|
|
89
|
+
if [Encoding::UTF_8, Encoding::BINARY, Encoding::US_ASCII].include?(text.encoding)
|
|
90
|
+
text.dup.force_encoding(Encoding::UTF_8).scrub
|
|
91
|
+
else
|
|
92
|
+
text.encode(Encoding::UTF_8, invalid: :replace, undef: :replace)
|
|
93
|
+
end
|
|
94
|
+
end
|
|
81
95
|
end
|
|
82
96
|
|
|
83
97
|
def icon
|
|
@@ -38,13 +38,13 @@ module Profiler
|
|
|
38
38
|
LOCAL_HTTP_HOSTS = [/\A127\.0\.0\.1\z/, /\Alocalhost\z/i, /\A::1\z/].freeze
|
|
39
39
|
|
|
40
40
|
attr_accessor :storage_options, :collectors,
|
|
41
|
-
:
|
|
41
|
+
:slow_query_threshold, :max_queries_warning, :sql_backtrace,
|
|
42
42
|
:track_memory, :allocated_objects_warning_threshold,
|
|
43
43
|
:mcp_enabled, :mcp_transport, :mcp_port,
|
|
44
44
|
:authorization_mode, :extension_cors_enabled,
|
|
45
45
|
:cors_allowed_origins, :api_forgery_protection, :frame_ancestors,
|
|
46
46
|
:track_ajax, :ajax_skip_paths,
|
|
47
|
-
:track_http, :slow_http_threshold, :
|
|
47
|
+
:track_http, :slow_http_threshold, :http_backtrace_depth,
|
|
48
48
|
:track_jobs,
|
|
49
49
|
:track_console,
|
|
50
50
|
:apply_env_overrides_when_disabled, :restrict_storage_permissions,
|
|
@@ -148,7 +148,23 @@ module Profiler
|
|
|
148
148
|
@tmp_path = value.nil? ? nil : Pathname.new(value)
|
|
149
149
|
end
|
|
150
150
|
|
|
151
|
-
attr_reader :cluster_allowed_slave_urls, :mcp_allowed_hosts
|
|
151
|
+
attr_reader :cluster_allowed_slave_urls, :mcp_allowed_hosts, :skip_paths, :http_skip_hosts
|
|
152
|
+
|
|
153
|
+
# Paths that are not profiled: a Regexp matched against the path, or a String equal to it.
|
|
154
|
+
# A single pattern is a list of one.
|
|
155
|
+
def skip_paths=(value)
|
|
156
|
+
@skip_paths = Array(value)
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
# Hosts whose outbound calls are left out: a String is a host name, matched whole and
|
|
160
|
+
# case-insensitively, or, when it starts with a dot (".internal"), a domain whose subdomains
|
|
161
|
+
# are left out; a Regexp is matched against the host name. Any other entry (a wildcard
|
|
162
|
+
# "*.internal", an empty String, nil) is left out with a warning in the log, the others kept:
|
|
163
|
+
# the gem is loaded in production too, where a mistake here must not stop the application
|
|
164
|
+
# from booting.
|
|
165
|
+
def http_skip_hosts=(value)
|
|
166
|
+
@http_skip_hosts = Array(value).select { |entry| valid_http_skip_host?(entry) }
|
|
167
|
+
end
|
|
152
168
|
|
|
153
169
|
# Host names the MCP HTTP endpoint accepts beyond the loopback ones and config.hosts: a String
|
|
154
170
|
# (a host name, any port, or a host:port) or a Regexp anchored with \A and \z, matched
|
|
@@ -323,6 +339,22 @@ module Profiler
|
|
|
323
339
|
end
|
|
324
340
|
end
|
|
325
341
|
|
|
342
|
+
def valid_http_skip_host?(entry)
|
|
343
|
+
problem =
|
|
344
|
+
case entry
|
|
345
|
+
when Regexp then nil
|
|
346
|
+
when String
|
|
347
|
+
if entry.strip.empty? || entry.match?(/[*\s]/)
|
|
348
|
+
"is not a host name; list each host, a domain as \".internal\" for its subdomains, or use a Regexp"
|
|
349
|
+
end
|
|
350
|
+
else "must be a String or a Regexp"
|
|
351
|
+
end
|
|
352
|
+
return true unless problem
|
|
353
|
+
|
|
354
|
+
Profiler.log_warn("config.http_skip_hosts: #{entry.inspect} #{problem}; ignored")
|
|
355
|
+
false
|
|
356
|
+
end
|
|
357
|
+
|
|
326
358
|
def validate_mcp_allowed_host_pattern(pattern)
|
|
327
359
|
unless Profiler::Cluster::Security.anchored_pattern?(pattern)
|
|
328
360
|
raise ArgumentError, "config.mcp_allowed_hosts: #{pattern.inspect} must start with \\A and end with \\z, " \
|
|
@@ -122,12 +122,29 @@ module Profiler
|
|
|
122
122
|
# it are the profiler's, whichever code makes them.
|
|
123
123
|
def self.skip_host?(host, port = nil)
|
|
124
124
|
config = Profiler.configuration
|
|
125
|
-
return true if config.http_skip_hosts.any? { |pattern|
|
|
125
|
+
return true if config.http_skip_hosts.any? { |pattern| skip_host_pattern?(pattern, host) }
|
|
126
126
|
|
|
127
127
|
master = master_address(config.master_url)
|
|
128
128
|
!master.nil? && master[0].casecmp?(host) && (port.nil? || master[1] == port)
|
|
129
129
|
end
|
|
130
130
|
|
|
131
|
+
# A String is never read as a Regexp: "example.com" would match notexample.com, and
|
|
132
|
+
# "*.internal" would raise on every call. It is the host name, or, starting with a dot, a
|
|
133
|
+
# domain whose subdomains match. A list changed in place (<<) is not validated: anything
|
|
134
|
+
# else matches nothing.
|
|
135
|
+
def self.skip_host_pattern?(pattern, host)
|
|
136
|
+
case pattern
|
|
137
|
+
when Regexp then pattern.match?(host)
|
|
138
|
+
when String
|
|
139
|
+
if pattern.start_with?(".")
|
|
140
|
+
host.length > pattern.length && host.downcase.end_with?(pattern.downcase)
|
|
141
|
+
else
|
|
142
|
+
pattern.casecmp?(host)
|
|
143
|
+
end
|
|
144
|
+
else false
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
|
|
131
148
|
def self.master_address(url)
|
|
132
149
|
return nil if url.nil? || url.to_s.empty?
|
|
133
150
|
|
|
@@ -278,7 +278,8 @@ module Profiler
|
|
|
278
278
|
|
|
279
279
|
# Skip profiler's own paths and static assets
|
|
280
280
|
path = env["PATH_INFO"]
|
|
281
|
-
|
|
281
|
+
# === matches a Regexp against the path and compares a String with it.
|
|
282
|
+
Profiler.configuration.skip_paths.none? { |pattern| pattern === path }
|
|
282
283
|
end
|
|
283
284
|
|
|
284
285
|
def build_request(env)
|
|
@@ -371,13 +372,7 @@ module Profiler
|
|
|
371
372
|
def read_buffered_body(body)
|
|
372
373
|
content =
|
|
373
374
|
begin
|
|
374
|
-
|
|
375
|
-
body.to_ary.join
|
|
376
|
-
else
|
|
377
|
-
parts = +""
|
|
378
|
-
body.each { |part| parts << part }
|
|
379
|
-
parts
|
|
380
|
-
end
|
|
375
|
+
join_parts(body.respond_to?(:to_ary) ? body.to_ary : body)
|
|
381
376
|
rescue Exception # rubocop:disable Lint/RescueException
|
|
382
377
|
begin
|
|
383
378
|
close_body(body)
|
|
@@ -390,6 +385,20 @@ module Profiler
|
|
|
390
385
|
content
|
|
391
386
|
end
|
|
392
387
|
|
|
388
|
+
# The fragments' bytes, joined as they are: fragments of different encodings (a binary one
|
|
389
|
+
# among UTF-8 ones) cannot be joined as text, and converting them would change the page.
|
|
390
|
+
# The result takes the encoding of the first fragment, as CapturingBody does.
|
|
391
|
+
def join_parts(parts)
|
|
392
|
+
content = String.new(encoding: Encoding::BINARY)
|
|
393
|
+
encoding = nil
|
|
394
|
+
parts.each do |part|
|
|
395
|
+
part = part.to_s
|
|
396
|
+
encoding ||= part.encoding
|
|
397
|
+
content << part.b
|
|
398
|
+
end
|
|
399
|
+
content.force_encoding(encoding || Encoding::UTF_8)
|
|
400
|
+
end
|
|
401
|
+
|
|
393
402
|
def close_body(body)
|
|
394
403
|
body.close if body.respond_to?(:close) && !(body.respond_to?(:closed?) && body.closed?)
|
|
395
404
|
end
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
require "fileutils"
|
|
4
4
|
require "json"
|
|
5
|
+
require "set"
|
|
5
6
|
require_relative "private_files"
|
|
6
7
|
require_relative "token"
|
|
7
8
|
|
|
@@ -12,6 +13,8 @@ module Profiler
|
|
|
12
13
|
# find nothing and writes are refused.
|
|
13
14
|
class BlobStore
|
|
14
15
|
NAME_FORMAT = /\A[a-z0-9_]+\z/
|
|
16
|
+
# A save writes its blobs before its row: prune leaves the directories this recent alone.
|
|
17
|
+
PRUNE_GRACE = 60
|
|
15
18
|
|
|
16
19
|
def initialize(path)
|
|
17
20
|
@path = path.to_s
|
|
@@ -46,6 +49,36 @@ module Profiler
|
|
|
46
49
|
FileUtils.rm_rf(dir) if File.directory?(dir)
|
|
47
50
|
end
|
|
48
51
|
|
|
52
|
+
# Removes one blob of token, and its directory once it holds no other.
|
|
53
|
+
def delete_blob(token, collector_name)
|
|
54
|
+
return unless valid?(token, collector_name)
|
|
55
|
+
|
|
56
|
+
dir = token_dir(token)
|
|
57
|
+
FileUtils.rm_f(File.join(dir, "#{collector_name}.json"))
|
|
58
|
+
Dir.rmdir(dir) if File.directory?(dir) && Dir.empty?(dir)
|
|
59
|
+
rescue SystemCallError => e
|
|
60
|
+
Profiler.log_error("BlobStore: could not delete #{token}/#{collector_name}", e)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# Removes the directories of the tokens not in except_tokens: those whose row was deleted
|
|
64
|
+
# outside the store, or that a failed save left behind. Only the directories named as a
|
|
65
|
+
# token and unchanged for grace seconds are considered.
|
|
66
|
+
def prune(except_tokens, grace: PRUNE_GRACE)
|
|
67
|
+
keep = except_tokens.to_set
|
|
68
|
+
cutoff = Time.now - grace
|
|
69
|
+
Dir.each_child(@path) do |name|
|
|
70
|
+
next if !Token.valid?(name) || keep.include?(name)
|
|
71
|
+
|
|
72
|
+
dir = token_dir(name)
|
|
73
|
+
stat = File.lstat(dir)
|
|
74
|
+
FileUtils.rm_rf(dir) if stat.directory? && stat.mtime < cutoff
|
|
75
|
+
rescue SystemCallError => e
|
|
76
|
+
Profiler.log_error("BlobStore: could not prune #{name}", e)
|
|
77
|
+
end
|
|
78
|
+
rescue SystemCallError => e
|
|
79
|
+
Profiler.log_error("BlobStore: could not prune #{@path}", e)
|
|
80
|
+
end
|
|
81
|
+
|
|
49
82
|
def exists?(token, collector_name)
|
|
50
83
|
return false unless valid?(token, collector_name)
|
|
51
84
|
|
|
@@ -28,6 +28,9 @@ module Profiler
|
|
|
28
28
|
# of an earlier version) is removed by one process, under a lock, by batches of INDEX_BATCH.
|
|
29
29
|
EVICT_BATCH = 100
|
|
30
30
|
EVICT_LOCK_SECONDS = 60
|
|
31
|
+
# A page that held expired profiles is read again, once they are out of the indexes, at
|
|
32
|
+
# most this many times.
|
|
33
|
+
LIST_REPAIRS = 3
|
|
31
34
|
# Deletes a lock only while it still holds the value its owner set: one that expired and was
|
|
32
35
|
# taken by another process is left to it.
|
|
33
36
|
RELEASE_LOCK = <<~LUA
|
|
@@ -50,7 +53,8 @@ module Profiler
|
|
|
50
53
|
data = profile.to_h
|
|
51
54
|
unindex(token, read_summary(token))
|
|
52
55
|
@redis.setex(profile_key(token), @ttl, data.to_json)
|
|
53
|
-
index(token, profile.started_at.to_f, Summary.build(data))
|
|
56
|
+
@redis.pipelined { |pipe| index(token, profile.started_at.to_f, Summary.build(data), client: pipe) }
|
|
57
|
+
prune_expired
|
|
54
58
|
evict_first_saved(token)
|
|
55
59
|
token
|
|
56
60
|
end
|
|
@@ -68,19 +72,18 @@ module Profiler
|
|
|
68
72
|
nil
|
|
69
73
|
end
|
|
70
74
|
|
|
71
|
-
# Newest first. summary: true reads the summaries only.
|
|
75
|
+
# Newest first. summary: true reads the summaries only. The profiles found expired are taken
|
|
76
|
+
# out of the indexes and the page read again, for it to hold limit profiles when there are.
|
|
72
77
|
def list(limit: 50, offset: 0, type: nil, summary: false)
|
|
73
78
|
ensure_index
|
|
74
|
-
|
|
75
|
-
|
|
79
|
+
key = type ? type_list_key(type) : list_key
|
|
80
|
+
LIST_REPAIRS.times do
|
|
81
|
+
page, expired = list_page(key, offset, limit, summary)
|
|
82
|
+
return page if expired.empty?
|
|
76
83
|
|
|
77
|
-
|
|
78
|
-
tokens.zip(summaries).filter_map do |token, json|
|
|
79
|
-
next Summary.to_profile(JSON.parse(json)) if json
|
|
80
|
-
|
|
81
|
-
profile = load(token)
|
|
82
|
-
profile && Summary.to_profile(Summary.build(profile))
|
|
84
|
+
remove_tokens(expired)
|
|
83
85
|
end
|
|
86
|
+
list_page(key, offset, limit, summary).first
|
|
84
87
|
end
|
|
85
88
|
|
|
86
89
|
def cleanup(older_than: 24 * 60 * 60)
|
|
@@ -118,6 +121,46 @@ module Profiler
|
|
|
118
121
|
Redis.new(url: url)
|
|
119
122
|
end
|
|
120
123
|
|
|
124
|
+
# The page at offset, and the tokens of it whose profile has expired.
|
|
125
|
+
def list_page(key, offset, limit, summary)
|
|
126
|
+
tokens = @redis.zrevrange(key, offset, offset + limit - 1)
|
|
127
|
+
return [[], []] if tokens.empty?
|
|
128
|
+
|
|
129
|
+
expired = []
|
|
130
|
+
page = if summary
|
|
131
|
+
summaries = @redis.mget(*tokens.map { |token| summary_key(token) })
|
|
132
|
+
tokens.zip(summaries).filter_map do |token, json|
|
|
133
|
+
next Summary.to_profile(JSON.parse(json)) if json
|
|
134
|
+
|
|
135
|
+
profile = load(token)
|
|
136
|
+
expired << token if profile.nil? && !@redis.exists?(profile_key(token))
|
|
137
|
+
profile && Summary.to_profile(Summary.build(profile))
|
|
138
|
+
end
|
|
139
|
+
else
|
|
140
|
+
profiles = @redis.mget(*tokens.map { |token| profile_key(token) })
|
|
141
|
+
tokens.zip(profiles).filter_map do |token, json|
|
|
142
|
+
if json.nil?
|
|
143
|
+
expired << token
|
|
144
|
+
next
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
Models::Profile.from_json(json)
|
|
148
|
+
rescue => e
|
|
149
|
+
Profiler.log_error("RedisStore: could not load profile #{token}", e)
|
|
150
|
+
nil
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
[page, expired]
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
# A profile expires a TTL after its last save, with its keys, and Redis says nothing to the
|
|
157
|
+
# indexes: the tokens saved before that are taken out of them at each save. The indexes
|
|
158
|
+
# themselves expire a TTL after the last save, when every profile they list has.
|
|
159
|
+
def prune_expired
|
|
160
|
+
expired = @redis.zrangebyscore(saved_key, "-inf", Time.now.to_f - @ttl)
|
|
161
|
+
expired.each_slice(INDEX_BATCH) { |tokens| remove_tokens(tokens) }
|
|
162
|
+
end
|
|
163
|
+
|
|
121
164
|
# score: the start time, the order of the lists. saved_at: the order of the saves, which the
|
|
122
165
|
# eviction follows.
|
|
123
166
|
def index(token, score, summary, saved_at: Time.now.to_f, client: @redis)
|
|
@@ -128,6 +171,7 @@ module Profiler
|
|
|
128
171
|
client.zadd(type_list_key(type), score, token)
|
|
129
172
|
client.zadd(saved_key, saved_at, token)
|
|
130
173
|
client.sadd(types_key, type)
|
|
174
|
+
[list_key, type_list_key(type), saved_key, types_key].each { |key| client.expire(key, @ttl) }
|
|
131
175
|
return unless Token.valid?(parent)
|
|
132
176
|
|
|
133
177
|
client.sadd(children_key(parent), token)
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
require "json"
|
|
4
4
|
require "fileutils"
|
|
5
|
+
require "time"
|
|
5
6
|
require_relative "base_store"
|
|
6
7
|
require_relative "blob_store"
|
|
7
8
|
require_relative "private_files"
|
|
@@ -14,6 +15,7 @@ module Profiler
|
|
|
14
15
|
class SqliteStore < BaseStore
|
|
15
16
|
# Past max_profiles, the oldest profiles are evicted down to this share of it.
|
|
16
17
|
LOW_WATER = 0.8
|
|
18
|
+
HTTP_BODIES = "http_response_bodies"
|
|
17
19
|
|
|
18
20
|
def initialize(options = {})
|
|
19
21
|
require "sqlite3"
|
|
@@ -40,13 +42,14 @@ module Profiler
|
|
|
40
42
|
data = profile.to_h
|
|
41
43
|
|
|
42
44
|
collectors_meta = {}
|
|
45
|
+
bodies_saved = false
|
|
43
46
|
(data[:collectors_data] || {}).each do |collector_name, collector_data|
|
|
44
47
|
next unless collector_data.is_a?(Hash)
|
|
45
48
|
|
|
46
49
|
if collector_name.to_s == "http"
|
|
47
50
|
requests = collector_data["requests"]
|
|
48
51
|
if requests
|
|
49
|
-
stripped = save_http_response_bodies(token, requests)
|
|
52
|
+
stripped, bodies_saved = save_http_response_bodies(token, requests)
|
|
50
53
|
collectors_meta[collector_name] = collector_data.merge("requests" => stripped)
|
|
51
54
|
else
|
|
52
55
|
collectors_meta[collector_name] = collector_data
|
|
@@ -55,6 +58,9 @@ module Profiler
|
|
|
55
58
|
collectors_meta[collector_name] = collector_data
|
|
56
59
|
end
|
|
57
60
|
end
|
|
61
|
+
# A profile saved again without response bodies keeps none of its previous save: they
|
|
62
|
+
# would be merged into its requests on load.
|
|
63
|
+
@blob_store.delete_blob(token, HTTP_BODIES) unless bodies_saved
|
|
58
64
|
|
|
59
65
|
@db.execute(
|
|
60
66
|
<<~SQL,
|
|
@@ -76,8 +82,8 @@ module Profiler
|
|
|
76
82
|
status: data[:status],
|
|
77
83
|
duration: data[:duration],
|
|
78
84
|
memory: data[:memory],
|
|
79
|
-
started_at: data[:started_at],
|
|
80
|
-
finished_at: data[:finished_at],
|
|
85
|
+
started_at: utc_iso8601(data[:started_at]),
|
|
86
|
+
finished_at: utc_iso8601(data[:finished_at]),
|
|
81
87
|
parent_token: data[:parent_token],
|
|
82
88
|
is_ajax: data[:is_ajax] ? 1 : 0,
|
|
83
89
|
tabs: JSON.generate(data[:tabs] || []),
|
|
@@ -157,6 +163,7 @@ module Profiler
|
|
|
157
163
|
).map { |r| r["token"] }
|
|
158
164
|
@db.execute("DELETE FROM profiler_profiles WHERE started_at < :cutoff", cutoff: cutoff)
|
|
159
165
|
tokens.each { |token| @blob_store.delete(token) }
|
|
166
|
+
prune_blobs
|
|
160
167
|
end
|
|
161
168
|
|
|
162
169
|
private
|
|
@@ -179,6 +186,12 @@ module Profiler
|
|
|
179
186
|
@db.execute("DELETE FROM profiler_profiles WHERE token IN (#{(["?"] * slice.size).join(",")})", slice)
|
|
180
187
|
end
|
|
181
188
|
tokens.each { |token| @blob_store.delete(token) }
|
|
189
|
+
prune_blobs
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
# The blobs of the rows deleted outside the store (a database removed or recreated).
|
|
193
|
+
def prune_blobs
|
|
194
|
+
@blob_store.prune(@db.execute("SELECT token FROM profiler_profiles").map { |r| r["token"] })
|
|
182
195
|
end
|
|
183
196
|
|
|
184
197
|
# A row written before the summary column falls back to the whole row, summarized.
|
|
@@ -195,24 +208,26 @@ module Profiler
|
|
|
195
208
|
nil
|
|
196
209
|
end
|
|
197
210
|
|
|
211
|
+
# The requests without their response bodies, and whether a blob of bodies was written.
|
|
198
212
|
def save_http_response_bodies(token, requests)
|
|
199
213
|
bodies = requests.map do |req|
|
|
200
214
|
{ "response_body" => req["response_body"], "response_body_encoding" => req["response_body_encoding"] }
|
|
201
215
|
end
|
|
202
216
|
|
|
203
|
-
return requests unless bodies.any? { |b| !b["response_body"].nil? }
|
|
217
|
+
return [requests, false] unless bodies.any? { |b| !b["response_body"].nil? }
|
|
204
218
|
|
|
205
|
-
@blob_store.write(token,
|
|
219
|
+
@blob_store.write(token, HTTP_BODIES, bodies)
|
|
206
220
|
|
|
207
|
-
requests.map do |req|
|
|
221
|
+
stripped = requests.map do |req|
|
|
208
222
|
req.reject { |k, _| %w[response_body response_body_encoding].include?(k) }
|
|
209
223
|
end
|
|
224
|
+
[stripped, true]
|
|
210
225
|
end
|
|
211
226
|
|
|
212
227
|
def load_http_response_bodies(token, requests)
|
|
213
228
|
return requests if requests.nil? || requests.empty?
|
|
214
229
|
|
|
215
|
-
bodies = @blob_store.read(token,
|
|
230
|
+
bodies = @blob_store.read(token, HTTP_BODIES)
|
|
216
231
|
return requests if bodies.nil? || bodies.empty?
|
|
217
232
|
|
|
218
233
|
requests.each_with_index.map do |req, i|
|
|
@@ -266,6 +281,26 @@ module Profiler
|
|
|
266
281
|
rescue SQLite3::Exception
|
|
267
282
|
# column already exists
|
|
268
283
|
end
|
|
284
|
+
|
|
285
|
+
migrate_times_to_utc
|
|
286
|
+
end
|
|
287
|
+
|
|
288
|
+
# Version 1 stores the times in UTC. The rows written before it carry the local offset of
|
|
289
|
+
# the process that saved them, and the default database outlives a restart: they are
|
|
290
|
+
# rewritten once, by SQLite, which reads the offset. A value it cannot read is left as is.
|
|
291
|
+
def migrate_times_to_utc
|
|
292
|
+
return if @db.get_first_value("PRAGMA user_version").to_i >= 1
|
|
293
|
+
|
|
294
|
+
@db.transaction do
|
|
295
|
+
%w[started_at finished_at].each do |column|
|
|
296
|
+
@db.execute(<<~SQL)
|
|
297
|
+
UPDATE profiler_profiles
|
|
298
|
+
SET #{column} = strftime('%Y-%m-%dT%H:%M:%SZ', #{column})
|
|
299
|
+
WHERE #{column} NOT LIKE '%Z' AND strftime('%Y-%m-%dT%H:%M:%SZ', #{column}) IS NOT NULL
|
|
300
|
+
SQL
|
|
301
|
+
end
|
|
302
|
+
@db.execute("PRAGMA user_version = 1")
|
|
303
|
+
end
|
|
269
304
|
end
|
|
270
305
|
|
|
271
306
|
def row_to_profile(row, load_blobs: true)
|
|
@@ -293,8 +328,8 @@ module Profiler
|
|
|
293
328
|
status: row["status"],
|
|
294
329
|
duration: row["duration"],
|
|
295
330
|
memory: row["memory"],
|
|
296
|
-
started_at: row["started_at"],
|
|
297
|
-
finished_at: row["finished_at"],
|
|
331
|
+
started_at: local_iso8601(row["started_at"]),
|
|
332
|
+
finished_at: local_iso8601(row["finished_at"]),
|
|
298
333
|
parent_token: row["parent_token"],
|
|
299
334
|
is_ajax: row["is_ajax"] == 1,
|
|
300
335
|
tabs: parse_json(row["tabs"], []),
|
|
@@ -312,6 +347,25 @@ module Profiler
|
|
|
312
347
|
nil
|
|
313
348
|
end
|
|
314
349
|
|
|
350
|
+
# The times are stored in UTC, all in the same form, for the cleanup and the lists to compare
|
|
351
|
+
# them as text; a profile gives them with the offset of the process that recorded it.
|
|
352
|
+
def utc_iso8601(value)
|
|
353
|
+
return value if value.nil?
|
|
354
|
+
|
|
355
|
+
Time.iso8601(value.to_s).utc.iso8601
|
|
356
|
+
rescue ArgumentError
|
|
357
|
+
value
|
|
358
|
+
end
|
|
359
|
+
|
|
360
|
+
# Read back in local time, as the profile gave them.
|
|
361
|
+
def local_iso8601(value)
|
|
362
|
+
return value if value.nil?
|
|
363
|
+
|
|
364
|
+
Time.iso8601(value).localtime.iso8601
|
|
365
|
+
rescue ArgumentError
|
|
366
|
+
value
|
|
367
|
+
end
|
|
368
|
+
|
|
315
369
|
def parse_json(value, default)
|
|
316
370
|
return default if value.nil? || value.empty?
|
|
317
371
|
|
|
@@ -1,17 +1,33 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
module Profiler
|
|
4
|
+
module Tasks
|
|
5
|
+
# The whole number an environment variable gives, or default when it is not set. An empty or
|
|
6
|
+
# non-numeric value ("", "abc", "1h") stops the task: read as 0, OLDER_THAN would delete every
|
|
7
|
+
# profile.
|
|
8
|
+
def self.integer_env(name, default, minimum:, unit:)
|
|
9
|
+
value = ENV[name]
|
|
10
|
+
return default if value.nil?
|
|
11
|
+
return Integer(value, 10) if value.match?(/\A\d+\z/) && Integer(value, 10) >= minimum
|
|
12
|
+
|
|
13
|
+
warn "#{name} must be a whole number of #{unit} (#{minimum} or more), got #{value.inspect}."
|
|
14
|
+
exit 1
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
|
|
3
19
|
namespace :profiler do
|
|
4
|
-
desc "Clean up old profiles"
|
|
20
|
+
desc "Clean up old profiles (OLDER_THAN: seconds, 86400 by default)"
|
|
5
21
|
task cleanup: :environment do
|
|
6
|
-
older_than =
|
|
22
|
+
older_than = Profiler::Tasks.integer_env("OLDER_THAN", 24 * 60 * 60, minimum: 0, unit: "seconds")
|
|
7
23
|
puts "Cleaning up profiles older than #{older_than} seconds..."
|
|
8
24
|
Profiler.storage.cleanup(older_than: older_than)
|
|
9
25
|
puts "Cleanup complete!"
|
|
10
26
|
end
|
|
11
27
|
|
|
12
|
-
desc "List recent profiles"
|
|
28
|
+
desc "List recent profiles (LIMIT: 20 by default)"
|
|
13
29
|
task list: :environment do
|
|
14
|
-
limit =
|
|
30
|
+
limit = Profiler::Tasks.integer_env("LIMIT", 20, minimum: 1, unit: "profiles")
|
|
15
31
|
profiles = Profiler.storage.list(limit: limit)
|
|
16
32
|
|
|
17
33
|
if profiles.empty?
|
data/lib/profiler/version.rb
CHANGED