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
- Log Output
- 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, making N+1s fail fast in test suites - Structured log output — JSON-style warnings via
Rails.loggerwith SQL, duration, count, and filtered backtrace - Zero overhead in production — auto-enabled in development only
Installation
Add to your Gemfile:
gem "query_owl"
Then run:
bundle install
Configuration
Create an initializer:
# config/initializers/query_owl.rb
QueryOwl.configure do |config|
config.enabled = Rails.env.development?
config.slow_query_threshold_ms = 100 # flag queries slower than this
config.n_plus_one_threshold = 2 # flag after this many repeated patterns
config.log_level = :warn # :warn | :info | :debug
config.backtrace_lines = 5 # number of backtrace frames to capture
config.backtrace_filter = ->(line) { line.start_with?("app/") } # optional custom filter
config.raise_on_n_plus_one = false # set true in CI to raise instead of log
end
Log Output
When a problem is detected, QueryOwl writes a structured line to Rails.logger:
[QueryOwl] {"type":"n_plus_one","sql":"SELECT * FROM posts WHERE user_id = ?","count":10,"backtrace":["app/controllers/posts_controller.rb:12"]}
[QueryOwl] {"type":"slow_query","sql":"SELECT * FROM reports WHERE ...","duration_ms":340}
[QueryOwl] {"type":"unused_eager_load","model":"Widget","association":"tags"}
[QueryOwl] Request complete — 10 N+1s, 1 slow query, 1 unused eager load
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) # loads tags but never touches them
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"}
Full pipeline (as it runs on every real HTTP request):
QueryOwl.config.slow_query_threshold_ms = 0
QueryOwl::QueryTracker.start!
QueryOwl::EagerLoadTracker.start!
Widget.all.each { |w| Widget.find(w.id) }
queries = QueryOwl::QueryTracker.stop!
eager_data = QueryOwl::EagerLoadTracker.stop!
events = QueryOwl::Detector.detect_n_plus_one(queries) +
QueryOwl::Detector.detect_slow_queries(queries) +
QueryOwl::Detector.detect_unused_eager_loads(eager_data)
QueryOwl::Logger.log_events(events)
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 releases, including unused eager load detection (0.2.0) and a /rails/slow_queries dashboard endpoint (0.3.0).
Contributing
- Fork the repo and create a
feat/<name>branch - Write specs for your change
- Run
bundle exec rake(lint + audit + tests) before opening a PR
License
MIT — see MIT-LICENSE.