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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: b8ae9f6ffeef6402349d63d9ceb1797a71aeb567e18383019b95e455b32d9b1b
4
- data.tar.gz: 86c59f125447834906a627ed2ace73cae60b171ecf4d3ef7183e7864d919cc0f
3
+ metadata.gz: 158d6070accc936171d9807ac597638529b17fc02e84694e28fc6a10ac250f26
4
+ data.tar.gz: 0ea83df32503c9f713e979053664b55d13ffc4492249d9d206f545f6ebf68c41
5
5
  SHA512:
6
- metadata.gz: 137231015fad7ceee3a2658aafb9ab7accea46c44feea299e9d17eb4e5f960e65b4792102592556fa9cff7bfda956cba9f6e5c04c0b02ab641f4e6e0f4541b5c
7
- data.tar.gz: f24bbbb97328f57c943a466c586494fbf94147349c99fb209c1232aa398e7058abe6d7c0b9dc5024e0a594b6388a0c9b6ac8999115e45054acf6961407b923c8
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. renders the affected associations as a model tree;
228
- 9. generates combined eager-loading and strict-loading suggestions for the
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
+ &middot; <%= @detection.total_ms %> ms database time
50
+ &middot; 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,
@@ -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
- frame = caller_lines.find do |entry|
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
- end
11
- return unless frame
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
- match = FRAME_PATTERN.match(frame.to_s)
14
- return unless match
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
- path, line, label = match.captures
17
- line = line.to_i
18
- new(path: path, line: line, label: label, snippet: read_snippet(path, line))
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
@@ -22,6 +22,7 @@ module NPlusInsight
22
22
  collector << Query.new(
23
23
  sql: sql,
24
24
  name: payload[:name],
25
+ started_at: started.to_f,
25
26
  duration_ms: (finished - started) * 1000,
26
27
  cached: payload[:cached],
27
28
  location: SourceLocation.from(caller),
@@ -1,3 +1,3 @@
1
1
  module NPlusInsight
2
- VERSION = "0.1.2"
2
+ VERSION = "0.1.3"
3
3
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: n_plus_insight
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.2
4
+ version: 0.1.3
5
5
  platform: ruby
6
6
  authors:
7
7
  - Jory Leech