# frozen_string_literal: true module ActiveSupport # = Active Support \Error Reporter # # +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 handle only a specific error class: # # maybe_tags = Rails.error.handle(Redis::BaseError) { redis.get("tags") } # class ErrorReporter SEVERITIES = %i(error warning info) DEFAULT_SOURCE = "application" attr_accessor :logger def initialize(*subscribers, logger: nil) @subscribers = subscribers.flatten @logger = logger end # Evaluates the given block, reporting and swallowing any unhandled error. # If no error is raised, returns the return value of the block. Otherwise, # returns the result of +fallback.call+, or +nil+ if +fallback+ is not # specified. # # # Will report a TypeError to all subscribers and return nil. # Rails.error.handle do # 1 + '1' # end # # Can be restricted to handle only specific error classes: # # maybe_tags = Rails.error.handle(Redis::BaseError) { redis.get("tags") } # # ==== Options # # * +:severity+ - This value is passed along to subscribers to indicate how # important the error report is. Can be +:error+, +:warning+, or +:info+. # Defaults to +:warning+. # # * +:context+ - Extra information that is passed along to subscribers. For # example: # # Rails.error.handle(context: { section: "admin" }) do # # ... # end # # * +:fallback+ - A callable that provides +handle+'s return value when an # unhandled error is raised. For example: # # user = Rails.error.handle(fallback: -> { User.anonymous }) do # User.find_by(params) # end # # * +:source+ - This value is passed along to subscribers to indicate the # source of the error. Subscribers can use this value to ignore certain # errors. Defaults to "application". def handle(*error_classes, severity: :warning, context: {}, fallback: nil, source: DEFAULT_SOURCE) error_classes = [StandardError] if error_classes.blank? yield rescue *error_classes => error report(error, handled: true, severity: severity, context: context, source: source) fallback.call if fallback end # Evaluates the given block, reporting and re-raising any unhandled error. # If no error is raised, returns the return value of the block. # # # Will report a TypeError to all subscribers and re-raise it. # Rails.error.record do # 1 + '1' # end # # Can be restricted to handle only specific error classes: # # tags = Rails.error.record(Redis::BaseError) { redis.get("tags") } # # ==== Options # # * +:severity+ - This value is passed along to subscribers to indicate how # important the error report is. Can be +:error+, +:warning+, or +:info+. # Defaults to +:error+. # # * +:context+ - Extra information that is passed along to subscribers. For # example: # # Rails.error.record(context: { section: "admin" }) do # # ... # end # # * +:source+ - This value is passed along to subscribers to indicate the # source of the error. Subscribers can use this value to ignore certain # errors. Defaults to "application". def record(*error_classes, severity: :error, context: {}, source: DEFAULT_SOURCE) error_classes = [StandardError] if error_classes.blank? yield rescue *error_classes => error report(error, handled: false, severity: severity, context: context, source: source) raise end # Register a new error subscriber. The subscriber must respond to # # report(Exception, handled: Boolean, severity: (:error OR :warning OR :info), context: Hash, source: String) # # 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 # Unregister an error subscriber. Accepts either a subscriber or a class. # # subscriber = MyErrorSubscriber.new # Rails.error.subscribe(subscriber) # # Rails.error.unsubscribe(subscriber) # # or # Rails.error.unsubscribe(MyErrorSubscriber) def unsubscribe(subscriber) @subscribers.delete_if { |s| subscriber === s } end # Prevent a subscriber from being notified of errors for the # duration of the block. You may pass in the subscriber itself, or its class. # # This can be helpful for error reporting service integrations, when they wish # to handle any errors higher in the stack. def disable(subscriber) disabled_subscribers = (ActiveSupport::IsolatedExecutionState[self] ||= []) disabled_subscribers << subscriber begin yield ensure disabled_subscribers.delete(subscriber) end end # Update the execution context that is accessible to error subscribers. Any # context passed to #handle, #record, or #report will be merged with the # context set here. # # Rails.error.set_context(section: "checkout", user_id: @user.id) # def set_context(...) ActiveSupport::ExecutionContext.set(...) end # Report an error directly to subscribers. You can use this method when the # block-based #handle and #record methods are not suitable. # # Rails.error.report(error) # def report(error, handled: true, severity: handled ? :warning : :error, context: {}, source: DEFAULT_SOURCE) return if error.instance_variable_defined?(:@__rails_error_reported) 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) disabled_subscribers = ActiveSupport::IsolatedExecutionState[self] @subscribers.each do |subscriber| unless disabled_subscribers&.any? { |s| s === subscriber } subscriber.report(error, handled: handled, severity: severity, context: full_context, source: source) end 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 unless error.frozen? error.instance_variable_set(:@__rails_error_reported, true) end nil end end end