AppSignal

Rails Error Reporting: Rails.error and ActiveSupport::ErrorReporter

Rails Error Reporting: Rails.error and ActiveSupport::ErrorReporter

Rails.error is Rails’ built-in error reporter, an ActiveSupport::ErrorReporter instance. Rails.error.handle swallows an exception and reports it; Rails.error.record reports and re-raises; Rails.error.report submits an error directly. Every report goes to each registered subscriber — an object with a report method — which is how error trackers like AppSignal receive Rails 8.1 exceptions.

This guide is the Rails.error reference: what each reporting method does, the exact severity and handled values subscribers receive, how context flows into reports, and how to write — or install — a subscriber. Every behavior described here is verified on Rails 8.1 (activesupport 8.1.3.1) and Ruby 3.4.

What Is the Rails Error Reporter?

Rails 7.0 introduced a framework-level answer to a question every production app faces: when code rescues an exception, where does the report go? The answer used to be a vendor API call at every rescue site. The error reporter decouples the two sides — application code reports errors through one interface, and whatever service should receive them subscribes once.

Rails.error returns the shared reporter instance:

Ruby
Rails.error.class
# => ActiveSupport::ErrorReporter

The reporting surface is small. Four methods submit errors: handle, record, report, and unexpected (the last arrived in Rails 7.2). The rest manage the pipeline: subscribe and unsubscribe register destinations, set_context and add_middleware enrich reports, disable silences a subscriber for the duration of a block, and debug_mode switches unexpected between reporting and raising.

One thing the reporter deliberately is not: a destination. Rails ships no built-in subscriber, so a report with nobody subscribed goes nowhere. That design is what keeps the API universal — your code stays vendor-neutral, and the error tracker plugs in underneath.

Swallowing Errors with Rails.error.handle

Swallowing an error — rescuing it, reporting it, and moving on without an error page — used to mean a begin block and a manual call into your error tracker’s API:

Ruby
begin
  raise "Oh CRUD!"
rescue => error
  Appsignal.report_error(error) do |transaction|
    transaction.set_action("StroopwaffleController#request_waffles")
    transaction.set_namespace("web")
  end
end

Rails.error.handle collapses that to three lines and keeps tracker APIs out of your application code:

Ruby
Rails.error.handle do
  raise "CRUD!"
end
# => nil

If the block raises, the reporter passes the exception to every subscriber with handled: true and severity: :warning, then swallows it: execution continues, and handle returns nil. If nothing raises, handle returns the block’s value. When nil is a poor stand-in at the call site, pass fallback: — a callable whose result is returned when the block fails:

Ruby
recommendations = Rails.error.handle(fallback: -> { [] }) do
  raise "recommendation service down"
end
recommendations
# => []

By default, handle catches StandardError and its descendants — the same boundary as a bare rescue. Our guide to rescuing exceptions in Ruby covers where that line sits and when to rescue yourself instead of handing off. To narrow the net, pass the class you expect, and anything else propagates unreported:

Ruby
Rails.error.handle(ArgumentError) do
  raise TypeError, "not matched"
end
# TypeError is re-raised — nothing is reported

Reporting with Rails.error.record and Rails.error.report

Rails.error.record is handle’s louder sibling: it reports the exception with handled: false and severity: :error, then re-raises it. Use it when the operation should still fail — the exception keeps propagating to whatever rescues, retries, or crashes with it. (For the mechanics on the raising side, see ensure, retry, and reraise exceptions in Ruby.)

Ruby
Rails.error.record do
  raise "boom"
end
# reported with handled: false, severity: :error — then the RuntimeError re-raises

When there is no block to wrap because you already hold a rescued exception, Rails.error.report submits it directly, with the report’s metadata under your control:

Ruby
begin
  1 / 0
rescue ZeroDivisionError => error
  Rails.error.report(error, severity: :info, context: { job: "DailyDigest" }, source: "my_gem")
end

severity accepts :error, :warning, or :info (anything else raises ArgumentError) and defaults to :warning with handled: true. source defaults to "application"; libraries report under their own source string so subscribers can filter them out. report requires a real exception object — handed anything else, it raises ArgumentError (for a plain string, unexpected is the method that accepts one).

The four reporting methods in one place:

MethodTakesReports withAfterwardsReturns
handlea blockhandled: true, severity: :warningswallows the errorblock value; on error, nil or fallback: value
recorda blockhandled: false, severity: :errorre-raises the errorblock value
reportan exceptionyour keywords (default handled: true, severity: :warning)execution continuesnil
unexpectedan exception or stringhandled: true, severity: :warningcontinues in production; raises in debug modenil

Flagging the Impossible with Rails.error.unexpected

Rails.error.unexpected reports a state that should never occur — the broken invariant rather than the failed operation. In a method on an order model, that looks like this:

Ruby
def discounted_total(order)
  if order.discount_code.nil?
    Rails.error.unexpected("Order #{order.id} is discounted but has no code")
    return order.total
  end
 
  order.total - order.discount
end

What happens next depends on debug_mode. In production, unexpected reports quietly — handled: true, severity: :warning — and returns nil, so execution continues down your recovery path. A string argument is wrapped in a RuntimeError before reporting. In development and test, the same call raises, so a broken assumption fails loudly while you are there to see it. Rails flips the switch through config.consider_all_requests_local: when that setting is true — the development and test default — the boot process turns debug_mode on.

ActiveSupport::ErrorReporter::UnexpectedError

The error raised in debug mode is not the one you passed in. Save this as unexpected_error.rb:

Ruby
require "rails"
 
Rails.error.debug_mode = true
Rails.error.unexpected("boom")

Running it prints:

Shell
unexpected_error.rb:4:in '<main>': RuntimeError: boom (ActiveSupport::ErrorReporter::UnexpectedError)
unexpected_error.rb:4:in '<main>': boom (RuntimeError)

unexpected wraps your error in an ActiveSupport::ErrorReporter::UnexpectedError whose message carries the original class and message, with the original attached as its cause (the second line of the output). The wrapper subclasses Exception directly, not StandardError — deliberately. A bare rescue => e will not catch it, and neither will Rails.error.handle, so no generic rescue between the assertion and your eyes can silence it. Only in production-style operation, with debug_mode off, does unexpected soften into a quiet report.

Adding Context to Error Reports

Every reporting method takes a context: hash that travels to subscribers with the report:

Ruby
Rails.error.handle(context: { tag1: "value1", tag2: "value2" }) do
  raise "Tagged CRUD!"
end

For context that applies to everything reported during the current request or job, set_context merges keys into the execution context once (Rails resets that context between requests and jobs):

Ruby
Rails.error.set_context(section: "checkout", user_id: 42)

Subscribers decide what context means. AppSignal’s subscriber reads one reserved key, appsignal:, for its own settings — a custom namespace and action for the incident:

Ruby
Rails.error.handle(context: { appsignal: { namespace: "custom_namespace", action: "CustomizedActionName#index" } }) do
  raise "Contextual CRUD!"
end

(The Appsignal.set_namespace and Appsignal.set_action helpers do the same for the whole surrounding transaction rather than a single report.) Every other context key becomes a tag on the error, and tags are filterable in your list of incidents:

Raised error with custom tags

Cross-cutting context — an app version, a deployment region — belongs in a context middleware, which runs on every report before subscribers see it and returns the (possibly modified) context hash:

Ruby
Rails.error.add_middleware(
  ->(error, context:, handled:, severity:, source:) { context.merge(app_version: "2.4.1") }
)

Writing an Error Subscriber

A subscriber is any object with a report method that accepts the error plus four keyword arguments. This one writes reports to the Rails log:

Ruby
class ErrorLogSubscriber
  def report(error, handled:, severity:, context:, source: nil)
    Rails.logger.warn(
      "[#{severity}] #{error.class}: #{error.message} " \
      "(handled: #{handled}, source: #{source}, context: #{context.inspect})"
    )
  end
end
 
Rails.error.subscribe(ErrorLogSubscriber.new)

Subscribe in an initializer, and every handle, record, report, and unexpected call in the app reaches the subscriber — parts of Rails, such as Active Job and the rails runner command, report through the same interface. Rails.error.unsubscribe(ErrorLogSubscriber) removes it again, and Rails.error.disable(ErrorLogSubscriber) { ... } skips it for the duration of a block (in a test that raises on purpose, say). A subscriber that itself raises does not take the report down with it: in production, where boot points Rails.error.logger at Rails.logger, the failure is logged as fatal instead.

The pipeline isn’t limited to controller code, either — background jobs, service objects, and API frameworks can all report through it. (For Grape APIs, which bring their own rescue semantics, see handling exceptions in Grape for Ruby.)

Monitoring Rails Exceptions with AppSignal

You rarely need to write that subscriber yourself. AppSignal for Ruby registers one automatically in Rails apps: every Rails.error.handle, record, and report call lands in AppSignal’s error tracking with the action and namespace detected from the surrounding request or job, and your context keys become filterable tags. One gem turns Rails’ reporting API into full error monitoring.

The integration shipped in AppSignal for Ruby 3.4.1 and is on by default in instrumented Rails apps (the enable_rails_error_reporter config option turns it off). Each report’s severity and source arrive as tags alongside your own, so incidents stay filterable by how — and from where — they were reported.

Wrapping Up

Rails.error gives every rescue site the same three verbs — swallow with handle, escalate with record, submit with report — plus unexpected for the states that should never happen. Subscribers turn those reports into somewhere useful to look, whether that is a log line you wrote yourself or an AppSignal dashboard. Wire one up, and the begin-block era of error reporting is behind you.

Happy coding!

P.S. If you’d like to read Ruby Magic posts as soon as they get off the press, subscribe to our Ruby Magic newsletter and never miss a single post!

Frequently asked questions

What is the difference between Rails.error.handle and Rails.error.record?
handle swallows the exception after reporting it, returning nil or a fallback value, with warning severity. record reports the exception with error severity and then re-raises it, so your code still fails.
How do I subscribe to the Rails error reporter?
Call Rails.error.subscribe with any object that responds to report, receiving the error plus handled, severity, context, and source keyword arguments. Every handle, record, report, and unexpected call then reaches your subscriber.
What does Rails.error.unexpected do?
It reports a should-never-happen condition. In production it reports the error with warning severity and execution continues; in development and test it raises an UnexpectedError so you notice the broken assumption immediately.
Does Rails report errors anywhere by default?
No. The error reporter is only an API: without a subscriber, reports go nowhere. Error tracking services subscribe to it to deliver reports to a dashboard.

Published , Updated

Wondering what you can do next?

Connor James

Connor James

Developer Marketing Manager at AppSignal. Podcast addict who loves cannoli so much that he's considering changing his name to Connoli. He thinks there's a `u` in color. You might find him on the mic, on the stage, or lying on the sofa when he's off duty.

All articles by Connor James
Tom de Bruijn

Tom de Bruijn

Tom is a developer at AppSignal, organizer, and writer from Amsterdam, The Netherlands.

All articles by Tom de Bruijn

Become our next author!

Find out more
$appsignal install

AppSignal monitors your apps

AppSignal provides insights for Ruby, Rails, Elixir, Phoenix, Node.js, Express and many other frameworks and libraries. We are located in beautiful Amsterdam. We love stroopwafels. If you do too, let us know. We might send you some!

Discover AppSignal