rails_vitals 0.6.1 → 0.6.2
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/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 +95 -9
- data/lib/rails_vitals/mcp/tools/explain_query.rb +13 -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: c655986f52ddc0e0848b8e67c28d603ccea2a97dc6b85d9157a7a670fd298fc6
|
|
4
|
+
data.tar.gz: 49bbb1b8fc0041bd75edb7d3d990ed517dcd907f64b7f99af95eb2d33dbdee80
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 7e905ff8aa217f398c7521b677c088f637b5483cd153af51db1453fbee560476d32b230a3a9d432d975910f60fd9992cd4368a79b6be07abb4a6f676cff5c866
|
|
7
|
+
data.tar.gz: fcd80c62bc58c1adfed045eb3764f29918705d6b9e0952af68f0f2601197f3af5079ca5abbe62aa6361c950b188711102f96ba3e36b28fa2766138b1fb69ce1d
|
|
@@ -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 %>
|
|
@@ -10,7 +10,14 @@ module RailsVitals
|
|
|
10
10
|
COLOR_NEUTRAL = "#a0aec0"
|
|
11
11
|
COLOR_COOL = "#76e4f7"
|
|
12
12
|
|
|
13
|
-
#
|
|
13
|
+
# ─── Result / PlanNode structs ────────────────────────────────────────────────
|
|
14
|
+
|
|
15
|
+
Result = Struct.new(
|
|
16
|
+
:sql, :plan, :warnings, :suggestions,
|
|
17
|
+
:total_cost, :actual_time_ms, :rows_examined,
|
|
18
|
+
:interpretation, :error, :function_calls, :dry_run_result,
|
|
19
|
+
keyword_init: true
|
|
20
|
+
)
|
|
14
21
|
NODE_METADATA = {
|
|
15
22
|
"Seq Scan" => {
|
|
16
23
|
risk: :danger,
|
|
@@ -114,12 +121,7 @@ module RailsVitals
|
|
|
114
121
|
}
|
|
115
122
|
}.freeze
|
|
116
123
|
|
|
117
|
-
|
|
118
|
-
:sql, :plan, :warnings, :suggestions,
|
|
119
|
-
:total_cost, :actual_time_ms, :rows_examined,
|
|
120
|
-
:interpretation, :error,
|
|
121
|
-
keyword_init: true
|
|
122
|
-
)
|
|
124
|
+
# ─── Safe SQL analysis ───────────────────────────────────────────────────────
|
|
123
125
|
|
|
124
126
|
PlanNode = Struct.new(
|
|
125
127
|
:node_type, :relation, :alias_name,
|
|
@@ -132,15 +134,34 @@ module RailsVitals
|
|
|
132
134
|
keyword_init: true
|
|
133
135
|
)
|
|
134
136
|
|
|
137
|
+
ALLOWED_FUNCTIONS = %w[
|
|
138
|
+
COUNT SUM AVG MIN MAX
|
|
139
|
+
COALESCE NULLIF
|
|
140
|
+
NOW CURRENT_TIMESTAMP CURRENT_DATE CURRENT_TIME LOCALTIMESTAMP LOCALTIME
|
|
141
|
+
EXTRACT DATE_PART DATE_TRUNC AGE
|
|
142
|
+
UPPER LOWER TRIM LENGTH SUBSTRING CONCAT REPLACE POSITION REVERSE
|
|
143
|
+
ROUND FLOOR CEIL ABS MOD POWER SQRT
|
|
144
|
+
GREATEST LEAST CAST
|
|
145
|
+
ROW_NUMBER RANK DENSE_RANK LAG LEAD FIRST_VALUE LAST_VALUE NTILE
|
|
146
|
+
ARRAY_AGG STRING_AGG
|
|
147
|
+
JSON_EXTRACT_PATH_TEXT JSONB_EXTRACT_PATH
|
|
148
|
+
].freeze
|
|
149
|
+
|
|
150
|
+
FUNCTION_CALL_PATTERN = /\b(?!#{ALLOWED_FUNCTIONS.join('|')}\b)[a-zA-Z_]\w*\s*\(/
|
|
151
|
+
|
|
135
152
|
def self.analyze(sql, binds: [])
|
|
136
153
|
return unsupported_env unless supported_environment?
|
|
137
154
|
return unsupported_sql unless select_query?(sql)
|
|
138
155
|
|
|
139
156
|
safe_sql = substitute_binds(sql, binds)
|
|
157
|
+
has_function_calls = contains_function_calls?(safe_sql)
|
|
140
158
|
|
|
141
|
-
|
|
159
|
+
dry_run_result = dry_run(safe_sql)
|
|
160
|
+
return dry_run_result if dry_run_result.error
|
|
161
|
+
|
|
162
|
+
raw = exec_in_read_only_transaction(
|
|
142
163
|
"EXPLAIN (FORMAT JSON, ANALYZE true, BUFFERS false) #{safe_sql}"
|
|
143
|
-
)
|
|
164
|
+
)
|
|
144
165
|
|
|
145
166
|
plan_json = JSON.parse(raw)
|
|
146
167
|
root_plan = plan_json.first["Plan"]
|
|
@@ -150,6 +171,14 @@ module RailsVitals
|
|
|
150
171
|
warnings = extract_warnings(root_node)
|
|
151
172
|
suggestions = build_suggestions(warnings, root_node)
|
|
152
173
|
|
|
174
|
+
if has_function_calls
|
|
175
|
+
warnings << {
|
|
176
|
+
type: :function_call,
|
|
177
|
+
severity: :info,
|
|
178
|
+
message: "Query contains function calls — executed inside a read-only transaction that prevents writes"
|
|
179
|
+
}
|
|
180
|
+
end
|
|
181
|
+
|
|
153
182
|
result = Result.new(
|
|
154
183
|
sql: safe_sql,
|
|
155
184
|
plan: root_node,
|
|
@@ -158,6 +187,8 @@ module RailsVitals
|
|
|
158
187
|
total_cost: root_plan["Total Cost"]&.round(2),
|
|
159
188
|
actual_time_ms: exec_time,
|
|
160
189
|
rows_examined: count_rows_examined(root_plan),
|
|
190
|
+
function_calls: has_function_calls,
|
|
191
|
+
dry_run_result: dry_run_result,
|
|
161
192
|
error: nil
|
|
162
193
|
)
|
|
163
194
|
|
|
@@ -169,6 +200,28 @@ module RailsVitals
|
|
|
169
200
|
actual_time_ms: nil, rows_examined: nil)
|
|
170
201
|
end
|
|
171
202
|
|
|
203
|
+
def self.dry_run(sql, binds: [])
|
|
204
|
+
safe_sql = substitute_binds(sql, binds)
|
|
205
|
+
|
|
206
|
+
raw = exec_in_read_only_transaction(
|
|
207
|
+
"EXPLAIN (FORMAT JSON, ANALYZE false, BUFFERS false) #{safe_sql}"
|
|
208
|
+
)
|
|
209
|
+
|
|
210
|
+
plan_json = JSON.parse(raw)
|
|
211
|
+
root_plan = plan_json.first["Plan"]
|
|
212
|
+
|
|
213
|
+
Result.new(
|
|
214
|
+
sql: safe_sql,
|
|
215
|
+
total_cost: root_plan["Total Cost"]&.round(2),
|
|
216
|
+
plan: build_node(root_plan),
|
|
217
|
+
error: nil
|
|
218
|
+
)
|
|
219
|
+
rescue => e
|
|
220
|
+
Result.new(error: e.message, sql: sql, plan: nil,
|
|
221
|
+
warnings: [], suggestions: [], total_cost: nil,
|
|
222
|
+
actual_time_ms: nil, rows_examined: nil)
|
|
223
|
+
end
|
|
224
|
+
|
|
172
225
|
private
|
|
173
226
|
|
|
174
227
|
def self.supported_environment?
|
|
@@ -179,6 +232,35 @@ module RailsVitals
|
|
|
179
232
|
sql.strip.match?(/\ASELECT/i)
|
|
180
233
|
end
|
|
181
234
|
|
|
235
|
+
def self.contains_function_calls?(sql)
|
|
236
|
+
clean = sql.gsub(/'.*?(?:''|')/, "").gsub(/".*?"/, "")
|
|
237
|
+
clean.match?(FUNCTION_CALL_PATTERN)
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
def self.exec_in_read_only_transaction(sql)
|
|
241
|
+
if postgresql?
|
|
242
|
+
connection = ActiveRecord::Base.connection
|
|
243
|
+
connection.execute("BEGIN")
|
|
244
|
+
connection.execute("SET TRANSACTION READ ONLY")
|
|
245
|
+
begin
|
|
246
|
+
result = connection.execute(sql)
|
|
247
|
+
connection.execute("COMMIT")
|
|
248
|
+
result.first["QUERY PLAN"]
|
|
249
|
+
rescue => e
|
|
250
|
+
connection.execute("ROLLBACK") rescue nil
|
|
251
|
+
raise e
|
|
252
|
+
end
|
|
253
|
+
else
|
|
254
|
+
ActiveRecord::Base.connection.execute(sql).first["QUERY PLAN"]
|
|
255
|
+
end
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
def self.postgresql?
|
|
259
|
+
ActiveRecord::Base.connection.adapter_name.match?(/postgresql/i)
|
|
260
|
+
rescue
|
|
261
|
+
false
|
|
262
|
+
end
|
|
263
|
+
|
|
182
264
|
def self.substitute_binds(sql, binds)
|
|
183
265
|
# Replace $1, $2 placeholders with safe literal values
|
|
184
266
|
result = sql.dup
|
|
@@ -334,6 +416,10 @@ module RailsVitals
|
|
|
334
416
|
parts << "Sequential scan detected — missing index is causing full table reads"
|
|
335
417
|
end
|
|
336
418
|
|
|
419
|
+
if result.function_calls
|
|
420
|
+
parts << "Query uses function calls — executed in a read-only transaction that prevents writes"
|
|
421
|
+
end
|
|
422
|
+
|
|
337
423
|
if result.actual_time_ms.to_f > 100
|
|
338
424
|
parts << "query took #{result.actual_time_ms}ms — above the 100ms warning threshold"
|
|
339
425
|
end
|
|
@@ -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
|
data/lib/rails_vitals/version.rb
CHANGED