Skip to content

Commit 5a98545

Browse files
authored
Merge pull request rails#43625 from Shopify/error-reporting-api
Rails standardized error reporting interface
2 parents 3b01e92 + 8cbc19d commit 5a98545

File tree

8 files changed

+284
-1
lines changed

8 files changed

+284
-1
lines changed

actionpack/lib/action_dispatch/middleware/executor.rb

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,9 @@ def call(env)
1313
begin
1414
response = @app.call(env)
1515
returned = response << ::Rack::BodyProxy.new(response.pop) { state.complete! }
16+
rescue => error
17+
@executor.error_reporter.report(error, handled: false)
18+
raise
1619
ensure
1720
state.complete! unless returned
1821
end

activesupport/lib/active_support.rb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ module ActiveSupport
4343
autoload :ExecutionContext
4444
autoload :ExecutionWrapper
4545
autoload :Executor
46+
autoload :ErrorReporter
4647
autoload :FileUpdateChecker
4748
autoload :EventedFileUpdateChecker
4849
autoload :ForkTracker
Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
# frozen_string_literal: true
2+
3+
module ActiveSupport
4+
# +ActiveSupport::ErrorReporter+ is a common interface for error reporting services.
5+
#
6+
# To rescue and report any unhandled error, you can use the +handle+ method:
7+
#
8+
# Rails.error.handle do
9+
# do_something!
10+
# end
11+
#
12+
# If an error is raised, it will be reported and swallowed.
13+
#
14+
# Alternatively if you want to report the error but not swallow it, you can use `record`
15+
#
16+
# Rails.error.record do
17+
# do_something!
18+
# end
19+
#
20+
# Both methods can be restricted to only handle a specific exception class
21+
#
22+
# maybe_tags = Rails.error.handle(Redis::BaseError) { redis.get("tags) }
23+
#
24+
# You can also pass some extra context information that may be used by the error subscribers:
25+
#
26+
# Rails.error.handle(context: { section: "admin" }) do
27+
# # ...
28+
# end
29+
#
30+
# Additionally a +severity+ can be passed along to communicate how important the error report is.
31+
# +severity+ can be one of +:error+, +:warning+ or +:info+. Handled errors default to the +:warning+
32+
# severity, and unhandled ones to +error+.
33+
class ErrorReporter
34+
SEVERITIES = %i(error warning info)
35+
36+
attr_accessor :logger
37+
38+
def initialize(*subscribers, logger: nil)
39+
@subscribers = subscribers.flatten
40+
@logger = logger
41+
end
42+
43+
# Report any unhandled exception, and swallow it.
44+
#
45+
# Rails.error.handle do
46+
# 1 + '1'
47+
# end
48+
#
49+
def handle(error_class = StandardError, severity: :warning, context: {})
50+
yield
51+
rescue error_class => error
52+
report(error, handled: true, severity: severity, context: context)
53+
nil
54+
end
55+
56+
def record(error_class = StandardError, severity: :error, context: {})
57+
yield
58+
rescue error_class => error
59+
report(error, handled: false, severity: severity, context: context)
60+
raise
61+
end
62+
63+
# Register a new error subscriber. The subscriber must respond to
64+
#
65+
# report(Exception, handled: Boolean, context: Hash)
66+
#
67+
# The +report+ method +should+ never raise an error.
68+
def subscribe(subscriber)
69+
unless subscriber.respond_to?(:report)
70+
raise ArgumentError, "Error subscribers must respond to #report"
71+
end
72+
@subscribers << subscriber
73+
end
74+
75+
# Update the execution context that is accessible to error subscribers
76+
#
77+
# Rails.error.set_context(section: "checkout", user_id: @user.id)
78+
#
79+
# See +ActiveSupport::ExecutionContext.set+
80+
def set_context(...)
81+
ActiveSupport::ExecutionContext.set(...)
82+
end
83+
84+
# When the block based +handle+ and +record+ methods are not suitable, you can directly use +report+
85+
#
86+
# Rails.error.report(error, handled: true)
87+
def report(error, handled:, severity: handled ? :warning : :error, context: {})
88+
unless SEVERITIES.include?(severity)
89+
raise ArgumentError, "severity must be one of #{SEVERITIES.map(&:inspect).join(", ")}, got: #{severity.inspect}"
90+
end
91+
92+
full_context = ActiveSupport::ExecutionContext.to_h.merge(context)
93+
@subscribers.each do |subscriber|
94+
subscriber.report(error, handled: handled, severity: severity, context: full_context)
95+
rescue => subscriber_error
96+
if logger
97+
logger.fatal(
98+
"Error subscriber raised an error: #{subscriber_error.message} (#{subscriber_error.class})\n" +
99+
subscriber_error.backtrace.join("\n")
100+
)
101+
else
102+
raise
103+
end
104+
end
105+
106+
nil
107+
end
108+
end
109+
end

activesupport/lib/active_support/execution_wrapper.rb

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
# frozen_string_literal: true
22

3+
require "active_support/error_reporter"
34
require "active_support/callbacks"
45
require "concurrent/hash"
56

@@ -86,6 +87,9 @@ def self.wrap
8687
instance = run!
8788
begin
8889
yield
90+
rescue => error
91+
error_reporter.report(error, handled: false)
92+
raise
8993
ensure
9094
instance.complete!
9195
end
@@ -105,6 +109,10 @@ class << self # :nodoc:
105109
attr_accessor :active
106110
end
107111

112+
def self.error_reporter
113+
@error_reporter ||= ActiveSupport::ErrorReporter.new
114+
end
115+
108116
def self.inherited(other) # :nodoc:
109117
super
110118
other.active = Concurrent::Hash.new
Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
# frozen_string_literal: true
2+
3+
require_relative "abstract_unit"
4+
require "active_support/execution_context/test_helper"
5+
6+
class ErrorReporterTest < ActiveSupport::TestCase
7+
# ExecutionContext is automatically reset in Rails app via executor hooks set in railtie
8+
# But not in Active Support's own test suite.
9+
include ActiveSupport::ExecutionContext::TestHelper
10+
11+
class ErrorSubscriber
12+
attr_reader :events
13+
14+
def initialize
15+
@events = []
16+
end
17+
18+
def report(error, handled:, severity:, context:)
19+
@events << [error, handled, severity, context]
20+
end
21+
end
22+
23+
setup do
24+
@reporter = ActiveSupport::ErrorReporter.new
25+
@subscriber = ErrorSubscriber.new
26+
@reporter.subscribe(@subscriber)
27+
@error = ArgumentError.new("Oops")
28+
end
29+
30+
test "receives the execution context" do
31+
@reporter.set_context(section: "admin")
32+
error = ArgumentError.new("Oops")
33+
@reporter.report(error, handled: true)
34+
assert_equal [[error, true, :warning, { section: "admin" }]], @subscriber.events
35+
end
36+
37+
test "passed context has priority over the execution context" do
38+
@reporter.set_context(section: "admin")
39+
error = ArgumentError.new("Oops")
40+
@reporter.report(error, handled: true, context: { section: "public" })
41+
assert_equal [[error, true, :warning, { section: "public" }]], @subscriber.events
42+
end
43+
44+
test "#handle swallow and report any unhandled error" do
45+
error = ArgumentError.new("Oops")
46+
@reporter.handle do
47+
raise error
48+
end
49+
assert_equal [[error, true, :warning, {}]], @subscriber.events
50+
end
51+
52+
test "#handle can be scoped to an exception class" do
53+
assert_raises ArgumentError do
54+
@reporter.handle(NameError) do
55+
raise ArgumentError
56+
end
57+
end
58+
assert_equal [], @subscriber.events
59+
end
60+
61+
test "#record report any unhandled error and re-raise them" do
62+
error = ArgumentError.new("Oops")
63+
assert_raises ArgumentError do
64+
@reporter.record do
65+
raise error
66+
end
67+
end
68+
assert_equal [[error, false, :error, {}]], @subscriber.events
69+
end
70+
71+
test "#record can be scoped to an exception class" do
72+
assert_raises ArgumentError do
73+
@reporter.record(NameError) do
74+
raise ArgumentError
75+
end
76+
end
77+
assert_equal [], @subscriber.events
78+
end
79+
80+
test "can have multiple subscribers" do
81+
second_subscriber = ErrorSubscriber.new
82+
@reporter.subscribe(second_subscriber)
83+
84+
error = ArgumentError.new("Oops")
85+
@reporter.report(error, handled: true)
86+
87+
assert_equal 1, @subscriber.events.size
88+
assert_equal 1, second_subscriber.events.size
89+
end
90+
91+
test "handled errors default to :warning severity" do
92+
@reporter.report(@error, handled: true)
93+
assert_equal :warning, @subscriber.events.dig(0, 2)
94+
end
95+
96+
test "unhandled errors default to :error severity" do
97+
@reporter.report(@error, handled: false)
98+
assert_equal :error, @subscriber.events.dig(0, 2)
99+
end
100+
101+
class FailingErrorSubscriber
102+
Error = Class.new(StandardError)
103+
104+
def initialize(error)
105+
@error = error
106+
end
107+
108+
def report(_error, handled:, severity:, context:)
109+
raise @error
110+
end
111+
end
112+
113+
test "subscriber errors are re-raised if no logger is set" do
114+
subscriber_error = FailingErrorSubscriber::Error.new("Big Oopsie")
115+
@reporter.subscribe(FailingErrorSubscriber.new(subscriber_error))
116+
assert_raises FailingErrorSubscriber::Error do
117+
@reporter.report(@error, handled: true)
118+
end
119+
end
120+
121+
test "subscriber errors are logged if a logger is set" do
122+
subscriber_error = FailingErrorSubscriber::Error.new("Big Oopsie")
123+
@reporter.subscribe(FailingErrorSubscriber.new(subscriber_error))
124+
log = StringIO.new
125+
@reporter.logger = ActiveSupport::Logger.new(log)
126+
@reporter.report(@error, handled: true)
127+
128+
expected = "Error subscriber raised an error: Big Oopsie (ErrorReporterTest::FailingErrorSubscriber::Error)"
129+
assert_equal expected, log.string.lines.first.chomp
130+
end
131+
end

activesupport/test/executor_test.rb

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,30 @@ class ExecutorTest < ActiveSupport::TestCase
66
class DummyError < RuntimeError
77
end
88

9+
class ErrorSubscriber
10+
attr_reader :events
11+
12+
def initialize
13+
@events = []
14+
end
15+
16+
def report(error, handled:, severity:, context:)
17+
@events << [error, handled, severity, context]
18+
end
19+
end
20+
21+
def test_wrap_report_errors
22+
subscriber = ErrorSubscriber.new
23+
executor.error_reporter.subscribe(subscriber)
24+
error = DummyError.new("Oops")
25+
assert_raises DummyError do
26+
executor.wrap do
27+
raise error
28+
end
29+
end
30+
assert_equal [[error, false, :error, {}]], subscriber.events
31+
end
32+
933
def test_wrap_invokes_callbacks
1034
called = []
1135
executor.to_run { called << :run }

railties/lib/rails.rb

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,10 @@ def env=(environment)
8181
@_env = ActiveSupport::EnvironmentInquirer.new(environment)
8282
end
8383

84+
def error
85+
application && application.executor.error_reporter
86+
end
87+
8488
# Returns all Rails groups for loading based on:
8589
#
8690
# * The Rails environment;

railties/lib/rails/application/bootstrap.rb

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,8 +49,11 @@ module Bootstrap
4949
)
5050
logger
5151
end
52-
5352
Rails.logger.level = ActiveSupport::Logger.const_get(config.log_level.to_s.upcase)
53+
54+
unless config.consider_all_requests_local
55+
Rails.error.logger = Rails.logger
56+
end
5457
end
5558

5659
# Initialize cache early in the stack so railties can make use of it.

0 commit comments

Comments
 (0)