exceptify 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/CHANGELOG.rdoc +16 -0
- data/CODE_OF_CONDUCT.md +22 -0
- data/CONTRIBUTING.md +33 -0
- data/MIT-LICENSE +23 -0
- data/README.md +534 -0
- data/RELEASING.md +51 -0
- data/Rakefile +25 -0
- data/docs/notifiers/custom.md +42 -0
- data/docs/notifiers/datadog.md +51 -0
- data/docs/notifiers/email.md +195 -0
- data/docs/notifiers/slack.md +154 -0
- data/docs/notifiers/sns.md +37 -0
- data/docs/notifiers/teams.md +54 -0
- data/docs/notifiers/webhook.md +60 -0
- data/exceptify.gemspec +48 -0
- data/lib/exceptify/base_notifier.rb +30 -0
- data/lib/exceptify/configuration.rb +184 -0
- data/lib/exceptify/datadog_notifier.rb +160 -0
- data/lib/exceptify/dispatcher.rb +49 -0
- data/lib/exceptify/email_notifier.rb +208 -0
- data/lib/exceptify/modules/backtrace_cleaner.rb +13 -0
- data/lib/exceptify/modules/error_grouping.rb +170 -0
- data/lib/exceptify/modules/formatter.rb +119 -0
- data/lib/exceptify/notification.rb +71 -0
- data/lib/exceptify/notifier.rb +19 -0
- data/lib/exceptify/notifier_registry.rb +55 -0
- data/lib/exceptify/rack.rb +88 -0
- data/lib/exceptify/rails/runner_tie.rb +57 -0
- data/lib/exceptify/rails.rb +29 -0
- data/lib/exceptify/rake.rb +59 -0
- data/lib/exceptify/request_context.rb +35 -0
- data/lib/exceptify/resque.rb +25 -0
- data/lib/exceptify/sidekiq.rb +15 -0
- data/lib/exceptify/slack_notifier.rb +141 -0
- data/lib/exceptify/sns_notifier.rb +98 -0
- data/lib/exceptify/solid_queue.rb +68 -0
- data/lib/exceptify/teams_notifier.rb +209 -0
- data/lib/exceptify/version.rb +5 -0
- data/lib/exceptify/views/exceptify/_backtrace.html.erb +3 -0
- data/lib/exceptify/views/exceptify/_backtrace.text.erb +1 -0
- data/lib/exceptify/views/exceptify/_data.html.erb +6 -0
- data/lib/exceptify/views/exceptify/_data.text.erb +1 -0
- data/lib/exceptify/views/exceptify/_environment.html.erb +10 -0
- data/lib/exceptify/views/exceptify/_environment.text.erb +5 -0
- data/lib/exceptify/views/exceptify/_request.html.erb +36 -0
- data/lib/exceptify/views/exceptify/_request.text.erb +10 -0
- data/lib/exceptify/views/exceptify/_session.html.erb +10 -0
- data/lib/exceptify/views/exceptify/_session.text.erb +2 -0
- data/lib/exceptify/views/exceptify/_title.html.erb +3 -0
- data/lib/exceptify/views/exceptify/_title.text.erb +3 -0
- data/lib/exceptify/views/exceptify/background_exceptify.html.erb +53 -0
- data/lib/exceptify/views/exceptify/background_exceptify.text.erb +14 -0
- data/lib/exceptify/views/exceptify/exceptify.html.erb +52 -0
- data/lib/exceptify/views/exceptify/exceptify.text.erb +24 -0
- data/lib/exceptify/webhook_notifier.rb +63 -0
- data/lib/exceptify.rb +177 -0
- data/lib/generators/exceptify/install_generator.rb +24 -0
- data/lib/generators/exceptify/templates/exceptify.rb.erb +44 -0
- metadata +364 -0
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "active_support"
|
|
4
|
+
require "active_support/core_ext/numeric/time"
|
|
5
|
+
require "active_support/concern"
|
|
6
|
+
|
|
7
|
+
module Exceptify
|
|
8
|
+
module ErrorGrouping
|
|
9
|
+
extend ActiveSupport::Concern
|
|
10
|
+
|
|
11
|
+
class Service
|
|
12
|
+
attr_reader :cache, :fallback_cache_store, :period, :notification_trigger, :logger
|
|
13
|
+
|
|
14
|
+
def initialize(cache:, fallback_cache_store:, period:, notification_trigger:, logger:)
|
|
15
|
+
@cache = cache
|
|
16
|
+
@fallback_cache_store = fallback_cache_store
|
|
17
|
+
@period = period
|
|
18
|
+
@notification_trigger = notification_trigger
|
|
19
|
+
@logger = logger
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def error_count(error_key)
|
|
23
|
+
count =
|
|
24
|
+
begin
|
|
25
|
+
cache_store.read(error_key)
|
|
26
|
+
rescue => e
|
|
27
|
+
log_cache_error(cache_store, e, :read)
|
|
28
|
+
fallback_cache_store.read(error_key)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
count&.to_i
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def save_error_count(error_key, count)
|
|
35
|
+
cache_store.write(error_key, count, expires_in: period)
|
|
36
|
+
rescue => e
|
|
37
|
+
log_cache_error(cache_store, e, :write)
|
|
38
|
+
fallback_cache_store.write(error_key, count, expires_in: period)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def group_error!(exception, options)
|
|
42
|
+
message_based_key = key_for_message(exception)
|
|
43
|
+
accumulated_errors_count = 1
|
|
44
|
+
|
|
45
|
+
if (count = error_count(message_based_key))
|
|
46
|
+
accumulated_errors_count = count + 1
|
|
47
|
+
save_error_count(message_based_key, accumulated_errors_count)
|
|
48
|
+
else
|
|
49
|
+
backtrace_based_key = key_for_backtrace(exception)
|
|
50
|
+
|
|
51
|
+
if (count = error_count(backtrace_based_key))
|
|
52
|
+
accumulated_errors_count = count + 1
|
|
53
|
+
save_error_count(backtrace_based_key, accumulated_errors_count)
|
|
54
|
+
else
|
|
55
|
+
save_error_count(backtrace_based_key, accumulated_errors_count)
|
|
56
|
+
save_error_count(message_based_key, accumulated_errors_count)
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
options[:accumulated_errors_count] = accumulated_errors_count
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def send_notification?(exception, count)
|
|
64
|
+
if notification_trigger.respond_to?(:call)
|
|
65
|
+
notification_trigger.call(exception, count)
|
|
66
|
+
else
|
|
67
|
+
factor = Math.log2(count)
|
|
68
|
+
factor.to_i == factor
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
private
|
|
73
|
+
|
|
74
|
+
def cache_store
|
|
75
|
+
cache || fallback_cache_store
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def key_for_message(exception)
|
|
79
|
+
"exception:#{Zlib.crc32("#{exception.class.name}\nmessage:#{exception.message}")}"
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def key_for_backtrace(exception)
|
|
83
|
+
"exception:#{Zlib.crc32("#{exception.class.name}\npath:#{exception.backtrace.try(:first)}")}"
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def log_cache_error(cache, exception, action)
|
|
87
|
+
logger.warn(
|
|
88
|
+
"#{cache.inspect} failed to #{action}, reason: #{exception.message}. Falling back to memory cache store."
|
|
89
|
+
)
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
included do
|
|
94
|
+
mattr_accessor :error_grouping
|
|
95
|
+
self.error_grouping = false
|
|
96
|
+
|
|
97
|
+
mattr_accessor :error_grouping_period
|
|
98
|
+
self.error_grouping_period = 5.minutes
|
|
99
|
+
|
|
100
|
+
mattr_accessor :notification_trigger
|
|
101
|
+
|
|
102
|
+
mattr_accessor :error_grouping_cache
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
module ClassMethods
|
|
106
|
+
# Fallback to the memory store while the specified cache store doesn't work
|
|
107
|
+
#
|
|
108
|
+
def fallback_cache_store
|
|
109
|
+
@fallback_cache_store ||= ActiveSupport::Cache::MemoryStore.new
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def error_count(error_key)
|
|
113
|
+
count =
|
|
114
|
+
begin
|
|
115
|
+
error_grouping_cache.read(error_key)
|
|
116
|
+
rescue => e
|
|
117
|
+
log_cache_error(error_grouping_cache, e, :read)
|
|
118
|
+
fallback_cache_store.read(error_key)
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
count&.to_i
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def save_error_count(error_key, count)
|
|
125
|
+
error_grouping_cache.write(error_key, count, expires_in: error_grouping_period)
|
|
126
|
+
rescue => e
|
|
127
|
+
log_cache_error(error_grouping_cache, e, :write)
|
|
128
|
+
fallback_cache_store.write(error_key, count, expires_in: error_grouping_period)
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def group_error!(exception, options)
|
|
132
|
+
message_based_key = "exception:#{Zlib.crc32("#{exception.class.name}\nmessage:#{exception.message}")}"
|
|
133
|
+
accumulated_errors_count = 1
|
|
134
|
+
|
|
135
|
+
if (count = error_count(message_based_key))
|
|
136
|
+
accumulated_errors_count = count + 1
|
|
137
|
+
save_error_count(message_based_key, accumulated_errors_count)
|
|
138
|
+
else
|
|
139
|
+
backtrace_based_key =
|
|
140
|
+
"exception:#{Zlib.crc32("#{exception.class.name}\npath:#{exception.backtrace.try(:first)}")}"
|
|
141
|
+
|
|
142
|
+
if (count = error_grouping_cache.read(backtrace_based_key))
|
|
143
|
+
accumulated_errors_count = count + 1
|
|
144
|
+
save_error_count(backtrace_based_key, accumulated_errors_count)
|
|
145
|
+
else
|
|
146
|
+
save_error_count(backtrace_based_key, accumulated_errors_count)
|
|
147
|
+
save_error_count(message_based_key, accumulated_errors_count)
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
options[:accumulated_errors_count] = accumulated_errors_count
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def send_notification?(exception, count)
|
|
155
|
+
if notification_trigger.respond_to?(:call)
|
|
156
|
+
notification_trigger.call(exception, count)
|
|
157
|
+
else
|
|
158
|
+
factor = Math.log2(count)
|
|
159
|
+
factor.to_i == factor
|
|
160
|
+
end
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
private
|
|
164
|
+
|
|
165
|
+
def log_cache_error(cache, exception, action)
|
|
166
|
+
"#{cache.inspect} failed to #{action}, reason: #{exception.message}. Falling back to memory cache store."
|
|
167
|
+
end
|
|
168
|
+
end
|
|
169
|
+
end
|
|
170
|
+
end
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "active_support/core_ext/time"
|
|
4
|
+
require "action_dispatch"
|
|
5
|
+
require "exceptify/notification"
|
|
6
|
+
|
|
7
|
+
module Exceptify
|
|
8
|
+
class Formatter
|
|
9
|
+
include Exceptify::BacktraceCleaner
|
|
10
|
+
|
|
11
|
+
attr_reader :app_name
|
|
12
|
+
|
|
13
|
+
def initialize(exception_or_notification, opts = {})
|
|
14
|
+
@notification = if exception_or_notification.is_a?(Notification)
|
|
15
|
+
exception_or_notification
|
|
16
|
+
else
|
|
17
|
+
Notification.new(exception_or_notification, opts, backtrace_cleaner: self)
|
|
18
|
+
end
|
|
19
|
+
@exception = notification.exception
|
|
20
|
+
@errors_count = notification.options[:accumulated_errors_count].to_i
|
|
21
|
+
@app_name = notification.app_name
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
#
|
|
25
|
+
# :warning: Error occurred in production :warning:
|
|
26
|
+
# :warning: Error occurred :warning:
|
|
27
|
+
#
|
|
28
|
+
def title
|
|
29
|
+
env = notification.env_name
|
|
30
|
+
|
|
31
|
+
if env
|
|
32
|
+
"⚠️ Error occurred in #{env} ⚠️"
|
|
33
|
+
else
|
|
34
|
+
"⚠️ Error occurred ⚠️"
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
#
|
|
39
|
+
# A *NoMethodError* occurred.
|
|
40
|
+
# 3 *NoMethodError* occurred.
|
|
41
|
+
# A *NoMethodError* occurred in *home#index*.
|
|
42
|
+
#
|
|
43
|
+
def subtitle
|
|
44
|
+
errors_text = if errors_count > 1
|
|
45
|
+
errors_count
|
|
46
|
+
else
|
|
47
|
+
/^[aeiou]/i.match?(exception.class.to_s) ? "An" : "A"
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
in_action = " in *#{controller_and_action}*" if controller
|
|
51
|
+
|
|
52
|
+
"#{errors_text} *#{exception.class}* occurred#{in_action}."
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
#
|
|
56
|
+
#
|
|
57
|
+
# *Request:*
|
|
58
|
+
# ```
|
|
59
|
+
# * url : https://www.example.com/
|
|
60
|
+
# * http_method : GET
|
|
61
|
+
# * ip_address : 127.0.0.1
|
|
62
|
+
# * parameters : {"controller"=>"home", "action"=>"index"}
|
|
63
|
+
# * timestamp : 2019-01-01 00:00:00 UTC
|
|
64
|
+
# ```
|
|
65
|
+
#
|
|
66
|
+
def request_message
|
|
67
|
+
request = notification.request_context.request
|
|
68
|
+
return unless request
|
|
69
|
+
|
|
70
|
+
[
|
|
71
|
+
"```",
|
|
72
|
+
"* url : #{request.original_url}",
|
|
73
|
+
"* http_method : #{request.method}",
|
|
74
|
+
"* ip_address : #{request.remote_ip}",
|
|
75
|
+
"* parameters : #{request.filtered_parameters}",
|
|
76
|
+
"* timestamp : #{notification.timestamp}",
|
|
77
|
+
"```"
|
|
78
|
+
].join("\n")
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
#
|
|
82
|
+
#
|
|
83
|
+
# *Backtrace:*
|
|
84
|
+
# ```
|
|
85
|
+
# * app/controllers/my_controller.rb:99:in `specific_function'
|
|
86
|
+
# * app/controllers/my_controller.rb:70:in `specific_param'
|
|
87
|
+
# * app/controllers/my_controller.rb:53:in `my_controller_params'
|
|
88
|
+
# ```
|
|
89
|
+
#
|
|
90
|
+
def backtrace_message
|
|
91
|
+
backtrace = notification.backtrace
|
|
92
|
+
|
|
93
|
+
return if backtrace.empty?
|
|
94
|
+
|
|
95
|
+
text = []
|
|
96
|
+
|
|
97
|
+
text << "```"
|
|
98
|
+
backtrace.first(3).each { |line| text << "* #{line}" }
|
|
99
|
+
text << "```"
|
|
100
|
+
|
|
101
|
+
text.join("\n")
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
#
|
|
105
|
+
# home#index
|
|
106
|
+
#
|
|
107
|
+
def controller_and_action
|
|
108
|
+
notification.controller_and_action
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
private
|
|
112
|
+
|
|
113
|
+
attr_reader :exception, :errors_count, :notification
|
|
114
|
+
|
|
115
|
+
def controller
|
|
116
|
+
notification.controller
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
end
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "socket"
|
|
4
|
+
require "active_support/core_ext/time"
|
|
5
|
+
require "exceptify/request_context"
|
|
6
|
+
|
|
7
|
+
module Exceptify
|
|
8
|
+
class Notification
|
|
9
|
+
attr_reader :exception, :options, :request_context
|
|
10
|
+
|
|
11
|
+
def initialize(exception, options = {}, clock: Time, hostname: -> { Socket.gethostname }, backtrace_cleaner: nil, **keyword_options)
|
|
12
|
+
@exception = exception
|
|
13
|
+
@options = options.merge(keyword_options)
|
|
14
|
+
@clock = clock
|
|
15
|
+
@hostname = hostname
|
|
16
|
+
@backtrace_cleaner = backtrace_cleaner
|
|
17
|
+
@request_context = @options[:request_context] || RequestContext.new(@options[:env])
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def env
|
|
21
|
+
request_context.env
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def data
|
|
25
|
+
request_context.exception_data.merge(options[:data] || {})
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def backtrace
|
|
29
|
+
return [] unless exception.backtrace
|
|
30
|
+
return @backtrace_cleaner.clean_backtrace(exception) if @backtrace_cleaner
|
|
31
|
+
|
|
32
|
+
exception.backtrace
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def timestamp
|
|
36
|
+
@clock.respond_to?(:current) ? @clock.current : @clock.now
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def hostname
|
|
40
|
+
@hostname.call
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def app_name
|
|
44
|
+
options[:app_name] || rails_app_name
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def env_name
|
|
48
|
+
Rails.env if defined?(::Rails) && ::Rails.respond_to?(:env)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def controller
|
|
52
|
+
request_context.controller
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def controller_and_action
|
|
56
|
+
request_context.controller_and_action
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
private
|
|
60
|
+
|
|
61
|
+
def rails_app_name
|
|
62
|
+
return unless defined?(::Rails) && ::Rails.respond_to?(:application)
|
|
63
|
+
|
|
64
|
+
if Rails::VERSION::MAJOR >= 6
|
|
65
|
+
Rails.application.class.module_parent_name.underscore
|
|
66
|
+
else
|
|
67
|
+
Rails.application.class.parent_name.underscore
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "active_support/deprecation"
|
|
4
|
+
|
|
5
|
+
module Exceptify
|
|
6
|
+
class Notifier
|
|
7
|
+
def self.exceptify(env, exception, options = {})
|
|
8
|
+
ActiveSupport::Deprecation.warn(
|
|
9
|
+
"Please use Exceptify.notify_exception(exception, options.merge(env: env))."
|
|
10
|
+
)
|
|
11
|
+
Exceptify.registered_notifier(:email).create_email(exception, options.merge(env: env))
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def self.background_exceptify(exception, options = {})
|
|
15
|
+
ActiveSupport::Deprecation.warn "Please use Exceptify.notify_exception(exception, options)."
|
|
16
|
+
Exceptify.registered_notifier(:email).create_email(exception, options)
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "active_support/core_ext/string/inflections"
|
|
4
|
+
|
|
5
|
+
module Exceptify
|
|
6
|
+
class NotifierRegistry
|
|
7
|
+
attr_reader :notifiers
|
|
8
|
+
|
|
9
|
+
def initialize(notifiers = {}, factory: nil)
|
|
10
|
+
@notifiers = notifiers.dup
|
|
11
|
+
@factory = factory || method(:build_notifier)
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def register(name, notifier_or_options)
|
|
15
|
+
if notifier_or_options.respond_to?(:call)
|
|
16
|
+
notifiers[name] = notifier_or_options
|
|
17
|
+
elsif notifier_or_options.is_a?(Hash)
|
|
18
|
+
notifiers[name] = @factory.call(name, notifier_or_options)
|
|
19
|
+
else
|
|
20
|
+
raise ArgumentError, "Invalid notifier '#{name}' defined as #{notifier_or_options.inspect}"
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def unregister(name)
|
|
25
|
+
notifiers.delete(name)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def fetch(name)
|
|
29
|
+
notifiers[name]
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def names
|
|
33
|
+
notifiers.keys
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def clear
|
|
37
|
+
notifiers.clear
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def copy
|
|
41
|
+
self.class.new(notifiers, factory: @factory)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
private
|
|
45
|
+
|
|
46
|
+
def build_notifier(name, options)
|
|
47
|
+
notifier_classname = "#{name}_notifier".camelize
|
|
48
|
+
notifier_class = Exceptify.const_get(notifier_classname)
|
|
49
|
+
notifier_class.new(options)
|
|
50
|
+
rescue NameError => e
|
|
51
|
+
raise UndefinedNotifierError,
|
|
52
|
+
"No notifier named '#{name}' was found. Please, revise your configuration options. Cause: #{e.message}"
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "exceptify"
|
|
4
|
+
|
|
5
|
+
module Exceptify
|
|
6
|
+
class Rack
|
|
7
|
+
class CascadePassException < RuntimeError; end
|
|
8
|
+
|
|
9
|
+
attr_reader :configuration
|
|
10
|
+
|
|
11
|
+
def initialize(app, options = {})
|
|
12
|
+
@app = app
|
|
13
|
+
@dispatcher = options.delete(:dispatcher)
|
|
14
|
+
@configuration = options.delete(:configuration)
|
|
15
|
+
@ignore_cascade_pass = options.delete(:ignore_cascade_pass) { true }
|
|
16
|
+
|
|
17
|
+
return if @dispatcher
|
|
18
|
+
return if options.empty? && @configuration.nil?
|
|
19
|
+
|
|
20
|
+
@configuration ||= Exceptify.configuration.copy
|
|
21
|
+
apply_options(@configuration, options)
|
|
22
|
+
@dispatcher = Dispatcher.new(@configuration)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def call(env)
|
|
26
|
+
_, headers, = response = @app.call(env)
|
|
27
|
+
|
|
28
|
+
if !@ignore_cascade_pass && headers["X-Cascade"] == "pass"
|
|
29
|
+
msg = "This exception means that the preceding Rack middleware set the 'X-Cascade' header to 'pass' -- in " \
|
|
30
|
+
"Rails, this often means that the route was not found (404 error)."
|
|
31
|
+
raise CascadePassException, msg
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
response
|
|
35
|
+
rescue Exception => e # standard:disable Lint/RescueException
|
|
36
|
+
env["exceptify.delivered"] = true if dispatcher.notify_exception(e, env: env)
|
|
37
|
+
|
|
38
|
+
raise e unless e.is_a?(CascadePassException)
|
|
39
|
+
|
|
40
|
+
response
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
private
|
|
44
|
+
|
|
45
|
+
def dispatcher
|
|
46
|
+
@dispatcher || Exceptify
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def apply_options(configuration, options)
|
|
50
|
+
configuration.ignored_exceptions = options.delete(:ignore_exceptions) if options.key?(:ignore_exceptions)
|
|
51
|
+
configuration.error_grouping = options.delete(:error_grouping) if options.key?(:error_grouping)
|
|
52
|
+
configuration.error_grouping_period = options.delete(:error_grouping_period) if options.key?(:error_grouping_period)
|
|
53
|
+
configuration.notification_trigger = options.delete(:notification_trigger) if options.key?(:notification_trigger)
|
|
54
|
+
|
|
55
|
+
if options.key?(:error_grouping_cache)
|
|
56
|
+
configuration.error_grouping_cache = options.delete(:error_grouping_cache)
|
|
57
|
+
elsif defined?(Rails) && Rails.respond_to?(:cache)
|
|
58
|
+
configuration.error_grouping_cache = Rails.cache
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
apply_ignore_options(configuration, options)
|
|
62
|
+
|
|
63
|
+
options.each do |notifier_name, opts|
|
|
64
|
+
configuration.register_notifier(notifier_name, opts)
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def apply_ignore_options(configuration, options)
|
|
69
|
+
if options.key?(:ignore_if)
|
|
70
|
+
rack_ignore = options.delete(:ignore_if)
|
|
71
|
+
configuration.ignore_if do |exception, opts|
|
|
72
|
+
opts.key?(:env) && rack_ignore.call(opts[:env], exception)
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
if options.key?(:ignore_notifier_if)
|
|
77
|
+
rack_ignore_by_notifier = options.delete(:ignore_notifier_if)
|
|
78
|
+
rack_ignore_by_notifier.each do |notifier, proc|
|
|
79
|
+
configuration.ignore_notifier_if(notifier) do |exception, opts|
|
|
80
|
+
opts.key?(:env) && proc.call(opts[:env], exception)
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
configuration.ignore_crawlers(options.delete(:ignore_crawlers)) if options.key?(:ignore_crawlers)
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "exceptify"
|
|
4
|
+
|
|
5
|
+
module Exceptify
|
|
6
|
+
module Rails
|
|
7
|
+
class RunnerTie
|
|
8
|
+
class << self
|
|
9
|
+
attr_writer :installed
|
|
10
|
+
|
|
11
|
+
def installed?
|
|
12
|
+
@installed == true
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def reset!
|
|
16
|
+
@installed = false
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def initialize(registrar: nil, error_source: nil, notifier: Exceptify)
|
|
21
|
+
@registrar = registrar || ->(&block) { at_exit(&block) }
|
|
22
|
+
@error_source = error_source || -> { $ERROR_INFO }
|
|
23
|
+
@notifier = notifier
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Registers an at_exit callback, which checks if there was an exception. This is a pretty
|
|
27
|
+
# crude way to detect exceptions from runner commands, but Rails doesn't provide a better API.
|
|
28
|
+
#
|
|
29
|
+
# This should only be called from a runner callback in your Rails config; otherwise you may
|
|
30
|
+
# register the at_exit callback in more places than you need or want it.
|
|
31
|
+
def call
|
|
32
|
+
return false if self.class.installed?
|
|
33
|
+
|
|
34
|
+
self.class.installed = true
|
|
35
|
+
|
|
36
|
+
@registrar.call do
|
|
37
|
+
exception = @error_source.call
|
|
38
|
+
if exception && !exception.is_a?(SystemExit)
|
|
39
|
+
@notifier.notify_exception(exception, data: data_for_exceptify(exception))
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
true
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
private
|
|
47
|
+
|
|
48
|
+
def data_for_exceptify(exception = nil)
|
|
49
|
+
data = {}
|
|
50
|
+
data[:error_class] = exception.class.name if exception
|
|
51
|
+
data[:error_message] = exception.message if exception
|
|
52
|
+
|
|
53
|
+
data
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Warning: This must be required after rails but before initializers have been run. If you require
|
|
4
|
+
# it from config/initializers/exceptify.rb, then the rails and rake_task callbacks
|
|
5
|
+
# registered here will have no effect, because Rails will have already invoked all registered rails
|
|
6
|
+
# and rake_tasks handlers.
|
|
7
|
+
|
|
8
|
+
require "exceptify"
|
|
9
|
+
|
|
10
|
+
module Exceptify
|
|
11
|
+
class Engine < ::Rails::Engine
|
|
12
|
+
config.exceptify = Exceptify
|
|
13
|
+
config.exceptify.logger = Rails.logger
|
|
14
|
+
config.exceptify.error_grouping_cache = Rails.cache
|
|
15
|
+
|
|
16
|
+
config.app_middleware.use Exceptify::Rack
|
|
17
|
+
|
|
18
|
+
rake_tasks do
|
|
19
|
+
# Report exceptions occurring in Rake tasks.
|
|
20
|
+
require "exceptify/rake"
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
runner do
|
|
24
|
+
# Report exceptions occurring in runner commands.
|
|
25
|
+
require "exceptify/rails/runner_tie"
|
|
26
|
+
Exceptify::Rails::RunnerTie.new.call
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Copied/adapted from https://github.com/airbrake/airbrake/blob/master/lib/airbrake/rake.rb
|
|
4
|
+
|
|
5
|
+
require "exceptify"
|
|
6
|
+
require "rake"
|
|
7
|
+
|
|
8
|
+
Rake::TaskManager.record_task_metadata = true if Rake.const_defined?(:TaskManager)
|
|
9
|
+
|
|
10
|
+
module Exceptify
|
|
11
|
+
module RakeTaskExtensions
|
|
12
|
+
# A wrapper around the original +#execute+, that catches all errors and
|
|
13
|
+
# passes them on to Exceptify.
|
|
14
|
+
def execute(args = nil)
|
|
15
|
+
super
|
|
16
|
+
rescue Exception => e # standard:disable Lint/RescueException
|
|
17
|
+
Exceptify.notify_exception(e, data: data_for_exceptify(e)) unless e.is_a?(SystemExit)
|
|
18
|
+
raise e
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
private
|
|
22
|
+
|
|
23
|
+
def data_for_exceptify(exception = nil)
|
|
24
|
+
data = {}
|
|
25
|
+
data[:error_class] = exception.class.name if exception
|
|
26
|
+
data[:error_message] = exception.message if exception
|
|
27
|
+
|
|
28
|
+
data[:rake] = {}
|
|
29
|
+
data[:rake][:rake_command_line] = reconstruct_command_line
|
|
30
|
+
data[:rake][:name] = name
|
|
31
|
+
data[:rake][:timestamp] = timestamp.to_s
|
|
32
|
+
# data[:investigation] = investigation
|
|
33
|
+
|
|
34
|
+
data[:rake][:full_comment] = full_comment if full_comment
|
|
35
|
+
data[:rake][:arg_names] = arg_names if arg_names.any?
|
|
36
|
+
data[:rake][:arg_description] = arg_description if arg_description
|
|
37
|
+
data[:rake][:locations] = locations if locations.any?
|
|
38
|
+
data[:rake][:sources] = sources if sources.any?
|
|
39
|
+
|
|
40
|
+
if prerequisite_tasks.any?
|
|
41
|
+
data[:rake][:prerequisite_tasks] = prerequisite_tasks.map do |p|
|
|
42
|
+
p.__send__(:data_for_exceptify)[:rake]
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
data
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def reconstruct_command_line
|
|
50
|
+
"rake #{ARGV.join(" ")}"
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
module Rake
|
|
56
|
+
class Task
|
|
57
|
+
prepend Exceptify::RakeTaskExtensions
|
|
58
|
+
end
|
|
59
|
+
end
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "action_dispatch"
|
|
4
|
+
|
|
5
|
+
module Exceptify
|
|
6
|
+
class RequestContext
|
|
7
|
+
attr_reader :env
|
|
8
|
+
|
|
9
|
+
def initialize(env)
|
|
10
|
+
@env = env
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def present?
|
|
14
|
+
!env.nil?
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def request
|
|
18
|
+
@request ||= ActionDispatch::Request.new(env) if present?
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def controller
|
|
22
|
+
env["action_controller.instance"] if present?
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def controller_and_action
|
|
26
|
+
"#{controller.controller_name}##{controller.action_name}" if controller
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def exception_data
|
|
30
|
+
return {} unless present?
|
|
31
|
+
|
|
32
|
+
env["exceptify.exception_data"] || {}
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|