rails-profiler 0.31.0 → 0.31.2

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 (49) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +139 -0
  3. data/app/assets/builds/profiler-toolbar.js +13 -6
  4. data/app/assets/builds/profiler.js +53 -33
  5. data/app/controllers/profiler/api/ajax_controller.rb +6 -0
  6. data/app/controllers/profiler/api/env_vars_controller.rb +10 -0
  7. data/lib/profiler/allocation_counter.rb +21 -0
  8. data/lib/profiler/cluster/slave_proxy.rb +6 -0
  9. data/lib/profiler/collectors/base_collector.rb +31 -0
  10. data/lib/profiler/collectors/cache_collector.rb +5 -0
  11. data/lib/profiler/collectors/database_collector.rb +5 -0
  12. data/lib/profiler/collectors/exception_collector.rb +5 -0
  13. data/lib/profiler/collectors/flamegraph_collector.rb +5 -0
  14. data/lib/profiler/collectors/http_collector.rb +6 -1
  15. data/lib/profiler/collectors/log_collector.rb +5 -0
  16. data/lib/profiler/collectors/request_collector.rb +14 -11
  17. data/lib/profiler/collectors/view_collector.rb +5 -0
  18. data/lib/profiler/configuration.rb +33 -7
  19. data/lib/profiler/console_profiler.rb +3 -18
  20. data/lib/profiler/env_override_store.rb +54 -43
  21. data/lib/profiler/job_profiler.rb +3 -16
  22. data/lib/profiler/mcp/body_formatter.rb +4 -4
  23. data/lib/profiler/mcp/file_cache.rb +19 -7
  24. data/lib/profiler/mcp/resources/recent_requests.rb +2 -0
  25. data/lib/profiler/mcp/tools/delete_env_var.rb +3 -1
  26. data/lib/profiler/mcp/tools/get_profile_detail.rb +13 -1
  27. data/lib/profiler/mcp/tools/get_test_profile_detail.rb +1 -1
  28. data/lib/profiler/mcp/tools/list_env_vars.rb +3 -0
  29. data/lib/profiler/mcp/tools/reset_all_env_vars.rb +3 -0
  30. data/lib/profiler/mcp/tools/reset_env_var.rb +3 -0
  31. data/lib/profiler/mcp/tools/set_env_var.rb +2 -0
  32. data/lib/profiler/middleware/capturing_body.rb +112 -0
  33. data/lib/profiler/middleware/profiler_middleware.rb +261 -53
  34. data/lib/profiler/middleware/streamed_profile.rb +135 -0
  35. data/lib/profiler/middleware/toolbar_injector.rb +3 -10
  36. data/lib/profiler/models/profile.rb +69 -5
  37. data/lib/profiler/redaction.rb +10 -0
  38. data/lib/profiler/storage/base_store.rb +5 -1
  39. data/lib/profiler/storage/blob_store.rb +28 -4
  40. data/lib/profiler/storage/file_store.rb +21 -15
  41. data/lib/profiler/storage/memory_store.rb +6 -0
  42. data/lib/profiler/storage/private_files.rb +135 -0
  43. data/lib/profiler/storage/redis_store.rb +6 -0
  44. data/lib/profiler/storage/sqlite_store.rb +26 -8
  45. data/lib/profiler/storage/token.rb +24 -0
  46. data/lib/profiler/test_profiler.rb +3 -18
  47. data/lib/profiler/version.rb +1 -1
  48. data/lib/profiler.rb +12 -0
  49. metadata +10 -5
@@ -5,17 +5,21 @@ require "securerandom"
5
5
  require "json"
6
6
  require "zlib"
7
7
  require_relative "../redaction"
8
+ require_relative "../allocation_counter"
8
9
 
9
10
  module Profiler
10
11
  module Models
11
12
  class Profile
12
13
  attr_reader :path
13
- attr_accessor :token, :method, :status, :duration, :memory,
14
+ attr_accessor :token, :method, :status, :duration, :allocated_objects,
14
15
  :started_at, :finished_at, :params, :headers,
15
16
  :response_headers, :collectors_data, :collectors_metadata,
16
17
  :parent_token, :is_ajax, :profile_type,
17
18
  :request_body, :request_body_encoding,
18
19
  :response_body, :response_body_encoding,
20
+ :request_body_size, :request_body_truncated, :request_body_size_is_minimum,
21
+ :response_body_size, :response_body_truncated, :response_body_size_is_minimum,
22
+ :collectors_released_after_seconds,
19
23
  :gem_version
20
24
 
21
25
  def initialize(request = nil)
@@ -35,13 +39,32 @@ module Profiler
35
39
  end
36
40
  end
37
41
 
38
- def set_bodies(request_body:, response_body:, req_content_type:, resp_content_type:)
42
+ # The sizes are those of the whole bodies, when a body was cut at max_captured_body_bytes;
43
+ # *_size_is_minimum when the whole size is not known (no Content-Length, a stream that
44
+ # stopped), only that it is at least that.
45
+ def set_bodies(request_body:, response_body:, req_content_type:, resp_content_type:,
46
+ request_body_size: nil, response_body_size: nil,
47
+ request_body_size_is_minimum: false, response_body_size_is_minimum: false)
39
48
  req = process_body(request_body, req_content_type)
40
49
  resp = process_body(response_body, resp_content_type)
41
50
  @request_body = req[:body]
42
51
  @request_body_encoding = req[:encoding]
43
52
  @response_body = resp[:body]
44
53
  @response_body_encoding = resp[:encoding]
54
+ @request_body_size, @request_body_truncated = body_size(request_body, request_body_size)
55
+ @response_body_size, @response_body_truncated = body_size(response_body, response_body_size)
56
+ @request_body_size_is_minimum = request_body_size_is_minimum ? true : false
57
+ @response_body_size_is_minimum = response_body_size_is_minimum ? true : false
58
+ end
59
+
60
+ # Deprecated: the number of allocated objects times 40, the figure earlier versions
61
+ # reported as bytes. Still written in to_h, for the readers that only know it.
62
+ def memory
63
+ @allocated_objects&.*(AllocationCounter::LEGACY_BYTES_PER_OBJECT)
64
+ end
65
+
66
+ def memory=(value)
67
+ @allocated_objects = value && value / AllocationCounter::LEGACY_BYTES_PER_OBJECT
45
68
  end
46
69
 
47
70
  def finish(status, response_headers = {})
@@ -67,8 +90,20 @@ module Profiler
67
90
  end
68
91
 
69
92
  def add_collector_metadata(collector)
93
+ @collectors_metadata << collector_metadata(collector)
94
+ end
95
+
96
+ # A collector collected again, after the profile's tabs were listed (a streamed response
97
+ # that failed part way): its tab keeps its place, with what it now has.
98
+ def refresh_collector_metadata(collector)
99
+ entry = collector_metadata(collector)
100
+ index = @collectors_metadata.index { |tab| tab[:key] == entry[:key] }
101
+ index ? @collectors_metadata[index] = entry : @collectors_metadata << entry
102
+ end
103
+
104
+ def collector_metadata(collector)
70
105
  config = collector.tab_config
71
- @collectors_metadata << {
106
+ {
72
107
  key: config[:key],
73
108
  label: config[:label],
74
109
  icon: config[:icon],
@@ -79,6 +114,7 @@ module Profiler
79
114
  has_data: collector.has_data?
80
115
  }
81
116
  end
117
+ private :collector_metadata
82
118
 
83
119
  def to_h
84
120
  req_body, req_enc = decode_body(@request_body, @request_body_encoding)
@@ -92,7 +128,8 @@ module Profiler
92
128
  method: @method,
93
129
  status: @status,
94
130
  duration: @duration,
95
- memory: @memory,
131
+ allocated_objects: @allocated_objects,
132
+ memory: memory,
96
133
  started_at: @started_at&.iso8601,
97
134
  finished_at: @finished_at&.iso8601,
98
135
  params: @params,
@@ -102,6 +139,13 @@ module Profiler
102
139
  request_body_encoding: req_enc,
103
140
  response_body: resp_body,
104
141
  response_body_encoding: resp_enc,
142
+ request_body_size: @request_body_size,
143
+ request_body_truncated: @request_body_truncated,
144
+ request_body_size_is_minimum: @request_body_size_is_minimum,
145
+ response_body_size: @response_body_size,
146
+ response_body_truncated: @response_body_truncated,
147
+ response_body_size_is_minimum: @response_body_size_is_minimum,
148
+ collectors_released_after_seconds: @collectors_released_after_seconds,
105
149
  collectors_data: @collectors_data,
106
150
  tabs: @collectors_metadata,
107
151
  parent_token: @parent_token,
@@ -125,7 +169,12 @@ module Profiler
125
169
  profile.method = data[:method]
126
170
  profile.status = data[:status]
127
171
  profile.duration = data[:duration]
128
- profile.memory = data[:memory]
172
+ # A profile saved before allocated_objects only carries memory, the count times 40.
173
+ if data.key?(:allocated_objects)
174
+ profile.allocated_objects = data[:allocated_objects]
175
+ else
176
+ profile.memory = data[:memory]
177
+ end
129
178
  profile.started_at = data[:started_at] ? Time.parse(data[:started_at]) : nil
130
179
  profile.finished_at = data[:finished_at] ? Time.parse(data[:finished_at]) : nil
131
180
  profile.params = data[:params]
@@ -135,6 +184,15 @@ module Profiler
135
184
  profile.request_body_encoding = data[:request_body_encoding] || "text"
136
185
  profile.response_body = data[:response_body]
137
186
  profile.response_body_encoding = data[:response_body_encoding] || "text"
187
+ profile.request_body_size = data[:request_body_size]
188
+ # Left nil when the store did not keep them (SqliteStore rebuilds a profile from its
189
+ # columns): the request collector's data still has them.
190
+ profile.request_body_truncated = data[:request_body_truncated]
191
+ profile.request_body_size_is_minimum = data[:request_body_size_is_minimum]
192
+ profile.response_body_size = data[:response_body_size]
193
+ profile.response_body_truncated = data[:response_body_truncated]
194
+ profile.response_body_size_is_minimum = data[:response_body_size_is_minimum]
195
+ profile.collectors_released_after_seconds = data[:collectors_released_after_seconds]
138
196
  profile.parent_token = data[:parent_token]
139
197
  profile.is_ajax = data[:is_ajax] || false
140
198
  profile.profile_type = data[:profile_type] || "http"
@@ -181,6 +239,12 @@ module Profiler
181
239
  end
182
240
  end
183
241
 
242
+ def body_size(captured, total)
243
+ captured_size = captured.to_s.bytesize
244
+ total ||= captured_size
245
+ [total, total > captured_size]
246
+ end
247
+
184
248
  def compress_body?(text)
185
249
  Profiler.configuration.compress_bodies &&
186
250
  text.bytesize > Profiler.configuration.compress_body_threshold
@@ -88,6 +88,16 @@ module Profiler
88
88
  text.length > max ? "#{text[0, max]}#{ellipsis}" : text
89
89
  end
90
90
 
91
+ # The first +max+ bytes of +text+, a body kept only in part. A credential cut in two by the
92
+ # limit would escape the masking by value, which runs later on what is kept: the bytes
93
+ # that could start one are left out as well. Text no longer than +max+ is returned whole.
94
+ def cut_bytes(text, max)
95
+ return text if text.bytesize <= max
96
+
97
+ cut = text.byteslice(0, max)
98
+ cut.byteslice(0, cut.bytesize - held_back_bytes(cut))
99
+ end
100
+
91
101
  # How many trailing bytes of +text+, a stream read in pieces, could be
92
102
  # the start of a credential and have to wait for the next piece: the
93
103
  # longest end of the text that is also the beginning of a credential,
@@ -1,12 +1,16 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "token"
4
+
3
5
  module Profiler
4
6
  module Storage
5
7
  class BaseStore
6
8
  # Public interface: persists the profile then records the save, for the pages showing it
7
9
  # to see it changed (Profiler::SSE).
8
- # Subclasses implement #do_save, not #save.
10
+ # Subclasses implement #do_save, not #save. The token must be one the gem issued
11
+ # (Storage::Token); every other public method answers "not found" for any other token.
9
12
  def save(token, profile)
13
+ Token.validate!(token)
10
14
  result = do_save(token, profile)
11
15
  broadcast_event(token)
12
16
  result
@@ -2,22 +2,34 @@
2
2
 
3
3
  require "fileutils"
4
4
  require "json"
5
+ require_relative "private_files"
6
+ require_relative "token"
5
7
 
6
8
  module Profiler
7
9
  module Storage
10
+ # Large collector payloads of the SQLite store, one directory per profile token and one JSON
11
+ # file per collector. A token or a name that is not one the gem writes is never a path: reads
12
+ # find nothing and writes are refused.
8
13
  class BlobStore
14
+ NAME_FORMAT = /\A[a-z0-9_]+\z/
15
+
9
16
  def initialize(path)
10
- @path = path
11
- FileUtils.mkdir_p(@path)
17
+ @path = path.to_s
18
+ PrivateFiles.mkdir(@path)
12
19
  end
13
20
 
14
21
  def write(token, collector_name, data)
22
+ Token.validate!(token)
23
+ raise ArgumentError, "invalid blob name: #{collector_name.inspect[0, 80]}" unless valid_name?(collector_name)
24
+
15
25
  dir = token_dir(token)
16
- FileUtils.mkdir_p(dir)
17
- File.write(File.join(dir, "#{collector_name}.json"), JSON.generate(data))
26
+ PrivateFiles.mkdir(dir)
27
+ PrivateFiles.write(File.join(dir, "#{collector_name}.json"), JSON.generate(data))
18
28
  end
19
29
 
20
30
  def read(token, collector_name)
31
+ return nil unless valid?(token, collector_name)
32
+
21
33
  file = File.join(token_dir(token), "#{collector_name}.json")
22
34
  return nil unless File.exist?(file)
23
35
 
@@ -28,16 +40,28 @@ module Profiler
28
40
  end
29
41
 
30
42
  def delete(token)
43
+ return unless Token.valid?(token)
44
+
31
45
  dir = token_dir(token)
32
46
  FileUtils.rm_rf(dir) if File.directory?(dir)
33
47
  end
34
48
 
35
49
  def exists?(token, collector_name)
50
+ return false unless valid?(token, collector_name)
51
+
36
52
  File.exist?(File.join(token_dir(token), "#{collector_name}.json"))
37
53
  end
38
54
 
39
55
  private
40
56
 
57
+ def valid?(token, collector_name)
58
+ Token.valid?(token) && valid_name?(collector_name)
59
+ end
60
+
61
+ def valid_name?(collector_name)
62
+ collector_name.is_a?(String) && NAME_FORMAT.match?(collector_name)
63
+ end
64
+
41
65
  def token_dir(token)
42
66
  File.join(@path, token)
43
67
  end
@@ -3,28 +3,38 @@
3
3
  require "fileutils"
4
4
  require "json"
5
5
  require_relative "base_store"
6
+ require_relative "private_files"
7
+ require_relative "token"
6
8
  require_relative "../models/profile"
7
9
 
8
10
  module Profiler
9
11
  module Storage
10
- # File-based profile storage backend. Persists each profile as a JSON file
11
- # under tmp_path and evicts the oldest files when total size exceeds max_size.
12
+ # File-based profile storage backend. Persists each profile as a JSON file named after its
13
+ # token, under tmp_path/profiles by default or the directory given as options[:path], and
14
+ # evicts the oldest files when total size exceeds max_size. Only the files named after a
15
+ # token are taken for profiles, so a path shared with other files (tmp_path itself, where the
16
+ # env overrides live) is safe.
12
17
  class FileStore < BaseStore
13
18
  def initialize(options = {})
14
19
  super()
15
- @path = options[:path] || default_path
16
20
  @max_size = options[:max_size] || (100 * 1024 * 1024) # 100 MB
17
- ensure_directory_exists
21
+ if options[:path]
22
+ @path = options[:path].to_s
23
+ PrivateFiles.mkdir(@path)
24
+ else
25
+ @path = PrivateFiles.tmp_dir("profiles").to_s
26
+ end
18
27
  end
19
28
 
20
29
  def do_save(token, profile)
21
- file_path = profile_file_path(token)
22
- File.write(file_path, profile.to_json)
30
+ PrivateFiles.write(profile_file_path(token), profile.to_json)
23
31
  cleanup_if_needed
24
32
  token
25
33
  end
26
34
 
27
35
  def load(token)
36
+ return nil unless Token.valid?(token)
37
+
28
38
  file_path = profile_file_path(token)
29
39
  return nil unless File.exist?(file_path)
30
40
 
@@ -55,6 +65,8 @@ module Profiler
55
65
  end
56
66
 
57
67
  def find_by_parent(parent_token)
68
+ return [] unless Token.valid?(parent_token)
69
+
58
70
  profile_files
59
71
  .map { |f| load(File.basename(f, ".json")) }
60
72
  .compact
@@ -63,6 +75,8 @@ module Profiler
63
75
  end
64
76
 
65
77
  def delete(token)
78
+ return unless Token.valid?(token)
79
+
66
80
  FileUtils.rm_f(profile_file_path(token))
67
81
  end
68
82
 
@@ -85,20 +99,12 @@ module Profiler
85
99
 
86
100
  private
87
101
 
88
- def default_path
89
- Profiler.configuration.tmp_path
90
- end
91
-
92
- def ensure_directory_exists
93
- FileUtils.mkdir_p(@path) unless File.directory?(@path)
94
- end
95
-
96
102
  def profile_file_path(token)
97
103
  File.join(@path, "#{token}.json")
98
104
  end
99
105
 
100
106
  def profile_files
101
- Dir.glob(File.join(@path, "*.json"))
107
+ Dir.glob(File.join(@path, "*.json")).select { |f| Token.valid?(File.basename(f, ".json")) }
102
108
  end
103
109
 
104
110
  def cleanup_if_needed # rubocop:disable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
@@ -19,6 +19,8 @@ module Profiler
19
19
  end
20
20
 
21
21
  def load(token)
22
+ return nil unless Token.valid?(token)
23
+
22
24
  data = @profiles[token]
23
25
  return nil unless data
24
26
 
@@ -43,6 +45,8 @@ module Profiler
43
45
  end
44
46
 
45
47
  def find_by_parent(parent_token)
48
+ return [] unless Token.valid?(parent_token)
49
+
46
50
  @profiles.values
47
51
  .map { |data| deserialize_profile(data) }
48
52
  .select { |profile| profile.parent_token == parent_token }
@@ -50,6 +54,8 @@ module Profiler
50
54
  end
51
55
 
52
56
  def delete(token)
57
+ return unless Token.valid?(token)
58
+
53
59
  @profiles.delete(token)
54
60
  end
55
61
 
@@ -0,0 +1,135 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "securerandom"
5
+
6
+ module Profiler
7
+ module Storage
8
+ # Creates the profiler's directories and files readable by the process user only (0700 and
9
+ # 0600), whatever the umask: profiles hold cookies, tokens and environment values. A directory
10
+ # that already exists keeps its mode. config.restrict_storage_permissions = false leaves the
11
+ # modes to the umask, for a web process and workers running under two users.
12
+ module PrivateFiles
13
+ DIR_MODE = 0o700
14
+ FILE_MODE = 0o600
15
+ # Where the platform has it, and while the modes are restricted: a symbolic link placed where
16
+ # the profiler expects its own file is not followed (ELOOP) instead of being written through.
17
+ NOFOLLOW = defined?(File::NOFOLLOW) ? File::NOFOLLOW : 0
18
+
19
+ @warned = {}
20
+ @warn_mutex = Mutex.new
21
+
22
+ module_function
23
+
24
+ def restricted?
25
+ Profiler.configuration.restrict_storage_permissions ? true : false
26
+ end
27
+
28
+ # Creates the missing parents with the umask's mode, so that the application's own
29
+ # directories (tmp/ for instance) are left as they would be, and the directory itself 0700.
30
+ def mkdir(path)
31
+ path = path.to_s
32
+ return if File.directory?(path)
33
+ return FileUtils.mkdir_p(path) unless restricted?
34
+
35
+ FileUtils.mkdir_p(File.dirname(path))
36
+ begin
37
+ Dir.mkdir(path, DIR_MODE)
38
+ File.chmod(DIR_MODE, path)
39
+ rescue Errno::EEXIST
40
+ raise unless File.directory?(path)
41
+ end
42
+ end
43
+
44
+ # A directory under tmp_path, created with tmp_path itself as #mkdir creates a directory.
45
+ def tmp_dir(*parts)
46
+ dir = Profiler.configuration.tmp_path
47
+ mkdir(dir)
48
+ warn_if_shared(dir)
49
+ parts.each do |part|
50
+ dir = dir.join(part)
51
+ mkdir(dir)
52
+ end
53
+ dir
54
+ end
55
+
56
+ # Writes to a temporary file next to path then renames it over path: a reader never sees a
57
+ # half-written file, and the file has its mode from the start, even when it replaces an
58
+ # older one with a wider mode.
59
+ def write(path, data)
60
+ path = path.to_s
61
+ tmp = File.join(File.dirname(path), ".#{File.basename(path)}.#{Process.pid}.#{SecureRandom.hex(4)}.tmp")
62
+ File.open(tmp, File::WRONLY | File::CREAT | File::EXCL, new_file_mode) do |file|
63
+ file.chmod(FILE_MODE) if restricted?
64
+ file.write(data)
65
+ end
66
+ File.rename(tmp, path)
67
+ tmp = nil
68
+ path
69
+ ensure
70
+ FileUtils.rm_f(tmp) if tmp
71
+ end
72
+
73
+ # Opens path for reading and writing, creating it 0600 when missing.
74
+ def open(path, &block)
75
+ File.open(path.to_s, File::RDWR | File::CREAT | nofollow, new_file_mode, &block)
76
+ end
77
+
78
+ # Creates an empty file 0600 when missing, or brings an existing one back to 0600.
79
+ def touch(path)
80
+ path = path.to_s
81
+ refuse_link(path)
82
+ File.open(path, File::WRONLY | File::CREAT | nofollow, new_file_mode) { |file| file.chmod(FILE_MODE) if restricted? }
83
+ end
84
+
85
+ # Brings an existing file back to 0600; a missing one is left missing.
86
+ def restrict(path)
87
+ path = path.to_s
88
+ refuse_link(path)
89
+ File.chmod(FILE_MODE, path) if restricted? && File.exist?(path)
90
+ end
91
+
92
+ # A symbolic link where the profiler keeps a file of its own would make it change the mode of
93
+ # (and write to) whatever the link points at. Refused while the permissions are restricted.
94
+ def refuse_link(path)
95
+ return unless restricted? && File.symlink?(path)
96
+
97
+ raise Profiler::Error, "Refusing to use #{path}: it is a symbolic link, where the profiler expects " \
98
+ "a file of its own. Remove it, or set config.restrict_storage_permissions = false, " \
99
+ "which follows such links and creates the files with the modes of the umask."
100
+ end
101
+
102
+ # Says once per directory when an existing tmp_path belongs to another user or can be written
103
+ # to by others (tmp/rails-profiler under a shared /tmp, outside Rails): they could place files
104
+ # or links there before the profiler does.
105
+ def warn_if_shared(dir)
106
+ return unless restricted?
107
+
108
+ stat = File.stat(dir.to_s)
109
+ return if stat.uid == Process.euid && (stat.mode & 0o022).zero?
110
+
111
+ key = dir.to_s
112
+ first = @warn_mutex.synchronize { @warned[key] ? false : (@warned[key] = true) }
113
+ return unless first
114
+
115
+ warn "[Profiler] tmp_path #{key} belongs to another user or is writable by group or others " \
116
+ "(mode #{format("%o", stat.mode & 0o777)}): other local users could place files there. Set " \
117
+ "config.tmp_path to a directory of your own, or run chmod 700 on it."
118
+ rescue SystemCallError
119
+ nil
120
+ end
121
+
122
+ def reset_warnings!
123
+ @warn_mutex.synchronize { @warned = {} }
124
+ end
125
+
126
+ def nofollow
127
+ restricted? ? NOFOLLOW : 0
128
+ end
129
+
130
+ def new_file_mode
131
+ restricted? ? FILE_MODE : 0o666
132
+ end
133
+ end
134
+ end
135
+ end
@@ -29,6 +29,8 @@ module Profiler
29
29
  end
30
30
 
31
31
  def load(token)
32
+ return nil unless Token.valid?(token)
33
+
32
34
  key = profile_key(token)
33
35
  json_data = @redis.get(key)
34
36
  return nil unless json_data
@@ -50,6 +52,8 @@ module Profiler
50
52
  end
51
53
 
52
54
  def find_by_parent(parent_token)
55
+ return [] unless Token.valid?(parent_token)
56
+
53
57
  # Get all tokens from the sorted set
54
58
  tokens = @redis.zrange(list_key, 0, -1)
55
59
 
@@ -61,6 +65,8 @@ module Profiler
61
65
  end
62
66
 
63
67
  def delete(token)
68
+ return unless Token.valid?(token)
69
+
64
70
  @redis.del(profile_key(token))
65
71
  @redis.zrem(list_key, token)
66
72
  end
@@ -4,6 +4,8 @@ require "json"
4
4
  require "fileutils"
5
5
  require_relative "base_store"
6
6
  require_relative "blob_store"
7
+ require_relative "private_files"
8
+ require_relative "token"
7
9
  require_relative "../models/profile"
8
10
 
9
11
  module Profiler
@@ -12,12 +14,12 @@ module Profiler
12
14
  def initialize(options = {})
13
15
  require "sqlite3"
14
16
 
15
- db_path = options[:database] || default_db_path
16
- blob_path = options[:blob_path] || default_blob_path
17
+ db_path = (options[:database] || default_db_path).to_s
18
+ blob_path = options[:blob_path] || PrivateFiles.tmp_dir("blobs")
17
19
 
18
- FileUtils.mkdir_p(File.dirname(db_path))
20
+ prepare_database_file(db_path, default: options[:database].nil?)
19
21
 
20
- @db = SQLite3::Database.new(db_path.to_s)
22
+ @db = SQLite3::Database.new(db_path)
21
23
  @db.results_as_hash = true
22
24
  @db.busy_timeout = 5000
23
25
  @db.execute("PRAGMA journal_mode=WAL")
@@ -83,6 +85,8 @@ module Profiler
83
85
  end
84
86
 
85
87
  def load(token)
88
+ return nil unless Token.valid?(token)
89
+
86
90
  row = @db.get_first_row(
87
91
  "SELECT * FROM profiler_profiles WHERE token = :token", token: token
88
92
  )
@@ -103,6 +107,8 @@ module Profiler
103
107
  end
104
108
 
105
109
  def find_by_parent(parent_token)
110
+ return [] unless Token.valid?(parent_token)
111
+
106
112
  rows = @db.execute(
107
113
  "SELECT * FROM profiler_profiles WHERE parent_token = :parent_token ORDER BY started_at ASC",
108
114
  parent_token: parent_token
@@ -111,6 +117,8 @@ module Profiler
111
117
  end
112
118
 
113
119
  def delete(token)
120
+ return unless Token.valid?(token)
121
+
114
122
  @db.execute("DELETE FROM profiler_profiles WHERE token = :token", token: token)
115
123
  @blob_store.delete(token)
116
124
  end
@@ -264,13 +272,23 @@ module Profiler
264
272
  default
265
273
  end
266
274
 
267
- def default_db_path
268
- File.join(Profiler.configuration.tmp_path.to_s, "profiler.db")
275
+ # SQLite gives the -wal and -shm files the mode of the database file: create it 0600 before
276
+ # SQLite does, and bring an older one, and its companions, back to 0600. An in-memory or URI
277
+ # database is left to SQLite.
278
+ def prepare_database_file(db_path, default:)
279
+ return if db_path == ":memory:" || db_path.start_with?("file:")
280
+
281
+ default ? PrivateFiles.tmp_dir : PrivateFiles.mkdir(File.dirname(db_path))
282
+ # A link in place of one of them is refused: SQLite would follow it.
283
+ %w[-wal -shm].each { |suffix| PrivateFiles.refuse_link("#{db_path}#{suffix}") }
284
+ PrivateFiles.touch(db_path)
285
+ %w[-wal -shm].each { |suffix| PrivateFiles.restrict("#{db_path}#{suffix}") }
269
286
  end
270
287
 
271
- def default_blob_path
272
- File.join(Profiler.configuration.tmp_path.to_s, "blobs")
288
+ def default_db_path
289
+ Profiler.configuration.tmp_path.join("profiler.db")
273
290
  end
291
+
274
292
  end
275
293
  end
276
294
  end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Profiler
4
+ module Storage
5
+ # The format of a profile token, as Models::Profile issues it (SecureRandom.hex(16)) for every
6
+ # kind of profile. The backends check a token against it before they turn it into a file name,
7
+ # a directory or a key: a token is client input for the controllers, the cluster proxy and the
8
+ # MCP tools, which all call the storage directly.
9
+ module Token
10
+ FORMAT = /\A[0-9a-f]{32}\z/
11
+
12
+ def self.valid?(token)
13
+ token.is_a?(String) && FORMAT.match?(token)
14
+ end
15
+
16
+ # For the writers: the gem only writes under the tokens it issued, so another one is a bug.
17
+ def self.validate!(token)
18
+ raise ArgumentError, "invalid profile token: #{token.inspect[0, 80]}" unless valid?(token)
19
+
20
+ token
21
+ end
22
+ end
23
+ end
24
+ end
@@ -56,7 +56,7 @@ module Profiler
56
56
 
57
57
  exception_collector = collectors.find { |c| c.is_a?(Collectors::ExceptionCollector) }
58
58
 
59
- memory_before = current_memory if Profiler.configuration.track_memory
59
+ allocations_before = AllocationCounter.current if Profiler.configuration.track_memory
60
60
 
61
61
  test_status = "passed"
62
62
  error_message = nil
@@ -103,7 +103,7 @@ module Profiler
103
103
  Profiler::CurrentContext.token = previous_token
104
104
 
105
105
  if Profiler.configuration.track_memory
106
- profile.memory = current_memory - memory_before
106
+ profile.allocated_objects = AllocationCounter.current - allocations_before
107
107
  end
108
108
 
109
109
  test_collector.update_status(test_status, error_message)
@@ -118,26 +118,11 @@ module Profiler
118
118
  end
119
119
  end
120
120
 
121
- Profiler.storage.save(profile.token, profile)
121
+ Profiler.save_profile(profile, from: "TestProfiler")
122
122
  end
123
123
  ensure
124
124
  # After collect, and also when collect or the storage failed.
125
125
  Collectors::Lifecycle.release_all(collectors)
126
126
  end
127
-
128
- private
129
-
130
- def current_memory
131
- return 0 unless defined?(GC.stat)
132
-
133
- stats = GC.stat
134
- if stats.key?(:total_allocated_size)
135
- stats[:total_allocated_size]
136
- elsif stats.key?(:total_allocated_objects)
137
- stats[:total_allocated_objects] * 40
138
- else
139
- 0
140
- end
141
- end
142
127
  end
143
128
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Profiler
4
- VERSION = "0.31.0"
4
+ VERSION = "0.31.2"
5
5
  end