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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: ce21f0b9edbbef2e18452c51755bf980c067c92afb651a86ee99b78aac0cbe6d
4
- data.tar.gz: 0ee7cddd129757762d01cdce2f049622bb6f449e44de05ff5d592bfd4933136d
3
+ metadata.gz: c655986f52ddc0e0848b8e67c28d603ccea2a97dc6b85d9157a7a670fd298fc6
4
+ data.tar.gz: 49bbb1b8fc0041bd75edb7d3d990ed517dcd907f64b7f99af95eb2d33dbdee80
5
5
  SHA512:
6
- metadata.gz: 201b26e862cafbde3dfe07f1011ceb3d8241f362ff5386cbae8edbe5e0a250aa99ac800fd4c3d57d9984beec6f54348c78bd7a13f5265cae6a390d137494348e
7
- data.tar.gz: de6f0ff8c15da14e155850368884cd14830dce374505ad098f8c3a902865d16ad7d42bd17a38ba51561b3e2fb829e73b03abf2530f16873f6249b1782da4ccc5
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] == :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 %>
@@ -10,7 +10,14 @@ module RailsVitals
10
10
  COLOR_NEUTRAL = "#a0aec0"
11
11
  COLOR_COOL = "#76e4f7"
12
12
 
13
- # Node types and their risk/education metadata
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
- Result = Struct.new(
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
- raw = ActiveRecord::Base.connection.execute(
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
- ).first["QUERY PLAN"]
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
- 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
@@ -1,3 +1,3 @@
1
1
  module RailsVitals
2
- VERSION = "0.6.1"
2
+ VERSION = "0.6.2"
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.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - David Sanchez