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 single before_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 static missing_policy_scope rule) 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 via NewRelic::Agent::Tracer.start_segment/Segment#finish, the same mechanism add_method_tracer itself uses internally.
  • :opentelemetry — a real nested span per call via OpenTelemetry.tracer_provider.tracer(...).start_span, with OpenTelemetry::Context.attach/.detach used 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: :off installs no hooks at all — measured overhead is within noise of not requiring the file (see benchmark/apm_overhead.rb).
  • Sampling is per-trace, not per-span: the decision is made once when Scryer::APM.trace opens (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_error and OpenTelemetry's start_span/finish/record_exception are each wrapped in their own rescue StandardError — see test_new_relic_segment_start_failure_does_not_break_the_traced_method and test_open_telemetry_span_start_failure_does_not_break_the_traced_method in test/apm_test.rb, which stub a failing Tracer.start_segment/Tracer#start_span and 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_span wired 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_method more 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_prepare in 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.rb exists to prove, not just assert: a bad class name in include, instrumentation set to an unimplemented mode, or any other failure while Scryer::Railtie auto-enables APM (see "That's genuinely the whole thing" above) is caught — broadly, rescue Exception rather than just StandardError, since a require failure from a packaging issue would raise LoadError, a ScriptError — 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 of include. This graceful-degradation bar is specific to the automatic Railtie pathScryer::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):

scenarions/callvs. uninstrumented
baseline (no Scryer::APM at all)~28 ns1.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: :discovery and :deep_trace modes (low-overhead profiling to find expensive methods automatically, and extra detail captured only for slow/failed requests, respectively).
  • Any provider other than :new_relic and :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 traceparent header). This module's own trace_id/span_id fields are local-process only. The :opentelemetry provider's spans do still nest correctly with each other within one process via Context.attach/.detach — what's missing is reading/writing the traceparent header 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 your Gemfile, 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 just bundle install + the generator.
  • Re-running it: standard Thor/Rails::Generators behavior — if config/initializers/scryer.rb already 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.