diff --git a/activesupport/CHANGELOG.md b/activesupport/CHANGELOG.md
index dc8e9f9624..3295afd192 100644
--- a/activesupport/CHANGELOG.md
+++ b/activesupport/CHANGELOG.md
@@ -1,3 +1,21 @@
+* Add `ErrorReported#unexpected` to report precondition violations.
+
+ For example:
+
+ ```ruby
+ def edit
+ if published?
+ Rails.error.unexpected("[BUG] Attempting to edit a published article, that shouldn't be possible")
+ return false
+ end
+ # ...
+ end
+ ```
+
+ The above will raise an error in development and test, but only report the error in production.
+
+ *Jean Boussier*
+
* Make the order of read_multi and write_multi notifications for `Cache::Store#fetch_multi` operations match the order they are executed in.
*Adam Renberg Tamm*
diff --git a/activesupport/lib/active_support/error_reporter.rb b/activesupport/lib/active_support/error_reporter.rb
index 71a94bffc2..251f553481 100644
--- a/activesupport/lib/active_support/error_reporter.rb
+++ b/activesupport/lib/active_support/error_reporter.rb
@@ -26,12 +26,16 @@ module ActiveSupport
class ErrorReporter
SEVERITIES = %i(error warning info)
DEFAULT_SOURCE = "application"
+ DEFAULT_RESCUE = [StandardError].freeze
- attr_accessor :logger
+ attr_accessor :logger, :debug_mode
+
+ UnexpectedError = Class.new(Exception)
def initialize(*subscribers, logger: nil)
@subscribers = subscribers.flatten
@logger = logger
+ @debug_mode = false
end
# Evaluates the given block, reporting and swallowing any unhandled error.
@@ -72,7 +76,7 @@ def initialize(*subscribers, logger: nil)
# 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?
+ error_classes = DEFAULT_RESCUE if error_classes.empty?
yield
rescue *error_classes => error
report(error, handled: true, severity: severity, context: context, source: source)
@@ -108,13 +112,47 @@ def handle(*error_classes, severity: :warning, context: {}, fallback: nil, sourc
# 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?
+ error_classes = DEFAULT_RESCUE if error_classes.empty?
yield
rescue *error_classes => error
report(error, handled: false, severity: severity, context: context, source: source)
raise
end
+ # Either report the given error when in production, or raise it when in development or test.
+ #
+ # When called in production, after the error is reported, this method will return
+ # nil and execution will continue.
+ #
+ # When called in development, the original error is wrapped in a different error class to ensure
+ # it's not being rescued higher in the stack and will be surfaced to the developer.
+ #
+ # This method is intended for reporting violated assertions about preconditions, or similar
+ # cases that can and should be gracefully handled in production, but that aren't supposed to happen.
+ #
+ # The error can be either an exception instance or a String.
+ #
+ # example:
+ #
+ # def edit
+ # if published?
+ # Rails.error.unexpected("[BUG] Attempting to edit a published article, that shouldn't be possible")
+ # return false
+ # end
+ # # ...
+ # end
+ #
+ def unexpected(error, severity: :warning, context: {}, source: DEFAULT_SOURCE)
+ error = RuntimeError.new(error) if error.is_a?(String)
+ error.set_backtrace(caller(1)) if error.backtrace.nil?
+
+ if @debug_mode
+ raise UnexpectedError, "#{error.class.name}: #{error.message}", error.backtrace, cause: error
+ else
+ report(error, handled: true, severity: severity, context: context, source: source)
+ end
+ 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)
diff --git a/activesupport/test/error_reporter_test.rb b/activesupport/test/error_reporter_test.rb
index 07e9faa9d1..b0411a0ccf 100644
--- a/activesupport/test/error_reporter_test.rb
+++ b/activesupport/test/error_reporter_test.rb
@@ -168,6 +168,38 @@ class ErrorReporterTest < ActiveSupport::TestCase
assert_equal 4, result
end
+ test "#unexpected swallows errors by default" do
+ error = RuntimeError.new("Oops")
+ assert_nil @reporter.unexpected(error)
+ assert_equal [[error, true, :warning, "application", {}]], @subscriber.events
+ assert_not_predicate error.backtrace, :empty?
+ end
+
+ test "#unexpected accepts an error message" do
+ assert_nil @reporter.unexpected("Oops")
+ assert_equal 1, @subscriber.events.size
+
+ error, *event_details = @subscriber.events.first
+ assert_equal [true, :warning, "application", {}], event_details
+
+ assert_equal "Oops", error.message
+ assert_equal RuntimeError, error.class
+ assert_not_predicate error.backtrace, :empty?
+ end
+
+ test "#unexpected re-raise errors in development and test" do
+ @reporter.debug_mode = true
+ error = RuntimeError.new("Oops")
+ raise_line = __LINE__ + 2
+ raised_error = assert_raises ActiveSupport::ErrorReporter::UnexpectedError do
+ @reporter.unexpected(error)
+ end
+ assert_includes raised_error.message, "RuntimeError: Oops"
+ assert_not_nil raised_error.cause
+ assert_same error, raised_error.cause
+ assert_includes raised_error.backtrace.first, "#{__FILE__}:#{raise_line}"
+ end
+
test "can have multiple subscribers" do
second_subscriber = ErrorSubscriber.new
@reporter.subscribe(second_subscriber)
diff --git a/railties/lib/rails/application/bootstrap.rb b/railties/lib/rails/application/bootstrap.rb
index 6b70edafc0..83a8da9298 100644
--- a/railties/lib/rails/application/bootstrap.rb
+++ b/railties/lib/rails/application/bootstrap.rb
@@ -61,8 +61,12 @@ module Bootstrap
broadcast_logger.formatter = Rails.logger.formatter
Rails.logger = broadcast_logger
end
+ end
- unless config.consider_all_requests_local
+ initializer :initialize_error_reporter, group: :all do
+ if config.consider_all_requests_local
+ Rails.error.debug_mode = true
+ else
Rails.error.logger = Rails.logger
end
end