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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: b6a0140a91e984bd6d06d7277a149c2a7eeace21ec53c3d3ddc1acb7eb137879
4
- data.tar.gz: 730abdd8332a638e3d78db3d74378ab10fc1280f9d5f83f11b327409b076c1e3
3
+ metadata.gz: 48da35ed6e2dfe679099e6312f3e763d4be32102f3a814c442c6b971d793617b
4
+ data.tar.gz: 3b417051b4cf1e9c833fd8fd057e813f8a5804ecf1a12ee901b679b9788a36ff
5
5
  SHA512:
6
- metadata.gz: '09a3cac5a02d3407f8a03dad19154d8f36c5dd57e8c1f5d9b0021aad015e7e459a4305592e68defb796672a9cbd29b40c48b2933cff56b2fe340c3ae370de767'
7
- data.tar.gz: dda83a3c7086e4679766abe06d0dc5e88befc5e9ebebb19a87ec605a3b268a2ab81fccef0274e8170043bb4c6ab452f68307e5a1546921b55b418289427fc805
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
- render json: { error: e.message }, status: :internal_server_error
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
- unless Profiler.configuration.enabled
10
- return render json: { error: "EXPLAIN is only available when the profiler is enabled" }, status: :forbidden
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 ArgumentError => e
22
+ rescue Profiler::ExplainRunner::RefusedError => e
23
23
  render json: { error: e.message }, status: :unprocessable_entity
24
- rescue => e
25
- render json: { error: e.message }, status: :internal_server_error
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
- render json: { error: "Proxy error: #{e.message}" }, status: :bad_gateway
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
- # An ArgumentError, so callers answer it as a bad request (422, MCP error).
16
- class UnsafeStatementError < ArgumentError; end
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 ArgumentError / RuntimeError
54
+ # or raises SecurityError (see .refusal), RefusedError, or any error of the database
25
55
  def self.run(profile_token, query_index)
26
- unless Profiler.configuration.enabled
27
- raise SecurityError, "EXPLAIN is only available when the profiler is enabled"
56
+ if (reason = refusal)
57
+ raise SecurityError, reason
28
58
  end
29
59
 
30
60
  profile = Profiler.storage.load(profile_token)
31
- raise ArgumentError, "Profile not found: #{profile_token}" unless profile
61
+ raise RefusedError, "Profile not found: #{profile_token}" unless profile
32
62
 
33
63
  db_data = profile.collector_data("database")
34
- raise ArgumentError, "No database data in this profile" unless db_data && db_data["queries"]
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 ArgumentError, "Query index #{query_index} out of range (0..#{queries.size - 1})"
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 ArgumentError, "EXPLAIN refused for query #{query_index}: one of its bind values was " \
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
 
@@ -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. Only available in development/test environments.",
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
- unless Profiler.configuration.enabled
30
- return [{ type: "text", text: "Error: EXPLAIN is only available when the profiler is enabled" }]
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 ArgumentError => e
50
+ rescue Profiler::ExplainRunner::RefusedError => e
51
51
  [{ type: "text", text: "Error: #{e.message}" }]
52
- rescue => e
53
- [{ type: "text", text: "EXPLAIN failed: #{e.message}" }]
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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Profiler
4
- VERSION = "0.34.0"
4
+ VERSION = "0.34.1"
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: rails-profiler
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.34.0
4
+ version: 0.34.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Sébastien Duplessy