LaunchKit
← All posts
· 19 min read · by The LaunchKit team · 0 views

Error handling in Rails, and the reporter you are not using

The error you can read in your log is not the error your error tracking service saw, and the gap between the two is a design decision you probably made by accident. A rescue_from in ApplicationController is a decision. An inline rescue in one action is a decision. So is doing nothing, because doing nothing is what gets the error reported.

Rails 8.1 has three separate mechanisms here and they do not compose the way the names suggest. rescue_from turns an exception into a response. Rails.error turns an exception into a report. config.exceptions_app turns an exception into a page. Everything below was reproduced against rails 8.1.3.1 on Ruby 4.0.5, in a throwaway application driven by rack-test, and the numbers are copied out of that run.

What rescue_from is made of

ActiveSupport::Rescuable is 176 lines and holds one piece of state: a class_attribute named rescue_handlers, which is an array of [class_name, handler] pairs. rescue_from appends to it. The comment on the append says what matters:

# Put the new handler at the end because the list is read in reverse.
self.rescue_handlers += [[key, with]]

The reverse read is at rescuable.rb:129, inside find_rescue_handler:

_, handler = rescue_handlers.reverse_each.detect do |class_or_name, _|
  if klass = constantize_rescue_handler_class(class_or_name)
    klass === exception
  end
end

Last declared wins. Not most specific wins, which is what the inheritance hierarchy of your exception classes would suggest and what every reader assumes on first pass. With CardDeclined < PaymentError, the order of two lines decides which handler runs:

declared specific last:
  specific handler: CardDeclined
declared specific FIRST (subclass shadowed by the later, broader handler):
  generic handler: CardDeclined

Same two handlers, same raised exception, opposite outcome. The rule to carry is that a broad rescue_from declared below a narrow one disables the narrow one, and neither the class nor the boot process says anything about it.

Two small guards are worth knowing because they fire at class definition time rather than at request time. rescue_from PaymentError with no with: and no block raises ArgumentError: Need a handler. Pass the with: keyword argument or provide a block., and rescue_from :not_a_class, with: :x raises ArgumentError: :not_a_class must be an Exception class or a String referencing an Exception class. The String form exists for lazy constant lookup, and constantize_rescue_handler_class tries a lexical const_get before safe_constantize, so rescue_from "Error" declared in a superclass resolves to the subclass's own nested Error.

The handler that runs for an exception nobody raised

rescue_with_handler does not stop at the exception it is given. When no handler matches, it recurses into exception.cause:

if handler = handler_for_rescue(exception, object: object)
  handler.call exception
  exception
elsif exception
  if visited_exceptions.include?(exception.cause)
    nil
  else
    rescue_with_handler(exception.cause, object: object, visited_exceptions: visited_exceptions)
  end
end

So this, with only rescue_from PaymentError declared:

begin
  raise CardDeclined, "declined by the issuer"
rescue
  raise RuntimeError, "could not complete the order"
end

produces pay handler ran for CardDeclined: declined by the issuer. A RuntimeError was raised, a PaymentError handler ran, and the argument the handler received is the CardDeclined buried two frames down, not the RuntimeError at the top. Write a handler that renders exception.message and your user reads the message from an exception your code never let out.

This is usually what you want. Any gem that wraps and re-raises, and ActiveRecord::StatementInvalid is the one everybody meets, would otherwise slip past a handler written for the error underneath. Knowing it happens is the difference between a useful rescue and a confusing one.

What happens when the handler itself blows up

rescue_from's documentation ends with a sentence that is easy to misread: "Exceptions raised inside exception handlers are not propagated up." The accurate reading is narrow. The handler's exception is not fed back into rescue_handlers, so a second handler cannot catch it and there is no recursion. It propagates out of the controller exactly like an unhandled error.

Measured, with a handler whose body raises Boom while handling a NotAllowed:

GET /handler_raises  -> raised into the caller: Boom: the handler itself blew up
                        reported=["Boom source=application.action_dispatch"]

The response is a 500 and the thing your service now has on file is Boom, with no trace of the NotAllowed that started it. A handler that calls redirect_back_or_to or reads exception.record.errors is one nil away from replacing a legible failure with an illegible one. Keep handler bodies to a render or a redirect with no lookups in them.

Where the handler belongs

rescue_from earns its place when one exception class has one answer everywhere. Authorization is the archetype: rescue_from Pundit::NotAuthorizedError, with: :deny in ApplicationController is one line replacing a rescue in forty actions, and every one of those actions wants the same 403.

It is the wrong tool when the answer depends on the action. The LaunchKit boilerplate has zero rescue_from declarations in app/, and rescues in the action body instead:

def stripe
  Billing::Webhooks::Handler.new(verified_event).call
  head :ok
rescue JSON::ParserError, Stripe::SignatureVerificationError
  head :bad_request
end

Three lines of rescue sitting against the four lines they protect. A reader of WebhooksController#stripe can see the whole contract without opening another file, which is the argument for inline rescue and it is a strong one. The argument against is that the same three lines appear in email_confirmations_controller.rb, passwords_controller.rb and sessions/omniauth_controller.rb in slightly different shapes, and nothing keeps them consistent. Pulling cross-cutting rescues into a concern is the middle position, and Rails concerns is about when that helps and when it just moves the code.

The cost of the recommendation, stated plainly: every rescue_from you add is an exception your error tracking stops seeing, for the reason in the next section, and you will not notice until the day a bug hides behind one of them.

The rescued error nobody hears about

Rails reports unhandled errors from a middleware. ActionDispatch::Executor#call, at executor.rb:36, wraps the whole downstream stack:

rescue Exception => error
  request = ActionDispatch::Request.new env
  backtrace_cleaner = request.get_header("action_dispatch.backtrace_cleaner")
  wrapper = ExceptionWrapper.new(backtrace_cleaner, error)
  @executor.error_reporter.report(wrapper.unwrapped_exception, handled: false, source: "application.action_dispatch")
  raise

Middleware sits above the controller. An exception a rescue_from handles never gets that high, so it is never reported. Three routes through one application, with a subscriber counting calls:

GET /boom     -> 500  reported=["Boom handled=false severity=error source=application.action_dispatch"]
GET /rescued  -> 403  reported=[]
GET /nope     -> 404  reported=[]

/rescued raises, renders a clean 403, and the rails error reporting above it never runs. That is correct for an authorization denial and wrong for the rescue_from ActiveRecord::RecordInvalid somebody adds six months later to stop a form crashing. The crash was the signal. The handler removed it.

The fix is one line in the handler body, and it costs nothing, for a reason worth its own section.

The report that only happens once

ErrorReporter#report begins and ends with the same trick:

def report(error, handled: true, severity: handled ? :warning : :error, context: {}, source: DEFAULT_SOURCE)
  return if error.instance_variable_defined?(:@__rails_error_reported)

and, after the subscribers have run:

while error
  unless error.frozen?
    error.instance_variable_set(:@__rails_error_reported, true)
  end
  error = error.cause
end

An instance variable on the exception object, propagated down the entire cause chain. Three report calls on one error produced one subscriber call. Reporting a wrapper and then separately reporting its cause produced one, because the cause was stamped by the first call.

The practical consequence is the useful one. Report where you know the most. Inside the action you have the order id, the plan, the third-party response code; twelve frames up in a middleware you have an exception and a URL. Wrap the code that knows those things in Rails.error.record(context: { order_id: order.id }), or call Rails.error.report from its rescue, and the middleware's later report is discarded rather than duplicated. Verified end to end, with a record block in the action and nothing else changed:

GET /recorded -> 500
reports: 1
  Boom handled=false severity=error source=application

One report, and its source is "application" rather than "application.action_dispatch", meaning the contextful one won and the generic one was dropped.

The deduplication is per exception object, not per message or per class, so two separate raises of the same class in one request are two reports. And an error that has been frozen skips the stamp entirely, which is the one case where you can get a duplicate.

handle, record, and the severity nobody sets on purpose

Rails.error.handle and Rails.error.record are seven lines each and differ in one keyword and one default.

def handle(*error_classes, severity: :warning, context: {}, fallback: nil, source: DEFAULT_SOURCE)
  error_classes = DEFAULT_RESCUE if error_classes.empty?
  yield
rescue *error_classes => error
  report(error, handled: true, severity: severity, context: context, source: source)
  fallback.call if fallback
end

record is the same body with severity: :error, no fallback, handled: false, and raise instead of the fallback call. So handle swallows and files a warning; record re-raises and files an error. If you have been reaching for handle because the name sounds neutral, you have been downgrading the severity of everything you report.

Measured behaviour, in order: handle { raise TypeError } returned nil; handle(fallback: -> { :fallback_value }) { raise } returned :fallback_value; handle { 42 } returned 42 with no report; record { raise ArgumentError, "kept" } reported and then let ArgumentError: kept out.

DEFAULT_RESCUE = [StandardError].freeze is the list used when you name no classes, so handle { raise Exception, "not a StandardError" } does not swallow. A SignalException or a NoMemoryError walks straight through a bare handle block, which is the right call and is not what "handle any unhandled error" sounds like.

Naming classes narrows it the way rescue does, and a mismatch is not an error, it is a pass-through: handle(KeyError) { raise TypeError } let the TypeError out. Severity, by contrast, is validated, and only at report time: report(..., severity: :critical) raises ArgumentError: severity must be one of :error, :warning, :info, got: :critical. So does passing a non-exception: Reported error must be an Exception, got: "just a string".

Swallowing on purpose

The place handle is exactly right is a degraded-mode read against something optional. A recommender, a preview renderer, a third-party avatar fetch:

suggestions = Rails.error.handle(Recommender::Error, severity: :info,
                                 context: { feature: "recommendations" },
                                 fallback: -> { [] }) do
  Recommender.for(current_user)
end

That shape, run in a controller action against a stub that raises, returned [], rendered a 200 and filed one report at severity: :info whose context carried feature: "recommendations" next to the controller Rails had already put there. The same block written as a bare rescue Recommender::Error; [] renders the same 200 and files nothing, which is how a feature comes to be silently broken for three weeks. That is the entire argument for the rails error reporter over rescue plus a log line, and the same argument appears from the other side in Transactions and rollback in Rails, where swallowing is the thing that quietly commits half your work.

What a subscriber is handed

A subscriber is any object with a report method, checked at subscribe time and nowhere else:

unless subscriber.respond_to?(:report)
  raise ArgumentError, "Error subscribers must respond to #report"
end

The signature is report(error, handled:, severity:, context:, source:). During a request, context arrives already populated, because ActionController::Metal::Instrumentation#process_action sets ActiveSupport::ExecutionContext[:controller] = self at instrumentation.rb:60, and report merges ExecutionContext.to_h under whatever you passed. Printed from inside a subscriber during a real request:

subscriber saw Boom handled=false severity=error source=application.action_dispatch
context keys: [:controller]
    controller: ThingsController

and for a handle call with its own context:

context keys: [:controller, :feature]
    controller: ThingsController
    feature: "recommendations"

Active Job sets ExecutionContext[:job] the same way, in execution.rb:66. Beyond those two, the context is yours: Rails.error.set_context(section: "checkout") anywhere in the request, or Rails.error.add_middleware(->(error, context:, **) { context.merge(release: ENV["GIT_SHA"]) }) once at boot, which is how you get a release tag onto every report without touching a call site.

The subscriber that takes your application down with it

A subscriber that raises is the failure mode this API is most exposed to, because a reporting service is a network call and network calls fail. The documentation says "The report method should never raise an error", and the enforcement is conditional:

rescue => subscriber_error
  if logger
    logger.fatal(
      "Error subscriber raised an error: #{subscriber_error.message} (#{subscriber_error.class})\n" +
      subscriber_error.backtrace.join("\n")
    )
  else
    raise
  end

With no logger, a subscriber raising IOError inside a handle block that was swallowing a TypeError gave this:

escaped the reporter: IOError: the reporting service is down

A block whose entire purpose was to swallow raised something worse than what it swallowed. With a logger set, the same code returned nil and wrote one FATAL line. Rails sets Rails.error.logger = Rails.logger during boot, in bootstrap.rb:70, so a real application is on the safe branch. A subscriber registered in a test harness, a rails runner script or a Rake task that built its own ErrorReporter is not, and neither is anything that assigned Rails.error.logger = nil.

The 400s your service sees in development only

The Rails guide is unambiguous: "For HTTP requests, errors present in ActionDispatch::ExceptionWrapper.rescue_responses are not reported as they do not result in server errors (500)." The mechanism is one line in show_exceptions.rb:38:

request.set_header "action_dispatch.report_exception", !wrapper.rescue_response?

which ActionDispatch::Executor reads at executor.rb:22 before deciding to report. Same application, same controller, one config difference:

### production shape (no reloader)
  GET /missing  -> 400  reported=[]
### development shape (reloader present)
  GET /missing  -> 400  reported=["ActionController::ParameterMissing handled=false severity=error source=application.action_dispatch"]

The cause is a one-line class in reloader.rb:14:

class Reloader < Executor
end

ActionDispatch::Reloader is inserted only when reloading is enabled, and it sits at position 10 in the stack, below ShowExceptions at 8. The flag check at executor.rb:22 belongs to the outer Executor at position 2, which never sees the exception because ShowExceptions already turned it into a response. The inner Reloader does see it, and takes the rescue Exception branch at executor.rb:36, which reports unconditionally.

ActionController::RoutingError escapes this because it is raised above the Reloader, at debug_exceptions.rb:35, when the router hands back X-Cascade: pass. So a 404 stays quiet in both environments and a 400 does not.

The practical damage is small and the lesson is not. If you are calibrating alert thresholds against what your development machine reports, you are calibrating against a different middleware stack than the one in production.

What the 500 page can and cannot say

ActionDispatch::PublicExceptions is the default exceptions_app and it is 64 lines. For an HTML request it reads a file off disk:

def render_html(status)
  path = "#{public_path}/#{status}.#{I18n.locale}.html"
  path = "#{public_path}/#{status}.html" unless (found = File.exist?(path))

  if found || File.exist?(path)
    render_format(status, "text/html", File.read(path))
  else
    [404, { Constants::X_CASCADE => "pass" }, []]
  end
end

File.read, nothing else. No ERB, no layout, no helpers, no database. That constraint is the whole design: the page renders after the application that would have rendered a nicer one has already failed, so it cannot depend on the application working. Delete the file and the failure is silent:

### without public/500.html
HTML  -> 500 ct=text/html; charset=UTF-8
body: ""

Status 500, zero bytes, a blank white page in the browser. Non-HTML requests never touch the file at all and get a generated body: {"status":500,"error":"Internal Server Error"}.

What the page should not say is the exception. The default page says "We're sorry, but something went wrong. If you're the application owner check the logs for more information", and the omission is deliberate.

This site keeps that copy and argues with the template about something else: a meta tag. The generated public/500.html carries <meta name="robots" content="noindex, nofollow"> on line 11. The one here carries no robots tag at all, and a comment in its place records why:

<!-- No robots meta tag here on purpose. The HTTP status code is the signal, and it is the
     accurate one: a 404 de-indexes on its own, a 5xx means "come back later". A noindex tag on
     an error page overrides that with "never index this URL", which turns any transient
     failure into a de-indexing instruction ... -->

That happened. /yield and /quiz were both reported as "Excluded by noindex tag" in Search Console while serving perfectly indexable pages, because a crawler caught a transient 5xx and read the noindex on the error page as the answer for the URL. The LaunchKit boilerplate still ships the generated tag, which is right for an application behind a login that nobody is indexing. On anything with public pages in it, the status code is already the honest signal and the tag overrides it.

Giving the error page a request id

Pointing config.exceptions_app at a controller buys back everything PublicExceptions gives up. The env carries what you need:

class ErrorsController < ActionController::Base
  def show
    status = request.path_info[1..].to_i
    render plain: "status=#{status} request_id=#{request.request_id} original=#{request.env["action_dispatch.original_path"]}",
           status: status
  end
end

Output on a failed request, with the response header alongside it:

status=500 request_id=f1484b36-634c-49cb-add1-28c8a1db015c original=/boom
header X-Request-Id: f1484b36-634c-49cb-add1-28c8a1db015c

The id in the body is the same id ActionDispatch::RequestId put in the header and the same one Rails.logger tagged the request with, which turns "something went wrong" into "something went wrong, quote f1484b36 at support". That is the one piece of information a 500 page should add.

The env also carries action_dispatch.exception, and rendering exception.message from it printed internal: password rotation failed straight into the response body. Nothing stops you. The whole reason PublicExceptions can only read a flat file is that a flat file cannot make that mistake, and a controller can make it in one line.

The trade is real: an exceptions app is your application, so it can fail, and then ShowExceptions#render_exception catches it and emits a plain-text failsafe body that begins "500 Internal Server Error. If you are the administrator of this website...". Keep the errors controller free of database calls, current_user, and the layout, or accept that your nice page has a worse page behind it.

Reporting to a service without naming it in your code

The subscriber interface is the decoupling, and it is a real one. Rails.error.report in your code, Rails.error.subscribe in one initializer, and the service's name appears in exactly one file:

# config/initializers/error_subscriber.rb
class LogSubscriber
  def report(error, handled:, severity:, context:, source:)
    Rails.logger.error({ error: error.class.name, message: error.message,
                         handled:, severity:, source:,
                         controller: context[:controller]&.class&.name }.to_json)
  end
end

Rails.error.subscribe(LogSubscriber.new)

Swapping vendors means editing that file. Multiple subscribers are supported, so routing errors to a log aggregator and an error tracker at once is two subscribe calls, and Rails.error.disable(Subscriber) { ... } mutes one for a block.

Now the part that matters before you assume this is already wired up for you. Rails error tracking in production means one of three gems for most people, and the three give three different answers about the reporter, checked here in their source rather than in their marketing:

sentry-rails 7.0.0, released 2026-09-01, MIT, 120.9 million downloads. It ships Sentry::Rails::ErrorSubscriber and does not register it. In configuration.rb, @register_error_subscriber = false, with the comment: "Rails 7.0 introduced a new error reporter feature, which the SDK once opted-in by default. But after receiving multiple issue reports, the integration seemed to cause serious troubles to some users. So the integration is now controlled by this configuration, which is disabled (false) by default." Sentry still captures your unhandled 500s, through its own Sentry::Rails::CaptureExceptions middleware inserted after ShowExceptions. What it does not capture, unless you set that flag, is every Rails.error.handle and Rails.error.record call you wrote.

honeybadger 6.9.2, released 2026-09-15, MIT, 40.4 million downloads. Calls ::Rails.error.subscribe(ErrorSubscriber) in its Rails plugin, gated on exceptions.enabled, whose default is true. Opposite answer, same week.

bugsnag 6.30.0, released 2026-04-13, MIT, 67.4 million downloads. No ErrorSubscriber anywhere in lib/, on the released gem here or on master. It hooks ActiveSupport::Notifications and rescues in its own middleware, so a Rails.error.report call never reaches it.

So "libraries like Sentry and Honeybadger automatically register subscribers", which is what the Rails guide says, is true for one of the two. Check yours by calling Rails.error.report(StandardError.new("subscriber smoke test")) from a console attached to staging and looking at the dashboard. If nothing arrives, the wiring is not there.

The call, and what would change it

Use rescue_from only for exception classes whose answer is identical in every action, and add Rails.error.report(exception) to the handler body unless you can say out loud why the failure is uninteresting. Rescue inline for everything else, because the rescue belongs next to the call that can fail. Reach for Rails.error.handle when a feature is genuinely optional and you want a fallback plus a warning rather than silence, and for record when you want the report attached to the context and the exception to keep going.

Keep public/500.html as your 500 page until you have an actual use for the request id. An exceptions_app is more moving parts in the one code path that runs when the application is already broken, and the failsafe behind it is a plain-text paragraph. When a support inbox starts filling with "it just said something went wrong", the id is worth the risk, and not before.

What would change the recommendation: if sentry-rails flipped register_error_subscriber to true, the reporter would become the one place to report from regardless of vendor, and the case for calling it everywhere would stop needing this much explanation. If Rails reported errors handled by rescue_from (an env flag the handler could clear, rather than silence by default) the main cost of the first paragraph above would disappear.

The position has a cost and it is discipline. "Report from the handler" is a convention, not a mechanism, and nothing in the framework fails when somebody forgets. A rescue_from added in a hurry to stop a page 500ing is indistinguishable, at review time, from one that was thought about, and the difference only shows up as an alert that never fires.

What this post does not cover

The LaunchKit boilerplate has no error tracking gem in its Gemfile, no rescue_from in app/, and no call to Rails.error anywhere. Neither does this sales site. Every measurement above comes from the framework source and from throwaway applications built for this post, which is the honest provenance for a page about a feature neither codebase uses yet.

Also absent: Active Job's error path, where retry_on and discard_on both take report: and both default it to false (active_job/exceptions.rb:64 and :109), so a job that retries five times and gives up reports nothing unless you ask; Rails.error.unexpected, which raises in development by wrapping your error in ActiveSupport::ErrorReporter::UnexpectedError, a direct subclass of Exception that a bare rescue will not catch, and reports quietly in production; rescue_from in Action Cable and Action Mailbox, which include the same Rescuable module (connection/base.rb:62, channel/base.rb:115, action_mailbox/base.rb:67) against very different lifecycles; and error grouping, fingerprinting and rate limiting, which are properties of the service you send to rather than of Rails.

#rails #active-support

Comments

No comments yet. Be the first.

Only used to confirm and publish your comment. Never shown publicly, never shared.

Markdown: **bold**, `code`, ```fenced blocks```, > quotes, [links](url). HTML and images are not rendered.