module QueryBudget
class Counter
IGNORED = %w[SCHEMA CACHE TRANSACTION].freeze
attr_reader :count
def initialize
@count = 0
@offenders = Hash.new(0)
end
def call(_name, _start, _finish, _id, payload)
return if IGNORED.include?(payload[:name])
return if payload[:cached]
@count += 1
@offenders[normalize(payload[:sql])] += 1
end
def top_offenders(limit = 5)
@offenders.sort_by { |_sql, n| -n }.first(limit)
end
private
def normalize(sql)
sql.to_s.gsub(/\d+/, "?").squeeze(" ").strip.slice(0, 120)
end
end
end
module QueryBudget
class Middleware
def initialize(app)
@app = app
end
def call(env)
counter = Counter.new
env["query_budget.counter"] = counter
subscriber = ActiveSupport::Notifications.subscribe("sql.active_record", counter)
status, headers, body = @app.call(env)
report!(env, counter)
[status, headers, body]
ensure
ActiveSupport::Notifications.unsubscribe(subscriber) if subscriber
end
private
def report!(env, counter)
budget = env["query_budget.limit"] || default_budget
return if counter.count <= budget
message = format(
"Query budget exceeded: %d/%d queries for %s\n%s",
counter.count, budget, env["PATH_INFO"],
counter.top_offenders.map { |sql, n| " #{n}x #{sql}" }.join("\n")
)
if raise_over_budget?
raise QueryBudget::BudgetExceeded, message
else
Rails.logger.warn(message)
end
end
def default_budget
Rails.application.config.x.query_budget || 100
end
def raise_over_budget?
Rails.env.test? || Rails.env.development?
end
end
class BudgetExceeded < StandardError; end
end
class ApplicationController < ActionController::Base
def query_count
counter = request.env["query_budget.counter"]
counter ? counter.count : 0
end
def with_query_budget(limit)
request.env["query_budget.limit"] = limit
yield
end
end
class DashboardsController < ApplicationController
def show
# This endpoint legitimately fans out; give it a tighter, explicit ceiling.
with_query_budget(40) do
@account = Account.includes(:projects, projects: :tasks).find(params[:id])
@recent = @account.projects.flat_map(&:tasks).sort_by(&:updated_at).last(20)
end
response.set_header("X-Query-Count", query_count.to_s)
end
end
require_relative "../lib/query_budget/counter"
require_relative "../lib/query_budget/middleware"
module Acme
class Application < Rails::Application
config.load_defaults 7.0
config.x.query_budget = ENV.fetch("QUERY_BUDGET", 100).to_i
# Insert late so the counter observes queries triggered by inner middleware.
config.middleware.use QueryBudget::Middleware
end
end
A single Rails action that fires 400 SQL queries usually looks fine in development and then melts a database connection pool in production. This snippet installs a per-request query budget so runaway pages announce themselves loudly instead of silently degrading. It counts every SQL statement executed within a request and, when the count crosses a threshold, either logs a rich warning or raises so the offending code path is caught in CI and staging.
The counting happens in QueryBudget counter, a small object subscribed to ActiveSupport's sql.active_record notification. Each call increments a plain integer unless the payload is a SCHEMA or CACHE query, since those are not real round trips to the database. The counter also captures the sql and name of the heaviest offenders so the eventual report can point at the actual statements. Keeping this as a per-instance object rather than a global avoids leaking counts across threads and requests.
QueryBudget middleware wires one counter into each request's lifecycle. It stashes the counter in env so controllers can read it, subscribes for the duration of the request, and always unsubscribes in an ensure block — a leaked subscription would keep counting forever and slowly poison every subsequent request on that worker. After the downstream app returns, report! compares the count against Rails.application.config.x.query_budget and decides whether to warn or raise, driven by raise_over_budget? so production stays lenient while test and development fail fast.
ApplicationController exposes the current count through query_count and adds with_query_budget, letting a specific action tighten its own limit for a known hot path. This is the key trade-off: a global budget catches regressions broadly, while per-action budgets encode intent for endpoints that legitimately need more queries.
The main pitfall is eager-loading blind spots — a budget alone does not fix N+1s, it only surfaces them, so the fix is still includes or a batched query. Because the subscriber is threadsafe per-request and cheap, the overhead is negligible even under load, making this a practical guardrail rather than a heavyweight APM.
Related snips
package api
import (
"net/http"
"runtime/debug"
)
Expose build metadata for debugging deploys
class CommentsController < ApplicationController
before_action :set_post
def create
@comment = @post.comments.build(comment_params)
System test: asserting Turbo Stream responses
class Post < ApplicationRecord
belongs_to :author, class_name: 'User'
has_many :comments, dependent: :destroy
scope :published, -> { where.not(published_at: nil).where('published_at <= ?', Time.current) }
scope :draft, -> { where(published_at: nil) }
ActiveRecord scopes for reusable query logic
export interface RetryOptions {
retries: number;
baseMs: number;
maxMs: number;
signal?: AbortSignal;
onRetry?: (attempt: number, delay: number, err: unknown) => void;
Exponential backoff with jitter for retries
module Api
module V1
class UsersController < BaseController
def show
user = User.includes(:profile).find(params[:id])
ETags for conditional requests and caching
class PostsController < ApplicationController
def index
@posts = Post.includes(:author)
.order(created_at: :desc)
.page(params[:page])
.per(10)
Turbo Frames: infinite scroll with lazy-loading frame
Share this code
Here's the card — post it anywhere.