rails-profiler 0.31.0 → 0.31.1

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: 1c74753b10258047c15aafb6de0c2b5900bc16f01f4b499405b5b72d0d6d3b9e
4
- data.tar.gz: 55ad17c8d51cf4eb8e2ed9ec3fd7158a15e23becba666b204b11d7b7c81f0e47
3
+ metadata.gz: f57ca8fcbd7fef2ddd81f41ab6c1b7b254e814ec986692ed906533f0c9042d4b
4
+ data.tar.gz: 66e0072789af17ddb711b3911c60d5ebaa683d040117ee44f2dba7b30e126ce7
5
5
  SHA512:
6
- metadata.gz: f1984f7c3ef795d9f318ddc33b64e964a8ed0171878fd913a927ad7c7d4479ba4523159598e75ddd3f52a8985bdaca9cb24bd49a8e0a0f6e1fbc025b904254ba
7
- data.tar.gz: 855f08439f3cd174f3686150eed1f813b349e7591dd731b554941be172b9557ff835229268a3dce9605825e161b353a4456e444f1680ed239f40ebf1a6c37abc
6
+ metadata.gz: 9378049525fb7fa8127ddcbe51be0f02a2fe27915cae7aad5cdbce22ce80ba6a5c2ee69b67980366f51f32cb29306c6d14d41878b3773aadaff4a7c5df0fe661
7
+ data.tar.gz: 1b881dbe5033362810063c9d86946978c94e7dcb95cad19130a729e80f1e6bdbfff20e754feb6f8c9f37dcb3d7bc3dd2571e282463824cd918fa0ea201f1087e
data/CHANGELOG.md CHANGED
@@ -19,6 +19,86 @@ every commit of every tag interval is accounted for one way or the other.
19
19
 
20
20
  ## [Unreleased]
21
21
 
22
+ ## [0.31.1] - 2026-10-05
23
+
24
+ <!-- stamped -->
25
+
26
+ ### Fixed
27
+
28
+ - **Storage:** Keep the file store's profiles in `tmp_path/profiles` by default, and take only the
29
+ files named after a profile token for profiles. Profiles shared `tmp_path` with
30
+ `env_overrides.json`, which the store listed as a profile with no token and deleted when the
31
+ profiles were cleared. An application that sets `storage_options[:path]` keeps its directory,
32
+ used as given (no subdirectory is added).
33
+ - **MCP:** Keep the bodies saved by the MCP tools in `tmp_path/mcp-cache`, and let their hourly
34
+ cleanup remove only the token directories it wrote there. It removed every directory of
35
+ `tmp_path` older than an hour, the SQLite store's blobs included, at random (one save in
36
+ twenty), which lost the stored HTTP response bodies.
37
+ - **Configuration:** `config.tmp_path` is always a `Pathname`: it was a `String` outside Rails, and
38
+ kept a `String` assigned to it, which made every env override operation fail with only a
39
+ warning. A failure to read or write the env overrides now reaches the Env tab (`500` with the
40
+ error, `ENV` left unchanged) and the MCP env tools (an error answer) instead of reporting a
41
+ success; at boot, before a Sidekiq job and before a console evaluation it is still a warning,
42
+ so an override never makes the application fail.
43
+ - **Jobs:** A storage error while saving a profile no longer makes the application fail: a job,
44
+ a console command, a test, or an outbound HTTP call that ends after its profile was collected
45
+ now goes on, with its own result or its own exception, and the error is printed as a warning.
46
+ The save of a job, console or test profile ran with nothing to catch it, so a full disk, a
47
+ Redis that went away or, from this version, a symbolic link refused in place of the SQLite
48
+ database made the job fail, or replaced the job's own exception. The HTTP middleware already
49
+ kept requests going.
50
+ - **Upgrading:** for the default `tmp_path`; adapt the paths if you set one.
51
+ - Profiles saved by the file store in `tmp/rails-profiler` itself are no longer listed, and are
52
+ not moved. To remove them, with the old MCP body cache, run from the application root:
53
+ ```sh
54
+ LC_ALL=C find tmp/rails-profiler -maxdepth 1 -type f -name '[0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f].json' -delete
55
+ LC_ALL=C find tmp/rails-profiler -mindepth 1 -maxdepth 1 -type d -name '[0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f]' -exec rm -rf {} +
56
+ ```
57
+ Both only match names made of 32 lower-case hexadecimal digits, a profile token (`LC_ALL=C`
58
+ keeps `[0-9a-f]` from matching upper-case letters in some locales): `env_overrides.json`, the
59
+ SQLite database, `blobs`, `profiles` and `mcp-cache` are left alone.
60
+ - To keep reading the old profiles instead, set
61
+ `config.storage_options = { path: Rails.root.join("tmp", "rails-profiler") }`, and then do
62
+ **not** run the first command: it would delete the current profiles.
63
+ - The directory `tmp/rails-profiler` created by an earlier version keeps its mode: run
64
+ `chmod 700 tmp/rails-profiler` to restrict it.
65
+ - If the web process and the workers reading the env overrides or a shared file or SQLite store
66
+ run as two different users, set `config.restrict_storage_permissions = false` to create new
67
+ files with the modes of the umask, as before. It does not reopen what was already created
68
+ `0700` and `0600`: run `chmod -R u=rwX,go=rX tmp/rails-profiler` for the modes earlier
69
+ versions created with the usual umask (`0755` and `0644`). A worker under another user that
70
+ also writes there (it opens `env_overrides.json.lock` for writing) needs the
71
+ files belonging to a group of both users (the same primary group for both, or
72
+ `chgrp -R <group> tmp/rails-profiler` then `find tmp/rails-profiler -type d -exec chmod g+s {} +`
73
+ so that new files inherit it), a umask of `002` for both processes, and
74
+ `chmod -R ug=rwX,o=rX tmp/rails-profiler`. The option also follows a symbolic link in place
75
+ of the SQLite database, its `-wal` or `-shm` file or the lock of the env overrides, and drops
76
+ the warning about a shared `tmp_path`, as before.
77
+
78
+ ### Security
79
+
80
+ - **Storage:** Check every profile token against the format the profiler issues (32 hexadecimal
81
+ characters) in every storage backend (file, memory, Redis, SQLite and its blobs), before it
82
+ becomes a file name, a directory or a key. A token such as `../../config/important` reached the
83
+ file store as a path: `DELETE /_profiler/api/profiles/:id` deleted, and
84
+ `POST /_profiler/api/ajax/link` overwrote, a `.json` file outside the storage directory. A
85
+ malformed token is now not found (`404` from the API, `nil` from the storage, no exception),
86
+ whoever asks: the controllers, the cluster proxy, which no longer forwards it to a slave, or the
87
+ MCP tools. Saving under one raises an `ArgumentError`. `POST /_profiler/api/ajax/link` answers
88
+ `400` for a malformed `parent_token` instead of storing it. The MCP tools no longer save a body
89
+ (`save_bodies`) under a token sent by a slave that is not a profile token. There is no option to
90
+ accept other tokens: the profiler never issues them, and accepting them would reopen the path.
91
+ - **Storage:** Create the profiler's directories `0700` and its files `0600`, whatever the umask:
92
+ the file store's profiles, the SQLite database and its `-wal` and `-shm` files, the blobs, the
93
+ env overrides with their lock and temporary files, and the MCP body cache. They were created
94
+ with the process defaults, usually `0755` and `0644`, readable by every local user although
95
+ profiles hold cookies, tokens and environment values. A file the profiler writes again (a
96
+ profile, the SQLite database at startup) is brought back to `0600`; a directory that already
97
+ exists keeps its mode. Writing a profile now goes through a temporary file renamed over it. A
98
+ symbolic link placed where the profiler keeps its SQLite database, its `-wal` or `-shm` file or
99
+ the lock of the env overrides is refused instead of followed, and an existing `tmp_path` that
100
+ belongs to another user or that group or others can write to is reported once with a warning.
101
+
22
102
  ## [0.31.0] - 2026-10-05
23
103
 
24
104
  <!-- stamped -->
@@ -12,6 +12,12 @@ module Profiler
12
12
  return render json: { error: "Missing parent_token or child_token" }, status: :bad_request
13
13
  end
14
14
 
15
+ # The parent token is written into the child profile: a value the gem never issues is
16
+ # refused rather than stored. A malformed child token is not found by the storage.
17
+ unless Profiler::Storage::Token.valid?(parent_token)
18
+ return render json: { error: "Invalid parent_token" }, status: :bad_request
19
+ end
20
+
15
21
  # Load child profile
16
22
  child_profile = Profiler.storage.load(child_token)
17
23
  unless child_profile
@@ -3,6 +3,13 @@
3
3
  module Profiler
4
4
  module Api
5
5
  class EnvVarsController < ApplicationController
6
+ # The overrides file could not be read or written: nothing was changed, ENV included.
7
+ rescue_from Profiler::EnvOverrideStore::Error do |error|
8
+ key = params[:key].to_s.strip
9
+ message = key.empty? ? "#{error.message}; nothing was changed" : "#{error.message}; #{key} was left unchanged"
10
+ render json: { error: message }, status: :internal_server_error
11
+ end
12
+
6
13
  def show
7
14
  variables = Profiler::Redaction.env_snapshot
8
15
  overrides = Profiler::Redaction.env_overrides(Profiler.env_override_store.all_overrides)
@@ -72,8 +79,11 @@ module Profiler
72
79
  ENV[key].nil? ? nil : Profiler::Redaction.env_value(key, ENV[key])
73
80
  end
74
81
 
82
+ # Read back after a change that succeeded: a failure here must not answer that nothing changed.
75
83
  def redacted_override(key)
76
84
  Profiler::Redaction.env_overrides(Profiler.env_override_store.all_overrides)[key]
85
+ rescue Profiler::EnvOverrideStore::Error
86
+ nil
77
87
  end
78
88
  end
79
89
  end
@@ -4,6 +4,7 @@ require "net/http"
4
4
  require "json"
5
5
  require "uri"
6
6
  require_relative "security"
7
+ require_relative "../storage/token"
7
8
 
8
9
  module Profiler
9
10
  module Cluster
@@ -39,7 +40,10 @@ module Profiler
39
40
  Array(data["profiles"]).map { |h| profile_from_api(h) }
40
41
  end
41
42
 
43
+ # A token that is not one the gem issues is not found, without asking the slave.
42
44
  def load(token)
45
+ return nil unless Storage::Token.valid?(token)
46
+
43
47
  data = get_json("/_profiler/api/profiles/#{Security.escape_segment(token)}")
44
48
  return nil unless data["token"] || data["profile"]
45
49
 
@@ -48,6 +52,8 @@ module Profiler
48
52
  end
49
53
 
50
54
  def find_by_parent(parent_token)
55
+ return [] unless Storage::Token.valid?(parent_token)
56
+
51
57
  data = get_json("/_profiler/api/profiles", parent_token: parent_token, all_types: true)
52
58
  Array(data["profiles"]).map { |h| profile_from_api(h) }
53
59
  end
@@ -125,7 +125,7 @@ module Profiler
125
125
  build_data(@requests)
126
126
  end
127
127
  store_data(data)
128
- Profiler.storage.save(@profile.token, @profile)
128
+ Profiler.save_profile(@profile, from: "HttpCollector")
129
129
  end
130
130
 
131
131
  def group_by_host(requests)
@@ -1,5 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "pathname"
3
4
  require_relative "local_request"
4
5
 
5
6
  module Profiler
@@ -39,7 +40,7 @@ module Profiler
39
40
  :track_http, :slow_http_threshold, :http_skip_hosts, :http_backtrace_depth,
40
41
  :track_jobs,
41
42
  :track_console,
42
- :apply_env_overrides_when_disabled,
43
+ :apply_env_overrides_when_disabled, :restrict_storage_permissions,
43
44
  :test_runner_allow_undiscovered_files,
44
45
  :track_mailers, :capture_mail_body, :sanitize_mailer_recipients, :mailer_skip_actions,
45
46
  :compress_bodies, :compress_body_threshold,
@@ -49,8 +50,6 @@ module Profiler
49
50
  :cluster_master, :cluster_secret, :cluster_require_secret,
50
51
  :cluster_allow_insecure_http
51
52
 
52
- attr_writer :tmp_path
53
-
54
53
  attr_reader :authorize_block, :enabled, :track_tests
55
54
 
56
55
  def initialize
@@ -100,6 +99,9 @@ module Profiler
100
99
  @filter_parameters = DEFAULT_FILTER_PARAMETERS.dup
101
100
  @env_allowlist = DEFAULT_ENV_ALLOWLIST.dup
102
101
  @tmp_path = nil
102
+ # The profiler's directories 0700 and files 0600 (Profiler::Storage::PrivateFiles). false
103
+ # leaves the modes to the umask, for a web process and workers running under two users.
104
+ @restrict_storage_permissions = true
103
105
  @name = nil
104
106
  @master_url = nil
105
107
  @self_url = nil
@@ -113,10 +115,15 @@ module Profiler
113
115
  @cluster_allow_insecure_http = false
114
116
  end
115
117
 
118
+ # Always a Pathname, whether set from a String or left to its default.
116
119
  def tmp_path
117
120
  @tmp_path || default_tmp_path
118
121
  end
119
122
 
123
+ def tmp_path=(value)
124
+ @tmp_path = value.nil? ? nil : Pathname.new(value)
125
+ end
126
+
120
127
  attr_reader :cluster_allowed_slave_urls
121
128
 
122
129
  # :any, or a list of slave URLs and anchored Regexp patterns (see Profiler::Cluster::Security).
@@ -226,7 +233,7 @@ module Profiler
226
233
  if defined?(Rails) && Rails.respond_to?(:root) && Rails.root
227
234
  Rails.root.join("tmp", "rails-profiler")
228
235
  else
229
- File.expand_path("tmp/rails-profiler", Dir.pwd)
236
+ Pathname.new(File.expand_path("tmp/rails-profiler", Dir.pwd))
230
237
  end
231
238
  end
232
239
 
@@ -80,7 +80,7 @@ module Profiler
80
80
  end
81
81
  end
82
82
 
83
- Profiler.storage.save(profile.token, profile)
83
+ Profiler.save_profile(profile, from: "ConsoleProfiler")
84
84
  end
85
85
  ensure
86
86
  # After collect, and also when collect or the storage failed.
@@ -2,9 +2,16 @@
2
2
 
3
3
  require "json"
4
4
  require "fileutils"
5
+ require_relative "storage/private_files"
5
6
 
6
7
  module Profiler
7
8
  class EnvOverrideStore
9
+ # Raised by the methods a caller asked for (set, delete, reset, reset_all, clear and
10
+ # all_overrides) when the overrides file cannot be read or written. The caller then knows that
11
+ # nothing was changed. apply! and apply_at_boot! still only warn: they run at boot and before
12
+ # the application's jobs, where an override must never make the application fail.
13
+ class Error < Profiler::Error; end
14
+
8
15
  DELETED_SENTINEL = "__profiler_deleted__"
9
16
  RESTORE_SENTINEL = "__profiler_restore__"
10
17
 
@@ -30,53 +37,53 @@ module Profiler
30
37
  end
31
38
 
32
39
  def set(key, value)
33
- remember_process_original(key)
34
- update_overrides do |overrides|
35
- overrides[key] = { "value" => value.to_s, "original" => original_for(overrides, key) }
40
+ surfacing("set #{key}") do
41
+ remember_process_original(key)
42
+ update_overrides do |overrides|
43
+ overrides[key] = { "value" => value.to_s, "original" => original_for(overrides, key) }
44
+ end
36
45
  end
37
- rescue => e
38
- warn "[Profiler] EnvOverrideStore: failed to set #{key}: #{e.message}"
39
46
  end
40
47
 
41
48
  def delete(key)
42
- remember_process_original(key)
43
- update_overrides do |overrides|
44
- overrides[key] = { "value" => DELETED_SENTINEL, "original" => original_for(overrides, key) }
49
+ surfacing("delete #{key}") do
50
+ remember_process_original(key)
51
+ update_overrides do |overrides|
52
+ overrides[key] = { "value" => DELETED_SENTINEL, "original" => original_for(overrides, key) }
53
+ end
45
54
  end
46
- rescue => e
47
- warn "[Profiler] EnvOverrideStore: failed to delete #{key}: #{e.message}"
48
55
  end
49
56
 
50
57
  # Returns whether ENV was written in this process.
51
58
  def reset(key)
52
59
  entry = nil
53
- update_overrides do |overrides|
54
- entry = overrides[key]
55
- # Keep a RESTORE entry so Sidekiq workers pick it up on next job
56
- overrides[key] = { "value" => RESTORE_SENTINEL, "original" => entry["original"] } if entry
60
+ surfacing("reset #{key}") do
61
+ update_overrides do |overrides|
62
+ entry = overrides[key]
63
+ # Keep a RESTORE entry so Sidekiq workers pick it up on next job
64
+ overrides[key] = { "value" => RESTORE_SENTINEL, "original" => entry["original"] } if entry
65
+ end
57
66
  end
58
67
 
59
68
  # Apply immediately to the current (web) process
60
69
  restore_env(key, entry, blocked: blocked_reason)
61
- rescue => e
62
- warn "[Profiler] EnvOverrideStore: failed to reset #{key}: #{e.message}"
63
70
  end
64
71
 
65
72
  def reset_all
66
73
  originals = {}
67
- update_overrides do |overrides|
68
- overrides.each do |key, entry|
69
- originals[key] = entry.is_a?(Hash) ? entry["original"] : nil
70
- # Leave a RESTORE sentinel for Sidekiq workers to pick up
71
- overrides[key] = { "value" => RESTORE_SENTINEL, "original" => originals[key] }
74
+ surfacing("reset the overrides") do
75
+ update_overrides do |overrides|
76
+ overrides.each do |key, entry|
77
+ originals[key] = entry.is_a?(Hash) ? entry["original"] : nil
78
+ # Leave a RESTORE sentinel for Sidekiq workers to pick up
79
+ overrides[key] = { "value" => RESTORE_SENTINEL, "original" => originals[key] }
80
+ end
72
81
  end
73
82
  end
74
83
 
75
84
  # Apply immediately to the current (web) process
76
85
  blocked = blocked_reason
77
86
  originals.each { |key, original| restore_env(key, { "original" => original }, blocked: blocked) }
78
- rescue => e
79
- warn "[Profiler] EnvOverrideStore: failed to reset all: #{e.message}"
80
87
  end
81
88
 
82
89
  # Why the persisted overrides must not reach ENV in this process, or nil when they may.
@@ -154,27 +161,36 @@ module Profiler
154
161
  warn "[Profiler] EnvOverrideStore: failed to apply overrides: #{e.message}"
155
162
  end
156
163
 
157
- # Returns active overrides (excludes RESTORE entries — already being reverted)
164
+ # Returns active overrides (excludes RESTORE entries, already being reverted)
165
+ # A file that does not parse reads as no override; one that cannot be read raises Error.
158
166
  def all_overrides
159
- load_overrides.reject do |_, entry|
160
- value = entry.is_a?(Hash) ? entry["value"] : entry
161
- value == RESTORE_SENTINEL
162
- end.transform_values do |entry|
163
- entry.is_a?(Hash) ? entry : { "value" => entry, "original" => nil }
167
+ surfacing("read the overrides") do
168
+ load_overrides.reject do |_, entry|
169
+ value = entry.is_a?(Hash) ? entry["value"] : entry
170
+ value == RESTORE_SENTINEL
171
+ end.transform_values do |entry|
172
+ entry.is_a?(Hash) ? entry : { "value" => entry, "original" => nil }
173
+ end
164
174
  end
165
- rescue => e
166
- warn "[Profiler] EnvOverrideStore: failed to load overrides: #{e.message}"
167
- {}
168
175
  end
169
176
 
170
177
  def clear
171
- with_lock { FileUtils.rm_f(override_file_path) }
172
- rescue => e
173
- warn "[Profiler] EnvOverrideStore: failed to clear: #{e.message}"
178
+ surfacing("clear the overrides") { with_lock { FileUtils.rm_f(override_file_path) } }
174
179
  end
175
180
 
176
181
  private
177
182
 
183
+ # The message names the file relative to tmp_path, and an Errno by its description only: it
184
+ # goes back to the Env tab, the MCP tools and the test runner output, without absolute paths.
185
+ def surfacing(action)
186
+ yield
187
+ rescue Error
188
+ raise
189
+ rescue StandardError => e
190
+ reason = e.is_a?(SystemCallError) ? e.class.new.message : e.class.name
191
+ raise Error, "could not #{action} in env_overrides.json under tmp_path: #{reason}"
192
+ end
193
+
178
194
  def remember_process_original(key)
179
195
  @process_originals_lock.synchronize do
180
196
  @process_originals[key] = ENV[key] unless @process_originals.key?(key)
@@ -227,8 +243,8 @@ module Profiler
227
243
  # An exclusive flock on a file next to the overrides file. Not reentrant: never nest it.
228
244
  def with_lock
229
245
  path = override_file_path
230
- FileUtils.mkdir_p(path.dirname)
231
- File.open("#{path}.lock", File::RDWR | File::CREAT, 0o644) do |lock|
246
+ Storage::PrivateFiles.tmp_dir
247
+ Storage::PrivateFiles.open("#{path}.lock") do |lock|
232
248
  lock.flock(File::LOCK_EX)
233
249
  yield
234
250
  end
@@ -237,12 +253,7 @@ module Profiler
237
253
  # Written to a temporary file then renamed over the old one, so that a reader never sees a
238
254
  # half-written file, which would parse as no override at all.
239
255
  def save_overrides(overrides)
240
- path = override_file_path
241
- tmp = "#{path}.#{Process.pid}.#{Thread.current.object_id}.tmp"
242
- File.write(tmp, JSON.generate(overrides))
243
- File.rename(tmp, path)
244
- ensure
245
- FileUtils.rm_f(tmp) if tmp
256
+ Storage::PrivateFiles.write(override_file_path, JSON.generate(overrides))
246
257
  end
247
258
  end
248
259
  end
@@ -107,7 +107,7 @@ module Profiler
107
107
  end
108
108
  end
109
109
 
110
- Profiler.storage.save(profile.token, profile)
110
+ Profiler.save_profile(profile, from: "JobProfiler")
111
111
  end
112
112
  ensure
113
113
  # After collect, and also when collect or the storage failed.
@@ -9,10 +9,10 @@ module Profiler
9
9
  # Formats a body string for MCP output.
10
10
  #
11
11
  # params keys used:
12
- # "save_bodies" (boolean) — save to ./tmp/rails-profiler/{token}/{name} and return path
13
- # "max_body_size" (number) — truncate inline body at N chars
14
- # "json_path" (string) — JSONPath to extract from body when save_bodies is true
15
- # "xml_path" (string) — XPath to extract from body when save_bodies is true
12
+ # "save_bodies" (boolean): save to {tmp_path}/mcp-cache/{token}/{name} and return path
13
+ # "max_body_size" (number): truncate inline body at N chars
14
+ # "json_path" (string): JSONPath to extract from body when save_bodies is true
15
+ # "xml_path" (string): XPath to extract from body when save_bodies is true
16
16
  #
17
17
  # Returns a formatted string ready to embed in markdown, or nil if body is blank.
18
18
  def self.format_body(token, name, body, encoding, params)
@@ -1,22 +1,31 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "fileutils"
4
+ require_relative "../storage/private_files"
5
+ require_relative "../storage/token"
4
6
 
5
7
  module Profiler
6
8
  module MCP
9
+ # The bodies the MCP tools save on request (save_bodies), under tmp_path/mcp-cache/<token>/<name>.
10
+ # The token comes from a profile, which a slave may have sent: one that is not a token the gem
11
+ # issues, or a name that is not one the tools use, is refused. The cleanup only removes the
12
+ # token directories of this cache: tmp_path also holds the profiles, the SQLite blobs and the
13
+ # env overrides.
7
14
  class FileCache
15
+ DIR_NAME = "mcp-cache"
16
+ NAME_FORMAT = /\A[a-z0-9_]+\z/
17
+
8
18
  def self.base_dir
9
- Profiler.configuration.tmp_path.to_s
19
+ Profiler.configuration.tmp_path.join(DIR_NAME).to_s
10
20
  end
11
21
 
12
22
  def self.save(token, name, content)
23
+ return nil unless Storage::Token.valid?(token) && name.is_a?(String) && NAME_FORMAT.match?(name)
24
+
13
25
  cleanup if rand < 0.05
14
26
 
15
- dir = File.join(base_dir, token)
16
- FileUtils.mkdir_p(dir)
17
- path = File.join(dir, name)
18
- File.write(path, content)
19
- path
27
+ dir = Storage::PrivateFiles.tmp_dir(DIR_NAME, token)
28
+ Storage::PrivateFiles.write(dir.join(name), content)
20
29
  rescue Errno::EACCES, Errno::EROFS
21
30
  nil
22
31
  end
@@ -25,7 +34,10 @@ module Profiler
25
34
  bd = base_dir
26
35
  return unless Dir.exist?(bd)
27
36
 
28
- Dir.glob(File.join(bd, "*")).each do |dir|
37
+ Dir.children(bd).each do |entry|
38
+ next unless Storage::Token.valid?(entry)
39
+
40
+ dir = File.join(bd, entry)
29
41
  FileUtils.rm_rf(dir) if File.directory?(dir) && (Time.now - File.mtime(dir)) > max_age
30
42
  end
31
43
  end
@@ -26,7 +26,9 @@ module Profiler
26
26
  Profiler.env_override_store.delete(key)
27
27
  ENV.delete(key)
28
28
 
29
- [{ type: "text", text: "Deleted #{key}. Override persisted — will remain deleted across restarts until reset." }]
29
+ [{ type: "text", text: "Deleted #{key}. Override persisted: it will remain deleted across restarts until reset." }]
30
+ rescue Profiler::EnvOverrideStore::Error => e
31
+ ::MCP::Tool::Response.new([{ type: "text", text: "Error: #{e.message}; #{key} was left unchanged." }], error: true)
30
32
  end
31
33
  end
32
34
  end
@@ -1,5 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "mcp"
3
4
  require_relative "../slave_support"
4
5
  require_relative "../../env_override_store"
5
6
  require_relative "../../redaction"
@@ -61,6 +62,8 @@ module Profiler
61
62
  end
62
63
 
63
64
  [{ type: "text", text: text }]
65
+ rescue Profiler::EnvOverrideStore::Error => e
66
+ ::MCP::Tool::Response.new([{ type: "text", text: "Error: #{e.message}." }], error: true)
64
67
  end
65
68
 
66
69
  private
@@ -1,5 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "mcp"
3
4
  require_relative "../slave_support"
4
5
 
5
6
  module Profiler
@@ -22,6 +23,8 @@ module Profiler
22
23
  Profiler.env_override_store.reset_all
23
24
 
24
25
  [{ type: "text", text: "Reset #{count} environment variable#{"s" if count != 1} to original values." }]
26
+ rescue Profiler::EnvOverrideStore::Error => e
27
+ ::MCP::Tool::Response.new([{ type: "text", text: "Error: #{e.message}; nothing was changed." }], error: true)
25
28
  end
26
29
  end
27
30
  end
@@ -1,5 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "mcp"
3
4
  require_relative "../slave_support"
4
5
 
5
6
  require "uri"
@@ -27,6 +28,8 @@ module Profiler
27
28
 
28
29
  done = restored ? "original value restored" : "ENV left unchanged"
29
30
  [{ type: "text", text: "Reset #{key}: override removed, #{done} in this process." }]
31
+ rescue Profiler::EnvOverrideStore::Error => e
32
+ ::MCP::Tool::Response.new([{ type: "text", text: "Error: #{e.message}; #{key} was left unchanged." }], error: true)
30
33
  end
31
34
  end
32
35
  end
@@ -33,6 +33,8 @@ module Profiler
33
33
  ENV[key] = value
34
34
 
35
35
  [{ type: "text", text: "Set #{key}=#{value}" }]
36
+ rescue Profiler::EnvOverrideStore::Error => e
37
+ ::MCP::Tool::Response.new([{ type: "text", text: "Error: #{e.message}; #{key} was left unchanged." }], error: true)
36
38
  end
37
39
  end
38
40
  end
@@ -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
@@ -118,7 +118,7 @@ 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.
@@ -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.1"
5
5
  end
data/lib/profiler.rb CHANGED
@@ -28,6 +28,15 @@ module Profiler
28
28
  @storage ||= configuration.storage_backend
29
29
  end
30
30
 
31
+ # Saves a profile from a path of the application (a job, a console command, a test, an
32
+ # outbound HTTP call): a storage error loses the profile, never the application's work.
33
+ def save_profile(profile, from:)
34
+ storage.save(profile.token, profile)
35
+ rescue StandardError => e
36
+ warn "[Profiler] #{from}: could not save profile #{profile.token}: #{e.class}: #{e.message}"
37
+ nil
38
+ end
39
+
31
40
  def env_override_store
32
41
  @env_override_store ||= EnvOverrideStore.new
33
42
  end
@@ -124,6 +133,8 @@ require_relative "profiler/collectors/i18n_collector"
124
133
  require_relative "profiler/collectors/env_collector"
125
134
  require_relative "profiler/collectors/mailer_collector"
126
135
 
136
+ require_relative "profiler/storage/token"
137
+ require_relative "profiler/storage/private_files"
127
138
  require_relative "profiler/env_override_store"
128
139
  require_relative "profiler/instrumentation/thread_context_propagation"
129
140
  require_relative "profiler/sse/event_bus"
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.31.0
4
+ version: 0.31.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Sébastien Duplessy
@@ -222,8 +222,10 @@ files:
222
222
  - lib/profiler/storage/blob_store.rb
223
223
  - lib/profiler/storage/file_store.rb
224
224
  - lib/profiler/storage/memory_store.rb
225
+ - lib/profiler/storage/private_files.rb
225
226
  - lib/profiler/storage/redis_store.rb
226
227
  - lib/profiler/storage/sqlite_store.rb
228
+ - lib/profiler/storage/token.rb
227
229
  - lib/profiler/tasks/profiler.rake
228
230
  - lib/profiler/test_helpers/minitest_support.rb
229
231
  - lib/profiler/test_helpers/reporter.rb