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 +4 -4
- data/CHANGELOG.md +44 -0
- data/app/controllers/profiler/api/env_vars_controller.rb +2 -1
- data/lib/profiler/configuration.rb +2 -0
- data/lib/profiler/env_override_store.rb +131 -32
- data/lib/profiler/mcp/tools/reset_env_var.rb +3 -5
- data/lib/profiler/railtie.rb +7 -4
- data/lib/profiler/version.rb +1 -1
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 7edf1c383b05c79e4013d76c96fa57077b376f3a312c27aa355927ca1bb7e362
|
|
4
|
+
data.tar.gz: b1b9b6bd25f7f739c14d2afe72309fac7455e8f2c62b5e7d81e206a09b8ba4fa
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
-
|
|
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
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
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
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
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
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
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
|
-
|
|
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
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
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
|
-
|
|
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
|
-
|
|
88
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
28
|
-
Profiler.env_override_store.reset(key)
|
|
26
|
+
restored = Profiler.env_override_store.reset(key)
|
|
29
27
|
|
|
30
|
-
|
|
31
|
-
[{ type: "text", text: "Reset #{key}
|
|
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
|
data/lib/profiler/railtie.rb
CHANGED
|
@@ -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
|
|
data/lib/profiler/version.rb
CHANGED