query_owl 0.7.0 → 1.0.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 +4 -4
- data/README.md +177 -44
- data/lib/query_owl/engine.rb +4 -0
- data/lib/query_owl/version.rb +1 -1
- data/lib/query_owl.rb +4 -0
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 9a23065ce633a10b81db2bc0c0fda038d653f8918bd88c51667543155110b6d5
|
|
4
|
+
data.tar.gz: 962b390a8e5c19d15c1fe48040ceb17b4e0aba35608926b9c6c0b8594d6232f4
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 512d974f0cd510230d9a8f9cce551cea8e6782ebcb7539309a31d2236f1f1d9040f4785329cb345a909407866443661aadff6cbba0f6898d690d330dd2e902f5
|
|
7
|
+
data.tar.gz: 45e313a61c01c4b96db3fe2dc2bace097770d7b2dbbc4bed0a5984e8c73cfcdefa0330b6e0d7495bafe33364789b84dc96e993f832507c39402da19e7e9e63eb
|
data/README.md
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
[](https://rubygems.org/gems/query_owl)
|
|
5
5
|
[](https://rubygems.org/gems/query_owl)
|
|
6
6
|
[](https://www.ruby-lang.org)
|
|
7
|
+
[](https://rubyonrails.org)
|
|
7
8
|
[](https://codecov.io/gh/eclectic-coding/query_owl)
|
|
8
9
|
|
|
9
10
|
A leaner alternative to Bullet. QueryOwl detects N+1 queries, slow queries, and unused eager loads in development, logging structured warnings to your Rails logger — without the noise.
|
|
@@ -13,8 +14,12 @@ A leaner alternative to Bullet. QueryOwl detects N+1 queries, slow queries, and
|
|
|
13
14
|
- [Features](#features)
|
|
14
15
|
- [Installation](#installation)
|
|
15
16
|
- [Configuration](#configuration)
|
|
17
|
+
- [Notifiers](#notifiers)
|
|
18
|
+
- [Ignoring Paths and Controllers](#ignoring-paths-and-controllers)
|
|
16
19
|
- [Log Output](#log-output)
|
|
17
|
-
- [Dashboard
|
|
20
|
+
- [Dashboard](#dashboard)
|
|
21
|
+
- [Test Helper](#test-helper)
|
|
22
|
+
- [Rake Tasks](#rake-tasks)
|
|
18
23
|
- [Manual Testing in the Dummy App](#manual-testing-in-the-dummy-app)
|
|
19
24
|
- [Roadmap](#roadmap)
|
|
20
25
|
- [Contributing](#contributing)
|
|
@@ -28,8 +33,10 @@ A leaner alternative to Bullet. QueryOwl detects N+1 queries, slow queries, and
|
|
|
28
33
|
- **Slow query detection** — flags queries exceeding a configurable threshold (default: 100ms)
|
|
29
34
|
- **Unused eager load detection** — flags associations preloaded via `includes`/`eager_load` that are never accessed during the request
|
|
30
35
|
- **Per-request summary** — single summary line at the end of each request with totals (e.g. `Request complete — 3 N+1s, 1 slow query`)
|
|
31
|
-
- **CI-friendly raise mode** — set `raise_on_n_plus_one: true` to raise `QueryOwl::NPlusOneError` instead of logging
|
|
36
|
+
- **CI-friendly raise mode** — set `raise_on_n_plus_one: true` to raise `QueryOwl::NPlusOneError` instead of logging
|
|
32
37
|
- **Structured log output** — JSON-style warnings via `Rails.logger` with SQL, duration, count, and filtered backtrace
|
|
38
|
+
- **HTML dashboard** — browser-accessible event table with filtering and sortable columns
|
|
39
|
+
- **Pluggable notifiers** — send events to any destination via a simple `#call(event)` interface
|
|
33
40
|
- **Zero overhead in production** — auto-enabled in development only
|
|
34
41
|
|
|
35
42
|
[↑ Back to top](#table-of-contents)
|
|
@@ -48,72 +55,153 @@ Then run:
|
|
|
48
55
|
|
|
49
56
|
```sh
|
|
50
57
|
bundle install
|
|
58
|
+
rails generate query_owl:install
|
|
51
59
|
```
|
|
52
60
|
|
|
61
|
+
The generator creates `config/initializers/query_owl.rb` with all options documented and commented out.
|
|
62
|
+
|
|
63
|
+
**Compatibility:** Ruby >= 3.3, Rails >= 7.1. Tested against Rails 8.1 on Ruby 3.3, 3.4, and 4.0.
|
|
64
|
+
|
|
53
65
|
[↑ Back to top](#table-of-contents)
|
|
54
66
|
|
|
55
67
|
---
|
|
56
68
|
|
|
57
69
|
## Configuration
|
|
58
70
|
|
|
59
|
-
|
|
71
|
+
All options are set inside a `QueryOwl.configure` block, typically in `config/initializers/query_owl.rb`.
|
|
72
|
+
|
|
73
|
+
| Option | Type | Default | Description |
|
|
74
|
+
|---|---|---|---|
|
|
75
|
+
| `enabled` | Boolean | `Rails.env.development?` | Master on/off switch |
|
|
76
|
+
| `slow_query_threshold_ms` | Integer | `100` | Flag queries slower than this many milliseconds |
|
|
77
|
+
| `n_plus_one_threshold` | Integer | `2` | Flag when the same SQL pattern fires this many times per request |
|
|
78
|
+
| `log_level` | Symbol | `:warn` | Log level for warnings — `:debug`, `:info`, or `:warn` |
|
|
79
|
+
| `backtrace_lines` | Integer | `5` | Number of backtrace frames captured per query |
|
|
80
|
+
| `backtrace_filter` | Callable | strips gem/internal paths | Proc that receives a line and returns `true` to keep it |
|
|
81
|
+
| `raise_on_n_plus_one` | Boolean | `false` | Raise `QueryOwl::NPlusOneError` instead of logging |
|
|
82
|
+
| `event_store_size` | Integer | `100` | Ring buffer capacity (oldest events dropped when full) |
|
|
83
|
+
| `dashboard_enabled` | Boolean | `Rails.env.development?` | Enable the HTML dashboard at `GET /slow_queries` |
|
|
84
|
+
| `log_file` | String / nil | `nil` | Append each event as a JSON line to this file path |
|
|
85
|
+
| `notifiers` | Array | `[Notifiers::Logger]` | Objects responding to `#call(event)` — see [Notifiers](#notifiers) |
|
|
86
|
+
| `ignore_paths` | Array | `[]` | Path prefixes or regexes to skip entirely |
|
|
87
|
+
| `ignore_controllers` | Array | `[]` | Controller names to skip after routing |
|
|
88
|
+
|
|
89
|
+
Example:
|
|
60
90
|
|
|
61
91
|
```ruby
|
|
62
|
-
# config/initializers/query_owl.rb
|
|
63
92
|
QueryOwl.configure do |config|
|
|
64
93
|
config.enabled = Rails.env.development?
|
|
65
|
-
config.slow_query_threshold_ms = 100
|
|
66
|
-
config.n_plus_one_threshold = 2
|
|
67
|
-
config.log_level = :warn
|
|
68
|
-
config.backtrace_lines = 5
|
|
69
|
-
config.
|
|
70
|
-
config.
|
|
71
|
-
config.
|
|
72
|
-
config.
|
|
94
|
+
config.slow_query_threshold_ms = 100
|
|
95
|
+
config.n_plus_one_threshold = 2
|
|
96
|
+
config.log_level = :warn
|
|
97
|
+
config.backtrace_lines = 5
|
|
98
|
+
config.raise_on_n_plus_one = false
|
|
99
|
+
config.event_store_size = 100
|
|
100
|
+
config.dashboard_enabled = Rails.env.development?
|
|
101
|
+
config.log_file = Rails.root.join("log/query_owl.log").to_s
|
|
102
|
+
config.ignore_paths = ["/up", "/healthz", %r{^/assets/}]
|
|
103
|
+
config.ignore_controllers = ["rails/health"]
|
|
104
|
+
config.notifiers = [QueryOwl::Notifiers::Console.new]
|
|
105
|
+
end
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
[↑ Back to top](#table-of-contents)
|
|
109
|
+
|
|
110
|
+
---
|
|
111
|
+
|
|
112
|
+
## Notifiers
|
|
113
|
+
|
|
114
|
+
Notifiers receive each detected event via `#call(event)`. Any object responding to `#call` is valid.
|
|
115
|
+
|
|
116
|
+
**Built-in notifiers:**
|
|
117
|
+
|
|
118
|
+
| Notifier | Description |
|
|
119
|
+
|---|---|
|
|
120
|
+
| `QueryOwl::Notifiers::Logger` | Writes to `Rails.logger` (default) |
|
|
121
|
+
| `QueryOwl::Notifiers::Console` | TTY-aware colorized output — yellow for N+1s, red for slow queries; falls back to plain output in CI |
|
|
122
|
+
| `QueryOwl::Notifiers::Stdout` | Writes to `$stdout`; useful for background jobs and Rake tasks |
|
|
123
|
+
|
|
124
|
+
**Custom notifier:**
|
|
125
|
+
|
|
126
|
+
```ruby
|
|
127
|
+
my_notifier = ->(event) { MyService.track(event) }
|
|
128
|
+
|
|
129
|
+
QueryOwl.configure do |config|
|
|
130
|
+
config.notifiers = [QueryOwl::Notifiers::Logger.new, my_notifier]
|
|
131
|
+
end
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
A failing notifier is rescued and logged via `Rails.logger.error` — it cannot crash the request or prevent other notifiers from running.
|
|
135
|
+
|
|
136
|
+
[↑ Back to top](#table-of-contents)
|
|
137
|
+
|
|
138
|
+
---
|
|
139
|
+
|
|
140
|
+
## Ignoring Paths and Controllers
|
|
141
|
+
|
|
142
|
+
Skip high-frequency or low-value requests to reduce noise:
|
|
143
|
+
|
|
144
|
+
```ruby
|
|
145
|
+
QueryOwl.configure do |config|
|
|
146
|
+
# String entries match as path prefix; Regexp entries use #match?
|
|
147
|
+
config.ignore_paths = ["/up", "/healthz", %r{^/assets/}]
|
|
148
|
+
|
|
149
|
+
# Match against the Rails controller name (e.g. "rails/health")
|
|
150
|
+
config.ignore_controllers = ["rails/health", "admin/metrics"]
|
|
73
151
|
end
|
|
74
152
|
```
|
|
75
153
|
|
|
154
|
+
Ignored paths are detected before tracking starts — no SQL or eager load data is collected. Ignored controllers are detected after routing — trackers still stop cleanly, but no events are dispatched.
|
|
155
|
+
|
|
76
156
|
[↑ Back to top](#table-of-contents)
|
|
77
157
|
|
|
78
158
|
---
|
|
79
159
|
|
|
80
160
|
## Log Output
|
|
81
161
|
|
|
82
|
-
When
|
|
162
|
+
When an issue is detected, QueryOwl writes a structured line to `Rails.logger`:
|
|
83
163
|
|
|
84
164
|
```
|
|
85
|
-
[QueryOwl] {"type":"n_plus_one","sql":"SELECT * FROM posts WHERE user_id = ?","count":10,"backtrace":["app/controllers/posts_controller.rb:12"]}
|
|
86
|
-
[QueryOwl] {"type":"slow_query","sql":"SELECT * FROM reports WHERE ...","duration_ms":340}
|
|
87
|
-
[QueryOwl] {"type":"unused_eager_load","model":"Widget","association":"tags"}
|
|
165
|
+
[QueryOwl] {"type":"n_plus_one","sql":"SELECT * FROM posts WHERE user_id = ?","count":10,"controller":"posts","action":"index","path":"/posts","backtrace":["app/controllers/posts_controller.rb:12"]}
|
|
166
|
+
[QueryOwl] {"type":"slow_query","sql":"SELECT * FROM reports WHERE ...","duration_ms":340,"controller":"reports","action":"show","path":"/reports/1"}
|
|
167
|
+
[QueryOwl] {"type":"unused_eager_load","model":"Widget","association":"tags","controller":"widgets","action":"index","path":"/widgets"}
|
|
88
168
|
[QueryOwl] Request complete — 10 N+1s, 1 slow query, 1 unused eager load
|
|
89
169
|
```
|
|
90
170
|
|
|
171
|
+
When `log_file` is set, each event is also appended as a JSON line to that file — useful for persistence across server restarts.
|
|
172
|
+
|
|
91
173
|
[↑ Back to top](#table-of-contents)
|
|
92
174
|
|
|
93
175
|
---
|
|
94
176
|
|
|
95
|
-
## Dashboard
|
|
177
|
+
## Dashboard
|
|
96
178
|
|
|
97
|
-
Mount the engine in your
|
|
179
|
+
Mount the engine in your routes to enable the dashboard:
|
|
98
180
|
|
|
99
181
|
```ruby
|
|
100
182
|
# config/routes.rb
|
|
101
183
|
mount QueryOwl::Engine => "/rails"
|
|
102
184
|
```
|
|
103
185
|
|
|
104
|
-
|
|
186
|
+
**HTML dashboard** at `GET /rails/slow_queries` (requires `config.dashboard_enabled = true`, default in development):
|
|
187
|
+
|
|
188
|
+
- Filter by event type and controller name (partial match supported)
|
|
189
|
+
- Sortable columns: Type, Info, Recorded At (click to toggle asc/desc)
|
|
190
|
+
- Turbo-powered — filter and sort changes replace only the table, not the full page
|
|
191
|
+
|
|
192
|
+
**JSON endpoint** at `GET /rails/slow_queries.json` (always available regardless of `dashboard_enabled`):
|
|
105
193
|
|
|
106
194
|
```
|
|
107
|
-
GET /rails/slow_queries
|
|
108
|
-
GET /rails/slow_queries.json # JSON array
|
|
195
|
+
GET /rails/slow_queries.json
|
|
109
196
|
GET /rails/slow_queries?type=n_plus_one
|
|
110
197
|
GET /rails/slow_queries?type=slow_query
|
|
111
198
|
GET /rails/slow_queries?type=unused_eager_load
|
|
199
|
+
GET /rails/slow_queries?controller=widgets
|
|
200
|
+
GET /rails/slow_queries?action=index
|
|
201
|
+
GET /rails/slow_queries?sort=recorded_at&direction=asc
|
|
112
202
|
```
|
|
113
203
|
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
The JSON response is an array of event objects, newest first, up to `config.event_store_size` entries:
|
|
204
|
+
**Example JSON response:**
|
|
117
205
|
|
|
118
206
|
```json
|
|
119
207
|
[
|
|
@@ -121,12 +209,74 @@ The JSON response is an array of event objects, newest first, up to `config.even
|
|
|
121
209
|
"type": "n_plus_one",
|
|
122
210
|
"sql": "SELECT * FROM posts WHERE user_id = ?",
|
|
123
211
|
"count": 5,
|
|
212
|
+
"controller": "posts",
|
|
213
|
+
"action": "index",
|
|
214
|
+
"path": "/posts",
|
|
124
215
|
"backtrace": ["app/controllers/posts_controller.rb:12"],
|
|
125
216
|
"recorded_at": "2026-06-15T18:00:00.000Z"
|
|
126
217
|
}
|
|
127
218
|
]
|
|
128
219
|
```
|
|
129
220
|
|
|
221
|
+
**Clear the event store** without restarting the server:
|
|
222
|
+
|
|
223
|
+
```sh
|
|
224
|
+
rails query_owl:clear
|
|
225
|
+
```
|
|
226
|
+
|
|
227
|
+
[↑ Back to top](#table-of-contents)
|
|
228
|
+
|
|
229
|
+
---
|
|
230
|
+
|
|
231
|
+
## Test Helper
|
|
232
|
+
|
|
233
|
+
QueryOwl ships an opt-in test helper with RSpec matchers and Minitest assertions.
|
|
234
|
+
|
|
235
|
+
**Setup (RSpec):**
|
|
236
|
+
|
|
237
|
+
```ruby
|
|
238
|
+
# spec/rails_helper.rb
|
|
239
|
+
require "query_owl/test_helper"
|
|
240
|
+
RSpec.configure { |c| c.include QueryOwl::TestHelper }
|
|
241
|
+
```
|
|
242
|
+
|
|
243
|
+
**Setup (Minitest):**
|
|
244
|
+
|
|
245
|
+
```ruby
|
|
246
|
+
# test/test_helper.rb
|
|
247
|
+
require "query_owl/test_helper"
|
|
248
|
+
class ActiveSupport::TestCase
|
|
249
|
+
include QueryOwl::TestHelper
|
|
250
|
+
end
|
|
251
|
+
```
|
|
252
|
+
|
|
253
|
+
**RSpec matchers:**
|
|
254
|
+
|
|
255
|
+
```ruby
|
|
256
|
+
expect { Post.all.each(&:author) }.not_to trigger_n_plus_one
|
|
257
|
+
expect { slow_operation }.not_to trigger_slow_query
|
|
258
|
+
expect { Widget.includes(:tags).map(&:name) }.not_to trigger_unused_eager_load
|
|
259
|
+
```
|
|
260
|
+
|
|
261
|
+
**Minitest assertions:**
|
|
262
|
+
|
|
263
|
+
```ruby
|
|
264
|
+
assert_no_n_plus_one { Post.all.each(&:author) }
|
|
265
|
+
assert_no_slow_query { slow_operation }
|
|
266
|
+
```
|
|
267
|
+
|
|
268
|
+
Each helper runs the block with trackers active, isolated from `config.enabled` and `config.raise_on_n_plus_one`.
|
|
269
|
+
|
|
270
|
+
[↑ Back to top](#table-of-contents)
|
|
271
|
+
|
|
272
|
+
---
|
|
273
|
+
|
|
274
|
+
## Rake Tasks
|
|
275
|
+
|
|
276
|
+
```sh
|
|
277
|
+
rails query_owl:clear # drain the in-memory event store
|
|
278
|
+
```
|
|
279
|
+
|
|
130
280
|
[↑ Back to top](#table-of-contents)
|
|
131
281
|
|
|
132
282
|
---
|
|
@@ -171,28 +321,13 @@ QueryOwl::Logger.log_events(events)
|
|
|
171
321
|
```ruby
|
|
172
322
|
QueryOwl.config.enabled = true
|
|
173
323
|
QueryOwl::EagerLoadTracker.start!
|
|
174
|
-
Widget.includes(:tags).map(&:name)
|
|
324
|
+
Widget.includes(:tags).map(&:name)
|
|
175
325
|
eager_data = QueryOwl::EagerLoadTracker.stop!
|
|
176
326
|
events = QueryOwl::Detector.detect_unused_eager_loads(eager_data)
|
|
177
327
|
QueryOwl::Logger.log_events(events)
|
|
178
328
|
# => [QueryOwl] {"type":"unused_eager_load","model":"Widget","association":"tags"}
|
|
179
329
|
```
|
|
180
330
|
|
|
181
|
-
**Full pipeline** (as it runs on every real HTTP request):
|
|
182
|
-
|
|
183
|
-
```ruby
|
|
184
|
-
QueryOwl.config.slow_query_threshold_ms = 0
|
|
185
|
-
QueryOwl::QueryTracker.start!
|
|
186
|
-
QueryOwl::EagerLoadTracker.start!
|
|
187
|
-
Widget.all.each { |w| Widget.find(w.id) }
|
|
188
|
-
queries = QueryOwl::QueryTracker.stop!
|
|
189
|
-
eager_data = QueryOwl::EagerLoadTracker.stop!
|
|
190
|
-
events = QueryOwl::Detector.detect_n_plus_one(queries) +
|
|
191
|
-
QueryOwl::Detector.detect_slow_queries(queries) +
|
|
192
|
-
QueryOwl::Detector.detect_unused_eager_loads(eager_data)
|
|
193
|
-
QueryOwl::Logger.log_events(events)
|
|
194
|
-
```
|
|
195
|
-
|
|
196
331
|
**Seed the dummy database first** (if needed):
|
|
197
332
|
|
|
198
333
|
```sh
|
|
@@ -207,7 +342,7 @@ RAILS_ENV=development bin/rails runner "3.times { |i| Widget.create!(name: \"Wid
|
|
|
207
342
|
|
|
208
343
|
## Roadmap
|
|
209
344
|
|
|
210
|
-
See [ROADMAP.md](ROADMAP.md) for planned
|
|
345
|
+
See [ROADMAP.md](ROADMAP.md) for planned features.
|
|
211
346
|
|
|
212
347
|
[↑ Back to top](#table-of-contents)
|
|
213
348
|
|
|
@@ -215,9 +350,7 @@ See [ROADMAP.md](ROADMAP.md) for planned releases, including unused eager load d
|
|
|
215
350
|
|
|
216
351
|
## Contributing
|
|
217
352
|
|
|
218
|
-
|
|
219
|
-
2. Write specs for your change
|
|
220
|
-
3. Run `bundle exec rake` (lint + audit + tests) before opening a PR
|
|
353
|
+
See [CONTRIBUTING.md](CONTRIBUTING.md) for setup instructions, conventions, and how to report bugs.
|
|
221
354
|
|
|
222
355
|
[↑ Back to top](#table-of-contents)
|
|
223
356
|
|
data/lib/query_owl/engine.rb
CHANGED
|
@@ -19,6 +19,10 @@ module QueryOwl
|
|
|
19
19
|
end
|
|
20
20
|
end
|
|
21
21
|
|
|
22
|
+
initializer "query_owl.deprecator" do |app|
|
|
23
|
+
app.deprecators[:query_owl] = QueryOwl.deprecator
|
|
24
|
+
end
|
|
25
|
+
|
|
22
26
|
initializer "query_owl.subscribe" do
|
|
23
27
|
ActiveSupport::Notifications.subscribe("sql.active_record") do |*args|
|
|
24
28
|
next unless QueryOwl.config.enabled
|
data/lib/query_owl/version.rb
CHANGED
data/lib/query_owl.rb
CHANGED