lib/active_support/error_reporter.rb



# frozen_string_literal: true

module ActiveSupport
  # +ActiveSupport::ErrorReporter+ is a common interface for error reporting services.
  #
  # To rescue and report any unhandled error, you can use the +handle+ method:
  #
  #   Rails.error.handle do
  #     do_something!
  #   end
  #
  # If an error is raised, it will be reported and swallowed.
  #
  # Alternatively if you want to report the error but not swallow it, you can use +record+
  #
  #   Rails.error.record do
  #     do_something!
  #   end
  #
  # Both methods can be restricted to only handle a specific exception class
  #
  #   maybe_tags = Rails.error.handle(Redis::BaseError) { redis.get("tags") }
  #
  # You can also pass some extra context information that may be used by the error subscribers:
  #
  #   Rails.error.handle(context: { section: "admin" }) do
  #     # ...
  #   end
  #
  # Additionally a +severity+ can be passed along to communicate how important the error report is.
  # +severity+ can be one of +:error+, +:warning+, or +:info+. Handled errors default to the +:warning+
  # severity, and unhandled ones to +:error+.
  #
  # Both +handle+ and +record+ pass through the return value from the block. In the case of +handle+
  # rescuing an error, a fallback can be provided. The fallback must be a callable whose result will
  # be returned when the block raises and is handled:
  #
  #   user = Rails.error.handle(fallback: -> { User.anonymous }) do
  #     User.find_by(params)
  #   end
  class ErrorReporter
    SEVERITIES = %i(error warning info)

    attr_accessor :logger

    def initialize(*subscribers, logger: nil)
      @subscribers = subscribers.flatten
      @logger = logger
    end

    # Report any unhandled exception, and swallow it.
    #
    #   Rails.error.handle do
    #     1 + '1'
    #   end
    #
    def handle(error_class = StandardError, severity: :warning, context: {}, fallback: nil)
      yield
    rescue error_class => error
      report(error, handled: true, severity: severity, context: context)
      fallback.call if fallback
    end

    def record(error_class = StandardError, severity: :error, context: {})
      yield
    rescue error_class => error
      report(error, handled: false, severity: severity, context: context)
      raise
    end

    # Register a new error subscriber. The subscriber must respond to
    #
    #   report(Exception, handled: Boolean, context: Hash)
    #
    # The +report+ method +should+ never raise an error.
    def subscribe(subscriber)
      unless subscriber.respond_to?(:report)
        raise ArgumentError, "Error subscribers must respond to #report"
      end
      @subscribers << subscriber
    end

    # Update the execution context that is accessible to error subscribers
    #
    #   Rails.error.set_context(section: "checkout", user_id: @user.id)
    #
    # See +ActiveSupport::ExecutionContext.set+
    def set_context(...)
      ActiveSupport::ExecutionContext.set(...)
    end

    # When the block based +handle+ and +record+ methods are not suitable, you can directly use +report+
    #
    #   Rails.error.report(error, handled: true)
    def report(error, handled:, severity: handled ? :warning : :error, context: {})
      unless SEVERITIES.include?(severity)
        raise ArgumentError, "severity must be one of #{SEVERITIES.map(&:inspect).join(", ")}, got: #{severity.inspect}"
      end

      full_context = ActiveSupport::ExecutionContext.to_h.merge(context)
      @subscribers.each do |subscriber|
        subscriber.report(error, handled: handled, severity: severity, context: full_context)
      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
      end

      nil
    end
  end
end