rails_vitals 0.3.0 โ 0.4.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 +29 -0
- data/app/assets/stylesheets/rails_vitals/application.css +146 -0
- data/app/controllers/rails_vitals/playgrounds_controller.rb +120 -0
- data/app/helpers/rails_vitals/application_helper.rb +20 -0
- data/app/views/layouts/rails_vitals/application.html.erb +1 -0
- data/app/views/rails_vitals/n_plus_ones/index.html.erb +8 -0
- data/app/views/rails_vitals/playgrounds/index.html.erb +289 -0
- data/app/views/rails_vitals/requests/show.html.erb +1 -1
- data/config/routes.rb +1 -0
- data/lib/rails_vitals/notifications/subscriber.rb +14 -14
- data/lib/rails_vitals/playground/sandbox.rb +222 -0
- data/lib/rails_vitals/version.rb +1 -1
- data/lib/rails_vitals.rb +1 -0
- metadata +4 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 8fc3f44a23831df5f94973a04dfd928519ac7b14adf763147cc7b4d6e6ee2702
|
|
4
|
+
data.tar.gz: 39be2ad3de1a6569e42e5c22cea38a04db5c933910014de9c5963652c6a15451
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: f95974f80db2e2975a0b7ccd77aef6d0335a6c89202a31186b6631de09fb25938e0f0f5a2f55806c4cafd7d9b469ff589593a7805a4769cdff9eaaa08ef72e96
|
|
7
|
+
data.tar.gz: d5698c85c0a72ac0f5f44f8db1c16c8b6b51c9c3aa0533ea70e774005f87d2e323a06c903f192d646e70ad1efa204560e94cbdbe122e2f6522412d50fbfbcfab
|
data/README.md
CHANGED
|
@@ -61,6 +61,11 @@ Any `SELECT` query in Request Detail can be sent directly to PostgreSQL's `EXPLA
|
|
|
61
61
|
|
|
62
62
|

|
|
63
63
|
|
|
64
|
+
### ๐งช N+1 Fix Playground
|
|
65
|
+
Try eager-loading fixes against your real app data before changing application code. Enter any ActiveRecord expression such as `Post.includes(:likes)`, optionally simulate association access to reproduce N+1 behavior, and RailsVitals runs it in a read-only sandbox with a 100-record cap and 2s timeout. Each run compares before vs. after score, query count, duration, and N+1 count, then shows the exact SQL fired with full Query DNA so you can verify that the fix actually batches queries.
|
|
66
|
+
|
|
67
|
+

|
|
68
|
+
|
|
64
69
|
### ๐ญ Callback Map
|
|
65
70
|
Every ActiveRecord callback (`before_save`, `after_create`, `before_validation`, etc.) is timed and grouped by model in the Request Detail view. Expensive callbacks surface immediately โ including hidden side effects like callbacks that trigger additional queries.
|
|
66
71
|
|
|
@@ -149,6 +154,7 @@ Navigate to `/rails_vitals` to access the full admin interface.
|
|
|
149
154
|
| N+1 Patterns | `/rails_vitals/n_plus_ones` | Cross-request N+1 aggregation with fix suggestions |
|
|
150
155
|
| Association Map | `/rails_vitals/associations` | Live SVG model graph with N+1 and index annotations |
|
|
151
156
|
| EXPLAIN Visualizer | `/rails_vitals/requests/:request_id/explain/:query_index` | Interactive PostgreSQL EXPLAIN ANALYZE tree with warnings and fix suggestions |
|
|
157
|
+
| Playground | `/rails_vitals/playgrounds` | Read-only sandbox for testing eager-loading fixes against real app data |
|
|
152
158
|
|
|
153
159
|
---
|
|
154
160
|
|
|
@@ -159,3 +159,32 @@ function badge(color, text) {
|
|
|
159
159
|
'font-size:9px;padding:1px 5px;border-radius:3px;margin-left:4px;">' +
|
|
160
160
|
text + '</span>';
|
|
161
161
|
}
|
|
162
|
+
|
|
163
|
+
// โโโ playgrounds/index โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
|
164
|
+
(function () {
|
|
165
|
+
var form = document.getElementById('playground-form');
|
|
166
|
+
if (!form) return;
|
|
167
|
+
|
|
168
|
+
// Disable the run button on submit to prevent double-runs
|
|
169
|
+
form.addEventListener('submit', function () {
|
|
170
|
+
var btn = document.getElementById('run-btn');
|
|
171
|
+
if (btn) {
|
|
172
|
+
btn.disabled = true;
|
|
173
|
+
btn.textContent = 'โณ Running...';
|
|
174
|
+
}
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
// Pre-fill expression textarea from ?expression= URL param (deep-link from N+1 page)
|
|
178
|
+
var params = new URLSearchParams(window.location.search);
|
|
179
|
+
var expr = params.get('expression');
|
|
180
|
+
if (expr) {
|
|
181
|
+
var ta = document.querySelector("textarea[name='expression']");
|
|
182
|
+
if (ta) ta.value = decodeURIComponent(expr);
|
|
183
|
+
}
|
|
184
|
+
}());
|
|
185
|
+
|
|
186
|
+
function toggleAssocLabel(assoc, checked) {
|
|
187
|
+
var label = document.getElementById('label_' + assoc);
|
|
188
|
+
if (!label) return;
|
|
189
|
+
label.classList.toggle('assoc-tag-active', checked);
|
|
190
|
+
}
|
|
@@ -276,6 +276,7 @@ tr:hover td { background: #1e2535; }
|
|
|
276
276
|
|
|
277
277
|
/* โโโ Component utilities โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ */
|
|
278
278
|
.section-label {
|
|
279
|
+
display: block;
|
|
279
280
|
color: #a0aec0;
|
|
280
281
|
font-size: 11px;
|
|
281
282
|
font-weight: bold;
|
|
@@ -353,3 +354,148 @@ tr:hover td { background: #1e2535; }
|
|
|
353
354
|
margin-right: 4px;
|
|
354
355
|
vertical-align: middle;
|
|
355
356
|
}
|
|
357
|
+
|
|
358
|
+
/* โโโ Gap utilities โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ */
|
|
359
|
+
.gap-4 { gap: 4px; }
|
|
360
|
+
.gap-6 { gap: 6px; }
|
|
361
|
+
.gap-8 { gap: 8px; }
|
|
362
|
+
.gap-12 { gap: 12px; }
|
|
363
|
+
.gap-16 { gap: 16px; }
|
|
364
|
+
.gap-24 { gap: 24px; }
|
|
365
|
+
|
|
366
|
+
/* โโโ Grid utilities โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ */
|
|
367
|
+
.grid-4 {
|
|
368
|
+
display: grid;
|
|
369
|
+
grid-template-columns: repeat(4, 1fr);
|
|
370
|
+
gap: 12px;
|
|
371
|
+
margin-bottom: 20px;
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
/* โโโ Stat box variant (dark surface) โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ */
|
|
375
|
+
.stat-grid-box {
|
|
376
|
+
background: #2d3748;
|
|
377
|
+
border-radius: 6px;
|
|
378
|
+
padding: 16px;
|
|
379
|
+
text-align: center;
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
/* โโโ Playground โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ */
|
|
383
|
+
.run-btn {
|
|
384
|
+
background: #2d6a2d;
|
|
385
|
+
border: 1px solid #68d391;
|
|
386
|
+
color: #68d391;
|
|
387
|
+
padding: 10px 24px;
|
|
388
|
+
border-radius: 6px;
|
|
389
|
+
font-size: 14px;
|
|
390
|
+
font-weight: bold;
|
|
391
|
+
cursor: pointer;
|
|
392
|
+
transition: background 0.15s;
|
|
393
|
+
}
|
|
394
|
+
.run-btn:hover { background: #3a8a3a; }
|
|
395
|
+
.run-btn:disabled { opacity: 0.5; cursor: not-allowed; }
|
|
396
|
+
|
|
397
|
+
@keyframes fadeIn {
|
|
398
|
+
from { opacity: 0; transform: translateY(6px); }
|
|
399
|
+
to { opacity: 1; transform: translateY(0); }
|
|
400
|
+
}
|
|
401
|
+
.result-fade { animation: fadeIn 0.3s ease forwards; }
|
|
402
|
+
|
|
403
|
+
.playground-textarea {
|
|
404
|
+
width: 100%;
|
|
405
|
+
background: #1a202c;
|
|
406
|
+
border: 1px solid #4a5568;
|
|
407
|
+
color: #e2e8f0;
|
|
408
|
+
padding: 12px;
|
|
409
|
+
border-radius: 4px;
|
|
410
|
+
font-family: ui-monospace, monospace;
|
|
411
|
+
font-size: 13px;
|
|
412
|
+
resize: vertical;
|
|
413
|
+
box-sizing: border-box;
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
.assoc-tag {
|
|
417
|
+
display: flex;
|
|
418
|
+
align-items: center;
|
|
419
|
+
gap: 6px;
|
|
420
|
+
background: #2d3748;
|
|
421
|
+
border: 1px solid #4a5568;
|
|
422
|
+
border-radius: 4px;
|
|
423
|
+
padding: 6px 12px;
|
|
424
|
+
cursor: pointer;
|
|
425
|
+
font-size: 12px;
|
|
426
|
+
font-family: ui-monospace, monospace;
|
|
427
|
+
color: #e2e8f0;
|
|
428
|
+
transition: background 0.15s;
|
|
429
|
+
}
|
|
430
|
+
.assoc-tag-active {
|
|
431
|
+
background: #1a2d1a;
|
|
432
|
+
border-color: rgba(104, 211, 145, 0.4);
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
.assoc-pill {
|
|
436
|
+
background: #2d3748;
|
|
437
|
+
color: #a0aec0;
|
|
438
|
+
font-size: 10px;
|
|
439
|
+
font-family: ui-monospace, monospace;
|
|
440
|
+
padding: 2px 6px;
|
|
441
|
+
border-radius: 3px;
|
|
442
|
+
}
|
|
443
|
+
.assoc-pill-active {
|
|
444
|
+
background: #1a3a1a;
|
|
445
|
+
color: #68d391;
|
|
446
|
+
font-size: 10px;
|
|
447
|
+
font-family: ui-monospace, monospace;
|
|
448
|
+
padding: 2px 6px;
|
|
449
|
+
border-radius: 3px;
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
.compare-box {
|
|
453
|
+
background: #1a202c;
|
|
454
|
+
border-radius: 6px;
|
|
455
|
+
padding: 12px 16px;
|
|
456
|
+
}
|
|
457
|
+
.compare-box-after {
|
|
458
|
+
background: #1a2d1a;
|
|
459
|
+
border-radius: 6px;
|
|
460
|
+
padding: 12px 16px;
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
.compare-arrow { text-align: center; }
|
|
464
|
+
.compare-delta {
|
|
465
|
+
font-size: 12px;
|
|
466
|
+
font-family: ui-monospace, monospace;
|
|
467
|
+
font-weight: bold;
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
.stat-value-22 {
|
|
471
|
+
font-size: 22px;
|
|
472
|
+
font-weight: bold;
|
|
473
|
+
font-family: ui-monospace, monospace;
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
.summary-line {
|
|
477
|
+
margin-top: 12px;
|
|
478
|
+
padding: 10px 16px;
|
|
479
|
+
background: #1a202c;
|
|
480
|
+
border-radius: 4px;
|
|
481
|
+
font-size: 13px;
|
|
482
|
+
font-family: ui-monospace, monospace;
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
.error-card {
|
|
486
|
+
background: #2d1515;
|
|
487
|
+
border: 1px solid rgba(252, 129, 129, 0.4);
|
|
488
|
+
border-radius: 6px;
|
|
489
|
+
padding: 16px;
|
|
490
|
+
color: #fc8181;
|
|
491
|
+
font-size: 13px;
|
|
492
|
+
margin-bottom: 20px;
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
.n1-alert {
|
|
496
|
+
background: #2d1515;
|
|
497
|
+
border: 1px solid rgba(252, 129, 129, 0.27);
|
|
498
|
+
border-radius: 6px;
|
|
499
|
+
padding: 12px 16px;
|
|
500
|
+
margin-bottom: 20px;
|
|
501
|
+
}
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
module RailsVitals
|
|
2
|
+
class PlaygroundsController < ApplicationController
|
|
3
|
+
def index
|
|
4
|
+
@default_query = default_query
|
|
5
|
+
@default_model = default_model_name
|
|
6
|
+
@available_assocs = associations_for_model(@default_model)
|
|
7
|
+
@prechecked_assocs = prechecked_associations
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def create
|
|
11
|
+
expression = params[:expression].to_s.strip
|
|
12
|
+
clean_expr = clean_expression(expression)
|
|
13
|
+
access_associations = Array(params[:access_associations]).reject(&:blank?)
|
|
14
|
+
|
|
15
|
+
result = Playground::Sandbox.run(
|
|
16
|
+
expression,
|
|
17
|
+
access_associations: access_associations
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
model_name = Playground::Sandbox.extract_model_name(expression)
|
|
21
|
+
@available_assocs = associations_for_model(model_name)
|
|
22
|
+
@prechecked_assocs = access_associations
|
|
23
|
+
|
|
24
|
+
@expression = clean_expr
|
|
25
|
+
@result = result
|
|
26
|
+
@previous = session_previous
|
|
27
|
+
@query_dna = build_dna(result.queries)
|
|
28
|
+
|
|
29
|
+
session[:playground_previous] = serialize_result(result, clean_expr, access_associations)
|
|
30
|
+
|
|
31
|
+
render :index
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
private
|
|
35
|
+
|
|
36
|
+
def discover_models
|
|
37
|
+
Analyzers::AssociationMapper.discover_models.map(&:name)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def worst_n1_pattern
|
|
41
|
+
records = RailsVitals.store.all
|
|
42
|
+
return nil if records.empty?
|
|
43
|
+
|
|
44
|
+
Analyzers::NPlusOneAggregator.aggregate(records).first
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def session_previous
|
|
48
|
+
raw = session[:playground_previous]
|
|
49
|
+
return nil unless raw
|
|
50
|
+
|
|
51
|
+
JSON.parse(raw, symbolize_names: true)
|
|
52
|
+
rescue
|
|
53
|
+
nil
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def default_model_name
|
|
57
|
+
pattern = worst_n1_pattern
|
|
58
|
+
return discover_models.first unless pattern
|
|
59
|
+
|
|
60
|
+
pattern[:fix_suggestion]&.dig(:owner_model) || discover_models.first
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def associations_for_model(model_name)
|
|
64
|
+
return [] unless model_name
|
|
65
|
+
|
|
66
|
+
Playground::Sandbox.associations_for(model_name)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def prechecked_associations
|
|
70
|
+
pattern = worst_n1_pattern
|
|
71
|
+
return [] unless pattern
|
|
72
|
+
|
|
73
|
+
# Pre-check the association from the worst N+1 pattern
|
|
74
|
+
table = pattern[:table]
|
|
75
|
+
return [] unless table
|
|
76
|
+
|
|
77
|
+
assoc_name = table # table name is usually the association name
|
|
78
|
+
[ @available_assocs.find { |a| a == assoc_name || a == assoc_name.singularize } ].compact
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def default_query
|
|
82
|
+
pattern = worst_n1_pattern
|
|
83
|
+
return "" unless pattern
|
|
84
|
+
|
|
85
|
+
fix = pattern[:fix_suggestion]&.dig(:code)
|
|
86
|
+
return "" unless fix
|
|
87
|
+
|
|
88
|
+
"# Worst N+1 detected in your app:\n# Fix: #{fix}\n\n#{fix.split('.').first}.all"
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def serialize_result(result, expression, access_associations = [])
|
|
92
|
+
{
|
|
93
|
+
expression: expression,
|
|
94
|
+
query_count: result.query_count,
|
|
95
|
+
score: result.score,
|
|
96
|
+
duration_ms: result.duration_ms,
|
|
97
|
+
n1_count: result.n1_patterns.size,
|
|
98
|
+
access_associations: access_associations,
|
|
99
|
+
error: result.error
|
|
100
|
+
}.to_json
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def clean_expression(expression)
|
|
104
|
+
expression
|
|
105
|
+
.lines
|
|
106
|
+
.reject { |l| l.strip.start_with?("#") }
|
|
107
|
+
.join
|
|
108
|
+
.strip
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def build_dna(queries)
|
|
112
|
+
queries.map do |q|
|
|
113
|
+
{
|
|
114
|
+
query: q,
|
|
115
|
+
dna: Analyzers::SqlTokenizer.tokenize(q[:sql], all_queries: queries)
|
|
116
|
+
}
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
end
|
|
@@ -87,5 +87,25 @@ module RailsVitals
|
|
|
87
87
|
else COLOR_LIGHT_RED
|
|
88
88
|
end
|
|
89
89
|
end
|
|
90
|
+
|
|
91
|
+
# Returns a hex color for a DNA risk level symbol (:healthy, :neutral, :warning, :danger)
|
|
92
|
+
def risk_color(risk)
|
|
93
|
+
{
|
|
94
|
+
healthy: COLOR_LIGHT_GREEN,
|
|
95
|
+
neutral: COLOR_NEUTRAL,
|
|
96
|
+
warning: COLOR_ORANGE,
|
|
97
|
+
danger: COLOR_LIGHT_RED
|
|
98
|
+
}[risk.to_sym] || COLOR_NEUTRAL
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# Returns a readable hex text color for a numeric health score (0-100)
|
|
102
|
+
def score_text_color(score)
|
|
103
|
+
case score.to_i
|
|
104
|
+
when 90..100 then COLOR_LIGHT_GREEN
|
|
105
|
+
when 70..89 then "#4299e1"
|
|
106
|
+
when 50..69 then COLOR_ORANGE
|
|
107
|
+
else COLOR_LIGHT_RED
|
|
108
|
+
end
|
|
109
|
+
end
|
|
90
110
|
end
|
|
91
111
|
end
|
|
@@ -20,6 +20,7 @@
|
|
|
20
20
|
<%= link_to "Models", rails_vitals.models_path %>
|
|
21
21
|
<%= link_to "N+1 Patterns", rails_vitals.n_plus_ones_path %>
|
|
22
22
|
<%= link_to "Association Map", rails_vitals.associations_path %>
|
|
23
|
+
<%= link_to "Playground", rails_vitals.playgrounds_path %>
|
|
23
24
|
</nav>
|
|
24
25
|
<div class="container">
|
|
25
26
|
<%= yield %>
|
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
<th>Affected Endpoints</th>
|
|
16
16
|
<th>Fix</th>
|
|
17
17
|
<th></th>
|
|
18
|
+
<th></th>
|
|
18
19
|
</tr>
|
|
19
20
|
</thead>
|
|
20
21
|
<tbody>
|
|
@@ -37,6 +38,13 @@
|
|
|
37
38
|
n_plus_one_path(pattern_id(p)),
|
|
38
39
|
class: "text-accent" %>
|
|
39
40
|
</td>
|
|
41
|
+
<td>
|
|
42
|
+
<%= link_to "Try in Playground โ",
|
|
43
|
+
rails_vitals.playgrounds_path(
|
|
44
|
+
expression: p[:fix_suggestion]&.dig(:code)
|
|
45
|
+
),
|
|
46
|
+
class: "text-accent" %>
|
|
47
|
+
</td>
|
|
40
48
|
</tr>
|
|
41
49
|
<% end %>
|
|
42
50
|
</tbody>
|
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
<%# Header %>
|
|
2
|
+
<div class="page-header">
|
|
3
|
+
<h2 class="page-heading mb-4">N+1 Fix Playground</h2>
|
|
4
|
+
<p class="page-subtitle">
|
|
5
|
+
Write any ActiveRecord query โ see exactly what SQL fires against your real data.
|
|
6
|
+
<span class="text-grey">Read-only. Capped at 100 records.</span>
|
|
7
|
+
</p>
|
|
8
|
+
</div>
|
|
9
|
+
|
|
10
|
+
<%# Input form %>
|
|
11
|
+
<div class="card mb-20">
|
|
12
|
+
<%= form_with url: rails_vitals.playgrounds_path,
|
|
13
|
+
method: :post,
|
|
14
|
+
local: true,
|
|
15
|
+
id: "playground-form" do |f| %>
|
|
16
|
+
|
|
17
|
+
<div class="mb-16">
|
|
18
|
+
<label class="section-label">Query</label>
|
|
19
|
+
<%= f.text_area :expression,
|
|
20
|
+
value: params[:expression] || @default_query,
|
|
21
|
+
rows: 5,
|
|
22
|
+
placeholder: "Post.includes(:likes).where(published: true)",
|
|
23
|
+
class: "playground-textarea" %>
|
|
24
|
+
</div>
|
|
25
|
+
|
|
26
|
+
<%# Association access โ simulate iteration %>
|
|
27
|
+
<% if @available_assocs.any? %>
|
|
28
|
+
<div class="mb-16">
|
|
29
|
+
<div class="section-label">
|
|
30
|
+
Simulate access to associations
|
|
31
|
+
<span class="text-grey text-10 ml-6" style="font-weight:normal;text-transform:none;letter-spacing:normal;">
|
|
32
|
+
โ checked associations will be loaded per record (triggers N+1 if not eager loaded)
|
|
33
|
+
</span>
|
|
34
|
+
</div>
|
|
35
|
+
<div class="flex flex-wrap gap-8">
|
|
36
|
+
<% @available_assocs.each do |assoc| %>
|
|
37
|
+
<% checked = @prechecked_assocs.include?(assoc) %>
|
|
38
|
+
<label class="assoc-tag <%= checked ? 'assoc-tag-active' : '' %>"
|
|
39
|
+
id="label_<%= assoc %>">
|
|
40
|
+
<input
|
|
41
|
+
type="checkbox"
|
|
42
|
+
name="access_associations[]"
|
|
43
|
+
value="<%= assoc %>"
|
|
44
|
+
checked
|
|
45
|
+
style="accent-color:#68d391;"
|
|
46
|
+
onchange="toggleAssocLabel(<%= j(assoc).to_json %>, this.checked)"
|
|
47
|
+
>
|
|
48
|
+
:<%= assoc %>
|
|
49
|
+
</label>
|
|
50
|
+
<% end %>
|
|
51
|
+
</div>
|
|
52
|
+
</div>
|
|
53
|
+
<% end %>
|
|
54
|
+
|
|
55
|
+
<div class="text-grey text-sm mb-16">
|
|
56
|
+
โ INSERT, UPDATE, DELETE, DROP are blocked.
|
|
57
|
+
Queries run against real data with a 100-record cap and 2s timeout.
|
|
58
|
+
</div>
|
|
59
|
+
|
|
60
|
+
<button type="submit" class="run-btn" id="run-btn">โถ Run</button>
|
|
61
|
+
<% end %>
|
|
62
|
+
</div>
|
|
63
|
+
|
|
64
|
+
<% if @result %>
|
|
65
|
+
<div class="result-fade">
|
|
66
|
+
|
|
67
|
+
<%# Error state %>
|
|
68
|
+
<% if @result.error %>
|
|
69
|
+
<div class="error-card">โ <%= @result.error %></div>
|
|
70
|
+
<% else %>
|
|
71
|
+
<%# Before โ older run %>
|
|
72
|
+
<div class="compare-box mb-16">
|
|
73
|
+
<div class="mini-label mb-6">โ Before</div>
|
|
74
|
+
<div class="mono text-12 text-muted mb-8"><%= @previous[:expression] %></div>
|
|
75
|
+
|
|
76
|
+
<% if @previous[:access_associations]&.any? %>
|
|
77
|
+
<div class="flex flex-wrap gap-4 mb-8">
|
|
78
|
+
<% @previous[:access_associations].each do |a| %>
|
|
79
|
+
<span class="assoc-pill">:<%= a %></span>
|
|
80
|
+
<% end %>
|
|
81
|
+
</div>
|
|
82
|
+
<% end %>
|
|
83
|
+
|
|
84
|
+
<div class="flex gap-16">
|
|
85
|
+
<div>
|
|
86
|
+
<span class="stat-value-22" style="color:<%= score_text_color(@previous[:score].to_i) %>;"><%= @previous[:score] %></span>
|
|
87
|
+
<span class="text-grey text-sm">score</span>
|
|
88
|
+
</div>
|
|
89
|
+
<div>
|
|
90
|
+
<span class="stat-value-22 text-blue"><%= @previous[:query_count] %></span>
|
|
91
|
+
<span class="text-grey text-sm">queries</span>
|
|
92
|
+
</div>
|
|
93
|
+
<div>
|
|
94
|
+
<span class="stat-value-22" style="color:<%= @previous[:n1_count].to_i > 0 ? '#fc8181' : '#68d391' %>;"><%= @previous[:n1_count] %></span>
|
|
95
|
+
<span class="text-grey text-sm">N+1</span>
|
|
96
|
+
</div>
|
|
97
|
+
<div>
|
|
98
|
+
<span class="stat-value-22 text-muted"><%= @previous[:duration_ms] %>ms</span>
|
|
99
|
+
<span class="text-grey text-sm">duration</span>
|
|
100
|
+
</div>
|
|
101
|
+
</div>
|
|
102
|
+
</div>
|
|
103
|
+
|
|
104
|
+
<%# Arrow + delta %>
|
|
105
|
+
<%
|
|
106
|
+
score_delta = @result.score.to_i - @previous[:score].to_i
|
|
107
|
+
query_delta = @result.query_count - @previous[:query_count].to_i
|
|
108
|
+
improved = score_delta > 0 || (score_delta == 0 && @result.n1_patterns.size < @previous[:n1_count].to_i)
|
|
109
|
+
worsened = score_delta < 0 || (score_delta == 0 && @result.n1_patterns.size > @previous[:n1_count].to_i)
|
|
110
|
+
arrow_color = improved ? "#68d391" : worsened ? "#fc8181" : "#4a5568"
|
|
111
|
+
%>
|
|
112
|
+
<div class="compare-arrow mb-16">
|
|
113
|
+
<div class="text-24" style="color:<%= arrow_color %>;">โ</div>
|
|
114
|
+
<% if score_delta != 0 %>
|
|
115
|
+
<div class="compare-delta" style="color:<%= arrow_color %>;">
|
|
116
|
+
<%= score_delta > 0 ? "+" : "" %><%= score_delta %>
|
|
117
|
+
</div>
|
|
118
|
+
<% end %>
|
|
119
|
+
</div>
|
|
120
|
+
|
|
121
|
+
<%# After โ current run %>
|
|
122
|
+
<div class="compare-box-after" style="border:1px solid <%= arrow_color %>44;">
|
|
123
|
+
<div class="mini-label mb-6" style="color:<%= arrow_color %>;">After โ</div>
|
|
124
|
+
<div class="mono text-12 text-muted mb-8"><%= @expression %></div>
|
|
125
|
+
|
|
126
|
+
<% if @prechecked_assocs&.any? %>
|
|
127
|
+
<div class="flex flex-wrap gap-4 mb-8">
|
|
128
|
+
<% @prechecked_assocs.each do |a| %>
|
|
129
|
+
<span class="assoc-pill-active">:<%= a %></span>
|
|
130
|
+
<% end %>
|
|
131
|
+
</div>
|
|
132
|
+
<% end %>
|
|
133
|
+
|
|
134
|
+
<div class="flex gap-16">
|
|
135
|
+
<div>
|
|
136
|
+
<span class="stat-value-22" style="color:<%= score_text_color(@result.score.to_i) %>;"><%= @result.score %></span>
|
|
137
|
+
<span class="text-grey text-sm">score</span>
|
|
138
|
+
</div>
|
|
139
|
+
<div>
|
|
140
|
+
<span class="stat-value-22 text-blue"><%= @result.query_count %></span>
|
|
141
|
+
<span class="text-grey text-sm">queries</span>
|
|
142
|
+
</div>
|
|
143
|
+
<div>
|
|
144
|
+
<span class="stat-value-22" style="color:<%= @result.n1_patterns.size > 0 ? '#fc8181' : '#68d391' %>;"><%= @result.n1_patterns.size %></span>
|
|
145
|
+
<span class="text-grey text-sm">N+1</span>
|
|
146
|
+
</div>
|
|
147
|
+
<div>
|
|
148
|
+
<span class="stat-value-22 text-muted"><%= @result.duration_ms %>ms</span>
|
|
149
|
+
<span class="text-grey text-sm">duration</span>
|
|
150
|
+
</div>
|
|
151
|
+
</div>
|
|
152
|
+
</div>
|
|
153
|
+
|
|
154
|
+
<%# Summary line %>
|
|
155
|
+
<%
|
|
156
|
+
improved = score_delta > 0 || (score_delta == 0 && @result.n1_patterns.size < @previous[:n1_count].to_i)
|
|
157
|
+
worsened = score_delta < 0 || (score_delta == 0 && @result.n1_patterns.size > @previous[:n1_count].to_i)
|
|
158
|
+
icon = improved ? "โ
" : worsened ? "โ" : "โ"
|
|
159
|
+
line_color = improved ? "#68d391" : worsened ? "#fc8181" : "#a0aec0"
|
|
160
|
+
%>
|
|
161
|
+
<div class="summary-line" style="color:<%= line_color %>;">
|
|
162
|
+
<%= icon %>
|
|
163
|
+
Score: <%= @previous[:score] %> โ <%= @result.score %>
|
|
164
|
+
(<%= score_delta >= 0 ? "+" : "" %><%= score_delta %> pts) ยท
|
|
165
|
+
Queries: <%= @previous[:query_count] %> โ <%= @result.query_count %>
|
|
166
|
+
(<%= query_delta >= 0 ? "+" : "" %><%= query_delta %>) ยท
|
|
167
|
+
N+1: <%= @previous[:n1_count] %> โ <%= @result.n1_patterns.size %>
|
|
168
|
+
</div>
|
|
169
|
+
|
|
170
|
+
<%# Result stats grid %>
|
|
171
|
+
<%
|
|
172
|
+
r_stats = [
|
|
173
|
+
{ label: "Queries Fired", value: @result.query_count,
|
|
174
|
+
color: @result.query_count > 25 ? "#fc8181" : @result.query_count > 10 ? "#f6ad55" : "#68d391" },
|
|
175
|
+
{ label: "Score", value: @result.score,
|
|
176
|
+
color: score_text_color(@result.score.to_i) },
|
|
177
|
+
{ label: "N+1 Patterns", value: @result.n1_patterns.size,
|
|
178
|
+
color: @result.n1_patterns.any? ? "#fc8181" : "#68d391" },
|
|
179
|
+
{ label: "Duration", value: "#{@result.duration_ms}ms",
|
|
180
|
+
color: "#a0aec0" }
|
|
181
|
+
]
|
|
182
|
+
%>
|
|
183
|
+
<div class="grid-4">
|
|
184
|
+
<% r_stats.each do |s| %>
|
|
185
|
+
<div class="stat-grid-box">
|
|
186
|
+
<div class="stat-value-xl bold mono" style="color:<%= s[:color] %>;"><%= s[:value] %></div>
|
|
187
|
+
<div class="text-muted text-sm mt-4"><%= s[:label] %></div>
|
|
188
|
+
</div>
|
|
189
|
+
<% end %>
|
|
190
|
+
</div>
|
|
191
|
+
|
|
192
|
+
<%# N+1 detected %>
|
|
193
|
+
<% if @result.n1_patterns.any? %>
|
|
194
|
+
<div class="n1-alert">
|
|
195
|
+
<div class="text-danger text-12 bold mb-8">
|
|
196
|
+
N+1 Patterns Detected (<%= @result.n1_patterns.size %>)
|
|
197
|
+
</div>
|
|
198
|
+
<% @result.n1_patterns.each do |p| %>
|
|
199
|
+
<div class="mono text-sm text-danger mb-4">
|
|
200
|
+
<%= p[:count] %>ร โ <%= truncate(p[:pattern], length: 100) %>
|
|
201
|
+
</div>
|
|
202
|
+
<% end %>
|
|
203
|
+
</div>
|
|
204
|
+
<% end %>
|
|
205
|
+
|
|
206
|
+
<%# Queries + DNA %>
|
|
207
|
+
<div class="card">
|
|
208
|
+
<div class="card-title">
|
|
209
|
+
Queries Fired
|
|
210
|
+
<span class="card-title-description">click any row to expand Query DNA</span>
|
|
211
|
+
</div>
|
|
212
|
+
|
|
213
|
+
<% if @query_dna.empty? %>
|
|
214
|
+
<div class="text-grey" style="font-size:13px;padding:8px 0;">
|
|
215
|
+
No queries fired โ the relation was not loaded or returned no results.
|
|
216
|
+
</div>
|
|
217
|
+
<% else %>
|
|
218
|
+
<table>
|
|
219
|
+
<thead>
|
|
220
|
+
<tr>
|
|
221
|
+
<th>#</th>
|
|
222
|
+
<th>SQL</th>
|
|
223
|
+
<th>Time</th>
|
|
224
|
+
<th>Complexity</th>
|
|
225
|
+
<th>Risk</th>
|
|
226
|
+
</tr>
|
|
227
|
+
</thead>
|
|
228
|
+
<tbody>
|
|
229
|
+
<% @query_dna.each_with_index do |item, i| %>
|
|
230
|
+
<% q = item[:query] %>
|
|
231
|
+
<% dna = item[:dna] %>
|
|
232
|
+
<% dna_id = "pg_dna_#{i}" %>
|
|
233
|
+
|
|
234
|
+
<tr onclick="toggleDna(<%= dna_id.to_json %>)" class="cursor-pointer">
|
|
235
|
+
<td class="text-muted"><%= i + 1 %></td>
|
|
236
|
+
<td class="truncate mono text-sm text-primary" style="max-width:400px;"><%= q[:sql] %></td>
|
|
237
|
+
<td style="color:<%= time_heat_color(q[:duration_ms]) %>;"><%= q[:duration_ms] %>ms</td>
|
|
238
|
+
<td>
|
|
239
|
+
<span style="color:<%= dna.complexity_label[:color] %>;" class="text-12">
|
|
240
|
+
<%= dna.complexity_label[:label] %>
|
|
241
|
+
<span class="text-muted text-10">(<%= dna.complexity %>/10)</span>
|
|
242
|
+
</span>
|
|
243
|
+
</td>
|
|
244
|
+
<td>
|
|
245
|
+
<span class="text-12 text-upper" style="color:<%= risk_color(dna.risk) %>;">
|
|
246
|
+
<%= dna.risk %>
|
|
247
|
+
</span>
|
|
248
|
+
</td>
|
|
249
|
+
</tr>
|
|
250
|
+
|
|
251
|
+
<%# DNA expansion panel %>
|
|
252
|
+
<tr id="<%= dna_id %>" class="d-none">
|
|
253
|
+
<td colspan="5" style="padding:0;">
|
|
254
|
+
<div class="dna-panel">
|
|
255
|
+
<pre class="code-block mb-12"><%= q[:sql] %></pre>
|
|
256
|
+
|
|
257
|
+
<div class="flex flex-wrap gap-6 mb-12">
|
|
258
|
+
<% dna.tokens.each do |token| %>
|
|
259
|
+
<span onclick="toggleCard(<%= "card_#{dna_id}_#{token[:type]}".to_json %>);event.stopPropagation();"
|
|
260
|
+
style="background:<%= token[:color] %>22;color:<%= token[:color] %>;border:1px solid <%= token[:color] %>66;padding:3px 10px;border-radius:4px;font-size:12px;font-family:monospace;cursor:pointer;">
|
|
261
|
+
<%= token[:label] %>
|
|
262
|
+
</span>
|
|
263
|
+
<% end %>
|
|
264
|
+
</div>
|
|
265
|
+
|
|
266
|
+
<% dna.tokens.each do |token| %>
|
|
267
|
+
<div id="card_<%= dna_id %>_<%= token[:type] %>" class="d-none mb-8">
|
|
268
|
+
<div style="background:#2d3748;border-left:3px solid <%= token[:color] %>;border-radius:4px;padding:12px 16px;">
|
|
269
|
+
<div class="mono bold text-sm mb-6" style="color:<%= token[:color] %>;">
|
|
270
|
+
๐ก <%= token[:label] %>
|
|
271
|
+
</div>
|
|
272
|
+
<div class="text-primary line-relaxed" style="font-size:13px;">
|
|
273
|
+
<%= token[:explanation] %>
|
|
274
|
+
</div>
|
|
275
|
+
</div>
|
|
276
|
+
</div>
|
|
277
|
+
<% end %>
|
|
278
|
+
</div>
|
|
279
|
+
</td>
|
|
280
|
+
</tr>
|
|
281
|
+
<% end %>
|
|
282
|
+
</tbody>
|
|
283
|
+
</table>
|
|
284
|
+
<% end %>
|
|
285
|
+
</div>
|
|
286
|
+
|
|
287
|
+
<% end %><%# closes if @result.error %>
|
|
288
|
+
</div><%# closes .result-fade in the error case %>
|
|
289
|
+
<% end %>
|
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
</div>
|
|
13
13
|
</div>
|
|
14
14
|
<div class="text-right">
|
|
15
|
-
<span class="badge badge
|
|
15
|
+
<span class="<%= "badge badge-#{@record.color}" %>" style="font-size:13px;padding:4px 14px;">
|
|
16
16
|
<%= @record.label %>
|
|
17
17
|
</span>
|
|
18
18
|
<div class="text-muted text-sm mt-6"><%= @record.recorded_at.strftime("%Y-%m-%d %H:%M:%S") %></div>
|
data/config/routes.rb
CHANGED
|
@@ -5,6 +5,7 @@ RailsVitals::Engine.routes.draw do
|
|
|
5
5
|
resources :models, only: [ :index ]
|
|
6
6
|
resources :n_plus_ones, only: [ :index, :show ]
|
|
7
7
|
resources :associations, only: [ :index ]
|
|
8
|
+
resources :playgrounds, only: [ :index, :create ]
|
|
8
9
|
get "heatmap", to: "heatmap#index", as: :heatmap
|
|
9
10
|
get "requests/:request_id/explain/:query_index", to: "explains#show", as: :explain
|
|
10
11
|
end
|
|
@@ -6,6 +6,20 @@ module RailsVitals
|
|
|
6
6
|
attach_action_controller_subscriber
|
|
7
7
|
end
|
|
8
8
|
|
|
9
|
+
# Skip Rails internal queries โ schema lookups, explain, etc.
|
|
10
|
+
def self.internal_query?(sql)
|
|
11
|
+
sql =~ /\A\s*(SCHEMA|EXPLAIN|PRAGMA|BEGIN|COMMIT|ROLLBACK|SAVEPOINT|RELEASE)/i ||
|
|
12
|
+
sql.include?("pg_class") ||
|
|
13
|
+
sql.include?("pg_attribute") ||
|
|
14
|
+
sql.include?("pg_type") ||
|
|
15
|
+
sql.include?("t.typname") ||
|
|
16
|
+
sql.include?("t.oid") ||
|
|
17
|
+
sql.include?("information_schema") ||
|
|
18
|
+
sql.include?("pg_namespace") ||
|
|
19
|
+
sql.include?("SHOW search_path") ||
|
|
20
|
+
sql.include?("SHOW max_identifier_length")
|
|
21
|
+
end
|
|
22
|
+
|
|
9
23
|
private_class_method def self.attach_sql_subscriber
|
|
10
24
|
ActiveSupport::Notifications.subscribe("sql.active_record") do |event|
|
|
11
25
|
next unless RailsVitals.config.enabled
|
|
@@ -32,20 +46,6 @@ module RailsVitals
|
|
|
32
46
|
end
|
|
33
47
|
end
|
|
34
48
|
|
|
35
|
-
# Skip Rails internal queries โ schema lookups, explain, etc.
|
|
36
|
-
private_class_method def self.internal_query?(sql)
|
|
37
|
-
sql =~ /\A\s*(SCHEMA|EXPLAIN|PRAGMA|BEGIN|COMMIT|ROLLBACK|SAVEPOINT|RELEASE)/i ||
|
|
38
|
-
sql.include?("pg_class") ||
|
|
39
|
-
sql.include?("pg_attribute") ||
|
|
40
|
-
sql.include?("pg_type") ||
|
|
41
|
-
sql.include?("t.typname") ||
|
|
42
|
-
sql.include?("t.oid") ||
|
|
43
|
-
sql.include?("information_schema") ||
|
|
44
|
-
sql.include?("pg_namespace") ||
|
|
45
|
-
sql.include?("SHOW search_path") ||
|
|
46
|
-
sql.include?("SHOW max_identifier_length")
|
|
47
|
-
end
|
|
48
|
-
|
|
49
49
|
private_class_method def self.rails_vitals_request?
|
|
50
50
|
Thread.current[:rails_vitals_own_request]
|
|
51
51
|
end
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
module RailsVitals
|
|
2
|
+
module Playground
|
|
3
|
+
class Sandbox
|
|
4
|
+
ALLOWED_METHODS = %w[
|
|
5
|
+
all where select limit offset order group
|
|
6
|
+
includes preload eager_load joins left_joins
|
|
7
|
+
find find_by first last count sum average
|
|
8
|
+
pluck distinct having references unscoped
|
|
9
|
+
].freeze
|
|
10
|
+
|
|
11
|
+
BLOCKED_PATTERNS = [
|
|
12
|
+
/\b(insert|update|delete|destroy|drop|truncate|create|alter)\b/i,
|
|
13
|
+
/\.save/i, /\.save!/i, /\.update/i, /\.delete/i,
|
|
14
|
+
/\.destroy/i, /`/
|
|
15
|
+
].freeze
|
|
16
|
+
|
|
17
|
+
DEFAULT_LIMIT = 100
|
|
18
|
+
|
|
19
|
+
Result = Struct.new(
|
|
20
|
+
:queries, :query_count, :duration_ms,
|
|
21
|
+
:error, :model_name, :record_count,
|
|
22
|
+
:score, :n1_patterns,
|
|
23
|
+
keyword_init: true
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
def self.run(expression, access_associations: [])
|
|
27
|
+
return blocked_result("No expression provided") if expression.blank?
|
|
28
|
+
|
|
29
|
+
BLOCKED_PATTERNS.each do |pattern|
|
|
30
|
+
return blocked_result(
|
|
31
|
+
"Expression contains blocked operation. " \
|
|
32
|
+
"The Playground is read-only โ no writes permitted."
|
|
33
|
+
) if expression.match?(pattern)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
model_name = extract_model_name(expression)
|
|
37
|
+
return blocked_result(
|
|
38
|
+
"Could not detect model from expression. " \
|
|
39
|
+
"Start your query with a model name e.g. Post.includes(:likes)"
|
|
40
|
+
) unless model_name
|
|
41
|
+
|
|
42
|
+
model = safe_constantize(model_name)
|
|
43
|
+
return blocked_result(
|
|
44
|
+
"Unknown model: #{model_name}. " \
|
|
45
|
+
"Available models: #{available_models.join(', ')}"
|
|
46
|
+
) unless model
|
|
47
|
+
|
|
48
|
+
queries = []
|
|
49
|
+
start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
50
|
+
|
|
51
|
+
subscriber = ActiveSupport::Notifications.subscribe("sql.active_record") do |*, payload|
|
|
52
|
+
next if RailsVitals::Notifications::Subscriber.internal_query?(payload[:sql])
|
|
53
|
+
queries << {
|
|
54
|
+
sql: payload[:sql],
|
|
55
|
+
duration_ms: (payload[:duration].to_f / 1000).round(3)
|
|
56
|
+
}
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
begin
|
|
60
|
+
Timeout.timeout(2) do
|
|
61
|
+
relation = build_relation(expression, model)
|
|
62
|
+
relation = apply_limit(relation)
|
|
63
|
+
records = relation.load
|
|
64
|
+
|
|
65
|
+
# Simulate association access โ triggers N+1 if not eager loaded
|
|
66
|
+
if access_associations.any?
|
|
67
|
+
records.each do |record|
|
|
68
|
+
access_associations.each do |assoc|
|
|
69
|
+
next unless record.class.reflect_on_association(assoc.to_sym)
|
|
70
|
+
assoc_value = record.public_send(assoc)
|
|
71
|
+
# Force load if it's a relation
|
|
72
|
+
assoc_value.load if assoc_value.respond_to?(:load)
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
rescue Timeout::Error
|
|
78
|
+
return blocked_result("Query timed out after 2 seconds.")
|
|
79
|
+
rescue => e
|
|
80
|
+
return blocked_result("Execution error: #{e.message}")
|
|
81
|
+
ensure
|
|
82
|
+
ActiveSupport::Notifications.unsubscribe(subscriber)
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
duration_ms = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - start_time) * 1000).round(2)
|
|
86
|
+
n1_patterns = detect_n1(queries)
|
|
87
|
+
score = project_score(queries.size, n1_patterns.size)
|
|
88
|
+
|
|
89
|
+
Result.new(
|
|
90
|
+
queries: queries,
|
|
91
|
+
query_count: queries.size,
|
|
92
|
+
duration_ms: duration_ms,
|
|
93
|
+
error: nil,
|
|
94
|
+
model_name: model_name,
|
|
95
|
+
record_count: DEFAULT_LIMIT,
|
|
96
|
+
score: score,
|
|
97
|
+
n1_patterns: n1_patterns
|
|
98
|
+
)
|
|
99
|
+
rescue => e
|
|
100
|
+
blocked_result("Unexpected error: #{e.message}")
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def self.associations_for(model_name)
|
|
104
|
+
model = safe_constantize(model_name)
|
|
105
|
+
return [] unless model
|
|
106
|
+
|
|
107
|
+
model.reflect_on_all_associations.map { |r| r.name.to_s }.sort
|
|
108
|
+
rescue
|
|
109
|
+
[]
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def self.extract_model_name(expression)
|
|
113
|
+
# Strip comments first
|
|
114
|
+
clean = expression.gsub(/#[^\n]*/, "").strip
|
|
115
|
+
# First word before a dot or whitespace โ must look like a constant (CamelCase)
|
|
116
|
+
match = clean.match(/\A([A-Z][A-Za-z0-9]*)/)
|
|
117
|
+
match ? match[1] : nil
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
private
|
|
121
|
+
|
|
122
|
+
def self.safe_constantize(name)
|
|
123
|
+
return nil unless name.match?(/\A[A-Z][A-Za-z0-9:]*\z/)
|
|
124
|
+
|
|
125
|
+
klass = name.constantize
|
|
126
|
+
return nil unless klass < ActiveRecord::Base
|
|
127
|
+
|
|
128
|
+
klass
|
|
129
|
+
rescue NameError
|
|
130
|
+
nil
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def self.build_relation(expression, model)
|
|
134
|
+
# Parse "Post.includes(:likes).where(published: true).limit(10)"
|
|
135
|
+
# Strip the model name prefix if present
|
|
136
|
+
chain_str = expression
|
|
137
|
+
.sub(/\A#{Regexp.escape(model.name)}\s*\.?\s*/, "")
|
|
138
|
+
.strip
|
|
139
|
+
|
|
140
|
+
return model.all if chain_str.blank?
|
|
141
|
+
|
|
142
|
+
# Build the chain by safe eval within a controlled binding
|
|
143
|
+
# Only the model constant is exposed, no access to app globals
|
|
144
|
+
sandbox_binding = build_binding(model)
|
|
145
|
+
relation = eval(chain_str, sandbox_binding) # rubocop:disable Security/Eval
|
|
146
|
+
|
|
147
|
+
unless relation.is_a?(ActiveRecord::Relation)
|
|
148
|
+
raise "Expression must return an ActiveRecord::Relation"
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
relation
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def self.build_binding(model)
|
|
155
|
+
# Create a minimal binding with only the model exposed
|
|
156
|
+
ctx = Object.new
|
|
157
|
+
ctx.define_singleton_method(:relation) { model.all }
|
|
158
|
+
ctx.instance_eval { binding }
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
def self.apply_limit(relation)
|
|
162
|
+
# Only apply default limit if no limit already set
|
|
163
|
+
if relation.limit_value.nil?
|
|
164
|
+
relation.limit(DEFAULT_LIMIT)
|
|
165
|
+
else
|
|
166
|
+
relation
|
|
167
|
+
end
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
def self.detect_n1(queries)
|
|
171
|
+
normalized = queries.map do |q|
|
|
172
|
+
q[:sql]
|
|
173
|
+
.gsub(/\b\d+\b/, "?")
|
|
174
|
+
.gsub(/'[^']*'/, "?")
|
|
175
|
+
.gsub(/\bIN\s*\([^)]+\)/, "IN (?)")
|
|
176
|
+
.downcase.strip
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
normalized
|
|
180
|
+
.tally
|
|
181
|
+
.select { |_, count| count > 1 }
|
|
182
|
+
.map { |sql, count| { pattern: sql, count: count } }
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
def self.project_score(query_count, n1_count)
|
|
186
|
+
config = RailsVitals.config
|
|
187
|
+
query_score = score_queries(query_count, config)
|
|
188
|
+
n1_score = score_n1(n1_count)
|
|
189
|
+
(query_score * 0.40 + n1_score * 0.60).round
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
def self.score_queries(count, config)
|
|
193
|
+
return 100 if count <= config.query_warn_threshold
|
|
194
|
+
return 0 if count >= config.query_critical_threshold
|
|
195
|
+
|
|
196
|
+
range = config.query_critical_threshold - config.query_warn_threshold
|
|
197
|
+
(100 - ((count - config.query_warn_threshold).to_f / range * 100)).round
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
def self.score_n1(count)
|
|
201
|
+
[ 100 - (count * 25), 0 ].max
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
def self.blocked_result(message)
|
|
205
|
+
Result.new(
|
|
206
|
+
queries: [], query_count: 0, duration_ms: 0,
|
|
207
|
+
error: message, model_name: nil, record_count: 0,
|
|
208
|
+
score: nil, n1_patterns: []
|
|
209
|
+
)
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
def self.available_models
|
|
213
|
+
ActiveRecord::Base.descendants
|
|
214
|
+
.reject(&:abstract_class?)
|
|
215
|
+
.reject { |m| m.name&.start_with?("RailsVitals") }
|
|
216
|
+
.select { |m| m.table_exists? rescue false }
|
|
217
|
+
.map(&:name)
|
|
218
|
+
.sort
|
|
219
|
+
end
|
|
220
|
+
end
|
|
221
|
+
end
|
|
222
|
+
end
|
data/lib/rails_vitals/version.rb
CHANGED
data/lib/rails_vitals.rb
CHANGED
|
@@ -13,6 +13,7 @@ require "rails_vitals/scorers/base_scorer"
|
|
|
13
13
|
require "rails_vitals/scorers/query_scorer"
|
|
14
14
|
require "rails_vitals/scorers/n_plus_one_scorer"
|
|
15
15
|
require "rails_vitals/scorers/composite_scorer"
|
|
16
|
+
require "rails_vitals/playground/sandbox"
|
|
16
17
|
require "rails_vitals/panel_renderer"
|
|
17
18
|
require "rails_vitals/middleware/panel_injector"
|
|
18
19
|
require "rails_vitals/engine"
|
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.4.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- David Sanchez
|
|
@@ -47,6 +47,7 @@ files:
|
|
|
47
47
|
- app/controllers/rails_vitals/heatmap_controller.rb
|
|
48
48
|
- app/controllers/rails_vitals/models_controller.rb
|
|
49
49
|
- app/controllers/rails_vitals/n_plus_ones_controller.rb
|
|
50
|
+
- app/controllers/rails_vitals/playgrounds_controller.rb
|
|
50
51
|
- app/controllers/rails_vitals/requests_controller.rb
|
|
51
52
|
- app/helpers/rails_vitals/application_helper.rb
|
|
52
53
|
- app/jobs/rails_vitals/application_job.rb
|
|
@@ -61,6 +62,7 @@ files:
|
|
|
61
62
|
- app/views/rails_vitals/models/index.html.erb
|
|
62
63
|
- app/views/rails_vitals/n_plus_ones/index.html.erb
|
|
63
64
|
- app/views/rails_vitals/n_plus_ones/show.html.erb
|
|
65
|
+
- app/views/rails_vitals/playgrounds/index.html.erb
|
|
64
66
|
- app/views/rails_vitals/requests/index.html.erb
|
|
65
67
|
- app/views/rails_vitals/requests/show.html.erb
|
|
66
68
|
- config/routes.rb
|
|
@@ -76,6 +78,7 @@ files:
|
|
|
76
78
|
- lib/rails_vitals/middleware/panel_injector.rb
|
|
77
79
|
- lib/rails_vitals/notifications/subscriber.rb
|
|
78
80
|
- lib/rails_vitals/panel_renderer.rb
|
|
81
|
+
- lib/rails_vitals/playground/sandbox.rb
|
|
79
82
|
- lib/rails_vitals/request_record.rb
|
|
80
83
|
- lib/rails_vitals/scorers/base_scorer.rb
|
|
81
84
|
- lib/rails_vitals/scorers/composite_scorer.rb
|