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 =

Node types and their risk/education 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

Class Method Summary collapse

Class Method Details

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



135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
# File 'lib/rails_vitals/analyzers/explain_analyzer.rb', line 135

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

  safe_sql = substitute_binds(sql, binds)

  raw = ActiveRecord::Base.connection.execute(
    "EXPLAIN (FORMAT JSON, ANALYZE true, BUFFERS false) #{safe_sql}"
  ).first["QUERY PLAN"]

  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)

  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),
    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



194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
# File 'lib/rails_vitals/analyzers/explain_analyzer.rb', line 194

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



252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
# File 'lib/rails_vitals/analyzers/explain_analyzer.rb', line 252

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

.count_rows_examined(plan) ⇒ Object



300
301
302
303
304
305
306
307
308
309
310
# File 'lib/rails_vitals/analyzers/explain_analyzer.rb', line 300

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

.extract_fk_from_filter(filter) ⇒ Object



293
294
295
296
297
298
# File 'lib/rails_vitals/analyzers/explain_analyzer.rb', line 293

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



221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
# File 'lib/rails_vitals/analyzers/explain_analyzer.rb', line 221

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



328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
# File 'lib/rails_vitals/analyzers/explain_analyzer.rb', line 328

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.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

.select_query?(sql) ⇒ Boolean

Returns:

  • (Boolean)


178
179
180
# File 'lib/rails_vitals/analyzers/explain_analyzer.rb', line 178

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

.substitute_binds(sql, binds) ⇒ Object



182
183
184
185
186
187
188
189
190
191
192
# File 'lib/rails_vitals/analyzers/explain_analyzer.rb', line 182

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)


174
175
176
# File 'lib/rails_vitals/analyzers/explain_analyzer.rb', line 174

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

.unsupported_envObject



312
313
314
315
316
317
318
# File 'lib/rails_vitals/analyzers/explain_analyzer.rb', line 312

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



320
321
322
323
324
325
326
# File 'lib/rails_vitals/analyzers/explain_analyzer.rb', line 320

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