rails_vitals 0.6.1 → 0.6.3
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/app/controllers/rails_vitals/mcp_controller.rb +3 -2
- data/app/helpers/rails_vitals/application_helper.rb +10 -0
- data/app/views/rails_vitals/explains/show.html.erb +8 -1
- data/lib/rails_vitals/analyzers/explain_analyzer.rb +98 -13
- data/lib/rails_vitals/configuration.rb +7 -1
- data/lib/rails_vitals/engine.rb +4 -1
- data/lib/rails_vitals/mcp/tools/explain_query.rb +13 -1
- data/lib/rails_vitals/playground/sandbox.rb +8 -1
- data/lib/rails_vitals/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: a4479577c80975bad32203d017cff00cf4070589c6059424b393796bb5f4b7a2
|
|
4
|
+
data.tar.gz: e96412a5354e3e79ae97e4957a8e4c96372ffabb705fb8cb52251bb9348a4d9d
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 9d7f670412fc9caaf74d8310161f246eb5d7f2d20a433a9eae01fa10350602afcfabbddb89048920b54a136001d0aaee0338c642ca6826dff3a8a99f133ee175
|
|
7
|
+
data.tar.gz: b46385277f03c958362eff854389098c921f6b7bdaf490ddbb41212c255a14c60c3c48c45c7a10fd290361dbd58d9a8619865c154b2a020b7daeb6bc342e9b66
|
|
@@ -13,11 +13,12 @@ module RailsVitals
|
|
|
13
13
|
private
|
|
14
14
|
|
|
15
15
|
def verify_environment
|
|
16
|
-
|
|
16
|
+
unless RailsVitals.config.permitted_environment?
|
|
17
17
|
render json: ResponseBuilder.error(
|
|
18
18
|
nil,
|
|
19
19
|
ResponseBuilder::AUTH_ERROR,
|
|
20
|
-
"RailsVitals MCP is not available in
|
|
20
|
+
"RailsVitals MCP is not available in this environment. " \
|
|
21
|
+
"Permitted: #{RailsVitals::Configuration::PERMITTED_ENVIRONMENTS.join(', ')}"
|
|
21
22
|
), status: :forbidden
|
|
22
23
|
end
|
|
23
24
|
end
|
|
@@ -104,6 +104,16 @@ module RailsVitals
|
|
|
104
104
|
}[risk.to_sym] || COLOR_NEUTRAL
|
|
105
105
|
end
|
|
106
106
|
|
|
107
|
+
# Returns a hex color for EXPLAIN warning severity (:danger, :warning, :info)
|
|
108
|
+
def explain_severity_color(severity)
|
|
109
|
+
case severity&.to_sym
|
|
110
|
+
when :danger then COLOR_LIGHT_RED
|
|
111
|
+
when :warning then COLOR_ORANGE
|
|
112
|
+
when :info then "#90cdf4"
|
|
113
|
+
else COLOR_ORANGE
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
|
|
107
117
|
# Returns a readable hex text color for a numeric health score (0-100)
|
|
108
118
|
def score_text_color(score)
|
|
109
119
|
case score.to_i
|
|
@@ -50,7 +50,7 @@
|
|
|
50
50
|
</div>
|
|
51
51
|
|
|
52
52
|
<% @result.warnings.each do |w| %>
|
|
53
|
-
<% severity_color = w[:severity]
|
|
53
|
+
<% severity_color = explain_severity_color(w[:severity]) %>
|
|
54
54
|
|
|
55
55
|
<div
|
|
56
56
|
style="background:#1a202c;border-left:3px solid <%= severity_color %>;
|
|
@@ -81,6 +81,13 @@
|
|
|
81
81
|
<div class="text-muted">
|
|
82
82
|
Nested Loop processed a large number of rows. Verify join columns are indexed.
|
|
83
83
|
</div>
|
|
84
|
+
<% when :function_call %>
|
|
85
|
+
<div class="bold mb-4" style="color:<%= severity_color %>;">
|
|
86
|
+
Function Call Detected
|
|
87
|
+
</div>
|
|
88
|
+
<div class="text-muted">
|
|
89
|
+
<%= w[:message] %>
|
|
90
|
+
</div>
|
|
84
91
|
<% end %>
|
|
85
92
|
</div>
|
|
86
93
|
<% end %>
|
|
@@ -1,8 +1,6 @@
|
|
|
1
1
|
module RailsVitals
|
|
2
2
|
module Analyzers
|
|
3
3
|
class ExplainAnalyzer
|
|
4
|
-
SUPPORTED_ENVIRONMENTS = %w[development test].freeze
|
|
5
|
-
|
|
6
4
|
COLOR_DANGER = "#fc8181"
|
|
7
5
|
COLOR_HEALTHY = "#68d391"
|
|
8
6
|
COLOR_WARNING = "#f6ad55"
|
|
@@ -10,7 +8,14 @@ module RailsVitals
|
|
|
10
8
|
COLOR_NEUTRAL = "#a0aec0"
|
|
11
9
|
COLOR_COOL = "#76e4f7"
|
|
12
10
|
|
|
13
|
-
#
|
|
11
|
+
# ─── Result / PlanNode structs ────────────────────────────────────────────────
|
|
12
|
+
|
|
13
|
+
Result = Struct.new(
|
|
14
|
+
:sql, :plan, :warnings, :suggestions,
|
|
15
|
+
:total_cost, :actual_time_ms, :rows_examined,
|
|
16
|
+
:interpretation, :error, :function_calls, :dry_run_result,
|
|
17
|
+
keyword_init: true
|
|
18
|
+
)
|
|
14
19
|
NODE_METADATA = {
|
|
15
20
|
"Seq Scan" => {
|
|
16
21
|
risk: :danger,
|
|
@@ -114,12 +119,7 @@ module RailsVitals
|
|
|
114
119
|
}
|
|
115
120
|
}.freeze
|
|
116
121
|
|
|
117
|
-
|
|
118
|
-
:sql, :plan, :warnings, :suggestions,
|
|
119
|
-
:total_cost, :actual_time_ms, :rows_examined,
|
|
120
|
-
:interpretation, :error,
|
|
121
|
-
keyword_init: true
|
|
122
|
-
)
|
|
122
|
+
# ─── Safe SQL analysis ───────────────────────────────────────────────────────
|
|
123
123
|
|
|
124
124
|
PlanNode = Struct.new(
|
|
125
125
|
:node_type, :relation, :alias_name,
|
|
@@ -132,15 +132,34 @@ module RailsVitals
|
|
|
132
132
|
keyword_init: true
|
|
133
133
|
)
|
|
134
134
|
|
|
135
|
+
ALLOWED_FUNCTIONS = %w[
|
|
136
|
+
COUNT SUM AVG MIN MAX
|
|
137
|
+
COALESCE NULLIF
|
|
138
|
+
NOW CURRENT_TIMESTAMP CURRENT_DATE CURRENT_TIME LOCALTIMESTAMP LOCALTIME
|
|
139
|
+
EXTRACT DATE_PART DATE_TRUNC AGE
|
|
140
|
+
UPPER LOWER TRIM LENGTH SUBSTRING CONCAT REPLACE POSITION REVERSE
|
|
141
|
+
ROUND FLOOR CEIL ABS MOD POWER SQRT
|
|
142
|
+
GREATEST LEAST CAST
|
|
143
|
+
ROW_NUMBER RANK DENSE_RANK LAG LEAD FIRST_VALUE LAST_VALUE NTILE
|
|
144
|
+
ARRAY_AGG STRING_AGG
|
|
145
|
+
JSON_EXTRACT_PATH_TEXT JSONB_EXTRACT_PATH
|
|
146
|
+
].freeze
|
|
147
|
+
|
|
148
|
+
FUNCTION_CALL_PATTERN = /\b(?!#{ALLOWED_FUNCTIONS.join('|')}\b)[a-zA-Z_]\w*\s*\(/
|
|
149
|
+
|
|
135
150
|
def self.analyze(sql, binds: [])
|
|
136
151
|
return unsupported_env unless supported_environment?
|
|
137
152
|
return unsupported_sql unless select_query?(sql)
|
|
138
153
|
|
|
139
154
|
safe_sql = substitute_binds(sql, binds)
|
|
155
|
+
has_function_calls = contains_function_calls?(safe_sql)
|
|
156
|
+
|
|
157
|
+
dry_run_result = dry_run(safe_sql)
|
|
158
|
+
return dry_run_result if dry_run_result.error
|
|
140
159
|
|
|
141
|
-
raw =
|
|
160
|
+
raw = exec_in_read_only_transaction(
|
|
142
161
|
"EXPLAIN (FORMAT JSON, ANALYZE true, BUFFERS false) #{safe_sql}"
|
|
143
|
-
)
|
|
162
|
+
)
|
|
144
163
|
|
|
145
164
|
plan_json = JSON.parse(raw)
|
|
146
165
|
root_plan = plan_json.first["Plan"]
|
|
@@ -150,6 +169,14 @@ module RailsVitals
|
|
|
150
169
|
warnings = extract_warnings(root_node)
|
|
151
170
|
suggestions = build_suggestions(warnings, root_node)
|
|
152
171
|
|
|
172
|
+
if has_function_calls
|
|
173
|
+
warnings << {
|
|
174
|
+
type: :function_call,
|
|
175
|
+
severity: :info,
|
|
176
|
+
message: "Query contains function calls — executed inside a read-only transaction that prevents writes"
|
|
177
|
+
}
|
|
178
|
+
end
|
|
179
|
+
|
|
153
180
|
result = Result.new(
|
|
154
181
|
sql: safe_sql,
|
|
155
182
|
plan: root_node,
|
|
@@ -158,6 +185,8 @@ module RailsVitals
|
|
|
158
185
|
total_cost: root_plan["Total Cost"]&.round(2),
|
|
159
186
|
actual_time_ms: exec_time,
|
|
160
187
|
rows_examined: count_rows_examined(root_plan),
|
|
188
|
+
function_calls: has_function_calls,
|
|
189
|
+
dry_run_result: dry_run_result,
|
|
161
190
|
error: nil
|
|
162
191
|
)
|
|
163
192
|
|
|
@@ -169,16 +198,67 @@ module RailsVitals
|
|
|
169
198
|
actual_time_ms: nil, rows_examined: nil)
|
|
170
199
|
end
|
|
171
200
|
|
|
201
|
+
def self.dry_run(sql, binds: [])
|
|
202
|
+
safe_sql = substitute_binds(sql, binds)
|
|
203
|
+
|
|
204
|
+
raw = exec_in_read_only_transaction(
|
|
205
|
+
"EXPLAIN (FORMAT JSON, ANALYZE false, BUFFERS false) #{safe_sql}"
|
|
206
|
+
)
|
|
207
|
+
|
|
208
|
+
plan_json = JSON.parse(raw)
|
|
209
|
+
root_plan = plan_json.first["Plan"]
|
|
210
|
+
|
|
211
|
+
Result.new(
|
|
212
|
+
sql: safe_sql,
|
|
213
|
+
total_cost: root_plan["Total Cost"]&.round(2),
|
|
214
|
+
plan: build_node(root_plan),
|
|
215
|
+
error: nil
|
|
216
|
+
)
|
|
217
|
+
rescue => e
|
|
218
|
+
Result.new(error: e.message, sql: sql, plan: nil,
|
|
219
|
+
warnings: [], suggestions: [], total_cost: nil,
|
|
220
|
+
actual_time_ms: nil, rows_examined: nil)
|
|
221
|
+
end
|
|
222
|
+
|
|
172
223
|
private
|
|
173
224
|
|
|
174
225
|
def self.supported_environment?
|
|
175
|
-
|
|
226
|
+
RailsVitals.config&.permitted_environment? || false
|
|
176
227
|
end
|
|
177
228
|
|
|
178
229
|
def self.select_query?(sql)
|
|
179
230
|
sql.strip.match?(/\ASELECT/i)
|
|
180
231
|
end
|
|
181
232
|
|
|
233
|
+
def self.contains_function_calls?(sql)
|
|
234
|
+
clean = sql.gsub(/'.*?(?:''|')/, "").gsub(/".*?"/, "")
|
|
235
|
+
clean.match?(FUNCTION_CALL_PATTERN)
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
def self.exec_in_read_only_transaction(sql)
|
|
239
|
+
if postgresql?
|
|
240
|
+
connection = ActiveRecord::Base.connection
|
|
241
|
+
connection.execute("BEGIN")
|
|
242
|
+
connection.execute("SET TRANSACTION READ ONLY")
|
|
243
|
+
begin
|
|
244
|
+
result = connection.execute(sql)
|
|
245
|
+
connection.execute("COMMIT")
|
|
246
|
+
result.first["QUERY PLAN"]
|
|
247
|
+
rescue => e
|
|
248
|
+
connection.execute("ROLLBACK") rescue nil
|
|
249
|
+
raise e
|
|
250
|
+
end
|
|
251
|
+
else
|
|
252
|
+
ActiveRecord::Base.connection.execute(sql).first["QUERY PLAN"]
|
|
253
|
+
end
|
|
254
|
+
end
|
|
255
|
+
|
|
256
|
+
def self.postgresql?
|
|
257
|
+
ActiveRecord::Base.connection.adapter_name.match?(/postgresql/i)
|
|
258
|
+
rescue
|
|
259
|
+
false
|
|
260
|
+
end
|
|
261
|
+
|
|
182
262
|
def self.substitute_binds(sql, binds)
|
|
183
263
|
# Replace $1, $2 placeholders with safe literal values
|
|
184
264
|
result = sql.dup
|
|
@@ -311,7 +391,8 @@ module RailsVitals
|
|
|
311
391
|
|
|
312
392
|
def self.unsupported_env
|
|
313
393
|
Result.new(
|
|
314
|
-
error: "EXPLAIN is only available in
|
|
394
|
+
error: "EXPLAIN is only available in permitted environments: " \
|
|
395
|
+
"#{RailsVitals::Configuration::PERMITTED_ENVIRONMENTS.join(', ')}.",
|
|
315
396
|
sql: nil, plan: nil, warnings: [], suggestions: [],
|
|
316
397
|
total_cost: nil, actual_time_ms: nil, rows_examined: nil
|
|
317
398
|
)
|
|
@@ -334,6 +415,10 @@ module RailsVitals
|
|
|
334
415
|
parts << "Sequential scan detected — missing index is causing full table reads"
|
|
335
416
|
end
|
|
336
417
|
|
|
418
|
+
if result.function_calls
|
|
419
|
+
parts << "Query uses function calls — executed in a read-only transaction that prevents writes"
|
|
420
|
+
end
|
|
421
|
+
|
|
337
422
|
if result.actual_time_ms.to_f > 100
|
|
338
423
|
parts << "query took #{result.actual_time_ms}ms — above the 100ms warning threshold"
|
|
339
424
|
end
|
|
@@ -15,8 +15,10 @@ module RailsVitals
|
|
|
15
15
|
:mcp_max_log_size,
|
|
16
16
|
:mcp_slow_query_threshold_ms
|
|
17
17
|
|
|
18
|
+
PERMITTED_ENVIRONMENTS = %w[development test].freeze
|
|
19
|
+
|
|
18
20
|
def initialize
|
|
19
|
-
@enabled = defined?(Rails) &&
|
|
21
|
+
@enabled = defined?(Rails) && permitted_environment?
|
|
20
22
|
@store_size = 200
|
|
21
23
|
@store_enabled = true
|
|
22
24
|
@auth = :none
|
|
@@ -33,5 +35,9 @@ module RailsVitals
|
|
|
33
35
|
@mcp_max_log_size = 100
|
|
34
36
|
@mcp_slow_query_threshold_ms = 100
|
|
35
37
|
end
|
|
38
|
+
|
|
39
|
+
def permitted_environment?
|
|
40
|
+
PERMITTED_ENVIRONMENTS.include?(Rails.env.to_s)
|
|
41
|
+
end
|
|
36
42
|
end
|
|
37
43
|
end
|
data/lib/rails_vitals/engine.rb
CHANGED
|
@@ -16,7 +16,10 @@ module RailsVitals
|
|
|
16
16
|
|
|
17
17
|
initializer "rails_vitals.mcp" do
|
|
18
18
|
if RailsVitals.config.mcp_enabled
|
|
19
|
-
|
|
19
|
+
unless RailsVitals.config.permitted_environment?
|
|
20
|
+
raise "RailsVitals MCP cannot run in #{Rails.env} environment. " \
|
|
21
|
+
"Permitted: #{RailsVitals::Configuration::PERMITTED_ENVIRONMENTS.join(', ')}"
|
|
22
|
+
end
|
|
20
23
|
|
|
21
24
|
require "rails_vitals/mcp/auth"
|
|
22
25
|
require "rails_vitals/mcp/response_builder"
|
|
@@ -39,7 +39,14 @@ module RailsVitals
|
|
|
39
39
|
return missing_sql_response if sql.nil? || sql.strip.empty?
|
|
40
40
|
return rejected_sql_response(sql) if dml_present?(sql)
|
|
41
41
|
|
|
42
|
-
|
|
42
|
+
cleaned_sql = sql.strip
|
|
43
|
+
|
|
44
|
+
cost_estimate = Analyzers::ExplainAnalyzer.dry_run(cleaned_sql)
|
|
45
|
+
if cost_estimate.error
|
|
46
|
+
return error_response("Cost estimate failed: #{cost_estimate.error}")
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
result = Analyzers::ExplainAnalyzer.analyze(cleaned_sql)
|
|
43
50
|
|
|
44
51
|
return error_response(result.error) if result.error
|
|
45
52
|
|
|
@@ -48,6 +55,11 @@ module RailsVitals
|
|
|
48
55
|
total_cost: result.total_cost,
|
|
49
56
|
actual_time_ms: result.actual_time_ms,
|
|
50
57
|
rows_examined: result.rows_examined,
|
|
58
|
+
cost_estimate: {
|
|
59
|
+
total_cost: cost_estimate.total_cost,
|
|
60
|
+
note: "Cost estimate from dry-run EXPLAIN (ANALYZE was not executed for this estimate)"
|
|
61
|
+
},
|
|
62
|
+
function_calls: result.function_calls,
|
|
51
63
|
warnings: serialize_warnings(result.warnings),
|
|
52
64
|
suggestions: serialize_suggestions(result.suggestions),
|
|
53
65
|
interpretation: result.interpretation
|
|
@@ -4,7 +4,14 @@ module RailsVitals
|
|
|
4
4
|
BLOCKED_PATTERNS = [
|
|
5
5
|
/\b(insert|update|delete|destroy|drop|truncate|create|alter)\b/i,
|
|
6
6
|
/\.save/i, /\.save!/i, /\.update/i, /\.delete/i,
|
|
7
|
-
/\.destroy/i,
|
|
7
|
+
/\.destroy/i, /`/,
|
|
8
|
+
/\.connection\b/i, /\.execute\b/i, /\.exec\b/i,
|
|
9
|
+
/\.send\b/i, /\.public_send\b/i, /\.__send__\b/i,
|
|
10
|
+
/\.send_data\b/i, /\.open\b/i,
|
|
11
|
+
/\.instance_eval\b/i, /\.class_eval\b/i, /\.module_eval\b/i,
|
|
12
|
+
/\.define_method\b/i, /\.method_missing\b/i,
|
|
13
|
+
/\bsystem\b/i, /\beval\b/i, /\bfork\b/i, /\bspawn\b/i,
|
|
14
|
+
/\bIO\b/i, /\bFile\b/i, /\bThread\b/i, /\bProcess\b/i
|
|
8
15
|
].freeze
|
|
9
16
|
|
|
10
17
|
SAFE_EXPRESSION_PATTERN = /\A[a-zA-Z0-9_\.\s\(\),:\[\]{}'"!?=<>|&*+\-\/\\%]+\z/
|
data/lib/rails_vitals/version.rb
CHANGED