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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 85f8d6772f3f10badb2a598143172c1e11375eb017affe72007fe893e2f6d241
4
- data.tar.gz: 072320c0d3826677abacc3d36cbfd22843be687a955e41c9f924cb07bba640f0
3
+ metadata.gz: 8fc3f44a23831df5f94973a04dfd928519ac7b14adf763147cc7b4d6e6ee2702
4
+ data.tar.gz: 39be2ad3de1a6569e42e5c22cea38a04db5c933910014de9c5963652c6a15451
5
5
  SHA512:
6
- metadata.gz: d6648831e36ed7b41615af2636939d1f1dace58b270a311486820ee5d9e48581a5d617d590622855dbf6430dfdbc3ed1716721bb6d03f58e1af8dda8a2fcd969
7
- data.tar.gz: 204fab1e081f960f358295b56f42666800867133c14eafdcc6074524694110a5de0cef994e65647f504ffea3d90aab42620b861ce1c00e3de31b32e929c40278
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
  ![EXPLAIN Visualizer](https://github.com/user-attachments/assets/e3547822-17ce-40e8-a468-714bbff01cd9)
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
+ ![Playground](https://github.com/user-attachments/assets/7df1bfbe-1576-4048-810d-465e52115d19)
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-<%= @record.color %>" style="font-size:13px;padding:4px 14px;">
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
@@ -1,3 +1,3 @@
1
1
  module RailsVitals
2
- VERSION = "0.3.0"
2
+ VERSION = "0.4.0"
3
3
  end
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.3.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