/prosopite

:mag: Rails N+1 queries auto-detection with zero false positives / false negatives

Primary LanguageRubyApache License 2.0Apache-2.0

Prosopite CI Gem Version

Prosopite is able to auto-detect Rails N+1 queries with zero false positives / false negatives.

N+1 queries detected:
  SELECT `users`.* FROM `users` WHERE `users`.`id` = 20 LIMIT 1
  SELECT `users`.* FROM `users` WHERE `users`.`id` = 21 LIMIT 1
  SELECT `users`.* FROM `users` WHERE `users`.`id` = 22 LIMIT 1
  SELECT `users`.* FROM `users` WHERE `users`.`id` = 23 LIMIT 1
  SELECT `users`.* FROM `users` WHERE `users`.`id` = 24 LIMIT 1
Call stack:
  app/controllers/thank_you_controller.rb:4:in `block in index'
  app/controllers/thank_you_controller.rb:3:in `each'
  app/controllers/thank_you_controller.rb:3:in `index':
  app/controllers/application_controller.rb:8:in `block in <class:ApplicationController>'

The need for prosopite emerged after dealing with various false positives / negatives using the bullet gem.

Compared to Bullet

Prosopite can auto-detect the following extra cases of N+1 queries:

N+1 queries after record creations (usually in tests)

FactoryBot.create_list(:leg, 10)

Leg.last(10).each do |l|
  l.chair
end

Not triggered by ActiveRecord associations

Leg.last(4).each do |l|
  Chair.find(l.chair_id)
end

First/last/pluck of collection associations

Chair.last(20).each do |c|
  c.legs.first
  c.legs.last
  c.legs.pluck(:id)
end

Changing the ActiveRecord class with #becomes

Chair.last(20).map{ |c| c.becomes(ArmChair) }.each do |ac|
  ac.legs.map(&:id)
end

Mongoid models calling ActiveRecord

class Leg::Design
  include Mongoid::Document
  ...
  field :cid, as: :chair_id, type: Integer
  ...
  def chair
    @chair ||= Chair.where(id: chair_id).first!
  end
end

Leg::Design.last(20) do |l|
  l.chair
end

Why a new gem

Creating a new gem makes more sense since bullet's core mechanism is completely different from prosopite's.

How it works

Prosopite monitors all SQL queries using the Active Support instrumentation and looks for the following pattern which is present in all N+1 query cases:

More than one queries have the same call stack and the same query fingerprint.

Installation

Add this line to your application's Gemfile:

gem 'prosopite'

And then execute:

$ bundle install

Or install it yourself as:

$ gem install prosopite

Configuration

The preferred type of notifications can be configured with:

  • Prosopite.rails_logger = true: Send warnings to the Rails log
  • Prosopite.prosopite_logger = true: Send warnings to log/prosopite.log
  • Prosopite.stderr_logger = true: Send warnings to STDERR
  • Prosopite.raise = true: Raise warnings as exceptions

Development Environment Usage

Prosopite auto-detection can be enabled on all controllers:

class ApplicationController < ActionController::Base
  before_action do
    Prosopite.scan
  end

  after_action do
    Prosopite.finish
  end
end

And the preferred notification channel should be configured:

# config/environments/development.rb

config.after_initialize do
  Prosopite.rails_logger = true
end

Test Environment Usage

Tests with N+1 queries can be configured to fail with:

# config/environments/test.rb

config.after_initialize do
  Prosopite.rails_logger = true
  Prosopite.raise = true
end

And each test can be scanned with:

# spec/spec_helper.rb

config.before(:each) do
  Prosopite.scan
end

config.after(:each) do
  Prosopite.finish
end

WARNING: scan/finish should run before/after each test and NOT before/after the whole suite.

Whitelisting

Ignore notifications for call stacks containing one or more substrings:

Prosopite.whitelist = ['substring_in_call_stack']

Scanning code outside controllers or tests

All you have to do is to wrap the code with:

Prosopite.scan
<code to scan>
Prosopite.finish

Contributing

Bug reports and pull requests are welcome on GitHub at https://github.com/charkost/prosopite.

License

Prosopite is licensed under the Apache License, Version 2.0. See LICENSE.txt for the full license text.