RUBY ON RAILS · 20 MIN READ ·

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.

Rails Error Reporter: Structured Error Handling Without rescue Spaghetti

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.

#rails-error-reporter #rails-error-handling #rescue-from-rails #rails-7-1-error-reporter #rails-error-tracking-production

Related Articles

Last section. Then please call.

It's a phone call. That's the worst it can get.

No discovery deck. No 45-minute "qualification" call. 30 minutes, your problem, my opinion. If we're a fit, you'll know by minute 12.

Direct line — answered by Roger
+31 6 5123 6132
Mon–Fri, 09:00–18:00 CET · Currently available

OR
info@ttb.software