n_plus_insight 0.1.2 → 0.1.3
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/CHANGELOG.md +8 -0
- data/README.md +4 -2
- data/app/assets/n_plus_insight/overlay.css +27 -0
- data/app/assets/n_plus_insight/overlay.js +96 -1
- data/app/views/layouts/n_plus_insight/application.html.erb +36 -1
- data/app/views/n_plus_insight/detections/show.html.erb +75 -0
- data/lib/n_plus_insight/analyzer.rb +41 -1
- data/lib/n_plus_insight/detection.rb +1 -1
- data/lib/n_plus_insight/overlay.rb +1 -0
- data/lib/n_plus_insight/query.rb +1 -1
- data/lib/n_plus_insight/source_location.rb +20 -11
- data/lib/n_plus_insight/subscriber.rb +1 -0
- data/lib/n_plus_insight/version.rb +1 -1
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 158d6070accc936171d9807ac597638529b17fc02e84694e28fc6a10ac250f26
|
|
4
|
+
data.tar.gz: 0ea83df32503c9f713e979053664b55d13ffc4492249d9d206f545f6ebf68c41
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: a340454688cc2beadcf95c747dd53d928c0e657a7b3c580b5415b011dac3b1640f58c97a5df30e8d853531e787e240a40f5855efb71ffce060cfec87c9bd671b
|
|
7
|
+
data.tar.gz: 6c483b35801c707df9f88777a0417fd548d9a74996f137673284e9dbca95477b1365e2b011c960a00ec5effb091ff78e05858f42c360ffe08d2157356f4710ce
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,13 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.1.3 - 2026-08-03
|
|
4
|
+
|
|
5
|
+
- Add per-pattern query loading waterfalls to the mounted dashboard and
|
|
6
|
+
on-page inspector, including individual query offsets, durations, elapsed
|
|
7
|
+
span, and cumulative database time.
|
|
8
|
+
- Add styled hover and keyboard-focus tooltips to every waterfall query bar.
|
|
9
|
+
- Add expandable backtrace to pattern show page.
|
|
10
|
+
|
|
3
11
|
## 0.1.0 - 2026-07-28
|
|
4
12
|
|
|
5
13
|
- Detect repeated Active Record query shapes within a request.
|
data/README.md
CHANGED
|
@@ -15,6 +15,7 @@ For each finding, NPlusInsight shows:
|
|
|
15
15
|
|
|
16
16
|
- the application file, line number, and surrounding source code;
|
|
17
17
|
- the repeated SQL shape, query count, and cumulative duration;
|
|
18
|
+
- a query loading waterfall with per-query start offsets and durations;
|
|
18
19
|
- the Active Record models and associations involved;
|
|
19
20
|
- suggested eager-loading and strict-loading remediations;
|
|
20
21
|
- an optional on-page alert and a mounted findings dashboard.
|
|
@@ -224,8 +225,9 @@ For each uncached `SELECT` query, it:
|
|
|
224
225
|
6. combines repeated shapes attributed to the same application source line into
|
|
225
226
|
one finding;
|
|
226
227
|
7. inspects Active Record reflections to infer every relevant model association;
|
|
227
|
-
8.
|
|
228
|
-
9.
|
|
228
|
+
8. plots each repeated query on a per-pattern loading waterfall;
|
|
229
|
+
9. renders the affected associations as a model tree;
|
|
230
|
+
10. generates combined eager-loading and strict-loading suggestions for the
|
|
229
231
|
complete tree.
|
|
230
232
|
|
|
231
233
|
For example, if one serializer line lazily loads both `post.comments` and
|
|
@@ -104,6 +104,30 @@
|
|
|
104
104
|
#n1v-root .n1v-line { display: grid !important; grid-template-columns: 38px minmax(0, 1fr) !important; min-width: max-content !important; padding: 0 12px !important; }
|
|
105
105
|
#n1v-root .n1v-line.n1v-active { background: rgba(217, 45, 32, .28) !important; }
|
|
106
106
|
#n1v-root .n1v-line-number { color: #98a2b3 !important; user-select: none !important; }
|
|
107
|
+
#n1v-root .n1v-waterfall-summary { margin: 0 0 8px !important; color: var(--n1v-muted) !important; font: 11px/1.4 ui-sans-serif, system-ui, sans-serif !important; }
|
|
108
|
+
#n1v-root .n1v-waterfall { min-width: 0 !important; }
|
|
109
|
+
#n1v-root .n1v-waterfall-axis { display: flex !important; justify-content: space-between !important; margin: 0 62px 4px 94px !important; color: var(--n1v-muted) !important; font: 10px/1.3 ui-sans-serif, system-ui, sans-serif !important; }
|
|
110
|
+
#n1v-root .n1v-waterfall-row { display: grid !important; grid-template-columns: 84px minmax(120px, 1fr) 54px !important; gap: 8px !important; align-items: center !important; min-height: 34px !important; margin: 0 !important; padding: 0 !important; }
|
|
111
|
+
#n1v-root .n1v-waterfall-row + .n1v-waterfall-row { border-top: 1px solid var(--n1v-border) !important; }
|
|
112
|
+
#n1v-root .n1v-waterfall-label { min-width: 0 !important; padding: 6px 0 !important; }
|
|
113
|
+
#n1v-root .n1v-waterfall-label strong, #n1v-root .n1v-waterfall-label small { display: block !important; overflow: hidden !important; color: var(--n1v-text) !important; text-overflow: ellipsis !important; white-space: nowrap !important; font: 600 11px/1.35 ui-sans-serif, system-ui, sans-serif !important; }
|
|
114
|
+
#n1v-root .n1v-waterfall-label small { color: var(--n1v-muted) !important; font-size: 10px !important; font-weight: 400 !important; }
|
|
115
|
+
#n1v-root .n1v-waterfall-track { position: relative !important; height: 20px !important; overflow: visible !important; border-radius: 5px !important; background: repeating-linear-gradient(90deg, var(--n1v-surface) 0, var(--n1v-surface) calc(25% - 1px), var(--n1v-border) 25%) !important; }
|
|
116
|
+
#n1v-root .n1v-waterfall-bar { position: absolute !important; top: 3px !important; left: var(--n1v-waterfall-start) !important; width: var(--n1v-waterfall-width) !important; min-width: 3px !important; height: 14px !important; border-radius: 3px !important; background: var(--n1v-accent) !important; box-shadow: inset 0 0 0 1px rgba(255,255,255,.2) !important; }
|
|
117
|
+
#n1v-root .n1v-waterfall-bar:hover, #n1v-root .n1v-waterfall-bar:focus-visible { z-index: 20 !important; filter: brightness(1.15) !important; outline: 2px solid var(--n1v-accent) !important; outline-offset: 2px !important; }
|
|
118
|
+
#n1v-root .n1v-waterfall-tooltip { position: absolute !important; left: 50% !important; bottom: calc(100% + 8px) !important; z-index: 30 !important; display: grid !important; grid-template-columns: auto auto !important; gap: 1px 10px !important; width: max-content !important; max-width: 180px !important; margin: 0 !important; padding: 7px 9px !important; border: 1px solid var(--n1v-border) !important; border-radius: 7px !important; background: var(--n1v-text) !important; color: var(--n1v-bg) !important; box-shadow: 0 7px 20px rgba(0,0,0,.25) !important; opacity: 0 !important; pointer-events: none !important; transform: translate(-50%,4px) !important; transition: opacity .12s ease,transform .12s ease !important; font: 10px/1.35 ui-sans-serif,system-ui,sans-serif !important; text-align: left !important; white-space: nowrap !important; }
|
|
119
|
+
#n1v-root .n1v-waterfall-tooltip::after { content: "" !important; position: absolute !important; top: 100% !important; left: 50% !important; border: 5px solid transparent !important; border-top-color: var(--n1v-text) !important; transform: translateX(-50%) !important; }
|
|
120
|
+
#n1v-root .n1v-waterfall-tooltip strong { margin: 0 !important; color: inherit !important; font: 650 10px/1.35 ui-sans-serif,system-ui,sans-serif !important; }
|
|
121
|
+
#n1v-root .n1v-waterfall-tooltip > span { margin: 0 !important; color: inherit !important; font: 700 10px/1.35 ui-sans-serif,system-ui,sans-serif !important; text-align: right !important; }
|
|
122
|
+
#n1v-root .n1v-waterfall-tooltip small { grid-column: 1 / -1 !important; margin: 0 !important; color: inherit !important; opacity: .72 !important; font: 10px/1.35 ui-sans-serif,system-ui,sans-serif !important; }
|
|
123
|
+
#n1v-root .n1v-waterfall-bar:hover .n1v-waterfall-tooltip, #n1v-root .n1v-waterfall-bar:focus-visible .n1v-waterfall-tooltip { opacity: 1 !important; transform: translate(-50%,0) !important; }
|
|
124
|
+
#n1v-root .n1v-waterfall-bar-start .n1v-waterfall-tooltip { left: 0 !important; transform: translate(0,4px) !important; }
|
|
125
|
+
#n1v-root .n1v-waterfall-bar-start:hover .n1v-waterfall-tooltip, #n1v-root .n1v-waterfall-bar-start:focus-visible .n1v-waterfall-tooltip { transform: translate(0,0) !important; }
|
|
126
|
+
#n1v-root .n1v-waterfall-bar-start .n1v-waterfall-tooltip::after { left: 5px !important; transform: none !important; }
|
|
127
|
+
#n1v-root .n1v-waterfall-bar-end .n1v-waterfall-tooltip { right: 0 !important; left: auto !important; transform: translate(0,4px) !important; }
|
|
128
|
+
#n1v-root .n1v-waterfall-bar-end:hover .n1v-waterfall-tooltip, #n1v-root .n1v-waterfall-bar-end:focus-visible .n1v-waterfall-tooltip { transform: translate(0,0) !important; }
|
|
129
|
+
#n1v-root .n1v-waterfall-bar-end .n1v-waterfall-tooltip::after { right: 5px !important; left: auto !important; transform: none !important; }
|
|
130
|
+
#n1v-root .n1v-waterfall-total { color: var(--n1v-muted) !important; text-align: right !important; white-space: nowrap !important; font: 10px/1.3 ui-sans-serif, system-ui, sans-serif !important; }
|
|
107
131
|
#n1v-root .n1v-graph { min-height: 74px !important; overflow: auto !important; padding: 4px 0 !important; }
|
|
108
132
|
#n1v-root .n1v-tree { margin: 0 !important; padding: 0 !important; list-style: none !important; }
|
|
109
133
|
#n1v-root .n1v-tree .n1v-tree { margin-left: 18px !important; padding-left: 18px !important; border-left: 1px solid var(--n1v-border) !important; }
|
|
@@ -131,8 +155,11 @@
|
|
|
131
155
|
@media (max-width: 560px) {
|
|
132
156
|
#n1v-root { right: 10px !important; bottom: 10px !important; }
|
|
133
157
|
#n1v-root .n1v-panel { width: calc(100vw - 20px) !important; max-height: calc(100vh - 84px) !important; bottom: 62px !important; }
|
|
158
|
+
#n1v-root .n1v-waterfall-axis { margin-left: 72px !important; }
|
|
159
|
+
#n1v-root .n1v-waterfall-row { grid-template-columns: 64px minmax(96px, 1fr) 48px !important; gap: 5px !important; }
|
|
134
160
|
}
|
|
135
161
|
|
|
136
162
|
@media (prefers-reduced-motion: reduce) {
|
|
137
163
|
#n1v-root .n1v-launcher { animation: none !important; }
|
|
164
|
+
#n1v-root .n1v-waterfall-tooltip { transition: none !important; }
|
|
138
165
|
}
|
|
@@ -23,16 +23,38 @@
|
|
|
23
23
|
}
|
|
24
24
|
}
|
|
25
25
|
|
|
26
|
+
function formatMilliseconds(value) {
|
|
27
|
+
return (Number(value) || 0)
|
|
28
|
+
.toFixed(2)
|
|
29
|
+
.replace(/\.00$/, "")
|
|
30
|
+
.replace(/(\.\d)0$/, "$1");
|
|
31
|
+
}
|
|
32
|
+
|
|
26
33
|
function queryGroupsFor(finding) {
|
|
27
34
|
if ((finding.query_groups || []).length) return finding.query_groups;
|
|
28
35
|
return [{
|
|
29
36
|
sql: finding.sql,
|
|
30
37
|
query_count: finding.query_count,
|
|
31
38
|
total_ms: finding.total_ms,
|
|
32
|
-
tables: []
|
|
39
|
+
tables: [],
|
|
40
|
+
waterfall: []
|
|
33
41
|
}];
|
|
34
42
|
}
|
|
35
43
|
|
|
44
|
+
function waterfallEntriesFor(group) {
|
|
45
|
+
if ((group.waterfall || []).length) return group.waterfall;
|
|
46
|
+
|
|
47
|
+
var count = Number(group.query_count) || 0;
|
|
48
|
+
var duration = count ? (Number(group.total_ms) || 0) / count : 0;
|
|
49
|
+
return Array.from({ length: count }, function (_value, index) {
|
|
50
|
+
return {
|
|
51
|
+
index: index + 1,
|
|
52
|
+
offset_ms: duration * index,
|
|
53
|
+
duration_ms: duration
|
|
54
|
+
};
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
|
|
36
58
|
var findings = decodePayload(root.dataset.payload || "");
|
|
37
59
|
var initialFindingIndex = findings.reduce(function (bestIndex, finding, index) {
|
|
38
60
|
var best = findings[bestIndex];
|
|
@@ -130,6 +152,78 @@
|
|
|
130
152
|
container.appendChild(section);
|
|
131
153
|
}
|
|
132
154
|
|
|
155
|
+
function renderWaterfall(container, finding) {
|
|
156
|
+
var groups = queryGroupsFor(finding);
|
|
157
|
+
var span = Number(finding.waterfall_ms) || groups.reduce(function (maximum, group) {
|
|
158
|
+
return Math.max(maximum, waterfallEntriesFor(group).reduce(function (groupMaximum, query) {
|
|
159
|
+
return Math.max(groupMaximum, Number(query.offset_ms) + Number(query.duration_ms));
|
|
160
|
+
}, 0));
|
|
161
|
+
}, 0);
|
|
162
|
+
span = Math.max(span, 0.01);
|
|
163
|
+
|
|
164
|
+
var section = element("section", "n1v-section");
|
|
165
|
+
section.appendChild(element("h3", "n1v-heading", "Query loading waterfall"));
|
|
166
|
+
section.appendChild(element(
|
|
167
|
+
"p",
|
|
168
|
+
"n1v-waterfall-summary",
|
|
169
|
+
formatMilliseconds(finding.waterfall_ms || span) +
|
|
170
|
+
" ms elapsed · " + finding.total_ms + " ms database time · Hover a bar for timing"
|
|
171
|
+
));
|
|
172
|
+
|
|
173
|
+
var waterfall = element("div", "n1v-waterfall");
|
|
174
|
+
waterfall.setAttribute("role", "img");
|
|
175
|
+
waterfall.setAttribute(
|
|
176
|
+
"aria-label",
|
|
177
|
+
"Waterfall of repeated query patterns over " + Number(finding.waterfall_ms || span) + " milliseconds"
|
|
178
|
+
);
|
|
179
|
+
|
|
180
|
+
var axis = element("div", "n1v-waterfall-axis");
|
|
181
|
+
axis.setAttribute("aria-hidden", "true");
|
|
182
|
+
axis.appendChild(element("span", "", "0 ms"));
|
|
183
|
+
axis.appendChild(element("span", "", formatMilliseconds(finding.waterfall_ms || span) + " ms"));
|
|
184
|
+
waterfall.appendChild(axis);
|
|
185
|
+
|
|
186
|
+
groups.forEach(function (group, groupIndex) {
|
|
187
|
+
var row = element("div", "n1v-waterfall-row");
|
|
188
|
+
var label = element("div", "n1v-waterfall-label");
|
|
189
|
+
label.appendChild(element("strong", "", "Pattern " + (groupIndex + 1)));
|
|
190
|
+
label.appendChild(element("small", "", (group.tables || []).join(", ") || "Repeated query"));
|
|
191
|
+
row.appendChild(label);
|
|
192
|
+
|
|
193
|
+
var track = element("div", "n1v-waterfall-track");
|
|
194
|
+
waterfallEntriesFor(group).forEach(function (query, queryIndex) {
|
|
195
|
+
var offset = Math.max(Number(query.offset_ms) || 0, 0);
|
|
196
|
+
var duration = Math.max(Number(query.duration_ms) || 0, 0);
|
|
197
|
+
var startPercent = Math.min(offset / span * 100, 100);
|
|
198
|
+
var widthPercent = Math.min(duration / span * 100, 100 - startPercent);
|
|
199
|
+
var edgeClass = startPercent < 20 ? " n1v-waterfall-bar-start" :
|
|
200
|
+
(startPercent > 80 ? " n1v-waterfall-bar-end" : "");
|
|
201
|
+
var bar = element("span", "n1v-waterfall-bar" + edgeClass);
|
|
202
|
+
var queryNumber = query.index || queryIndex + 1;
|
|
203
|
+
bar.style.setProperty("--n1v-waterfall-start", startPercent.toFixed(4) + "%");
|
|
204
|
+
bar.style.setProperty("--n1v-waterfall-width", widthPercent.toFixed(4) + "%");
|
|
205
|
+
bar.tabIndex = 0;
|
|
206
|
+
|
|
207
|
+
var tooltip = element("span", "n1v-waterfall-tooltip");
|
|
208
|
+
var tooltipId = "n1v-waterfall-tooltip-" + groupIndex + "-" + queryIndex;
|
|
209
|
+
tooltip.id = tooltipId;
|
|
210
|
+
tooltip.setAttribute("role", "tooltip");
|
|
211
|
+
tooltip.appendChild(element("strong", "", "Query " + queryNumber));
|
|
212
|
+
tooltip.appendChild(element("span", "", formatMilliseconds(duration) + " ms"));
|
|
213
|
+
tooltip.appendChild(element("small", "", "Started +" + formatMilliseconds(offset) + " ms"));
|
|
214
|
+
bar.setAttribute("aria-describedby", tooltipId);
|
|
215
|
+
bar.appendChild(tooltip);
|
|
216
|
+
track.appendChild(bar);
|
|
217
|
+
});
|
|
218
|
+
row.appendChild(track);
|
|
219
|
+
row.appendChild(element("span", "n1v-waterfall-total", group.total_ms + " ms"));
|
|
220
|
+
waterfall.appendChild(row);
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
section.appendChild(waterfall);
|
|
224
|
+
container.appendChild(section);
|
|
225
|
+
}
|
|
226
|
+
|
|
133
227
|
function renderTreeNode(entry) {
|
|
134
228
|
var item = element("li", "n1v-tree-item");
|
|
135
229
|
if (entry.association) {
|
|
@@ -223,6 +317,7 @@
|
|
|
223
317
|
|
|
224
318
|
var finding = findings[index];
|
|
225
319
|
renderSource(body, finding);
|
|
320
|
+
renderWaterfall(body, finding);
|
|
226
321
|
renderGraph(body, finding);
|
|
227
322
|
renderQueries(body, finding);
|
|
228
323
|
renderFixes(body, finding);
|
|
@@ -32,6 +32,31 @@
|
|
|
32
32
|
.source div.active { background:rgba(180,35,24,.28); } .source b { color:#9ca3af; font-weight:400; user-select:none; }
|
|
33
33
|
.query-pattern + .query-pattern { border-top:1px solid var(--line); padding-top:18px; margin-top:18px; }
|
|
34
34
|
.query-pattern h3 { margin-bottom:8px; }
|
|
35
|
+
.waterfall-summary { margin-bottom:14px; }
|
|
36
|
+
.waterfall { min-width:0; }
|
|
37
|
+
.waterfall-axis { display:flex; justify-content:space-between; margin:0 76px 5px 136px; color:var(--muted); font-size:11px; }
|
|
38
|
+
.waterfall-row { display:grid; grid-template-columns:124px minmax(180px,1fr) 64px; gap:12px; align-items:center; min-height:38px; }
|
|
39
|
+
.waterfall-row + .waterfall-row { border-top:1px solid var(--line); }
|
|
40
|
+
.waterfall-label { min-width:0; padding:8px 0; }
|
|
41
|
+
.waterfall-label strong,.waterfall-label small { display:block; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
|
|
42
|
+
.waterfall-label small { color:var(--muted); font-size:11px; }
|
|
43
|
+
.waterfall-track { position:relative; height:22px; overflow:visible; border-radius:5px; background:repeating-linear-gradient(90deg,var(--bg) 0,var(--bg) calc(25% - 1px),var(--line) 25%); }
|
|
44
|
+
.waterfall-bar { position:absolute; top:3px; left:var(--waterfall-start); width:var(--waterfall-width); min-width:3px; height:16px; border-radius:4px; background:var(--accent); box-shadow:inset 0 0 0 1px rgba(255,255,255,.22); }
|
|
45
|
+
.waterfall-bar:hover,.waterfall-bar:focus-visible { z-index:20; filter:brightness(1.15); outline:2px solid var(--accent); outline-offset:2px; }
|
|
46
|
+
.waterfall-tooltip { position:absolute; left:50%; bottom:calc(100% + 9px); z-index:30; display:grid; grid-template-columns:auto auto; gap:1px 12px; width:max-content; max-width:190px; padding:8px 10px; border:1px solid var(--line); border-radius:7px; background:var(--text); color:var(--panel); box-shadow:0 7px 20px rgba(0,0,0,.22); opacity:0; pointer-events:none; transform:translate(-50%,4px); transition:opacity .12s ease,transform .12s ease; font:11px/1.35 ui-sans-serif,system-ui,-apple-system,sans-serif; }
|
|
47
|
+
.waterfall-tooltip::after { content:""; position:absolute; top:100%; left:50%; border:5px solid transparent; border-top-color:var(--text); transform:translateX(-50%); }
|
|
48
|
+
.waterfall-tooltip strong { color:inherit; font-size:11px; }
|
|
49
|
+
.waterfall-tooltip > span { color:inherit; font-weight:700; text-align:right; }
|
|
50
|
+
.waterfall-tooltip small { grid-column:1 / -1; color:inherit; opacity:.72; }
|
|
51
|
+
.waterfall-bar:hover .waterfall-tooltip,.waterfall-bar:focus-visible .waterfall-tooltip { opacity:1; transform:translate(-50%,0); }
|
|
52
|
+
.waterfall-bar-start .waterfall-tooltip { left:0; transform:translate(0,4px); }
|
|
53
|
+
.waterfall-bar-start:hover .waterfall-tooltip,.waterfall-bar-start:focus-visible .waterfall-tooltip { transform:translate(0,0); }
|
|
54
|
+
.waterfall-bar-start .waterfall-tooltip::after { left:6px; transform:none; }
|
|
55
|
+
.waterfall-bar-end .waterfall-tooltip { right:0; left:auto; transform:translate(0,4px); }
|
|
56
|
+
.waterfall-bar-end:hover .waterfall-tooltip,.waterfall-bar-end:focus-visible .waterfall-tooltip { transform:translate(0,0); }
|
|
57
|
+
.waterfall-bar-end .waterfall-tooltip::after { right:6px; left:auto; transform:none; }
|
|
58
|
+
.waterfall-total { color:var(--muted); font-size:12px; text-align:right; white-space:nowrap; }
|
|
59
|
+
.sr-only { position:absolute!important; width:1px!important; height:1px!important; padding:0!important; margin:-1px!important; overflow:hidden!important; clip:rect(0,0,0,0)!important; white-space:nowrap!important; border:0!important; }
|
|
35
60
|
.tree-wrap { overflow:auto; padding:4px 0; }
|
|
36
61
|
.model-tree { list-style:none; margin:0; padding:0; }
|
|
37
62
|
.model-tree .model-tree { margin-left:22px; padding-left:20px; border-left:1px solid var(--line); }
|
|
@@ -42,8 +67,18 @@
|
|
|
42
67
|
.tree-node small { color:var(--muted); }
|
|
43
68
|
.tree-edge { display:block; margin:0 0 5px; color:var(--muted); font-size:12px; }
|
|
44
69
|
.suggestion + .suggestion { border-top:1px solid var(--line); padding-top:18px; margin-top:18px; }
|
|
70
|
+
.method-backtrace summary { display:flex; align-items:flex-start; justify-content:space-between; gap:16px; cursor:pointer; list-style-position:outside; }
|
|
71
|
+
.method-backtrace summary strong,.method-backtrace summary small { display:block; }
|
|
72
|
+
.method-backtrace summary small { margin-top:3px; color:var(--muted); font-size:13px; font-weight:400; }
|
|
73
|
+
.method-backtrace ol { margin:18px 0 0; padding-left:28px; }
|
|
74
|
+
.method-backtrace li { padding:7px 0 7px 5px; }
|
|
75
|
+
.method-backtrace li + li { border-top:1px solid var(--line); }
|
|
76
|
+
.method-backtrace li code,.method-backtrace li span { display:block; overflow-wrap:anywhere; }
|
|
77
|
+
.method-backtrace li code { color:var(--text); font-weight:650; }
|
|
78
|
+
.method-backtrace li span { color:var(--muted); font:12px/1.5 ui-monospace,SFMono-Regular,Consolas,monospace; }
|
|
45
79
|
.crumb { display:inline-block; margin-bottom:18px; }
|
|
46
|
-
@media (max-width:760px) { .grid { grid-template-columns:1fr; } main, header div { width:min(100% - 20px,1120px); } .row { flex-direction:column; } }
|
|
80
|
+
@media (max-width:760px) { .grid { grid-template-columns:1fr; } main, header div { width:min(100% - 20px,1120px); } .row { flex-direction:column; } .waterfall-axis { margin-left:88px; } .waterfall-row { grid-template-columns:76px minmax(130px,1fr) 56px; gap:7px; } }
|
|
81
|
+
@media (prefers-reduced-motion:reduce) { .waterfall-tooltip { transition:none; } }
|
|
47
82
|
</style>
|
|
48
83
|
</head>
|
|
49
84
|
<body>
|
|
@@ -40,6 +40,58 @@
|
|
|
40
40
|
</div>
|
|
41
41
|
</section>
|
|
42
42
|
|
|
43
|
+
<section>
|
|
44
|
+
<div class="row">
|
|
45
|
+
<div>
|
|
46
|
+
<h2>Query loading waterfall</h2>
|
|
47
|
+
<p class="muted waterfall-summary">
|
|
48
|
+
<%= @detection.waterfall_ms %> ms elapsed
|
|
49
|
+
· <%= @detection.total_ms %> ms database time
|
|
50
|
+
· Hover a bar for its start time and duration
|
|
51
|
+
</p>
|
|
52
|
+
</div>
|
|
53
|
+
<span class="badge"><%= @detection.query_count %> queries</span>
|
|
54
|
+
</div>
|
|
55
|
+
<div
|
|
56
|
+
class="waterfall"
|
|
57
|
+
role="img"
|
|
58
|
+
aria-label="Waterfall of repeated query patterns over <%= @detection.waterfall_ms %> milliseconds">
|
|
59
|
+
<div class="waterfall-axis" aria-hidden="true">
|
|
60
|
+
<span>0 ms</span>
|
|
61
|
+
<span><%= @detection.waterfall_ms %> ms</span>
|
|
62
|
+
</div>
|
|
63
|
+
<% span_ms = [@detection.waterfall_ms.to_f, 0.01].max %>
|
|
64
|
+
<% @detection.query_groups.each_with_index do |group, pattern_index| %>
|
|
65
|
+
<div class="waterfall-row">
|
|
66
|
+
<div class="waterfall-label">
|
|
67
|
+
<strong>Pattern <%= pattern_index + 1 %></strong>
|
|
68
|
+
<small><%= group[:tables].join(", ") %></small>
|
|
69
|
+
</div>
|
|
70
|
+
<div class="waterfall-track">
|
|
71
|
+
<% (group[:waterfall] || []).each do |query| %>
|
|
72
|
+
<% start_percent = [[query[:offset_ms].to_f / span_ms * 100, 0].max, 100].min %>
|
|
73
|
+
<% width_percent = [[query[:duration_ms].to_f / span_ms * 100, 0].max, 100 - start_percent].min %>
|
|
74
|
+
<% edge_class = start_percent < 20 ? "waterfall-bar-start" : (start_percent > 80 ? "waterfall-bar-end" : nil) %>
|
|
75
|
+
<% tooltip_id = "waterfall-tooltip-#{pattern_index}-#{query[:index]}" %>
|
|
76
|
+
<span
|
|
77
|
+
class="waterfall-bar <%= edge_class %>"
|
|
78
|
+
style="--waterfall-start:<%= start_percent.round(4) %>%;--waterfall-width:<%= width_percent.round(4) %>%"
|
|
79
|
+
tabindex="0"
|
|
80
|
+
aria-describedby="<%= tooltip_id %>">
|
|
81
|
+
<span class="waterfall-tooltip" id="<%= tooltip_id %>" role="tooltip">
|
|
82
|
+
<strong>Query <%= query[:index] %></strong>
|
|
83
|
+
<span><%= query[:duration_ms] %> ms</span>
|
|
84
|
+
<small>Started +<%= query[:offset_ms] %> ms</small>
|
|
85
|
+
</span>
|
|
86
|
+
</span>
|
|
87
|
+
<% end %>
|
|
88
|
+
</div>
|
|
89
|
+
<span class="waterfall-total"><%= group[:total_ms] %> ms</span>
|
|
90
|
+
</div>
|
|
91
|
+
<% end %>
|
|
92
|
+
</div>
|
|
93
|
+
</section>
|
|
94
|
+
|
|
43
95
|
<section>
|
|
44
96
|
<h2>Suggested fixes</h2>
|
|
45
97
|
<% @detection.suggestions.each do |suggestion| %>
|
|
@@ -50,6 +102,29 @@
|
|
|
50
102
|
</div>
|
|
51
103
|
<% end %>
|
|
52
104
|
</section>
|
|
105
|
+
|
|
106
|
+
<% if @detection.location %>
|
|
107
|
+
<% frames = @detection.location.backtrace.presence || [{ path: @detection.location.path, line: @detection.location.line, label: @detection.location.label }] %>
|
|
108
|
+
<section>
|
|
109
|
+
<details class="method-backtrace">
|
|
110
|
+
<summary>
|
|
111
|
+
<span>
|
|
112
|
+
<strong>Method backtrace</strong>
|
|
113
|
+
<small>Expand to trace the query through serializers and other application methods</small>
|
|
114
|
+
</span>
|
|
115
|
+
<span class="badge"><%= frames.length %> frame<%= "s" unless frames.one? %></span>
|
|
116
|
+
</summary>
|
|
117
|
+
<ol>
|
|
118
|
+
<% frames.each do |frame| %>
|
|
119
|
+
<li>
|
|
120
|
+
<code><%= frame[:label].presence || "(unknown method)" %></code>
|
|
121
|
+
<span><%= frame[:path] %>:<%= frame[:line] %></span>
|
|
122
|
+
</li>
|
|
123
|
+
<% end %>
|
|
124
|
+
</ol>
|
|
125
|
+
</details>
|
|
126
|
+
</section>
|
|
127
|
+
<% end %>
|
|
53
128
|
</div>
|
|
54
129
|
|
|
55
130
|
<section>
|
|
@@ -24,6 +24,7 @@ module NPlusInsight
|
|
|
24
24
|
|
|
25
25
|
def build_detection(query_groups)
|
|
26
26
|
location = dominant_location(query_groups.flatten)
|
|
27
|
+
timeline_origin = query_groups.flatten.filter_map(&:started_at).map(&:to_f).min
|
|
27
28
|
graph_models = {}
|
|
28
29
|
graph_edges = []
|
|
29
30
|
|
|
@@ -39,7 +40,8 @@ module NPlusInsight
|
|
|
39
40
|
sql: queries.first.normalized_sql,
|
|
40
41
|
query_count: queries.length,
|
|
41
42
|
total_ms: queries.sum(&:duration_ms).round(2),
|
|
42
|
-
tables: tables
|
|
43
|
+
tables: tables,
|
|
44
|
+
waterfall: serialize_waterfall(queries, timeline_origin)
|
|
43
45
|
}
|
|
44
46
|
end
|
|
45
47
|
|
|
@@ -54,6 +56,7 @@ module NPlusInsight
|
|
|
54
56
|
created_at: Time.now.utc.iso8601,
|
|
55
57
|
query_count: serialized_groups.sum { |group| group[:query_count] },
|
|
56
58
|
total_ms: serialized_groups.sum { |group| group[:total_ms] }.round(2),
|
|
59
|
+
waterfall_ms: waterfall_span(query_groups.flatten, timeline_origin),
|
|
57
60
|
sql: serialized_groups.first[:sql],
|
|
58
61
|
query_groups: serialized_groups,
|
|
59
62
|
location: location,
|
|
@@ -64,6 +67,43 @@ module NPlusInsight
|
|
|
64
67
|
)
|
|
65
68
|
end
|
|
66
69
|
|
|
70
|
+
def serialize_waterfall(queries, timeline_origin)
|
|
71
|
+
fallback_offset = 0.0
|
|
72
|
+
|
|
73
|
+
queries
|
|
74
|
+
.each_with_index
|
|
75
|
+
.sort_by { |query, index| [query.started_at ? query.started_at.to_f : Float::INFINITY, index] }
|
|
76
|
+
.map.with_index do |(query, _original_index), index|
|
|
77
|
+
duration = query.duration_ms.to_f
|
|
78
|
+
offset = if timeline_origin && query.started_at
|
|
79
|
+
(query.started_at.to_f - timeline_origin) * 1000
|
|
80
|
+
else
|
|
81
|
+
fallback_offset
|
|
82
|
+
end
|
|
83
|
+
fallback_offset = [fallback_offset, offset + duration].max
|
|
84
|
+
|
|
85
|
+
{
|
|
86
|
+
index: index + 1,
|
|
87
|
+
name: query.name,
|
|
88
|
+
offset_ms: offset.round(2),
|
|
89
|
+
duration_ms: duration.round(2)
|
|
90
|
+
}
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def waterfall_span(queries, timeline_origin)
|
|
95
|
+
if timeline_origin
|
|
96
|
+
finishes = queries.filter_map do |query|
|
|
97
|
+
next unless query.started_at
|
|
98
|
+
|
|
99
|
+
((query.started_at.to_f - timeline_origin) * 1000) + query.duration_ms.to_f
|
|
100
|
+
end
|
|
101
|
+
return finishes.max.to_f.round(2) if finishes.any?
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
queries.sum { |query| query.duration_ms.to_f }.round(2)
|
|
105
|
+
end
|
|
106
|
+
|
|
67
107
|
def dominant_location(queries)
|
|
68
108
|
queries
|
|
69
109
|
.filter_map(&:location)
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
module NPlusInsight
|
|
2
2
|
Detection = Struct.new(
|
|
3
|
-
:id, :request_id, :method, :path, :created_at, :query_count, :total_ms,
|
|
3
|
+
:id, :request_id, :method, :path, :created_at, :query_count, :total_ms, :waterfall_ms,
|
|
4
4
|
:sql, :query_groups, :location, :models, :edges, :tree, :suggestions,
|
|
5
5
|
keyword_init: true
|
|
6
6
|
) do
|
|
@@ -21,6 +21,7 @@ module NPlusInsight
|
|
|
21
21
|
path: detection.path,
|
|
22
22
|
query_count: detection.query_count,
|
|
23
23
|
total_ms: detection.total_ms,
|
|
24
|
+
waterfall_ms: detection.waterfall_ms,
|
|
24
25
|
sql: detection.sql,
|
|
25
26
|
query_groups: detection.query_groups,
|
|
26
27
|
location: detection.location&.as_json,
|
data/lib/n_plus_insight/query.rb
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
module NPlusInsight
|
|
2
|
-
Query = Struct.new(:sql, :name, :duration_ms, :cached, :location, :tables, keyword_init: true) do
|
|
2
|
+
Query = Struct.new(:sql, :name, :started_at, :duration_ms, :cached, :location, :tables, keyword_init: true) do
|
|
3
3
|
def normalized_sql
|
|
4
4
|
sql.to_s
|
|
5
5
|
.gsub(/'(?:[^']|'')*'/, "?")
|
|
@@ -1,21 +1,30 @@
|
|
|
1
1
|
module NPlusInsight
|
|
2
|
-
SourceLocation = Struct.new(:path, :line, :label, :snippet, keyword_init: true) do
|
|
2
|
+
SourceLocation = Struct.new(:path, :line, :label, :snippet, :backtrace, keyword_init: true) do
|
|
3
3
|
FRAME_PATTERN = /\A(.+?):(\d+)(?::in [`'](.+)[`'])?\z/
|
|
4
|
+
MAX_BACKTRACE_FRAMES = 30
|
|
4
5
|
|
|
5
6
|
def self.from(caller_lines)
|
|
6
7
|
root = defined?(Rails) && Rails.respond_to?(:root) && Rails.root ? Rails.root.to_s.tr("\\", "/") : nil
|
|
7
|
-
|
|
8
|
+
frames = caller_lines.filter_map do |entry|
|
|
8
9
|
path = entry.to_s.tr("\\", "/")
|
|
9
|
-
root ? path.start_with?("#{root}/app/", "#{root}/lib/") : !path.include?("/gems/")
|
|
10
|
-
|
|
11
|
-
|
|
10
|
+
next unless root ? path.start_with?("#{root}/app/", "#{root}/lib/") : !path.include?("/gems/")
|
|
11
|
+
|
|
12
|
+
match = FRAME_PATTERN.match(entry.to_s)
|
|
13
|
+
next unless match
|
|
12
14
|
|
|
13
|
-
|
|
14
|
-
|
|
15
|
+
path, line, label = match.captures
|
|
16
|
+
{ path: path, line: line.to_i, label: label }
|
|
17
|
+
end.first(MAX_BACKTRACE_FRAMES)
|
|
18
|
+
return if frames.empty?
|
|
15
19
|
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
20
|
+
frame = frames.first
|
|
21
|
+
new(
|
|
22
|
+
path: frame[:path],
|
|
23
|
+
line: frame[:line],
|
|
24
|
+
label: frame[:label],
|
|
25
|
+
snippet: read_snippet(frame[:path], frame[:line]),
|
|
26
|
+
backtrace: frames
|
|
27
|
+
)
|
|
19
28
|
end
|
|
20
29
|
|
|
21
30
|
def self.read_snippet(path, line)
|
|
@@ -29,7 +38,7 @@ module NPlusInsight
|
|
|
29
38
|
end
|
|
30
39
|
|
|
31
40
|
def as_json(*)
|
|
32
|
-
{ path: path, line: line, label: label, snippet: snippet }
|
|
41
|
+
{ path: path, line: line, label: label, snippet: snippet, backtrace: backtrace }
|
|
33
42
|
end
|
|
34
43
|
end
|
|
35
44
|
end
|