rails-profiler 0.30.7 → 0.30.8

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: f606ebbea7d5aecac4c62d789f07ddab9b4942269d4f8246441b3f37bc3220e7
4
- data.tar.gz: 53f89719e215f799ba93d436fbace90ee45b38b0ec8d7f453cd2d2410e50cc0e
3
+ metadata.gz: 7edf1c383b05c79e4013d76c96fa57077b376f3a312c27aa355927ca1bb7e362
4
+ data.tar.gz: b1b9b6bd25f7f739c14d2afe72309fac7455e8f2c62b5e7d81e206a09b8ba4fa
5
5
  SHA512:
6
- metadata.gz: 3fe0149c4be61248f1fbfdfa381083a01710248c81a8e02dfec015bd721e7ff0238c2296dbcb4df141701603f8f1a3b03cabd4ef839c3031006215680e34bed9
7
- data.tar.gz: 1aca6f23e91b25b303db9d68f970f4878a222bb02cb5d1e5b89533f07c99306d38bec2f004b8471d8790bb6990b8a59a2a68a86cc00e50becb23b85020f8bdae
6
+ metadata.gz: ca0ca79568606603601b5b33aa3ee69fb54d6497e8c8e2087f45cb8d78092ad0264ed8a8794472955e6b5608f743d198de31fcdf63493e40b7f538fd4c4d8298
7
+ data.tar.gz: fd3f7cb5326209b731f11abbca7e7e0b3b226ce019ea62f32636a137d1d402c26f692dc11754e27d3144bae0814524ea7be282031d8adacfe3bda5a97d3d4cc9
data/CHANGELOG.md CHANGED
@@ -19,6 +19,50 @@ every commit of every tag interval is accounted for one way or the other.
19
19
 
20
20
  ## [Unreleased]
21
21
 
22
+ ## [0.30.8] - 2026-10-04
23
+
24
+ <!-- stamped -->
25
+
26
+ ### Fixed
27
+
28
+ - **Env overrides:** Keep every override when several are set, deleted or reset at the same
29
+ time, from threads of the web process or from Sidekiq processes. Each writer read the file,
30
+ changed it and saved it over the others' changes, and a reader could see a half-written file as
31
+ no override at all. The file is now changed under a lock (`env_overrides.json.lock`, next to
32
+ it) and replaced in one step.
33
+
34
+ ### Security
35
+
36
+ - **Env overrides:** Stop applying the environment variable overrides saved from the Env tab or
37
+ the MCP env tools in production, and while the profiler is disabled. They were written into
38
+ `ENV` at every boot whatever `enabled` said and whatever the environment, so an
39
+ `env_overrides.json` left under `tmp/` and shipped with a deployment changed the environment of
40
+ the production application, and an application with `config.enabled = false` still had them
41
+ applied without a word. Sidekiq jobs and console evaluations, which apply them again, follow the
42
+ same rules. When overrides are left out, the boot logs one warning to `Rails.logger` with the
43
+ file, the number of overrides and the reason, never a name or a value.
44
+ - **Env overrides:** Apply them at boot once the application's `config/initializers` have run,
45
+ where `enabled` is decided, instead of before. An initializer of the application that reads
46
+ `ENV` while it runs no longer sees them; requests, jobs, eager loading and `after_initialize`
47
+ still do.
48
+ - **Env overrides:** Resetting one override, or all of them, from the Env tab or the MCP env
49
+ tools no longer writes into `ENV` the "original" values saved in `env_overrides.json` in
50
+ production, nor while the profiler is disabled: those values come from the machine that wrote
51
+ the file, so a reset could put a development value into a production variable, or delete it.
52
+ There, a reset restores only the variables the running process changed itself, to the values
53
+ they had before.
54
+ - **Env overrides:** In production, or with the profiler disabled, typing back in the Env tab the
55
+ original value the overrides file shows for a variable now records the running value first, so
56
+ a later reset puts it back instead of keeping the file's value. A reset also restores a variable
57
+ this process changed even when its entry has gone from the file. The `reset_env_var` MCP tool
58
+ now says whether the original value was restored in the process or `ENV` was left unchanged,
59
+ and no longer prints a value, masked or not.
60
+ - **Upgrading:** to apply the env overrides while the profiler is disabled outside production,
61
+ as before, set `config.apply_env_overrides_when_disabled = true`. Nothing applies them in
62
+ production. If an initializer of yours needs them, call `Profiler.env_override_store.apply!` at
63
+ the end of `config/initializers/profiler.rb`; initializers loaded after it then see them, under
64
+ the same rules.
65
+
22
66
  ## [0.30.7] - 2026-10-04
23
67
 
24
68
  <!-- stamped -->
@@ -32,7 +32,8 @@ module Profiler
32
32
  render json: { key: key, value: nil, deleted: true, override: redacted_override(key) }
33
33
  else
34
34
  current_original = Profiler.env_override_store.all_overrides.dig(key, "original")
35
- if current_original == value.to_s
35
+ # Where the overrides are blocked, set records this process's value, which reset would not.
36
+ if current_original == value.to_s && Profiler.env_override_store.blocked_reason.nil?
36
37
  Profiler.env_override_store.reset(key)
37
38
  else
38
39
  Profiler.env_override_store.set(key, value.to_s)
@@ -35,6 +35,7 @@ module Profiler
35
35
  :track_http, :slow_http_threshold, :http_skip_hosts, :http_backtrace_depth,
36
36
  :track_jobs,
37
37
  :track_console,
38
+ :apply_env_overrides_when_disabled,
38
39
  :track_tests,
39
40
  :track_mailers, :capture_mail_body, :sanitize_mailer_recipients, :mailer_skip_actions,
40
41
  :compress_bodies, :compress_body_threshold,
@@ -77,6 +78,7 @@ module Profiler
77
78
  @http_backtrace_depth = 40
78
79
  @track_jobs = true
79
80
  @track_console = true
81
+ @apply_env_overrides_when_disabled = false
80
82
  @track_tests = false
81
83
  @track_mailers = true
82
84
  @capture_mail_body = false
@@ -8,63 +8,108 @@ module Profiler
8
8
  DELETED_SENTINEL = "__profiler_deleted__"
9
9
  RESTORE_SENTINEL = "__profiler_restore__"
10
10
 
11
+ # Why the persisted overrides are left out of ENV, as logged at boot.
12
+ BLOCKED_REASONS = {
13
+ production: "persisted overrides are never applied in production; " \
14
+ "delete the file if it was deployed by mistake",
15
+ disabled: "the profiler is disabled; set " \
16
+ "config.apply_env_overrides_when_disabled = true to apply them anyway"
17
+ }.freeze
18
+
11
19
  # File format: { "KEY" => { "value" => "...", "original" => "..." } }
12
20
  # Sentinels for "value":
13
21
  # DELETED_SENTINEL → ENV.delete(key)
14
22
  # RESTORE_SENTINEL → restore ENV[key] to "original" (cleaned up after apply!)
15
23
 
24
+ def initialize
25
+ # What ENV held in this process before set, delete or apply! changed a key. Where the
26
+ # overrides are blocked, a reset restores these and never the file's "original" values,
27
+ # which come from whatever machine wrote the file.
28
+ @process_originals = {}
29
+ @process_originals_lock = Mutex.new
30
+ end
31
+
16
32
  def set(key, value)
17
- overrides = load_overrides
18
- original = original_for(overrides, key)
19
- overrides[key] = { "value" => value.to_s, "original" => original }
20
- save_overrides(overrides)
33
+ remember_process_original(key)
34
+ update_overrides do |overrides|
35
+ overrides[key] = { "value" => value.to_s, "original" => original_for(overrides, key) }
36
+ end
21
37
  rescue => e
22
38
  warn "[Profiler] EnvOverrideStore: failed to set #{key}: #{e.message}"
23
39
  end
24
40
 
25
41
  def delete(key)
26
- overrides = load_overrides
27
- original = original_for(overrides, key)
28
- overrides[key] = { "value" => DELETED_SENTINEL, "original" => original }
29
- save_overrides(overrides)
42
+ remember_process_original(key)
43
+ update_overrides do |overrides|
44
+ overrides[key] = { "value" => DELETED_SENTINEL, "original" => original_for(overrides, key) }
45
+ end
30
46
  rescue => e
31
47
  warn "[Profiler] EnvOverrideStore: failed to delete #{key}: #{e.message}"
32
48
  end
33
49
 
50
+ # Returns whether ENV was written in this process.
34
51
  def reset(key)
35
- overrides = load_overrides
36
- entry = overrides[key]
37
- return unless entry
38
-
39
- original = entry["original"]
40
- # Keep a RESTORE entry so Sidekiq workers pick it up on next job
41
- overrides[key] = { "value" => RESTORE_SENTINEL, "original" => original }
42
- save_overrides(overrides)
52
+ 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
57
+ end
43
58
 
44
59
  # Apply immediately to the current (web) process
45
- original.nil? ? ENV.delete(key) : ENV[key] = original
60
+ restore_env(key, entry, blocked: blocked_reason)
46
61
  rescue => e
47
62
  warn "[Profiler] EnvOverrideStore: failed to reset #{key}: #{e.message}"
48
63
  end
49
64
 
50
65
  def reset_all
51
- overrides = load_overrides
52
- restore_overrides = {}
53
-
54
- overrides.each do |key, entry|
55
- original = entry.is_a?(Hash) ? entry["original"] : nil
56
- # Apply immediately to the current (web) process
57
- original.nil? ? ENV.delete(key) : ENV[key] = original
58
- # Leave a RESTORE sentinel for Sidekiq workers to pick up
59
- restore_overrides[key] = { "value" => RESTORE_SENTINEL, "original" => original }
66
+ 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] }
72
+ end
60
73
  end
61
74
 
62
- restore_overrides.empty? ? clear : save_overrides(restore_overrides)
75
+ # Apply immediately to the current (web) process
76
+ blocked = blocked_reason
77
+ originals.each { |key, original| restore_env(key, { "original" => original }, blocked: blocked) }
63
78
  rescue => e
64
79
  warn "[Profiler] EnvOverrideStore: failed to reset all: #{e.message}"
65
80
  end
66
81
 
82
+ # Why the persisted overrides must not reach ENV in this process, or nil when they may.
83
+ def blocked_reason
84
+ return :production if defined?(Rails) && Rails.respond_to?(:env) && Rails.env.production?
85
+
86
+ config = Profiler.configuration
87
+ return :disabled unless config.enabled || config.apply_env_overrides_when_disabled
88
+
89
+ nil
90
+ end
91
+
92
+ # Applies the overrides at boot, or says once in the log why they are left out. The message
93
+ # carries the file and the number of keys only, never a key or a value.
94
+ def apply_at_boot!(logger)
95
+ reason = blocked_reason
96
+ return apply! unless reason
97
+ return if @boot_warning_logged
98
+
99
+ count = all_overrides.size
100
+ return if count.zero?
101
+
102
+ @boot_warning_logged = true
103
+ noun, verb = count == 1 ? %w[override was] : %w[overrides were]
104
+ logger&.warn("[Profiler] #{count} persisted environment #{noun} in #{override_file_path} " \
105
+ "#{verb} not applied: #{BLOCKED_REASONS.fetch(reason)}.")
106
+ rescue => e
107
+ warn "[Profiler] EnvOverrideStore: failed to check overrides at boot: #{e.message}"
108
+ end
109
+
67
110
  def apply!
111
+ return if blocked_reason
112
+
68
113
  overrides = load_overrides
69
114
  restore_keys = []
70
115
 
@@ -72,6 +117,8 @@ module Profiler
72
117
  value = entry.is_a?(Hash) ? entry["value"] : entry
73
118
  original = entry.is_a?(Hash) ? entry["original"] : nil
74
119
 
120
+ remember_process_original(key) unless value == RESTORE_SENTINEL
121
+
75
122
  case value
76
123
  when DELETED_SENTINEL
77
124
  ENV.delete(key)
@@ -84,8 +131,13 @@ module Profiler
84
131
  end
85
132
 
86
133
  if restore_keys.any?
87
- restore_keys.each { |k| overrides.delete(k) }
88
- save_overrides(overrides)
134
+ # Read again under the lock: another writer may have changed the file since.
135
+ update_overrides do |current|
136
+ restore_keys.each do |k|
137
+ entry = current[k]
138
+ current.delete(k) if entry.is_a?(Hash) && entry["value"] == RESTORE_SENTINEL
139
+ end
140
+ end
89
141
  end
90
142
  rescue => e
91
143
  warn "[Profiler] EnvOverrideStore: failed to apply overrides: #{e.message}"
@@ -105,13 +157,34 @@ module Profiler
105
157
  end
106
158
 
107
159
  def clear
108
- FileUtils.rm_f(override_file_path)
160
+ with_lock { FileUtils.rm_f(override_file_path) }
109
161
  rescue => e
110
162
  warn "[Profiler] EnvOverrideStore: failed to clear: #{e.message}"
111
163
  end
112
164
 
113
165
  private
114
166
 
167
+ def remember_process_original(key)
168
+ @process_originals_lock.synchronize do
169
+ @process_originals[key] = ENV[key] unless @process_originals.key?(key)
170
+ end
171
+ end
172
+
173
+ # Puts a reset key back to its original value and returns whether ENV was written. Where the
174
+ # overrides are blocked, or the file has no entry for the key, only a key this process
175
+ # changed itself is touched, back to the value it had here; ENV is left alone for any other.
176
+ def restore_env(key, file_entry, blocked:)
177
+ changed_here, process_original = @process_originals_lock.synchronize do
178
+ [@process_originals.key?(key), @process_originals.delete(key)]
179
+ end
180
+ from_process = blocked || file_entry.nil?
181
+ return false if from_process && !changed_here
182
+
183
+ original = from_process ? process_original : file_entry["original"]
184
+ original.nil? ? ENV.delete(key) : ENV[key] = original
185
+ true
186
+ end
187
+
115
188
  def original_for(overrides, key)
116
189
  overrides.key?(key) ? overrides[key]["original"] : ENV[key]
117
190
  end
@@ -129,10 +202,36 @@ module Profiler
129
202
  {}
130
203
  end
131
204
 
132
- def save_overrides(overrides)
205
+ # Reads, changes and writes the file under the lock, so that the web process, its threads and
206
+ # the Sidekiq processes never save over each other's changes. The file is removed when the
207
+ # block leaves it empty.
208
+ def update_overrides
209
+ with_lock do
210
+ overrides = load_overrides
211
+ yield overrides
212
+ overrides.empty? ? FileUtils.rm_f(override_file_path) : save_overrides(overrides)
213
+ end
214
+ end
215
+
216
+ # An exclusive flock on a file next to the overrides file. Not reentrant: never nest it.
217
+ def with_lock
133
218
  path = override_file_path
134
219
  FileUtils.mkdir_p(path.dirname)
135
- File.write(path, JSON.generate(overrides))
220
+ File.open("#{path}.lock", File::RDWR | File::CREAT, 0o644) do |lock|
221
+ lock.flock(File::LOCK_EX)
222
+ yield
223
+ end
224
+ end
225
+
226
+ # Written to a temporary file then renamed over the old one, so that a reader never sees a
227
+ # half-written file, which would parse as no override at all.
228
+ def save_overrides(overrides)
229
+ path = override_file_path
230
+ tmp = "#{path}.#{Process.pid}.#{Thread.current.object_id}.tmp"
231
+ File.write(tmp, JSON.generate(overrides))
232
+ File.rename(tmp, path)
233
+ ensure
234
+ FileUtils.rm_f(tmp) if tmp
136
235
  end
137
236
  end
138
237
  end
@@ -1,7 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "../slave_support"
4
- require_relative "../../redaction"
5
4
 
6
5
  require "uri"
7
6
 
@@ -24,11 +23,10 @@ module Profiler
24
23
  return [{ type: "text", text: "No active override for #{key}." }]
25
24
  end
26
25
 
27
- original = overrides[key]["original"]
28
- Profiler.env_override_store.reset(key)
26
+ restored = Profiler.env_override_store.reset(key)
29
27
 
30
- restored = original.nil? ? "(unset)" : Profiler::Redaction.env_value(key, original)
31
- [{ type: "text", text: "Reset #{key} to original value: #{restored}" }]
28
+ done = restored ? "original value restored" : "ENV left unchanged"
29
+ [{ type: "text", text: "Reset #{key}: override removed, #{done} in this process." }]
32
30
  end
33
31
  end
34
32
  end
@@ -6,10 +6,6 @@ module Profiler
6
6
  class Railtie < Rails::Railtie
7
7
  config.profiler = ActiveSupport::OrderedOptions.new
8
8
 
9
- initializer "profiler.apply_env_overrides" do
10
- Profiler.env_override_store.apply!
11
- end
12
-
13
9
  initializer "profiler.set_configs" do |app|
14
10
  # Set default configuration for Rails environment
15
11
  Profiler.configure do |config|
@@ -103,6 +99,13 @@ module Profiler
103
99
  end
104
100
  end
105
101
 
102
+ # After the application's config/initializers, so that `enabled` is the application's own.
103
+ # Declared last: an initializer without `after:` runs after the one declared before it, so
104
+ # one declared below this one would move past the application's initializers too.
105
+ initializer "profiler.apply_env_overrides", after: :load_config_initializers do
106
+ Profiler.env_override_store.apply_at_boot!(Rails.logger)
107
+ end
108
+
106
109
  console do
107
110
  next unless Profiler.configuration.enabled && Profiler.configuration.track_console
108
111
 
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Profiler
4
- VERSION = "0.30.7"
4
+ VERSION = "0.30.8"
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: rails-profiler
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.30.7
4
+ version: 0.30.8
5
5
  platform: ruby
6
6
  authors:
7
7
  - Sébastien Duplessy