rails-profiler 0.33.2 → 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 +29 -0
- 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,35 @@ 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
|
+
|
|
22
51
|
## [0.33.2] - 2026-10-14
|
|
23
52
|
|
|
24
53
|
<!-- stamped -->
|
|
@@ -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