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.
- checksums.yaml +4 -4
- data/README.md +6 -0
- data/app/assets/javascripts/rails_vitals/application.js +161 -0
- data/app/assets/stylesheets/rails_vitals/application.css +175 -0
- data/app/controllers/rails_vitals/explains_controller.rb +16 -0
- data/app/controllers/rails_vitals/n_plus_ones_controller.rb +0 -1
- data/app/controllers/rails_vitals/requests_controller.rb +1 -1
- data/app/helpers/rails_vitals/application_helper.rb +28 -0
- data/app/views/layouts/rails_vitals/application.html.erb +1 -0
- data/app/views/rails_vitals/associations/index.html.erb +41 -189
- data/app/views/rails_vitals/dashboard/index.html.erb +6 -6
- data/app/views/rails_vitals/explains/_plan_node.html.erb +137 -0
- data/app/views/rails_vitals/explains/show.html.erb +186 -0
- data/app/views/rails_vitals/heatmap/index.html.erb +7 -7
- data/app/views/rails_vitals/models/index.html.erb +19 -36
- data/app/views/rails_vitals/n_plus_ones/index.html.erb +9 -9
- data/app/views/rails_vitals/n_plus_ones/show.html.erb +30 -76
- data/app/views/rails_vitals/requests/index.html.erb +5 -5
- data/app/views/rails_vitals/requests/show.html.erb +82 -165
- data/config/routes.rb +1 -0
- data/lib/rails_vitals/analyzers/explain_analyzer.rb +347 -0
- data/lib/rails_vitals/collector.rb +2 -1
- data/lib/rails_vitals/notifications/subscriber.rb +2 -1
- data/lib/rails_vitals/version.rb +1 -1
- data/lib/rails_vitals.rb +1 -0
- metadata +8 -9
|
@@ -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
|
data/lib/rails_vitals/version.rb
CHANGED
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.
|
|
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:
|
|
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:
|
|
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
|