n_plus_insight 0.1.0 → 0.1.1

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: b64b2190f32d5bd69dc972654ccb46add8d3a2a4bacc9938a3e0bf5b7035004f
4
- data.tar.gz: 9af66e38cdbe1676aece5a7cf193f6eae284589163b845e58adf9bef36a5ef66
3
+ metadata.gz: 3c665e58752b93be9ca53334439c3ed74166a40d7ec564a0c48f652d05850541
4
+ data.tar.gz: 8216f64ce6620fcd2faf3a072314ac5d2798d90526ee3455f0db3f58dcf0fe28
5
5
  SHA512:
6
- metadata.gz: e6ed0f4ad2dacf35a5e9c52ba9d7597c6d536d05f2930d1798082da89bd811d4e6bb7b63a60b1f9f10608c63b2fbbc982b799038410acf5b46d2b5224c862170
7
- data.tar.gz: f9c71aa555d723bb1b2d109e572c8f49458c361196de2ee48d6f734158ba55bafba84d4ef0fe418aea10559f994ef751eb3407ac29d4035a0923d54e56ae837b
6
+ metadata.gz: d02624df18c5889892dee85c63ea7839aa782a2620e8aa8124625cad15b122af1af92b5a0516e29ce757681af07d888ea58481c8b4e45f3cfd5289c93f2ac160
7
+ data.tar.gz: 7e33bc27f3ca6623d70737dc15d3e01f6deecc6b56518f5ce16600b8d7c870873fb1df04454795e697fb8cdb5c61b1b499764ed173ba0c884df61b082abb0c67
data/CHANGELOG.md CHANGED
@@ -3,9 +3,15 @@
3
3
  ## 0.1.0 - 2026-07-28
4
4
 
5
5
  - Detect repeated Active Record query shapes within a request.
6
+ - Combine multiple repeated query shapes attributed to the same source line.
6
7
  - Identify relevant application source files, line numbers, and code excerpts.
7
- - Visualize affected models and associations.
8
- - Recommend eager-loading and strict-loading remediations.
8
+ - Visualize multi-model associations as nested trees.
9
+ - Recommend combined eager-loading and strict-loading remediations for the
10
+ complete association tree.
9
11
  - Provide an optional on-page status indicator and findings panel.
10
12
  - Provide a full mounted findings dashboard.
13
+ - Allow operators to clear stored findings from the dashboard.
14
+ - Roll off the oldest dashboard findings according to `max_events`.
15
+ - Publish canonical GitHub, documentation, changelog, and issue-tracker
16
+ metadata.
11
17
  - Support environment-neutral activation and deployment.
data/LICENSE.txt CHANGED
@@ -1,6 +1,6 @@
1
1
  MIT License
2
2
 
3
- Copyright (c) 2026 NPlusInsight contributors
3
+ Copyright (c) 2026 Jory Leech
4
4
 
5
5
  Permission is hereby granted, free of charge, to any person obtaining a copy
6
6
  of this software and associated documentation files (the "Software"), to deal
data/README.md CHANGED
@@ -1,70 +1,367 @@
1
1
  # NPlusInsight
2
2
 
3
- NPlusInsight is a Rails gem that detects repeated query
4
- shapes within a request and turns them into a useful debugging report:
3
+ [![Ruby 3.1+](https://img.shields.io/badge/Ruby-3.1%2B-CC342D?logo=ruby&logoColor=white)](https://www.ruby-lang.org/)
4
+ [![Rails 7–8](https://img.shields.io/badge/Rails-7.0%E2%80%938.x-D30001?logo=rubyonrails&logoColor=white)](https://rubyonrails.org/)
5
+ [![License: MIT](https://img.shields.io/badge/License-MIT-2563EB.svg)](LICENSE.txt)
5
6
 
6
- - the exact application file and line that issued the query;
7
- - a model/association graph for the tables involved;
8
- - normalized SQL, repetition count, and total query time;
9
- - suggested `includes` and `strict_loading` fixes.
7
+ [Source code](https://github.com/joryleech/NPlusInsight) ·
8
+ [Issue tracker](https://github.com/joryleech/NPlusInsight/issues) ·
9
+ [Changelog](CHANGELOG.md)
10
10
 
11
- ## Install
11
+ NPlusInsight detects N+1 queries in Ruby on Rails applications and turns them
12
+ into actionable, source-level diagnostics.
12
13
 
13
- Add the gem in every environment where you may enable it:
14
+ For each finding, NPlusInsight shows:
15
+
16
+ - the application file, line number, and surrounding source code;
17
+ - the repeated SQL shape, query count, and cumulative duration;
18
+ - the Active Record models and associations involved;
19
+ - suggested eager-loading and strict-loading remediations;
20
+ - an optional on-page alert and a mounted findings dashboard.
21
+
22
+ NPlusInsight is environment-neutral. Enable it anywhere you need diagnostics,
23
+ including development, test, staging, or a controlled production environment.
24
+
25
+ ## Table of contents
26
+
27
+ - [Why NPlusInsight?](#why-nplusinsight)
28
+ - [Screenshots](#screenshots)
29
+ - [Requirements](#requirements)
30
+ - [Installation](#installation)
31
+ - [Quick start](#quick-start)
32
+ - [Configuration](#configuration)
33
+ - [Dashboard and on-page inspector](#dashboard-and-on-page-inspector)
34
+ - [How detection works](#how-detection-works)
35
+ - [Security and privacy](#security-and-privacy)
36
+ - [Operational considerations](#operational-considerations)
37
+ - [CI and test environments](#ci-and-test-environments)
38
+ - [Troubleshooting](#troubleshooting)
39
+ - [Development](#development)
40
+ - [Contributing](#contributing)
41
+ - [Releasing](#releasing)
42
+ - [License](#license)
43
+
44
+ ## Why NPlusInsight?
45
+
46
+ Rails logs make repeated queries visible, but they do not always make the cause
47
+ obvious. NPlusInsight connects runtime query evidence to the application code
48
+ that triggered it, then presents the affected model relationship and a
49
+ candidate fix in one place.
50
+
51
+ The gem is designed to keep the feedback loop short:
52
+
53
+ 1. Exercise an application page.
54
+ 2. See the on-page alert when repeated query patterns are detected.
55
+ 3. Open the finding to inspect the responsible source line and model graph.
56
+ 4. Apply eager loading.
57
+ 5. Reload the page and confirm that the finding is gone.
58
+
59
+ ## Screenshots
60
+
61
+ ### On-page alert
62
+
63
+ The corner indicator turns red and displays the number of N+1 query patterns
64
+ detected while rendering the current page.
65
+
66
+ ![NPlusInsight on-page N+1 alert](docs/images/on-page-alert.png)
67
+
68
+ ### On-page findings inspector
69
+
70
+ Open the indicator to inspect relevant source lines, affected models, and
71
+ remediation suggestions without leaving the page.
72
+
73
+ ![NPlusInsight grouped query patterns and multi-model tree in the on-page inspector](docs/images/on-page-popout-modeled.png)
74
+
75
+ ### Mounted findings dashboard
76
+
77
+ The mounted dashboard collects recent findings across requests for deeper
78
+ inspection.
79
+
80
+ ![NPlusInsight full findings dashboard](docs/images/full-dashboard.png)
81
+
82
+ ## Requirements
83
+
84
+ | Dependency | Supported versions |
85
+ | --- | --- |
86
+ | Ruby | 3.1 or newer |
87
+ | Ruby on Rails | 7.0 through 8.x |
88
+ | Active Record | Provided by the Rails application |
89
+
90
+ ## Installation
91
+
92
+ Add NPlusInsight to the environments where it must be available:
14
93
 
15
94
  ```ruby
95
+ # Gemfile
16
96
  gem "n_plus_insight"
17
97
  ```
18
98
 
19
- Run `bundle install`, then configure NPlusInsight for the environments where it
20
- should be active.
99
+ Install dependencies:
100
+
101
+ ```sh
102
+ bundle install
103
+ ```
104
+
105
+ The Rails engine registers its middleware and mounts its dashboard
106
+ automatically. No route declaration is required.
21
107
 
22
- Then configure activation from the environment rather than tying it to a Rails
23
- environment name:
108
+ ## Quick start
109
+
110
+ Enable NPlusInsight in the Rails environment where you want to use it:
111
+
112
+ ```ruby
113
+ # config/environments/development.rb
114
+ NPlusInsight.configure do |config|
115
+ config.enabled = true
116
+ end
117
+ ```
118
+
119
+ Keep shared settings in an initializer:
120
+
121
+ ```ruby
122
+ # config/initializers/n_plus_insight.rb
123
+ NPlusInsight.configure do |config|
124
+ config.minimum_repetitions = 2
125
+ config.max_events = 100
126
+ config.mount_path = "/n_plus_insight"
127
+ config.on_page = true
128
+ config.raise_on_detection = false
129
+ end
130
+ ```
131
+
132
+ Start Rails, exercise the application, and visit:
133
+
134
+ ```text
135
+ http://localhost:3000/n_plus_insight
136
+ ```
137
+
138
+ ### Environment-variable activation
139
+
140
+ For containerized or remotely managed deployments, activation can be driven by
141
+ an environment variable:
24
142
 
25
143
  ```ruby
26
144
  # config/initializers/n_plus_insight.rb
27
- if Rails.env.development?
28
- NPlusInsight.configure do |config|
29
- config.minimum_repetitions = 2
30
- config.mount_path = "/n_plus_insight"
31
- config.on_page = true
32
- config.max_events = 100
33
- config.raise_on_detection = false
34
- end
145
+ NPlusInsight.configure do |config|
146
+ config.enabled = ENV.fetch("NPLUS_INSIGHT_ENABLED", "false")
147
+ .match?(/\A(?:1|true|yes|on)\z/i)
148
+ end
149
+ ```
150
+
151
+ The gem uses the same `NPLUS_INSIGHT_ENABLED` parsing when `enabled` is not set
152
+ explicitly. Its default value is `false`.
153
+
154
+ ## Configuration
155
+
156
+ | Option | Default | Description |
157
+ | --- | --- | --- |
158
+ | `enabled` | `NPLUS_INSIGHT_ENABLED`, otherwise `false` | Enables request-level query collection and analysis. |
159
+ | `minimum_repetitions` | `2` | Number of matching query shapes required to create a finding. |
160
+ | `max_events` | `100` | Maximum number of findings retained for the dashboard. The oldest findings are removed first. |
161
+ | `mount_path` | `"/n_plus_insight"` | Path used for the mounted dashboard and overlay assets. Configure this before the application finishes booting. |
162
+ | `on_page` | `true` | Injects the status indicator and findings inspector into HTML responses. |
163
+ | `raise_on_detection` | `false` | Raises when a request produces at least one finding. Useful in controlled tests or CI. |
164
+ | `ignore_paths` | Asset and Active Storage paths | Array of regular expressions for request paths that should not be analyzed. |
165
+ | `ignore_sql` | Transactions and Rails metadata | Array of regular expressions for SQL statements that should be ignored. |
166
+
167
+ ### Ignoring application paths
168
+
169
+ ```ruby
170
+ NPlusInsight.configure do |config|
171
+ config.ignore_paths += [
172
+ %r{\A/health\z},
173
+ %r{\A/metrics\z}
174
+ ]
35
175
  end
36
176
  ```
37
- With `on_page` enabled, every HTML page also gets a small status circle in the
38
- bottom-right corner. It turns red and shows a count when the current request
39
- contains N+1 query patterns. Select it to inspect source lines, the affected
40
- model graph, and suggested fixes without leaving the page. Set
41
- `config.on_page = false` to remove the on-page tool while retaining collection
42
- and the full dashboard.
177
+
178
+ ### Ignoring known query patterns
179
+
180
+ ```ruby
181
+ NPlusInsight.configure do |config|
182
+ config.ignore_sql += [
183
+ /FROM "audit_events"/i
184
+ ]
185
+ end
186
+ ```
187
+
188
+ ## Dashboard and on-page inspector
189
+
190
+ ### On-page inspector
191
+
192
+ When `on_page` is enabled, NPlusInsight injects a small status indicator into
193
+ HTML responses:
194
+
195
+ - green means no repeated query pattern was detected for that request;
196
+ - red means one or more patterns were detected;
197
+ - the badge shows the number of findings;
198
+ - selecting the indicator opens source, model, and remediation details.
199
+
200
+ Set `config.on_page = false` to disable response injection while continuing to
201
+ collect findings for the mounted dashboard.
202
+
203
+ ### Mounted dashboard
204
+
205
+ The dashboard is available at `mount_path`, which defaults to
206
+ `/n_plus_insight`. It lists the most recent findings stored by the current Rails
207
+ process and links to a detailed report for each finding. Use **Clear** to remove
208
+ all stored findings. The dashboard retains at most `max_events`
209
+ findings, rolling off the oldest finding whenever the configured limit is
210
+ exceeded.
43
211
 
44
212
  ## How detection works
45
213
 
46
- The gem listens to `sql.active_record` events during each web request. It
47
- removes literal values from SQL and groups matching shapes. A shape repeated at
48
- least `minimum_repetitions` times is reported. Framework, gem, schema, cached,
49
- transaction, and asset requests are excluded.
214
+ NPlusInsight subscribes to `sql.active_record` notifications while a web
215
+ request is being processed.
216
+
217
+ For each uncached `SELECT` query, it:
50
218
 
51
- This deliberately reports evidence rather than patching source automatically.
52
- Suggested fixes should be reviewed because scopes, polymorphic associations,
53
- and intentionally lazy-loaded data can change the best eager-loading strategy.
219
+ 1. captures the first relevant application stack frame;
220
+ 2. extracts tables referenced by `FROM` and `JOIN`;
221
+ 3. replaces literal strings and numbers with placeholders;
222
+ 4. groups queries with the same normalized shape;
223
+ 5. keeps shapes meeting `minimum_repetitions`;
224
+ 6. combines repeated shapes attributed to the same application source line into
225
+ one finding;
226
+ 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
229
+ complete tree.
54
230
 
55
- ## CI mode
231
+ For example, if one serializer line lazily loads both `post.comments` and
232
+ `post.likes`, NPlusInsight reports one finding with two query patterns, a
233
+ `Post` tree branching to `Comment` and `Like`, and a combined remediation such
234
+ as `Post.includes(:comments, :likes)`. Deeper paths are represented with nested
235
+ `includes`, such as `User.includes({ posts: [:comments, :likes] })`.
56
236
 
57
- To turn findings into failures in a test or staging environment:
237
+ Cached queries, schema activity, Rails metadata queries, transaction statements,
238
+ and ignored requests are excluded.
239
+
240
+ NPlusInsight reports evidence and candidate remediations; it does not modify
241
+ application source code automatically.
242
+
243
+ ## Security and privacy
244
+
245
+ Findings can contain:
246
+
247
+ - local source paths and source-code excerpts;
248
+ - normalized SQL shapes;
249
+ - controller paths and request methods;
250
+ - model and association names.
251
+
252
+ The dashboard is intentionally not restricted to a specific Rails environment.
253
+ If NPlusInsight is enabled on an internet-facing deployment, protect
254
+ `mount_path` with application authentication, a VPN, network policy, or
255
+ reverse-proxy access controls.
256
+
257
+ NPlusInsight stores findings in process memory. It does not persist findings to
258
+ a database or transmit them to an external service.
259
+
260
+ ## Operational considerations
261
+
262
+ - **Process-local storage:** each Rails process maintains its own findings.
263
+ Findings are not shared between Puma workers, containers, or hosts.
264
+ - **Ephemeral history:** findings are cleared when the Rails process restarts.
265
+ - **HTML buffering:** the on-page inspector buffers eligible HTML responses so
266
+ it can inject the overlay before `</body>`.
267
+ - **Non-HTML responses:** JSON, files, streams, and other response types are not
268
+ modified.
269
+ - **Compressed responses:** responses already carrying `Content-Encoding` are
270
+ not modified by the on-page inspector.
271
+ - **Heuristic detection:** repeated queries can be intentional. Review each
272
+ finding before changing loading behavior.
273
+ - **Heuristic suggestions:** polymorphic relationships, custom scopes, and
274
+ indirect associations may require a different preload strategy than the
275
+ suggested code.
276
+
277
+ For high-traffic production deployments, begin with `on_page = false`, use a
278
+ small `max_events`, and evaluate overhead under representative load.
279
+
280
+ ## CI and test environments
281
+
282
+ Enable `raise_on_detection` to turn a finding into a request failure:
58
283
 
59
284
  ```ruby
60
- config.raise_on_detection = true
285
+ # config/environments/test.rb
286
+ NPlusInsight.configure do |config|
287
+ config.enabled = true
288
+ config.on_page = false
289
+ config.raise_on_detection = true
290
+ end
61
291
  ```
62
292
 
63
- The dashboard is not restricted to a particular Rails environment. If it is
64
- enabled on an internet-facing deployment, protect `/n_plus_insight` with your
65
- application's authentication, authorization, VPN, or reverse-proxy access
66
- controls because findings contain source paths, code excerpts, and SQL shapes.
293
+ Use this mode selectively. Test setup, factories, and intentionally repeated
294
+ queries can otherwise produce noisy failures.
295
+
296
+ ## Troubleshooting
297
+
298
+ ### The indicator does not appear
299
+
300
+ Confirm that:
301
+
302
+ - `config.enabled` and `config.on_page` are both `true`;
303
+ - the response has a `text/html` content type;
304
+ - the response is not already compressed;
305
+ - the request path does not match `ignore_paths`;
306
+ - the application's Content Security Policy permits same-origin scripts and
307
+ styles.
308
+
309
+ ### The indicator is green, but repeated queries appear in the Rails log
310
+
311
+ Check whether the queries:
312
+
313
+ - occur fewer times than `minimum_repetitions`;
314
+ - are served from the Active Record query cache;
315
+ - are not `SELECT` statements;
316
+ - match an `ignore_sql` pattern;
317
+ - execute outside the lifecycle of an HTTP request.
318
+
319
+ ### The dashboard is empty in a multi-process deployment
320
+
321
+ The store is process-local. A request served by one worker is not visible in a
322
+ dashboard request served by another worker.
323
+
324
+ ### Findings point to the correct line but suggest the wrong association
325
+
326
+ Association inference is heuristic. Use the source location, SQL shape, and
327
+ model graph as evidence, then apply the eager-loading strategy appropriate for
328
+ the relation that feeds that code.
329
+
330
+ ## Development
331
+
332
+ Install dependencies and run the test suite:
333
+
334
+ ```sh
335
+ bundle install
336
+ bundle exec rake test
337
+ ```
338
+
339
+ Build the gem locally:
340
+
341
+ ```sh
342
+ bundle exec rake build
343
+ ```
344
+
345
+ The built package is written to `pkg/`.
346
+
347
+ ## Contributing
348
+
349
+ [Bug reports](https://github.com/joryleech/NPlusInsight/issues/new/choose) and
350
+ [pull requests](https://github.com/joryleech/NPlusInsight/pulls) are welcome.
351
+
352
+ When proposing a change:
353
+
354
+ 1. include a focused reproduction or test;
355
+ 2. keep detection behavior deterministic;
356
+ 3. preserve environment-neutral configuration;
357
+ 4. verify both the mounted dashboard and on-page inspector when changing UI;
358
+ 5. run `bundle exec rake test`.
359
+
360
+ Please avoid including production SQL, source code, credentials, or other
361
+ sensitive application data in public issues.
67
362
 
68
363
  ## License
69
364
 
70
- MIT
365
+ NPlusInsight is maintained by
366
+ [Jory Leech](https://github.com/joryleech) and available under the
367
+ [MIT License](LICENSE.txt).
@@ -100,14 +100,22 @@
100
100
  #n1v-root .n1v-heading { margin: 0 0 8px !important; color: var(--n1v-text) !important; font: 650 13px/1.3 ui-sans-serif, system-ui, sans-serif !important; }
101
101
  #n1v-root .n1v-location { margin: 0 0 8px !important; color: var(--n1v-muted) !important; overflow-wrap: anywhere !important; font: 12px/1.4 ui-monospace, SFMono-Regular, Consolas, monospace !important; }
102
102
  #n1v-root .n1v-code { display: block !important; max-width: 100% !important; margin: 0 !important; padding: 10px 0 !important; overflow: auto !important; border-radius: 8px !important; background: var(--n1v-code) !important; color: #e4e7ec !important; font: 12px/1.5 ui-monospace, SFMono-Regular, Consolas, monospace !important; white-space: pre !important; }
103
+ #n1v-root .n1v-code > code { display: block !important; margin: 0 !important; padding: 0 12px !important; border: 0 !important; border-radius: 0 !important; background: transparent !important; color: inherit !important; font: inherit !important; white-space: inherit !important; }
103
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; }
104
105
  #n1v-root .n1v-line.n1v-active { background: rgba(217, 45, 32, .28) !important; }
105
106
  #n1v-root .n1v-line-number { color: #98a2b3 !important; user-select: none !important; }
106
- #n1v-root .n1v-graph { display: flex !important; align-items: center !important; gap: 7px !important; min-height: 74px !important; overflow-x: auto !important; padding: 8px 0 !important; }
107
- #n1v-root .n1v-node { flex: 0 0 auto !important; min-width: 130px !important; padding: 10px 12px !important; border: 1px solid var(--n1v-accent) !important; border-radius: 9px !important; background: var(--n1v-accent-soft) !important; color: var(--n1v-text) !important; text-align: center !important; }
107
+ #n1v-root .n1v-graph { min-height: 74px !important; overflow: auto !important; padding: 4px 0 !important; }
108
+ #n1v-root .n1v-tree { margin: 0 !important; padding: 0 !important; list-style: none !important; }
109
+ #n1v-root .n1v-tree .n1v-tree { margin-left: 18px !important; padding-left: 18px !important; border-left: 1px solid var(--n1v-border) !important; }
110
+ #n1v-root .n1v-tree-item { position: relative !important; margin: 9px 0 !important; list-style: none !important; }
111
+ #n1v-root .n1v-tree .n1v-tree > .n1v-tree-item::before { content: "" !important; position: absolute !important; left: -18px !important; top: 27px !important; width: 18px !important; border-top: 1px solid var(--n1v-border) !important; }
112
+ #n1v-root .n1v-node { display: inline-grid !important; min-width: 130px !important; padding: 10px 12px !important; border: 1px solid var(--n1v-accent) !important; border-radius: 9px !important; background: var(--n1v-accent-soft) !important; color: var(--n1v-text) !important; text-align: center !important; }
108
113
  #n1v-root .n1v-node strong, #n1v-root .n1v-node small { display: block !important; color: inherit !important; }
109
114
  #n1v-root .n1v-node small { margin-top: 2px !important; color: var(--n1v-muted) !important; font-size: 11px !important; }
110
- #n1v-root .n1v-edge { flex: 0 0 auto !important; color: var(--n1v-muted) !important; text-align: center !important; font-size: 11px !important; }
115
+ #n1v-root .n1v-edge { display: block !important; margin: 0 0 4px !important; color: var(--n1v-muted) !important; font-size: 11px !important; }
116
+ #n1v-root .n1v-query { margin: 0 !important; padding: 8px 0 !important; border-top: 1px solid var(--n1v-border) !important; }
117
+ #n1v-root .n1v-query:first-of-type { padding-top: 0 !important; border-top: 0 !important; }
118
+ #n1v-root .n1v-query-meta { margin: 0 0 5px !important; color: var(--n1v-muted) !important; font: 11px/1.4 ui-sans-serif, system-ui, sans-serif !important; }
111
119
  #n1v-root .n1v-fix { margin: 0 !important; padding: 12px 0 !important; border-top: 1px solid var(--n1v-border) !important; }
112
120
  #n1v-root .n1v-fix:first-of-type { padding-top: 0 !important; border-top: 0 !important; }
113
121
  #n1v-root .n1v-fix-title { margin: 0 0 4px !important; color: var(--n1v-text) !important; font: 650 13px/1.4 ui-sans-serif, system-ui, sans-serif !important; }
@@ -23,10 +23,33 @@
23
23
  }
24
24
  }
25
25
 
26
+ function queryGroupsFor(finding) {
27
+ if ((finding.query_groups || []).length) return finding.query_groups;
28
+ return [{
29
+ sql: finding.sql,
30
+ query_count: finding.query_count,
31
+ total_ms: finding.total_ms,
32
+ tables: []
33
+ }];
34
+ }
35
+
26
36
  var findings = decodePayload(root.dataset.payload || "");
37
+ var initialFindingIndex = findings.reduce(function (bestIndex, finding, index) {
38
+ var best = findings[bestIndex];
39
+ var patternCount = queryGroupsFor(finding).length;
40
+ var bestPatternCount = best ? queryGroupsFor(best).length : -1;
41
+
42
+ if (patternCount > bestPatternCount) return index;
43
+ if (patternCount === bestPatternCount && finding.query_count > best.query_count) return index;
44
+ return bestIndex;
45
+ }, 0);
46
+ var patternCount = findings.reduce(function (total, finding) {
47
+ return total + queryGroupsFor(finding).length;
48
+ }, 0);
49
+
27
50
  var launcher = element("button", "n1v-launcher" + (findings.length ? " n1v-has-alert" : ""), "N+1");
28
51
  launcher.type = "button";
29
- launcher.setAttribute("aria-label", findings.length ? "Open NPlusInsight: " + findings.length + " detected" : "Open NPlusInsight: no detections");
52
+ launcher.setAttribute("aria-label", findings.length ? "Open NPlusInsight: " + findings.length + " affected source locations" : "Open NPlusInsight: no detections");
30
53
  launcher.setAttribute("aria-expanded", "false");
31
54
 
32
55
  if (findings.length) {
@@ -44,7 +67,14 @@
44
67
  var header = element("div", "n1v-header");
45
68
  var headingWrap = element("div");
46
69
  headingWrap.appendChild(element("h2", "n1v-title", findings.length ? "N+1 detected" : "No N+1 queries"));
47
- headingWrap.appendChild(element("p", "n1v-subtitle", findings.length ? findings.length + " repeated query pattern" + (findings.length === 1 ? "" : "s") + " on this page" : "This request is clear"));
70
+ headingWrap.appendChild(element(
71
+ "p",
72
+ "n1v-subtitle",
73
+ findings.length ?
74
+ findings.length + " affected source location" + (findings.length === 1 ? "" : "s") +
75
+ " · " + patternCount + " query pattern" + (patternCount === 1 ? "" : "s") :
76
+ "This request is clear"
77
+ ));
48
78
  header.appendChild(headingWrap);
49
79
 
50
80
  var close = element("button", "n1v-close", "×");
@@ -70,6 +100,7 @@
70
100
  container.appendChild(section);
71
101
  return;
72
102
  }
103
+
73
104
  section.appendChild(element("p", "n1v-location", finding.location.path + ":" + finding.location.line));
74
105
  var pre = element("pre", "n1v-code");
75
106
  var code = element("code");
@@ -84,22 +115,60 @@
84
115
  container.appendChild(section);
85
116
  }
86
117
 
118
+ function renderQueries(container, finding) {
119
+ var section = element("section", "n1v-section");
120
+ section.appendChild(element("h3", "n1v-heading", "Repeated query patterns"));
121
+ queryGroupsFor(finding).forEach(function (group, index) {
122
+ var query = element("div", "n1v-query");
123
+ var tables = (group.tables || []).join(", ");
124
+ var metadata = "Pattern " + (index + 1) + " · " + group.query_count +
125
+ " queries · " + group.total_ms + " ms" + (tables ? " · " + tables : "");
126
+ query.appendChild(element("p", "n1v-query-meta", metadata));
127
+ appendCode(query, group.sql);
128
+ section.appendChild(query);
129
+ });
130
+ container.appendChild(section);
131
+ }
132
+
133
+ function renderTreeNode(entry) {
134
+ var item = element("li", "n1v-tree-item");
135
+ if (entry.association) {
136
+ item.appendChild(element(
137
+ "span",
138
+ "n1v-edge",
139
+ entry.association + (entry.macro ? " (" + entry.macro + ")" : "")
140
+ ));
141
+ }
142
+
143
+ var node = element("div", "n1v-node");
144
+ node.appendChild(element("strong", "", entry.name));
145
+ node.appendChild(element("small", "", entry.table));
146
+ item.appendChild(node);
147
+
148
+ if ((entry.children || []).length) {
149
+ var children = element("ul", "n1v-tree");
150
+ entry.children.forEach(function (child) {
151
+ children.appendChild(renderTreeNode(child));
152
+ });
153
+ item.appendChild(children);
154
+ }
155
+ return item;
156
+ }
157
+
87
158
  function renderGraph(container, finding) {
88
159
  var section = element("section", "n1v-section");
89
- section.appendChild(element("h3", "n1v-heading", "Affected models"));
160
+ section.appendChild(element("h3", "n1v-heading", "Affected model tree"));
90
161
  var graph = element("div", "n1v-graph");
91
162
  graph.setAttribute("role", "img");
92
- graph.setAttribute("aria-label", "Model associations involved in the repeated query");
93
- (finding.models || []).forEach(function (model, index) {
94
- if (index > 0) {
95
- var edge = (finding.edges || [])[index - 1];
96
- graph.appendChild(element("span", "n1v-edge", (edge && edge.association ? edge.association + " " : "") + "→"));
97
- }
98
- var node = element("div", "n1v-node");
99
- node.appendChild(element("strong", "", model.name));
100
- node.appendChild(element("small", "", model.table));
101
- graph.appendChild(node);
163
+ graph.setAttribute("aria-label", "Tree of models and associations involved in the repeated queries");
164
+ var tree = element("ul", "n1v-tree");
165
+ var roots = finding.tree || (finding.models || []).map(function (model) {
166
+ return { name: model.name, table: model.table, children: [] };
167
+ });
168
+ roots.forEach(function (entry) {
169
+ tree.appendChild(renderTreeNode(entry));
102
170
  });
171
+ graph.appendChild(tree);
103
172
  section.appendChild(graph);
104
173
  container.appendChild(section);
105
174
  }
@@ -112,6 +181,7 @@
112
181
  var copy = element("button", "n1v-copy", "Copy");
113
182
  copy.type = "button";
114
183
  copy.addEventListener("click", function () {
184
+ if (!navigator.clipboard) return;
115
185
  navigator.clipboard.writeText(suggestion.code).then(function () {
116
186
  copy.textContent = "Copied";
117
187
  window.setTimeout(function () { copy.textContent = "Copy"; }, 1200);
@@ -136,7 +206,12 @@
136
206
  var tabs = element("div", "n1v-tabs");
137
207
  tabs.setAttribute("role", "tablist");
138
208
  findings.forEach(function (finding, tabIndex) {
139
- var tab = element("button", "n1v-tab", "#" + (tabIndex + 1) + " · " + finding.query_count + " queries");
209
+ var groupCount = queryGroupsFor(finding).length;
210
+ var tab = element(
211
+ "button",
212
+ "n1v-tab",
213
+ "#" + (tabIndex + 1) + " · " + groupCount + " pattern" + (groupCount === 1 ? "" : "s")
214
+ );
140
215
  tab.type = "button";
141
216
  tab.setAttribute("role", "tab");
142
217
  tab.setAttribute("aria-selected", String(tabIndex === index));
@@ -149,6 +224,7 @@
149
224
  var finding = findings[index];
150
225
  renderSource(body, finding);
151
226
  renderGraph(body, finding);
227
+ renderQueries(body, finding);
152
228
  renderFixes(body, finding);
153
229
  }
154
230
 
@@ -167,7 +243,7 @@
167
243
  if (event.key === "Escape" && panel.getAttribute("aria-hidden") === "false") setOpen(false);
168
244
  });
169
245
 
170
- renderFinding(0);
246
+ renderFinding(initialFindingIndex);
171
247
  root.appendChild(panel);
172
248
  root.appendChild(launcher);
173
249
  }());
@@ -8,5 +8,10 @@ module NPlusInsight
8
8
  @detection = Store.find(params[:id])
9
9
  return head :not_found unless @detection
10
10
  end
11
+
12
+ def clear
13
+ Store.clear
14
+ redirect_to root_path, status: :see_other, notice: "Stored detections cleared."
15
+ end
11
16
  end
12
17
  end
@@ -19,21 +19,38 @@
19
19
  .list { display:grid; gap:12px; } .card { display:block; background:var(--panel); border:1px solid var(--line); border-radius:12px; padding:18px; color:var(--text); text-decoration:none; }
20
20
  .card:hover { border-color:var(--accent); } .row { display:flex; align-items:flex-start; justify-content:space-between; gap:16px; }
21
21
  .badge { display:inline-flex; padding:2px 8px; border-radius:999px; background:var(--accent-soft); color:var(--text); font-size:12px; }
22
+ .dashboard-actions { display:flex; align-items:center; gap:10px; }
23
+ .button { appearance:none; border:1px solid var(--line); border-radius:8px; padding:7px 11px; background:var(--panel); color:var(--text); cursor:pointer; font:600 13px/1 ui-sans-serif,system-ui,-apple-system,sans-serif; }
24
+ .button:hover { border-color:var(--bad); color:var(--bad); }
25
+ .button:disabled { cursor:not-allowed; opacity:.5; }
26
+ .notice { margin:0 0 18px; padding:10px 12px; border:1px solid var(--accent); border-radius:8px; background:var(--accent-soft); }
22
27
  .danger { color:var(--bad); font-weight:650; } .meta { display:flex; flex-wrap:wrap; gap:8px 18px; color:var(--muted); font-size:13px; }
23
28
  .grid { display:grid; grid-template-columns:minmax(0,1.25fr) minmax(280px,.75fr); gap:20px; align-items:start; }
24
29
  .stack { display:grid; gap:16px; } section { background:var(--panel); border:1px solid var(--line); border-radius:12px; padding:20px; }
25
30
  pre { margin:0; overflow:auto; padding:14px; border-radius:8px; background:var(--code); color:#e5e7eb; font:13px/1.55 ui-monospace,SFMono-Regular,Consolas,monospace; }
26
31
  .source { padding:0; } .source div { display:grid; grid-template-columns:44px 1fr; padding:1px 14px; }
27
32
  .source div.active { background:rgba(180,35,24,.28); } .source b { color:#9ca3af; font-weight:400; user-select:none; }
28
- .graph { width:100%; min-height:240px; } .graph line { stroke:var(--line); stroke-width:2; }
29
- .graph rect { fill:var(--accent-soft); stroke:var(--accent); } .graph text { fill:var(--text); font:13px ui-sans-serif,system-ui; }
33
+ .query-pattern + .query-pattern { border-top:1px solid var(--line); padding-top:18px; margin-top:18px; }
34
+ .query-pattern h3 { margin-bottom:8px; }
35
+ .tree-wrap { overflow:auto; padding:4px 0; }
36
+ .model-tree { list-style:none; margin:0; padding:0; }
37
+ .model-tree .model-tree { margin-left:22px; padding-left:20px; border-left:1px solid var(--line); }
38
+ .model-tree li { position:relative; margin:12px 0; }
39
+ .model-tree .model-tree > li::before { content:""; position:absolute; left:-20px; top:30px; width:20px; border-top:1px solid var(--line); }
40
+ .tree-node { display:inline-grid; min-width:170px; padding:10px 12px; border:1px solid var(--accent); border-radius:9px; background:var(--accent-soft); text-align:center; }
41
+ .tree-node strong,.tree-node small { display:block; }
42
+ .tree-node small { color:var(--muted); }
43
+ .tree-edge { display:block; margin:0 0 5px; color:var(--muted); font-size:12px; }
30
44
  .suggestion + .suggestion { border-top:1px solid var(--line); padding-top:18px; margin-top:18px; }
31
45
  .crumb { display:inline-block; margin-bottom:18px; }
32
46
  @media (max-width:760px) { .grid { grid-template-columns:1fr; } main, header div { width:min(100% - 20px,1120px); } .row { flex-direction:column; } }
33
47
  </style>
34
48
  </head>
35
49
  <body>
36
- <header><div><a class="brand" href="<%= root_path %>">NPlusInsight</a><span class="badge">Development only</span></div></header>
37
- <main><%= yield %></main>
50
+ <header><div><a class="brand" href="<%= root_path %>">NPlusInsight</a><span class="badge">Active</span></div></header>
51
+ <main>
52
+ <% if notice %><p class="notice"><%= notice %></p><% end %>
53
+ <%= yield %>
54
+ </main>
38
55
  </body>
39
56
  </html>
@@ -1,9 +1,17 @@
1
1
  <div class="row">
2
2
  <div>
3
3
  <h1>Detected N+1 queries</h1>
4
- <p class="muted">Repeated query shapes grouped by request, with the application frame that triggered them.</p>
4
+ <p class="muted">Repeated query patterns grouped by request and source location.</p>
5
+ </div>
6
+ <div class="dashboard-actions">
7
+ <span class="badge"><%= @detections.length %> finding<%= "s" unless @detections.one? %></span>
8
+ <%= button_to "Clear",
9
+ clear_detections_path,
10
+ method: :delete,
11
+ class: "button",
12
+ disabled: @detections.empty?,
13
+ form: { onsubmit: "return window.confirm('Clear all stored N+1 detections?')" } %>
5
14
  </div>
6
- <span class="badge"><%= @detections.length %> finding<%= "s" unless @detections.one? %></span>
7
15
  </div>
8
16
 
9
17
  <% if @detections.empty? %>
@@ -17,14 +25,18 @@
17
25
  <a class="card" href="<%= detection_path(detection.id) %>">
18
26
  <div class="row">
19
27
  <div>
20
- <div><span class="danger"><%= detection.query_count %> repeated queries</span> on <strong><%= detection.method %> <%= detection.path %></strong></div>
28
+ <div>
29
+ <span class="danger"><%= detection.query_count %> repeated queries</span>
30
+ across <%= detection.query_groups.length %> pattern<%= "s" unless detection.query_groups.one? %>
31
+ on <strong><%= detection.method %> <%= detection.path %></strong>
32
+ </div>
21
33
  <div class="meta">
22
34
  <span><%= detection.total_ms %> ms</span>
23
35
  <% if detection.location %><span><%= detection.location.path %>:<%= detection.location.line %></span><% end %>
24
- <span><%= detection.models.map { |model| model[:name] }.join(" ") %></span>
36
+ <span><%= detection.models.map { |model| model[:name] }.join(", ") %></span>
25
37
  </div>
26
38
  </div>
27
- <span aria-hidden="true">→</span>
39
+ <span aria-hidden="true">&rarr;</span>
28
40
  </div>
29
41
  </a>
30
42
  <% end %>
@@ -1,8 +1,13 @@
1
- <a class="crumb" href="<%= root_path %>">← All detections</a>
1
+ <a class="crumb" href="<%= root_path %>">&larr; All detections</a>
2
2
  <div class="row">
3
3
  <div>
4
4
  <h1><%= @detection.method %> <%= @detection.path %></h1>
5
- <p class="muted"><%= @detection.query_count %> repeated queries · <%= @detection.total_ms %> ms total · <%= @detection.created_at %></p>
5
+ <p class="muted">
6
+ <%= @detection.query_count %> repeated queries
7
+ &middot; <%= @detection.query_groups.length %> query pattern<%= "s" unless @detection.query_groups.one? %>
8
+ &middot; <%= @detection.total_ms %> ms total
9
+ &middot; <%= @detection.created_at %>
10
+ </p>
6
11
  </div>
7
12
  <span class="badge">N+1 detected</span>
8
13
  </div>
@@ -15,13 +20,24 @@
15
20
  <p><%= @detection.location.path %>:<%= @detection.location.line %></p>
16
21
  <pre class="source"><% @detection.location.snippet.each do |line| %><div class="<%= "active" if line[:active] %>"><b><%= line[:line] %></b><span><%= line[:text] %></span></div><% end %></pre>
17
22
  <% else %>
18
- <p class="muted">The query was captured, but no application stack frame was available.</p>
23
+ <p class="muted">The queries were captured, but no application stack frame was available.</p>
19
24
  <% end %>
20
25
  </section>
21
26
 
22
27
  <section>
23
- <h2>Repeated SQL shape</h2>
24
- <pre><%= @detection.sql %></pre>
28
+ <h2>Repeated query patterns</h2>
29
+ <div class="query-patterns">
30
+ <% @detection.query_groups.each_with_index do |group, index| %>
31
+ <div class="query-pattern">
32
+ <div class="row">
33
+ <h3>Pattern <%= index + 1 %></h3>
34
+ <span class="badge"><%= group[:query_count] %> queries &middot; <%= group[:total_ms] %> ms</span>
35
+ </div>
36
+ <p class="muted"><%= group[:tables].join(", ") %></p>
37
+ <pre><%= group[:sql] %></pre>
38
+ </div>
39
+ <% end %>
40
+ </div>
25
41
  </section>
26
42
 
27
43
  <section>
@@ -37,20 +53,29 @@
37
53
  </div>
38
54
 
39
55
  <section>
40
- <h2>Affected model graph</h2>
41
- <% models = @detection.models.presence || [{ name: "Unknown model", table: "unknown" }] %>
42
- <% height = [models.length * 100, 240].max %>
43
- <svg class="graph" viewBox="0 0 360 <%= height %>" role="img" aria-label="Models involved in the repeated query">
44
- <% models.each_with_index do |model, index| %>
45
- <% y = 25 + index * 100 %>
46
- <% if index.positive? %><line x1="180" y1="<%= y - 50 %>" x2="180" y2="<%= y %>"/><% end %>
47
- <rect x="70" y="<%= y %>" width="220" height="58" rx="9"/>
48
- <text x="180" y="<%= y + 25 %>" text-anchor="middle"><%= model[:name] %></text>
49
- <text x="180" y="<%= y + 44 %>" text-anchor="middle" opacity=".65"><%= model[:table] %></text>
56
+ <h2>Affected model tree</h2>
57
+ <% render_tree = lambda do |nodes| %>
58
+ <% content_tag(:ul, class: "model-tree") do %>
59
+ <% safe_join(nodes.map do |node| %>
60
+ <% content_tag(:li) do %>
61
+ <% parts = [] %>
62
+ <% if node[:association] %>
63
+ <% parts << content_tag(:span, "#{node[:association]} (#{node[:macro]})", class: "tree-edge") %>
64
+ <% end %>
65
+ <% parts << content_tag(:div, class: "tree-node") do %>
66
+ <% safe_join([
67
+ content_tag(:strong, node[:name]),
68
+ content_tag(:small, node[:table])
69
+ ]) %>
70
+ <% end %>
71
+ <% parts << render_tree.call(node[:children]) if node[:children].any? %>
72
+ <% safe_join(parts) %>
73
+ <% end %>
74
+ <% end) %>
50
75
  <% end %>
51
- <% @detection.edges.each_with_index do |edge, index| %>
52
- <text x="190" y="<%= 20 + (index + 1) * 100 %>" opacity=".7"><%= edge[:association] %></text>
53
- <% end %>
54
- </svg>
76
+ <% end %>
77
+ <div class="tree-wrap" role="img" aria-label="Tree of models and associations involved in the repeated queries">
78
+ <%= render_tree.call(@detection.tree) %>
79
+ </div>
55
80
  </section>
56
81
  </div>
data/config/routes.rb CHANGED
@@ -2,5 +2,6 @@ NPlusInsight::Engine.routes.draw do
2
2
  root "detections#index"
3
3
  get "assets/overlay.css", to: "assets#stylesheet"
4
4
  get "assets/overlay.js", to: "assets#script"
5
+ delete "detections", to: "detections#clear", as: :clear_detections
5
6
  resources :detections, only: [:show]
6
7
  end
Binary file
Binary file
@@ -6,24 +6,45 @@ module NPlusInsight
6
6
  end
7
7
 
8
8
  def call
9
- groups = @queries.group_by(&:binds_signature)
10
- groups.filter_map do |_shape, queries|
11
- next if queries.length < NPlusInsight.configuration.minimum_repetitions
12
- build_detection(queries)
9
+ repeated_shapes = @queries.group_by { |query| repeated_shape_key(query) }.values.select do |queries|
10
+ queries.length >= NPlusInsight.configuration.minimum_repetitions
13
11
  end
12
+
13
+ repeated_shapes
14
+ .group_by { |queries| location_key(dominant_location(queries), queries.first.binds_signature) }
15
+ .map { |_key, query_groups| build_detection(query_groups) }
14
16
  end
15
17
 
16
18
  private
17
19
 
18
- def build_detection(queries)
19
- tables = queries.flat_map(&:tables).uniq
20
- model_map = model_map_for(tables)
21
- location = queries.filter_map(&:location).group_by { |item| [item.path, item.line] }.max_by { |_key, values| values.length }&.last&.first
22
- inferred = infer_owner(model_map.values, location)
23
- graph_models = ([inferred&.fetch(:owner, nil)] + model_map.values).compact.uniq
24
- models = graph_models.map { |model| { table: model.table_name, name: model.name, primary: tables.include?(model.table_name) } }
25
- models = tables.map { |table| { table: table, name: table.classify, primary: true } } if models.empty?
26
- edges = inferred ? [{ from: inferred[:owner].name, to: inferred[:target].name, association: inferred[:reflection].name, macro: inferred[:reflection].macro }] : association_edges(model_map)
20
+ def repeated_shape_key(query)
21
+ location = query.location
22
+ [query.binds_signature, location&.path, location&.line]
23
+ end
24
+
25
+ def build_detection(query_groups)
26
+ location = dominant_location(query_groups.flatten)
27
+ graph_models = {}
28
+ graph_edges = []
29
+
30
+ serialized_groups = query_groups.map do |queries|
31
+ tables = queries.flat_map(&:tables).uniq
32
+ model_map = model_map_for(tables)
33
+ inferred = infer_owner(model_map.values, location)
34
+
35
+ add_models(graph_models, tables, model_map, inferred)
36
+ graph_edges.concat(edges_for(model_map, inferred))
37
+
38
+ {
39
+ sql: queries.first.normalized_sql,
40
+ query_count: queries.length,
41
+ total_ms: queries.sum(&:duration_ms).round(2),
42
+ tables: tables
43
+ }
44
+ end
45
+
46
+ edges = graph_edges.uniq { |edge| [edge[:from], edge[:to], edge[:association]] }
47
+ models = graph_models.values
27
48
 
28
49
  Detection.new(
29
50
  id: SecureRandom.uuid,
@@ -31,19 +52,73 @@ module NPlusInsight
31
52
  method: @request[:method],
32
53
  path: @request[:path],
33
54
  created_at: Time.now.utc.iso8601,
34
- query_count: queries.length,
35
- total_ms: queries.sum(&:duration_ms).round(2),
36
- sql: queries.first.normalized_sql,
55
+ query_count: serialized_groups.sum { |group| group[:query_count] },
56
+ total_ms: serialized_groups.sum { |group| group[:total_ms] }.round(2),
57
+ sql: serialized_groups.first[:sql],
58
+ query_groups: serialized_groups,
37
59
  location: location,
38
60
  models: models,
39
61
  edges: edges,
40
- suggestions: suggestions(model_map, tables, location, inferred)
62
+ tree: build_forest(models, edges),
63
+ suggestions: suggestions(edges, location)
41
64
  )
42
65
  end
43
66
 
67
+ def dominant_location(queries)
68
+ queries
69
+ .filter_map(&:location)
70
+ .group_by { |item| [item.path, item.line] }
71
+ .max_by { |_key, values| values.length }
72
+ &.last
73
+ &.first
74
+ end
75
+
76
+ def location_key(location, fallback)
77
+ location ? [location.path, location.line] : [:query_shape, fallback]
78
+ end
79
+
80
+ def add_models(graph_models, tables, model_map, inferred)
81
+ tables.each do |table|
82
+ model = model_map[table]
83
+ name = model&.name || table.classify
84
+ graph_models[name] ||= { table: table, name: name, primary: false }
85
+ graph_models[name][:primary] = true
86
+ end
87
+
88
+ return unless inferred
89
+
90
+ [inferred[:owner], inferred[:target]].each do |model|
91
+ graph_models[model.name] ||= {
92
+ table: model.table_name,
93
+ name: model.name,
94
+ primary: tables.include?(model.table_name)
95
+ }
96
+ end
97
+ end
98
+
99
+ def edges_for(model_map, inferred)
100
+ if inferred
101
+ [{
102
+ from: inferred[:owner].name,
103
+ to: inferred[:target].name,
104
+ association: inferred[:reflection].name,
105
+ macro: inferred[:reflection].macro
106
+ }]
107
+ else
108
+ association_edges(model_map)
109
+ end
110
+ end
111
+
44
112
  def model_map_for(tables)
45
113
  return {} unless defined?(ActiveRecord::Base)
46
- Rails.application.eager_load! if Rails.application.config.eager_load && ActiveRecord::Base.descendants.empty?
114
+
115
+ if defined?(Rails) &&
116
+ Rails.respond_to?(:application) &&
117
+ Rails.application&.config&.eager_load &&
118
+ ActiveRecord::Base.descendants.empty?
119
+ Rails.application.eager_load!
120
+ end
121
+
47
122
  ActiveRecord::Base.descendants.each_with_object({}) do |model, map|
48
123
  map[model.table_name] = model unless model.abstract_class?
49
124
  rescue StandardError
@@ -56,13 +131,20 @@ module NPlusInsight
56
131
  model.reflect_on_all_associations.filter_map do |reflection|
57
132
  target = reflection.klass rescue nil
58
133
  next unless target && model_map.value?(target)
59
- { from: model.name, to: target.name, association: reflection.name, macro: reflection.macro }
134
+
135
+ {
136
+ from: model.name,
137
+ to: target.name,
138
+ association: reflection.name,
139
+ macro: reflection.macro
140
+ }
60
141
  end
61
142
  end.uniq
62
143
  end
63
144
 
64
145
  def infer_owner(targets, location)
65
146
  return unless defined?(ActiveRecord::Base)
147
+
66
148
  candidates = ActiveRecord::Base.descendants.flat_map do |owner|
67
149
  owner.reflect_on_all_associations.filter_map do |reflection|
68
150
  target = reflection.klass rescue nil
@@ -71,47 +153,113 @@ module NPlusInsight
71
153
  rescue StandardError
72
154
  []
73
155
  end
156
+
74
157
  source = location&.snippet.to_a.map { |line| line[:text] }.join(" ")
75
158
  receiver_match = candidates.find do |item|
76
159
  owner_name = item[:owner].model_name.element
77
160
  association_name = item[:reflection].name
78
161
  source.match?(/(?:@|\b)#{Regexp.escape(owner_name)}\.#{Regexp.escape(association_name.to_s)}\b/i)
79
162
  end
163
+
80
164
  receiver_match ||
81
- candidates.find { |item| source.match?(/\b#{Regexp.escape(item[:reflection].name.to_s)}\b/) } ||
82
- candidates.first
165
+ candidates.find do |item|
166
+ source.match?(/\.\s*#{Regexp.escape(item[:reflection].name.to_s)}\b/)
167
+ end ||
168
+ candidates.min_by { |item| item[:reflection].macro == :belongs_to ? 1 : 0 }
169
+ end
170
+
171
+ def build_forest(models, edges)
172
+ models_by_name = models.to_h { |model| [model[:name], model] }
173
+ children = edges.group_by { |edge| edge[:from] }
174
+ child_names = edges.map { |edge| edge[:to] }
175
+ roots = models.map { |model| model[:name] }.reject { |name| child_names.include?(name) }
176
+ roots = [models.first[:name]] if roots.empty? && models.any?
177
+
178
+ roots.filter_map do |name|
179
+ build_tree_node(name, models_by_name, children, [])
180
+ end
181
+ end
182
+
183
+ def build_tree_node(name, models_by_name, children, ancestors, association = nil, macro = nil)
184
+ return if ancestors.include?(name)
185
+
186
+ model = models_by_name[name] || { name: name, table: name.to_s.tableize }
187
+ next_ancestors = ancestors + [name]
188
+ {
189
+ name: model[:name],
190
+ table: model[:table],
191
+ association: association,
192
+ macro: macro,
193
+ children: children.fetch(name, []).filter_map do |edge|
194
+ build_tree_node(
195
+ edge[:to],
196
+ models_by_name,
197
+ children,
198
+ next_ancestors,
199
+ edge[:association],
200
+ edge[:macro]
201
+ )
202
+ end
203
+ }
83
204
  end
84
205
 
85
- def suggestions(model_map, tables, location, inferred)
86
- edges = inferred ? [{ from: inferred[:owner].name, association: inferred[:reflection].name }] : association_edges(model_map)
87
- association = edges.first&.fetch(:association, nil)
88
- owner = edges.first&.fetch(:from, nil)
89
- relation = owner || "ParentModel"
90
- eager_load = association ? "#{relation}.includes(:#{association})" : "ParentModel.includes(:association)"
206
+ def suggestions(edges, location)
207
+ roots = (edges.map { |edge| edge[:from] } - edges.map { |edge| edge[:to] }).uniq
208
+ roots = [edges.first[:from]] if roots.empty? && edges.any?
209
+ eager_load = roots.map { |root| eager_load_for(root, edges) }.compact.join("\n")
210
+ eager_load = "ParentModel.includes(:association)" if eager_load.empty?
211
+ strict_loading = roots.any? ? roots.map { |root| "#{root}.strict_loading" }.join("\n") : "ParentModel.strict_loading"
212
+ association_count = edges.map { |edge| [edge[:from], edge[:association]] }.uniq.length
91
213
 
92
214
  results = [
93
215
  {
94
- title: "Eager-load the association",
216
+ title: association_count > 1 ? "Eager-load all affected associations" : "Eager-load the association",
95
217
  code: eager_load,
96
- explanation: "Load related rows in a bounded number of queries before the loop renders them.",
97
- confidence: association ? "high" : "medium"
218
+ explanation: association_count > 1 ?
219
+ "Preload the complete association tree before this source line executes." :
220
+ "Load related rows in a bounded number of queries before the loop renders them.",
221
+ confidence: edges.any? ? "high" : "medium"
98
222
  },
99
223
  {
100
224
  title: "Use strict loading to prevent regressions",
101
- code: "#{relation}.strict_loading",
225
+ code: strict_loading,
102
226
  explanation: "Rails will raise when code lazily loads an association that was not preloaded.",
103
227
  confidence: "medium"
104
228
  }
105
229
  ]
230
+
106
231
  if location
107
232
  results.unshift(
108
- title: "Change the relation feeding this line",
233
+ title: association_count > 1 ? "Change the relation feeding this call site" : "Change the relation feeding this line",
109
234
  code: "# #{location.path}:#{location.line}\n#{eager_load}",
110
- explanation: "Apply eager loading where the parent relation is constructed, not inside the iteration.",
111
- confidence: association ? "high" : "medium"
235
+ explanation: "Apply eager loading where the root relation is constructed, not inside the iteration.",
236
+ confidence: edges.any? ? "high" : "medium"
112
237
  )
113
238
  end
239
+
114
240
  results
115
241
  end
242
+
243
+ def eager_load_for(root, edges)
244
+ children = edges.group_by { |edge| edge[:from] }
245
+ associations = format_associations(root, children, [])
246
+ return if associations.empty?
247
+
248
+ "#{root}.includes(#{associations.join(', ')})"
249
+ end
250
+
251
+ def format_associations(model_name, children, ancestors)
252
+ return [] if ancestors.include?(model_name)
253
+
254
+ children.fetch(model_name, []).map do |edge|
255
+ nested = format_associations(edge[:to], children, ancestors + [model_name])
256
+ if nested.empty?
257
+ ":#{edge[:association]}"
258
+ else
259
+ value = nested.one? ? nested.first : "[#{nested.join(', ')}]"
260
+ "{ #{edge[:association]}: #{value} }"
261
+ end
262
+ end
263
+ end
116
264
  end
117
265
  end
@@ -1,7 +1,8 @@
1
1
  module NPlusInsight
2
2
  Detection = Struct.new(
3
3
  :id, :request_id, :method, :path, :created_at, :query_count, :total_ms,
4
- :sql, :location, :models, :edges, :suggestions, keyword_init: true
4
+ :sql, :query_groups, :location, :models, :edges, :tree, :suggestions,
5
+ keyword_init: true
5
6
  ) do
6
7
  def as_json(*)
7
8
  members.to_h { |member| [member, public_send(member)] }
@@ -22,9 +22,11 @@ module NPlusInsight
22
22
  query_count: detection.query_count,
23
23
  total_ms: detection.total_ms,
24
24
  sql: detection.sql,
25
+ query_groups: detection.query_groups,
25
26
  location: detection.location&.as_json,
26
27
  models: detection.models,
27
28
  edges: detection.edges,
29
+ tree: detection.tree,
28
30
  suggestions: detection.suggestions
29
31
  }
30
32
  end
@@ -1,3 +1,3 @@
1
1
  module NPlusInsight
2
- VERSION = "0.1.0"
2
+ VERSION = "0.1.1"
3
3
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: n_plus_insight
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.1.1
5
5
  platform: ruby
6
6
  authors:
7
- - NPlusInsight contributors
7
+ - Jory Leech
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-07-28 00:00:00.000000000 Z
11
+ date: 2026-07-29 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: rails
@@ -30,10 +30,11 @@ dependencies:
30
30
  - - "<"
31
31
  - !ruby/object:Gem::Version
32
32
  version: '9'
33
- description: A Rails engine that detects repeated query shapes per request, pinpoints
34
- the application code responsible, visualizes affected Active Record models, and
35
- recommends eager-loading fixes.
33
+ description: A Rails engine that groups N+1 query patterns by source line, visualizes
34
+ affected Active Record associations as trees in an on-page inspector and dashboard,
35
+ and recommends eager-loading fixes.
36
36
  email:
37
+ - joryleech@gmail.com
37
38
  executables: []
38
39
  extensions: []
39
40
  extra_rdoc_files: []
@@ -41,7 +42,6 @@ files:
41
42
  - CHANGELOG.md
42
43
  - LICENSE.txt
43
44
  - README.md
44
- - RELEASING.md
45
45
  - app/assets/n_plus_insight/overlay.css
46
46
  - app/assets/n_plus_insight/overlay.js
47
47
  - app/controllers/n_plus_insight/application_controller.rb
@@ -51,6 +51,9 @@ files:
51
51
  - app/views/n_plus_insight/detections/index.html.erb
52
52
  - app/views/n_plus_insight/detections/show.html.erb
53
53
  - config/routes.rb
54
+ - docs/images/full-dashboard.png
55
+ - docs/images/on-page-alert.png
56
+ - docs/images/on-page-popout-modeled.png
54
57
  - lib/n_plus_insight.rb
55
58
  - lib/n_plus_insight/analyzer.rb
56
59
  - lib/n_plus_insight/configuration.rb
@@ -64,12 +67,17 @@ files:
64
67
  - lib/n_plus_insight/store.rb
65
68
  - lib/n_plus_insight/subscriber.rb
66
69
  - lib/n_plus_insight/version.rb
67
- homepage:
70
+ homepage: https://github.com/joryleech/NPlusInsight
68
71
  licenses:
69
72
  - MIT
70
73
  metadata:
71
74
  allowed_push_host: https://rubygems.org
72
75
  rubygems_mfa_required: 'true'
76
+ homepage_uri: https://github.com/joryleech/NPlusInsight
77
+ source_code_uri: https://github.com/joryleech/NPlusInsight/tree/master
78
+ documentation_uri: https://github.com/joryleech/NPlusInsight/blob/master/README.md
79
+ changelog_uri: https://github.com/joryleech/NPlusInsight/blob/master/CHANGELOG.md
80
+ bug_tracker_uri: https://github.com/joryleech/NPlusInsight/issues
73
81
  post_install_message:
74
82
  rdoc_options: []
75
83
  require_paths:
data/RELEASING.md DELETED
@@ -1,47 +0,0 @@
1
- # Releasing NPlusInsight
2
-
3
- ## First release
4
-
5
- 1. Create or sign in to your account at https://rubygems.org.
6
- 2. Enable MFA for the account.
7
- 3. Confirm that `n_plus_insight` is still available:
8
-
9
- ```sh
10
- gem search --remote --exact n_plus_insight
11
- ```
12
-
13
- No output means there is no published gem with that exact name.
14
-
15
- 4. Authenticate without putting an API key in the repository:
16
-
17
- ```sh
18
- gem signin
19
- ```
20
-
21
- 5. Run the tests and build the package:
22
-
23
- ```sh
24
- bundle install
25
- bundle exec rake test
26
- bundle exec rake build
27
- ```
28
-
29
- 6. Publish the built package:
30
-
31
- ```sh
32
- gem push pkg/n_plus_insight-0.1.0.gem
33
- ```
34
-
35
- 7. Verify the release:
36
-
37
- ```sh
38
- gem info n_plus_insight --remote
39
- ```
40
-
41
- ## Later releases
42
-
43
- Update `NPlusInsight::VERSION` in `lib/n_plus_insight/version.rb`, add the
44
- release notes to `CHANGELOG.md`, rerun the tests, build, and push the new
45
- version. RubyGems does not allow replacing an existing version.
46
-
47
- Never commit `.gem/credentials`, an API key, or an OTP recovery code.