RUBY ON RAILS · 19 MIN READ ·

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.

Rails Error Reporter: Gestructureerde Foutafhandeling Zonder rescue-Spaghetti

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.

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

Related Articles

Laatste sectie. Bel dan alsjeblieft.

Het is een telefoongesprek. Erger dan dat kan het niet worden.

Geen discovery-deck. Geen 45-minuten "kwalificatiegesprek." 30 minuten, jouw probleem, mijn mening. Als we een fit zijn weet je dat in minuut 12.

Directe lijn — Roger neemt zelf op
+31 6 5123 6132
Ma–vr, 09:00–18:00 CET · Nu beschikbaar

OF
info@ttb.software