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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: daca52db3c3f2564dc0e0e51b2271e995db9042ffd82d2b2a2ef25529fc5cbd4
4
- data.tar.gz: 47cad0afa15471a0fb60dc6366dbadcc5bc480b2cf7b17321198230fd46f8d8b
3
+ metadata.gz: 9a23065ce633a10b81db2bc0c0fda038d653f8918bd88c51667543155110b6d5
4
+ data.tar.gz: 962b390a8e5c19d15c1fe48040ceb17b4e0aba35608926b9c6c0b8594d6232f4
5
5
  SHA512:
6
- metadata.gz: 82cb7e1f4f42c553731c3e0ba32f4b4f7f01a0bf21c837644371fcb02313d4995f27f84fd70f574df3f6ef889266ddec6ba2c7bbf3c5302f3ee0c1084d431539
7
- data.tar.gz: 8bf2d7078c201e0bb673943194dbb3d98d07e30a1ce6a9c64ac6c72f0416870aca1349a21050779c7edbc92c1d5f2609219d44fed7745591d60ca83dfe98c387
6
+ metadata.gz: 512d974f0cd510230d9a8f9cce551cea8e6782ebcb7539309a31d2236f1f1d9040f4785329cb345a909407866443661aadff6cbba0f6898d690d330dd2e902f5
7
+ data.tar.gz: 45e313a61c01c4b96db3fe2dc2bace097770d7b2dbbc4bed0a5984e8c73cfcdefa0330b6e0d7495bafe33364789b84dc96e993f832507c39402da19e7e9e63eb
data/README.md CHANGED
@@ -4,6 +4,7 @@
4
4
  [![Gem Version](https://img.shields.io/gem/v/query_owl)](https://rubygems.org/gems/query_owl)
5
5
  [![Downloads](https://img.shields.io/gem/dt/query_owl)](https://rubygems.org/gems/query_owl)
6
6
  [![Ruby](https://img.shields.io/badge/ruby-%3E%3D%203.3-ruby)](https://www.ruby-lang.org)
7
+ [![Rails](https://img.shields.io/badge/rails-%3E%3D%207.1-red)](https://rubyonrails.org)
7
8
  [![codecov](https://codecov.io/gh/eclectic-coding/query_owl/branch/main/graph/badge.svg)](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 Endpoint](#dashboard-endpoint)
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, making N+1s fail fast in test suites
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
- Create an initializer:
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 # flag queries slower than this
66
- config.n_plus_one_threshold = 2 # flag after this many repeated patterns
67
- config.log_level = :warn # :warn | :info | :debug
68
- config.backtrace_lines = 5 # number of backtrace frames to capture
69
- config.backtrace_filter = ->(line) { line.start_with?("app/") } # optional custom filter
70
- config.raise_on_n_plus_one = false # set true in CI to raise instead of log
71
- config.event_store_size = 100 # ring buffer capacity
72
- config.dashboard_enabled = Rails.env.development? # HTML view on/off
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 a problem is detected, QueryOwl writes a structured line to `Rails.logger`:
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 Endpoint
177
+ ## Dashboard
96
178
 
97
- Mount the engine in your host app's routes to enable the JSON endpoint:
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
- Then browse the HTML dashboard or query JSON at `GET /rails/slow_queries`:
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 # HTML dashboard (browser)
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
- The HTML view is enabled when `config.dashboard_enabled` is `true` (default in development); returns `403` otherwise. The JSON endpoint is always available.
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) # loads tags but never touches them
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 releases, including unused eager load detection (0.2.0) and a `/rails/slow_queries` dashboard endpoint (0.3.0).
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
- 1. Fork the repo and create a `feat/<name>` branch
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
 
@@ -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
@@ -1,3 +1,3 @@
1
1
  module QueryOwl
2
- VERSION = "0.7.0"
2
+ VERSION = "1.0.0"
3
3
  end
data/lib/query_owl.rb CHANGED
@@ -28,5 +28,9 @@ module QueryOwl
28
28
  def reset_config!
29
29
  @config = Configuration.new
30
30
  end
31
+
32
+ def deprecator
33
+ @deprecator ||= ActiveSupport::Deprecation.new("1.0", "QueryOwl")
34
+ end
31
35
  end
32
36
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: query_owl
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.7.0
4
+ version: 1.0.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Chuck Smith