rails-profiler 0.33.2 → 0.34.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 8e1caccb1662a8f09108682f5933e943d41a0b36ed7d60930c60eb8c385b5159
4
- data.tar.gz: c10d767853cc07caa36484f9693a4c4adeef415e0874d29ca817d8d7d1b17730
3
+ metadata.gz: b6a0140a91e984bd6d06d7277a149c2a7eeace21ec53c3d3ddc1acb7eb137879
4
+ data.tar.gz: 730abdd8332a638e3d78db3d74378ab10fc1280f9d5f83f11b327409b076c1e3
5
5
  SHA512:
6
- metadata.gz: 8aacfc0469f52d9f6a11a58356eebb853ff716f2729ca47833b9b0ca99197f767a0dad5904f8c9e7d8a8501869dd712a0c5e6e246fa8ea0e55976b546589d1ac
7
- data.tar.gz: 3ce33e8db95c0ae4028a68e11d941bb72072b5a6edcf539cfc0fb918c2248d675c4df92e28788da0d59d942546458384b22ec5ac974bb58404be1d0049130627
6
+ metadata.gz: '09a3cac5a02d3407f8a03dad19154d8f36c5dd57e8c1f5d9b0021aad015e7e459a4305592e68defb796672a9cbd29b40c48b2933cff56b2fe340c3ae370de767'
7
+ data.tar.gz: dda83a3c7086e4679766abe06d0dc5e88befc5e9ebebb19a87ec605a3b268a2ab81fccef0274e8170043bb4c6ab452f68307e5a1546921b55b418289427fc805
data/CHANGELOG.md CHANGED
@@ -19,6 +19,79 @@ every commit of every tag interval is accounted for one way or the other.
19
19
 
20
20
  ## [Unreleased]
21
21
 
22
+ ## [0.34.0] - 2026-10-15
23
+
24
+ <!-- stamped -->
25
+
26
+ ### Added
27
+
28
+ - **MCP:** `config.mcp_read_only = true` leaves out the tools that change something:
29
+ `run_tests`, `clear_profiles`, `set_env_var`, `delete_env_var`, `reset_env_var` and
30
+ `reset_all_env_vars`. They are not listed by `tools/list`, and a client that calls one by name
31
+ gets a "tool not found" error; the tools that only read stay. False by default. The gem's
32
+ `profiler-mcp` executable does not boot the application, so it never reads the option: set
33
+ `PROFILER_MCP_READ_ONLY=1` in the environment the MCP client starts it with. The variable works
34
+ with `rake profiler:mcp` and the HTTP transport too, and never turns read-only off.
35
+
36
+ ### Changed
37
+
38
+ - **Env overrides:** An override saved by an earlier version for a name the profiler now refuses,
39
+ such as `DATABASE_URL`, `PRIMARY_DATABASE_URL`, `LD_PRELOAD` or `HTTPS_PROXY`, is no longer
40
+ applied: not at boot, not before a Sidekiq job, not in the test runner. It stays in the file
41
+ until reset, and a warning gives how many were left out, never their names. Set such a variable
42
+ in the shell that starts the application instead.
43
+
44
+ ### Fixed
45
+
46
+ - **Env overrides:** The Env tab, `set_env_var` and `delete_env_var` refuse a name that is not
47
+ made of letters, digits and underscores (`A=B`, `MY-VAR`), and the variables that select the
48
+ database, the secrets or the network of the application, or make a process load other code:
49
+ `DATABASE_URL` and every name ending in `_DATABASE_URL`, every name starting with `PG` or
50
+ `MYSQL_`, `RAILS_MASTER_KEY`, every proxy variable in any case (`http_proxy`, `HTTPS_PROXY`),
51
+ `SSL_CERT_FILE`, `SSL_CERT_DIR`, `OPENSSL_CONF`, `GCONV_PATH`, `IRBRC`, `PRYRC`, `ZDOTDIR`,
52
+ `LD_PRELOAD`, `RUBYOPT` and the others listed in the docs. Before, any of these could be set,
53
+ `DATABASE_URL` included. The refusal comes before anything is written: a 422 from the endpoint,
54
+ an error from the tool. The list cannot be complete: a gem may read a variable of its own.
55
+ - **Env overrides:** A value holding a NUL byte is refused the same way. Before, it was written to
56
+ the file, then the tool failed with an internal error, and every process logged the same error
57
+ again before each Sidekiq job.
58
+ - **Env overrides:** A reset now reaches every process that applied the override, each Sidekiq
59
+ process and Puma worker, not only the first one to read the file: each puts back the value it
60
+ had before. A process that never applied the override is left alone.
61
+ - **Env overrides:** The overrides file is no longer read and parsed again before every Sidekiq
62
+ job while it does not change. A change written within two seconds of the previous one is never
63
+ missed, on ext4 too, where the file system reuses the inode and the modification time can stay
64
+ the same.
65
+
66
+ ## [0.33.3] - 2026-10-14
67
+
68
+ <!-- stamped -->
69
+
70
+ ### Fixed
71
+
72
+ - **Rake tasks:** `rake profiler:cleanup` with an empty or non-numeric `OLDER_THAN` (`""`,
73
+ `abc`) no longer removes every profile, and `OLDER_THAN=1h` is no longer read as one second.
74
+ `OLDER_THAN` and `LIMIT` take a whole number only; any other value stops the task with an error
75
+ and removes nothing. `OLDER_THAN=0` still removes every profile, when written so.
76
+ - **Storage:** The SQLite store compares the start times of the profiles in UTC. Outside UTC,
77
+ `rake profiler:cleanup` and `cleanup` removed recent profiles (America/New_York) or kept old
78
+ ones (Asia/Tokyo), and the lists were out of order across a clock change. The profiles already
79
+ in the database are converted once, when the store opens it; the API shows the same times.
80
+ - **Storage:** The Redis store takes expired profiles out of its lists at each save, and its list
81
+ keys now expire a TTL after the last save. Without `max_profiles`, or between two evictions,
82
+ the tokens of expired profiles stayed listed until a manual cleanup, and a page of the list
83
+ came back with fewer profiles than asked.
84
+ - **Storage:** With the SQLite store, a profile saved again without a response body no longer
85
+ shows the response body of its previous save. The response bodies left on disk by profiles
86
+ removed outside the store are removed by `cleanup` and when the oldest profiles are evicted.
87
+
88
+ ### Limitations
89
+
90
+ - **Storage:** Two SQLite stores that share a `blob_path` remove each other's response bodies:
91
+ give each `database` its own `blob_path` (see `docs/storage.md`).
92
+ - **Storage:** On the first save after an upgrade, the Redis store can drop from its lists a job
93
+ that started more than a TTL ago and was saved recently. It happens once.
94
+
22
95
  ## [0.33.2] - 2026-10-14
23
96
 
24
97
  <!-- stamped -->
@@ -24,14 +24,20 @@ module Profiler
24
24
  return
25
25
  end
26
26
 
27
- if Profiler::EnvOverrideStore.reserved_key?(key)
28
- render json: { error: "#{Profiler::EnvOverrideStore::RESERVED_KEY_ERROR}; #{key} was left unchanged" },
29
- status: :unprocessable_entity
27
+ # Checked before the store is written: a refused key never reaches the file nor ENV.
28
+ if (reason = Profiler::EnvKeyPolicy.rejection(key))
29
+ render json: { error: "#{reason}; #{key} was left unchanged" }, status: :unprocessable_entity
30
30
  return
31
31
  end
32
32
 
33
33
  value = params[:value]
34
34
 
35
+ # A value ENV cannot hold is refused before the store is written, like a refused key.
36
+ if (reason = Profiler::EnvKeyPolicy.value_rejection(key, value))
37
+ render json: { error: "#{reason}; #{key} was left unchanged" }, status: :unprocessable_entity
38
+ return
39
+ end
40
+
35
41
  if Profiler::Redaction.mask?(value)
36
42
  render json: { error: "#{Profiler::Redaction::MASK} is the mask the profiler shows in place of a " \
37
43
  "hidden value, not a value; #{key} was left unchanged" },
data/exe/profiler-mcp CHANGED
@@ -1,6 +1,11 @@
1
1
  #!/usr/bin/env ruby
2
2
  # frozen_string_literal: true
3
3
 
4
+ # The MCP server over stdio, without the application: its initializers do not run here, so
5
+ # config.mcp_read_only set there has no effect. Set PROFILER_MCP_READ_ONLY=1 in the environment the
6
+ # MCP client gives this process to leave out the write tools (Profiler::MCP::Server.read_only?).
7
+ # `bundle exec rake profiler:mcp` boots the application and reads both.
8
+
4
9
  require "bundler/setup"
5
10
  require "profiler"
6
11
  require "profiler/mcp/server"
@@ -40,7 +40,7 @@ module Profiler
40
40
  attr_accessor :storage_options, :collectors,
41
41
  :slow_query_threshold, :max_queries_warning, :sql_backtrace,
42
42
  :track_memory, :allocated_objects_warning_threshold,
43
- :mcp_enabled, :mcp_transport, :mcp_port,
43
+ :mcp_enabled, :mcp_transport, :mcp_port, :mcp_read_only,
44
44
  :authorization_mode, :extension_cors_enabled,
45
45
  :cors_allowed_origins, :api_forgery_protection, :frame_ancestors,
46
46
  :track_ajax, :ajax_skip_paths,
@@ -79,6 +79,10 @@ module Profiler
79
79
  @mcp_enabled = false
80
80
  @mcp_transport = :stdio
81
81
  @mcp_port = 3001
82
+ # true leaves out of the MCP server every tool that changes something: run_tests,
83
+ # clear_profiles and the env tools that set, delete or reset a variable
84
+ # (Profiler::MCP::Server::WRITE_TOOLS). Read when the server is built, at the first MCP call.
85
+ @mcp_read_only = false
82
86
  @authorization_mode = :allow_local
83
87
  @authorize_block = nil
84
88
  @max_profiles = 100
@@ -0,0 +1,105 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Profiler
4
+ # Which environment variables the profiler may change: the Env tab, the MCP env tools, the
5
+ # overrides replayed by EnvOverrideStore#apply! and the environment of the test runner all
6
+ # read this one list.
7
+ #
8
+ # It is a deny list, chosen over an allow list on purpose (DECISIONS.md): the Env tab exists for
9
+ # the application's own variables, feature flags and API URLs, whose names nobody can enumerate
10
+ # in the gem. A deny list is by nature incomplete: any gem, library or tool the application uses
11
+ # can read a variable of its own tomorrow, a new database adapter or HTTP client among them, and
12
+ # no list written here can foresee it. The names below are the ones known to redirect the
13
+ # application's connections, hand over its secrets, or make a process load other code; a
14
+ # variable left out here can still be set in the shell that starts the application.
15
+ module EnvKeyPolicy
16
+ # Set by the test runner in the environment of the test process.
17
+ TEST_RUNNER_CHILD_ENV = "PROFILER_TEST_RUNNER_CHILD"
18
+ RESERVED_KEY_ERROR = "#{TEST_RUNNER_CHILD_ENV} is reserved for the test runner"
19
+
20
+ # Select the environment, the database or the secrets of the application, where it connects
21
+ # and which certificates it trusts, or the profiler's own read-only switch.
22
+ PROTECTED_KEYS = %w[
23
+ RAILS_ENV RACK_ENV DATABASE_URL SECRET_KEY_BASE RAILS_MASTER_KEY PROFILER_TEST_RUNNER_CHILD
24
+ PROFILER_MCP_READ_ONLY SSL_CERT_FILE SSL_CERT_DIR
25
+ ].freeze
26
+ # PG: libpq (PGHOST, PGPASSWORD, PGSERVICEFILE...). MYSQL_: the MySQL client (MYSQL_PWD,
27
+ # MYSQL_HOST...). OPENSSL_: OPENSSL_CONF and OPENSSL_MODULES can load a shared library.
28
+ PROTECTED_PREFIXES = %w[PG MYSQL_ OPENSSL_].freeze
29
+ # _DATABASE_URL: ActiveRecord reads PRIMARY_DATABASE_URL (any <name>_DATABASE_URL of a
30
+ # multi-database configuration) before DATABASE_URL. _PROXY: http_proxy, HTTPS_PROXY, no_proxy
31
+ # and the others, read again by Net::HTTP at each connection, whatever their case.
32
+ PROTECTED_SUFFIXES = %w[_DATABASE_URL _PROXY].freeze
33
+
34
+ # Overrides of these variables make a process, or the shell shims (rbenv, asdf) that start
35
+ # it, load or run code other than its own.
36
+ CODE_LOADING_KEYS = %w[
37
+ SPEC_OPTS TESTOPTS TEST PATH HOME XDG_CONFIG_HOME GEMRC NODE_OPTIONS NODE_PATH
38
+ SHELLOPTS BASHOPTS PS4 ENV CDPATH IFS ZDOTDIR GCONV_PATH LOCPATH IRBRC PRYRC PRY_RC
39
+ LESSOPEN LESSCLOSE PAGER EDITOR VISUAL JAVA_TOOL_OPTIONS JDK_JAVA_OPTIONS _JAVA_OPTIONS
40
+ ].freeze
41
+ CODE_LOADING_PREFIXES = %w[
42
+ RUBY GEM_ BUNDLE_ BUNDLER_ LD_ DYLD_ BASH_ RBENV_ ASDF_ RVM_ CHRUBY GIT_ BOOTSNAP_
43
+ PYTHON PERL5
44
+ ].freeze
45
+
46
+ # Letters, digits and underscores, not starting with a digit. Anything else, "=" or a NUL
47
+ # byte above all, either cannot be written into ENV or is not a name a shell can set.
48
+ ENV_NAME = /\A[A-Z_][A-Z0-9_]*\z/i
49
+
50
+ module_function
51
+
52
+ def valid_name?(key)
53
+ ENV_NAME.match?(key.to_s)
54
+ end
55
+
56
+ def reserved?(key)
57
+ key.to_s.upcase == TEST_RUNNER_CHILD_ENV
58
+ end
59
+
60
+ def protected?(key)
61
+ name = key.to_s.upcase
62
+ PROTECTED_KEYS.include?(name) ||
63
+ PROTECTED_PREFIXES.any? { |prefix| name.start_with?(prefix) } ||
64
+ PROTECTED_SUFFIXES.any? { |suffix| name.end_with?(suffix) }
65
+ end
66
+
67
+ # True for a name that is not valid, too: the test runner leaves those out with the others.
68
+ def code_loading?(key)
69
+ name = key.to_s.upcase
70
+ return true unless valid_name?(name)
71
+
72
+ CODE_LOADING_KEYS.include?(name) || CODE_LOADING_PREFIXES.any? { |prefix| name.start_with?(prefix) }
73
+ end
74
+
75
+ # Why the profiler refuses to set or delete key, or nil when it may.
76
+ def rejection(key)
77
+ name = key.to_s
78
+ return "key cannot be blank" if name.strip.empty?
79
+ return RESERVED_KEY_ERROR if reserved?(name)
80
+ unless valid_name?(name)
81
+ return "#{name.inspect} is not a valid environment variable name " \
82
+ "(letters, digits and underscores, not starting with a digit)"
83
+ end
84
+ if protected?(name) || code_loading?(name)
85
+ return "#{name} is protected: the profiler does not change the variables that select the " \
86
+ "environment, the database, the secrets or the network of the application, nor " \
87
+ "those that make a process load other code"
88
+ end
89
+
90
+ nil
91
+ end
92
+
93
+ # Why the profiler refuses to set key to value, or nil when it may. A NUL byte cannot be held
94
+ # by an environment variable: ENV[]= raises on it.
95
+ def value_rejection(key, value)
96
+ return nil unless value.to_s.include?("\0")
97
+
98
+ "the value of #{key} contains a NUL byte, which an environment variable cannot hold"
99
+ end
100
+
101
+ def allowed?(key)
102
+ rejection(key).nil?
103
+ end
104
+ end
105
+ end
@@ -3,6 +3,7 @@
3
3
  require "json"
4
4
  require "fileutils"
5
5
  require_relative "storage/private_files"
6
+ require_relative "env_key_policy"
6
7
 
7
8
  module Profiler
8
9
  class EnvOverrideStore
@@ -26,17 +27,37 @@ module Profiler
26
27
  # File format: { "KEY" => { "value" => "...", "original" => "..." } }
27
28
  # Sentinels for "value":
28
29
  # DELETED_SENTINEL → ENV.delete(key)
29
- # RESTORE_SENTINEL → restore ENV[key] to "original" (cleaned up after apply!)
30
+ # RESTORE_SENTINEL → every process that applied the key puts back the value it had before
31
+ # (apply!). The entry carries "reset_at" and stays RESTORE_TTL seconds,
32
+ # so that each Sidekiq process and Puma worker sees it, not only the first.
33
+
34
+ # How long a RESTORE entry stays in the file. A process that runs no job for longer keeps the
35
+ # override until it restarts.
36
+ RESTORE_TTL = 7 * 24 * 60 * 60
37
+
38
+ # How old the overrides file must be before apply! keeps its content between calls
39
+ # (cached_overrides).
40
+ CACHE_SETTLE_SECONDS = 2
30
41
 
31
42
  def initialize
32
43
  # What ENV held in this process before set, delete or apply! changed a key. Where the
33
44
  # overrides are blocked, a reset restores these and never the file's "original" values,
34
- # which come from whatever machine wrote the file.
45
+ # which come from whatever machine wrote the file. apply! restores these too, so it knows
46
+ # which keys this process changed and has to put back.
35
47
  @process_originals = {}
36
48
  @process_originals_lock = Mutex.new
49
+ # [signature of the file, parsed overrides] as apply! last read them.
50
+ @cached_overrides = nil
51
+ @skipped_keys_logged = nil
37
52
  end
38
53
 
39
54
  def set(key, value)
55
+ # Before the file is written: an entry ENV cannot hold would fail in every process that
56
+ # applies it.
57
+ if (reason = EnvKeyPolicy.value_rejection(key, value))
58
+ raise Error, "could not set #{key}: #{reason}"
59
+ end
60
+
40
61
  surfacing("set #{key}") do
41
62
  remember_process_original(key)
42
63
  update_overrides do |overrides|
@@ -61,7 +82,7 @@ module Profiler
61
82
  update_overrides do |overrides|
62
83
  entry = overrides[key]
63
84
  # Keep a RESTORE entry so Sidekiq workers pick it up on next job
64
- overrides[key] = { "value" => RESTORE_SENTINEL, "original" => entry["original"] } if entry
85
+ overrides[key] = restore_entry(entry["original"]) if entry
65
86
  end
66
87
  end
67
88
 
@@ -74,9 +95,12 @@ module Profiler
74
95
  surfacing("reset the overrides") do
75
96
  update_overrides do |overrides|
76
97
  overrides.each do |key, entry|
98
+ # Already reset: its reset_at stays, so that it still expires.
99
+ next if restore_entry?(entry)
100
+
77
101
  originals[key] = entry.is_a?(Hash) ? entry["original"] : nil
78
102
  # Leave a RESTORE sentinel for Sidekiq workers to pick up
79
- overrides[key] = { "value" => RESTORE_SENTINEL, "original" => originals[key] }
103
+ overrides[key] = restore_entry(originals[key])
80
104
  end
81
105
  end
82
106
  end
@@ -116,47 +140,34 @@ module Profiler
116
140
 
117
141
  # Set by the test runner in the environment of the test process, to which it already gave
118
142
  # the admitted overrides only (TestRunner::Runner.build_env).
119
- TEST_RUNNER_CHILD_ENV = "PROFILER_TEST_RUNNER_CHILD"
120
- RESERVED_KEY_ERROR = "#{TEST_RUNNER_CHILD_ENV} is reserved for the test runner"
143
+ TEST_RUNNER_CHILD_ENV = EnvKeyPolicy::TEST_RUNNER_CHILD_ENV
144
+ RESERVED_KEY_ERROR = EnvKeyPolicy::RESERVED_KEY_ERROR
121
145
 
122
146
  # The env tools may not set or delete the test runner marker, whatever its case.
123
147
  def self.reserved_key?(key)
124
- key.to_s.upcase == TEST_RUNNER_CHILD_ENV
148
+ EnvKeyPolicy.reserved?(key)
125
149
  end
126
150
 
151
+ # Runs before every Sidekiq job and console line: the file is parsed again only when it was
152
+ # replaced or changed since the last call (cached_overrides). Each key is applied on its own,
153
+ # so that a key that cannot be written leaves the others applied. A key EnvKeyPolicy refuses,
154
+ # or a value ENV cannot hold, written by a version that did not check them, is left out.
127
155
  def apply!
128
156
  return if ENV[TEST_RUNNER_CHILD_ENV] == "1"
129
157
  return if blocked_reason
130
158
 
131
- overrides = load_overrides
132
- restore_keys = []
133
-
134
- overrides.each do |key, entry|
135
- value = entry.is_a?(Hash) ? entry["value"] : entry
136
- original = entry.is_a?(Hash) ? entry["original"] : nil
137
-
138
- remember_process_original(key) unless value == RESTORE_SENTINEL
139
-
140
- case value
141
- when DELETED_SENTINEL
142
- ENV.delete(key)
143
- when RESTORE_SENTINEL
144
- original.nil? ? ENV.delete(key) : ENV[key] = original
145
- restore_keys << key
146
- else
147
- ENV[key] = value
159
+ skipped = []
160
+ cached_overrides.each do |key, entry|
161
+ unless applicable?(key, entry)
162
+ skipped << key
163
+ next
148
164
  end
149
- end
150
165
 
151
- if restore_keys.any?
152
- # Read again under the lock: another writer may have changed the file since.
153
- update_overrides do |current|
154
- restore_keys.each do |k|
155
- entry = current[k]
156
- current.delete(k) if entry.is_a?(Hash) && entry["value"] == RESTORE_SENTINEL
157
- end
158
- end
166
+ apply_entry(key, entry)
167
+ rescue StandardError => e
168
+ Profiler.log_error("EnvOverrideStore: failed to apply an override", e)
159
169
  end
170
+ log_skipped_keys(skipped)
160
171
  rescue => e
161
172
  Profiler.log_error("EnvOverrideStore: failed to apply overrides", e)
162
173
  end
@@ -191,6 +202,72 @@ module Profiler
191
202
  raise Error, "could not #{action} in env_overrides.json under tmp_path: #{reason}"
192
203
  end
193
204
 
205
+ def applicable?(key, entry)
206
+ return false unless EnvKeyPolicy.allowed?(key)
207
+
208
+ value = entry.is_a?(Hash) ? entry["value"] : entry
209
+ EnvKeyPolicy.value_rejection(key, value).nil?
210
+ end
211
+
212
+ def apply_entry(key, entry)
213
+ value = entry.is_a?(Hash) ? entry["value"] : entry
214
+
215
+ if value == RESTORE_SENTINEL
216
+ restore_applied(key)
217
+ return
218
+ end
219
+
220
+ remember_process_original(key)
221
+ value == DELETED_SENTINEL ? ENV.delete(key) : ENV[key] = value
222
+ end
223
+
224
+ # A RESTORE entry stays in the file for every process: this one puts the key back only if it
225
+ # changed it, once, to the value it had before. A process that never applied the override has
226
+ # nothing to restore.
227
+ def restore_applied(key)
228
+ changed_here, original = @process_originals_lock.synchronize do
229
+ [@process_originals.key?(key), @process_originals.delete(key)]
230
+ end
231
+ return unless changed_here
232
+
233
+ original.nil? ? ENV.delete(key) : ENV[key] = original
234
+ end
235
+
236
+ # Says once per set of keys how many were left out, never which: the log may be shared.
237
+ def log_skipped_keys(skipped)
238
+ return if skipped.empty? || @skipped_keys_logged == skipped
239
+
240
+ @skipped_keys_logged = skipped
241
+ noun = skipped.size == 1 ? "override was" : "overrides were"
242
+ Profiler.log_warn("#{skipped.size} persisted environment #{noun} not applied: the name is not " \
243
+ "a valid environment variable name, or one the profiler does not change, " \
244
+ "or the value holds a NUL byte.")
245
+ end
246
+
247
+ def restore_entry(original)
248
+ { "value" => RESTORE_SENTINEL, "original" => original, "reset_at" => Time.now.to_i }
249
+ end
250
+
251
+ def restore_entry?(entry)
252
+ entry.is_a?(Hash) && entry["value"] == RESTORE_SENTINEL
253
+ end
254
+
255
+ # RESTORE entries older than RESTORE_TTL go when the file is next written. One written by an
256
+ # earlier version, with no reset_at, starts its delay now.
257
+ def expire_restore_entries(overrides)
258
+ now = Time.now.to_i
259
+ overrides.delete_if do |_key, entry|
260
+ next false unless restore_entry?(entry)
261
+
262
+ reset_at = entry["reset_at"]
263
+ unless reset_at.is_a?(Integer)
264
+ entry["reset_at"] = now
265
+ next false
266
+ end
267
+ now - reset_at > RESTORE_TTL
268
+ end
269
+ end
270
+
194
271
  def remember_process_original(key)
195
272
  @process_originals_lock.synchronize do
196
273
  @process_originals[key] = ENV[key] unless @process_originals.key?(key)
@@ -208,6 +285,11 @@ module Profiler
208
285
  return false if from_process && !changed_here
209
286
 
210
287
  original = from_process ? process_original : file_entry["original"]
288
+ # A name ENV cannot hold ("A=B"), written by a version that did not check the names: its
289
+ # entry is reset in the file, and there is nothing to put back here.
290
+ return false unless EnvKeyPolicy.valid_name?(key)
291
+ return false if EnvKeyPolicy.value_rejection(key, original)
292
+
211
293
  original.nil? ? ENV.delete(key) : ENV[key] = original
212
294
  true
213
295
  end
@@ -224,11 +306,46 @@ module Profiler
224
306
  path = override_file_path
225
307
  return {} unless File.exist?(path)
226
308
 
227
- JSON.parse(File.read(path))
309
+ parse_overrides(File.read(path))
310
+ end
311
+
312
+ def parse_overrides(content)
313
+ parsed = JSON.parse(content)
314
+ parsed.is_a?(Hash) ? parsed : {}
228
315
  rescue JSON::ParserError
229
316
  {}
230
317
  end
231
318
 
319
+ # load_overrides for apply!, without reading the file again while it is the same file.
320
+ #
321
+ # The signature (inode, device, mtime, size) alone can miss a write: ext4 hands the inode the
322
+ # rename freed to the next temporary file, and two writes of the same size within one tick of
323
+ # the mtime clock then look identical, the stale value kept until the file is written again.
324
+ # So the content is cached only once the file has settled: its mtime at least
325
+ # CACHE_SETTLE_SECONDS in the past. Any later write is then at least that much younger, and
326
+ # gets another mtime on any file system whose mtime tick is shorter (2 seconds on FAT, the
327
+ # coarsest). A file written less than that ago is read at each call, which costs a read only
328
+ # during the seconds after a change. A write between the stat and the read caches the new
329
+ # content under the old signature: the next call sees another mtime and reads again.
330
+ def cached_overrides
331
+ path = override_file_path
332
+ stat = File.stat(path)
333
+ signature = [path.to_s, stat.ino, stat.dev, stat.mtime, stat.size]
334
+ cached = @cached_overrides
335
+ return cached[1] if cached && cached[0] == signature
336
+
337
+ overrides = parse_overrides(File.read(path)).freeze
338
+ @cached_overrides = settled?(stat) ? [signature, overrides] : nil
339
+ overrides
340
+ rescue Errno::ENOENT
341
+ @cached_overrides = nil
342
+ {}
343
+ end
344
+
345
+ def settled?(stat)
346
+ Time.now - stat.mtime >= CACHE_SETTLE_SECONDS
347
+ end
348
+
232
349
  # Reads, changes and writes the file under the lock, so that the web process, its threads and
233
350
  # the Sidekiq processes never save over each other's changes. The file is removed when the
234
351
  # block leaves it empty.
@@ -236,6 +353,7 @@ module Profiler
236
353
  with_lock do
237
354
  overrides = load_overrides
238
355
  yield overrides
356
+ expire_restore_entries(overrides)
239
357
  overrides.empty? ? FileUtils.rm_f(override_file_path) : save_overrides(overrides)
240
358
  end
241
359
  end
@@ -6,11 +6,32 @@ require_relative "http_guard"
6
6
  module Profiler
7
7
  module MCP
8
8
  class Server
9
+ # The tools that change the profiler's data, ENV or run code: left out when
10
+ # config.mcp_read_only is true or PROFILER_MCP_READ_ONLY is set (Server.read_only?).
11
+ WRITE_TOOLS = %w[
12
+ run_tests clear_profiles set_env_var delete_env_var reset_env_var reset_all_env_vars
13
+ ].freeze
14
+
15
+ # Makes the server read-only like config.mcp_read_only, for exe/profiler-mcp above all: that
16
+ # executable does not boot the application, so the application's initializers, and the
17
+ # option they set, never run there. Any value but an empty one, 0, false, no or off turns
18
+ # it on; it never turns off a config.mcp_read_only set to true.
19
+ READ_ONLY_ENV = "PROFILER_MCP_READ_ONLY"
20
+ READ_ONLY_ENV_OFF = %w[0 false no off].freeze
21
+
9
22
  class << self
10
23
  def instance
11
24
  @instance ||= new
12
25
  end
13
26
 
27
+ # Whether the server leaves out WRITE_TOOLS: config.mcp_read_only, or READ_ONLY_ENV.
28
+ def read_only?
29
+ return true if Profiler.configuration.mcp_read_only
30
+
31
+ value = ENV[READ_ONLY_ENV].to_s.strip.downcase
32
+ !value.empty? && !READ_ONLY_ENV_OFF.include?(value)
33
+ end
34
+
14
35
  def rack_app
15
36
  # Memoized at class level — survives route reloads in development
16
37
  @rack_app ||= lambda do |env|
@@ -399,10 +420,13 @@ module Profiler
399
420
  input_schema: { properties: {} },
400
421
  handler: Tools::ListSlaves
401
422
  )
402
- ]
423
+ ].compact
403
424
  end
404
425
 
426
+ # nil for a tool a read-only server leaves out (Server.read_only?); build_tools drops it.
405
427
  def define_tool(name:, description:, input_schema:, handler:)
428
+ return nil if WRITE_TOOLS.include?(name) && self.class.read_only?
429
+
406
430
  ::MCP::Tool.define(name: name, description: description, input_schema: input_schema) do |server_context: nil, **args|
407
431
  result = handler.call(args.transform_keys(&:to_s))
408
432
  result.is_a?(::MCP::Tool::Response) ? result : ::MCP::Tool::Response.new(result)
@@ -2,6 +2,7 @@
2
2
 
3
3
  require "mcp"
4
4
  require_relative "../slave_support"
5
+ require_relative "../../env_key_policy"
5
6
 
6
7
  module Profiler
7
8
  module MCP
@@ -11,9 +12,11 @@ module Profiler
11
12
  key = params["key"].to_s.strip
12
13
 
13
14
  return [{ type: "text", text: "Error: key cannot be blank." }] if key.empty?
14
- if Profiler::EnvOverrideStore.reserved_key?(key)
15
+ # Checked before the store is written, and before a slave is asked: a refused key never
16
+ # reaches the file nor ENV.
17
+ if (reason = Profiler::EnvKeyPolicy.rejection(key))
15
18
  return ::MCP::Tool::Response.new(
16
- [{ type: "text", text: "Error: #{Profiler::EnvOverrideStore::RESERVED_KEY_ERROR}; #{key} was left unchanged." }],
19
+ [{ type: "text", text: "Error: #{reason}; #{key} was left unchanged." }],
17
20
  error: true
18
21
  )
19
22
  end
@@ -2,6 +2,7 @@
2
2
 
3
3
  require "mcp"
4
4
  require_relative "../slave_support"
5
+ require_relative "../../env_key_policy"
5
6
  require_relative "../../redaction"
6
7
 
7
8
  module Profiler
@@ -13,9 +14,11 @@ module Profiler
13
14
  value = params["value"].to_s
14
15
 
15
16
  return [{ type: "text", text: "Error: key cannot be blank." }] if key.empty?
16
- if Profiler::EnvOverrideStore.reserved_key?(key)
17
+ # Checked before the store is written, and before a slave is asked: a refused key or value
18
+ # never reaches the file nor ENV.
19
+ if (reason = Profiler::EnvKeyPolicy.rejection(key) || Profiler::EnvKeyPolicy.value_rejection(key, value))
17
20
  return ::MCP::Tool::Response.new(
18
- [{ type: "text", text: "Error: #{Profiler::EnvOverrideStore::RESERVED_KEY_ERROR}; #{key} was left unchanged." }],
21
+ [{ type: "text", text: "Error: #{reason}; #{key} was left unchanged." }],
19
22
  error: true
20
23
  )
21
24
  end
@@ -2,6 +2,7 @@
2
2
 
3
3
  require "fileutils"
4
4
  require "json"
5
+ require "set"
5
6
  require_relative "private_files"
6
7
  require_relative "token"
7
8
 
@@ -12,6 +13,8 @@ module Profiler
12
13
  # find nothing and writes are refused.
13
14
  class BlobStore
14
15
  NAME_FORMAT = /\A[a-z0-9_]+\z/
16
+ # A save writes its blobs before its row: prune leaves the directories this recent alone.
17
+ PRUNE_GRACE = 60
15
18
 
16
19
  def initialize(path)
17
20
  @path = path.to_s
@@ -46,6 +49,36 @@ module Profiler
46
49
  FileUtils.rm_rf(dir) if File.directory?(dir)
47
50
  end
48
51
 
52
+ # Removes one blob of token, and its directory once it holds no other.
53
+ def delete_blob(token, collector_name)
54
+ return unless valid?(token, collector_name)
55
+
56
+ dir = token_dir(token)
57
+ FileUtils.rm_f(File.join(dir, "#{collector_name}.json"))
58
+ Dir.rmdir(dir) if File.directory?(dir) && Dir.empty?(dir)
59
+ rescue SystemCallError => e
60
+ Profiler.log_error("BlobStore: could not delete #{token}/#{collector_name}", e)
61
+ end
62
+
63
+ # Removes the directories of the tokens not in except_tokens: those whose row was deleted
64
+ # outside the store, or that a failed save left behind. Only the directories named as a
65
+ # token and unchanged for grace seconds are considered.
66
+ def prune(except_tokens, grace: PRUNE_GRACE)
67
+ keep = except_tokens.to_set
68
+ cutoff = Time.now - grace
69
+ Dir.each_child(@path) do |name|
70
+ next if !Token.valid?(name) || keep.include?(name)
71
+
72
+ dir = token_dir(name)
73
+ stat = File.lstat(dir)
74
+ FileUtils.rm_rf(dir) if stat.directory? && stat.mtime < cutoff
75
+ rescue SystemCallError => e
76
+ Profiler.log_error("BlobStore: could not prune #{name}", e)
77
+ end
78
+ rescue SystemCallError => e
79
+ Profiler.log_error("BlobStore: could not prune #{@path}", e)
80
+ end
81
+
49
82
  def exists?(token, collector_name)
50
83
  return false unless valid?(token, collector_name)
51
84
 
@@ -28,6 +28,9 @@ module Profiler
28
28
  # of an earlier version) is removed by one process, under a lock, by batches of INDEX_BATCH.
29
29
  EVICT_BATCH = 100
30
30
  EVICT_LOCK_SECONDS = 60
31
+ # A page that held expired profiles is read again, once they are out of the indexes, at
32
+ # most this many times.
33
+ LIST_REPAIRS = 3
31
34
  # Deletes a lock only while it still holds the value its owner set: one that expired and was
32
35
  # taken by another process is left to it.
33
36
  RELEASE_LOCK = <<~LUA
@@ -50,7 +53,8 @@ module Profiler
50
53
  data = profile.to_h
51
54
  unindex(token, read_summary(token))
52
55
  @redis.setex(profile_key(token), @ttl, data.to_json)
53
- index(token, profile.started_at.to_f, Summary.build(data))
56
+ @redis.pipelined { |pipe| index(token, profile.started_at.to_f, Summary.build(data), client: pipe) }
57
+ prune_expired
54
58
  evict_first_saved(token)
55
59
  token
56
60
  end
@@ -68,19 +72,18 @@ module Profiler
68
72
  nil
69
73
  end
70
74
 
71
- # Newest first. summary: true reads the summaries only.
75
+ # Newest first. summary: true reads the summaries only. The profiles found expired are taken
76
+ # out of the indexes and the page read again, for it to hold limit profiles when there are.
72
77
  def list(limit: 50, offset: 0, type: nil, summary: false)
73
78
  ensure_index
74
- tokens = @redis.zrevrange(type ? type_list_key(type) : list_key, offset, offset + limit - 1)
75
- return tokens.filter_map { |token| load(token) } unless summary
79
+ key = type ? type_list_key(type) : list_key
80
+ LIST_REPAIRS.times do
81
+ page, expired = list_page(key, offset, limit, summary)
82
+ return page if expired.empty?
76
83
 
77
- summaries = tokens.empty? ? [] : @redis.mget(*tokens.map { |token| summary_key(token) })
78
- tokens.zip(summaries).filter_map do |token, json|
79
- next Summary.to_profile(JSON.parse(json)) if json
80
-
81
- profile = load(token)
82
- profile && Summary.to_profile(Summary.build(profile))
84
+ remove_tokens(expired)
83
85
  end
86
+ list_page(key, offset, limit, summary).first
84
87
  end
85
88
 
86
89
  def cleanup(older_than: 24 * 60 * 60)
@@ -118,6 +121,46 @@ module Profiler
118
121
  Redis.new(url: url)
119
122
  end
120
123
 
124
+ # The page at offset, and the tokens of it whose profile has expired.
125
+ def list_page(key, offset, limit, summary)
126
+ tokens = @redis.zrevrange(key, offset, offset + limit - 1)
127
+ return [[], []] if tokens.empty?
128
+
129
+ expired = []
130
+ page = if summary
131
+ summaries = @redis.mget(*tokens.map { |token| summary_key(token) })
132
+ tokens.zip(summaries).filter_map do |token, json|
133
+ next Summary.to_profile(JSON.parse(json)) if json
134
+
135
+ profile = load(token)
136
+ expired << token if profile.nil? && !@redis.exists?(profile_key(token))
137
+ profile && Summary.to_profile(Summary.build(profile))
138
+ end
139
+ else
140
+ profiles = @redis.mget(*tokens.map { |token| profile_key(token) })
141
+ tokens.zip(profiles).filter_map do |token, json|
142
+ if json.nil?
143
+ expired << token
144
+ next
145
+ end
146
+
147
+ Models::Profile.from_json(json)
148
+ rescue => e
149
+ Profiler.log_error("RedisStore: could not load profile #{token}", e)
150
+ nil
151
+ end
152
+ end
153
+ [page, expired]
154
+ end
155
+
156
+ # A profile expires a TTL after its last save, with its keys, and Redis says nothing to the
157
+ # indexes: the tokens saved before that are taken out of them at each save. The indexes
158
+ # themselves expire a TTL after the last save, when every profile they list has.
159
+ def prune_expired
160
+ expired = @redis.zrangebyscore(saved_key, "-inf", Time.now.to_f - @ttl)
161
+ expired.each_slice(INDEX_BATCH) { |tokens| remove_tokens(tokens) }
162
+ end
163
+
121
164
  # score: the start time, the order of the lists. saved_at: the order of the saves, which the
122
165
  # eviction follows.
123
166
  def index(token, score, summary, saved_at: Time.now.to_f, client: @redis)
@@ -128,6 +171,7 @@ module Profiler
128
171
  client.zadd(type_list_key(type), score, token)
129
172
  client.zadd(saved_key, saved_at, token)
130
173
  client.sadd(types_key, type)
174
+ [list_key, type_list_key(type), saved_key, types_key].each { |key| client.expire(key, @ttl) }
131
175
  return unless Token.valid?(parent)
132
176
 
133
177
  client.sadd(children_key(parent), token)
@@ -2,6 +2,7 @@
2
2
 
3
3
  require "json"
4
4
  require "fileutils"
5
+ require "time"
5
6
  require_relative "base_store"
6
7
  require_relative "blob_store"
7
8
  require_relative "private_files"
@@ -14,6 +15,7 @@ module Profiler
14
15
  class SqliteStore < BaseStore
15
16
  # Past max_profiles, the oldest profiles are evicted down to this share of it.
16
17
  LOW_WATER = 0.8
18
+ HTTP_BODIES = "http_response_bodies"
17
19
 
18
20
  def initialize(options = {})
19
21
  require "sqlite3"
@@ -40,13 +42,14 @@ module Profiler
40
42
  data = profile.to_h
41
43
 
42
44
  collectors_meta = {}
45
+ bodies_saved = false
43
46
  (data[:collectors_data] || {}).each do |collector_name, collector_data|
44
47
  next unless collector_data.is_a?(Hash)
45
48
 
46
49
  if collector_name.to_s == "http"
47
50
  requests = collector_data["requests"]
48
51
  if requests
49
- stripped = save_http_response_bodies(token, requests)
52
+ stripped, bodies_saved = save_http_response_bodies(token, requests)
50
53
  collectors_meta[collector_name] = collector_data.merge("requests" => stripped)
51
54
  else
52
55
  collectors_meta[collector_name] = collector_data
@@ -55,6 +58,9 @@ module Profiler
55
58
  collectors_meta[collector_name] = collector_data
56
59
  end
57
60
  end
61
+ # A profile saved again without response bodies keeps none of its previous save: they
62
+ # would be merged into its requests on load.
63
+ @blob_store.delete_blob(token, HTTP_BODIES) unless bodies_saved
58
64
 
59
65
  @db.execute(
60
66
  <<~SQL,
@@ -76,8 +82,8 @@ module Profiler
76
82
  status: data[:status],
77
83
  duration: data[:duration],
78
84
  memory: data[:memory],
79
- started_at: data[:started_at],
80
- finished_at: data[:finished_at],
85
+ started_at: utc_iso8601(data[:started_at]),
86
+ finished_at: utc_iso8601(data[:finished_at]),
81
87
  parent_token: data[:parent_token],
82
88
  is_ajax: data[:is_ajax] ? 1 : 0,
83
89
  tabs: JSON.generate(data[:tabs] || []),
@@ -157,6 +163,7 @@ module Profiler
157
163
  ).map { |r| r["token"] }
158
164
  @db.execute("DELETE FROM profiler_profiles WHERE started_at < :cutoff", cutoff: cutoff)
159
165
  tokens.each { |token| @blob_store.delete(token) }
166
+ prune_blobs
160
167
  end
161
168
 
162
169
  private
@@ -179,6 +186,12 @@ module Profiler
179
186
  @db.execute("DELETE FROM profiler_profiles WHERE token IN (#{(["?"] * slice.size).join(",")})", slice)
180
187
  end
181
188
  tokens.each { |token| @blob_store.delete(token) }
189
+ prune_blobs
190
+ end
191
+
192
+ # The blobs of the rows deleted outside the store (a database removed or recreated).
193
+ def prune_blobs
194
+ @blob_store.prune(@db.execute("SELECT token FROM profiler_profiles").map { |r| r["token"] })
182
195
  end
183
196
 
184
197
  # A row written before the summary column falls back to the whole row, summarized.
@@ -195,24 +208,26 @@ module Profiler
195
208
  nil
196
209
  end
197
210
 
211
+ # The requests without their response bodies, and whether a blob of bodies was written.
198
212
  def save_http_response_bodies(token, requests)
199
213
  bodies = requests.map do |req|
200
214
  { "response_body" => req["response_body"], "response_body_encoding" => req["response_body_encoding"] }
201
215
  end
202
216
 
203
- return requests unless bodies.any? { |b| !b["response_body"].nil? }
217
+ return [requests, false] unless bodies.any? { |b| !b["response_body"].nil? }
204
218
 
205
- @blob_store.write(token, "http_response_bodies", bodies)
219
+ @blob_store.write(token, HTTP_BODIES, bodies)
206
220
 
207
- requests.map do |req|
221
+ stripped = requests.map do |req|
208
222
  req.reject { |k, _| %w[response_body response_body_encoding].include?(k) }
209
223
  end
224
+ [stripped, true]
210
225
  end
211
226
 
212
227
  def load_http_response_bodies(token, requests)
213
228
  return requests if requests.nil? || requests.empty?
214
229
 
215
- bodies = @blob_store.read(token, "http_response_bodies")
230
+ bodies = @blob_store.read(token, HTTP_BODIES)
216
231
  return requests if bodies.nil? || bodies.empty?
217
232
 
218
233
  requests.each_with_index.map do |req, i|
@@ -266,6 +281,26 @@ module Profiler
266
281
  rescue SQLite3::Exception
267
282
  # column already exists
268
283
  end
284
+
285
+ migrate_times_to_utc
286
+ end
287
+
288
+ # Version 1 stores the times in UTC. The rows written before it carry the local offset of
289
+ # the process that saved them, and the default database outlives a restart: they are
290
+ # rewritten once, by SQLite, which reads the offset. A value it cannot read is left as is.
291
+ def migrate_times_to_utc
292
+ return if @db.get_first_value("PRAGMA user_version").to_i >= 1
293
+
294
+ @db.transaction do
295
+ %w[started_at finished_at].each do |column|
296
+ @db.execute(<<~SQL)
297
+ UPDATE profiler_profiles
298
+ SET #{column} = strftime('%Y-%m-%dT%H:%M:%SZ', #{column})
299
+ WHERE #{column} NOT LIKE '%Z' AND strftime('%Y-%m-%dT%H:%M:%SZ', #{column}) IS NOT NULL
300
+ SQL
301
+ end
302
+ @db.execute("PRAGMA user_version = 1")
303
+ end
269
304
  end
270
305
 
271
306
  def row_to_profile(row, load_blobs: true)
@@ -293,8 +328,8 @@ module Profiler
293
328
  status: row["status"],
294
329
  duration: row["duration"],
295
330
  memory: row["memory"],
296
- started_at: row["started_at"],
297
- finished_at: row["finished_at"],
331
+ started_at: local_iso8601(row["started_at"]),
332
+ finished_at: local_iso8601(row["finished_at"]),
298
333
  parent_token: row["parent_token"],
299
334
  is_ajax: row["is_ajax"] == 1,
300
335
  tabs: parse_json(row["tabs"], []),
@@ -312,6 +347,25 @@ module Profiler
312
347
  nil
313
348
  end
314
349
 
350
+ # The times are stored in UTC, all in the same form, for the cleanup and the lists to compare
351
+ # them as text; a profile gives them with the offset of the process that recorded it.
352
+ def utc_iso8601(value)
353
+ return value if value.nil?
354
+
355
+ Time.iso8601(value.to_s).utc.iso8601
356
+ rescue ArgumentError
357
+ value
358
+ end
359
+
360
+ # Read back in local time, as the profile gave them.
361
+ def local_iso8601(value)
362
+ return value if value.nil?
363
+
364
+ Time.iso8601(value).localtime.iso8601
365
+ rescue ArgumentError
366
+ value
367
+ end
368
+
315
369
  def parse_json(value, default)
316
370
  return default if value.nil? || value.empty?
317
371
 
@@ -1,17 +1,33 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ module Profiler
4
+ module Tasks
5
+ # The whole number an environment variable gives, or default when it is not set. An empty or
6
+ # non-numeric value ("", "abc", "1h") stops the task: read as 0, OLDER_THAN would delete every
7
+ # profile.
8
+ def self.integer_env(name, default, minimum:, unit:)
9
+ value = ENV[name]
10
+ return default if value.nil?
11
+ return Integer(value, 10) if value.match?(/\A\d+\z/) && Integer(value, 10) >= minimum
12
+
13
+ warn "#{name} must be a whole number of #{unit} (#{minimum} or more), got #{value.inspect}."
14
+ exit 1
15
+ end
16
+ end
17
+ end
18
+
3
19
  namespace :profiler do
4
- desc "Clean up old profiles"
20
+ desc "Clean up old profiles (OLDER_THAN: seconds, 86400 by default)"
5
21
  task cleanup: :environment do
6
- older_than = ENV["OLDER_THAN"]&.to_i || 24 * 60 * 60
22
+ older_than = Profiler::Tasks.integer_env("OLDER_THAN", 24 * 60 * 60, minimum: 0, unit: "seconds")
7
23
  puts "Cleaning up profiles older than #{older_than} seconds..."
8
24
  Profiler.storage.cleanup(older_than: older_than)
9
25
  puts "Cleanup complete!"
10
26
  end
11
27
 
12
- desc "List recent profiles"
28
+ desc "List recent profiles (LIMIT: 20 by default)"
13
29
  task list: :environment do
14
- limit = ENV["LIMIT"]&.to_i || 20
30
+ limit = Profiler::Tasks.integer_env("LIMIT", 20, minimum: 1, unit: "profiles")
15
31
  profiles = Profiler.storage.list(limit: limit)
16
32
 
17
33
  if profiles.empty?
@@ -3,6 +3,7 @@
3
3
  require "set"
4
4
  require_relative "../boot_env"
5
5
  require_relative "../env_override_store"
6
+ require_relative "../env_key_policy"
6
7
  require_relative "run_store"
7
8
  require_relative "discovery"
8
9
 
@@ -176,26 +177,14 @@ module Profiler
176
177
  end
177
178
  end
178
179
 
179
- BLOCKED_ENV_KEYS = %w[RAILS_ENV RACK_ENV DATABASE_URL SECRET_KEY_BASE PROFILER_TEST_RUNNER_CHILD].freeze
180
-
181
- # Overrides of these variables make the test process, or the shell shims (rbenv, asdf) that
182
- # start it, load or run code other than the selected tests. It is a deny list, so it cannot
183
- # be complete; a variable needed by the tests can be set in the shell that starts Rails.
184
- CODE_LOADING_ENV_KEYS = %w[
185
- SPEC_OPTS TESTOPTS TEST PATH HOME XDG_CONFIG_HOME GEMRC NODE_OPTIONS NODE_PATH
186
- SHELLOPTS BASHOPTS PS4 ENV CDPATH IFS
187
- ].freeze
188
- CODE_LOADING_ENV_PREFIXES = %w[
189
- RUBY GEM_ BUNDLE_ BUNDLER_ LD_ DYLD_ BASH_ RBENV_ ASDF_ RVM_ CHRUBY GIT_ BOOTSNAP_
190
- PYTHON PERL5
191
- ].freeze
192
- ENV_NAME = /\A[A-Z_][A-Z0-9_]*\z/i
180
+ # The lists live in Profiler::EnvKeyPolicy, shared with the Env tab and the MCP env tools.
181
+ BLOCKED_ENV_KEYS = EnvKeyPolicy::PROTECTED_KEYS
182
+ CODE_LOADING_ENV_KEYS = EnvKeyPolicy::CODE_LOADING_KEYS
183
+ CODE_LOADING_ENV_PREFIXES = EnvKeyPolicy::CODE_LOADING_PREFIXES
184
+ ENV_NAME = EnvKeyPolicy::ENV_NAME
193
185
 
194
186
  def self.code_loading_env_key?(key)
195
- name = key.to_s.upcase
196
- return true unless ENV_NAME.match?(name)
197
-
198
- CODE_LOADING_ENV_KEYS.include?(name) || CODE_LOADING_ENV_PREFIXES.any? { |prefix| name.start_with?(prefix) }
187
+ EnvKeyPolicy.code_loading?(key)
199
188
  end
200
189
 
201
190
  # Starts from the copy of ENV taken when the gem was loaded, not from ENV: the overrides are
@@ -208,12 +197,15 @@ module Profiler
208
197
 
209
198
  overrides = Profiler.env_override_store.all_overrides
210
199
  overrides.each do |key, entry|
211
- blocked = BLOCKED_ENV_KEYS.include?(key.upcase)
200
+ blocked = EnvKeyPolicy.protected?(key)
212
201
  if blocked || code_loading_env_key?(key)
213
202
  left_out << key unless blocked
214
203
  next
215
204
  end
216
205
  value = entry.is_a?(Hash) ? entry["value"] : entry
206
+ # A value the process environment cannot hold, saved by a version that did not check it.
207
+ next if EnvKeyPolicy.value_rejection(key, value)
208
+
217
209
  if value == EnvOverrideStore::DELETED_SENTINEL
218
210
  base.delete(key)
219
211
  else
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Profiler
4
- VERSION = "0.33.2"
4
+ VERSION = "0.34.0"
5
5
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: rails-profiler
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.33.2
4
+ version: 0.34.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Sébastien Duplessy
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2026-10-14 00:00:00.000000000 Z
11
+ date: 2026-10-15 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: rails
@@ -169,6 +169,7 @@ files:
169
169
  - lib/profiler/console_profiler.rb
170
170
  - lib/profiler/current_context.rb
171
171
  - lib/profiler/engine.rb
172
+ - lib/profiler/env_key_policy.rb
172
173
  - lib/profiler/env_override_store.rb
173
174
  - lib/profiler/explain_runner.rb
174
175
  - lib/profiler/instrumentation/active_job_instrumentation.rb