rails-profiler 0.30.8 → 0.30.9
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 +28 -0
- data/lib/profiler/explain_runner.rb +60 -13
- data/lib/profiler/mcp/server.rb +1 -1
- data/lib/profiler/sql_statement.rb +138 -0
- data/lib/profiler/version.rb +1 -1
- metadata +2 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: a3a3ebc22965fd603d63bafecef777474fc488912625ac2950e618566bce8f09
|
|
4
|
+
data.tar.gz: e82aa1ce06e0f24cfb65b86fe01a942557851735f596edb3172c3fd00d7941f3
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 857313cb42832f5992a26897cbe6cef4d7dd66d6f59b5fccc63c8758db55da54cd6e3587e254e3d1410db561b668782ef8f3fcda73e4535235efba1e9a34c9be
|
|
7
|
+
data.tar.gz: 1bd504a236e7c548011ceed8dcc1488b21bc89400d824306c2f90d8b1817290768a10264265ee093f78d295f873b72a250a00a96fc5fe04f54f85cd2d63597db
|
data/CHANGELOG.md
CHANGED
|
@@ -19,6 +19,34 @@ every commit of every tag interval is accounted for one way or the other.
|
|
|
19
19
|
|
|
20
20
|
## [Unreleased]
|
|
21
21
|
|
|
22
|
+
## [0.30.9] - 2026-10-04
|
|
23
|
+
|
|
24
|
+
<!-- stamped -->
|
|
25
|
+
|
|
26
|
+
### Security
|
|
27
|
+
|
|
28
|
+
- **Database:** Explain only read-only queries. The **Explain** button, `POST /_profiler/api/explain`
|
|
29
|
+
and the MCP `explain_query` tool ran `EXPLAIN ANALYZE` on any stored query, and PostgreSQL's
|
|
30
|
+
`EXPLAIN ANALYZE` runs the statement it explains: explaining a `DELETE`, `UPDATE` or `INSERT` the
|
|
31
|
+
application had just run ran it a second time, for real. Only statements starting with `SELECT`,
|
|
32
|
+
`WITH`, `TABLE` or `VALUES` are explained now, and a writing CTE, `SELECT ... INTO`, `FOR UPDATE`
|
|
33
|
+
or a second statement is refused too, with a 422 from the endpoint and an error from the MCP tool.
|
|
34
|
+
The probe also runs in a transaction that is always rolled back, read-only on PostgreSQL.
|
|
35
|
+
Upgrading: nothing to set, and no setting brings back the explaining of writes, since the old
|
|
36
|
+
behaviour was to run them again; to see the plan of a write, run it yourself in `psql` between
|
|
37
|
+
`BEGIN` and `ROLLBACK`, as the README shows.
|
|
38
|
+
- **Database:** Run the Explain probe on a connection of its own, opened outside the pool and closed
|
|
39
|
+
afterwards, so that nothing it does to its session outlives it: a `SELECT pg_try_advisory_lock(...)`
|
|
40
|
+
explained from a profile left the lock held by a connection of the pool. On PostgreSQL the probe
|
|
41
|
+
is also limited to 30 seconds (`statement_timeout`), and its EXPLAIN goes by the extended
|
|
42
|
+
protocol, which refuses a second statement.
|
|
43
|
+
|
|
44
|
+
### Fixed
|
|
45
|
+
|
|
46
|
+
- **Database:** Put bind values back correctly when explaining a query with ten binds or more
|
|
47
|
+
(`$10` was read as `$1` followed by `0`), and leave alone a `?` or `$1` inside a string literal or
|
|
48
|
+
inside a value already put back.
|
|
49
|
+
|
|
22
50
|
## [0.30.8] - 2026-10-04
|
|
23
51
|
|
|
24
52
|
<!-- stamped -->
|
|
@@ -1,9 +1,23 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "profiler/sql_statement"
|
|
4
|
+
|
|
3
5
|
module Profiler
|
|
4
6
|
# Shared service for running EXPLAIN ANALYZE on a stored query.
|
|
5
7
|
# Used by both the HTTP API controller and the MCP explain_query tool.
|
|
8
|
+
#
|
|
9
|
+
# Only read-only statements are explained: PostgreSQL's EXPLAIN ANALYZE runs the
|
|
10
|
+
# statement, so explaining a write would replay it. The probe itself runs, for
|
|
11
|
+
# what no reading of the statement can rule out (a function with a side effect),
|
|
12
|
+
# on a connection of its own that is closed afterwards, in a transaction that is
|
|
13
|
+
# always rolled back, read-only and time-limited on PostgreSQL.
|
|
6
14
|
module ExplainRunner
|
|
15
|
+
# An ArgumentError, so callers answer it as a bad request (422, MCP error).
|
|
16
|
+
class UnsafeStatementError < ArgumentError; end
|
|
17
|
+
|
|
18
|
+
# PostgreSQL's EXPLAIN ANALYZE runs the query: a slow one is stopped after this.
|
|
19
|
+
PROBE_STATEMENT_TIMEOUT = "30s"
|
|
20
|
+
|
|
7
21
|
# @param profile_token [String]
|
|
8
22
|
# @param query_index [Integer]
|
|
9
23
|
# @return [Hash] { result:, format: "json"|"text", adapter: String }
|
|
@@ -35,11 +49,14 @@ module Profiler
|
|
|
35
49
|
|
|
36
50
|
conn = ActiveRecord::Base.connection
|
|
37
51
|
adapter = conn.adapter_name.downcase
|
|
52
|
+
refusal = SqlStatement.read_only_refusal(sql, dialect: dialect_for(adapter))
|
|
53
|
+
raise UnsafeStatementError, refusal if refusal
|
|
54
|
+
|
|
38
55
|
full_sql = reconstruct_sql(sql, binds, conn, adapter)
|
|
39
56
|
|
|
40
57
|
explain_sql, format = build_explain_statement(full_sql, adapter)
|
|
41
58
|
|
|
42
|
-
rows =
|
|
59
|
+
rows = probe(explain_sql, adapter)
|
|
43
60
|
|
|
44
61
|
result = if format == "json"
|
|
45
62
|
# PostgreSQL / MySQL return JSON in rows[0]["QUERY PLAN"] or rows[0]["EXPLAIN"]
|
|
@@ -55,21 +72,51 @@ module Profiler
|
|
|
55
72
|
private
|
|
56
73
|
|
|
57
74
|
def self.reconstruct_sql(sql, binds, conn, adapter)
|
|
58
|
-
|
|
75
|
+
SqlStatement.substitute(sql, binds, dialect: dialect_for(adapter)) { |value| conn.quote(value) }
|
|
76
|
+
end
|
|
59
77
|
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
78
|
+
def self.dialect_for(adapter)
|
|
79
|
+
if adapter.include?("postgresql") then :postgresql
|
|
80
|
+
elsif adapter.include?("mysql") || adapter.include?("trilogy") then :mysql
|
|
81
|
+
else :sqlite
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# Runs on a connection of its own, opened outside the pool and closed
|
|
86
|
+
# afterwards, so that nothing the probe does to its session outlives it (an
|
|
87
|
+
# advisory lock, a setting, a LISTEN), the application's own transaction is
|
|
88
|
+
# never touched, and a full pool does not keep Explain waiting.
|
|
89
|
+
# The transaction is always rolled back. On PostgreSQL it is also read-only and
|
|
90
|
+
# time-limited, and the EXPLAIN goes by the extended protocol, which refuses
|
|
91
|
+
# more than one statement: a COMMIT slipped in could not end the read-only
|
|
92
|
+
# transaction. Neither MySQL's EXPLAIN nor SQLite's EXPLAIN QUERY PLAN runs the
|
|
93
|
+
# statement.
|
|
94
|
+
def self.probe(explain_sql, adapter)
|
|
95
|
+
conn = open_probe_connection
|
|
96
|
+
begin
|
|
97
|
+
rows = nil
|
|
98
|
+
conn.transaction do
|
|
99
|
+
if adapter.include?("postgresql")
|
|
100
|
+
conn.execute("SET TRANSACTION READ ONLY")
|
|
101
|
+
conn.execute("SET LOCAL statement_timeout = #{conn.quote(PROBE_STATEMENT_TIMEOUT)}")
|
|
102
|
+
rows = conn.raw_connection.exec_params(explain_sql, []).to_a
|
|
103
|
+
else
|
|
104
|
+
rows = conn.exec_query(explain_sql, "EXPLAIN").to_a
|
|
105
|
+
end
|
|
106
|
+
raise ActiveRecord::Rollback
|
|
64
107
|
end
|
|
65
|
-
|
|
108
|
+
rows
|
|
109
|
+
ensure
|
|
110
|
+
conn.disconnect!
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def self.open_probe_connection
|
|
115
|
+
db_config = ActiveRecord::Base.connection_pool.db_config
|
|
116
|
+
if db_config.respond_to?(:new_connection) # Active Record 7.2 and later
|
|
117
|
+
db_config.new_connection
|
|
66
118
|
else
|
|
67
|
-
|
|
68
|
-
result = sql.dup
|
|
69
|
-
binds.each do |value|
|
|
70
|
-
result = result.sub("?", conn.quote(value))
|
|
71
|
-
end
|
|
72
|
-
result
|
|
119
|
+
ActiveRecord::Base.public_send(db_config.adapter_method, db_config.configuration_hash)
|
|
73
120
|
end
|
|
74
121
|
end
|
|
75
122
|
|
data/lib/profiler/mcp/server.rb
CHANGED
|
@@ -139,7 +139,7 @@ module Profiler
|
|
|
139
139
|
),
|
|
140
140
|
define_tool(
|
|
141
141
|
name: "explain_query",
|
|
142
|
-
description: "Run EXPLAIN ANALYZE on a specific query from a profile. Returns the query execution plan with cost and row estimates. Only available in development/test environments.",
|
|
142
|
+
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.",
|
|
143
143
|
input_schema: {
|
|
144
144
|
properties: {
|
|
145
145
|
token: { type: "string", description: "Profile token, or 'latest' for the most recent profile (required)" },
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "strscan"
|
|
4
|
+
|
|
5
|
+
module Profiler
|
|
6
|
+
# Reads a stored SQL statement the way the database would, one token at a time:
|
|
7
|
+
# comments, string literals and quoted identifiers are never mistaken for keywords
|
|
8
|
+
# or placeholders. Used by ExplainRunner to refuse anything but a read, and to put
|
|
9
|
+
# the bind values back into the statement.
|
|
10
|
+
module SqlStatement
|
|
11
|
+
# The first keyword of a statement EXPLAIN may run.
|
|
12
|
+
READ_ONLY_VERBS = %w[SELECT WITH TABLE VALUES].freeze
|
|
13
|
+
|
|
14
|
+
# Keywords that make a read-only verb write or lock: a CTE that writes,
|
|
15
|
+
# SELECT ... INTO (and MySQL's INTO OUTFILE), FOR UPDATE / FOR SHARE,
|
|
16
|
+
# LOCK IN SHARE MODE. Matched as whole words outside literals, so a column
|
|
17
|
+
# named `updated_at` is fine and an unquoted column named `share` is refused.
|
|
18
|
+
WRITE_KEYWORDS = %w[INSERT UPDATE DELETE MERGE TRUNCATE DROP ALTER CREATE INTO LOCK SHARE].freeze
|
|
19
|
+
|
|
20
|
+
Token = Struct.new(:type, :text)
|
|
21
|
+
|
|
22
|
+
# 'it''s' everywhere; 'it\'s' too where a backslash escapes. An unterminated
|
|
23
|
+
# literal runs to the end: the database refuses such a statement anyway.
|
|
24
|
+
PLAIN_LITERAL = /[Nn]?'(?:[^']|'')*'?/m
|
|
25
|
+
BACKSLASH_LITERAL = /[Nn]?'(?:[^'\\]|''|\\.)*'?/m
|
|
26
|
+
ESCAPE_LITERAL = /[Ee]'(?:[^'\\]|''|\\.)*'?/m
|
|
27
|
+
|
|
28
|
+
# Dialects differ in what a literal or a comment is: a backslash escapes a quote
|
|
29
|
+
# in every MySQL string but only in PostgreSQL's E'...' strings, dollar quoting
|
|
30
|
+
# is PostgreSQL's, backticks quote identifiers in MySQL and SQLite, MySQL starts
|
|
31
|
+
# a comment with `#` and needs a space after `--`, and only PostgreSQL nests
|
|
32
|
+
# block comments.
|
|
33
|
+
DIALECTS = %i[postgresql mysql sqlite].freeze
|
|
34
|
+
|
|
35
|
+
# @param dialect [Symbol] one of DIALECTS
|
|
36
|
+
# @return [String, nil] why the statement must not be explained, or nil when it reads only
|
|
37
|
+
def self.read_only_refusal(sql, dialect:)
|
|
38
|
+
all = tokenize(sql, dialect)
|
|
39
|
+
if dialect == :mysql && all.any? { |t| t.type == :comment && t.text.start_with?("/*!") }
|
|
40
|
+
return refusal("it holds a MySQL executable comment (/*! ... */)")
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
tokens = all.reject { |t| %i[space comment].include?(t.type) }
|
|
44
|
+
first = tokens.find { |t| t.type != :punct || t.text != "(" }
|
|
45
|
+
return refusal("it is empty") if first.nil?
|
|
46
|
+
|
|
47
|
+
verb = first.text.upcase
|
|
48
|
+
return refusal("it starts with #{verb}") unless first.type == :word && READ_ONLY_VERBS.include?(verb)
|
|
49
|
+
|
|
50
|
+
tokens.each_with_index do |token, i|
|
|
51
|
+
if token.type == :word && WRITE_KEYWORDS.include?(token.text.upcase)
|
|
52
|
+
return refusal("it contains #{token.text.upcase}")
|
|
53
|
+
end
|
|
54
|
+
if token.type == :punct && token.text == ";" && i != tokens.size - 1
|
|
55
|
+
return refusal("it holds more than one statement")
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
nil
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Replaces each placeholder by its quoted value in a single pass: `$1`, `$10`...
|
|
62
|
+
# by number (PostgreSQL), or `?` in order (MySQL, SQLite). Placeholders inside
|
|
63
|
+
# literals or comments stay as they are, and a substituted value is never read
|
|
64
|
+
# again. A placeholder without a value is left in place.
|
|
65
|
+
def self.substitute(sql, binds, dialect:, "e)
|
|
66
|
+
return sql if binds.empty?
|
|
67
|
+
|
|
68
|
+
position = 0
|
|
69
|
+
tokenize(sql, dialect).map do |token|
|
|
70
|
+
index = case token.type
|
|
71
|
+
when :numbered then token.text[1..].to_i - 1
|
|
72
|
+
when :question then (position += 1) - 1
|
|
73
|
+
end
|
|
74
|
+
index && index >= 0 && index < binds.size ? quote.call(binds[index]) : token.text
|
|
75
|
+
end.join
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def self.refusal(reason)
|
|
79
|
+
"EXPLAIN refused: only read-only statements (SELECT, WITH ... SELECT, TABLE, VALUES) " \
|
|
80
|
+
"are explained, and #{reason}. PostgreSQL's EXPLAIN ANALYZE runs the statement it explains."
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# Splits the statement into tokens whose texts, joined, give it back unchanged.
|
|
84
|
+
def self.tokenize(sql, dialect)
|
|
85
|
+
raise ArgumentError, "unknown SQL dialect: #{dialect.inspect}" unless DIALECTS.include?(dialect)
|
|
86
|
+
|
|
87
|
+
s = StringScanner.new(sql.to_s)
|
|
88
|
+
tokens = []
|
|
89
|
+
until s.eos?
|
|
90
|
+
tokens << if s.scan(/\s+/)
|
|
91
|
+
Token.new(:space, s.matched)
|
|
92
|
+
elsif s.scan(dialect == :mysql ? /(?:--(?=\s|\z)|#)[^\n]*/ : /--[^\n]*/)
|
|
93
|
+
Token.new(:comment, s.matched)
|
|
94
|
+
elsif s.check(%r{/\*})
|
|
95
|
+
Token.new(:comment, scan_block_comment(s, nested: dialect == :postgresql))
|
|
96
|
+
elsif s.scan(dialect == :mysql ? BACKSLASH_LITERAL : PLAIN_LITERAL) ||
|
|
97
|
+
(dialect == :postgresql && s.scan(ESCAPE_LITERAL))
|
|
98
|
+
Token.new(:literal, s.matched)
|
|
99
|
+
elsif s.scan(/"(?:[^"]|"")*"?/m) || (dialect != :postgresql && s.scan(/`(?:[^`]|``)*`?/m))
|
|
100
|
+
Token.new(:identifier, s.matched)
|
|
101
|
+
elsif dialect == :postgresql && s.scan(/\$\d+/)
|
|
102
|
+
Token.new(:numbered, s.matched)
|
|
103
|
+
elsif dialect == :postgresql && s.scan(/\$([A-Za-z_][A-Za-z0-9_]*)?\$/)
|
|
104
|
+
tag = s.matched
|
|
105
|
+
body = s.scan_until(/#{Regexp.escape(tag)}/) || s.rest.tap { s.terminate }
|
|
106
|
+
Token.new(:literal, tag + body)
|
|
107
|
+
elsif dialect != :postgresql && s.scan(/\?/)
|
|
108
|
+
Token.new(:question, s.matched)
|
|
109
|
+
elsif s.scan(/[A-Za-z_][A-Za-z0-9_$]*/)
|
|
110
|
+
Token.new(:word, s.matched)
|
|
111
|
+
else
|
|
112
|
+
Token.new(:punct, s.getch)
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
tokens
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# An unterminated block comment runs to the end.
|
|
119
|
+
def self.scan_block_comment(s, nested:)
|
|
120
|
+
text = +""
|
|
121
|
+
depth = 0
|
|
122
|
+
until s.eos?
|
|
123
|
+
if s.scan(%r{/\*})
|
|
124
|
+
depth += 1 if nested || depth.zero?
|
|
125
|
+
elsif s.scan(%r{\*/})
|
|
126
|
+
depth -= 1
|
|
127
|
+
else
|
|
128
|
+
s.getch
|
|
129
|
+
end
|
|
130
|
+
text << s.matched
|
|
131
|
+
break if depth.zero?
|
|
132
|
+
end
|
|
133
|
+
text
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
private_class_method :refusal, :tokenize, :scan_block_comment
|
|
137
|
+
end
|
|
138
|
+
end
|
data/lib/profiler/version.rb
CHANGED
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.30.
|
|
4
|
+
version: 0.30.9
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Sébastien Duplessy
|
|
@@ -210,6 +210,7 @@ files:
|
|
|
210
210
|
- lib/profiler/models/timeline_event.rb
|
|
211
211
|
- lib/profiler/railtie.rb
|
|
212
212
|
- lib/profiler/redaction.rb
|
|
213
|
+
- lib/profiler/sql_statement.rb
|
|
213
214
|
- lib/profiler/sse/bus.rb
|
|
214
215
|
- lib/profiler/sse/event_bus.rb
|
|
215
216
|
- lib/profiler/sse/redis_event_bus.rb
|