Rails Integration
September 21, 2026 · View on GitHub
← Back to Scryer — Ruby on Rails Security Auditor
Runtime query watcher
Everything above is a one-shot static scan — it reads source, never boots Rails, and can't see
what actually happens at request time. Scryer::QueryWatcher is different: it's an opt-in
runtime instrumentation that watches a running app for two of the problems
Bullet is best known for catching — a collection query
followed by one repeat query per row ("N+1"), and an .includes/.preload/.eager_load
association that gets fetched but never actually read ("unused eager loading"). It was built
independently of Bullet, on a different mechanism (SQL-shape correlation via
ActiveSupport::Notifications, plus a Module#prepend on ActiveRecord::QueryMethods's eager-load
methods and Association#reader — not Bullet's own per-request association bookkeeping); no
Bullet source was read or copied to build it.
Enable it from an initializer (typically gated to development/test, though nothing stops you from running it in production if you want the noise):
# config/initializers/scryer.rb
if Rails.env.development? || Rails.env.test?
require "scryer/query_watcher"
Scryer::QueryWatcher.enable!
Rails.application.config.middleware.use Scryer::QueryWatcher::Middleware
end
The middleware opens one tracking scope per request and logs (via Rails.logger by default —
override with Scryer::QueryWatcher.enable!(logger: ...)) anything it finds when the request
ends. Outside a request — a Sidekiq job, a rake task, a console session — wrap the code yourself:
findings = Scryer::QueryWatcher.watch { SomeJob.new.perform }
enable!(n_plus_one_threshold: 2) controls how many repeats of the same query shape from the same
call site count as N+1 (default: the second occurrence already means one collection load produced
more than one query). Each finding is a Scryer::QueryWatcher::Finding with kind
(n_plus_one_query_runtime or unused_eager_load), message, call_site, count, and
suggested_fix — the same shape as everything else in this gem, so it's straightforward to feed
into your own logging/alerting instead of (or alongside) the built-in logger call.
This is genuinely runtime-only: with no queries running, it finds nothing, and it never appears in
the static tmp/scryer_report.html output. Think of it as this gem's answer to "what actually
happened during this request", where the rest of Scryer answers "what does this code look like".
Runtime authorization watcher
The static idor/missing_authorization/missing_policy_scope rules can only ever say "no call
to a known authorization method is visible anywhere in this controller's source" — which is exactly
as wrong as it sounds whenever the real check happens somewhere the static AST walk can't see (a
shared base controller, a concern, a class-level macro). Scryer::AuthorizationWatcher answers a
narrower but far more reliable question instead: for this actual request, did Pundit's
authorize/policy_scope or CanCanCan's authorize! genuinely get called? Both libraries already
track this themselves internally (for their own verify_authorized/check_authorization helpers)
— this watcher registers one more after_action alongside those and checks the same flags
(Pundit::Authorization#pundit_policy_authorized?/#pundit_policy_scoped?, CanCanCan's
@_authorized ivar — verified by reading both gems' actual source, not guessed).
# config/initializers/scryer.rb
require "scryer/authorization_watcher"
Scryer::AuthorizationWatcher.enable!
No middleware to install and no per-request scope to open (unlike QueryWatcher above) — a Rails
controller instance is already fresh per request, so the after_action above is all enable!
needs. It flags a create/update/destroy action (or any POST/PUT/PATCH/DELETE request)
that completed successfully (status < 400) with neither flag set:
Scryer::AuthorizationWatcher.findings
# => [#<struct Scryer::AuthorizationWatcher::Finding kind="runtime_missing_authorization",
# message="WidgetsController#update completed a PATCH request (status 200) with no
# authorization check actually invoked during it ...", controller="WidgetsController",
# action="update", method="PATCH", path="/widgets/1", suggested_fix="...">]
Two honesty points worth being precise about:
- Pundit/CanCanCan-only, same scope as the static rules it complements. With neither gem
loaded,
enable!still runs, but every request is silently skipped — an app with fully custom, non-object-level authorization (a singlebefore_action :require_admin!) gets no findings and no false-positive flood, rather than being flagged for a pattern this watcher has no way to recognize as intentional. - Write actions only. Read-scoping gaps (an unscoped
index— see the staticmissing_policy_scoperule) aren't covered here: verifying "was the returned data correctly scoped" at runtime, rather than "was a method called," is a materially different and harder check this class doesn't attempt.
Runtime method tracing (APM)
The problem QueryWatcher and AuthorizationWatcher don't touch: once a request lands inside
application code — a service object, a repository, a background-job method — most APM tools
(New Relic included) can only see it as one undifferentiated block of "Application Code" or
"Other" time. Scryer::APM answers "which method inside that block actually took the time" by
wrapping configured classes' own methods (the same Module#prepend technique QueryWatcher uses
on ActiveRecord::QueryMethods, not TracePoint) and, when a supported APM agent/SDK is loaded,
creating a real nested span per call via that agent's own public API — never an undocumented
endpoint or a separate exporter of Scryer's own. Two providers are implemented:
:new_relic— a real nested segment per call viaNewRelic::Agent::Tracer.start_segment/Segment#finish, the same mechanismadd_method_traceritself uses internally.:opentelemetry— a real nested span per call viaOpenTelemetry.tracer_provider.tracer(...).start_span, withOpenTelemetry::Context.attach/.detachused to make each span the "current" one so the next nested instrumented call picks it up as its parent automatically — the SDK's own documented manual-span API (the one used whenever the block form,Tracer#in_span, doesn't fit).
Neither SDK is a hard dependency of this gem (see the gemspec: zero runtime deps beyond stdlib) —
both are soft-detected via defined?(...). If you set c.apm.provider = :opentelemetry, add
opentelemetry-sdk (plus whatever exporter you want, e.g. opentelemetry-exporter-otlp) to your
own app's Gemfile, exactly the way you already add newrelic_rpm yourself to use :new_relic.
If the configured provider's SDK isn't loaded, spans are still traced (available via
Scryer::APM.on_span) but nothing is exported anywhere — a warning is logged once, the
instrumented methods keep working normally either way.
# config/initializers/scryer.rb
Scryer.configure do |c|
c.apm.enabled = true
c.apm.provider = :opentelemetry # or :new_relic
c.apm.instrumentation = :selective # only mode implemented so far — see Limitations below
c.apm.include = %w[BookingService PaymentService InventoryService]
c.apm.exclude = %w[] # Scryer:: / ActiveRecord:: / ActionController:: / ActionDispatch:: / Rails::
# are always excluded regardless of this list
c.apm.sampling_rate = 0.10 # 0.0–1.0, decided once per request, not per span
c.apm.capture_exception_messages = false # class name only by default; see Redaction below
end
That's genuinely the whole thing — c.apm.enabled = true is sufficient by itself, no separate
require/.enable!/middleware.use calls needed. Scryer::Railtie (loaded automatically as
soon as the gem is in your Gemfile) checks c.apm.enabled in a Rails initializer that runs
after every file in config/initializers/ — including this one — has already run, and does the
require "scryer/apm" / Scryer::APM.enable! / Rails.application.config.middleware.use Scryer::APM::Middleware sequence for you (Scryer::Railtie.maybe_enable_apm, tested directly in
test/railtie_apm_test.rb) if and only if that flag is true. Leaving c.apm.enabled at its
default (false) — or omitting the c.apm block entirely — means none of this runs and there is
no measurable overhead, same as never having required this file at all.
The one thing that genuinely can't be defaulted: c.apm.include must still be populated.
There is no "trace every method automatically" mode — an empty include (the default) means
enabled = true turns the machinery on but instruments nothing, by design (see Production safety
below for why "instrument every method in the app" is deliberately not offered).
Outside a request (a Sidekiq job, a rake task), open a trace scope yourself the same way
QueryWatcher.watch works for queries:
Scryer::APM.trace { SomeJob.new.perform }
What gets instrumented, precisely
c.apm.include takes class/module name strings. For each one, only methods defined directly on
that class (instance_methods(false) + private/protected equivalents) are wrapped — methods
inherited from a superclass or module are never touched, even if the named class itself has none
of its own. This is deliberate, not a current limitation: instrumenting inherited methods via a
bare class-name entry is exactly how include: ["SomeModel"] would end up silently wrapping
ActiveRecord::Base internals, which is the "sensitive framework internals" risk a production
instrumentation tool has to avoid by construction, not by asking users to get their exclude list
right. Method visibility (public/private/protected) is preserved exactly. Positional args, keyword
args, blocks, and return values all pass through super unchanged; exceptions of every class
(not just StandardError) are recorded on the span and always re-raised, never swallowed.
What each span records
class_name, method_name, file/line (captured once when the method is wrapped, not per
call), trace_id/span_id/parent_span_id (nested calls get correct parent/child relationships
via a thread-local span stack — see the "nested calls" test in test/apm_test.rb), duration_ms,
status (:ok/:error), exception_class, thread_id. Method argument values, local
variables, and return values are never recorded, under any configuration — there is no opt-in
that changes this. exception_message is nil unless capture_exception_messages: true, and even
then passes through a best-effort (not exhaustive — see Limitations) redaction pass that masks
long token/hash-shaped substrings and email addresses before being kept.
Production safety
instrumentation: :offinstalls no hooks at all — measured overhead is within noise of not requiring the file (seebenchmark/apm_overhead.rb).- Sampling is per-trace, not per-span: the decision is made once when
Scryer::APM.traceopens (or on the first instrumented call if a request somehow reaches one without a scope), so a sampled request gets a complete, connected trace rather than a random scatter of orphaned spans. - Provider failures never break the traced method. New Relic's
start_segment/finish/notice_errorand OpenTelemetry'sstart_span/finish/record_exceptionare each wrapped in their ownrescue StandardError— seetest_new_relic_segment_start_failure_does_not_break_the_traced_methodandtest_open_telemetry_span_start_failure_does_not_break_the_traced_methodintest/apm_test.rb, which stub a failingTracer.start_segment/Tracer#start_spanand confirm the real method's return value still comes through unaffected. - No custom export queue, batching, or retry/backoff exists in this module, and none is needed:
spans are handed directly to the already-async, already-batched, already-retried agent/SDK
transport via its own span API — Scryer::APM never talks to a network endpoint itself. The one
thing this means: if your configured provider isn't loaded but you keep
Scryer::APM.on_spanwired to your own sink, that sink is your responsibility to make non-blocking/bounded — nothing here queues or batches on your behalf. - Calling
.instrument_by_name/.instrument_methodmore than once for the same class/method is a no-op (a process-wide dedupe guard), so re-running an initializer (e.g., Rails'to_preparein development) can't double-wrap a method into two nested spans for one call. - A misconfiguration can never fail app boot, even in production. This is the specific
guarantee
test/railtie_apm_test.rbexists to prove, not just assert: a bad class name ininclude,instrumentationset to an unimplemented mode, or any other failure whileScryer::Railtieauto-enables APM (see "That's genuinely the whole thing" above) is caught — broadly,rescue Exceptionrather than justStandardError, since arequirefailure from a packaging issue would raiseLoadError, aScriptError— logged once, and leaves APM cleanly disabled for that process; the rest of the app boots and runs normally. One method failing to instrument (an internal error, not a bad config value) is scoped even tighter: only that method is skipped, not the rest of its class or the rest ofinclude. This graceful-degradation bar is specific to the automatic Railtie path —Scryer::APM.enable!called directly (a console, a test, a non-Rails script) still raises immediately for an unimplemented mode, which is the correct behavior when a developer is watching and wants fast feedback rather than a silently disabled feature.
Measured overhead
From benchmark/apm_overhead.rb (Apple silicon dev machine, Ruby 3.4.8, 200,000 calls per
scenario — re-run this yourself before trusting these numbers for capacity planning on different
hardware):
| scenario | ns/call | vs. uninstrumented |
|---|---|---|
| baseline (no Scryer::APM at all) | ~28 ns | 1.0x |
instrumentation: :off | ~28 ns | ~1.0x (no measurable difference) |
:selective, inside a trace, sampling_rate: 0.0 | ~172 ns | ~6.3x |
:selective, inside a trace, sampling_rate: 1.0 (full span) | ~1771 ns | ~64x |
The :off vs. unsampled-but-wrapped gap (~6x) is the fixed cost of the prepend/define_method
dispatch layer itself, before any span is even considered — this is the floor cost paid by every
call to a wrapped method regardless of sampling, which is why include should name specific
service/business-logic classes, not broad namespaces. Full span creation (~1771 ns, ~1.8 µs) is
still small in absolute terms next to typical service-method bodies (a DB query alone is usually
1–20+ ms), but at high call volume with sampling_rate: 1.0 it is not free — this is exactly what
sampling_rate exists to bound. Retained-memory cost: 50,000 held Span objects (worst case — a
consumer that doesn't drain them) measured ~53 MB RSS growth, roughly ~1 KB/span.
Limitations — implemented vs. not yet
Implemented and tested (see test/apm_test.rb, 29 examples, and test/railtie_apm_test.rb, 5
more): :off and :selective modes; nested parent/child spans; exception recording + re-raise;
keyword args/blocks/return values; method visibility preservation; inherited-method exclusion;
duplicate-instrumentation guard; per-class exclude; trace-scoped sampling; thread isolation
(concurrent requests on different threads never share trace/span state); New Relic segment
creation, error notification, and exporter-failure safety; OpenTelemetry span creation
(with correct parent/child nesting via Context.attach/.detach), exception recording, and
start-failure safety; the Rack middleware; reading config through Scryer.configure;
c.apm.enabled = true alone (no manual require/.enable!/middleware.use) genuinely
triggering tracing via the Railtie; a bad include entry or an unimplemented mode failing to
crash boot (see "Production safety" above); a single method's instrumentation failure not taking
out the rest of its class.
Not implemented — raises a clear ArgumentError naming this doc rather than silently
no-opping if configured:
instrumentation: :discoveryand:deep_tracemodes (low-overhead profiling to find expensive methods automatically, and extra detail captured only for slow/failed requests, respectively).- Any provider other than
:new_relicand:opentelemetry— no Datadog, no vendor-specific SDK beyond those two. - Singleton (class) method instrumentation — only instance methods.
- Distributed trace-context propagation across a process boundary (an outgoing HTTP call to
another service, or an inbound request that already carries a W3C
traceparentheader). This module's owntrace_id/span_idfields are local-process only. The:opentelemetryprovider's spans do still nest correctly with each other within one process viaContext.attach/.detach— what's missing is reading/writing thetraceparentheader itself to connect that local trace to one already in progress elsewhere.
Known caveat, not a bug: Scryer::APM.disable! (test-only) clears configuration/dedupe state
but cannot un-prepend an already-instrumented class — Ruby has no supported way to do that. Tests
that need a clean slate instrument a freshly-defined, uniquely-named class per example rather than
reusing one across examples; a host app calling .enable! more than once in a running process
should expect the same constraint.
Generators
Scryer ships one generator: scryer:install. Run bin/rails generate scryer:install --help
in a host app for the full description (also in
lib/generators/scryer/USAGE); short version:
- What it creates: a single file,
config/initializers/scryer.rb, templated from lib/generators/scryer/templates/scryer_initializer.rb. Nothing else — no routes, no migrations, no controllers/views. - What it doesn't do: the rake tasks (
scryer:report) are registered by Scryer::Railtie as soon as the gem is in yourGemfile, whether or not you ever run this generator. The generator exists purely to give you an editable config file. - Settings it exposes:
c.project_name(report header label),c.dirs(which top-level directories get scanned),c.branch(override the git branch label — see Branch reporting),c.skip_rules(silence specific rule_ids — see Skipping rules),c.ai_client(see AI-assisted fix suggestions). All are optional; the commented-out initializer works as-is with justbundle install+ the generator. - Re-running it: standard Thor/Rails::Generators behavior — if
config/initializers/scryer.rbalready exists, you'll be prompted to overwrite, skip, or diff rather than have it silently clobbered.
Testing Scryer results in your own test suite
Two opt-in files turn "this app's own scan stays clean" into a normal test-suite assertion, so a
regression fails the suite the same way any other regression would, instead of only showing up the
next time someone runs scryer by hand. Neither loads with the gem automatically (RSpec/Minitest
are never Scryer runtime dependencies — see the gemspec) — require the one matching your test
framework yourself:
# spec/spec_helper.rb (RSpec)
require "scryer/rspec"
RSpec.describe "security" do
it "has no critical findings" do
expect(Scryer.scan(root: Rails.root.to_s)).to have_no_critical_findings
end
it "never reintroduces the mass-assignment bug fixed in PR #123" do
expect(Scryer.scan(root: Rails.root.to_s)).to have_no_findings_for("mass_assignment")
end
end
# test/test_helper.rb (Minitest / ActiveSupport::TestCase)
require "scryer/minitest"
class SecurityTest < ActiveSupport::TestCase
include Scryer::MinitestAssertions
test "no critical findings" do
assert_no_critical_scryer_findings(Scryer.scan(root: Rails.root.to_s))
end
end
Scryer.scan(root:) runs the same static scan scryer:report/the scryer executable do (using
c.dirs/c.skip_rules from your initializer), without needing a report written to disk —
have_no_critical_findings/assert_no_critical_scryer_findings only look at security findings
(same scoping as the Security Score); have_no_findings_for/
assert_no_scryer_findings_for check a specific rule_id across all three static categories,
for pinning a specific bug so it can't come back unnoticed. This talks to the live filesystem on
every test run (a real Ripper-based scan, same cost as running scryer itself) — for a large app
this is meaningfully slower than a typical unit test, so it's usually one dedicated test/spec file
run occasionally (a nightly job, a pre-release check) rather than part of every rspec/rails test
invocation.