QueryOwl

CI Gem Version Downloads Ruby codecov

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

  • 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_load that 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: true to raise QueryOwl::NPlusOneError instead of logging, making N+1s fail fast in test suites
  • Structured log output — JSON-style warnings via Rails.logger with SQL, duration, count, and filtered backtrace
  • Zero overhead in production — auto-enabled in development only

↑ Back to top


Installation

Add to your Gemfile:

gem "query_owl"

Then run:

bundle install

↑ Back to top


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
  config.event_store_size        = 100   # ring buffer capacity
  config.dashboard_enabled       = Rails.env.development? # HTML view on/off
end

↑ Back to top


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

↑ Back to top


Dashboard Endpoint

Mount the engine in your host app's routes to enable the JSON endpoint:

# config/routes.rb
mount QueryOwl::Engine => "/rails"

Then browse the HTML dashboard or query JSON at GET /rails/slow_queries:

GET /rails/slow_queries              # HTML dashboard (browser)
GET /rails/slow_queries.json         # JSON array
GET /rails/slow_queries?type=n_plus_one
GET /rails/slow_queries?type=slow_query
GET /rails/slow_queries?type=unused_eager_load

The HTML view is enabled when config.dashboard_enabled is true (default in development); returns 403 otherwise. The JSON endpoint is always available.

The JSON response is an array of event objects, newest first, up to config.event_store_size entries:

[
  {
    "type": "n_plus_one",
    "sql": "SELECT * FROM posts WHERE user_id = ?",
    "count": 5,
    "backtrace": ["app/controllers/posts_controller.rb:12"],
    "recorded_at": "2026-06-15T18:00:00.000Z"
  }
]

↑ Back to top


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}\") }"

↑ Back to top


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).

↑ Back to top


Contributing

  1. Fork the repo and create a feat/<name> branch
  2. Write specs for your change
  3. Run bundle exec rake (lint + audit + tests) before opening a PR

↑ Back to top


License

MIT — see MIT-LICENSE.

↑ Back to top