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 +4 -4
- data/CHANGELOG.md +73 -0
- data/app/controllers/profiler/api/env_vars_controller.rb +9 -3
- data/exe/profiler-mcp +5 -0
- data/lib/profiler/configuration.rb +5 -1
- data/lib/profiler/env_key_policy.rb +105 -0
- data/lib/profiler/env_override_store.rb +152 -34
- data/lib/profiler/mcp/server.rb +25 -1
- data/lib/profiler/mcp/tools/delete_env_var.rb +5 -2
- data/lib/profiler/mcp/tools/set_env_var.rb +5 -2
- data/lib/profiler/storage/blob_store.rb +33 -0
- data/lib/profiler/storage/redis_store.rb +54 -10
- data/lib/profiler/storage/sqlite_store.rb +63 -9
- data/lib/profiler/tasks/profiler.rake +20 -4
- data/lib/profiler/test_runner/runner.rb +11 -19
- data/lib/profiler/version.rb +1 -1
- metadata +3 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: b6a0140a91e984bd6d06d7277a149c2a7eeace21ec53c3d3ddc1acb7eb137879
|
|
4
|
+
data.tar.gz: 730abdd8332a638e3d78db3d74378ab10fc1280f9d5f83f11b327409b076c1e3
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
-
|
|
28
|
-
|
|
29
|
-
|
|
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 →
|
|
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] =
|
|
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] =
|
|
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 =
|
|
120
|
-
RESERVED_KEY_ERROR =
|
|
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
|
|
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
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
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
|
-
|
|
152
|
-
|
|
153
|
-
|
|
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
|
-
|
|
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
|
data/lib/profiler/mcp/server.rb
CHANGED
|
@@ -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
|
-
|
|
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: #{
|
|
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
|
-
|
|
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: #{
|
|
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
|
-
|
|
75
|
-
|
|
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
|
-
|
|
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,
|
|
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,
|
|
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 =
|
|
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 =
|
|
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
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
data/lib/profiler/version.rb
CHANGED
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.
|
|
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-
|
|
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
|