QueryOwl

CI Gem Version Downloads Ruby Rails 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
  • Structured log output — JSON-style warnings via Rails.logger with 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

↑ Back to top


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.

↑ Back to top


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

↑ Back to top


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.

↑ Back to top


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.

↑ Back to top


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.

↑ Back to top


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

↑ Back to top


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.

↑ Back to top


Rake Tasks

rails query_owl:clear   # drain the in-memory event store

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

↑ Back to top


Roadmap

See ROADMAP.md for planned features.

↑ Back to top


Contributing

See CONTRIBUTING.md for setup instructions, conventions, and how to report bugs.

↑ Back to top


License

MIT — see MIT-LICENSE.

↑ Back to top