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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: d632e07d28796ba7902cd20f9466b4c529c77ab76b5627c2b946e6b059b23c1f
4
- data.tar.gz: 7c91607d3ae24180f6708938a8dfa118ad297b11a6b9749cf155adeef5b81336
3
+ metadata.gz: 533fac499cbab38112f1be4ab7d066761f0f0cea1b22d76e4d355059ef03d9dd
4
+ data.tar.gz: b8d37a14501188a671be2ee4876b8ce9678641cca8acbd836e456ea62249ef48
5
5
  SHA512:
6
- metadata.gz: a7781894a3b105e730d47d63c0e7a2a15b28a58a51a38fc4662fb193c0665c1cdb8fd7072cadc7926f38a96e6a68a350ae19f16ef2560a119c89355fdbe3f4a6
7
- data.tar.gz: e626c7d2d344ad58473fdecbbbaf3bfae340faf17eb1dd25ca7f88325ffe87c9a9837fefcdc9b1dea3cdc3541ce46a4156d1d4b17acd3f4b049defd2c08fcd03
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
- rescue => e
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
- :skip_paths, :slow_query_threshold, :max_queries_warning, :sql_backtrace,
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, :http_skip_hosts, :http_backtrace_depth,
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| host.match?(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
- Profiler.configuration.skip_paths.none? { |pattern| path =~ pattern }
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
- if body.respond_to?(:to_ary)
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
- 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.1"
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.1
4
+ version: 0.33.3
5
5
  platform: ruby
6
6
  authors:
7
7
  - Sébastien Duplessy