rails_vitals 0.2.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 +7 -0
- data/MIT-LICENSE +20 -0
- data/README.md +340 -0
- data/Rakefile +6 -0
- data/app/assets/stylesheets/rails_vitals/application.css +180 -0
- data/app/controllers/rails_vitals/application_controller.rb +30 -0
- data/app/controllers/rails_vitals/associations_controller.rb +8 -0
- data/app/controllers/rails_vitals/dashboard_controller.rb +59 -0
- data/app/controllers/rails_vitals/heatmap_controller.rb +39 -0
- data/app/controllers/rails_vitals/models_controller.rb +65 -0
- data/app/controllers/rails_vitals/n_plus_ones_controller.rb +43 -0
- data/app/controllers/rails_vitals/requests_controller.rb +44 -0
- data/app/helpers/rails_vitals/application_helper.rb +63 -0
- data/app/jobs/rails_vitals/application_job.rb +4 -0
- data/app/mailers/rails_vitals/application_mailer.rb +6 -0
- data/app/models/rails_vitals/application_record.rb +5 -0
- data/app/views/layouts/rails_vitals/application.html.erb +27 -0
- data/app/views/rails_vitals/associations/index.html.erb +370 -0
- data/app/views/rails_vitals/dashboard/index.html.erb +158 -0
- data/app/views/rails_vitals/heatmap/index.html.erb +66 -0
- data/app/views/rails_vitals/models/index.html.erb +117 -0
- data/app/views/rails_vitals/n_plus_ones/index.html.erb +49 -0
- data/app/views/rails_vitals/n_plus_ones/show.html.erb +139 -0
- data/app/views/rails_vitals/requests/index.html.erb +60 -0
- data/app/views/rails_vitals/requests/show.html.erb +396 -0
- data/config/routes.rb +9 -0
- data/lib/rails_vitals/analyzers/association_mapper.rb +121 -0
- data/lib/rails_vitals/analyzers/n_plus_one_aggregator.rb +116 -0
- data/lib/rails_vitals/analyzers/sql_tokenizer.rb +240 -0
- data/lib/rails_vitals/collector.rb +78 -0
- data/lib/rails_vitals/configuration.rb +27 -0
- data/lib/rails_vitals/engine.rb +25 -0
- data/lib/rails_vitals/instrumentation/callback_instrumentation.rb +30 -0
- data/lib/rails_vitals/middleware/panel_injector.rb +75 -0
- data/lib/rails_vitals/notifications/subscriber.rb +59 -0
- data/lib/rails_vitals/panel_renderer.rb +233 -0
- data/lib/rails_vitals/request_record.rb +51 -0
- data/lib/rails_vitals/scorers/base_scorer.rb +25 -0
- data/lib/rails_vitals/scorers/composite_scorer.rb +36 -0
- data/lib/rails_vitals/scorers/n_plus_one_scorer.rb +43 -0
- data/lib/rails_vitals/scorers/query_scorer.rb +42 -0
- data/lib/rails_vitals/store.rb +34 -0
- data/lib/rails_vitals/version.rb +3 -0
- data/lib/rails_vitals.rb +33 -0
- data/lib/tasks/rails_vitals_tasks.rake +4 -0
- metadata +113 -0
|
@@ -0,0 +1,396 @@
|
|
|
1
|
+
<div style="display:flex;align-items:center;gap:16px;margin-bottom:20px;">
|
|
2
|
+
<%= link_to "← Back", rails_vitals.requests_path, style: "color:#a0aec0;font-size:12px;" %>
|
|
3
|
+
<div class="page-title" style="margin:0;">Request Detail</div>
|
|
4
|
+
</div>
|
|
5
|
+
|
|
6
|
+
<%# Header %>
|
|
7
|
+
<div class="card" style="display:flex;align-items:center;justify-content:space-between;">
|
|
8
|
+
<div>
|
|
9
|
+
<div style="font-size:42px;font-weight:bold;color:<%= score_color(@record.color) %>;">
|
|
10
|
+
<%= @record.score %>
|
|
11
|
+
<span style="font-size:16px;color:#a0aec0;">/ 100</span>
|
|
12
|
+
</div>
|
|
13
|
+
</div>
|
|
14
|
+
<div style="text-align:right;">
|
|
15
|
+
<span class="badge badge-<%= @record.color %>" style="font-size:13px;padding:4px 14px;">
|
|
16
|
+
<%= @record.label %>
|
|
17
|
+
</span>
|
|
18
|
+
<div style="color:#a0aec0;font-size:11px;margin-top:6px;"><%= @record.recorded_at.strftime("%Y-%m-%d %H:%M:%S") %></div>
|
|
19
|
+
</div>
|
|
20
|
+
</div>
|
|
21
|
+
|
|
22
|
+
<%# Request Info %>
|
|
23
|
+
<div class="card">
|
|
24
|
+
<div class="card-title">Request Info</div>
|
|
25
|
+
<table>
|
|
26
|
+
<tbody>
|
|
27
|
+
<tr><td style="color:#a0aec0;width:140px;">Endpoint</td><td><%= @record.endpoint %></td></tr>
|
|
28
|
+
<tr><td style="color:#a0aec0;">Method</td> <td><%= @record.http_method %></td></tr>
|
|
29
|
+
<tr><td style="color:#a0aec0;">Status</td> <td><%= @record.response_status %></td></tr>
|
|
30
|
+
<tr><td style="color:#a0aec0;">Duration</td><td><%= @record.duration_ms&.round(1) %>ms</td></tr>
|
|
31
|
+
</tbody>
|
|
32
|
+
</table>
|
|
33
|
+
</div>
|
|
34
|
+
|
|
35
|
+
<%# Query Summary %>
|
|
36
|
+
<div class="card">
|
|
37
|
+
<div class="card-title">Query Summary</div>
|
|
38
|
+
<table>
|
|
39
|
+
<tbody>
|
|
40
|
+
<tr><td style="color:#a0aec0;width:140px;">Total Queries</td><td><%= @record.total_query_count %></td></tr>
|
|
41
|
+
<tr><td style="color:#a0aec0;">DB Time</td> <td><%= @record.total_db_time_ms.round(1) %>ms</td></tr>
|
|
42
|
+
<tr>
|
|
43
|
+
<td style="color:#a0aec0;">N+1 Patterns</td>
|
|
44
|
+
<td>
|
|
45
|
+
<% if @record.n_plus_one_patterns.any? %>
|
|
46
|
+
<span class="n1-badge"><%= @record.n_plus_one_patterns.size %> detected</span>
|
|
47
|
+
<% else %>
|
|
48
|
+
<span style="color:#68d391;">None detected</span>
|
|
49
|
+
<% end %>
|
|
50
|
+
</td>
|
|
51
|
+
</tr>
|
|
52
|
+
</tbody>
|
|
53
|
+
</table>
|
|
54
|
+
</div>
|
|
55
|
+
|
|
56
|
+
<%# N+1 Patterns %>
|
|
57
|
+
<% if @record.n_plus_one_patterns.any? %>
|
|
58
|
+
<div class="card">
|
|
59
|
+
<div class="card-title">N+1 Patterns</div>
|
|
60
|
+
<table>
|
|
61
|
+
<thead>
|
|
62
|
+
<tr>
|
|
63
|
+
<th>Query Pattern</th>
|
|
64
|
+
<th>Repeated</th>
|
|
65
|
+
</tr>
|
|
66
|
+
</thead>
|
|
67
|
+
<tbody>
|
|
68
|
+
<% @record.n_plus_one_patterns.each do |pattern, count| %>
|
|
69
|
+
<tr>
|
|
70
|
+
<td class="sql"><%= pattern %></td>
|
|
71
|
+
<td><span class="n1-badge"><%= count %>x</span></td>
|
|
72
|
+
</tr>
|
|
73
|
+
<% end %>
|
|
74
|
+
</tbody>
|
|
75
|
+
</table>
|
|
76
|
+
</div>
|
|
77
|
+
<% end %>
|
|
78
|
+
|
|
79
|
+
<%# All Queries %>
|
|
80
|
+
<div class="card">
|
|
81
|
+
<div
|
|
82
|
+
class="card-title"
|
|
83
|
+
onclick="toggleCard('all_queries_table', 'all_queries_chevron_simple')"
|
|
84
|
+
style="cursor:pointer;"
|
|
85
|
+
>
|
|
86
|
+
<div style="display:flex;justify-content:space-between;align-items:center;">
|
|
87
|
+
<span>All Queries (<%= @record.queries.size %>)</span>
|
|
88
|
+
<span style="font-size:14px;" id="all_queries_chevron_simple">▼</span>
|
|
89
|
+
</div>
|
|
90
|
+
</div>
|
|
91
|
+
<table id="all_queries_table">
|
|
92
|
+
<thead>
|
|
93
|
+
<tr>
|
|
94
|
+
<th>SQL</th>
|
|
95
|
+
<th>Time</th>
|
|
96
|
+
</tr>
|
|
97
|
+
</thead>
|
|
98
|
+
<tbody>
|
|
99
|
+
<% @record.queries.sort_by { |q| -q[:duration_ms] }.each do |q| %>
|
|
100
|
+
<tr>
|
|
101
|
+
<td class="sql"><%= q[:sql] %></td>
|
|
102
|
+
<td style="white-space:nowrap;"><%= q[:duration_ms].round(1) %>ms</td>
|
|
103
|
+
</tr>
|
|
104
|
+
<% end %>
|
|
105
|
+
</tbody>
|
|
106
|
+
</table>
|
|
107
|
+
</div>
|
|
108
|
+
|
|
109
|
+
<%# Callback Map %>
|
|
110
|
+
<% if @record.callbacks.any? %>
|
|
111
|
+
<div class="card">
|
|
112
|
+
<div class="card-title">
|
|
113
|
+
Callback Map
|
|
114
|
+
<span style="color:#a0aec0;font-weight:normal;margin-left:8px;">
|
|
115
|
+
<%= @record.callbacks.size %> callbacks —
|
|
116
|
+
<%= @record.total_callback_time_ms.round(1) %>ms total
|
|
117
|
+
</span>
|
|
118
|
+
</div>
|
|
119
|
+
<% @record.callbacks.group_by { |c| c[:model] }.each do |model, callbacks| %>
|
|
120
|
+
<div style="margin-bottom:16px;">
|
|
121
|
+
<div style="
|
|
122
|
+
color:#90cdf4;
|
|
123
|
+
font-size:12px;
|
|
124
|
+
font-weight:bold;
|
|
125
|
+
margin-bottom:6px;
|
|
126
|
+
padding-bottom:4px;
|
|
127
|
+
border-bottom:1px solid #2d3748;
|
|
128
|
+
">
|
|
129
|
+
<%= model %>
|
|
130
|
+
<span style="color:#a0aec0;font-weight:normal;">
|
|
131
|
+
(<%= callbacks.size %> callbacks,
|
|
132
|
+
<%= callbacks.sum { |c| c[:duration_ms] }.round(1) %>ms)
|
|
133
|
+
</span>
|
|
134
|
+
</div>
|
|
135
|
+
<table>
|
|
136
|
+
<thead>
|
|
137
|
+
<tr>
|
|
138
|
+
<th>Callback</th>
|
|
139
|
+
<th>Time</th>
|
|
140
|
+
<th>% of Total</th>
|
|
141
|
+
</tr>
|
|
142
|
+
</thead>
|
|
143
|
+
<tbody>
|
|
144
|
+
<% callbacks.sort_by { |c| -c[:duration_ms] }.each do |cb| %>
|
|
145
|
+
<tr>
|
|
146
|
+
<td>
|
|
147
|
+
<span style="
|
|
148
|
+
background:<%= callback_color(cb[:kind]) %>;
|
|
149
|
+
color:#fff;
|
|
150
|
+
padding:1px 6px;
|
|
151
|
+
border-radius:3px;
|
|
152
|
+
font-size:11px;
|
|
153
|
+
">
|
|
154
|
+
<%= cb[:kind] %>
|
|
155
|
+
</span>
|
|
156
|
+
</td>
|
|
157
|
+
<td><%= cb[:duration_ms] %>ms</td>
|
|
158
|
+
<td style="color:#a0aec0;">
|
|
159
|
+
<% pct = @record.total_callback_time_ms > 0 ?
|
|
160
|
+
((cb[:duration_ms] / @record.total_callback_time_ms) * 100).round(1) : 0 %>
|
|
161
|
+
<%= pct %>%
|
|
162
|
+
</td>
|
|
163
|
+
</tr>
|
|
164
|
+
<% end %>
|
|
165
|
+
</tbody>
|
|
166
|
+
</table>
|
|
167
|
+
</div>
|
|
168
|
+
<% end %>
|
|
169
|
+
</div>
|
|
170
|
+
<% end %>
|
|
171
|
+
|
|
172
|
+
<%# All Queries with DNA %>
|
|
173
|
+
<div class="card">
|
|
174
|
+
<div
|
|
175
|
+
class="card-title"
|
|
176
|
+
onclick="toggleCard('all_queries_table_dna', 'all_queries_chevron_dna')"
|
|
177
|
+
style="cursor:pointer;"
|
|
178
|
+
>
|
|
179
|
+
<div style="display:flex;justify-content:space-between;align-items:center;">
|
|
180
|
+
<p>
|
|
181
|
+
All Queries (<%= @record.queries.size %>)
|
|
182
|
+
<span class="card-title-description">
|
|
183
|
+
click any query to expand DNA
|
|
184
|
+
</span>
|
|
185
|
+
</p>
|
|
186
|
+
<span style="font-size:14px;" id="all_queries_chevron_dna">▼</span>
|
|
187
|
+
</div>
|
|
188
|
+
</div>
|
|
189
|
+
<table id="all_queries_table_dna">
|
|
190
|
+
<thead>
|
|
191
|
+
<tr>
|
|
192
|
+
<th>#</th>
|
|
193
|
+
<th>SQL</th>
|
|
194
|
+
<th>Time</th>
|
|
195
|
+
<th>Complexity</th>
|
|
196
|
+
<th>Risk</th>
|
|
197
|
+
</tr>
|
|
198
|
+
</thead>
|
|
199
|
+
<tbody>
|
|
200
|
+
<% @query_dna.each_with_index do |item, i| %>
|
|
201
|
+
<% q = item[:query] %>
|
|
202
|
+
<% dna = item[:dna] %>
|
|
203
|
+
<% dna_id = "dna_#{i}" %>
|
|
204
|
+
|
|
205
|
+
<%# Query row — clickable %>
|
|
206
|
+
<tr
|
|
207
|
+
onclick="toggleDna('<%= dna_id %>')"
|
|
208
|
+
style="cursor:pointer;"
|
|
209
|
+
>
|
|
210
|
+
<td style="color:#a0aec0;"><%= i + 1 %></td>
|
|
211
|
+
<td style="font-family:monospace;font-size:11px;color:#e2e8f0;max-width:400px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">
|
|
212
|
+
<%= q[:sql] %>
|
|
213
|
+
</td>
|
|
214
|
+
<td style="color:<%= time_heat_color(q[:duration_ms]) %>;">
|
|
215
|
+
<%= q[:duration_ms].round(2) %>ms
|
|
216
|
+
</td>
|
|
217
|
+
<td>
|
|
218
|
+
<span style="color:<%= dna.complexity_label[:color] %>;font-size:12px;">
|
|
219
|
+
<%= dna.complexity_label[:label] %>
|
|
220
|
+
<span style="color:#a0aec0;font-size:10px;">(<%= dna.complexity %>/10)</span>
|
|
221
|
+
</span>
|
|
222
|
+
</td>
|
|
223
|
+
<td>
|
|
224
|
+
<% risk_colors = { healthy: "#68d391", neutral: "#a0aec0", warning: "#f6ad55", danger: "#fc8181" } %>
|
|
225
|
+
<span style="color:<%= risk_colors[dna.risk] %>;font-size:12px;text-transform:capitalize;">
|
|
226
|
+
<%= dna.risk %>
|
|
227
|
+
</span>
|
|
228
|
+
</td>
|
|
229
|
+
</tr>
|
|
230
|
+
|
|
231
|
+
<%# DNA panel — hidden by default %>
|
|
232
|
+
<tr id="<%= dna_id %>" style="display:none;">
|
|
233
|
+
<td colspan="5" style="padding:0;">
|
|
234
|
+
<div style="
|
|
235
|
+
background:#1a202c;
|
|
236
|
+
border-left:3px solid #4299e1;
|
|
237
|
+
padding:16px;
|
|
238
|
+
margin:4px 0;
|
|
239
|
+
">
|
|
240
|
+
|
|
241
|
+
<%# Full SQL %>
|
|
242
|
+
<div style="margin-bottom:16px;">
|
|
243
|
+
<div style="color:#a0aec0;font-size:10px;text-transform:uppercase;letter-spacing:0.05em;margin-bottom:6px;">
|
|
244
|
+
Full SQL
|
|
245
|
+
</div>
|
|
246
|
+
<pre style="color:#e2e8f0;font-size:11px;white-space:pre-wrap;word-break:break-all;margin:0;"><%= q[:sql] %></pre>
|
|
247
|
+
</div>
|
|
248
|
+
|
|
249
|
+
<%# Token strip %>
|
|
250
|
+
<div style="margin-bottom:16px;">
|
|
251
|
+
<div style="color:#a0aec0;font-size:10px;text-transform:uppercase;letter-spacing:0.05em;margin-bottom:8px;">
|
|
252
|
+
Query DNA
|
|
253
|
+
</div>
|
|
254
|
+
<div style="display:flex;flex-wrap:wrap;gap:6px;">
|
|
255
|
+
<% dna.tokens.each do |token| %>
|
|
256
|
+
<span
|
|
257
|
+
onclick="toggleCard('<%= "card_#{dna_id}_#{token[:type]}" %>'); event.stopPropagation();"
|
|
258
|
+
style="
|
|
259
|
+
background:<%= token[:color] %>22;
|
|
260
|
+
color:<%= token[:color] %>;
|
|
261
|
+
border:1px solid <%= token[:color] %>66;
|
|
262
|
+
padding:3px 10px;
|
|
263
|
+
border-radius:4px;
|
|
264
|
+
font-size:12px;
|
|
265
|
+
font-family:monospace;
|
|
266
|
+
cursor:pointer;
|
|
267
|
+
"
|
|
268
|
+
>
|
|
269
|
+
<%= token[:label] %>
|
|
270
|
+
</span>
|
|
271
|
+
<% end %>
|
|
272
|
+
</div>
|
|
273
|
+
</div>
|
|
274
|
+
|
|
275
|
+
<%# Repetition bar %>
|
|
276
|
+
<% if dna.repetition_bar.is_a?(Hash) && dna.repetition_count > 1 %>
|
|
277
|
+
<div style="margin-bottom:16px;">
|
|
278
|
+
<div style="color:#a0aec0;font-size:10px;text-transform:uppercase;letter-spacing:0.05em;margin-bottom:6px;">
|
|
279
|
+
Repetition — <%= dna.repetition_count %>x in this request
|
|
280
|
+
</div>
|
|
281
|
+
<div style="display:flex;align-items:center;gap:8px;">
|
|
282
|
+
<div style="font-family:monospace;font-size:13px;">
|
|
283
|
+
<span style="color:#fc8181;">
|
|
284
|
+
<%= "█" * dna.repetition_bar[:filled] %>
|
|
285
|
+
</span>
|
|
286
|
+
<span style="color:#2d3748;">
|
|
287
|
+
<%= "█" * dna.repetition_bar[:empty] %>
|
|
288
|
+
</span>
|
|
289
|
+
</div>
|
|
290
|
+
<span style="color:#fc8181;font-size:12px;font-weight:bold;">
|
|
291
|
+
<%= dna.repetition_count %>x — N+1 pattern
|
|
292
|
+
</span>
|
|
293
|
+
</div>
|
|
294
|
+
</div>
|
|
295
|
+
<% end %>
|
|
296
|
+
|
|
297
|
+
<%# Complexity + metadata row %>
|
|
298
|
+
<div style="display:flex;gap:24px;margin-bottom:16px;font-size:12px;">
|
|
299
|
+
<div>
|
|
300
|
+
<span style="color:#a0aec0;">Complexity</span>
|
|
301
|
+
<span style="color:<%= dna.complexity_label[:color] %>;margin-left:6px;font-weight:bold;">
|
|
302
|
+
<%= dna.complexity_label[:label] %> (<%= dna.complexity %>/10)
|
|
303
|
+
</span>
|
|
304
|
+
</div>
|
|
305
|
+
<div>
|
|
306
|
+
<span style="color:#a0aec0;">Risk</span>
|
|
307
|
+
<% risk_colors = { healthy: "#68d391", neutral: "#a0aec0", warning: "#f6ad55", danger: "#fc8181" } %>
|
|
308
|
+
<span style="color:<%= risk_colors[dna.risk] %>;margin-left:6px;font-weight:bold;text-transform:capitalize;">
|
|
309
|
+
<%= dna.risk %>
|
|
310
|
+
</span>
|
|
311
|
+
</div>
|
|
312
|
+
<div>
|
|
313
|
+
<span style="color:#a0aec0;">Duration</span>
|
|
314
|
+
<span style="color:<%= time_heat_color(q[:duration_ms]) %>;margin-left:6px;font-weight:bold;">
|
|
315
|
+
<%= q[:duration_ms].round(2) %>ms
|
|
316
|
+
</span>
|
|
317
|
+
</div>
|
|
318
|
+
</div>
|
|
319
|
+
|
|
320
|
+
<%# Education cards — one per token, hidden by default %>
|
|
321
|
+
<% dna.tokens.each do |token| %>
|
|
322
|
+
<div
|
|
323
|
+
id="card_<%= dna_id %>_<%= token[:type] %>"
|
|
324
|
+
style="display:none;margin-bottom:8px;"
|
|
325
|
+
>
|
|
326
|
+
<div style="
|
|
327
|
+
background:#2d3748;
|
|
328
|
+
border-left:3px solid <%= token[:color] %>;
|
|
329
|
+
border-radius:4px;
|
|
330
|
+
padding:12px 16px;
|
|
331
|
+
">
|
|
332
|
+
<div style="
|
|
333
|
+
color:<%= token[:color] %>;
|
|
334
|
+
font-size:12px;
|
|
335
|
+
font-weight:bold;
|
|
336
|
+
font-family:monospace;
|
|
337
|
+
margin-bottom:6px;
|
|
338
|
+
">
|
|
339
|
+
💡 <%= token[:label] %>
|
|
340
|
+
<span style="
|
|
341
|
+
background:<%= token[:color] %>33;
|
|
342
|
+
color:<%= token[:color] %>;
|
|
343
|
+
font-size:10px;
|
|
344
|
+
padding:1px 6px;
|
|
345
|
+
border-radius:3px;
|
|
346
|
+
margin-left:6px;
|
|
347
|
+
text-transform:uppercase;
|
|
348
|
+
font-family:Arial,sans-serif;
|
|
349
|
+
">
|
|
350
|
+
<%= token[:risk] %>
|
|
351
|
+
</span>
|
|
352
|
+
</div>
|
|
353
|
+
<div style="color:#e2e8f0;font-size:13px;line-height:1.6;">
|
|
354
|
+
<%= token[:explanation] %>
|
|
355
|
+
</div>
|
|
356
|
+
</div>
|
|
357
|
+
</div>
|
|
358
|
+
<% end %>
|
|
359
|
+
|
|
360
|
+
</div>
|
|
361
|
+
</td>
|
|
362
|
+
</tr>
|
|
363
|
+
|
|
364
|
+
<% end %>
|
|
365
|
+
</tbody>
|
|
366
|
+
</table>
|
|
367
|
+
</div>
|
|
368
|
+
|
|
369
|
+
<%# Inline JS for toggle behavior %>
|
|
370
|
+
<script>
|
|
371
|
+
function toggleDna(id) {
|
|
372
|
+
var row = document.getElementById(id);
|
|
373
|
+
if (row) {
|
|
374
|
+
row.style.display = row.style.display === 'none' ? 'table-row' : 'none';
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
function toggleCard(id, chevronId) {
|
|
379
|
+
var card = document.getElementById(id);
|
|
380
|
+
if (card) {
|
|
381
|
+
if (card.style.display === 'none') {
|
|
382
|
+
card.style.display = card.tagName === 'TABLE' ? 'table' : 'block';
|
|
383
|
+
} else {
|
|
384
|
+
card.style.display = 'none';
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
// Toggle chevron icon if provided
|
|
389
|
+
if (chevronId) {
|
|
390
|
+
var chevron = document.getElementById(chevronId);
|
|
391
|
+
if (chevron) {
|
|
392
|
+
chevron.textContent = chevron.textContent === '▼' ? '▶' : '▼';
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
</script>
|
data/config/routes.rb
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
RailsVitals::Engine.routes.draw do
|
|
2
|
+
root to: "dashboard#index"
|
|
3
|
+
|
|
4
|
+
resources :requests, only: [ :index, :show ]
|
|
5
|
+
resources :models, only: [ :index ]
|
|
6
|
+
resources :n_plus_ones, only: [ :index, :show ]
|
|
7
|
+
resources :associations, only: [ :index ]
|
|
8
|
+
get "heatmap", to: "heatmap#index", as: :heatmap
|
|
9
|
+
end
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
module RailsVitals
|
|
2
|
+
module Analyzers
|
|
3
|
+
class AssociationMapper
|
|
4
|
+
ModelNode = Struct.new(
|
|
5
|
+
:name, :table, :depth, :position,
|
|
6
|
+
:associations, :query_count, :avg_query_time_ms,
|
|
7
|
+
:has_n1, :n1_patterns,
|
|
8
|
+
keyword_init: true
|
|
9
|
+
)
|
|
10
|
+
|
|
11
|
+
AssociationEdge = Struct.new(
|
|
12
|
+
:from_model, :to_model, :macro,
|
|
13
|
+
:foreign_key, :indexed, :has_n1,
|
|
14
|
+
keyword_init: true
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
def self.build(store)
|
|
18
|
+
records = store.all
|
|
19
|
+
models = discover_models
|
|
20
|
+
n1_data = NPlusOneAggregator.aggregate(records)
|
|
21
|
+
n1_tables = n1_data.map { |p| p[:table] }.compact.uniq
|
|
22
|
+
|
|
23
|
+
nodes = models.map do |model|
|
|
24
|
+
queries = queries_for_model(model, records)
|
|
25
|
+
avg_time = queries.empty? ? 0 : (queries.sum { |q| q[:duration_ms] } / queries.size).round(2)
|
|
26
|
+
|
|
27
|
+
ModelNode.new(
|
|
28
|
+
name: model.name,
|
|
29
|
+
table: model.table_name,
|
|
30
|
+
depth: association_depth(model, models),
|
|
31
|
+
position: nil,
|
|
32
|
+
associations: build_edges(model, n1_tables),
|
|
33
|
+
query_count: queries.size,
|
|
34
|
+
avg_query_time_ms: avg_time,
|
|
35
|
+
has_n1: n1_tables.include?(model.table_name),
|
|
36
|
+
n1_patterns: n1_data.select { |p| p[:table] == model.table_name }
|
|
37
|
+
)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
nodes, canvas_h = assign_positions(nodes)
|
|
41
|
+
[ nodes, canvas_h ]
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def self.discover_models
|
|
45
|
+
ActiveRecord::Base.descendants
|
|
46
|
+
.reject(&:abstract_class?)
|
|
47
|
+
.reject { |m| m.name&.start_with?("RailsVitals") }
|
|
48
|
+
.select { |m| m.table_exists? rescue false }
|
|
49
|
+
.sort_by(&:name)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Depth = how many belongs_to hops from root
|
|
53
|
+
def self.association_depth(model, all_models)
|
|
54
|
+
belongs_to_targets = model.reflect_on_all_associations(:belongs_to)
|
|
55
|
+
.map { |r| r.klass rescue nil }
|
|
56
|
+
.compact
|
|
57
|
+
|
|
58
|
+
return 0 if belongs_to_targets.empty?
|
|
59
|
+
|
|
60
|
+
belongs_to_targets.map { |target|
|
|
61
|
+
target == model ? 0 : association_depth(target, all_models) + 1
|
|
62
|
+
}.min
|
|
63
|
+
rescue
|
|
64
|
+
0
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def self.build_edges(model, n1_tables)
|
|
68
|
+
model.reflect_on_all_associations.map do |assoc|
|
|
69
|
+
target = assoc.klass rescue next
|
|
70
|
+
fk = assoc.foreign_key.to_s
|
|
71
|
+
table = assoc.macro == :belongs_to ? model.table_name : target.table_name
|
|
72
|
+
|
|
73
|
+
indexed = begin
|
|
74
|
+
ActiveRecord::Base.connection
|
|
75
|
+
.indexes(table)
|
|
76
|
+
.any? { |i| i.columns.first == fk }
|
|
77
|
+
rescue
|
|
78
|
+
false
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
AssociationEdge.new(
|
|
82
|
+
from_model: model.name,
|
|
83
|
+
to_model: target.name,
|
|
84
|
+
macro: assoc.macro,
|
|
85
|
+
foreign_key: fk,
|
|
86
|
+
indexed: indexed,
|
|
87
|
+
has_n1: n1_tables.include?(target.table_name)
|
|
88
|
+
)
|
|
89
|
+
end.compact
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def self.queries_for_model(model, records)
|
|
93
|
+
records.flat_map { |r| r.queries }
|
|
94
|
+
.select { |q|
|
|
95
|
+
q[:sql].match?(/FROM\s+"?#{model.table_name}"?/i) ||
|
|
96
|
+
q[:sql].match?(/UPDATE\s+"?#{model.table_name}"?/i)
|
|
97
|
+
}
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# Assign x/y positions by depth layer
|
|
101
|
+
def self.assign_positions(nodes)
|
|
102
|
+
by_depth = nodes.group_by(&:depth)
|
|
103
|
+
canvas_w = 900
|
|
104
|
+
canvas_h = 120 + (by_depth.keys.max || 0) * 160
|
|
105
|
+
|
|
106
|
+
by_depth.each do |depth, layer_nodes|
|
|
107
|
+
count = layer_nodes.size
|
|
108
|
+
x_step = canvas_w / (count + 1)
|
|
109
|
+
layer_nodes.each_with_index do |node, i|
|
|
110
|
+
node.position = {
|
|
111
|
+
x: x_step * (i + 1),
|
|
112
|
+
y: 60 + depth * 160
|
|
113
|
+
}
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
[ nodes, canvas_h ]
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
end
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
module RailsVitals
|
|
2
|
+
module Analyzers
|
|
3
|
+
class NPlusOneAggregator
|
|
4
|
+
def self.aggregate(records)
|
|
5
|
+
pattern_data = Hash.new do |h, k|
|
|
6
|
+
h[k] = {
|
|
7
|
+
pattern: k,
|
|
8
|
+
occurrences: 0,
|
|
9
|
+
endpoints: Hash.new(0),
|
|
10
|
+
table: nil,
|
|
11
|
+
foreign_key: nil
|
|
12
|
+
}
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
records.each do |record|
|
|
16
|
+
next if record.n_plus_one_patterns.empty?
|
|
17
|
+
|
|
18
|
+
record.n_plus_one_patterns.each do |sql, count|
|
|
19
|
+
normalized = normalize(sql)
|
|
20
|
+
Rails.logger.debug "Processing SQL: #{sql} → normalized: #{normalized}"
|
|
21
|
+
|
|
22
|
+
pattern_data[normalized][:occurrences] += count
|
|
23
|
+
pattern_data[normalized][:endpoints][record.endpoint] += 1
|
|
24
|
+
pattern_data[normalized][:table] ||= extract_table(sql)
|
|
25
|
+
pattern_data[normalized][:foreign_key] ||= extract_foreign_key(sql)
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
pattern_data
|
|
30
|
+
.values
|
|
31
|
+
.sort_by { |p| -p[:occurrences] }
|
|
32
|
+
.map do |p|
|
|
33
|
+
p[:endpoints] = p[:endpoints].to_h
|
|
34
|
+
p.merge(fix_suggestion: build_suggestion(p))
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
private
|
|
39
|
+
|
|
40
|
+
def self.normalize(sql)
|
|
41
|
+
sql
|
|
42
|
+
.gsub('\\"', '"') # unescape stored escaped quotes
|
|
43
|
+
.gsub(/\b\d+\b/, "?")
|
|
44
|
+
.gsub(/'[^']*'/, "?")
|
|
45
|
+
.gsub(/\s+/, " ")
|
|
46
|
+
.strip
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def self.extract_table(sql)
|
|
50
|
+
clean = sql.gsub('\\"', '"')
|
|
51
|
+
clean.match(/FROM\s+"?(\w+)"?/i)&.captures&.first
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def self.extract_foreign_key(sql)
|
|
55
|
+
clean = sql.gsub('\\"', '"')
|
|
56
|
+
clean.match(/WHERE\s+"?\w+"?\."?(\w+_id)"\s*=/i)&.captures&.first
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def self.build_suggestion(pattern)
|
|
60
|
+
table = pattern[:table]
|
|
61
|
+
foreign_key = pattern[:foreign_key]
|
|
62
|
+
|
|
63
|
+
return generic_suggestion(table) unless table && foreign_key
|
|
64
|
+
|
|
65
|
+
# Map foreign_key back to association
|
|
66
|
+
owner_model = infer_owner_model(foreign_key)
|
|
67
|
+
assoc_name = infer_association(table, foreign_key)
|
|
68
|
+
|
|
69
|
+
if owner_model && assoc_name
|
|
70
|
+
{
|
|
71
|
+
code: "#{owner_model}.includes(:#{assoc_name})",
|
|
72
|
+
description: "Eager load :#{assoc_name} on #{owner_model} to eliminate this N+1",
|
|
73
|
+
owner: owner_model,
|
|
74
|
+
association: assoc_name
|
|
75
|
+
}
|
|
76
|
+
else
|
|
77
|
+
generic_suggestion(table)
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def self.infer_owner_model(foreign_key)
|
|
82
|
+
# foreign_key = "user_id" → owner is "User"
|
|
83
|
+
foreign_key.sub(/_id$/, "").classify
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def self.infer_association(table, foreign_key)
|
|
87
|
+
# table = "posts", foreign_key = "user_id"
|
|
88
|
+
# → association :posts on User
|
|
89
|
+
owner_class_name = foreign_key.sub(/_id$/, "").classify
|
|
90
|
+
|
|
91
|
+
begin
|
|
92
|
+
owner_class = owner_class_name.constantize
|
|
93
|
+
return nil unless owner_class < ActiveRecord::Base
|
|
94
|
+
|
|
95
|
+
# Find association on owner that points to this table
|
|
96
|
+
assoc = owner_class.reflect_on_all_associations.find do |r|
|
|
97
|
+
r.klass.table_name == table rescue false
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
assoc&.name
|
|
101
|
+
rescue NameError
|
|
102
|
+
nil
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def self.generic_suggestion(table)
|
|
107
|
+
{
|
|
108
|
+
code: "includes(:#{table&.singularize})",
|
|
109
|
+
description: "Use includes(), eager_load(), or preload() to batch this query",
|
|
110
|
+
owner: nil,
|
|
111
|
+
association: table&.singularize
|
|
112
|
+
}
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
end
|