rails-profiler 0.34.0 → 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 +22 -0
- data/app/controllers/profiler/api/ajax_controller.rb +4 -2
- data/app/controllers/profiler/api/explain_controller.rb +7 -5
- data/app/controllers/profiler/api/slave_proxy_controller.rb +5 -2
- data/lib/profiler/configuration.rb +5 -1
- data/lib/profiler/explain_runner.rb +39 -9
- data/lib/profiler/mcp/server.rb +1 -1
- data/lib/profiler/mcp/tools/explain_query.rb +7 -5
- data/lib/profiler/version.rb +1 -1
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 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,28 @@ 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
|
+
|
|
22
44
|
## [0.34.0] - 2026-10-15
|
|
23
45
|
|
|
24
46
|
<!-- 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
|
|
@@ -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
|
|
@@ -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,
|
|
@@ -108,6 +108,10 @@ module Profiler
|
|
|
108
108
|
# The test runner only runs the files listed by its discovery. true restores the
|
|
109
109
|
# previous behavior: any file under the Rails root.
|
|
110
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
|
|
111
115
|
@track_mailers = true
|
|
112
116
|
@capture_mail_body = false
|
|
113
117
|
@sanitize_mailer_recipients = false
|
|
@@ -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
|
@@ -176,7 +176,7 @@ module Profiler
|
|
|
176
176
|
),
|
|
177
177
|
define_tool(
|
|
178
178
|
name: "explain_query",
|
|
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.
|
|
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.",
|
|
180
180
|
input_schema: {
|
|
181
181
|
properties: {
|
|
182
182
|
token: { type: "string", description: "Profile token, or 'latest' for the most recent profile (required)" },
|
|
@@ -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
|
data/lib/profiler/version.rb
CHANGED