exception_handling 1.2.1 → 2.2.1

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.
Files changed (32) hide show
  1. checksums.yaml +5 -5
  2. data/.ruby-version +1 -1
  3. data/Gemfile +17 -0
  4. data/Gemfile.lock +142 -102
  5. data/README.md +11 -2
  6. data/config/exception_filters.yml +2 -0
  7. data/exception_handling.gemspec +6 -10
  8. data/lib/exception_handling.rb +222 -313
  9. data/lib/exception_handling/exception_catalog.rb +8 -6
  10. data/lib/exception_handling/exception_description.rb +8 -6
  11. data/lib/exception_handling/exception_info.rb +272 -0
  12. data/lib/exception_handling/honeybadger_callbacks.rb +42 -0
  13. data/lib/exception_handling/log_stub_error.rb +18 -3
  14. data/lib/exception_handling/mailer.rb +14 -0
  15. data/lib/exception_handling/methods.rb +26 -8
  16. data/lib/exception_handling/testing.rb +2 -2
  17. data/lib/exception_handling/version.rb +3 -1
  18. data/test/helpers/controller_helpers.rb +27 -0
  19. data/test/helpers/exception_helpers.rb +11 -0
  20. data/test/test_helper.rb +42 -19
  21. data/test/unit/exception_handling/exception_catalog_test.rb +19 -0
  22. data/test/unit/exception_handling/exception_description_test.rb +12 -1
  23. data/test/unit/exception_handling/exception_info_test.rb +501 -0
  24. data/test/unit/exception_handling/honeybadger_callbacks_test.rb +85 -0
  25. data/test/unit/exception_handling/log_error_stub_test.rb +26 -3
  26. data/test/unit/exception_handling/mailer_test.rb +39 -14
  27. data/test/unit/exception_handling/methods_test.rb +40 -18
  28. data/test/unit/exception_handling_test.rb +947 -539
  29. data/views/exception_handling/mailer/escalate_custom.html.erb +17 -0
  30. data/views/exception_handling/mailer/exception_notification.html.erb +1 -1
  31. data/views/exception_handling/mailer/log_parser_exception_notification.html.erb +1 -1
  32. metadata +28 -60
@@ -15,14 +15,16 @@ module ExceptionHandling
15
15
  private
16
16
 
17
17
  def refresh_filters
18
- mtime = last_modified_time
19
- if @filters_last_modified_time.nil? || mtime != @filters_last_modified_time
20
- ExceptionHandling.logger.info("Reloading filter list from: #{@filter_path}. Last loaded time: #{@filters_last_modified_time}. Last modified time: #{mtime}")
21
- load_file
18
+ if (mtime = last_modified_time)
19
+ if @filters_last_modified_time.nil? || mtime != @filters_last_modified_time
20
+ ExceptionHandling.logger.info("Reloading filter list from: #{@filter_path}. Last loaded time: #{@filters_last_modified_time}. Last modified time: #{mtime}")
21
+ load_file
22
+ end
22
23
  end
23
24
 
24
25
  rescue => ex # any exceptions
25
- ExceptionHandling::log_error(ex, "ExceptionRegexes::refresh_filters: #{@filter_path}", nil, true)
26
+ # DO NOT CALL ExceptionHandling.log_error because this method is called from that. It can loop and cause mayhem.
27
+ ExceptionHandling.write_exception_to_log(ex, "ExceptionCatalog#refresh_filters: #{@filter_path}", Time.now.to_i)
26
28
  end
27
29
 
28
30
  def load_file
@@ -34,7 +36,7 @@ module ExceptionHandling
34
36
  end
35
37
 
36
38
  def last_modified_time
37
- File.mtime(@filter_path)
39
+ @filter_path && File.mtime(@filter_path)
38
40
  end
39
41
  end
40
42
  end
@@ -3,13 +3,14 @@ module ExceptionHandling
3
3
  MATCH_SECTIONS = [:error, :request, :session, :environment, :backtrace, :event_response]
4
4
 
5
5
  CONFIGURATION_SECTIONS = {
6
- send_email: false, # should email be sent?
7
- send_metric: true, # should the metric be sent.
8
- metric_name: nil, # Will be derived from section name if not passed
9
- notes: nil # Will be included in exception email if set, used to keep notes and relevant links
6
+ send_email: false, # should email be sent?
7
+ send_to_honeybadger: false, # should be sent to honeybadger?
8
+ send_metric: true, # should the metric be sent.
9
+ metric_name: nil, # Will be derived from section name if not passed
10
+ notes: nil # Will be included in exception email if set, used to keep notes and relevant links
10
11
  }
11
12
 
12
- attr_reader :filter_name, :send_email, :send_metric, :metric_name, :notes
13
+ attr_reader :filter_name, :send_email, :send_to_honeybadger, :send_metric, :metric_name, :notes
13
14
 
14
15
  def initialize(filter_name, configuration)
15
16
  @filter_name = filter_name
@@ -19,13 +20,14 @@ module ExceptionHandling
19
20
 
20
21
  @configuration = CONFIGURATION_SECTIONS.merge(configuration)
21
22
  @send_email = @configuration[:send_email]
23
+ @send_to_honeybadger = @configuration[:send_to_honeybadger]
22
24
  @send_metric = @configuration[:send_metric]
23
25
  @metric_name = (@configuration[:metric_name] || @filter_name ).to_s.gsub(" ","_")
24
26
  @notes = @configuration[:notes]
25
27
 
26
28
  regex_config = @configuration.reject { |k,v| k.in?(CONFIGURATION_SECTIONS.keys) || v.blank? }
27
29
 
28
- @regexes = Hash[regex_config.map { |section, regex| [section, Regexp.new(regex, 'i') ] }]
30
+ @regexes = Hash[regex_config.map { |section, regex| [section, Regexp.new(regex, Regexp::IGNORECASE | Regexp::MULTILINE) ] }]
29
31
 
30
32
  !@regexes.empty? or raise ArgumentError, "Filter #{filter_name} has all blank regexes: #{configuration.inspect}"
31
33
  end
@@ -0,0 +1,272 @@
1
+ module ExceptionHandling
2
+ class ExceptionInfo
3
+
4
+ ENVIRONMENT_WHITELIST = [
5
+ /^HTTP_/,
6
+ /^QUERY_/,
7
+ /^REQUEST_/,
8
+ /^SERVER_/
9
+ ]
10
+
11
+ ENVIRONMENT_OMIT =(
12
+ <<EOF
13
+ CONTENT_TYPE: application/x-www-form-urlencoded
14
+ GATEWAY_INTERFACE: CGI/1.2
15
+ HTTP_ACCEPT: */*
16
+ HTTP_ACCEPT: */*, text/javascript, text/html, application/xml, text/xml, */*
17
+ HTTP_ACCEPT_CHARSET: ISO-8859-1,utf-8;q=0.7,*;q=0.7
18
+ HTTP_ACCEPT_ENCODING: gzip, deflate
19
+ HTTP_ACCEPT_ENCODING: gzip,deflate
20
+ HTTP_ACCEPT_LANGUAGE: en-us
21
+ HTTP_CACHE_CONTROL: no-cache
22
+ HTTP_CONNECTION: Keep-Alive
23
+ HTTP_HOST: www.invoca.com
24
+ HTTP_MAX_FORWARDS: 10
25
+ HTTP_UA_CPU: x86
26
+ HTTP_VERSION: HTTP/1.1
27
+ HTTP_X_FORWARDED_HOST: www.invoca.com
28
+ HTTP_X_FORWARDED_SERVER: www2.invoca.com
29
+ HTTP_X_REQUESTED_WITH: XMLHttpRequest
30
+ LANG:
31
+ PATH: /sbin:/usr/sbin:/bin:/usr/bin
32
+ PWD: /
33
+ RAILS_ENV: production
34
+ RAW_POST_DATA: id=500
35
+ REMOTE_ADDR: 10.251.34.225
36
+ SCRIPT_NAME: /
37
+ SERVER_NAME: www.invoca.com
38
+ SERVER_PORT: 80
39
+ SERVER_PROTOCOL: HTTP/1.1
40
+ SERVER_SOFTWARE: Mongrel 1.1.4
41
+ SHLVL: 1
42
+ TERM: linux
43
+ TERM: xterm-color
44
+ _: /usr/bin/mongrel_cluster_ctl
45
+ EOF
46
+ ).split("\n")
47
+
48
+ SECTIONS = [:request, :session, :environment, :backtrace, :event_response]
49
+ HONEYBADGER_CONTEXT_SECTIONS = [:timestamp, :error_class, :exception_context, :server, :scm_revision, :notes, :user_details, :request, :session, :environment, :backtrace, :event_response]
50
+
51
+ attr_reader :exception, :controller, :exception_context, :timestamp
52
+
53
+ def initialize(exception, exception_context, timestamp, controller = nil, data_callback = nil)
54
+ @exception = exception
55
+ @exception_context = exception_context
56
+ @timestamp = timestamp
57
+ @controller = controller || controller_from_context(exception_context)
58
+ @data_callback = data_callback
59
+ end
60
+
61
+ def data
62
+ @data ||= exception_to_data
63
+ end
64
+
65
+ def enhanced_data
66
+ @enhanced_data ||= exception_to_enhanced_data
67
+ end
68
+
69
+ def exception_description
70
+ @exception_description ||= ExceptionHandling.exception_catalog.find(enhanced_data)
71
+ end
72
+
73
+ def send_to_honeybadger?
74
+ ExceptionHandling.honeybadger? && (!exception_description || exception_description.send_to_honeybadger)
75
+ end
76
+
77
+ def honeybadger_context_data
78
+ @honeybadger_context_data ||= enhanced_data_to_honeybadger_context
79
+ end
80
+
81
+ private
82
+
83
+ def controller_from_context(exception_context)
84
+ exception_context.is_a?(Hash) ? exception_context["action_controller.instance"] : nil
85
+ end
86
+
87
+ def exception_to_data
88
+ exception_message = @exception.message.to_s
89
+ data = ActiveSupport::HashWithIndifferentAccess.new
90
+ data[:error_class] = @exception.class.name
91
+ data[:error_string]= "#{data[:error_class]}: #{ExceptionHandling.encode_utf8(exception_message)}"
92
+ data[:timestamp] = @timestamp
93
+ data[:backtrace] = ExceptionHandling.clean_backtrace(@exception)
94
+ if @exception_context && @exception_context.is_a?(Hash)
95
+ # if we are a hash, then we got called from the DebugExceptions rack middleware filter
96
+ # and we need to do some things different to get the info we want
97
+ data[:error] = "#{data[:error_class]}: #{ExceptionHandling.encode_utf8(exception_message)}"
98
+ data[:session] = @exception_context['rack.session']
99
+ data[:environment] = @exception_context
100
+ else
101
+ data[:error] = "#{data[:error_string]}#{': ' + @exception_context.to_s unless @exception_context.blank?}"
102
+ data[:environment] = { message: @exception_context }
103
+ end
104
+ data
105
+ end
106
+
107
+ def exception_to_enhanced_data
108
+ enhanced_data = exception_to_data
109
+ extract_and_merge_controller_data(enhanced_data)
110
+ customize_from_data_callback(enhanced_data)
111
+ enhance_exception_data(enhanced_data)
112
+ normalize_exception_data(enhanced_data)
113
+ clean_exception_data(enhanced_data)
114
+ stringify_sections(enhanced_data)
115
+
116
+ description = ExceptionHandling.exception_catalog.find(enhanced_data)
117
+ description ? ActiveSupport::HashWithIndifferentAccess.new(description.exception_data.merge(enhanced_data)) : enhanced_data
118
+ end
119
+
120
+ def enhance_exception_data(data)
121
+ return if ! ExceptionHandling.custom_data_hook
122
+ begin
123
+ ExceptionHandling.custom_data_hook.call(data)
124
+ rescue Exception => ex
125
+ # can't call log_error here or we will blow the call stack
126
+ traces = ex.backtrace.join("\n")
127
+ ExceptionHandling.log_info("Unable to execute custom custom_data_hook callback. #{ExceptionHandling.encode_utf8(ex.message.to_s)} #{traces}\n")
128
+ end
129
+ end
130
+
131
+ def normalize_exception_data(data)
132
+ if data[:location].nil?
133
+ data[:location] = {}
134
+ if data[:request] && data[:request].key?(:params)
135
+ data[:location][:controller] = data[:request][:params]['controller']
136
+ data[:location][:action] = data[:request][:params]['action']
137
+ end
138
+ end
139
+ if data[:backtrace] && data[:backtrace].first
140
+ first_line = data[:backtrace].first
141
+
142
+ # template exceptions have the line number and filename as the first element in backtrace
143
+ if matched = first_line.match( /on line #(\d*) of (.*)/i )
144
+ backtrace_hash = {}
145
+ backtrace_hash[:line] = matched[1]
146
+ backtrace_hash[:file] = matched[2]
147
+ else
148
+ backtrace_hash = Hash[* [:file, :line].zip( first_line.split( ':' )[0..1]).flatten ]
149
+ end
150
+
151
+ data[:location].merge!( backtrace_hash )
152
+ end
153
+ end
154
+
155
+ def clean_exception_data( data )
156
+ if (as_array = data[:backtrace].to_a).size == 1
157
+ data[:backtrace] = as_array.first.to_s.split(/\n\s*/)
158
+ end
159
+
160
+ if data[:request].is_a?(Hash) && data[:request][:params].is_a?(Hash)
161
+ data[:request][:params] = deep_clean_hash(data[:request][:params])
162
+ end
163
+
164
+ if data[:environment].is_a?(Hash)
165
+ data[:environment] = clean_environment(data[:environment])
166
+ end
167
+ end
168
+
169
+ def clean_environment(env)
170
+ Hash[ env.map do |k, v|
171
+ [k, v] if !"#{k}: #{v}".in?(ENVIRONMENT_OMIT) && ENVIRONMENT_WHITELIST.any? { |regex| k =~ regex }
172
+ end.compact ]
173
+ end
174
+
175
+ def deep_clean_hash(hash)
176
+ hash.is_a?(Hash) or return hash
177
+
178
+ hash.build_hash do |k, v|
179
+ value = v.is_a?(Hash) ? deep_clean_hash(v) : filter_sensitive_value(k, v)
180
+ [k, value]
181
+ end
182
+ end
183
+
184
+ def filter_sensitive_value(key, value)
185
+ if key =~ /(password|oauth_token)/
186
+ "[FILTERED]"
187
+ elsif key == "rack.request.form_vars" && value.respond_to?(:match) && (captured_matches = value.match(/(.*)(password=)([^&]+)(.*)/)&.captures)
188
+ [*captured_matches[0..1], "[FILTERED]", *captured_matches[3..-1]].join
189
+ else
190
+ value
191
+ end
192
+ end
193
+
194
+ #
195
+ # Pull certain fields out of the controller and add to the data hash.
196
+ #
197
+ def extract_and_merge_controller_data(data)
198
+ if @controller
199
+ data[:request] = {
200
+ params: @controller.request.parameters.to_hash,
201
+ rails_root: defined?(Rails) && defined?(Rails.root) ? Rails.root : "Rails.root not defined. Is this a test environment?",
202
+ url: @controller.complete_request_uri
203
+ }
204
+ data[:environment].merge!(@controller.request.env.to_hash)
205
+
206
+ @controller.session[:fault_in_session]
207
+ data[:session] = {
208
+ key: @controller.request.session_options[:id],
209
+ data: @controller.session.to_hash
210
+ }
211
+ end
212
+ end
213
+
214
+ def customize_from_data_callback(data)
215
+ if @data_callback
216
+ # the expectation is that if the caller passed a block then they will be
217
+ # doing their own merge of hash values into data
218
+ begin
219
+ @data_callback.call(data)
220
+ rescue Exception => ex
221
+ data.merge!(environment: "Exception in yield: #{ex.class}:#{ex}")
222
+ end
223
+ end
224
+ end
225
+
226
+ def stringify_sections(data)
227
+ SECTIONS.each { |section| add_to_s(data[section]) if data[section].is_a?(Hash) }
228
+ end
229
+
230
+ def unstringify_sections(data)
231
+ SECTIONS.each do |section|
232
+ if data[section].is_a?(Hash) && data[section].key?(:to_s)
233
+ data[section] = data[section].dup
234
+ data[section].delete(:to_s)
235
+ end
236
+ end
237
+ end
238
+
239
+ def add_to_s( data_section )
240
+ data_section[:to_s] = dump_hash( data_section )
241
+ end
242
+
243
+ def dump_hash( h, indent_level = 0 )
244
+ result = ""
245
+ h.sort { |a, b| a.to_s <=> b.to_s }.each do |key, value|
246
+ result << ' ' * (2 * indent_level)
247
+ result << "#{key}:"
248
+ case value
249
+ when Hash
250
+ result << "\n" << dump_hash( value, indent_level + 1 )
251
+ else
252
+ result << " #{value}\n"
253
+ end
254
+ end unless h.nil?
255
+ result
256
+ end
257
+
258
+ def enhanced_data_to_honeybadger_context
259
+ data = enhanced_data.dup
260
+ data[:server] = ExceptionHandling.server_name
261
+ data[:exception_context] = deep_clean_hash(@exception_context) if @exception_context.present?
262
+ unstringify_sections(data)
263
+ context_data = HONEYBADGER_CONTEXT_SECTIONS.reduce({}) do |context, section|
264
+ if data[section].present?
265
+ context[section] = data[section]
266
+ end
267
+ context
268
+ end
269
+ context_data
270
+ end
271
+ end
272
+ end
@@ -0,0 +1,42 @@
1
+ module ExceptionHandling
2
+ module HoneybadgerCallbacks
3
+ def self.register_callbacks
4
+ if ExceptionHandling.honeybadger?
5
+ Honeybadger.local_variable_filter(&method(:local_variable_filter))
6
+ end
7
+ end
8
+
9
+ private
10
+
11
+ def self.local_variable_filter(symbol, object, filter_keys)
12
+ case object
13
+ # Honeybadger will filter these data types for us
14
+ when String, Hash, Array, Set, Numeric, TrueClass, FalseClass, NilClass
15
+ object
16
+ else # handle other Ruby objects, intended for POROs
17
+ inspection_output = object.inspect
18
+ if contains_filter_key?(filter_keys, inspection_output)
19
+ filtered_object(object)
20
+ else
21
+ inspection_output
22
+ end
23
+ end
24
+ end
25
+
26
+ def self.contains_filter_key?(filter_keys, string)
27
+ filter_keys._?.any? { |key| string.include?(key) }
28
+ end
29
+
30
+ def self.filtered_object(object)
31
+ # make the output look similar to inspect
32
+ # use [FILTERED], just like honeybadger does
33
+ if object.respond_to?(:to_pk)
34
+ "#<#{object.class.name} @pk=#{object.to_pk}, [FILTERED]>"
35
+ elsif object.respond_to?(:id)
36
+ "#<#{object.class.name} @id=#{object.id}, [FILTERED]>"
37
+ else
38
+ "#<#{object.class.name} [FILTERED]>"
39
+ end
40
+ end
41
+ end
42
+ end
@@ -11,16 +11,31 @@ module LogErrorStub
11
11
  stub_log_error unless respond_to?(:dont_stub_log_error) && dont_stub_log_error
12
12
  end
13
13
 
14
+ # if used in Minitest::Test this should be called in `before teardown`
15
+ # if used in Test::Unit this should be called as the first line of `teardown`
14
16
  def teardown_log_error_stub
15
17
  ExceptionHandling.stub_handler = nil
16
18
  return unless @exception_whitelist
17
- @exception_whitelist.each do |item|
18
- add_failure("log_error expected #{item[1][:expected]} times with pattern: '#{item[0].is_a?(Regexp) ? item[0].source : item[0]}' #{item[1][:count]} found #{item[1][:found]}") unless item[1][:expected] == item[1][:found]
19
+
20
+ @exception_whitelist.each do |pattern, match|
21
+ unless match[:expected] == match[:found]
22
+ message = "log_error expected #{match[:expected]} times with pattern: '#{pattern.is_a?(Regexp) ? pattern.source : pattern}' found #{match[:found]}"
23
+
24
+ if is_mini_test?
25
+ flunk(message)
26
+ else
27
+ add_failure(message)
28
+ end
29
+ end
19
30
  end
20
31
  end
21
32
 
22
33
  attr_accessor :exception_whitelist
23
34
 
35
+ # for overriding when testing this module
36
+ def is_mini_test?
37
+ defined?(Minitest::Test) && self.is_a?(Minitest::Test)
38
+ end
24
39
  #
25
40
  # Call this function in your functional tests - usually first line after a "should" statement
26
41
  # once called, you can then call expects_exception
@@ -81,4 +96,4 @@ module LogErrorStub
81
96
  "------")
82
97
  end
83
98
 
84
- end
99
+ end
@@ -48,6 +48,20 @@ module ExceptionHandling
48
48
  :subject => subject)
49
49
  end
50
50
 
51
+ def escalate_custom(summary, data, recipients)
52
+ subject = "#{email_environment} Escalation: #{summary}"
53
+ from = sender_address.gsub('xception', 'scalation')
54
+ recipients = recipients
55
+
56
+ @summary = summary
57
+ @server = ExceptionHandling.server_name
58
+ @cleaned_data = data
59
+
60
+ mail(:from => from,
61
+ :to => recipients,
62
+ :subject => subject)
63
+ end
64
+
51
65
  def log_parser_exception_notification( cleaned_data, key )
52
66
  if cleaned_data.is_a?(Hash)
53
67
  cleaned_data = cleaned_data.symbolize_keys
@@ -1,5 +1,8 @@
1
+ require 'active_support/concern'
2
+
1
3
  module ExceptionHandling
2
4
  module Methods # included on models and controllers
5
+ extend ActiveSupport::Concern
3
6
 
4
7
  protected
5
8
 
@@ -13,7 +16,7 @@ module ExceptionHandling
13
16
  end
14
17
 
15
18
  def log_warning(message)
16
- log_error(Warning.new(message))
19
+ ExceptionHandling.log_warning(message)
17
20
  end
18
21
 
19
22
  def log_info(message)
@@ -57,23 +60,38 @@ module ExceptionHandling
57
60
  end
58
61
  end
59
62
 
60
- # Store aside the current controller when included
61
- LONG_REQUEST_SECONDS = (defined?(Rails) && Rails.env == 'test' ? 300 : 30)
63
+ def long_controller_action_timeout
64
+ if defined?(Rails) && Rails.respond_to?(:env) && Rails.env == 'test'
65
+ 300
66
+ else
67
+ 30
68
+ end
69
+ end
70
+
62
71
  def set_current_controller
63
72
  ExceptionHandling.current_controller = self
64
73
  result = nil
65
74
  time = Benchmark.measure do
66
75
  result = yield
67
76
  end
68
- name = " in #{controller_name}::#{action_name}" rescue " "
69
- log_error( "Long controller action detected#{name} %.4fs " % time.real ) if time.real > LONG_REQUEST_SECONDS && !['development', 'test'].include?(ExceptionHandling.email_environment)
77
+ if time.real > self.long_controller_action_timeout && !['development', 'test'].include?(ExceptionHandling.email_environment)
78
+ name = " in #{controller_name}::#{action_name}" rescue " "
79
+ log_error( "Long controller action detected#{name} %.4fs " % time.real )
80
+ end
70
81
  result
71
82
  ensure
72
83
  ExceptionHandling.current_controller = nil
73
84
  end
74
85
 
75
- def self.included( controller )
76
- controller.around_filter :set_current_controller if controller.respond_to? :around_filter
86
+ included do
87
+ around_filter :set_current_controller if respond_to? :around_filter
88
+ end
89
+
90
+ class_methods do
91
+ def set_long_controller_action_timeout(timeout)
92
+ define_method(:long_controller_action_timeout) { timeout }
93
+ protected :long_controller_action_timeout
94
+ end
77
95
  end
78
96
  end
79
- end
97
+ end