rails-profiler 0.31.2 → 0.31.4

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.
Files changed (55) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +119 -0
  3. data/app/assets/builds/profiler.js +20 -4
  4. data/app/controllers/profiler/api/console_controller.rb +1 -11
  5. data/app/controllers/profiler/api/jobs_controller.rb +1 -11
  6. data/app/controllers/profiler/api/profiles_controller.rb +20 -28
  7. data/app/controllers/profiler/api/tests_controller.rb +1 -11
  8. data/app/controllers/profiler/api/toolbar_controller.rb +2 -20
  9. data/app/controllers/profiler/application_controller.rb +55 -15
  10. data/app/controllers/profiler/profiles_controller.rb +2 -16
  11. data/lib/profiler/ajax_data.rb +40 -0
  12. data/lib/profiler/cluster/slave_proxy.rb +7 -5
  13. data/lib/profiler/collectors/base_collector.rb +8 -3
  14. data/lib/profiler/collectors/cache_collector.rb +3 -3
  15. data/lib/profiler/collectors/database_collector.rb +128 -10
  16. data/lib/profiler/collectors/env_collector.rb +12 -4
  17. data/lib/profiler/collectors/exception_collector.rb +7 -9
  18. data/lib/profiler/collectors/flamegraph_collector.rb +5 -5
  19. data/lib/profiler/collectors/function_profiler_collector.rb +28 -4
  20. data/lib/profiler/collectors/mailer_collector.rb +2 -8
  21. data/lib/profiler/collectors/routes_collector.rb +7 -50
  22. data/lib/profiler/collectors/scoped_notifications.rb +229 -0
  23. data/lib/profiler/collectors/view_collector.rb +2 -2
  24. data/lib/profiler/configuration.rb +20 -5
  25. data/lib/profiler/instrumentation/executor_context_propagation.rb +47 -0
  26. data/lib/profiler/instrumentation/request_context.rb +77 -0
  27. data/lib/profiler/instrumentation/thread_context_propagation.rb +11 -16
  28. data/lib/profiler/job_profiler.rb +4 -2
  29. data/lib/profiler/mcp/resources/failing_tests.rb +1 -1
  30. data/lib/profiler/mcp/resources/recent_console.rb +2 -2
  31. data/lib/profiler/mcp/resources/recent_jobs.rb +2 -2
  32. data/lib/profiler/mcp/resources/slow_tests.rb +1 -1
  33. data/lib/profiler/mcp/server.rb +8 -2
  34. data/lib/profiler/mcp/tools/get_profile_ajax.rb +2 -0
  35. data/lib/profiler/mcp/tools/get_profile_detail.rb +8 -1
  36. data/lib/profiler/mcp/tools/get_test_profile_detail.rb +1 -1
  37. data/lib/profiler/mcp/tools/query_console_profiles.rb +1 -1
  38. data/lib/profiler/mcp/tools/query_jobs.rb +1 -1
  39. data/lib/profiler/mcp/tools/query_profiles.rb +1 -1
  40. data/lib/profiler/mcp/tools/query_test_profiles.rb +1 -1
  41. data/lib/profiler/mcp/tools/run_tests.rb +1 -1
  42. data/lib/profiler/middleware/profiler_middleware.rb +4 -1
  43. data/lib/profiler/process_snapshot.rb +157 -0
  44. data/lib/profiler/railtie.rb +3 -0
  45. data/lib/profiler/storage/file_store.rb +332 -58
  46. data/lib/profiler/storage/memory_store.rb +60 -31
  47. data/lib/profiler/storage/private_files.rb +13 -0
  48. data/lib/profiler/storage/redis_store.rb +251 -32
  49. data/lib/profiler/storage/sqlite_store.rb +57 -9
  50. data/lib/profiler/storage/summary.rb +50 -0
  51. data/lib/profiler/storage/unavailable.rb +58 -0
  52. data/lib/profiler/test_helpers/reporter.rb +1 -1
  53. data/lib/profiler/version.rb +1 -1
  54. data/lib/profiler.rb +17 -1
  55. metadata +8 -1
@@ -2,19 +2,38 @@
2
2
 
3
3
  require "concurrent-ruby"
4
4
  require_relative "base_store"
5
+ require_relative "summary"
5
6
  require_relative "../models/profile"
6
7
 
7
8
  module Profiler
8
9
  module Storage
10
+ # Keeps the profiles in the process, as the Hashes of Profile#to_h, at most max_profiles of
11
+ # them (100 when neither the option nor config.max_profiles sets it: this store always has a
12
+ # cap). Beside them, the start time, type and parent of each, and the children of each parent,
13
+ # so that listing, finding the children and evicting deserialize only what they return.
9
14
  class MemoryStore < BaseStore
15
+ Entry = Struct.new(:at, :type, :parent, :sequence)
16
+
10
17
  def initialize(options = {})
11
18
  @profiles = Concurrent::Hash.new
12
19
  @max_profiles = options[:max_profiles] || Profiler.configuration.max_profiles || 100
20
+ @mutex = Mutex.new
21
+ @entries = {}
22
+ @children = {}
23
+ @sequence = 0
24
+ @sorted = nil
13
25
  end
14
26
 
15
27
  def do_save(token, profile)
16
- cleanup_if_needed
17
- @profiles[token] = serialize_profile(profile)
28
+ data = serialize_profile(profile)
29
+ @mutex.synchronize do
30
+ cleanup_if_needed unless @profiles.key?(token)
31
+ remove_entry(token)
32
+ @profiles[token] = data
33
+ @entries[token] = Entry.new(profile.started_at.to_f, data[:profile_type].to_s, profile.parent_token, @sequence += 1)
34
+ (@children[profile.parent_token] ||= {})[token] = true if profile.parent_token
35
+ @sorted = nil
36
+ end
18
37
  token
19
38
  end
20
39
 
@@ -27,65 +46,75 @@ module Profiler
27
46
  deserialize_profile(data)
28
47
  end
29
48
 
30
- def list(limit: 50, offset: 0)
31
- @profiles.values
32
- .map { |data| deserialize_profile(data) }
33
- .sort_by { |p| p.started_at }
34
- .reverse
35
- .drop(offset)
36
- .take(limit)
49
+ # Newest first; summary: true leaves the bodies, headers and nested collector data out.
50
+ def list(limit: 50, offset: 0, type: nil, summary: false)
51
+ page = @mutex.synchronize do
52
+ tokens = sorted_tokens
53
+ tokens = tokens.select { |token| @entries[token].type == type.to_s } if type
54
+ tokens.drop(offset).first(limit).filter_map { |token| @profiles[token] }
55
+ end
56
+ page.map { |data| summary ? Summary.to_profile(Summary.build(data)) : deserialize_profile(data) }
37
57
  end
38
58
 
39
59
  def cleanup(older_than: 24 * 60 * 60)
40
- cutoff_time = Time.now - older_than
41
- @profiles.delete_if do |_token, data|
42
- profile = deserialize_profile(data)
43
- profile.started_at < cutoff_time
60
+ cutoff = (Time.now - older_than).to_f
61
+ @mutex.synchronize do
62
+ @entries.select { |_, entry| entry.at < cutoff }.each_key { |token| remove(token) }
44
63
  end
45
64
  end
46
65
 
47
66
  def find_by_parent(parent_token)
48
67
  return [] unless Token.valid?(parent_token)
49
68
 
50
- @profiles.values
51
- .map { |data| deserialize_profile(data) }
52
- .select { |profile| profile.parent_token == parent_token }
53
- .sort_by { |profile| profile.started_at }
69
+ children = @mutex.synchronize { (@children[parent_token] || {}).keys.filter_map { |token| @profiles[token] } }
70
+ children.map { |data| deserialize_profile(data) }.sort_by(&:started_at)
54
71
  end
55
72
 
56
73
  def delete(token)
57
74
  return unless Token.valid?(token)
58
75
 
59
- @profiles.delete(token)
76
+ @mutex.synchronize { remove(token) }
60
77
  end
61
78
 
62
79
  def clear(type: nil)
63
- if type.nil?
64
- @profiles.clear
65
- else
66
- @profiles.delete_if do |_token, data|
67
- deserialize_profile(data).profile_type == type.to_s
68
- end
80
+ @mutex.synchronize do
81
+ @entries.select { |_, entry| type.nil? || entry.type == type.to_s }.each_key { |token| remove(token) }
69
82
  end
70
83
  end
71
84
 
72
85
  private
73
86
 
87
+ # Called before a new profile is added: removes the first saved down to 80% of the cap, in the
88
+ # order of the saves (@entries keeps it), not of the starts: a job saved when it ends is new.
74
89
  def cleanup_if_needed
75
90
  return if @profiles.size < @max_profiles
76
91
 
77
- # Remove oldest profiles until we're under the limit
78
92
  profiles_to_remove = @profiles.size - (@max_profiles * 0.8).to_i
79
93
  return if profiles_to_remove <= 0
80
94
 
81
- sorted_tokens = @profiles.map do |token, data|
82
- profile = deserialize_profile(data)
83
- [token, profile.started_at]
84
- end.sort_by { |_, time| time }.map(&:first)
95
+ @entries.keys.first(profiles_to_remove).each { |token| remove(token) }
96
+ end
85
97
 
86
- sorted_tokens.take(profiles_to_remove).each do |token|
87
- @profiles.delete(token)
98
+ def remove(token)
99
+ removed = @profiles.delete(token)
100
+ remove_entry(token)
101
+ removed
102
+ end
103
+
104
+ def remove_entry(token)
105
+ entry = @entries.delete(token)
106
+ return unless entry
107
+
108
+ if entry.parent && (siblings = @children[entry.parent])
109
+ siblings.delete(token)
110
+ @children.delete(entry.parent) if siblings.empty?
88
111
  end
112
+ @sorted = nil
113
+ end
114
+
115
+ # Newest first; two profiles started in the same instant, the one saved last first.
116
+ def sorted_tokens
117
+ @sorted ||= @entries.sort_by { |_, entry| [-entry.at, -entry.sequence] }.map(&:first)
89
118
  end
90
119
 
91
120
  def serialize_profile(profile)
@@ -70,6 +70,19 @@ module Profiler
70
70
  FileUtils.rm_f(tmp) if tmp
71
71
  end
72
72
 
73
+ # Appends data to path in one write, creating it 0600 when missing: lines appended by
74
+ # several processes this way do not interleave.
75
+ def append(path, data)
76
+ File.open(path.to_s, File::WRONLY | File::APPEND | File::CREAT | nofollow, new_file_mode) do |file|
77
+ file.write(data)
78
+ end
79
+ end
80
+
81
+ # Opens path for reading, refusing a symbolic link while the modes are restricted.
82
+ def open_for_reading(path, &block)
83
+ File.open(path.to_s, File::RDONLY | nofollow, &block)
84
+ end
85
+
73
86
  # Opens path for reading and writing, creating it 0600 when missing.
74
87
  def open(path, &block)
75
88
  File.open(path.to_s, File::RDWR | File::CREAT | nofollow, new_file_mode, &block)
@@ -2,13 +2,38 @@
2
2
 
3
3
  require "redis"
4
4
  require "json"
5
+ require "securerandom"
6
+ require "time"
5
7
  require_relative "base_store"
8
+ require_relative "summary"
6
9
  require_relative "../models/profile"
7
10
 
8
11
  module Profiler
9
12
  module Storage
13
+ # Profiles as JSON strings with a TTL, listed by a sorted set scored by start time. Beside
14
+ # them: a sorted set per profile type, a set of children per parent and the summary each list
15
+ # shows, so that listing one type, a page of summaries or the children of a page reads only
16
+ # what it returns; and at most max_profiles profiles, the oldest evicted first. Data written by
17
+ # a version without these indexes is indexed once, on first use.
10
18
  class RedisStore < BaseStore
11
19
  DEFAULT_TTL = 24 * 60 * 60 # 24 hours
20
+ INDEX_VERSION = "1"
21
+ # Past max_profiles, the first saved profiles are evicted down to this share of it.
22
+ LOW_WATER = 0.8
23
+ # Indexing the data of an earlier version: tokens read per batch, and how long one process
24
+ # holds the right to do it.
25
+ INDEX_BATCH = 500
26
+ INDEX_LOCK_SECONDS = 60
27
+ # Past the cap, a save removes at most this many profiles itself; a larger backlog (the data
28
+ # of an earlier version) is removed by one process, under a lock, by batches of INDEX_BATCH.
29
+ EVICT_BATCH = 100
30
+ EVICT_LOCK_SECONDS = 60
31
+ # Deletes a lock only while it still holds the value its owner set: one that expired and was
32
+ # taken by another process is left to it.
33
+ RELEASE_LOCK = <<~LUA
34
+ if redis.call("get", KEYS[1]) == ARGV[1] then return redis.call("del", KEYS[1]) end
35
+ return 0
36
+ LUA
12
37
 
13
38
  attr_reader :redis
14
39
 
@@ -16,15 +41,17 @@ module Profiler
16
41
  @redis = options[:redis] || build_redis_client(options)
17
42
  @ttl = options[:ttl] || DEFAULT_TTL
18
43
  @key_prefix = options[:key_prefix] || "profiler"
44
+ @max_profiles = options.key?(:max_profiles) ? options[:max_profiles] : Profiler.configuration.max_profiles
45
+ @indexed = false
19
46
  end
20
47
 
21
48
  def do_save(token, profile)
22
- key = profile_key(token)
23
- @redis.setex(key, @ttl, profile.to_json)
24
-
25
- # Add to sorted set for listing
26
- @redis.zadd(list_key, profile.started_at.to_f, token)
27
-
49
+ ensure_index
50
+ data = profile.to_h
51
+ unindex(token, read_summary(token))
52
+ @redis.setex(profile_key(token), @ttl, data.to_json)
53
+ index(token, profile.started_at.to_f, Summary.build(data))
54
+ evict_first_saved(token)
28
55
  token
29
56
  end
30
57
 
@@ -41,50 +68,47 @@ module Profiler
41
68
  nil
42
69
  end
43
70
 
44
- def list(limit: 50, offset: 0)
45
- tokens = @redis.zrevrange(list_key, offset, offset + limit - 1)
46
- tokens.map { |token| load(token) }.compact
71
+ # Newest first. summary: true reads the summaries only.
72
+ def list(limit: 50, offset: 0, type: nil, summary: false)
73
+ 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
76
+
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))
83
+ end
47
84
  end
48
85
 
49
86
  def cleanup(older_than: 24 * 60 * 60)
87
+ ensure_index
50
88
  cutoff_time = Time.now.to_f - older_than
51
- @redis.zremrangebyscore(list_key, "-inf", cutoff_time)
89
+ @redis.zrangebyscore(list_key, "-inf", cutoff_time).each_slice(INDEX_BATCH) { |tokens| remove_tokens(tokens) }
52
90
  end
53
91
 
54
92
  def find_by_parent(parent_token)
55
93
  return [] unless Token.valid?(parent_token)
56
94
 
57
- # Get all tokens from the sorted set
58
- tokens = @redis.zrange(list_key, 0, -1)
59
-
60
- # Load each profile and filter by parent_token
61
- tokens.map { |token| load(token) }
62
- .compact
95
+ ensure_index
96
+ @redis.smembers(children_key(parent_token))
97
+ .filter_map { |token| load(token) if Token.valid?(token) }
63
98
  .select { |profile| profile.parent_token == parent_token }
64
- .sort_by { |profile| profile.started_at }
99
+ .sort_by(&:started_at)
65
100
  end
66
101
 
67
102
  def delete(token)
68
103
  return unless Token.valid?(token)
69
104
 
70
- @redis.del(profile_key(token))
71
- @redis.zrem(list_key, token)
105
+ ensure_index
106
+ remove(token)
72
107
  end
73
108
 
74
109
  def clear(type: nil)
75
- tokens = @redis.zrange(list_key, 0, -1)
76
- tokens.each do |token|
77
- if type.nil?
78
- @redis.del(profile_key(token))
79
- @redis.zrem(list_key, token)
80
- else
81
- profile = load(token)
82
- if profile&.profile_type == type.to_s
83
- @redis.del(profile_key(token))
84
- @redis.zrem(list_key, token)
85
- end
86
- end
87
- end
110
+ ensure_index
111
+ @redis.zrange(type ? type_list_key(type) : list_key, 0, -1).each_slice(INDEX_BATCH) { |tokens| remove_tokens(tokens) }
88
112
  end
89
113
 
90
114
  private
@@ -94,6 +118,169 @@ module Profiler
94
118
  Redis.new(url: url)
95
119
  end
96
120
 
121
+ # score: the start time, the order of the lists. saved_at: the order of the saves, which the
122
+ # eviction follows.
123
+ def index(token, score, summary, saved_at: Time.now.to_f, client: @redis)
124
+ type = (summary[:profile_type] || summary["profile_type"] || "http").to_s
125
+ parent = summary[:parent_token] || summary["parent_token"]
126
+ client.setex(summary_key(token), @ttl, JSON.generate(summary))
127
+ client.zadd(list_key, score, token)
128
+ client.zadd(type_list_key(type), score, token)
129
+ client.zadd(saved_key, saved_at, token)
130
+ client.sadd(types_key, type)
131
+ return unless Token.valid?(parent)
132
+
133
+ client.sadd(children_key(parent), token)
134
+ client.expire(children_key(parent), @ttl)
135
+ end
136
+
137
+ # Takes token out of the type list and the children set its previous save put it in.
138
+ def unindex(token, summary)
139
+ return unless summary
140
+
141
+ @redis.zrem(type_list_key(summary["profile_type"] || "http"), token)
142
+ parent = summary["parent_token"]
143
+ @redis.srem(children_key(parent), token) if Token.valid?(parent)
144
+ end
145
+
146
+ def remove(token)
147
+ remove_tokens([token])
148
+ end
149
+
150
+ # Removes tokens and their index entries in three round trips, whatever their number: their
151
+ # summaries (type and parent), the types when a summary is missing, then one pipeline.
152
+ def remove_tokens(tokens)
153
+ tokens = tokens.select { |token| Token.valid?(token) }
154
+ return if tokens.empty?
155
+
156
+ summaries = @redis.mget(*tokens.map { |token| summary_key(token) }).map { |json| parse_summary(json) }
157
+ types = summaries.all? ? [] : @redis.smembers(types_key)
158
+ @redis.pipelined do |pipe|
159
+ tokens.zip(summaries).each do |token, summary|
160
+ if summary
161
+ pipe.zrem(type_list_key(summary["profile_type"] || "http"), token)
162
+ parent = summary["parent_token"]
163
+ pipe.srem(children_key(parent), token) if Token.valid?(parent)
164
+ else
165
+ types.each { |type| pipe.zrem(type_list_key(type), token) }
166
+ end
167
+ pipe.del(profile_key(token), summary_key(token))
168
+ end
169
+ pipe.zrem(list_key, tokens)
170
+ pipe.zrem(saved_key, tokens)
171
+ end
172
+ end
173
+
174
+ def parse_summary(json)
175
+ json && JSON.parse(json)
176
+ rescue JSON::ParserError
177
+ nil
178
+ end
179
+
180
+ def read_summary(token)
181
+ parse_summary(@redis.get(summary_key(token)))
182
+ end
183
+
184
+ # In the order of the saves, not of the starts: a job saved when it ends is a new profile,
185
+ # and the one just saved (keep) is never evicted. ZCARD is O(1): the eviction costs nothing
186
+ # until the cap is passed. A backlog larger than EVICT_BATCH is removed by one process, under
187
+ # a lock, by batches; meanwhile the other saves remove EVICT_BATCH profiles each at most.
188
+ def evict_first_saved(keep)
189
+ return unless @max_profiles
190
+
191
+ target = [(@max_profiles * LOW_WATER).floor, 1].max
192
+ excess = @redis.zcard(saved_key) - target
193
+ return if excess <= @max_profiles - target
194
+
195
+ if excess <= EVICT_BATCH
196
+ remove_tokens(@redis.zrange(saved_key, 0, excess - 1) - [keep])
197
+ else
198
+ evicted_all = with_lock(evict_lock_key, EVICT_LOCK_SECONDS) do
199
+ loop do
200
+ excess = @redis.zcard(saved_key) - target
201
+ break if excess <= 0
202
+
203
+ batch = @redis.zrange(saved_key, 0, [excess, INDEX_BATCH].min - 1) - [keep]
204
+ break if batch.empty?
205
+
206
+ remove_tokens(batch)
207
+ end
208
+ end
209
+ remove_tokens(@redis.zrange(saved_key, 0, EVICT_BATCH - 1) - [keep]) unless evicted_all
210
+ end
211
+ end
212
+
213
+ # Runs the block holding key, set to a value of this call's own; false, without running it,
214
+ # when another process holds it.
215
+ def with_lock(key, seconds)
216
+ owner = SecureRandom.hex(16)
217
+ return false unless @redis.set(key, owner, nx: true, ex: seconds)
218
+
219
+ begin
220
+ yield
221
+ ensure
222
+ @redis.eval(RELEASE_LOCK, keys: [key], argv: [owner])
223
+ end
224
+ true
225
+ end
226
+
227
+ # Indexes, once, the profiles saved by a version that kept only the list, which it never
228
+ # pruned: the tokens started long before the TTL first, then the others read by batches.
229
+ # One process does it, under a lock; the others go on meanwhile, with the list alone.
230
+ def ensure_index
231
+ return if @indexed
232
+ return @indexed = true if @redis.get(index_version_key) == INDEX_VERSION
233
+
234
+ with_lock(index_lock_key, INDEX_LOCK_SECONDS) do
235
+ # An hour of margin for a profile saved long after it started.
236
+ @redis.zremrangebyscore(list_key, "-inf", Time.now.to_f - @ttl - 3600)
237
+ tokens = @redis.zrange(list_key, 0, -1)
238
+ # Past the cap, the first saved would be evicted right after: removed without indexing.
239
+ if @max_profiles && tokens.size > @max_profiles
240
+ target = [(@max_profiles * LOW_WATER).floor, 1].max
241
+ tokens.first(tokens.size - target).each_slice(INDEX_BATCH) { |batch| drop_unindexed(batch) }
242
+ tokens = tokens.last(target)
243
+ end
244
+ tokens.each_slice(INDEX_BATCH) { |batch| index_batch(batch) }
245
+ @redis.set(index_version_key, INDEX_VERSION)
246
+ @indexed = true
247
+ end
248
+ end
249
+
250
+ # Profiles of an earlier version, which only the list and their own key know.
251
+ def drop_unindexed(tokens)
252
+ @redis.pipelined do |pipe|
253
+ tokens.each { |token| pipe.del(profile_key(token)) if Token.valid?(token) }
254
+ pipe.zrem(list_key, tokens)
255
+ end
256
+ end
257
+
258
+ def index_batch(tokens)
259
+ tokens = tokens.select { |token| Token.valid?(token) }
260
+ return if tokens.empty?
261
+
262
+ expired = []
263
+ profiles = tokens.zip(@redis.mget(*tokens.map { |token| profile_key(token) })).filter_map do |token, json|
264
+ if json.nil?
265
+ expired << token
266
+ next
267
+ end
268
+
269
+ # The summary from the parsed JSON, without building the Profile (and inflating its bodies).
270
+ data = JSON.parse(json, symbolize_names: true)
271
+ [token, data[:started_at] ? Time.parse(data[:started_at]).to_f : 0.0, Summary.build(data)]
272
+ rescue StandardError => e
273
+ warn "RedisStore: could not index profile #{token}: #{e.message}"
274
+ nil
275
+ end
276
+ @redis.pipelined do |pipe|
277
+ profiles.each do |token, started_at, summary|
278
+ index(token, started_at, summary, saved_at: started_at, client: pipe)
279
+ end
280
+ end
281
+ @redis.zrem(list_key, expired) unless expired.empty?
282
+ end
283
+
97
284
  def profile_key(token)
98
285
  "#{@key_prefix}:#{token}"
99
286
  end
@@ -101,6 +288,38 @@ module Profiler
101
288
  def list_key
102
289
  "#{@key_prefix}:list"
103
290
  end
291
+
292
+ def type_list_key(type)
293
+ "#{@key_prefix}:list:#{type}"
294
+ end
295
+
296
+ def types_key
297
+ "#{@key_prefix}:types"
298
+ end
299
+
300
+ def summary_key(token)
301
+ "#{@key_prefix}:summary:#{token}"
302
+ end
303
+
304
+ def children_key(parent_token)
305
+ "#{@key_prefix}:children:#{parent_token}"
306
+ end
307
+
308
+ def index_version_key
309
+ "#{@key_prefix}:index_version"
310
+ end
311
+
312
+ def index_lock_key
313
+ "#{@key_prefix}:index_lock"
314
+ end
315
+
316
+ def evict_lock_key
317
+ "#{@key_prefix}:evict_lock"
318
+ end
319
+
320
+ def saved_key
321
+ "#{@key_prefix}:saved"
322
+ end
104
323
  end
105
324
  end
106
325
  end
@@ -5,15 +5,21 @@ require "fileutils"
5
5
  require_relative "base_store"
6
6
  require_relative "blob_store"
7
7
  require_relative "private_files"
8
+ require_relative "summary"
8
9
  require_relative "token"
9
10
  require_relative "../models/profile"
10
11
 
11
12
  module Profiler
12
13
  module Storage
13
14
  class SqliteStore < BaseStore
15
+ # Past max_profiles, the oldest profiles are evicted down to this share of it.
16
+ LOW_WATER = 0.8
17
+
14
18
  def initialize(options = {})
15
19
  require "sqlite3"
16
20
 
21
+ @max_profiles = options.key?(:max_profiles) ? options[:max_profiles] : Profiler.configuration.max_profiles
22
+
17
23
  db_path = (options[:database] || default_db_path).to_s
18
24
  blob_path = options[:blob_path] || PrivateFiles.tmp_dir("blobs")
19
25
 
@@ -55,11 +61,11 @@ module Profiler
55
61
  INSERT OR REPLACE INTO profiler_profiles (
56
62
  token, profile_type, gem_version, path, method, status, duration, memory,
57
63
  started_at, finished_at, parent_token, is_ajax,
58
- tabs, params, headers, response_headers, collectors_meta
64
+ tabs, params, headers, response_headers, collectors_meta, summary
59
65
  ) VALUES (
60
66
  :token, :profile_type, :gem_version, :path, :method, :status, :duration, :memory,
61
67
  :started_at, :finished_at, :parent_token, :is_ajax,
62
- :tabs, :params, :headers, :response_headers, :collectors_meta
68
+ :tabs, :params, :headers, :response_headers, :collectors_meta, :summary
63
69
  )
64
70
  SQL
65
71
  token: token,
@@ -78,8 +84,10 @@ module Profiler
78
84
  params: JSON.generate(data[:params] || {}),
79
85
  headers: JSON.generate(data[:headers] || {}),
80
86
  response_headers: JSON.generate(data[:response_headers] || {}),
81
- collectors_meta: JSON.generate(collectors_meta)
87
+ collectors_meta: JSON.generate(collectors_meta),
88
+ summary: JSON.generate(Summary.build(data))
82
89
  )
90
+ evict_oldest
83
91
 
84
92
  token
85
93
  end
@@ -98,12 +106,18 @@ module Profiler
98
106
  nil
99
107
  end
100
108
 
101
- def list(limit: 50, offset: 0)
109
+ # Newest first. summary: true reads the summary column only, for the rows that have it.
110
+ def list(limit: 50, offset: 0, type: nil, summary: false)
111
+ where = type ? "WHERE profile_type = :type" : ""
112
+ # The summaries need neither the params, headers and tabs nor the collector data.
113
+ columns = summary ? "token, summary" : "*"
102
114
  rows = @db.execute(
103
- "SELECT * FROM profiler_profiles ORDER BY started_at DESC LIMIT :limit OFFSET :offset",
104
- limit: limit, offset: offset
115
+ "SELECT #{columns} FROM profiler_profiles #{where} ORDER BY started_at DESC, rowid DESC LIMIT :limit OFFSET :offset",
116
+ { limit: limit, offset: offset }.merge(type ? { type: type.to_s } : {})
105
117
  )
106
- rows.map { |row| row_to_profile(row, load_blobs: false) }.compact
118
+ return rows.filter_map { |row| row_to_profile(row, load_blobs: false) } unless summary
119
+
120
+ rows.filter_map { |row| row_to_summary(row) }
107
121
  end
108
122
 
109
123
  def find_by_parent(parent_token)
@@ -147,6 +161,40 @@ module Profiler
147
161
 
148
162
  private
149
163
 
164
+ # In the order the profiles were written (rowid: INSERT OR REPLACE gives a saved again
165
+ # profile a new one), not started: a job saved when it ends is a new profile, and the one
166
+ # just written is never evicted. One query on the rowid tells whether the table holds more
167
+ # than max_profiles.
168
+ def evict_oldest
169
+ return unless @max_profiles
170
+ return unless @db.get_first_value(
171
+ "SELECT 1 FROM profiler_profiles ORDER BY rowid DESC LIMIT 1 OFFSET :max", max: @max_profiles
172
+ )
173
+
174
+ keep = [(@max_profiles * LOW_WATER).floor, 1].max
175
+ tokens = @db.execute(
176
+ "SELECT token FROM profiler_profiles ORDER BY rowid DESC LIMIT -1 OFFSET :keep", keep: keep
177
+ ).map { |r| r["token"] }
178
+ tokens.each_slice(500) do |slice|
179
+ @db.execute("DELETE FROM profiler_profiles WHERE token IN (#{(["?"] * slice.size).join(",")})", slice)
180
+ end
181
+ tokens.each { |token| @blob_store.delete(token) }
182
+ end
183
+
184
+ # A row written before the summary column falls back to the whole row, summarized.
185
+ def row_to_summary(row)
186
+ if row["summary"].nil? || row["summary"].empty?
187
+ full = @db.get_first_row("SELECT * FROM profiler_profiles WHERE token = :token", token: row["token"])
188
+ profile = full && row_to_profile(full, load_blobs: false)
189
+ return profile && Summary.to_profile(Summary.build(profile))
190
+ end
191
+
192
+ Summary.to_profile(JSON.parse(row["summary"]))
193
+ rescue JSON::ParserError => e
194
+ warn "SqliteStore: failed to read the summary of #{row["token"]}: #{e.message}"
195
+ nil
196
+ end
197
+
150
198
  def save_http_response_bodies(token, requests)
151
199
  bodies = requests.map do |req|
152
200
  { "response_body" => req["response_body"], "response_body_encoding" => req["response_body_encoding"] }
@@ -213,8 +261,8 @@ module Profiler
213
261
  ON profiler_profiles(profile_type);
214
262
  SQL
215
263
 
216
- begin
217
- @db.execute("ALTER TABLE profiler_profiles ADD COLUMN gem_version TEXT")
264
+ %w[gem_version summary].each do |column|
265
+ @db.execute("ALTER TABLE profiler_profiles ADD COLUMN #{column} TEXT")
218
266
  rescue SQLite3::Exception
219
267
  # column already exists
220
268
  end
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../models/profile"
4
+
5
+ module Profiler
6
+ module Storage
7
+ # What the profile lists show of a profile, for the stores to keep apart from the profile
8
+ # itself: the top-level fields without the bodies, params, headers and tabs, and for each
9
+ # collector its scalar values only (database.total_queries, job.queue, test.test_name...),
10
+ # nested lists and hashes left out, strings cut at MAX_SCALAR_LENGTH (a console return value,
11
+ # an exception message). Built from Profile#to_h, after the collectors and the
12
+ # redaction have run: a summary is a subset of the stored profile (a long string cut to its
13
+ # start) and holds no value the profile does not.
14
+ module Summary
15
+ MAX_SCALAR_LENGTH = 500
16
+
17
+ DROPPED = %i[params headers response_headers request_body request_body_encoding
18
+ response_body response_body_encoding tabs].freeze
19
+
20
+ module_function
21
+
22
+ # From a Profile, or the Hash its #to_h returns.
23
+ def build(profile)
24
+ data = profile.is_a?(Hash) ? profile : profile.to_h
25
+ summary = data.transform_keys(&:to_sym).except(*DROPPED)
26
+ summary[:collectors_data] = (summary[:collectors_data] || {}).to_h do |name, values|
27
+ [name.to_s, values.is_a?(Hash) ? scalars(values) : {}]
28
+ end
29
+ summary
30
+ end
31
+
32
+ # The Profile a list shows, from a summary with string or symbol keys.
33
+ def to_profile(summary)
34
+ Models::Profile.from_hash(summary.transform_keys(&:to_sym))
35
+ end
36
+
37
+ def scalars(values)
38
+ values.each_with_object({}) do |(key, value), kept|
39
+ next unless scalar?(value)
40
+
41
+ kept[key.to_s] = value.is_a?(String) && value.length > MAX_SCALAR_LENGTH ? value[0, MAX_SCALAR_LENGTH] : value
42
+ end
43
+ end
44
+
45
+ def scalar?(value)
46
+ value.nil? || value.is_a?(String) || value.is_a?(Numeric) || value == true || value == false
47
+ end
48
+ end
49
+ end
50
+ end