rails_vitals 0.2.0 → 0.3.0

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.
@@ -0,0 +1,347 @@
1
+ module RailsVitals
2
+ module Analyzers
3
+ class ExplainAnalyzer
4
+ SUPPORTED_ENVIRONMENTS = %w[development test].freeze
5
+
6
+ COLOR_DANGER = "#fc8181"
7
+ COLOR_HEALTHY = "#68d391"
8
+ COLOR_WARNING = "#f6ad55"
9
+ COLOR_INFO = "#9f7aea"
10
+ COLOR_NEUTRAL = "#a0aec0"
11
+ COLOR_COOL = "#76e4f7"
12
+
13
+ # Node types and their risk/education metadata
14
+ NODE_METADATA = {
15
+ "Seq Scan" => {
16
+ risk: :danger,
17
+ color: COLOR_DANGER,
18
+ label: "Sequential Scan",
19
+ explanation: "PostgreSQL read every row in the table to find matches. " \
20
+ "No index was used. This gets linearly slower as the table grows, " \
21
+ "at 1M rows it scans 1M rows for every query hit.",
22
+ fix_type: :missing_index
23
+ },
24
+ "Index Scan" => {
25
+ risk: :healthy,
26
+ color: COLOR_HEALTHY,
27
+ label: "Index Scan",
28
+ explanation: "PostgreSQL used an index to locate matching rows directly. " \
29
+ "Fast and consistent regardless of table size.",
30
+ fix_type: nil
31
+ },
32
+ "Index Only Scan" => {
33
+ risk: :healthy,
34
+ color: COLOR_HEALTHY,
35
+ label: "Index Only Scan",
36
+ explanation: "PostgreSQL satisfied the query entirely from the index " \
37
+ "without touching the table. The most efficient scan type.",
38
+ fix_type: nil
39
+ },
40
+ "Bitmap Heap Scan" => {
41
+ risk: :neutral,
42
+ color: COLOR_WARNING,
43
+ label: "Bitmap Heap Scan",
44
+ explanation: "PostgreSQL built a bitmap of matching index entries, then " \
45
+ "fetched the actual rows. Common with IN (...) conditions and " \
46
+ "multiple indexes. Generally acceptable.",
47
+ fix_type: nil
48
+ },
49
+ "Bitmap Index Scan" => {
50
+ risk: :neutral,
51
+ color: COLOR_WARNING,
52
+ label: "Bitmap Index Scan",
53
+ explanation: "Builds a bitmap of row locations from an index. " \
54
+ "Works in conjunction with Bitmap Heap Scan.",
55
+ fix_type: nil
56
+ },
57
+ "Hash Join" => {
58
+ risk: :neutral,
59
+ color: COLOR_INFO,
60
+ label: "Hash Join",
61
+ explanation: "Builds a hash table from one side of the JOIN, then probes " \
62
+ "it for each row from the other side. Common with includes() " \
63
+ "and eager_load(). Efficient for large datasets.",
64
+ fix_type: nil
65
+ },
66
+ "Nested Loop" => {
67
+ risk: :warning,
68
+ color: COLOR_WARNING,
69
+ label: "Nested Loop",
70
+ explanation: "For each row on the outer side, scans the inner side. " \
71
+ "Fast when the inner side is small or uses an index. " \
72
+ "Dangerous when both sides are large, O(n²) complexity.",
73
+ fix_type: :check_join_indexes
74
+ },
75
+ "Merge Join" => {
76
+ risk: :neutral,
77
+ color: COLOR_COOL,
78
+ label: "Merge Join",
79
+ explanation: "Joins two pre-sorted datasets by merging them in order. " \
80
+ "Efficient when both sides are already sorted on the join key.",
81
+ fix_type: nil
82
+ },
83
+ "Aggregate" => {
84
+ risk: :neutral,
85
+ color: COLOR_NEUTRAL,
86
+ label: "Aggregate",
87
+ explanation: "Computes an aggregate function (COUNT, SUM, AVG, etc.) " \
88
+ "over a set of rows. Generated by .count, .sum, .average in Rails.",
89
+ fix_type: nil
90
+ },
91
+ "Hash" => {
92
+ risk: :neutral,
93
+ color: COLOR_NEUTRAL,
94
+ label: "Hash",
95
+ explanation: "Builds a hash table in memory for use by a Hash Join node above it.",
96
+ fix_type: nil
97
+ },
98
+ "Sort" => {
99
+ risk: :warning,
100
+ color: COLOR_WARNING,
101
+ label: "Sort",
102
+ explanation: "PostgreSQL sorted rows in memory (or on disk if large). " \
103
+ "Generated by ORDER BY on an unindexed column. " \
104
+ "An index on the sort column eliminates this node entirely.",
105
+ fix_type: :index_sort_column
106
+ },
107
+ "Limit" => {
108
+ risk: :healthy,
109
+ color: COLOR_HEALTHY,
110
+ label: "Limit",
111
+ explanation: "Stops fetching rows once the LIMIT is reached. " \
112
+ "Good, always paginate large result sets.",
113
+ fix_type: nil
114
+ }
115
+ }.freeze
116
+
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
+ )
123
+
124
+ PlanNode = Struct.new(
125
+ :node_type, :relation, :alias_name,
126
+ :startup_cost, :total_cost,
127
+ :actual_startup_ms, :actual_total_ms,
128
+ :plan_rows, :actual_rows,
129
+ :filter, :index_name, :index_condition,
130
+ :rows_removed_by_filter, :loops,
131
+ :plan_width, :metadata, :children,
132
+ keyword_init: true
133
+ )
134
+
135
+ def self.analyze(sql, binds: [])
136
+ return unsupported_env unless supported_environment?
137
+ return unsupported_sql unless select_query?(sql)
138
+
139
+ safe_sql = substitute_binds(sql, binds)
140
+
141
+ raw = ActiveRecord::Base.connection.execute(
142
+ "EXPLAIN (FORMAT JSON, ANALYZE true, BUFFERS false) #{safe_sql}"
143
+ ).first["QUERY PLAN"]
144
+
145
+ plan_json = JSON.parse(raw)
146
+ root_plan = plan_json.first["Plan"]
147
+ exec_time = plan_json.first["Execution Time"]&.round(2)
148
+
149
+ root_node = build_node(root_plan)
150
+ warnings = extract_warnings(root_node)
151
+ suggestions = build_suggestions(warnings, root_node)
152
+
153
+ result = Result.new(
154
+ sql: safe_sql,
155
+ plan: root_node,
156
+ warnings: warnings,
157
+ suggestions: suggestions,
158
+ total_cost: root_plan["Total Cost"]&.round(2),
159
+ actual_time_ms: exec_time,
160
+ rows_examined: count_rows_examined(root_plan),
161
+ error: nil
162
+ )
163
+
164
+ result.interpretation = interpret(result)
165
+ result
166
+ rescue => e
167
+ Result.new(error: e.message, sql: sql, plan: nil,
168
+ warnings: [], suggestions: [], total_cost: nil,
169
+ actual_time_ms: nil, rows_examined: nil)
170
+ end
171
+
172
+ private
173
+
174
+ def self.supported_environment?
175
+ SUPPORTED_ENVIRONMENTS.include?(Rails.env.to_s)
176
+ end
177
+
178
+ def self.select_query?(sql)
179
+ sql.strip.match?(/\ASELECT/i)
180
+ end
181
+
182
+ def self.substitute_binds(sql, binds)
183
+ # Replace $1, $2 placeholders with safe literal values
184
+ result = sql.dup
185
+ binds.each_with_index do |bind, i|
186
+ value = bind.is_a?(String) ? "'#{bind.gsub("'", "''")}'" : bind.to_s
187
+ result = result.gsub("$#{i + 1}", value)
188
+ end
189
+ # Replace any remaining ? placeholders with NULL
190
+ result.gsub("?", "NULL")
191
+ end
192
+
193
+ def self.build_node(plan)
194
+ meta = NODE_METADATA[plan["Node Type"]] || {
195
+ risk: :neutral, color: COLOR_NEUTRAL,
196
+ label: plan["Node Type"], explanation: nil, fix_type: nil
197
+ }
198
+
199
+ PlanNode.new(
200
+ node_type: plan["Node Type"],
201
+ relation: plan["Relation Name"],
202
+ alias_name: plan["Alias"],
203
+ startup_cost: plan["Startup Cost"]&.round(2),
204
+ total_cost: plan["Total Cost"]&.round(2),
205
+ actual_startup_ms: plan["Actual Startup Time"]&.round(2),
206
+ actual_total_ms: plan["Actual Total Time"]&.round(2),
207
+ plan_rows: plan["Plan Rows"],
208
+ actual_rows: plan["Actual Rows"],
209
+ filter: plan["Filter"],
210
+ index_name: plan["Index Name"],
211
+ index_condition: plan["Index Cond"],
212
+ rows_removed_by_filter: plan["Rows Removed by Filter"],
213
+ loops: plan["Actual Loops"],
214
+ plan_width: plan["Plan Width"],
215
+ metadata: meta,
216
+ children: Array(plan["Plans"]).map { |p| build_node(p) }
217
+ )
218
+ end
219
+
220
+ def self.extract_warnings(node, warnings = [])
221
+ case node.node_type
222
+ when "Seq Scan"
223
+ warnings << {
224
+ type: :sequential_scan,
225
+ table: node.relation,
226
+ filter: node.filter,
227
+ rows: node.actual_rows,
228
+ removed: node.rows_removed_by_filter,
229
+ severity: :danger
230
+ }
231
+ when "Sort"
232
+ warnings << {
233
+ type: :sort_without_index,
234
+ table: node.relation,
235
+ severity: :warning
236
+ }
237
+ when "Nested Loop"
238
+ if node.actual_rows.to_i > 1000
239
+ warnings << {
240
+ type: :large_nested_loop,
241
+ rows: node.actual_rows,
242
+ severity: :warning
243
+ }
244
+ end
245
+ end
246
+
247
+ node.children.each { |child| extract_warnings(child, warnings) }
248
+ warnings
249
+ end
250
+
251
+ def self.build_suggestions(warnings, root_node)
252
+ suggestions = []
253
+
254
+ warnings.each do |w|
255
+ case w[:type]
256
+ when :sequential_scan
257
+ fk = extract_fk_from_filter(w[:filter])
258
+ suggestions << {
259
+ severity: :danger,
260
+ title: "Add index on #{w[:table]}#{fk ? ".#{fk}" : ""}",
261
+ body: "PostgreSQL scanned #{w[:rows].to_i + w[:removed].to_i} rows " \
262
+ "to return #{w[:rows]} — no index was used on #{w[:table]}.",
263
+ migration: fk ?
264
+ "add_index :#{w[:table]}, :#{fk}" :
265
+ "add_index :#{w[:table]}, :COLUMN_NAME",
266
+ command: "rails g migration Add#{fk&.camelize || 'Index'}To#{w[:table].camelize}"
267
+ }
268
+ when :sort_without_index
269
+ suggestions << {
270
+ severity: :warning,
271
+ title: "Add index for ORDER BY",
272
+ body: "A Sort node appeared — PostgreSQL sorted rows in memory because " \
273
+ "the ORDER BY column has no index.",
274
+ migration: "add_index :#{w[:table]}, :SORT_COLUMN",
275
+ command: nil
276
+ }
277
+ when :large_nested_loop
278
+ suggestions << {
279
+ severity: :warning,
280
+ title: "Large Nested Loop — check join indexes",
281
+ body: "A Nested Loop processed #{w[:rows]} rows. " \
282
+ "Ensure the inner side of the join has an index on the join column.",
283
+ migration: nil,
284
+ command: nil
285
+ }
286
+ end
287
+ end
288
+
289
+ suggestions
290
+ end
291
+
292
+ def self.extract_fk_from_filter(filter)
293
+ return nil unless filter
294
+ match = filter.match(/\((\w+)\s*=\s*/i)
295
+ match ? match[1] : nil
296
+ end
297
+
298
+ def self.count_rows_examined(plan)
299
+ children = Array(plan["Plans"])
300
+
301
+ if children.empty?
302
+ # Leaf node, actual table read
303
+ plan["Actual Rows"].to_i + plan["Rows Removed by Filter"].to_i
304
+ else
305
+ # Intermediate node, recurse only into children
306
+ children.sum { |p| count_rows_examined(p) }
307
+ end
308
+ end
309
+
310
+ def self.unsupported_env
311
+ Result.new(
312
+ error: "EXPLAIN is only available in development and test environments.",
313
+ sql: nil, plan: nil, warnings: [], suggestions: [],
314
+ total_cost: nil, actual_time_ms: nil, rows_examined: nil
315
+ )
316
+ end
317
+
318
+ def self.unsupported_sql
319
+ Result.new(
320
+ error: "EXPLAIN is only available for SELECT queries.",
321
+ sql: nil, plan: nil, warnings: [], suggestions: [],
322
+ total_cost: nil, actual_time_ms: nil, rows_examined: nil
323
+ )
324
+ end
325
+
326
+ def self.interpret(result)
327
+ return nil if result.error
328
+
329
+ parts = []
330
+
331
+ if result.warnings.any? { |w| w[:type] == :sequential_scan }
332
+ parts << "Sequential scan detected — missing index is causing full table reads"
333
+ end
334
+
335
+ if result.actual_time_ms.to_f > 100
336
+ parts << "query took #{result.actual_time_ms}ms — above the 100ms warning threshold"
337
+ end
338
+
339
+ if result.plan&.plan_width.to_i > 200
340
+ parts << "row width is #{result.plan.plan_width}B — consider using .select(:col) instead of SELECT *"
341
+ end
342
+
343
+ parts.empty? ? "Plan looks healthy — index used, no warnings." : parts.join(". ")
344
+ end
345
+ end
346
+ end
347
+ end
@@ -15,11 +15,12 @@ module RailsVitals
15
15
  end
16
16
 
17
17
  # Called by the sql.active_record subscriber
18
- def add_query(sql:, duration_ms:, source:)
18
+ def add_query(sql:, duration_ms:, source:, binds: [])
19
19
  @queries << {
20
20
  sql: sql,
21
21
  duration_ms: duration_ms,
22
22
  source: source,
23
+ binds: binds,
23
24
  called_at: Time.now
24
25
  }
25
26
  end
@@ -16,7 +16,8 @@ module RailsVitals
16
16
  collector.add_query(
17
17
  sql: event.payload[:sql],
18
18
  duration_ms: event.duration,
19
- source: extract_source(event.payload[:binds])
19
+ source: extract_source(event.payload[:binds]),
20
+ binds: event.payload[:binds]&.map(&:value) || []
20
21
  )
21
22
  end
22
23
  end
@@ -1,3 +1,3 @@
1
1
  module RailsVitals
2
- VERSION = "0.2.0"
2
+ VERSION = "0.3.0"
3
3
  end
data/lib/rails_vitals.rb CHANGED
@@ -8,6 +8,7 @@ require "rails_vitals/instrumentation/callback_instrumentation"
8
8
  require "rails_vitals/analyzers/n_plus_one_aggregator"
9
9
  require "rails_vitals/analyzers/sql_tokenizer"
10
10
  require "rails_vitals/analyzers/association_mapper"
11
+ require "rails_vitals/analyzers/explain_analyzer"
11
12
  require "rails_vitals/scorers/base_scorer"
12
13
  require "rails_vitals/scorers/query_scorer"
13
14
  require "rails_vitals/scorers/n_plus_one_scorer"
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.2.0
4
+ version: 0.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - David Sanchez
@@ -13,22 +13,16 @@ dependencies:
13
13
  name: rails
14
14
  requirement: !ruby/object:Gem::Requirement
15
15
  requirements:
16
- - - "~>"
17
- - !ruby/object:Gem::Version
18
- version: '8.1'
19
16
  - - ">="
20
17
  - !ruby/object:Gem::Version
21
- version: 8.1.2
18
+ version: '7.0'
22
19
  type: :runtime
23
20
  prerelease: false
24
21
  version_requirements: !ruby/object:Gem::Requirement
25
22
  requirements:
26
- - - "~>"
27
- - !ruby/object:Gem::Version
28
- version: '8.1'
29
23
  - - ">="
30
24
  - !ruby/object:Gem::Version
31
- version: 8.1.2
25
+ version: '7.0'
32
26
  description: RailsVitals is a lightweight Rails engine gem that makes the hidden runtime
33
27
  behavior of a Rails application visible, measurable, and teachable. It provides
34
28
  insights into the inner workings of a Rails app, helping developers understand and
@@ -44,10 +38,12 @@ files:
44
38
  - MIT-LICENSE
45
39
  - README.md
46
40
  - Rakefile
41
+ - app/assets/javascripts/rails_vitals/application.js
47
42
  - app/assets/stylesheets/rails_vitals/application.css
48
43
  - app/controllers/rails_vitals/application_controller.rb
49
44
  - app/controllers/rails_vitals/associations_controller.rb
50
45
  - app/controllers/rails_vitals/dashboard_controller.rb
46
+ - app/controllers/rails_vitals/explains_controller.rb
51
47
  - app/controllers/rails_vitals/heatmap_controller.rb
52
48
  - app/controllers/rails_vitals/models_controller.rb
53
49
  - app/controllers/rails_vitals/n_plus_ones_controller.rb
@@ -59,6 +55,8 @@ files:
59
55
  - app/views/layouts/rails_vitals/application.html.erb
60
56
  - app/views/rails_vitals/associations/index.html.erb
61
57
  - app/views/rails_vitals/dashboard/index.html.erb
58
+ - app/views/rails_vitals/explains/_plan_node.html.erb
59
+ - app/views/rails_vitals/explains/show.html.erb
62
60
  - app/views/rails_vitals/heatmap/index.html.erb
63
61
  - app/views/rails_vitals/models/index.html.erb
64
62
  - app/views/rails_vitals/n_plus_ones/index.html.erb
@@ -68,6 +66,7 @@ files:
68
66
  - config/routes.rb
69
67
  - lib/rails_vitals.rb
70
68
  - lib/rails_vitals/analyzers/association_mapper.rb
69
+ - lib/rails_vitals/analyzers/explain_analyzer.rb
71
70
  - lib/rails_vitals/analyzers/n_plus_one_aggregator.rb
72
71
  - lib/rails_vitals/analyzers/sql_tokenizer.rb
73
72
  - lib/rails_vitals/collector.rb