rails-profiler 0.33.3 → 0.34.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +66 -0
- data/app/controllers/profiler/api/ajax_controller.rb +4 -2
- data/app/controllers/profiler/api/env_vars_controller.rb +9 -3
- data/app/controllers/profiler/api/explain_controller.rb +7 -5
- data/app/controllers/profiler/api/slave_proxy_controller.rb +5 -2
- data/exe/profiler-mcp +5 -0
- data/lib/profiler/configuration.rb +10 -2
- data/lib/profiler/env_key_policy.rb +105 -0
- data/lib/profiler/env_override_store.rb +152 -34
- data/lib/profiler/explain_runner.rb +39 -9
- data/lib/profiler/mcp/server.rb +26 -2
- data/lib/profiler/mcp/tools/delete_env_var.rb +5 -2
- data/lib/profiler/mcp/tools/explain_query.rb +7 -5
- data/lib/profiler/mcp/tools/set_env_var.rb +5 -2
- 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: 48da35ed6e2dfe679099e6312f3e763d4be32102f3a814c442c6b971d793617b
|
|
4
|
+
data.tar.gz: 3b417051b4cf1e9c833fd8fd057e813f8a5804ecf1a12ee901b679b9788a36ff
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 067a9332736da6de166e68414f19fe17d36d1e312caa430ace65ed58c6910afc697b17b658613df72c95b8c718a03488e853a2472a194a1e43a85935f2bf4e32
|
|
7
|
+
data.tar.gz: 3ae810aa41f27dfabcfa68e0a94ac2bb7e34e0b860d8097d2d5fea1c0216b3f26d3a23d243faab56a03cebb9a1b7840e234d9ec7c53cb4c83bf2c2cc49dd6029
|
data/CHANGELOG.md
CHANGED
|
@@ -19,6 +19,72 @@ every commit of every tag interval is accounted for one way or the other.
|
|
|
19
19
|
|
|
20
20
|
## [Unreleased]
|
|
21
21
|
|
|
22
|
+
## [0.34.1] - 2026-10-15
|
|
23
|
+
|
|
24
|
+
<!-- stamped -->
|
|
25
|
+
|
|
26
|
+
### Security
|
|
27
|
+
|
|
28
|
+
- **Explain:** The Explain button, `POST /_profiler/api/explain` and the MCP `explain_query` tool
|
|
29
|
+
now only run in the `development` and `test` environments, even where an application sets
|
|
30
|
+
`enabled = true`. Elsewhere, `production` and `staging` included, they are refused before
|
|
31
|
+
anything reaches the database: a `403` from the endpoint, an error from the tool. PostgreSQL's
|
|
32
|
+
`EXPLAIN ANALYZE` runs the query it explains, so a costly `SELECT` used to run for real on the
|
|
33
|
+
production database. Set `config.allow_explain_outside_development = true` to allow it there
|
|
34
|
+
anyway (false by default). The description of `explain_query` given to MCP clients now says so;
|
|
35
|
+
it claimed the tool was limited to development and test, which nothing enforced.
|
|
36
|
+
- **API:** An unexpected error no longer sends its message to the client. `POST
|
|
37
|
+
/_profiler/api/explain` answers `500` with `Explain failed`, `POST /_profiler/api/ajax/link`
|
|
38
|
+
with `Could not link the Ajax request`, the slave proxy (`/_profiler/api/slaves/:name/...`)
|
|
39
|
+
`502` with `Proxy error`, and `explain_query` with `EXPLAIN failed`. The message, which could
|
|
40
|
+
hold the SQL of the query, an internal file path or the address of a slave, goes to the
|
|
41
|
+
application log instead. The profiler's own refusals (a query that is not read-only, an index
|
|
42
|
+
out of range, an unknown or offline slave) still give their reason.
|
|
43
|
+
|
|
44
|
+
## [0.34.0] - 2026-10-15
|
|
45
|
+
|
|
46
|
+
<!-- stamped -->
|
|
47
|
+
|
|
48
|
+
### Added
|
|
49
|
+
|
|
50
|
+
- **MCP:** `config.mcp_read_only = true` leaves out the tools that change something:
|
|
51
|
+
`run_tests`, `clear_profiles`, `set_env_var`, `delete_env_var`, `reset_env_var` and
|
|
52
|
+
`reset_all_env_vars`. They are not listed by `tools/list`, and a client that calls one by name
|
|
53
|
+
gets a "tool not found" error; the tools that only read stay. False by default. The gem's
|
|
54
|
+
`profiler-mcp` executable does not boot the application, so it never reads the option: set
|
|
55
|
+
`PROFILER_MCP_READ_ONLY=1` in the environment the MCP client starts it with. The variable works
|
|
56
|
+
with `rake profiler:mcp` and the HTTP transport too, and never turns read-only off.
|
|
57
|
+
|
|
58
|
+
### Changed
|
|
59
|
+
|
|
60
|
+
- **Env overrides:** An override saved by an earlier version for a name the profiler now refuses,
|
|
61
|
+
such as `DATABASE_URL`, `PRIMARY_DATABASE_URL`, `LD_PRELOAD` or `HTTPS_PROXY`, is no longer
|
|
62
|
+
applied: not at boot, not before a Sidekiq job, not in the test runner. It stays in the file
|
|
63
|
+
until reset, and a warning gives how many were left out, never their names. Set such a variable
|
|
64
|
+
in the shell that starts the application instead.
|
|
65
|
+
|
|
66
|
+
### Fixed
|
|
67
|
+
|
|
68
|
+
- **Env overrides:** The Env tab, `set_env_var` and `delete_env_var` refuse a name that is not
|
|
69
|
+
made of letters, digits and underscores (`A=B`, `MY-VAR`), and the variables that select the
|
|
70
|
+
database, the secrets or the network of the application, or make a process load other code:
|
|
71
|
+
`DATABASE_URL` and every name ending in `_DATABASE_URL`, every name starting with `PG` or
|
|
72
|
+
`MYSQL_`, `RAILS_MASTER_KEY`, every proxy variable in any case (`http_proxy`, `HTTPS_PROXY`),
|
|
73
|
+
`SSL_CERT_FILE`, `SSL_CERT_DIR`, `OPENSSL_CONF`, `GCONV_PATH`, `IRBRC`, `PRYRC`, `ZDOTDIR`,
|
|
74
|
+
`LD_PRELOAD`, `RUBYOPT` and the others listed in the docs. Before, any of these could be set,
|
|
75
|
+
`DATABASE_URL` included. The refusal comes before anything is written: a 422 from the endpoint,
|
|
76
|
+
an error from the tool. The list cannot be complete: a gem may read a variable of its own.
|
|
77
|
+
- **Env overrides:** A value holding a NUL byte is refused the same way. Before, it was written to
|
|
78
|
+
the file, then the tool failed with an internal error, and every process logged the same error
|
|
79
|
+
again before each Sidekiq job.
|
|
80
|
+
- **Env overrides:** A reset now reaches every process that applied the override, each Sidekiq
|
|
81
|
+
process and Puma worker, not only the first one to read the file: each puts back the value it
|
|
82
|
+
had before. A process that never applied the override is left alone.
|
|
83
|
+
- **Env overrides:** The overrides file is no longer read and parsed again before every Sidekiq
|
|
84
|
+
job while it does not change. A change written within two seconds of the previous one is never
|
|
85
|
+
missed, on ext4 too, where the file system reuses the inode and the modification time can stay
|
|
86
|
+
the same.
|
|
87
|
+
|
|
22
88
|
## [0.33.3] - 2026-10-14
|
|
23
89
|
|
|
24
90
|
<!-- stamped -->
|
|
@@ -32,8 +32,10 @@ module Profiler
|
|
|
32
32
|
Profiler.storage.save(child_token, child_profile)
|
|
33
33
|
|
|
34
34
|
render json: { success: true }
|
|
35
|
-
rescue => e
|
|
36
|
-
|
|
35
|
+
rescue StandardError => e
|
|
36
|
+
# The storage's message can name an internal path: the log only.
|
|
37
|
+
Profiler.log_error("Ajax: could not link the request #{child_token.to_s.inspect} to #{parent_token.inspect}", e)
|
|
38
|
+
render json: { error: "Could not link the Ajax request" }, status: :internal_server_error
|
|
37
39
|
end
|
|
38
40
|
end
|
|
39
41
|
end
|
|
@@ -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" },
|
|
@@ -6,8 +6,8 @@ module Profiler
|
|
|
6
6
|
module Api
|
|
7
7
|
class ExplainController < ApplicationController
|
|
8
8
|
def create
|
|
9
|
-
|
|
10
|
-
return render json: { error:
|
|
9
|
+
if (reason = Profiler::ExplainRunner.refusal)
|
|
10
|
+
return render json: { error: reason }, status: :forbidden
|
|
11
11
|
end
|
|
12
12
|
|
|
13
13
|
token = params[:token].to_s
|
|
@@ -19,10 +19,12 @@ module Profiler
|
|
|
19
19
|
|
|
20
20
|
result = Profiler::ExplainRunner.run(token, query_index)
|
|
21
21
|
render json: result
|
|
22
|
-
rescue
|
|
22
|
+
rescue Profiler::ExplainRunner::RefusedError => e
|
|
23
23
|
render json: { error: e.message }, status: :unprocessable_entity
|
|
24
|
-
rescue => e
|
|
25
|
-
|
|
24
|
+
rescue StandardError => e
|
|
25
|
+
# The database's message carries the SQL, and sometimes a path: the log only.
|
|
26
|
+
Profiler.log_error("Explain: failed for query #{query_index} of profile #{token.inspect}", e)
|
|
27
|
+
render json: { error: "Explain failed" }, status: :internal_server_error
|
|
26
28
|
end
|
|
27
29
|
end
|
|
28
30
|
end
|
|
@@ -26,10 +26,13 @@ module Profiler
|
|
|
26
26
|
end
|
|
27
27
|
|
|
28
28
|
render json: result
|
|
29
|
+
# The profiler's own refusals (unknown or offline slave, refused URL, missing secret).
|
|
29
30
|
rescue Profiler::Error => e
|
|
30
31
|
render json: { error: e.message }, status: :bad_gateway
|
|
31
|
-
rescue => e
|
|
32
|
-
|
|
32
|
+
rescue StandardError => e
|
|
33
|
+
# A network error names the slave's address, and sometimes a path: the log only.
|
|
34
|
+
Profiler.log_error("SlaveProxy: could not forward #{request.method} to slave profiler #{params[:slave_name].to_s.inspect}", e)
|
|
35
|
+
render json: { error: "Proxy error" }, status: :bad_gateway
|
|
33
36
|
end
|
|
34
37
|
|
|
35
38
|
private
|
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,
|
|
@@ -48,7 +48,7 @@ module Profiler
|
|
|
48
48
|
:track_jobs,
|
|
49
49
|
:track_console,
|
|
50
50
|
:apply_env_overrides_when_disabled, :restrict_storage_permissions,
|
|
51
|
-
:test_runner_allow_undiscovered_files,
|
|
51
|
+
:test_runner_allow_undiscovered_files, :allow_explain_outside_development,
|
|
52
52
|
:track_mailers, :capture_mail_body, :sanitize_mailer_recipients, :mailer_skip_actions,
|
|
53
53
|
:compress_bodies, :compress_body_threshold, :max_captured_body_bytes, :max_captured_log_bytes,
|
|
54
54
|
:redact_sensitive_data, :filter_parameters, :env_allowlist,
|
|
@@ -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
|
|
@@ -104,6 +108,10 @@ module Profiler
|
|
|
104
108
|
# The test runner only runs the files listed by its discovery. true restores the
|
|
105
109
|
# previous behavior: any file under the Rails root.
|
|
106
110
|
@test_runner_allow_undiscovered_files = false
|
|
111
|
+
# Explain (the Database tab button, POST /_profiler/api/explain, the explain_query MCP tool)
|
|
112
|
+
# only runs in the development and test environments, even with enabled = true elsewhere:
|
|
113
|
+
# PostgreSQL's EXPLAIN ANALYZE runs the query. true allows it in every environment.
|
|
114
|
+
@allow_explain_outside_development = false
|
|
107
115
|
@track_mailers = true
|
|
108
116
|
@capture_mail_body = false
|
|
109
117
|
@sanitize_mailer_recipients = false
|
|
@@ -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
|
|
@@ -11,9 +11,39 @@ module Profiler
|
|
|
11
11
|
# what no reading of the statement can rule out (a function with a side effect),
|
|
12
12
|
# on a connection of its own that is closed afterwards, in a transaction that is
|
|
13
13
|
# always rolled back, read-only and time-limited on PostgreSQL.
|
|
14
|
+
#
|
|
15
|
+
# It only runs in the development and test environments, whatever config.enabled says,
|
|
16
|
+
# unless config.allow_explain_outside_development is set: PostgreSQL's EXPLAIN ANALYZE runs
|
|
17
|
+
# the query, and a costly SELECT would run for real on the production database.
|
|
14
18
|
module ExplainRunner
|
|
15
|
-
#
|
|
16
|
-
|
|
19
|
+
# A request the runner refuses, with a message written for the caller: an ArgumentError,
|
|
20
|
+
# so callers answer it as a bad request (422, MCP error). Any other error is internal, and
|
|
21
|
+
# its message (SQL, paths) stays in the server log.
|
|
22
|
+
class RefusedError < ArgumentError; end
|
|
23
|
+
|
|
24
|
+
class UnsafeStatementError < RefusedError; end
|
|
25
|
+
|
|
26
|
+
# The environments EXPLAIN runs in without config.allow_explain_outside_development.
|
|
27
|
+
ENVIRONMENTS = %w[development test].freeze
|
|
28
|
+
|
|
29
|
+
NOT_ENABLED = "EXPLAIN is only available when the profiler is enabled"
|
|
30
|
+
OUTSIDE_DEVELOPMENT = "EXPLAIN is only available in the development and test environments " \
|
|
31
|
+
"(config.allow_explain_outside_development allows it in the others)"
|
|
32
|
+
|
|
33
|
+
# Why EXPLAIN may not run in this process, or nil when it may. Without Rails, the
|
|
34
|
+
# environment is unknown and EXPLAIN is refused.
|
|
35
|
+
def self.refusal
|
|
36
|
+
config = Profiler.configuration
|
|
37
|
+
return NOT_ENABLED unless config.enabled
|
|
38
|
+
return nil if config.allow_explain_outside_development == true
|
|
39
|
+
return nil if ENVIRONMENTS.include?(current_environment)
|
|
40
|
+
|
|
41
|
+
OUTSIDE_DEVELOPMENT
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def self.current_environment
|
|
45
|
+
Rails.env.to_s if defined?(Rails) && Rails.respond_to?(:env) && Rails.env
|
|
46
|
+
end
|
|
17
47
|
|
|
18
48
|
# PostgreSQL's EXPLAIN ANALYZE runs the query: a slow one is stopped after this.
|
|
19
49
|
PROBE_STATEMENT_TIMEOUT = "30s"
|
|
@@ -21,29 +51,29 @@ module Profiler
|
|
|
21
51
|
# @param profile_token [String]
|
|
22
52
|
# @param query_index [Integer]
|
|
23
53
|
# @return [Hash] { result:, format: "json"|"text", adapter: String }
|
|
24
|
-
# or raises
|
|
54
|
+
# or raises SecurityError (see .refusal), RefusedError, or any error of the database
|
|
25
55
|
def self.run(profile_token, query_index)
|
|
26
|
-
|
|
27
|
-
raise SecurityError,
|
|
56
|
+
if (reason = refusal)
|
|
57
|
+
raise SecurityError, reason
|
|
28
58
|
end
|
|
29
59
|
|
|
30
60
|
profile = Profiler.storage.load(profile_token)
|
|
31
|
-
raise
|
|
61
|
+
raise RefusedError, "Profile not found: #{profile_token}" unless profile
|
|
32
62
|
|
|
33
63
|
db_data = profile.collector_data("database")
|
|
34
|
-
raise
|
|
64
|
+
raise RefusedError, "No database data in this profile" unless db_data && db_data["queries"]
|
|
35
65
|
|
|
36
66
|
queries = db_data["queries"]
|
|
37
67
|
query_index = query_index.to_i
|
|
38
68
|
unless query_index >= 0 && query_index < queries.size
|
|
39
|
-
raise
|
|
69
|
+
raise RefusedError, "Query index #{query_index} out of range (0..#{queries.size - 1})"
|
|
40
70
|
end
|
|
41
71
|
|
|
42
72
|
query = queries[query_index]
|
|
43
73
|
sql = query["sql"].to_s
|
|
44
74
|
binds = Array(query["binds"])
|
|
45
75
|
if binds.include?(Redaction::MASK)
|
|
46
|
-
raise
|
|
76
|
+
raise RefusedError, "EXPLAIN refused for query #{query_index}: one of its bind values was " \
|
|
47
77
|
"filtered when captured (config.filter_parameters), so the query cannot be rebuilt"
|
|
48
78
|
end
|
|
49
79
|
|
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|
|
|
@@ -155,7 +176,7 @@ module Profiler
|
|
|
155
176
|
),
|
|
156
177
|
define_tool(
|
|
157
178
|
name: "explain_query",
|
|
158
|
-
description: "Run EXPLAIN ANALYZE on a specific query from a profile. Returns the query execution plan with cost and row estimates. Only read-only statements (SELECT, WITH ... SELECT, TABLE, VALUES) are explained, in a transaction always rolled back; other queries return an error.
|
|
179
|
+
description: "Run EXPLAIN ANALYZE on a specific query from a profile. Returns the query execution plan with cost and row estimates. Only read-only statements (SELECT, WITH ... SELECT, TABLE, VALUES) are explained, in a transaction always rolled back; other queries return an error. Refused with an error unless the application that runs it (this one, or the slave given) has the profiler enabled and runs in the development or test environment; config.allow_explain_outside_development = true lifts the environment check.",
|
|
159
180
|
input_schema: {
|
|
160
181
|
properties: {
|
|
161
182
|
token: { type: "string", description: "Profile token, or 'latest' for the most recent profile (required)" },
|
|
@@ -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
|
|
@@ -26,8 +26,8 @@ module Profiler
|
|
|
26
26
|
return [{ type: "text", text: text }]
|
|
27
27
|
end
|
|
28
28
|
|
|
29
|
-
|
|
30
|
-
return [{ type: "text", text: "Error:
|
|
29
|
+
if (reason = Profiler::ExplainRunner.refusal)
|
|
30
|
+
return [{ type: "text", text: "Error: #{reason}" }]
|
|
31
31
|
end
|
|
32
32
|
|
|
33
33
|
data = Profiler::ExplainRunner.run(token, query_index.to_i)
|
|
@@ -47,10 +47,12 @@ module Profiler
|
|
|
47
47
|
end
|
|
48
48
|
|
|
49
49
|
[{ type: "text", text: lines.join("\n") }]
|
|
50
|
-
rescue
|
|
50
|
+
rescue Profiler::ExplainRunner::RefusedError => e
|
|
51
51
|
[{ type: "text", text: "Error: #{e.message}" }]
|
|
52
|
-
rescue => e
|
|
53
|
-
|
|
52
|
+
rescue StandardError => e
|
|
53
|
+
# The database's message carries the SQL, and sometimes a path: the log only.
|
|
54
|
+
Profiler.log_error("MCP explain_query: failed for query #{query_index.to_i} of profile #{token.to_s.inspect}", e)
|
|
55
|
+
[{ type: "text", text: "EXPLAIN failed (the application log has the details)" }]
|
|
54
56
|
end
|
|
55
57
|
end
|
|
56
58
|
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
|
|
@@ -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.1
|
|
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
|