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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 8e1caccb1662a8f09108682f5933e943d41a0b36ed7d60930c60eb8c385b5159
4
- data.tar.gz: c10d767853cc07caa36484f9693a4c4adeef415e0874d29ca817d8d7d1b17730
3
+ metadata.gz: 533fac499cbab38112f1be4ab7d066761f0f0cea1b22d76e4d355059ef03d9dd
4
+ data.tar.gz: b8d37a14501188a671be2ee4876b8ce9678641cca8acbd836e456ea62249ef48
5
5
  SHA512:
6
- metadata.gz: 8aacfc0469f52d9f6a11a58356eebb853ff716f2729ca47833b9b0ca99197f767a0dad5904f8c9e7d8a8501869dd712a0c5e6e246fa8ea0e55976b546589d1ac
7
- data.tar.gz: 3ce33e8db95c0ae4028a68e11d941bb72072b5a6edcf539cfc0fb918c2248d675c4df92e28788da0d59d942546458384b22ec5ac974bb58404be1d0049130627
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
- tokens = @redis.zrevrange(type ? type_list_key(type) : list_key, offset, offset + limit - 1)
75
- return tokens.filter_map { |token| load(token) } unless summary
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
- summaries = tokens.empty? ? [] : @redis.mget(*tokens.map { |token| summary_key(token) })
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, "http_response_bodies", bodies)
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, "http_response_bodies")
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 = ENV["OLDER_THAN"]&.to_i || 24 * 60 * 60
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 = ENV["LIMIT"]&.to_i || 20
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?
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Profiler
4
- VERSION = "0.33.2"
4
+ VERSION = "0.33.3"
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: rails-profiler
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.33.2
4
+ version: 0.33.3
5
5
  platform: ruby
6
6
  authors:
7
7
  - Sébastien Duplessy