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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: ce21f0b9edbbef2e18452c51755bf980c067c92afb651a86ee99b78aac0cbe6d
4
- data.tar.gz: 0ee7cddd129757762d01cdce2f049622bb6f449e44de05ff5d592bfd4933136d
3
+ metadata.gz: a4479577c80975bad32203d017cff00cf4070589c6059424b393796bb5f4b7a2
4
+ data.tar.gz: e96412a5354e3e79ae97e4957a8e4c96372ffabb705fb8cb52251bb9348a4d9d
5
5
  SHA512:
6
- metadata.gz: 201b26e862cafbde3dfe07f1011ceb3d8241f362ff5386cbae8edbe5e0a250aa99ac800fd4c3d57d9984beec6f54348c78bd7a13f5265cae6a390d137494348e
7
- data.tar.gz: de6f0ff8c15da14e155850368884cd14830dce374505ad098f8c3a902865d16ad7d42bd17a38ba51561b3e2fb829e73b03abf2530f16873f6249b1782da4ccc5
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
- if Rails.env.production?
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 production"
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] == :danger ? "#fc8181" : "#f6ad55" %>
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
- # Node types and their risk/education metadata
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
- Result = Struct.new(
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 = ActiveRecord::Base.connection.execute(
160
+ raw = exec_in_read_only_transaction(
142
161
  "EXPLAIN (FORMAT JSON, ANALYZE true, BUFFERS false) #{safe_sql}"
143
- ).first["QUERY PLAN"]
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
- SUPPORTED_ENVIRONMENTS.include?(Rails.env.to_s)
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 development and test environments.",
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) && !Rails.env.production?
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
@@ -16,7 +16,10 @@ module RailsVitals
16
16
 
17
17
  initializer "rails_vitals.mcp" do
18
18
  if RailsVitals.config.mcp_enabled
19
- raise "RailsVitals MCP cannot run in production" if Rails.env.production?
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
- result = Analyzers::ExplainAnalyzer.analyze(sql.strip)
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/
@@ -1,3 +1,3 @@
1
1
  module RailsVitals
2
- VERSION = "0.6.1"
2
+ VERSION = "0.6.3"
3
3
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: rails_vitals
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.6.1
4
+ version: 0.6.3
5
5
  platform: ruby
6
6
  authors:
7
7
  - David Sanchez