Bulletproof
Bulletproof detects ActiveRecord memory problems caused by over-eager includes.
-
Static analysis — Scans Ruby source files and flags
includescalls that load unbounded record sets - Runtime monitoring — Measures actual record counts and GC pressure per request, and warns when thresholds are exceeded
Installation
# Gemfile
gem "bulletproof", group: :developmentbundle installStatic Analysis
CLI
# Analyze a directory
bundle exec bulletproof app/
# Analyze a single file
bundle exec bulletproof app/models/user.rb
# Override thresholds
bundle exec bulletproof app/ --max-includes-depth 3 --max-associations 5Output (violations found):
[WARNING] app/models/post.rb:12 — nesting depth 3 (limit: 2), no record-limiting method (limit / find, etc.) in chain
[WARNING] app/controllers/users_controller.rb:45 — 4 associations (limit: 3), no record-limiting method in chain
2 violation(s) found.
Output (clean):
No violations found.
The exit code is 0 when clean and 1 when violations are found, making it easy to integrate into CI.
Programmatic usage
report = Bulletproof.analyze("app/")
if report.ok?
puts "No violations found."
else
report.violations.each do |v|
puts "[#{v.severity.upcase}] #{v.file}:#{v.line} — #{v.message}"
end
endHow detection works
A call to includes is only flagged when both conditions are true:
- The nesting depth or association count exceeds the configured threshold
- No record-limiting method (
limit,find,first,page, etc.) appears anywhere in the method chain
# Flagged — unbounded full-table load
User.includes(posts: { comments: :author }).all
Post.includes(:user, :comments, :tags, :likes)
# Safe — record count is bounded
User.includes(posts: { comments: :author }).limit(10)
User.includes(posts: { comments: :author }).page(1).per(20)Runtime Monitoring (Rails)
Setup
# config/initializers/bulletproof.rb
Bulletproof.configure do |c|
c.enabled = Rails.env.development?
# ---- Thresholds ----------------------------------------------------------
# Max records loaded at once per model (default: 1_000)
c.max_records_per_model = 1_000
# Max total records loaded across all models per request (default: 5_000)
# find_each / in_batches loads are excluded from this count
c.max_total_records = 5_000
# Max GC runs per request (default: nil = disabled)
# GC frequency varies widely between apps; set explicitly if needed
# c.max_gc_runs_per_request = 10
# ---- Notifiers -----------------------------------------------------------
# Log to Rails.logger.warn (default: true)
c.rails_logger = true
# Inject console.warn into HTML responses (default: true)
# Visible in the browser's Console tab (F12)
c.console = true
# Inject a floating overlay panel into HTML responses (default: false)
# Visible on the page without opening DevTools
c.alert = true
# Append warnings to a log file (default: nil = disabled)
# c.log_file = Rails.root.join("log/bulletproof.log").to_s
# Custom notifier callable — for Slack, etc. (default: nil = disabled)
# c.notifier = ->(w) { SlackNotifier.ping(w.message) }
endSetting enabled = true causes the Railtie to automatically insert the Rack middleware. You do not need to call config.middleware.use manually.
Notifiers
| Key | Default | Description |
|---|---|---|
rails_logger |
true |
Calls Rails.logger.warn for each warning |
console |
true |
Injects console.warn before </body>. Visible in the browser Console tab |
alert |
false |
Injects a floating overlay panel before </body>. Dismissible with ✕ |
log_file |
nil (disabled) |
Appends timestamped warnings to the specified file path |
notifier |
nil (disabled) |
Callable receiving a RuntimeWarning. Use for Slack, webhooks, etc. |
Warning output example
[Bulletproof] Post: loaded 5,200 records at once (limit: 1,000)
→ app/controllers/posts_controller.rb:15:in 'index'
[Bulletproof] Total records loaded in this request: 7,800 (limit: 5,000) [Post: 5,200, Comment: 2,600]
Warning types
| Type | Condition | Related config key |
|---|---|---|
:mass_instantiation |
A single model loaded more than max_records_per_model records in one batch |
max_records_per_model |
:high_total_records |
Total records across all models exceeded max_total_records (batch loads excluded) |
max_total_records |
:gc_pressure |
GC ran more than max_gc_runs_per_request times during the request |
max_gc_runs_per_request |
find_each / in_batches
Batch processing is intentional and memory-safe, so Bulletproof does not warn on it.
# No warning — find_each loads records in bounded batches
Post.find_each(batch_size: 500) { |post| process(post) }
# Warning — all records loaded into memory at once
Post.all.to_aHow it works
Bulletproof subscribes to the instantiation.active_record ActiveSupport notification for the duration of each request. It accumulates record counts per model and uses caller_locations to identify the application code line responsible for each load. Subscription is scoped to the current thread via Thread.current, so parallel requests in multi-threaded servers (e.g. Puma) do not interfere with each other.
Configuration reference
| Key | Default | Description |
|---|---|---|
enabled |
false |
Enable runtime monitoring |
max_includes_depth |
2 |
Static: max includes nesting depth |
max_associations |
3 |
Static: max associations per includes call |
max_records_per_model |
1_000 |
Runtime: max records loaded at once per model |
max_total_records |
5_000 |
Runtime: max total records per request (batch loads excluded) |
max_gc_runs_per_request |
nil (disabled) |
Runtime: max GC runs per request |
rails_logger |
true |
Notifier: output to Rails.logger.warn
|
console |
true |
Notifier: inject console.warn into HTML |
alert |
false |
Notifier: inject overlay panel into HTML |
log_file |
nil (disabled) |
Notifier: append to log file |
notifier |
nil (disabled) |
Notifier: custom callable receiving RuntimeWarning
|
Requirements
- Ruby 3.0+
- Rails 6.0+ (runtime monitoring only)