Class: RailsVitals::Analyzers::ExplainAnalyzer
- Inherits:
-
Object
- Object
- RailsVitals::Analyzers::ExplainAnalyzer
- Defined in:
- lib/rails_vitals/analyzers/explain_analyzer.rb
Defined Under Namespace
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
- .analyze(sql, binds: []) ⇒ Object
- .build_node(plan) ⇒ Object
- .build_suggestions(warnings, root_node) ⇒ Object
- .count_rows_examined(plan) ⇒ Object
- .extract_fk_from_filter(filter) ⇒ Object
- .extract_warnings(node, warnings = []) ⇒ Object
- .interpret(result) ⇒ Object
- .select_query?(sql) ⇒ Boolean
- .substitute_binds(sql, binds) ⇒ Object
- .supported_environment? ⇒ Boolean
- .unsupported_env ⇒ Object
- .unsupported_sql ⇒ Object
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., 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) = 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: , 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
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
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_env ⇒ Object
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_sql ⇒ Object
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 |