Rails Error Reporter: Structured Error Handling Without rescue Spaghetti
Rails Error Reporter centralizes production error handling with handle, record, and report. Stop scattering rescue blocks and context everywhere.
The codebase had been through six engineers in four years. I counted forty-three distinct places where someone had written rescue => e; logger.error(e.message). In one controller action, the same rescue block appeared three times in a five-line method — apparently each engineer had fixed a different exception class without noticing the others. In the background jobs, errors vanished entirely: rescue StandardError at the top of every perform method, each ending with nil.
Sentry existed but received roughly 20% of the errors actually happening in production. The other 80% were swallowed, logged to a file nobody read, or silently caught and reported to a second error tracker that had been decommissioned eighteen months earlier.
This is the natural endpoint of ad-hoc exception handling. Every engineer adds their own pattern, every rescue block grows independently, and error context — user ID, request ID, the business operation that was running — gets attached inconsistently or not at all.
Rails 7.0 shipped a fix: the Rails Error Reporter, Rails.error. It is a simple, standardized layer between your code and whatever error tracking you use. After nineteen years of Rails, it is one of those features I wish had existed from the beginning.
What the Rails Error Reporter Is
Rails.error is an instance of ActiveSupport::ErrorReporter, available globally from Rails 7.0 onward. It provides three core methods — handle, record, and report — plus context management. Anything you route through it lands in every registered subscriber simultaneously.
The subscribers are what connect it to the real world: Sentry, Honeybadger, Bugsnag, or your own custom backend. Sentry-ruby has supported the Rails Error Reporter since sentry-rails 5.9.0. If you run gem "sentry-rails", it subscribes automatically when Rails initializes. The same is true for Honeybadger 5.x and AppSignal 3.x.
The point is that your application code has no knowledge of the error tracker. It speaks to Rails.error. The tracker subscribes. You swap trackers by changing one line in an initializer, not by grepping for Sentry.capture_exception across fifty files.
The Three Methods
handle: catch, report, and continue
handle wraps a block, catches exceptions, reports them, and continues execution. The block’s caller never sees the exception.
# Without Rails Error Reporter
def import_row(row)
process(row)
rescue => e
Sentry.capture_exception(e, extra: { row: row })
nil
end
# With Rails Error Reporter
def import_row(row)
Rails.error.handle(context: { row: row }) do
process(row)
end
end
handle returns nil if an exception occurs. You can specify a fallback:
def fetch_pricing(sku)
Rails.error.handle(fallback: -> { DEFAULT_PRICE }) do
PricingService.lookup(sku)
end
end
You can narrow the error class:
Rails.error.handle(NetworkError, TimeoutError, severity: :warning) do
ExternalApi.sync
end
Only NetworkError and TimeoutError are caught and reported with :warning severity. Everything else propagates normally.
The severity option accepts :debug, :info, :warning, :error, and :fatal. Most subscribers filter or route by severity. Setting :warning on transient network errors keeps your Sentry inbox clean without dropping the data.
record: report and re-raise
record catches, reports, and re-raises. Use it when you need error reporting but cannot absorb the exception — the caller still needs to deal with it.
def charge_card(amount:, token:)
Rails.error.record(context: { amount: amount }) do
PaymentGateway.charge(token: token, amount: amount)
end
end
If PaymentGateway.charge raises, the error goes to Sentry (or wherever), then the exception continues propagating to the controller, which can render the appropriate error response. You get the tracking without losing the exception flow.
# This is equivalent to:
begin
PaymentGateway.charge(token: token, amount: amount)
rescue => e
Rails.error.report(e, handled: false, context: { amount: amount })
raise
end
report: for pre-caught exceptions
Sometimes you have already rescued an exception and you are deep enough in the call stack that you can handle it, but you still want it tracked. report takes an exception object directly:
def notify_users(user_ids)
user_ids.each do |user_id|
UserMailer.notification(user_id).deliver_now
rescue => e
Rails.error.report(e, handled: true, context: { user_id: user_id })
# Continue with the next user
end
end
handled: true tells subscribers this error was expected and dealt with. handled: false means you are reporting something you could not handle — it is unexpected and should alert. Most error trackers use this distinction to separate noise from fires.
Context: Making Errors Debuggable
The most common failure mode in error tracking is receiving an exception with no idea who the user was, what they were doing, or which downstream service was involved. Fixing this manually means adding context at every rescue site.
The Error Reporter solves this at two levels.
Thread-local context with set_context
Rails.error.set_context sets context for all errors reported in the current thread. Call it once in a before_action and every error for that request carries the data:
# app/controllers/concerns/error_context.rb
module ErrorContext
extend ActiveSupport::Concern
included do
before_action :set_error_reporter_context
end
private
def set_error_reporter_context
Rails.error.set_context(
user_id: current_user&.id,
account_id: current_account&.id,
request_id: request.request_id,
locale: I18n.locale
)
end
end
Include it in ApplicationController and every error — handled or unhandled, in the controller action or deep in a service object — carries the user ID without any extra work at the rescue site.
Per-block context
The context: option on handle, record, and report merges additional context for that specific operation:
Rails.error.handle(context: { payment_id: payment.id, gateway: "stripe" }) do
stripe_charge(payment)
end
Per-block context does not pollute the thread-local context — it only applies to errors from that block. This matters when you process a collection of items: per-iteration context does not bleed between iterations.
source
Rails 7.1 added the source: parameter to identify where an error originated:
Rails.error.handle(source: "payment_processor", context: { ... }) do
...
end
Custom subscribers can route by source — send payment processor errors to PagerDuty, and third-party API errors to a lower-priority Slack channel.
Writing a Custom Subscriber
You do not need Sentry. A subscriber is any object that responds to report(error, handled:, severity:, context:, source:):
# config/initializers/error_reporter.rb
class SlackErrorSubscriber
WEBHOOK_URL = ENV.fetch("SLACK_ERROR_WEBHOOK_URL")
def report(error, handled:, severity:, context:, source: nil)
return if handled && severity == :warning
payload = {
text: "[#{severity.upcase}] #{error.class}: #{error.message}",
blocks: [
{
type: "section",
text: {
type: "mrkdwn",
text: "*#{error.class}*: #{error.message}\nHandled: #{handled}\nSource: #{source}\nContext: #{context.inspect}"
}
}
]
}
Net::HTTP.post(
URI(WEBHOOK_URL),
payload.to_json,
"Content-Type" => "application/json"
)
rescue => e
Rails.logger.error("SlackErrorSubscriber failed: #{e.message}")
end
end
Rails.application.config.after_initialize do
Rails.error.subscribe(SlackErrorSubscriber.new)
end
Multiple subscribers can coexist. Rails.error.subscribe in an initializer registers globally. If you want a subscriber for just one block of code, Rails 7.1 added the block form of subscribe:
captured = []
subscriber = ->(error, **opts) { captured << { error: error, **opts } }
Rails.error.subscribe(subscriber) do
run_batch_import
end
# subscriber is automatically removed after the block
This is exactly what the test helper uses, which leads to testing.
Background Jobs
Before the Error Reporter, every job had its own error handling pattern. Some jobs caught everything, logged it, and returned nil. Some re-raised. Some reported to Sentry in the rescue block, but not to the other error tracker used by a different team.
ActiveJob in Rails 7.1 automatically reports job errors through Rails.error before retrying or discarding. This means that any subscriber you register picks up job failures with no extra code. But you can also use the Error Reporter explicitly inside jobs:
class DataSyncJob < ApplicationJob
def perform(account_id)
account = Account.find(account_id)
Rails.error.set_context(account_id: account.id, job: self.class.name)
Rails.error.handle(NetworkError, severity: :warning, context: { phase: "fetch" }) do
remote_data = ExternalApi.fetch(account.external_id)
account.update!(remote_data: remote_data)
end
Rails.error.record(context: { phase: "sync" }) do
SyncService.run(account)
end
end
end
The set_context call at the top of perform means every error from this job, including unhandled ones that ActiveJob reports, carries account_id and job name. The handle for the fetch phase swallows transient network errors with a :warning severity. The record for the sync phase re-raises — if the sync fails, you want the job to retry — but Sentry sees it immediately.
For Solid Queue and Sidekiq specifically: both integrate with ActiveJob’s error reporting pipeline. Jobs using perform_later through ActiveJob automatically feed into Rails.error. Sidekiq’s native Sidekiq::ServerMiddleware does not, but you can add it:
class SidekiqErrorMiddleware
def call(_worker, job, _queue)
yield
rescue => e
Rails.error.report(e, handled: false, context: { jid: job["jid"], class: job["class"] })
raise
end
end
Sidekiq.configure_server do |config|
config.server_middleware do |chain|
chain.prepend SidekiqErrorMiddleware
end
end
Testing
The scoped subscriber form makes testing error reporting behavior clean:
class PaymentServiceTest < ActiveSupport::TestCase
test "reports invalid amount errors without raising" do
reported = []
subscriber = Class.new do
attr_reader :reported
def initialize = @reported = []
def report(error, **) = @reported << error
end.new
Rails.error.subscribe(subscriber) do
PaymentService.new.charge(amount: -50, token: "tok_test")
end
assert_equal 1, subscriber.reported.length
assert_instance_of InvalidAmountError, subscriber.reported.first
end
test "includes user context in reported errors" do
context_received = {}
subscriber = Class.new do
def initialize(store) = @store = store
def report(error, context:, **) = @store.merge!(context)
end.new(context_received)
Rails.error.subscribe(subscriber) do
Rails.error.set_context(user_id: 42)
Rails.error.handle { raise "oops" }
end
assert_equal 42, context_received[:user_id]
end
end
In RSpec, the same pattern works in a before block:
RSpec.describe PaymentService do
let(:error_subscriber) do
Class.new do
attr_reader :reports
def initialize = @reports = []
def report(error, **opts) = @reports << { error: error, **opts }
end.new
end
around do |example|
Rails.error.subscribe(error_subscriber, &example)
end
it "reports network errors as warnings" do
allow(PaymentGateway).to receive(:charge).and_raise(NetworkError)
service.charge(amount: 100)
expect(error_subscriber.reports.size).to eq(1)
expect(error_subscriber.reports.first[:severity]).to eq(:warning)
end
end
The around block ensures the subscriber is scoped to each test and automatically cleaned up. No global state leaks between tests.
Before and After
Here is a realistic before/after from the audit I mentioned at the start. One service object that talked to an external API:
Before:
class ExternalSyncService
def sync(account)
data = client.fetch(account.external_id)
account.update!(synced_data: data, last_synced_at: Time.current)
rescue Faraday::TimeoutError => e
logger.error("Timeout syncing account #{account.id}: #{e.message}")
Sentry.capture_exception(e, extra: { account_id: account.id })
false
rescue Faraday::Error => e
logger.warn("Network error syncing account #{account.id}: #{e.message}")
# forgot to report this one
false
rescue ActiveRecord::RecordInvalid => e
logger.error("DB error syncing account #{account.id}: #{e.message}")
Bugsnag.notify(e, account_id: account.id) # different tracker!
raise
end
end
Three different error reporting mechanisms, one missed error class, inconsistent context, and two trackers that the current team did not know were both active.
After:
class ExternalSyncService
def sync(account)
Rails.error.set_context(account_id: account.id, service: "external_sync")
Rails.error.handle(Faraday::Error, severity: :warning, context: { phase: "fetch" }) do
data = client.fetch(account.external_id)
account.update!(synced_data: data, last_synced_at: Time.current)
true
end
end
end
One tracker, consistent context, appropriate severity, and if you later add Honeybadger, this service automatically starts reporting to it without touching this code.
FAQ
When should I use handle versus record in Rails?
Use handle when your code can proceed without the operation that failed — an optional enrichment, a non-critical third-party call, a single row in a batch import where others can still succeed. Use record when the exception must be seen by the caller but you also want it tracked before it propagates — a critical payment operation, a data migration step where you need the error to trigger a retry. When in doubt: handle swallows the exception (callers do not know), record re-raises it (callers do know). Choose based on whether the caller needs to respond.
Does the Rails Error Reporter replace rescue_from in controllers?
They are complementary. rescue_from is still the right place to translate specific exception classes into HTTP responses — rescue_from ActiveRecord::RecordNotFound, with: :not_found. What the Error Reporter replaces is the ad-hoc error tracking inside rescue_from handlers and inside service objects. A clean setup: rescue_from handles the response, and either Rails.error.report in the handler or Rails.error.record in the service object ensures tracking. You do not need both: unhandled exceptions that bubble up from a controller will also be reported by the error reporter if your framework integration (Sentry-rails, etc.) is set up, because they subscribe to Rails.error and forward it.
How does Rails Error Reporter handle errors in concurrent code?
set_context is thread-local. If you use Concurrent::Future, Ractors, or the async gem for parallel work, each thread or fiber has its own context. That means context you set in the parent thread does not automatically carry to child threads. For parallel work, pass context explicitly via the context: parameter on each handle/record/report call rather than relying on set_context. In Puma’s multi-threaded environment (one thread per request), set_context in a before_action is safe — each request gets its own thread, so context does not bleed between requests.
What version of Rails introduced the Error Reporter?
The core API (handle, record, report, set_context, subscribe) was introduced in Rails 7.0 (released December 2021). Rails 7.1 (October 2023) added the source: parameter, the block form of subscribe for scoped subscriptions, and tighter ActiveJob integration that automatically reports job errors to all subscribers. If you are still on Rails 6, upgrade incrementally — the Error Reporter alone is worth the jump.
Your Rails app is eating exceptions in the dark? TTB Software audits and modernizes production Rails error handling. After nineteen years of Rails, we have seen every rescue pattern that can go wrong — and the clean patterns that do not.
Related Articles
Rails StackProf: Flamegraph CPU Profiling for Ruby Production Applications
Rails StackProf flamegraph profiling reveals exactly where your Ruby burns CPU — sampling middleware, three profiling...
Rails Kredis: Redis-Backed Model Attributes Without Writing Redis Code
Rails Kredis adds Redis-backed counters, flags, lists, and hashes directly to your models — no manual key management,...
Rails Hybrid Search: Combining pgvector Semantic Search with Postgres Full-Text for Better Recall
Rails hybrid search combines pgvector semantic vectors with Postgres full-text search and Reciprocal Rank Fusion for ...