exception_handling 1.2.1 → 2.2.1

Sign up to get free protection for your applications and to get access to all the features.
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