Rails Error Reporter: Gestructureerde Foutafhandeling Zonder rescue-Spaghetti
Rails Error Reporter centraliseert foutafhandeling in productie met handle, record en report. Stop met rescue-blokken overal verspreiden.
De codebase had zes engineers in vier jaar gekend. Ik telde drieënveertig plekken waar iemand rescue => e; logger.error(e.message) had geschreven. In één controller-action stond hetzelfde rescue-blok driemaal in een methode van vijf regels — elke engineer had kennelijk een andere exception-klasse gerepareerd zonder de rest op te merken. In de achtergrondtaken verdwenen fouten helemaal: rescue StandardError bovenaan elke perform-methode, allemaal eindigend met nil.
Sentry was aanwezig, maar ontving ongeveer 20% van de fouten die daadwerkelijk in productie optraden. De andere 80% werd ingeslikt, gelogd naar een bestand dat niemand las, of stilletjes opgevangen en doorgestuurd naar een tweede error tracker die achttien maanden eerder was uitgeschakeld.
Dit is het eindresultaat van ad-hoc foutafhandeling. Elke engineer voegt zijn eigen patroon toe, elk rescue-blok groeit onafhankelijk, en de foutcontext — gebruikers-ID, request-ID, de bedrijfsoperatie die liep — wordt inconsistent of helemaal niet meegestuurd.
Rails 7.0 leverde een oplossing: de Rails Error Reporter, Rails.error. Het is een eenvoudige, gestandaardiseerde laag tussen jouw code en welke error-tracking je ook gebruikt. Na negentien jaar Rails is dit een van die functies waarvan ik wou dat ze er van het begin af aan waren geweest.
Wat de Rails Error Reporter Is
Rails.error is een instantie van ActiveSupport::ErrorReporter, wereldwijd beschikbaar vanaf Rails 7.0. Het biedt drie kernmethoden — handle, record en report — plus contextbeheer. Alles wat je erdoorheen stuurt, belandt tegelijk bij alle geregistreerde subscribers.
De subscribers zijn wat het verbindt met de buitenwereld: Sentry, Honeybadger, Bugsnag, of je eigen backend. Sentry-ruby ondersteunt de Rails Error Reporter sinds sentry-rails 5.9.0. Als je gem "sentry-rails" gebruikt, subscribt het automatisch bij Rails-initialisatie. Hetzelfde geldt voor Honeybadger 5.x en AppSignal 3.x.
Het punt is dat jouw applicatiecode niets weet van de error tracker. Het spreekt tot Rails.error. De tracker subscribt. Je wisselt van tracker door één regel in een initializer te wijzigen, niet door vijftig bestanden te doorzoeken op Sentry.capture_exception.
De Drie Methoden
handle: vangen, rapporteren en verder gaan
handle wikkelt een blok in, vangt exceptions op, rapporteert ze en gaat door. De aanroeper ziet de exception nooit.
# Zonder Rails Error Reporter
def import_row(row)
process(row)
rescue => e
Sentry.capture_exception(e, extra: { row: row })
nil
end
# Met Rails Error Reporter
def import_row(row)
Rails.error.handle(context: { row: row }) do
process(row)
end
end
handle geeft nil terug als er een exception optreedt. Je kunt een fallback opgeven:
def fetch_pricing(sku)
Rails.error.handle(fallback: -> { DEFAULT_PRICE }) do
PricingService.lookup(sku)
end
end
Je kunt de error-klasse afbakenen:
Rails.error.handle(NetworkError, TimeoutError, severity: :warning) do
ExternalApi.sync
end
Alleen NetworkError en TimeoutError worden opgevangen en gerapporteerd met :warning-ernst. Al het andere propageert normaal.
De severity-optie accepteert :debug, :info, :warning, :error en :fatal. De meeste subscribers filteren of routeren op ernst. :warning instellen voor vluchtige netwerkfouten houdt je Sentry-inbox schoon zonder de data weg te gooien.
record: rapporteren en opnieuw gooien
record vangt op, rapporteert en gooit opnieuw. Gebruik het als je foutrapportage nodig hebt maar de exception niet kunt absorberen — de aanroeper moet er nog mee omgaan.
def charge_card(amount:, token:)
Rails.error.record(context: { amount: amount }) do
PaymentGateway.charge(token: token, amount: amount)
end
end
Als PaymentGateway.charge een exception gooit, gaat de fout naar Sentry (of elders), waarna de exception verder propageert naar de controller die de juiste foutrespons kan renderen. Je krijgt de tracking zonder de exception-flow te verliezen.
# Dit is equivalent aan:
begin
PaymentGateway.charge(token: token, amount: amount)
rescue => e
Rails.error.report(e, handled: false, context: { amount: amount })
raise
end
report: voor al-gevangen exceptions
Soms heb je al een exception opgevangen en zit je diep genoeg in de call-stack dat je het kunt afhandelen, maar wil je het toch bijhouden. report neemt direct een exception-object:
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 })
# Ga door met de volgende gebruiker
end
end
handled: true vertelt subscribers dat deze fout verwacht was en afgehandeld. handled: false betekent dat je iets rapporteert wat je niet kon afhandelen — onverwacht, en een reden voor een melding. De meeste error trackers gebruiken dit onderscheid om ruis van echte problemen te scheiden.
Context: Fouten Debuggeerbaar Maken
Het meest voorkomende falen bij error-tracking is het ontvangen van een exception zonder enig idee wie de gebruiker was, wat ze deden, of welke downstream-service betrokken was. Dit handmatig oplossen betekent op elke rescue-plek context toevoegen.
De Error Reporter lost dit op twee niveaus op.
Thread-lokale context met set_context
Rails.error.set_context stelt context in voor alle fouten die in de huidige thread worden gerapporteerd. Roep het eenmalig aan in een before_action en elke fout voor dat request heeft de 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
Neem het op in ApplicationController en elke fout — afgehandeld of niet, in de controller-action of diep in een service-object — draagt de gebruikers-ID zonder extra werk op de rescue-plek.
Per-blok context
De context:-optie op handle, record en report voegt extra context toe voor die specifieke operatie:
Rails.error.handle(context: { payment_id: payment.id, gateway: "stripe" }) do
stripe_charge(payment)
end
Per-blok context vervuilt de thread-lokale context niet — het geldt alleen voor fouten uit dat blok. Dit is belangrijk als je een verzameling items verwerkt: iteratiespecifieke context loopt niet over naar andere iteraties.
source
Rails 7.1 voegde de source:-parameter toe om te identificeren waar een fout vandaan komt:
Rails.error.handle(source: "payment_processor", context: { ... }) do
...
end
Aangepaste subscribers kunnen routeren op bron — betalingsprocesfouten naar PagerDuty, en derde-partij API-fouten naar een minder urgent Slack-kanaal.
Een Aangepaste Subscriber Schrijven
Je hebt Sentry niet nodig. Een subscriber is elk object dat reageert op 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}\nAfgehandeld: #{handled}\nBron: #{source}\nContext: #{context.inspect}"
}
}
]
}
Net::HTTP.post(
URI(WEBHOOK_URL),
payload.to_json,
"Content-Type" => "application/json"
)
rescue => e
Rails.logger.error("SlackErrorSubscriber mislukt: #{e.message}")
end
end
Rails.application.config.after_initialize do
Rails.error.subscribe(SlackErrorSubscriber.new)
end
Meerdere subscribers kunnen naast elkaar bestaan. Rails.error.subscribe in een initializer registreert globaal. Als je een subscriber voor slechts één codeblok wilt, voegde Rails 7.1 de blok-vorm van subscribe toe:
captured = []
subscriber = ->(error, **opts) { captured << { error: error, **opts } }
Rails.error.subscribe(subscriber) do
run_batch_import
end
# subscriber wordt automatisch verwijderd na het blok
Dit is precies wat de test-helper gebruikt.
Achtergrondtaken
Voor de Error Reporter had elke job zijn eigen foutafhandelingspatroon. Sommige jobs vingen alles op, logden het en gaven nil terug. Sommige gooiden opnieuw. Sommige rapporteerden aan Sentry in het rescue-blok, maar niet aan de andere tracker die een ander team gebruikte.
ActiveJob in Rails 7.1 rapporteert job-fouten automatisch via Rails.error voor opnieuw proberen of verwerpen. Dit betekent dat elke subscriber die je registreert job-mislukkingen oppikt zonder extra code. Maar je kunt de Error Reporter ook expliciet gebruiken in 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
De set_context-aanroep bovenaan perform zorgt ervoor dat elke fout van deze job, inclusief niet-afgehandelde die ActiveJob rapporteert, account_id en job-naam meeneemt. De handle voor de ophaalfase slikt vluchtige netwerkfouten in met :warning-ernst. De record voor de synchronisatiefase gooit opnieuw — als de synchronisatie mislukt, wil je dat de job het opnieuw probeert — maar Sentry ziet het onmiddellijk.
Voor Solid Queue en Sidekiq specifiek: beide integreren met de foutrapportagepipeline van ActiveJob. Jobs die perform_later via ActiveJob gebruiken, vloeien automatisch in Rails.error. Sidekiq’s native Sidekiq::ServerMiddleware doet dit niet, maar je kunt het toevoegen:
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
Testen
De scoped subscriber-vorm maakt het testen van foutrapportagegedrag overzichtelijk:
class PaymentServiceTest < ActiveSupport::TestCase
test "rapporteert ongeldige bedragfouten zonder te gooien" do
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 "bevat gebruikerscontext in gerapporteerde fouten" 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 werkt hetzelfde patroon in een before-blok:
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 "rapporteert netwerkfouten als waarschuwingen" 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
Voor en Na
Hier is een realistisch voor/na uit de audit die ik aan het begin noemde. Één service-object dat met een externe API communiceerde:
Voor:
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 bij synchroniseren account #{account.id}: #{e.message}")
Sentry.capture_exception(e, extra: { account_id: account.id })
false
rescue Faraday::Error => e
logger.warn("Netwerkfout bij synchroniseren account #{account.id}: #{e.message}")
# vergeten te rapporteren
false
rescue ActiveRecord::RecordInvalid => e
logger.error("DB-fout bij synchroniseren account #{account.id}: #{e.message}")
Bugsnag.notify(e, account_id: account.id) # andere tracker!
raise
end
end
Drie verschillende foutrapportmechanismen, één gemiste exception-klasse, inconsistente context, en twee trackers die het huidige team niet wist dat ze beiden actief waren.
Na:
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
Één tracker, consistente context, passende ernst, en als je later Honeybadger toevoegt, rapporteert deze service er automatisch aan zonder deze code aan te raken.
Veelgestelde Vragen
Wanneer gebruik ik handle versus record in Rails?
Gebruik handle als je code verder kan gaan zonder de operatie die mislukte — een optionele verrijking, een niet-kritieke externe aanroep, één rij in een batch-import terwijl anderen nog kunnen slagen. Gebruik record als de exception door de aanroeper gezien moet worden, maar je het ook bijgehouden wilt hebben voordat het propageert — een kritieke betaling, een datamigratestap waarbij je de fout een herpoging wilt laten triggeren. Bij twijfel: handle slikt de exception in (aanroepers weten het niet), record gooit opnieuw (aanroepers weten het wel). Kies op basis van of de aanroeper moet reageren.
Vervangt de Rails Error Reporter rescue_from in controllers?
Ze zijn complementair. rescue_from is nog steeds de juiste plek om specifieke exception-klassen te vertalen naar HTTP-responses — rescue_from ActiveRecord::RecordNotFound, with: :not_found. Wat de Error Reporter vervangt is de ad-hoc error-tracking binnen rescue_from-handlers en binnen service-objecten. Een nette opzet: rescue_from afhandelt de response, en ofwel Rails.error.report in de handler ofwel Rails.error.record in het service-object zorgt voor tracking. Je hebt niet beide nodig: niet-afgehandelde exceptions die opborrelen vanuit een controller worden ook gerapporteerd door de error reporter als je framework-integratie (Sentry-rails, etc.) is ingesteld.
Hoe gaat de Rails Error Reporter om met fouten in gelijktijdige code?
set_context is thread-lokaal. Als je Concurrent::Future, Ractors of de async-gem gebruikt voor parallel werk, heeft elke thread of fiber zijn eigen context. Context die je in de parent-thread instelt, wordt niet automatisch doorgegeven aan child-threads. Geef voor parallel werk context expliciet mee via de context:-parameter op elke handle/record/report-aanroep in plaats van op set_context te vertrouwen. In Puma’s multi-threaded omgeving (één thread per request) is set_context in een before_action veilig — elk request krijgt zijn eigen thread, dus context loopt niet over tussen requests.
Welke Rails-versie introduceerde de Error Reporter?
De kern-API (handle, record, report, set_context, subscribe) werd geïntroduceerd in Rails 7.0 (uitgebracht in december 2021). Rails 7.1 (oktober 2023) voegde de source:-parameter toe, de blok-vorm van subscribe voor scoped subscriptions, en tightere ActiveJob-integratie die job-fouten automatisch aan alle subscribers rapporteert. Als je nog op Rails 6 zit, upgrade dan incrementeel — de Error Reporter alleen is de overstap al waard.
Jouw Rails-app slokt exceptions op in het donker? TTB Software auditeert en moderniseert productie-foutafhandeling in Rails. Na negentien jaar Rails hebben we elk rescue-patroon gezien dat fout kan gaan — en de nette patronen die dat niet doen.
Related Articles
Rails StackProf: Flamegraph CPU-profilering voor Ruby-productieapplicaties
Rails StackProf flamegraph-profilering laat precies zien waar je Ruby CPU verbrandt — sampling-middleware, drie profi...
Rails Kredis: Redis-Backed Model Attributen Zonder Redis-Code te Schrijven
Rails Kredis voegt Redis-backed counters, flags, lists en hashes direct toe aan je models — geen handmatig key-beheer...
Rails Hybrid Search: pgvector Semantisch Zoeken Combineren met Postgres Full-Text voor Betere Recall
Rails hybrid search combineert pgvector semantische vectoren met Postgres full-text zoeken en Reciprocal Rank Fusion ...