QueryOwl
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.
Table of Contents
- Features
- Installation
- Configuration
- Notifiers
- Ignoring Paths and Controllers
- Log Output
- Dashboard
- Test Helper
- Rake Tasks
- Manual Testing in the Dummy App
- Roadmap
- Contributing
- License
Features
- N+1 detection — flags when the same SQL pattern fires 2+ times in a single request
- Slow query detection — flags queries exceeding a configurable threshold (default: 100ms)
- Unused eager load detection — flags associations preloaded via
includes/eager_loadthat are never accessed during the request - Per-request summary — single summary line at the end of each request with totals (e.g.
Request complete — 3 N+1s, 1 slow query) - CI-friendly raise mode — set
raise_on_n_plus_one: trueto raiseQueryOwl::NPlusOneErrorinstead of logging - Structured log output — JSON-style warnings via
Rails.loggerwith SQL, duration, count, and filtered backtrace - HTML dashboard — browser-accessible event table with filtering and sortable columns
- Pluggable notifiers — send events to any destination via a simple
#call(event)interface - Zero overhead in production — auto-enabled in development only
Installation
Add to your Gemfile:
gem "query_owl"
Then run:
bundle install
rails generate query_owl:install
The generator creates config/initializers/query_owl.rb with all options documented and commented out.
Compatibility: Ruby >= 3.3, Rails >= 7.1. Tested against Rails 8.1 on Ruby 3.3, 3.4, and 4.0.
Configuration
All options are set inside a QueryOwl.configure block, typically in config/initializers/query_owl.rb.
| Option | Type | Default | Description |
|---|---|---|---|
enabled |
Boolean | Rails.env.development? |
Master on/off switch |
slow_query_threshold_ms |
Integer | 100 |
Flag queries slower than this many milliseconds |
n_plus_one_threshold |
Integer | 2 |
Flag when the same SQL pattern fires this many times per request |
log_level |
Symbol | :warn |
Log level for warnings — :debug, :info, or :warn |
backtrace_lines |
Integer | 5 |
Number of backtrace frames captured per query |
backtrace_filter |
Callable | strips gem/internal paths | Proc that receives a line and returns true to keep it |
raise_on_n_plus_one |
Boolean | false |
Raise QueryOwl::NPlusOneError instead of logging |
event_store_size |
Integer | 100 |
Ring buffer capacity (oldest events dropped when full) |
dashboard_enabled |
Boolean | Rails.env.development? |
Enable the HTML dashboard at GET /slow_queries |
log_file |
String / nil | nil |
Append each event as a JSON line to this file path |
notifiers |
Array | [Notifiers::Logger] |
Objects responding to #call(event) — see Notifiers |
ignore_paths |
Array | [] |
Path prefixes or regexes to skip entirely |
ignore_controllers |
Array | [] |
Controller names to skip after routing |
Example:
QueryOwl.configure do |config|
config.enabled = Rails.env.development?
config.slow_query_threshold_ms = 100
config.n_plus_one_threshold = 2
config.log_level = :warn
config.backtrace_lines = 5
config.raise_on_n_plus_one = false
config.event_store_size = 100
config.dashboard_enabled = Rails.env.development?
config.log_file = Rails.root.join("log/query_owl.log").to_s
config.ignore_paths = ["/up", "/healthz", %r{^/assets/}]
config.ignore_controllers = ["rails/health"]
config.notifiers = [QueryOwl::Notifiers::Console.new]
end
Notifiers
Notifiers receive each detected event via #call(event). Any object responding to #call is valid.
Built-in notifiers:
| Notifier | Description |
|---|---|
QueryOwl::Notifiers::Logger |
Writes to Rails.logger (default) |
QueryOwl::Notifiers::Console |
TTY-aware colorized output — yellow for N+1s, red for slow queries; falls back to plain output in CI |
QueryOwl::Notifiers::Stdout |
Writes to $stdout; useful for background jobs and Rake tasks |
Custom notifier:
my_notifier = ->(event) { MyService.track(event) }
QueryOwl.configure do |config|
config.notifiers = [QueryOwl::Notifiers::Logger.new, my_notifier]
end
A failing notifier is rescued and logged via Rails.logger.error — it cannot crash the request or prevent other notifiers from running.
Ignoring Paths and Controllers
Skip high-frequency or low-value requests to reduce noise:
QueryOwl.configure do |config|
# String entries match as path prefix; Regexp entries use #match?
config.ignore_paths = ["/up", "/healthz", %r{^/assets/}]
# Match against the Rails controller name (e.g. "rails/health")
config.ignore_controllers = ["rails/health", "admin/metrics"]
end
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.
Log Output
When an issue is detected, QueryOwl writes a structured line to Rails.logger:
[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"]}
[QueryOwl] {"type":"slow_query","sql":"SELECT * FROM reports WHERE ...","duration_ms":340,"controller":"reports","action":"show","path":"/reports/1"}
[QueryOwl] {"type":"unused_eager_load","model":"Widget","association":"tags","controller":"widgets","action":"index","path":"/widgets"}
[QueryOwl] Request complete — 10 N+1s, 1 slow query, 1 unused eager load
When log_file is set, each event is also appended as a JSON line to that file — useful for persistence across server restarts.
Dashboard
Mount the engine in your routes to enable the dashboard:
# config/routes.rb
mount QueryOwl::Engine => "/rails"
HTML dashboard at GET /rails/slow_queries (requires config.dashboard_enabled = true, default in development):
- Filter by event type and controller name (partial match supported)
- Sortable columns: Type, Info, Recorded At (click to toggle asc/desc)
- Turbo-powered — filter and sort changes replace only the table, not the full page
JSON endpoint at GET /rails/slow_queries.json (always available regardless of dashboard_enabled):
GET /rails/slow_queries.json
GET /rails/slow_queries?type=n_plus_one
GET /rails/slow_queries?type=slow_query
GET /rails/slow_queries?type=unused_eager_load
GET /rails/slow_queries?controller=widgets
GET /rails/slow_queries?action=index
GET /rails/slow_queries?sort=recorded_at&direction=asc
Example JSON response:
[
{
"type": "n_plus_one",
"sql": "SELECT * FROM posts WHERE user_id = ?",
"count": 5,
"controller": "posts",
"action": "index",
"path": "/posts",
"backtrace": ["app/controllers/posts_controller.rb:12"],
"recorded_at": "2026-06-15T18:00:00.000Z"
}
]
Clear the event store without restarting the server:
rails query_owl:clear
Test Helper
QueryOwl ships an opt-in test helper with RSpec matchers and Minitest assertions.
Setup (RSpec):
# spec/rails_helper.rb
require "query_owl/test_helper"
RSpec.configure { |c| c.include QueryOwl::TestHelper }
Setup (Minitest):
# test/test_helper.rb
require "query_owl/test_helper"
class ActiveSupport::TestCase
include QueryOwl::TestHelper
end
RSpec matchers:
expect { Post.all.each(&:author) }.not_to trigger_n_plus_one
expect { slow_operation }.not_to trigger_slow_query
expect { Widget.includes(:tags).map(&:name) }.not_to trigger_unused_eager_load
Minitest assertions:
assert_no_n_plus_one { Post.all.each(&:author) }
assert_no_slow_query { slow_operation }
Each helper runs the block with trackers active, isolated from config.enabled and config.raise_on_n_plus_one.
Rake Tasks
rails query_owl:clear # drain the in-memory event store
Manual Testing in the Dummy App
The gem ships with a minimal Rails app in spec/dummy/ for manual verification.
Start a console:
cd spec/dummy
RAILS_ENV=development bin/rails console
Trigger N+1 detection:
QueryOwl.config.enabled = true
QueryOwl::QueryTracker.start!
Widget.all.each { |w| Widget.find(w.id) }
queries = QueryOwl::QueryTracker.stop!
events = QueryOwl::Detector.detect_n_plus_one(queries)
QueryOwl::Logger.log_events(events)
# => [QueryOwl] {"type":"n_plus_one","sql":"SELECT ...","count":3,...}
Trigger slow query detection:
QueryOwl.config.slow_query_threshold_ms = 0 # flag everything
QueryOwl::QueryTracker.start!
Widget.all.to_a
queries = QueryOwl::QueryTracker.stop!
events = QueryOwl::Detector.detect_slow_queries(queries)
QueryOwl::Logger.log_events(events)
# => [QueryOwl] {"type":"slow_query","sql":"SELECT ...","duration_ms":...}
Trigger unused eager load detection:
QueryOwl.config.enabled = true
QueryOwl::EagerLoadTracker.start!
Widget.includes(:tags).map(&:name)
eager_data = QueryOwl::EagerLoadTracker.stop!
events = QueryOwl::Detector.detect_unused_eager_loads(eager_data)
QueryOwl::Logger.log_events(events)
# => [QueryOwl] {"type":"unused_eager_load","model":"Widget","association":"tags"}
Seed the dummy database first (if needed):
cd spec/dummy
RAILS_ENV=development bin/rails db:migrate
RAILS_ENV=development bin/rails runner "3.times { |i| Widget.create!(name: \"Widget #{i}\") }"
Roadmap
See ROADMAP.md for planned features.
Contributing
See CONTRIBUTING.md for setup instructions, conventions, and how to report bugs.
License
MIT — see MIT-LICENSE.