diff --git a/Gemfile b/Gemfile index 722ec64..c7ad7b8 100644 --- a/Gemfile +++ b/Gemfile @@ -13,4 +13,5 @@ group :development do # DEVELOPMENT gem 'jeweler' + gem 'binding_of_caller', :platforms => [:mri_19, :mri_20] end diff --git a/Gemfile.lock b/Gemfile.lock index 6353c1e..59e17ec 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,7 +1,10 @@ GEM remote: https://rubygems.org/ specs: - diff-lcs (1.2.2) + binding_of_caller (0.7.1) + debug_inspector (>= 0.0.1) + debug_inspector (0.0.2) + diff-lcs (1.2.4) fakefs (0.4.2) git (1.2.5) jeweler (1.8.4) @@ -9,12 +12,12 @@ GEM git (>= 1.2.5) rake rdoc - json (1.7.7) - json (1.7.7-java) + json (1.8.0) + json (1.8.0-java) rake (10.0.4) rdoc (4.0.1) json (~> 1.4) - redcarpet (2.2.2) + redcarpet (2.3.0) rspec (2.13.0) rspec-core (~> 2.13.0) rspec-expectations (~> 2.13.0) @@ -23,13 +26,14 @@ GEM rspec-expectations (2.13.0) diff-lcs (>= 1.1.3, < 2.0) rspec-mocks (2.13.1) - yard (0.8.5.2) + yard (0.8.6.1) PLATFORMS java ruby DEPENDENCIES + binding_of_caller fakefs jeweler json diff --git a/README.md b/README.md index 4e82882..5e3aec1 100644 --- a/README.md +++ b/README.md @@ -120,6 +120,24 @@ end If monkey-patching doesn't appeal to you, then don't load `squash/ruby/exception_additions`; it's not required for the client to work. +#### Recording Local Variables + +The Squash Ruby gem can be configured to read and record the values of local +variables at each stack frame, and transmit this information back to the +website. The website can display this information contextually to aid developers +in troubleshooting an exception. + +In order to use this functionality, you'll need to do the following: + +* Include the [binding_of_caller](https://github.com/banister/binding_of_caller) + gem in your project. (This gem is only compatible with MRI 1.9, MRI 2.0, and + Rubinius.) +* Add `require 'squash/ruby/exception_additions'` in your project if it's not + there already. + +Note that using this feature will impact performance when recording and +transmitting exception data to Squash. + #### Ignoring Exceptions You can ignore certain exceptions within a block of code if those exceptions are diff --git a/lib/squash/ruby.rb b/lib/squash/ruby.rb index c736994..c95c039 100644 --- a/lib/squash/ruby.rb +++ b/lib/squash/ruby.rb @@ -22,6 +22,11 @@ rescue LoadError # optional end +begin + require 'binding_of_caller' +rescue LoadError + # optional +end # Container for methods relating to notifying Squash of exceptions. @@ -442,11 +447,7 @@ def self.exception_info_hash(exception, occurred, user_data, parents) environment_data.merge(top_level_user_data).merge( 'class_name' => exception.class.to_s, 'message' => exception.to_s, - 'backtraces' => [{ - 'name' => "Active Thread/Fiber", - 'faulted' => true, - 'backtrace' => prepare_backtrace(exception.backtrace) - }], + 'backtraces' => [prepare_backtrace(exception)], 'occurred_at' => occurred, 'revision' => current_revision, @@ -460,116 +461,174 @@ def self.exception_info_hash(exception, occurred, user_data, parents) 'parent_exceptions' => parents.nil? ? nil : parents.map do |parent| {'class_name' => parent.class.to_s, 'message' => parent.to_s, - 'backtraces' => [{ - 'name' => "Active Thread/Fiber", - 'faulted' => true, - 'backtrace' => prepare_backtrace(parent.backtrace) - }], + 'backtraces' => [prepare_backtrace(parent)], 'association' => 'original_exception', 'ivars' => instance_variable_hash(parent)} end ) end - def self.prepare_backtrace(bt) + def self.prepare_backtrace(exception, name='Active Thread/Fiber', faulted=true) if defined?(JRuby) - bt.map(&:strip).map do |element| - if element =~ /^((?:[a-z0-9_$]+\.)*(?:[a-z0-9_$]+))\.(\w+)\((\w+.java):(\d+)\)$/i - # special JRuby backtrace element of the form "org.jruby.RubyHash$27.visit(RubyHash.java:1646)" - { - 'type' => 'obfuscated', - 'file' => $3, - 'line' => $4.to_i, - 'symbol' => $2, - 'class' => $1 - } - elsif element =~ /^(.+?)\.(\w+)\(Native Method\)$/ - { - 'type' => 'java_native', - 'symbol' => $2, - 'class' => $1 - } - elsif element =~ /^rubyjit[$.](.+?)\$\$(\w+?[?!]?)_[0-9A-F]{40}.+?__(?:file|ensure)__\.call\(.+\)$/ - { - 'type' => 'jruby_block', - 'class' => $1, - 'symbol' => $2 - } - elsif element =~ /^rubyjit[$.](.+?)\$\$(\w+?[?!]?)_[0-9A-F]{40}.+?__(?:file|ensure)__\((.+?):(\d+)\)$/ - { - 'file' => $3, - 'line' => $4.to_i, - 'symbol' => "#{$1}##{$2}" - } - elsif element =~ /^.+\.call\(.+?(\w+)\.gen\)$/ - { - 'type' => 'asm_invoker', - 'file' => $1 + '.gen' - } - elsif element =~ /^rubyjit[$.](.+?)\$\$(\w+?[?!]?)_[0-9A-F]{40}.+?__(?:file|ensure)__\((.+?)\)$/ - { - 'file' => $3, - 'type' => 'jruby_noline', - 'symbol' => "#{$1}##{$2}" - } + prepare_jruby_backtrace exception.backtrace, name, faulted + elsif exception.respond_to?(:_squash_bindings_stack) + prepare_advanced_mri_backtrace exception._squash_bindings_stack, name, faulted + else + prepare_mri_backtrace exception.backtrace, name, faulted + end + end + + def self.prepare_mri_backtrace(bt, name, faulted) + trace = bt.map do |element| + file, line, method = decode_basic_backtrace_line(element) + + { + 'file' => file, + 'line' => line, + 'symbol' => method + } + end + + { + 'name' => name, + 'faulted' => faulted, + 'backtrace' => trace + } + end + + def self.decode_basic_backtrace_line(element) + file, line, method = element.split(':') + line = line.to_i + line = nil if line < 1 + + file.slice! 0, configuration(:project_root).length + 1 if file[0, configuration(:project_root).length + 1] == configuration(:project_root) + '/' + + if method =~ /^in `(.+)'$/ + method = $1 + end + method = nil if method && method.empty? + return file, line, method + end + + def self.prepare_advanced_mri_backtrace(bindings, name, faulted) + data = { + 'name' => name, + 'faulted' => faulted, + 'backtrace' => [] + } + + bindings.each do |binding| + if respond_to?(:caller_locations) + file = binding.eval('caller_locations(0, 1)[0].path') + line = binding.eval('caller_locations(0, 1)[0].lineno') + method = binding.eval('caller_locations(0, 1)[0].label') + else + file, line, method = decode_basic_backtrace_line(binding.eval('caller(0, 1)[0]')) + end + + if binding.eval('defined?(local_variables)') == 'method' + locals = binding.eval('local_variables') + locals = locals.inject({}) { |hsh, lvar| hsh[lvar] = binding.eval(lvar.to_s); hsh } + else + locals = nil + end + + data['backtrace'] << { + 'file' => file, + 'line' => line, + 'symbol' => method, + 'locals' => valueify(locals, true) + } + end + + data + end + + def self.prepare_jruby_backtrace(bt, name, faulted) + trace = bt.map(&:strip).map do |element| + if element =~ /^((?:[a-z0-9_$]+\.)*(?:[a-z0-9_$]+))\.(\w+)\((\w+.java):(\d+)\)$/i + # special JRuby backtrace element of the form "org.jruby.RubyHash$27.visit(RubyHash.java:1646)" + { + 'type' => 'obfuscated', + 'file' => $3, + 'line' => $4.to_i, + 'symbol' => $2, + 'class' => $1 + } + elsif element =~ /^(.+?)\.(\w+)\(Native Method\)$/ + { + 'type' => 'java_native', + 'symbol' => $2, + 'class' => $1 + } + elsif element =~ /^rubyjit[$.](.+?)\$\$(\w+?[?!]?)_[0-9A-F]{40}.+?__(?:file|ensure)__\.call\(.+\)$/ + { + 'type' => 'jruby_block', + 'class' => $1, + 'symbol' => $2 + } + elsif element =~ /^rubyjit[$.](.+?)\$\$(\w+?[?!]?)_[0-9A-F]{40}.+?__(?:file|ensure)__\((.+?):(\d+)\)$/ + { + 'file' => $3, + 'line' => $4.to_i, + 'symbol' => "#{$1}##{$2}" + } + elsif element =~ /^.+\.call\(.+?(\w+)\.gen\)$/ + { + 'type' => 'asm_invoker', + 'file' => $1 + '.gen' + } + elsif element =~ /^rubyjit[$.](.+?)\$\$(\w+?[?!]?)_[0-9A-F]{40}.+?__(?:file|ensure)__\((.+?)\)$/ + { + 'file' => $3, + 'type' => 'jruby_noline', + 'symbol' => "#{$1}##{$2}" + } + else + if element.include?(' at ') + method, fileline = element.split(' at ') + method.lstrip! + file, line = fileline.split(':') else - if element.include?(' at ') - method, fileline = element.split(' at ') - method.lstrip! - file, line = fileline.split(':') - else - file, line, method = element.split(':') - if method =~ /^in `(.+)'$/ - method = $1 - end - method = nil if method && method.empty? - end - line = line.to_i - line = nil if line < 1 + file, line, method = element.split(':') if method =~ /^in `(.+)'$/ method = $1 end method = nil if method && method.empty? - - # it could still be a java backtrace, even if it's not the special format - if file[-5, 5] == '.java' - { - 'type' => 'obfuscated', - 'file' => file.split('/').last, - 'line' => line, - 'symbol' => method, - 'class' => file.sub(/\.java$/, '').gsub('/', '.') - } - else - # ok now we're sure it's a ruby backtrace - file.slice! 0, configuration(:project_root).length + 1 if file[0, configuration(:project_root).length + 1] == configuration(:project_root) + '/' - { - 'file' => file, - 'line' => line, - 'symbol' => method - } - end end - end - else - bt.map do |element| - file, line, method = element.split(':') - line = line.to_i + line = line.to_i line = nil if line < 1 - - file.slice! 0, configuration(:project_root).length + 1 if file[0, configuration(:project_root).length + 1] == configuration(:project_root) + '/' - if method =~ /^in `(.+)'$/ method = $1 end method = nil if method && method.empty? - { - 'file' => file, - 'line' => line, - 'symbol' => method - } + + # it could still be a java backtrace, even if it's not the special format + if file[-5, 5] == '.java' + { + 'type' => 'obfuscated', + 'file' => file.split('/').last, + 'line' => line, + 'symbol' => method, + 'class' => file.sub(/\.java$/, '').gsub('/', '.') + } + else + # ok now we're sure it's a ruby backtrace + file.slice! 0, configuration(:project_root).length + 1 if file[0, configuration(:project_root).length + 1] == configuration(:project_root) + '/' + { + 'file' => file, + 'line' => line, + 'symbol' => method + } + end end end + + { + 'name' => name, + 'faulted' => faulted, + 'backtrace' => trace + } end def self.valueify(instance, elements_only=false) diff --git a/lib/squash/ruby/exception_additions.rb b/lib/squash/ruby/exception_additions.rb index 8e23099..d9d230c 100644 --- a/lib/squash/ruby/exception_additions.rb +++ b/lib/squash/ruby/exception_additions.rb @@ -51,4 +51,28 @@ def user_data(data) end self end + + # This code courtesy of better_errors (MIT license): + # https://github.com/charliesome/better_errors + + if binding.respond_to?(:callers) + original_set_backtrace = instance_method(:set_backtrace) + + define_method :set_backtrace do |*args| + unless Thread.current[:_squash_exception_lock] + Thread.current[:_squash_exception_lock] = true + begin + @_squash_bindings_stack = binding.callers[1..-1] + ensure + Thread.current[:_squash_exception_lock] = false + end + end + original_set_backtrace.bind(self).call(*args) + end + + def _squash_bindings_stack + @_squash_bindings_stack || [] + end + end end + diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index 5d1a0db..78d2d2c 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -17,6 +17,7 @@ require 'rspec' require 'fakefs/safe' require 'squash/ruby' +require 'squash/ruby/exception_additions' # Requires supporting files with custom matchers and macros, etc, # in ./support/ and its subdirectories. diff --git a/spec/squash_ruby_spec.rb b/spec/squash_ruby_spec.rb index 8752e07..6c42a2b 100644 --- a/spec/squash_ruby_spec.rb +++ b/spec/squash_ruby_spec.rb @@ -369,17 +369,21 @@ def to_s() raise ArgumentError, "oops!"; end end it "should properly tokenize and normalize backtraces" do - bt = @exception.backtrace.reject { |line| line.include?('.java') } + bt = if @exception.respond_to?(:_squash_bindings_stack) + @exception._squash_bindings_stack + else + @exception.backtrace.reject { |line| line.include?('.java') } + end serialized = @json['backtraces'].first['backtrace'].reject { |elem| elem['type'] } - serialized.should eql(bt.map do |element| - file, line, method = element.split(':') + serialized.zip(bt).each do |(serialized_element, bt_element)| + bt_element = bt_element.eval('caller(0, 1)[0]') if bt_element.kind_of?(Binding) + file, line, method = bt_element.split(':') file.sub! /^#{Regexp.escape Dir.getwd}\//, '' - { - 'file' => file, - 'line' => line.to_i, - 'symbol' => method ? method.match(/in `(.+)'$/)[1] : nil - } - end) + + serialized_element['file'].should eql(file) + serialized_element['line'].should eql(line.to_i) + serialized_element['symbol'].should eql(method ? method.match(/in `(.+)'$/)[1] : nil) + end end it "should transmit information about the environment" do