and_one 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/CHANGELOG.md +5 -0
- data/CODE_OF_CONDUCT.md +10 -0
- data/LICENSE.txt +21 -0
- data/README.md +287 -0
- data/Rakefile +12 -0
- data/TODO.md +52 -0
- data/lib/and_one/active_job_hook.rb +25 -0
- data/lib/and_one/aggregate.rb +85 -0
- data/lib/and_one/association_resolver.rb +227 -0
- data/lib/and_one/console.rb +128 -0
- data/lib/and_one/detection.rb +102 -0
- data/lib/and_one/detector.rb +141 -0
- data/lib/and_one/dev_ui.rb +162 -0
- data/lib/and_one/fingerprint.rb +55 -0
- data/lib/and_one/formatter.rb +134 -0
- data/lib/and_one/ignore_file.rb +109 -0
- data/lib/and_one/json_formatter.rb +94 -0
- data/lib/and_one/matchers.rb +130 -0
- data/lib/and_one/middleware.rb +19 -0
- data/lib/and_one/railtie.rb +48 -0
- data/lib/and_one/rspec.rb +18 -0
- data/lib/and_one/scan_helper.rb +32 -0
- data/lib/and_one/sidekiq_middleware.rb +26 -0
- data/lib/and_one/version.rb +5 -0
- data/lib/and_one.rb +278 -0
- data/sig/and_one.rbs +4 -0
- metadata +113 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 9ee084901a4915dba0065a11cf14a6996f277d6ca5fba2efd388b27ddc2f349a
|
|
4
|
+
data.tar.gz: 05d02c49d473359e70ba9b300711e6d15f52d391ab48e22ca1ddb8651e3eb7bc
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 5f59a82aac0fc38ef5f9d2d11b633a1b881378124834d8d8ec0dd4ecb85e887d16aec5feee30225205df60ab90cb1577eb02ebca0306685736231faad0e53799
|
|
7
|
+
data.tar.gz: '0663708679e397440e6e1fe26bee567e868e1909e1138cfb94d3da085cabdc4da8808005f72a610aa6f99fde5f262ffead6ff57bd4bc21e0b685c9c07195df0b'
|
data/CHANGELOG.md
ADDED
data/CODE_OF_CONDUCT.md
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
# Code of Conduct
|
|
2
|
+
|
|
3
|
+
"and_one" follows [The Ruby Community Conduct Guideline](https://www.ruby-lang.org/en/conduct) in all "collaborative space", which is defined as community communications channels (such as mailing lists, submitted patches, commit comments, etc.):
|
|
4
|
+
|
|
5
|
+
* Participants will be tolerant of opposing views.
|
|
6
|
+
* Participants must ensure that their language and actions are free of personal attacks and disparaging personal remarks.
|
|
7
|
+
* When interpreting the words and actions of others, participants should always assume good intentions.
|
|
8
|
+
* Behaviour which can be reasonably considered harassment will not be tolerated.
|
|
9
|
+
|
|
10
|
+
If you have any concerns about behaviour within this project, please contact us at ["keiththomps@hey.com"](mailto:"keiththomps@hey.com").
|
data/LICENSE.txt
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
The MIT License (MIT)
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Keith Thompson
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in
|
|
13
|
+
all copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
|
21
|
+
THE SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
# 🏀 AndOne
|
|
2
|
+
|
|
3
|
+
Detect N+1 queries in Rails applications with zero configuration and actionable fix suggestions.
|
|
4
|
+
|
|
5
|
+
AndOne stays completely invisible until it detects an N+1 query — then it tells you exactly what's wrong and how to fix it. No external dependencies beyond Rails itself.
|
|
6
|
+
|
|
7
|
+
## Features
|
|
8
|
+
|
|
9
|
+
- **Zero configuration** — Railtie auto-setup in development and test
|
|
10
|
+
- **Actionable fix suggestions** — suggests the exact `.includes()`, `.preload()`, or `.eager_load()` call
|
|
11
|
+
- **Smart location detection** — identifies both the origin (where the N+1 fires) and the fix location (where to add `.includes`)
|
|
12
|
+
- **Clean error handling** — never corrupts backtraces or interferes with exception propagation
|
|
13
|
+
- **No external dependencies** — only Rails itself
|
|
14
|
+
- **Auto-raises in test** — N+1s fail your test suite by default
|
|
15
|
+
- **Background job support** — ActiveJob (`around_perform`) and Sidekiq server middleware, with double-scan protection
|
|
16
|
+
- **Ignore file** — `.and_one_ignore` with `gem:`, `path:`, `query:`, and `fingerprint:` rules
|
|
17
|
+
- **Aggregate mode** — report each unique N+1 once per server session with occurrence counts
|
|
18
|
+
- **Test matchers** — Minitest (`assert_no_n_plus_one`) and RSpec (`expect { }.not_to cause_n_plus_one`)
|
|
19
|
+
- **Dev UI dashboard** — browse `/__and_one` in development for a live N+1 overview
|
|
20
|
+
- **Rails console integration** — auto-scans in `rails console` and prints warnings inline
|
|
21
|
+
- **Structured JSON logging** — JSON output mode for Datadog, Splunk, and other log aggregation services
|
|
22
|
+
- **Per-environment thresholds** — different `min_n_queries` for development vs test
|
|
23
|
+
- **GitHub Actions annotations** — N+1s appear as warning annotations on PR diffs
|
|
24
|
+
- **`strict_loading` suggestions** — also suggests model-level prevention as an alternative
|
|
25
|
+
- **`has_many :through` and polymorphic support** — resolves complex association chains
|
|
26
|
+
- **Thread-safe under Puma** — per-thread isolation verified with concurrent stress tests
|
|
27
|
+
|
|
28
|
+
## Installation
|
|
29
|
+
|
|
30
|
+
Add to your Gemfile:
|
|
31
|
+
|
|
32
|
+
```ruby
|
|
33
|
+
group :development, :test do
|
|
34
|
+
gem "and_one"
|
|
35
|
+
end
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
That's it. AndOne automatically activates in development and test environments via a Railtie.
|
|
39
|
+
|
|
40
|
+
## What You'll See
|
|
41
|
+
|
|
42
|
+
When an N+1 is detected, you get output like:
|
|
43
|
+
|
|
44
|
+
```
|
|
45
|
+
──────────────────────────────────────────────────────────────────────────
|
|
46
|
+
🏀 And One! 1 N+1 query detected
|
|
47
|
+
──────────────────────────────────────────────────────────────────────────
|
|
48
|
+
|
|
49
|
+
1) 9x repeated query on `comments`
|
|
50
|
+
fingerprint: a1b2c3d4e5f6
|
|
51
|
+
|
|
52
|
+
Query:
|
|
53
|
+
SELECT "comments".* FROM "comments" WHERE "comments"."post_id" = ?
|
|
54
|
+
|
|
55
|
+
Origin (where the N+1 is triggered):
|
|
56
|
+
→ app/views/posts/index.html.erb:5
|
|
57
|
+
|
|
58
|
+
Fix here (where to add .includes):
|
|
59
|
+
⇒ app/controllers/posts_controller.rb:8
|
|
60
|
+
|
|
61
|
+
Call stack:
|
|
62
|
+
app/views/posts/index.html.erb:5
|
|
63
|
+
app/controllers/posts_controller.rb:8
|
|
64
|
+
|
|
65
|
+
💡 Suggestion:
|
|
66
|
+
Add `.includes(:comments)` to your Post query
|
|
67
|
+
|
|
68
|
+
To ignore, add to .and_one_ignore:
|
|
69
|
+
fingerprint:a1b2c3d4e5f6
|
|
70
|
+
|
|
71
|
+
──────────────────────────────────────────────────────────────────────────
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
## Background Jobs
|
|
75
|
+
|
|
76
|
+
### ActiveJob (any backend)
|
|
77
|
+
|
|
78
|
+
Automatically hooked via `around_perform`. Works with **every** ActiveJob backend:
|
|
79
|
+
Sidekiq, GoodJob, SolidQueue, Delayed Job, Resque, and anything else that uses ActiveJob.
|
|
80
|
+
|
|
81
|
+
No configuration needed — the Railtie handles it.
|
|
82
|
+
|
|
83
|
+
### Sidekiq (direct usage)
|
|
84
|
+
|
|
85
|
+
For jobs that use Sidekiq directly (bypassing ActiveJob), AndOne installs a server middleware automatically when Sidekiq is detected.
|
|
86
|
+
|
|
87
|
+
If you need manual installation:
|
|
88
|
+
|
|
89
|
+
```ruby
|
|
90
|
+
Sidekiq.configure_server do |config|
|
|
91
|
+
config.server_middleware do |chain|
|
|
92
|
+
chain.add AndOne::SidekiqMiddleware
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
When both hooks are active (ActiveJob job running through Sidekiq), the Sidekiq middleware detects the existing scan from ActiveJobHook and passes through — no double-scanning.
|
|
98
|
+
|
|
99
|
+
## Ignoring N+1s
|
|
100
|
+
|
|
101
|
+
### The `.and_one_ignore` file
|
|
102
|
+
|
|
103
|
+
Create a `.and_one_ignore` file in your project root to permanently silence known N+1s. Supports four rule types:
|
|
104
|
+
|
|
105
|
+
```bash
|
|
106
|
+
# Ignore N+1s originating from a specific gem
|
|
107
|
+
# (matches against raw backtrace paths, e.g. /gems/devise-4.9.0/)
|
|
108
|
+
gem:devise
|
|
109
|
+
gem:administrate
|
|
110
|
+
|
|
111
|
+
# Ignore N+1s whose call stack matches a path pattern (supports * globs)
|
|
112
|
+
path:app/views/admin/*
|
|
113
|
+
path:lib/legacy/**
|
|
114
|
+
|
|
115
|
+
# Ignore N+1s matching a SQL pattern
|
|
116
|
+
query:schema_migrations
|
|
117
|
+
query:pg_catalog
|
|
118
|
+
|
|
119
|
+
# Ignore a specific detection by its fingerprint (shown in output)
|
|
120
|
+
fingerprint:a1b2c3d4e5f6
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
This is especially useful for **N+1s coming from gems** where you can't add `.includes()` to the source. Instead of littering your code with `AndOne.pause` blocks, add a `gem:` rule.
|
|
124
|
+
|
|
125
|
+
### When to use each rule type
|
|
126
|
+
|
|
127
|
+
| Rule | Use when... |
|
|
128
|
+
|---|---|
|
|
129
|
+
| `gem:devise` | A gem you depend on has an N+1 you can't fix |
|
|
130
|
+
| `path:app/views/admin/*` | An area of your app has known N+1s you've accepted |
|
|
131
|
+
| `query:some_table` | A specific query pattern should always be ignored |
|
|
132
|
+
| `fingerprint:abc123` | You want to silence one specific detection (shown in output) |
|
|
133
|
+
|
|
134
|
+
## Aggregate Mode
|
|
135
|
+
|
|
136
|
+
In development, the same N+1 can fire on every request, flooding your logs. Aggregate mode reports each unique pattern only once per server session:
|
|
137
|
+
|
|
138
|
+
```ruby
|
|
139
|
+
# config/initializers/and_one.rb
|
|
140
|
+
AndOne.aggregate_mode = true
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
You can check the session summary at any time:
|
|
144
|
+
|
|
145
|
+
```ruby
|
|
146
|
+
AndOne.aggregate.summary # formatted string of all unique N+1s
|
|
147
|
+
AndOne.aggregate.size # number of unique patterns
|
|
148
|
+
AndOne.aggregate.reset! # clear and start fresh
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
## Test Matchers
|
|
152
|
+
|
|
153
|
+
### Minitest
|
|
154
|
+
|
|
155
|
+
```ruby
|
|
156
|
+
class PostsControllerTest < ActionDispatch::IntegrationTest
|
|
157
|
+
include AndOne::MinitestHelper
|
|
158
|
+
|
|
159
|
+
test "index does not cause N+1 queries" do
|
|
160
|
+
assert_no_n_plus_one do
|
|
161
|
+
get posts_path
|
|
162
|
+
end
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
test "known N+1 is documented" do
|
|
166
|
+
detections = assert_n_plus_one do
|
|
167
|
+
get legacy_report_path
|
|
168
|
+
end
|
|
169
|
+
assert_equal "comments", detections.first.table_name
|
|
170
|
+
end
|
|
171
|
+
end
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
### RSpec
|
|
175
|
+
|
|
176
|
+
```ruby
|
|
177
|
+
# In spec_helper.rb or rails_helper.rb
|
|
178
|
+
require "and_one/rspec"
|
|
179
|
+
|
|
180
|
+
# Then in your specs
|
|
181
|
+
RSpec.describe "Posts" do
|
|
182
|
+
it "loads posts efficiently" do
|
|
183
|
+
expect {
|
|
184
|
+
Post.includes(:comments).each { |p| p.comments.to_a }
|
|
185
|
+
}.not_to cause_n_plus_one
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
it "has a known N+1" do
|
|
189
|
+
expect {
|
|
190
|
+
Post.all.each { |p| p.comments.to_a }
|
|
191
|
+
}.to cause_n_plus_one
|
|
192
|
+
end
|
|
193
|
+
end
|
|
194
|
+
```
|
|
195
|
+
|
|
196
|
+
The matchers temporarily disable `raise_on_detect` internally, so they work correctly regardless of your global configuration.
|
|
197
|
+
|
|
198
|
+
## Behavior by Environment
|
|
199
|
+
|
|
200
|
+
- **Development**: Logs N+1 warnings to Rails logger and stderr
|
|
201
|
+
- **Test**: Raises `AndOne::NPlus1Error` so N+1s fail your test suite
|
|
202
|
+
- **Production**: Completely disabled (not even loaded)
|
|
203
|
+
|
|
204
|
+
## Configuration
|
|
205
|
+
|
|
206
|
+
AndOne works out of the box, but you can customize:
|
|
207
|
+
|
|
208
|
+
```ruby
|
|
209
|
+
# config/initializers/and_one.rb
|
|
210
|
+
AndOne.configure do |config|
|
|
211
|
+
# Raise on detection (default: true in test, false in development)
|
|
212
|
+
config.raise_on_detect = false
|
|
213
|
+
|
|
214
|
+
# Minimum repeated queries to trigger (default: 2)
|
|
215
|
+
config.min_n_queries = 3
|
|
216
|
+
|
|
217
|
+
# Aggregate mode — only report each unique N+1 once per session
|
|
218
|
+
config.aggregate_mode = true
|
|
219
|
+
|
|
220
|
+
# Path to ignore file (default: Rails.root/.and_one_ignore)
|
|
221
|
+
config.ignore_file_path = Rails.root.join(".and_one_ignore").to_s
|
|
222
|
+
|
|
223
|
+
# Allow specific patterns (won't flag these call stacks)
|
|
224
|
+
config.allow_stack_paths = [
|
|
225
|
+
/admin_controller/,
|
|
226
|
+
/some_legacy_code/
|
|
227
|
+
]
|
|
228
|
+
|
|
229
|
+
# Ignore specific query patterns
|
|
230
|
+
config.ignore_queries = [
|
|
231
|
+
/pg_catalog/,
|
|
232
|
+
/schema_migrations/
|
|
233
|
+
]
|
|
234
|
+
|
|
235
|
+
# Custom backtrace cleaner
|
|
236
|
+
config.backtrace_cleaner = Rails.backtrace_cleaner
|
|
237
|
+
|
|
238
|
+
# Custom callback for integrations (logging services, etc.)
|
|
239
|
+
config.notifications_callback = ->(detections, message) {
|
|
240
|
+
# detections is an array of AndOne::Detection objects
|
|
241
|
+
# message is the formatted string
|
|
242
|
+
MyLogger.warn(message)
|
|
243
|
+
}
|
|
244
|
+
end
|
|
245
|
+
```
|
|
246
|
+
|
|
247
|
+
## Manual Scanning
|
|
248
|
+
|
|
249
|
+
You can also scan specific blocks:
|
|
250
|
+
|
|
251
|
+
```ruby
|
|
252
|
+
# In a test
|
|
253
|
+
detections = AndOne.scan do
|
|
254
|
+
posts = Post.all
|
|
255
|
+
posts.each { |p| p.comments.to_a }
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
assert_empty detections
|
|
259
|
+
|
|
260
|
+
# Pause/resume within a scan
|
|
261
|
+
AndOne.scan do
|
|
262
|
+
# This is scanned
|
|
263
|
+
posts.each { |p| p.comments.to_a }
|
|
264
|
+
|
|
265
|
+
AndOne.pause do
|
|
266
|
+
# This is NOT scanned
|
|
267
|
+
legacy_code_with_known_n_plus_ones
|
|
268
|
+
end
|
|
269
|
+
|
|
270
|
+
# Scanning resumes automatically after the pause block
|
|
271
|
+
end
|
|
272
|
+
```
|
|
273
|
+
|
|
274
|
+
## How It Works
|
|
275
|
+
|
|
276
|
+
1. **Subscribe** to `sql.active_record` notifications (built into Rails)
|
|
277
|
+
2. **Group** queries by call stack fingerprint
|
|
278
|
+
3. **Fingerprint** SQL to detect same-shape queries with different bind values
|
|
279
|
+
4. **Resolve** table names back to ActiveRecord models and associations
|
|
280
|
+
5. **Suggest** the exact `.includes()` call to fix the N+1
|
|
281
|
+
6. **Filter** against the `.and_one_ignore` file and aggregate tracker
|
|
282
|
+
|
|
283
|
+
The middleware is designed to **never interfere with error propagation**. If your app raises an exception during a request, AndOne silently stops scanning and re-raises the original exception with its backtrace completely intact.
|
|
284
|
+
|
|
285
|
+
## License
|
|
286
|
+
|
|
287
|
+
The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
|
data/Rakefile
ADDED
data/TODO.md
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# AndOne — Feature Roadmap
|
|
2
|
+
|
|
3
|
+
## ✅ Completed
|
|
4
|
+
|
|
5
|
+
- [x] Core detection engine using `sql.active_record` notifications
|
|
6
|
+
- [x] SQL fingerprinting without external dependencies
|
|
7
|
+
- [x] Association resolver that suggests exact `.includes()` fixes
|
|
8
|
+
- [x] Rich formatted output with query, call stack, and fix suggestions
|
|
9
|
+
- [x] Rack middleware that never corrupts error backtraces
|
|
10
|
+
- [x] Railtie for zero-config auto-setup in dev/test
|
|
11
|
+
- [x] Raises in test env, warns in dev, disabled in production
|
|
12
|
+
- [x] Pause/resume support for known N+1s
|
|
13
|
+
- [x] ActiveJob `around_perform` hook (works with any backend)
|
|
14
|
+
- [x] Sidekiq server middleware (for jobs bypassing ActiveJob)
|
|
15
|
+
- [x] `ScanHelper` shared module to DRY up scan lifecycle across entry points
|
|
16
|
+
- [x] Double-scan protection (ActiveJob + Sidekiq don't conflict)
|
|
17
|
+
|
|
18
|
+
## 🎯 High Value — Completed
|
|
19
|
+
|
|
20
|
+
- [x] **Auto-detect the "fix location"** — Walks the backtrace to identify two key frames: the "origin" (where the N+1 is triggered inside a loop) and the "fix location" (the outer frame where `.includes()` should be added). Both are highlighted in the output.
|
|
21
|
+
|
|
22
|
+
- [x] **Ignore file (`.and_one_ignore`)** — Supports four rule types: `gem:` (for N+1s from gems like devise/administrate you can't fix), `path:` (glob patterns for app areas), `query:` (SQL patterns), and `fingerprint:` (specific detections). Checked into source control.
|
|
23
|
+
|
|
24
|
+
- [x] **Aggregate mode for development** — `AndOne.aggregate_mode = true` reports each unique N+1 only once per server session. Tracks occurrence counts. `AndOne.aggregate.summary` shows a session overview. Thread-safe.
|
|
25
|
+
|
|
26
|
+
- [x] **RSpec / Minitest matchers** — `assert_no_n_plus_one { ... }` / `assert_n_plus_one { ... }` for Minitest. `expect { ... }.not_to cause_n_plus_one` for RSpec. Matchers temporarily disable `raise_on_detect` internally so they work regardless of config.
|
|
27
|
+
|
|
28
|
+
## ✅ Medium Value — Polish & Power User Features (Completed)
|
|
29
|
+
|
|
30
|
+
- [x] **`strict_loading` suggestion** — When an N+1 is detected, also suggest the `strict_loading` approach as an alternative: "You could also add `has_many :comments, strict_loading: true` to prevent this at the model level."
|
|
31
|
+
|
|
32
|
+
- [x] **Query count in test failure messages** — "N+1 detected: 47 queries to `comments` (expected 1). Add `.includes(:comments)` to reduce to 1 query." Makes severity immediately obvious.
|
|
33
|
+
|
|
34
|
+
- [x] **Dev UI endpoint** — A tiny Rack endpoint (e.g., `/__and_one`) in development that shows all N+1s detected in the current server session with fix suggestions. Like a mini BetterErrors for N+1s.
|
|
35
|
+
|
|
36
|
+
- [x] **GitHub Actions / CI annotations** — When `GITHUB_ACTIONS` env var is set, output detections in `::warning file=...` format so they appear as annotations on the PR diff.
|
|
37
|
+
|
|
38
|
+
- [x] **Ignore by caller pattern** — In addition to `ignore_queries` (SQL patterns), support `ignore_callers` to suppress detections originating from specific paths: "ignore any N+1 from `app/views/admin/*`".
|
|
39
|
+
|
|
40
|
+
- [x] **`has_many :through` and polymorphic support** — Extend the association resolver to handle `has_many :through` join chains and polymorphic associations, which are common sources of confusing N+1s.
|
|
41
|
+
|
|
42
|
+
- [x] **`preload` vs `includes` vs `eager_load` recommendation** — Suggest the optimal loading strategy based on the query pattern (e.g., `eager_load` when there's a WHERE on the association).
|
|
43
|
+
|
|
44
|
+
## ✅ Lower Priority — Nice to Have (Completed)
|
|
45
|
+
|
|
46
|
+
- [x] **Structured JSON logging** — A JSON output mode for log aggregation services (Datadog, Splunk, etc.). Set `AndOne.json_logging = true`. Uses `JsonFormatter` which outputs structured JSON with event, table, fingerprint, query count, suggestion, and backtrace. Also provides `format_hashes` for integrations that accept Ruby hashes directly.
|
|
47
|
+
|
|
48
|
+
- [x] **Thread-safety audit for Puma** — Formal audit and stress test suite complete. Found and fixed a **critical cross-thread contamination bug** in `Detector#subscribe`: the `ActiveSupport::Notifications` callback closure captured `self`, causing SQL from one thread to be recorded in another thread's Detector. Fixed by checking `Thread.current[:and_one_detector].object_id` in the callback. Also added Mutex protection for lazy singletons (`aggregate`, `ignore_list`), `AssociationResolver.@table_model_cache`, and report output serialization. 14 concurrent stress tests verify isolation, atomicity, and correctness under Puma-like load.
|
|
49
|
+
|
|
50
|
+
- [x] **Rails console integration** — Auto-scan in `rails console` sessions and print warnings inline. Activated automatically by the Railtie in development, or manually via `AndOne::Console.activate!`. Hooks into IRB (via `Context#evaluate` prepend) and Pry (via `:after_eval` hook) to cycle scans between commands.
|
|
51
|
+
|
|
52
|
+
- [x] **Configurable per-environment thresholds** — Different `min_n_queries` for dev vs test. Configure via `AndOne.env_thresholds = { "development" => 3, "test" => 2 }`. Falls back to global `min_n_queries` when no env-specific threshold is set. Supports both string and symbol keys.
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module AndOne
|
|
4
|
+
# Hooks into ActiveJob's around_perform callback to scan every job for N+1 queries.
|
|
5
|
+
# Works with any ActiveJob backend: Sidekiq, GoodJob, SolidQueue, Delayed Job, etc.
|
|
6
|
+
#
|
|
7
|
+
# Automatically installed by the Railtie. Can also be installed manually:
|
|
8
|
+
#
|
|
9
|
+
# ActiveJob::Base.include(AndOne::ActiveJobHook)
|
|
10
|
+
#
|
|
11
|
+
module ActiveJobHook
|
|
12
|
+
extend ActiveSupport::Concern
|
|
13
|
+
include ScanHelper
|
|
14
|
+
|
|
15
|
+
included do
|
|
16
|
+
around_perform :and_one_scan
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
private
|
|
20
|
+
|
|
21
|
+
def and_one_scan
|
|
22
|
+
and_one_wrap { yield }
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module AndOne
|
|
4
|
+
# Tracks unique N+1 detections across requests/jobs in a server session.
|
|
5
|
+
# In aggregate mode, each unique N+1 (by fingerprint) is only reported once.
|
|
6
|
+
# Subsequent occurrences are silently counted.
|
|
7
|
+
#
|
|
8
|
+
# Usage:
|
|
9
|
+
# AndOne.aggregate_mode = true
|
|
10
|
+
#
|
|
11
|
+
# The aggregate can be queried at any time:
|
|
12
|
+
# AndOne.aggregate.summary # => formatted string
|
|
13
|
+
# AndOne.aggregate.detections # => { fingerprint => { detection:, count:, first_seen_at: } }
|
|
14
|
+
# AndOne.aggregate.reset!
|
|
15
|
+
#
|
|
16
|
+
class Aggregate
|
|
17
|
+
Entry = Struct.new(:detection, :occurrences, :first_seen_at, :last_seen_at, keyword_init: true)
|
|
18
|
+
|
|
19
|
+
def initialize
|
|
20
|
+
@mutex = Mutex.new
|
|
21
|
+
@entries = {}
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# Record a detection. Returns true if this is a NEW unique detection
|
|
25
|
+
# (first time seeing this fingerprint), false if it's a repeat.
|
|
26
|
+
def record(detection)
|
|
27
|
+
fp = detection.fingerprint
|
|
28
|
+
|
|
29
|
+
@mutex.synchronize do
|
|
30
|
+
if @entries.key?(fp)
|
|
31
|
+
@entries[fp].occurrences += 1
|
|
32
|
+
@entries[fp].last_seen_at = Time.now
|
|
33
|
+
false
|
|
34
|
+
else
|
|
35
|
+
@entries[fp] = Entry.new(
|
|
36
|
+
detection: detection,
|
|
37
|
+
occurrences: 1,
|
|
38
|
+
first_seen_at: Time.now,
|
|
39
|
+
last_seen_at: Time.now
|
|
40
|
+
)
|
|
41
|
+
true
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def detections
|
|
47
|
+
@mutex.synchronize { @entries.dup }
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def size
|
|
51
|
+
@mutex.synchronize { @entries.size }
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def empty?
|
|
55
|
+
@mutex.synchronize { @entries.empty? }
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def reset!
|
|
59
|
+
@mutex.synchronize { @entries.clear }
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def summary
|
|
63
|
+
@mutex.synchronize do
|
|
64
|
+
return "No N+1 queries detected this session." if @entries.empty?
|
|
65
|
+
|
|
66
|
+
lines = []
|
|
67
|
+
lines << ""
|
|
68
|
+
lines << "🏀 AndOne Session Summary: #{@entries.size} unique N+1 pattern#{'s' if @entries.size != 1}"
|
|
69
|
+
lines << "─" * 60
|
|
70
|
+
|
|
71
|
+
@entries.each_with_index do |(fp, entry), i|
|
|
72
|
+
det = entry.detection
|
|
73
|
+
lines << " #{i + 1}) #{det.table_name || 'unknown'} — #{entry.occurrences} occurrence#{'s' if entry.occurrences != 1}"
|
|
74
|
+
lines << " #{det.sample_query[0, 120]}"
|
|
75
|
+
lines << " origin: #{det.origin_frame}" if det.origin_frame
|
|
76
|
+
lines << " fingerprint: #{fp}"
|
|
77
|
+
lines << ""
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
lines << "─" * 60
|
|
81
|
+
lines.join("\n")
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|