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,30 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Exceptify
|
|
4
|
+
class BaseNotifier
|
|
5
|
+
attr_accessor :base_options
|
|
6
|
+
|
|
7
|
+
def initialize(options = {})
|
|
8
|
+
@base_options = options
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def send_notice(exception, options, message, message_opts = nil)
|
|
12
|
+
_pre_callback(exception, options, message, message_opts)
|
|
13
|
+
result = yield(message, message_opts)
|
|
14
|
+
_post_callback(exception, options, message, message_opts)
|
|
15
|
+
result
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def _pre_callback(exception, options, message, message_opts)
|
|
19
|
+
return unless @base_options[:pre_callback].respond_to?(:call)
|
|
20
|
+
|
|
21
|
+
@base_options[:pre_callback].call(options, self, exception.backtrace, message, message_opts)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def _post_callback(exception, options, message, message_opts)
|
|
25
|
+
return unless @base_options[:post_callback].respond_to?(:call)
|
|
26
|
+
|
|
27
|
+
@base_options[:post_callback].call(options, self, exception.backtrace, message, message_opts)
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "logger"
|
|
4
|
+
require "active_support/core_ext/numeric/time"
|
|
5
|
+
require "active_support/cache"
|
|
6
|
+
require "exceptify/notifier_registry"
|
|
7
|
+
require "exceptify/modules/error_grouping"
|
|
8
|
+
|
|
9
|
+
module Exceptify
|
|
10
|
+
class Configuration
|
|
11
|
+
attr_accessor :logger,
|
|
12
|
+
:ignored_exceptions,
|
|
13
|
+
:testing_mode,
|
|
14
|
+
:error_grouping,
|
|
15
|
+
:error_grouping_period,
|
|
16
|
+
:notification_trigger,
|
|
17
|
+
:error_grouping_cache,
|
|
18
|
+
:fallback_cache_store
|
|
19
|
+
|
|
20
|
+
attr_reader :notifier_registry
|
|
21
|
+
|
|
22
|
+
DEFAULT_IGNORED_EXCEPTIONS = %w[
|
|
23
|
+
ActiveRecord::RecordNotFound Mongoid::Errors::DocumentNotFound AbstractController::ActionNotFound
|
|
24
|
+
ActionController::RoutingError ActionController::UnknownFormat ActionController::UrlGenerationError
|
|
25
|
+
ActionDispatch::Http::MimeNegotiation::InvalidType Rack::Utils::InvalidParameterError
|
|
26
|
+
].freeze
|
|
27
|
+
|
|
28
|
+
def initialize(
|
|
29
|
+
logger: Logger.new($stdout),
|
|
30
|
+
ignored_exceptions: DEFAULT_IGNORED_EXCEPTIONS,
|
|
31
|
+
notifier_registry: NotifierRegistry.new,
|
|
32
|
+
error_grouping_cache: nil,
|
|
33
|
+
fallback_cache_store: ActiveSupport::Cache::MemoryStore.new
|
|
34
|
+
)
|
|
35
|
+
@logger = logger
|
|
36
|
+
@ignored_exceptions = ignored_exceptions.dup
|
|
37
|
+
@testing_mode = false
|
|
38
|
+
@error_grouping = false
|
|
39
|
+
@error_grouping_period = 5.minutes
|
|
40
|
+
@notification_trigger = nil
|
|
41
|
+
@error_grouping_cache = error_grouping_cache
|
|
42
|
+
@fallback_cache_store = fallback_cache_store
|
|
43
|
+
@notifier_registry = notifier_registry
|
|
44
|
+
@ignores = []
|
|
45
|
+
@by_notifier_ignores = {}
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def copy
|
|
49
|
+
self.class.new(
|
|
50
|
+
logger: logger,
|
|
51
|
+
ignored_exceptions: ignored_exceptions,
|
|
52
|
+
notifier_registry: notifier_registry.copy,
|
|
53
|
+
error_grouping_cache: error_grouping_cache,
|
|
54
|
+
fallback_cache_store: fallback_cache_store
|
|
55
|
+
).tap do |configuration|
|
|
56
|
+
configuration.testing_mode = testing_mode
|
|
57
|
+
configuration.error_grouping = error_grouping
|
|
58
|
+
configuration.error_grouping_period = error_grouping_period
|
|
59
|
+
configuration.notification_trigger = notification_trigger
|
|
60
|
+
ignores.each { |condition| configuration.ignore_if(&condition) }
|
|
61
|
+
by_notifier_ignores.each { |notifier, condition| configuration.ignore_notifier_if(notifier, &condition) }
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def reset!
|
|
66
|
+
notifier_registry.clear
|
|
67
|
+
clear_ignore_conditions!
|
|
68
|
+
self.error_grouping = false
|
|
69
|
+
self.notification_trigger = nil
|
|
70
|
+
self.error_grouping_cache = nil
|
|
71
|
+
fallback_cache_store.clear if fallback_cache_store.respond_to?(:clear)
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def testing_mode!
|
|
75
|
+
self.testing_mode = true
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def register_notifier(name, notifier_or_options)
|
|
79
|
+
notifier_registry.register(name, notifier_or_options)
|
|
80
|
+
end
|
|
81
|
+
alias_method :add_notifier, :register_notifier
|
|
82
|
+
|
|
83
|
+
def unregister_notifier(name)
|
|
84
|
+
notifier_registry.unregister(name)
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def registered_notifier(name)
|
|
88
|
+
notifier_registry.fetch(name)
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def notifiers
|
|
92
|
+
notifier_registry.names
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def ignore_if(&block)
|
|
96
|
+
ignores << block
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def ignore_notifier_if(notifier, &block)
|
|
100
|
+
by_notifier_ignores[notifier] = block
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def ignore_crawlers(crawlers)
|
|
104
|
+
ignore_if do |_exception, opts|
|
|
105
|
+
opts.key?(:env) && from_crawler(opts[:env], crawlers)
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def clear_ignore_conditions!
|
|
110
|
+
ignores.clear
|
|
111
|
+
by_notifier_ignores.clear
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def ignored?(exception, options)
|
|
115
|
+
ignores.any? { |condition| condition.call(exception, options) }
|
|
116
|
+
rescue Exception => e # standard:disable Lint/RescueException
|
|
117
|
+
raise e if testing_mode
|
|
118
|
+
|
|
119
|
+
logger.warn(
|
|
120
|
+
"An error occurred when evaluating an ignore condition. #{e.class}: #{e.message}\n#{e.backtrace.join("\n")}"
|
|
121
|
+
)
|
|
122
|
+
false
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def notifier_ignored?(exception, options, notifier:)
|
|
126
|
+
return false unless by_notifier_ignores.key?(notifier)
|
|
127
|
+
|
|
128
|
+
by_notifier_ignores[notifier].call(exception, options)
|
|
129
|
+
rescue Exception => e # standard:disable Lint/RescueException
|
|
130
|
+
raise e if testing_mode
|
|
131
|
+
|
|
132
|
+
logger.warn(<<~"MESSAGE")
|
|
133
|
+
An error occurred when evaluating a by-notifier ignore condition. #{e.class}: #{e.message}
|
|
134
|
+
#{e.backtrace.join("\n")}
|
|
135
|
+
MESSAGE
|
|
136
|
+
false
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def ignored_exception?(ignore_array, exception)
|
|
140
|
+
all_ignored_exceptions = (Array(ignored_exceptions) + Array(ignore_array)).map(&:to_s)
|
|
141
|
+
exception_ancestors = exception.singleton_class.ancestors.map(&:to_s)
|
|
142
|
+
!(all_ignored_exceptions & exception_ancestors).empty?
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def error_count(error_key)
|
|
146
|
+
error_grouping_service.error_count(error_key)
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
def save_error_count(error_key, count)
|
|
150
|
+
error_grouping_service.save_error_count(error_key, count)
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
def group_error!(exception, options)
|
|
154
|
+
error_grouping_service.group_error!(exception, options)
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
def send_notification?(exception, count)
|
|
158
|
+
error_grouping_service.send_notification?(exception, count)
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
protected
|
|
162
|
+
|
|
163
|
+
attr_reader :ignores, :by_notifier_ignores
|
|
164
|
+
|
|
165
|
+
private
|
|
166
|
+
|
|
167
|
+
def error_grouping_service
|
|
168
|
+
ErrorGrouping::Service.new(
|
|
169
|
+
cache: error_grouping_cache,
|
|
170
|
+
fallback_cache_store: fallback_cache_store,
|
|
171
|
+
period: error_grouping_period,
|
|
172
|
+
notification_trigger: notification_trigger,
|
|
173
|
+
logger: logger
|
|
174
|
+
)
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
def from_crawler(env, ignored_crawlers)
|
|
178
|
+
agent = env["HTTP_USER_AGENT"]
|
|
179
|
+
Array(ignored_crawlers).any? do |crawler|
|
|
180
|
+
agent =~ Regexp.new(crawler)
|
|
181
|
+
end
|
|
182
|
+
end
|
|
183
|
+
end
|
|
184
|
+
end
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "action_dispatch"
|
|
4
|
+
|
|
5
|
+
module Exceptify
|
|
6
|
+
class DatadogNotifier < BaseNotifier
|
|
7
|
+
attr_reader :client,
|
|
8
|
+
:default_options
|
|
9
|
+
|
|
10
|
+
def initialize(options)
|
|
11
|
+
super
|
|
12
|
+
@client = options[:client]
|
|
13
|
+
raise ArgumentError, "You must provide 'client' option" unless @client
|
|
14
|
+
|
|
15
|
+
@default_options = options
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def call(exception, options = {})
|
|
19
|
+
client.emit_event(
|
|
20
|
+
datadog_event(exception, options)
|
|
21
|
+
)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def datadog_event(exception, options = {})
|
|
25
|
+
DatadogExceptionEvent.new(
|
|
26
|
+
exception,
|
|
27
|
+
options.reverse_merge(default_options)
|
|
28
|
+
).event
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
class DatadogExceptionEvent
|
|
32
|
+
include Exceptify::BacktraceCleaner
|
|
33
|
+
|
|
34
|
+
MAX_TITLE_LENGTH = 120
|
|
35
|
+
MAX_VALUE_LENGTH = 300
|
|
36
|
+
MAX_BACKTRACE_SIZE = 3
|
|
37
|
+
ALERT_TYPE = "error"
|
|
38
|
+
|
|
39
|
+
attr_reader :exception,
|
|
40
|
+
:notification,
|
|
41
|
+
:options
|
|
42
|
+
|
|
43
|
+
def initialize(exception, options)
|
|
44
|
+
@notification = Notification.new(exception, options, backtrace_cleaner: self)
|
|
45
|
+
@exception = notification.exception
|
|
46
|
+
@options = options
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def request
|
|
50
|
+
@request ||= notification.request_context.request
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def controller
|
|
54
|
+
@controller ||= notification.controller
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def backtrace
|
|
58
|
+
@backtrace ||= notification.backtrace
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def tags
|
|
62
|
+
options[:tags] || []
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def title_prefix
|
|
66
|
+
options[:title_prefix] || ""
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def event
|
|
70
|
+
title = formatted_title
|
|
71
|
+
body = formatted_body
|
|
72
|
+
|
|
73
|
+
Dogapi::Event.new(
|
|
74
|
+
body,
|
|
75
|
+
msg_title: title,
|
|
76
|
+
alert_type: ALERT_TYPE,
|
|
77
|
+
tags: tags,
|
|
78
|
+
aggregation_key: [title]
|
|
79
|
+
)
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def formatted_title
|
|
83
|
+
title =
|
|
84
|
+
"#{title_prefix}#{controller_subtitle} (#{exception.class}) #{exception.message.inspect}"
|
|
85
|
+
|
|
86
|
+
truncate(title, MAX_TITLE_LENGTH)
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def formatted_body
|
|
90
|
+
text = []
|
|
91
|
+
|
|
92
|
+
text << "%%%"
|
|
93
|
+
text << formatted_request if request
|
|
94
|
+
text << formatted_session if request
|
|
95
|
+
text << formatted_backtrace
|
|
96
|
+
text << "%%%"
|
|
97
|
+
|
|
98
|
+
text.join("\n")
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def formatted_key_value(key, value)
|
|
102
|
+
"**#{key}:** #{value}"
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def formatted_request
|
|
106
|
+
text = []
|
|
107
|
+
text << "### **Request**"
|
|
108
|
+
text << formatted_key_value("URL", request.url)
|
|
109
|
+
text << formatted_key_value("HTTP Method", request.request_method)
|
|
110
|
+
text << formatted_key_value("IP Address", request.remote_ip)
|
|
111
|
+
text << formatted_key_value("Parameters", request.filtered_parameters.inspect)
|
|
112
|
+
text << formatted_key_value("Timestamp", notification.timestamp)
|
|
113
|
+
text << formatted_key_value("Server", notification.hostname)
|
|
114
|
+
text << formatted_key_value("Rails root", Rails.root) if defined?(Rails) && Rails.respond_to?(:root)
|
|
115
|
+
text << formatted_key_value("Process", Process.pid)
|
|
116
|
+
text << "___"
|
|
117
|
+
text.join("\n")
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def formatted_session
|
|
121
|
+
text = []
|
|
122
|
+
text << "### **Session**"
|
|
123
|
+
text << formatted_key_value("Data", request.session.to_hash)
|
|
124
|
+
text << "___"
|
|
125
|
+
text.join("\n")
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def formatted_backtrace
|
|
129
|
+
size = [backtrace.size, MAX_BACKTRACE_SIZE].min
|
|
130
|
+
|
|
131
|
+
text = []
|
|
132
|
+
text << "### **Backtrace**"
|
|
133
|
+
text << "````"
|
|
134
|
+
size.times { |i| text << backtrace[i] }
|
|
135
|
+
text << "````"
|
|
136
|
+
text << "___"
|
|
137
|
+
text.join("\n")
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def truncate(string, max)
|
|
141
|
+
(string.length > max) ? "#{string[0...max]}..." : string
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def inspect_object(object)
|
|
145
|
+
case object
|
|
146
|
+
when Hash, Array
|
|
147
|
+
truncate(object.inspect, MAX_VALUE_LENGTH)
|
|
148
|
+
else
|
|
149
|
+
object.to_s
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
private
|
|
154
|
+
|
|
155
|
+
def controller_subtitle
|
|
156
|
+
"#{controller.controller_name} #{controller.action_name}" if controller
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
end
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Exceptify
|
|
4
|
+
class Dispatcher
|
|
5
|
+
attr_reader :configuration
|
|
6
|
+
|
|
7
|
+
def initialize(configuration)
|
|
8
|
+
@configuration = configuration
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def notify_exception(exception, options = {}, &block)
|
|
12
|
+
options = options.dup
|
|
13
|
+
|
|
14
|
+
return false if configuration.ignored_exception?(options[:ignore_exceptions], exception)
|
|
15
|
+
return false if configuration.ignored?(exception, options)
|
|
16
|
+
|
|
17
|
+
if configuration.error_grouping
|
|
18
|
+
errors_count = configuration.group_error!(exception, options)
|
|
19
|
+
return false unless configuration.send_notification?(exception, errors_count)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
notification_fired = false
|
|
23
|
+
selected_notifiers = options.delete(:notifiers) || configuration.notifiers
|
|
24
|
+
[*selected_notifiers].each do |notifier|
|
|
25
|
+
unless configuration.notifier_ignored?(exception, options, notifier: notifier)
|
|
26
|
+
fire_notification(notifier, exception, options.dup, &block)
|
|
27
|
+
notification_fired = true
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
notification_fired
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
private
|
|
35
|
+
|
|
36
|
+
def fire_notification(notifier_name, exception, options, &block)
|
|
37
|
+
notifier = configuration.registered_notifier(notifier_name)
|
|
38
|
+
notifier.call(exception, options, &block)
|
|
39
|
+
rescue Exception => e # standard:disable Lint/RescueException
|
|
40
|
+
raise e if configuration.testing_mode
|
|
41
|
+
|
|
42
|
+
configuration.logger.warn(
|
|
43
|
+
"An error occurred when sending a notification using '#{notifier_name}' notifier." \
|
|
44
|
+
"#{e.class}: #{e.message}\n#{e.backtrace.join("\n")}"
|
|
45
|
+
)
|
|
46
|
+
false
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "active_support/core_ext/time"
|
|
4
|
+
require "action_mailer"
|
|
5
|
+
require "action_dispatch"
|
|
6
|
+
require "pp"
|
|
7
|
+
|
|
8
|
+
module Exceptify
|
|
9
|
+
class EmailNotifier < BaseNotifier
|
|
10
|
+
DEFAULT_OPTIONS = {
|
|
11
|
+
sender_address: %("Exceptify" <notifier@example.com>),
|
|
12
|
+
exception_recipients: [],
|
|
13
|
+
email_prefix: "[ERROR] ",
|
|
14
|
+
email_format: :text,
|
|
15
|
+
sections: %w[request session environment backtrace],
|
|
16
|
+
background_sections: %w[backtrace data],
|
|
17
|
+
verbose_subject: true,
|
|
18
|
+
normalize_subject: false,
|
|
19
|
+
include_controller_and_action_names_in_subject: true,
|
|
20
|
+
delivery_method: nil,
|
|
21
|
+
mailer_settings: nil,
|
|
22
|
+
email_headers: {},
|
|
23
|
+
mailer_parent: "ActionMailer::Base",
|
|
24
|
+
template_path: "exceptify",
|
|
25
|
+
deliver_with: nil
|
|
26
|
+
}.freeze
|
|
27
|
+
|
|
28
|
+
module Mailer
|
|
29
|
+
class MissingController
|
|
30
|
+
def method_missing(*args, &block)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def respond_to_missing?(*args)
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def self.extended(base)
|
|
38
|
+
base.class_eval do
|
|
39
|
+
send(:include, Exceptify::BacktraceCleaner)
|
|
40
|
+
|
|
41
|
+
# Append application view path to the Exceptify lookup context.
|
|
42
|
+
append_view_path "#{File.dirname(__FILE__)}/views"
|
|
43
|
+
|
|
44
|
+
def exceptify(env, exception, options = {}, default_options = {})
|
|
45
|
+
load_custom_views
|
|
46
|
+
|
|
47
|
+
@env = env
|
|
48
|
+
@exception = exception
|
|
49
|
+
|
|
50
|
+
env_options = env["exceptify.options"] || {}
|
|
51
|
+
@options = default_options.merge(env_options).merge(options)
|
|
52
|
+
|
|
53
|
+
@kontroller = env["action_controller.instance"] || MissingController.new
|
|
54
|
+
@request = ActionDispatch::Request.new(env)
|
|
55
|
+
@backtrace = exception.backtrace ? clean_backtrace(exception) : []
|
|
56
|
+
@timestamp = Time.current
|
|
57
|
+
@sections = @options[:sections]
|
|
58
|
+
@data = (env["exceptify.exception_data"] || {}).merge(options[:data] || {})
|
|
59
|
+
@sections += %w[data] unless @data.empty?
|
|
60
|
+
|
|
61
|
+
compose_email
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def background_exceptify(exception, options = {}, default_options = {})
|
|
65
|
+
load_custom_views
|
|
66
|
+
|
|
67
|
+
@exception = exception
|
|
68
|
+
@options = default_options.merge(options).symbolize_keys
|
|
69
|
+
@backtrace = exception.backtrace || []
|
|
70
|
+
@timestamp = Time.current
|
|
71
|
+
@sections = @options[:background_sections]
|
|
72
|
+
@data = options[:data] || {}
|
|
73
|
+
@env = @kontroller = nil
|
|
74
|
+
|
|
75
|
+
compose_email
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
private
|
|
79
|
+
|
|
80
|
+
def compose_subject
|
|
81
|
+
subject = @options[:email_prefix].to_s.dup
|
|
82
|
+
subject << "(#{@options[:accumulated_errors_count]} times)" if @options[:accumulated_errors_count].to_i > 1
|
|
83
|
+
subject << "#{@kontroller.controller_name}##{@kontroller.action_name}" if include_controller?
|
|
84
|
+
subject << " (#{@exception.class})"
|
|
85
|
+
subject << " #{@exception.message.inspect}" if @options[:verbose_subject]
|
|
86
|
+
subject = EmailNotifier.normalize_digits(subject) if @options[:normalize_subject]
|
|
87
|
+
(subject.length > 120) ? subject[0...120] + "..." : subject
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def include_controller?
|
|
91
|
+
@kontroller && @options[:include_controller_and_action_names_in_subject]
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def set_data_variables
|
|
95
|
+
@data.each do |name, value|
|
|
96
|
+
instance_variable_set(:"@#{name}", value)
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
helper_method :inspect_object
|
|
101
|
+
|
|
102
|
+
def truncate(string, max)
|
|
103
|
+
(string.length > max) ? "#{string[0...max]}..." : string
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def inspect_object(object)
|
|
107
|
+
case object
|
|
108
|
+
when Hash, Array
|
|
109
|
+
truncate(object.inspect, 300)
|
|
110
|
+
else
|
|
111
|
+
object.to_s
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
helper_method :safe_encode
|
|
116
|
+
|
|
117
|
+
def safe_encode(value)
|
|
118
|
+
value.encode("utf-8", invalid: :replace, undef: :replace, replace: "_")
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def html_mail?
|
|
122
|
+
@options[:email_format] == :html
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def compose_email
|
|
126
|
+
set_data_variables
|
|
127
|
+
subject = compose_subject
|
|
128
|
+
name = @env.nil? ? "background_exceptify" : "exceptify"
|
|
129
|
+
exception_recipients = maybe_call(@options[:exception_recipients])
|
|
130
|
+
|
|
131
|
+
headers = {
|
|
132
|
+
delivery_method: @options[:delivery_method],
|
|
133
|
+
to: exception_recipients,
|
|
134
|
+
from: @options[:sender_address],
|
|
135
|
+
subject: subject,
|
|
136
|
+
template_name: name
|
|
137
|
+
}.merge(@options[:email_headers])
|
|
138
|
+
|
|
139
|
+
mail = mail(headers) do |format|
|
|
140
|
+
format.text
|
|
141
|
+
format.html if html_mail?
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
mail.delivery_method.settings.merge!(@options[:mailer_settings]) if @options[:mailer_settings]
|
|
145
|
+
|
|
146
|
+
mail
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
def load_custom_views
|
|
150
|
+
return unless defined?(Rails) && Rails.respond_to?(:root)
|
|
151
|
+
|
|
152
|
+
prepend_view_path Rails.root.nil? ? "app/views" : "#{Rails.root}/app/views"
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
def maybe_call(maybe_proc)
|
|
156
|
+
maybe_proc.respond_to?(:call) ? maybe_proc.call : maybe_proc
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
def initialize(options)
|
|
163
|
+
super
|
|
164
|
+
|
|
165
|
+
delivery_method = options[:delivery_method] || :smtp
|
|
166
|
+
mailer_settings_key = :"#{delivery_method}_settings"
|
|
167
|
+
options[:mailer_settings] = options.delete(mailer_settings_key)
|
|
168
|
+
|
|
169
|
+
@base_options = DEFAULT_OPTIONS.merge(options)
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
def call(exception, options = {})
|
|
173
|
+
message = create_email(exception, options)
|
|
174
|
+
|
|
175
|
+
message.send(base_options[:deliver_with] || default_deliver_with(message))
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
def create_email(exception, options = {})
|
|
179
|
+
env = options[:env]
|
|
180
|
+
|
|
181
|
+
send_notice(exception, options, nil, base_options) do |_, default_opts|
|
|
182
|
+
if env.nil?
|
|
183
|
+
mailer.background_exceptify(exception, options, default_opts)
|
|
184
|
+
else
|
|
185
|
+
mailer.exceptify(env, exception, options, default_opts)
|
|
186
|
+
end
|
|
187
|
+
end
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
def self.normalize_digits(string)
|
|
191
|
+
string.gsub(/[0-9]+/, "N")
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
private
|
|
195
|
+
|
|
196
|
+
def mailer
|
|
197
|
+
@mailer ||= Class.new(base_options[:mailer_parent].constantize).tap do |mailer|
|
|
198
|
+
mailer.extend(EmailNotifier::Mailer)
|
|
199
|
+
mailer.mailer_name = base_options[:template_path]
|
|
200
|
+
end
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
def default_deliver_with(message)
|
|
204
|
+
# FIXME: use `if Gem::Version.new(ActionMailer::VERSION::STRING) < Gem::Version.new('4.1')`
|
|
205
|
+
message.respond_to?(:deliver_now) ? :deliver_now : :deliver
|
|
206
|
+
end
|
|
207
|
+
end
|
|
208
|
+
end
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Exceptify
|
|
4
|
+
module BacktraceCleaner
|
|
5
|
+
def clean_backtrace(exception)
|
|
6
|
+
if defined?(Rails) && Rails.respond_to?(:backtrace_cleaner)
|
|
7
|
+
Rails.backtrace_cleaner.send(:filter, exception.backtrace)
|
|
8
|
+
else
|
|
9
|
+
exception.backtrace
|
|
10
|
+
end
|
|
11
|
+
end
|
|
12
|
+
end
|
|
13
|
+
end
|