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 +4 -4
- data/CHANGELOG.md +8 -2
- data/LICENSE.txt +1 -1
- data/README.md +338 -41
- data/app/assets/n_plus_insight/overlay.css +11 -3
- data/app/assets/n_plus_insight/overlay.js +91 -15
- data/app/controllers/n_plus_insight/detections_controller.rb +5 -0
- data/app/views/layouts/n_plus_insight/application.html.erb +21 -4
- data/app/views/n_plus_insight/detections/index.html.erb +17 -5
- data/app/views/n_plus_insight/detections/show.html.erb +44 -19
- data/config/routes.rb +1 -0
- data/docs/images/full-dashboard.png +0 -0
- data/docs/images/on-page-alert.png +0 -0
- data/docs/images/on-page-popout-modeled.png +0 -0
- data/lib/n_plus_insight/analyzer.rb +182 -34
- data/lib/n_plus_insight/detection.rb +2 -1
- data/lib/n_plus_insight/overlay.rb +2 -0
- data/lib/n_plus_insight/version.rb +1 -1
- metadata +16 -8
- data/RELEASING.md +0 -47
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 3c665e58752b93be9ca53334439c3ed74166a40d7ec564a0c48f652d05850541
|
|
4
|
+
data.tar.gz: 8216f64ce6620fcd2faf3a072314ac5d2798d90526ee3455f0db3f58dcf0fe28
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
|
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
data/README.md
CHANGED
|
@@ -1,70 +1,367 @@
|
|
|
1
1
|
# NPlusInsight
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
3
|
+
[](https://www.ruby-lang.org/)
|
|
4
|
+
[](https://rubyonrails.org/)
|
|
5
|
+
[](LICENSE.txt)
|
|
5
6
|
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
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
|
-
|
|
11
|
+
NPlusInsight detects N+1 queries in Ruby on Rails applications and turns them
|
|
12
|
+
into actionable, source-level diagnostics.
|
|
12
13
|
|
|
13
|
-
|
|
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
|
+

|
|
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
|
+

|
|
74
|
+
|
|
75
|
+
### Mounted findings dashboard
|
|
76
|
+
|
|
77
|
+
The mounted dashboard collects recent findings across requests for deeper
|
|
78
|
+
inspection.
|
|
79
|
+
|
|
80
|
+

|
|
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
|
-
|
|
20
|
-
|
|
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
|
-
|
|
23
|
-
|
|
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
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
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
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
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
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
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
|
-
|
|
52
|
-
|
|
53
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
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
|
-
|
|
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 {
|
|
107
|
-
#n1v-root .n1v-
|
|
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 {
|
|
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 + "
|
|
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(
|
|
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
|
|
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", "
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
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
|
|
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(
|
|
246
|
+
renderFinding(initialFindingIndex);
|
|
171
247
|
root.appendChild(panel);
|
|
172
248
|
root.appendChild(launcher);
|
|
173
249
|
}());
|
|
@@ -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
|
-
.
|
|
29
|
-
.
|
|
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">
|
|
37
|
-
<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
|
|
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
|
|
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("
|
|
36
|
+
<span><%= detection.models.map { |model| model[:name] }.join(", ") %></span>
|
|
25
37
|
</div>
|
|
26
38
|
</div>
|
|
27
|
-
<span aria-hidden="true"
|
|
39
|
+
<span aria-hidden="true">→</span>
|
|
28
40
|
</div>
|
|
29
41
|
</a>
|
|
30
42
|
<% end %>
|
|
@@ -1,8 +1,13 @@
|
|
|
1
|
-
<a class="crumb" href="<%= root_path %>"
|
|
1
|
+
<a class="crumb" href="<%= root_path %>">← All detections</a>
|
|
2
2
|
<div class="row">
|
|
3
3
|
<div>
|
|
4
4
|
<h1><%= @detection.method %> <%= @detection.path %></h1>
|
|
5
|
-
<p class="muted"
|
|
5
|
+
<p class="muted">
|
|
6
|
+
<%= @detection.query_count %> repeated queries
|
|
7
|
+
· <%= @detection.query_groups.length %> query pattern<%= "s" unless @detection.query_groups.one? %>
|
|
8
|
+
· <%= @detection.total_ms %> ms total
|
|
9
|
+
· <%= @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
|
|
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
|
|
24
|
-
<
|
|
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 · <%= 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
|
|
41
|
-
<%
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
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
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
</
|
|
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
|
|
Binary file
|
|
@@ -6,24 +6,45 @@ module NPlusInsight
|
|
|
6
6
|
end
|
|
7
7
|
|
|
8
8
|
def call
|
|
9
|
-
|
|
10
|
-
|
|
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
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
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:
|
|
35
|
-
total_ms:
|
|
36
|
-
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
82
|
-
|
|
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(
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
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:
|
|
97
|
-
|
|
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:
|
|
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
|
|
111
|
-
confidence:
|
|
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, :
|
|
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
|
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.
|
|
4
|
+
version: 0.1.1
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
|
-
-
|
|
7
|
+
- Jory Leech
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: bin
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date: 2026-07-
|
|
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
|
|
34
|
-
|
|
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.
|