-
Notifications
You must be signed in to change notification settings - Fork 21.4k
/
error_reporter.rb
241 lines (224 loc) · 8.54 KB
/
error_reporter.rb
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
# 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"
DEFAULT_RESCUE = [StandardError].freeze
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.
# 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 <tt>"application"</tt>.
def handle(*error_classes, severity: :warning, context: {}, fallback: nil, source: DEFAULT_SOURCE)
error_classes = DEFAULT_RESCUE if error_classes.empty?
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 <tt>"application"</tt>.
def record(*error_classes, severity: :error, context: {}, source: DEFAULT_SOURCE)
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)
#
# The +report+ method <b>should never</b> 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