Class: RailsVitals::Analyzers::ExplainAnalyzer

Inherits:
Object
  • Object
show all
Defined in:
lib/rails_vitals/analyzers/explain_analyzer.rb

Defined Under Namespace

Classes: PlanNode, Result

Constant Summary collapse

SUPPORTED_ENVIRONMENTS =
%w[development test].freeze
COLOR_DANGER =
"#fc8181"
COLOR_HEALTHY =
"#68d391"
COLOR_WARNING =
"#f6ad55"
COLOR_INFO =
"#9f7aea"
COLOR_NEUTRAL =
"#a0aec0"
COLOR_COOL =
"#76e4f7"
NODE_METADATA =
{
  "Seq Scan" => {
    risk: :danger,
    color: COLOR_DANGER,
    label: "Sequential Scan",
    explanation: "PostgreSQL read every row in the table to find matches. " \
                 "No index was used. This gets linearly slower as the table grows, " \
                 "at 1M rows it scans 1M rows for every query hit.",
    fix_type: :missing_index
  },
  "Index Scan" => {
    risk: :healthy,
    color: COLOR_HEALTHY,
    label: "Index Scan",
    explanation: "PostgreSQL used an index to locate matching rows directly. " \
                 "Fast and consistent regardless of table size.",
    fix_type: nil
  },
  "Index Only Scan" => {
    risk: :healthy,
    color: COLOR_HEALTHY,
    label: "Index Only Scan",
    explanation: "PostgreSQL satisfied the query entirely from the index " \
                 "without touching the table. The most efficient scan type.",
    fix_type: nil
  },
  "Bitmap Heap Scan" => {
    risk: :neutral,
    color: COLOR_WARNING,
    label: "Bitmap Heap Scan",
    explanation: "PostgreSQL built a bitmap of matching index entries, then " \
                 "fetched the actual rows. Common with IN (...) conditions and " \
                 "multiple indexes. Generally acceptable.",
    fix_type: nil
  },
  "Bitmap Index Scan" => {
    risk: :neutral,
    color: COLOR_WARNING,
    label: "Bitmap Index Scan",
    explanation: "Builds a bitmap of row locations from an index. " \
                 "Works in conjunction with Bitmap Heap Scan.",
    fix_type: nil
  },
  "Hash Join" => {
    risk: :neutral,
    color: COLOR_INFO,
    label: "Hash Join",
    explanation: "Builds a hash table from one side of the JOIN, then probes " \
                 "it for each row from the other side. Common with includes() " \
                 "and eager_load(). Efficient for large datasets.",
    fix_type: nil
  },
  "Nested Loop" => {
    risk: :warning,
    color: COLOR_WARNING,
    label: "Nested Loop",
    explanation: "For each row on the outer side, scans the inner side. " \
                 "Fast when the inner side is small or uses an index. " \
                 "Dangerous when both sides are large, O(n²) complexity.",
    fix_type: :check_join_indexes
  },
  "Merge Join" => {
    risk: :neutral,
    color: COLOR_COOL,
    label: "Merge Join",
    explanation: "Joins two pre-sorted datasets by merging them in order. " \
                 "Efficient when both sides are already sorted on the join key.",
    fix_type: nil
  },
  "Aggregate" => {
    risk: :neutral,
    color: COLOR_NEUTRAL,
    label: "Aggregate",
    explanation: "Computes an aggregate function (COUNT, SUM, AVG, etc.) " \
                 "over a set of rows. Generated by .count, .sum, .average in Rails.",
    fix_type: nil
  },
  "Hash" => {
    risk: :neutral,
    color: COLOR_NEUTRAL,
    label: "Hash",
    explanation: "Builds a hash table in memory for use by a Hash Join node above it.",
    fix_type: nil
  },
  "Sort" => {
    risk: :warning,
    color: COLOR_WARNING,
    label: "Sort",
    explanation: "PostgreSQL sorted rows in memory (or on disk if large). " \
                 "Generated by ORDER BY on an unindexed column. " \
                 "An index on the sort column eliminates this node entirely.",
    fix_type: :index_sort_column
  },
  "Limit" => {
    risk: :healthy,
    color: COLOR_HEALTHY,
    label: "Limit",
    explanation: "Stops fetching rows once the LIMIT is reached. " \
                 "Good, always paginate large result sets.",
    fix_type: nil
  }
}.freeze
ALLOWED_FUNCTIONS =
%w[
  COUNT SUM AVG MIN MAX
  COALESCE NULLIF
  NOW CURRENT_TIMESTAMP CURRENT_DATE CURRENT_TIME LOCALTIMESTAMP LOCALTIME
  EXTRACT DATE_PART DATE_TRUNC AGE
  UPPER LOWER TRIM LENGTH SUBSTRING CONCAT REPLACE POSITION REVERSE
  ROUND FLOOR CEIL ABS MOD POWER SQRT
  GREATEST LEAST CAST
  ROW_NUMBER RANK DENSE_RANK LAG LEAD FIRST_VALUE LAST_VALUE NTILE
  ARRAY_AGG STRING_AGG
  JSON_EXTRACT_PATH_TEXT JSONB_EXTRACT_PATH
].freeze
FUNCTION_CALL_PATTERN =
/\b(?!#{ALLOWED_FUNCTIONS.join('|')}\b)[a-zA-Z_]\w*\s*\(/

Class Method Summary collapse

Class Method Details

.analyze(sql, binds: []) ⇒ Object



152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
# File 'lib/rails_vitals/analyzers/explain_analyzer.rb', line 152

def self.analyze(sql, binds: [])
  return unsupported_env unless supported_environment?
  return unsupported_sql unless select_query?(sql)

  safe_sql = substitute_binds(sql, binds)
  has_function_calls = contains_function_calls?(safe_sql)

  dry_run_result = dry_run(safe_sql)
  return dry_run_result if dry_run_result.error

  raw = exec_in_read_only_transaction(
    "EXPLAIN (FORMAT JSON, ANALYZE true, BUFFERS false) #{safe_sql}"
  )

  plan_json = JSON.parse(raw)
  root_plan = plan_json.first["Plan"]
  exec_time = plan_json.first["Execution Time"]&.round(2)

  root_node = build_node(root_plan)
  warnings = extract_warnings(root_node)
  suggestions = build_suggestions(warnings, root_node)

  if has_function_calls
    warnings << {
      type: :function_call,
      severity: :info,
      message: "Query contains function calls — executed inside a read-only transaction that prevents writes"
    }
  end

  result = Result.new(
    sql: safe_sql,
    plan: root_node,
    warnings: warnings,
    suggestions: suggestions,
    total_cost: root_plan["Total Cost"]&.round(2),
    actual_time_ms: exec_time,
    rows_examined: count_rows_examined(root_plan),
    function_calls: has_function_calls,
    dry_run_result: dry_run_result,
    error: nil
  )

  result.interpretation = interpret(result)
  result
rescue => e
  Result.new(error: e.message, sql: sql, plan: nil,
             warnings: [], suggestions: [], total_cost: nil,
             actual_time_ms: nil, rows_examined: nil)
end

.build_node(plan) ⇒ Object



276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
# File 'lib/rails_vitals/analyzers/explain_analyzer.rb', line 276

def self.build_node(plan)
  meta = NODE_METADATA[plan["Node Type"]] || {
    risk: :neutral, color: COLOR_NEUTRAL,
    label: plan["Node Type"], explanation: nil, fix_type: nil
  }

  PlanNode.new(
    node_type: plan["Node Type"],
    relation: plan["Relation Name"],
    alias_name: plan["Alias"],
    startup_cost: plan["Startup Cost"]&.round(2),
    total_cost: plan["Total Cost"]&.round(2),
    actual_startup_ms: plan["Actual Startup Time"]&.round(2),
    actual_total_ms: plan["Actual Total Time"]&.round(2),
    plan_rows: plan["Plan Rows"],
    actual_rows: plan["Actual Rows"],
    filter: plan["Filter"],
    index_name: plan["Index Name"],
    index_condition: plan["Index Cond"],
    rows_removed_by_filter: plan["Rows Removed by Filter"],
    loops: plan["Actual Loops"],
    plan_width: plan["Plan Width"],
    metadata: meta,
    children: Array(plan["Plans"]).map { |p| build_node(p) }
  )
end

.build_suggestions(warnings, root_node) ⇒ Object



334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
# File 'lib/rails_vitals/analyzers/explain_analyzer.rb', line 334

def self.build_suggestions(warnings, root_node)
  suggestions = []

  warnings.each do |w|
    case w[:type]
    when :sequential_scan
      fk = extract_fk_from_filter(w[:filter])
      suggestions << {
        severity: :danger,
        title: "Add index on #{w[:table]}#{fk ? ".#{fk}" : ""}",
        body: "PostgreSQL scanned #{w[:rows].to_i + w[:removed].to_i} rows " \
              "to return #{w[:rows]} — no index was used on #{w[:table]}.",
        migration: fk ?
          "add_index :#{w[:table]}, :#{fk}" :
          "add_index :#{w[:table]}, :COLUMN_NAME",
        command: "rails g migration Add#{fk&.camelize || 'Index'}To#{w[:table].camelize}"
      }
    when :sort_without_index
      suggestions << {
        severity: :warning,
        title: "Add index for ORDER BY",
        body: "A Sort node appeared — PostgreSQL sorted rows in memory because " \
              "the ORDER BY column has no index.",
        migration: "add_index :#{w[:table]}, :SORT_COLUMN",
        command: nil
      }
    when :large_nested_loop
      suggestions << {
        severity: :warning,
        title: "Large Nested Loop — check join indexes",
        body: "A Nested Loop processed #{w[:rows]} rows. " \
              "Ensure the inner side of the join has an index on the join column.",
        migration: nil,
        command: nil
      }
    end
  end

  suggestions
end

.contains_function_calls?(sql) ⇒ Boolean

Returns:

  • (Boolean)


235
236
237
238
# File 'lib/rails_vitals/analyzers/explain_analyzer.rb', line 235

def self.contains_function_calls?(sql)
  clean = sql.gsub(/'.*?(?:''|')/, "").gsub(/".*?"/, "")
  clean.match?(FUNCTION_CALL_PATTERN)
end

.count_rows_examined(plan) ⇒ Object



382
383
384
385
386
387
388
389
390
391
392
# File 'lib/rails_vitals/analyzers/explain_analyzer.rb', line 382

def self.count_rows_examined(plan)
  children = Array(plan["Plans"])

  if children.empty?
    # Leaf node, actual table read
    plan["Actual Rows"].to_i + plan["Rows Removed by Filter"].to_i
  else
    # Intermediate node, recurse only into children
    children.sum { |p| count_rows_examined(p) }
  end
end

.dry_run(sql, binds: []) ⇒ Object



203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
# File 'lib/rails_vitals/analyzers/explain_analyzer.rb', line 203

def self.dry_run(sql, binds: [])
  safe_sql = substitute_binds(sql, binds)

  raw = exec_in_read_only_transaction(
    "EXPLAIN (FORMAT JSON, ANALYZE false, BUFFERS false) #{safe_sql}"
  )

  plan_json = JSON.parse(raw)
  root_plan = plan_json.first["Plan"]

  Result.new(
    sql: safe_sql,
    total_cost: root_plan["Total Cost"]&.round(2),
    plan: build_node(root_plan),
    error: nil
  )
rescue => e
  Result.new(error: e.message, sql: sql, plan: nil,
             warnings: [], suggestions: [], total_cost: nil,
             actual_time_ms: nil, rows_examined: nil)
end

.exec_in_read_only_transaction(sql) ⇒ Object



240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
# File 'lib/rails_vitals/analyzers/explain_analyzer.rb', line 240

def self.exec_in_read_only_transaction(sql)
  if postgresql?
    connection = ActiveRecord::Base.connection
    connection.execute("BEGIN")
    connection.execute("SET TRANSACTION READ ONLY")
    begin
      result = connection.execute(sql)
      connection.execute("COMMIT")
      result.first["QUERY PLAN"]
    rescue => e
      connection.execute("ROLLBACK") rescue nil
      raise e
    end
  else
    ActiveRecord::Base.connection.execute(sql).first["QUERY PLAN"]
  end
end

.extract_fk_from_filter(filter) ⇒ Object



375
376
377
378
379
380
# File 'lib/rails_vitals/analyzers/explain_analyzer.rb', line 375

def self.extract_fk_from_filter(filter)
  return nil unless filter

  match = filter.match(/\((\w+)\s*=\s*/i)
  match ? match[1] : nil
end

.extract_warnings(node, warnings = []) ⇒ Object



303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
# File 'lib/rails_vitals/analyzers/explain_analyzer.rb', line 303

def self.extract_warnings(node, warnings = [])
  case node.node_type
  when "Seq Scan"
    warnings << {
      type: :sequential_scan,
      table: node.relation,
      filter: node.filter,
      rows: node.actual_rows,
      removed: node.rows_removed_by_filter,
      severity: :danger
    }
  when "Sort"
    warnings << {
      type: :sort_without_index,
      table: node.relation,
      severity: :warning
    }
  when "Nested Loop"
    if node.actual_rows.to_i > 1000
      warnings << {
        type: :large_nested_loop,
        rows: node.actual_rows,
        severity: :warning
      }
    end
  end

  node.children.each { |child| extract_warnings(child, warnings) }
  warnings
end

.interpret(result) ⇒ Object



410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
# File 'lib/rails_vitals/analyzers/explain_analyzer.rb', line 410

def self.interpret(result)
  return nil if result.error

  parts = []

  if result.warnings.any? { |w| w[:type] == :sequential_scan }
    parts << "Sequential scan detected — missing index is causing full table reads"
  end

  if result.function_calls
    parts << "Query uses function calls — executed in a read-only transaction that prevents writes"
  end

  if result.actual_time_ms.to_f > 100
    parts << "query took #{result.actual_time_ms}ms — above the 100ms warning threshold"
  end

  if result.plan&.plan_width.to_i > 200
    parts << "row width is #{result.plan.plan_width}B — consider using .select(:col) instead of SELECT *"
  end

  parts.empty? ? "Plan looks healthy — index used, no warnings." : parts.join(". ")
end

.postgresql?Boolean

Returns:

  • (Boolean)


258
259
260
261
262
# File 'lib/rails_vitals/analyzers/explain_analyzer.rb', line 258

def self.postgresql?
  ActiveRecord::Base.connection.adapter_name.match?(/postgresql/i)
rescue
  false
end

.select_query?(sql) ⇒ Boolean

Returns:

  • (Boolean)


231
232
233
# File 'lib/rails_vitals/analyzers/explain_analyzer.rb', line 231

def self.select_query?(sql)
  sql.strip.match?(/\ASELECT/i)
end

.substitute_binds(sql, binds) ⇒ Object



264
265
266
267
268
269
270
271
272
273
274
# File 'lib/rails_vitals/analyzers/explain_analyzer.rb', line 264

def self.substitute_binds(sql, binds)
  # Replace $1, $2 placeholders with safe literal values
  result = sql.dup
  binds.each_with_index do |bind, i|
    value = bind.is_a?(String) ? "'#{bind.gsub("'", "''")}'" : bind.to_s
    result = result.gsub("$#{i + 1}", value)
  end

  # Replace any remaining ? placeholders with NULL
  result.gsub("?", "NULL")
end

.supported_environment?Boolean

Returns:

  • (Boolean)


227
228
229
# File 'lib/rails_vitals/analyzers/explain_analyzer.rb', line 227

def self.supported_environment?
  SUPPORTED_ENVIRONMENTS.include?(Rails.env.to_s)
end

.unsupported_envObject



394
395
396
397
398
399
400
# File 'lib/rails_vitals/analyzers/explain_analyzer.rb', line 394

def self.unsupported_env
  Result.new(
    error: "EXPLAIN is only available in development and test environments.",
    sql: nil, plan: nil, warnings: [], suggestions: [],
    total_cost: nil, actual_time_ms: nil, rows_examined: nil
  )
end

.unsupported_sqlObject



402
403
404
405
406
407
408
# File 'lib/rails_vitals/analyzers/explain_analyzer.rb', line 402

def self.unsupported_sql
  Result.new(
    error: "EXPLAIN is only available for SELECT queries.",
    sql: nil, plan: nil, warnings: [], suggestions: [],
    total_cost: nil, actual_time_ms: nil, rows_examined: nil
  )
end