diff --git a/lib/airbrake-ruby.rb b/lib/airbrake-ruby.rb index b207195c..dae17f63 100644 --- a/lib/airbrake-ruby.rb +++ b/lib/airbrake-ruby.rb @@ -25,6 +25,7 @@ require 'airbrake-ruby/filters/thread_filter' require 'airbrake-ruby/filter_chain' require 'airbrake-ruby/notifier' +require 'airbrake-ruby/code_hunk' ## # This module defines the Airbrake API. The user is meant to interact with diff --git a/lib/airbrake-ruby/backtrace.rb b/lib/airbrake-ruby/backtrace.rb index 8a87b0af..e1508eb2 100644 --- a/lib/airbrake-ruby/backtrace.rb +++ b/lib/airbrake-ruby/backtrace.rb @@ -102,7 +102,7 @@ module Patterns # @param [Exception] exception The exception, which contains a backtrace to # parse # @return [ArrayString,Integer}>] the parsed backtrace - def self.parse(exception, logger) + def self.parse(config, exception) return [] if exception.backtrace.nil? || exception.backtrace.none? regexp = best_regexp_for(exception) @@ -111,14 +111,14 @@ def self.parse(exception, logger) frame = match_frame(regexp, stackframe) unless frame - logger.error( + config.logger.error( "can't parse '#{stackframe}' (please file an issue so we can fix " \ "it: https://github.com/airbrake/airbrake-ruby/issues/new)" ) frame = { file: nil, line: nil, function: stackframe } end - stack_frame(frame) + stack_frame(config, frame) end end @@ -176,10 +176,15 @@ def execjs_exception?(exception) end # rubocop:enable Metrics/PerceivedComplexity, Metrics/CyclomaticComplexity - def stack_frame(match) - { file: match[:file], + def stack_frame(config, match) + frame = { + file: match[:file], line: (Integer(match[:line]) if match[:line]), - function: match[:function] } + function: match[:function] + } + + populate_code(config, frame) if config.code_hunks + frame end def match_frame(regexp, stackframe) @@ -188,6 +193,11 @@ def match_frame(regexp, stackframe) Patterns::GENERIC.match(stackframe) end + + def populate_code(config, frame) + code = Airbrake::CodeHunk.new(config).get(frame[:file], frame[:line]) + frame[:code] = code if code + end end end end diff --git a/lib/airbrake-ruby/code_hunk.rb b/lib/airbrake-ruby/code_hunk.rb new file mode 100644 index 00000000..078ba9ba --- /dev/null +++ b/lib/airbrake-ruby/code_hunk.rb @@ -0,0 +1,48 @@ +module Airbrake + ## + # Represents a small hunk of code consisting of a base line and a couple lines + # around it + # @api private + class CodeHunk + ## + # @return [Integer] the maximum length of a line + MAX_LINE_LEN = 200 + + ## + # @return [Integer] how many lines should be read around the base line + NLINES = 2 + + def initialize(config) + @config = config + end + + ## + # @param [String] file The file to read + # @param [Integer] line The base line in the file + # @return [Hash{Integer=>String}, nil] lines of code around the base line + def get(file, line) + return unless File.exist?(file) + + start_line = [line - NLINES, 1].max + end_line = line + NLINES + lines = {} + + begin + File.foreach(file).with_index(1) do |l, i| + next if i < start_line + break if i > end_line + + lines[i] = l[0...MAX_LINE_LEN].rstrip + end + rescue StandardError => ex + @config.logger.error( + "#{self.class.name}##{__method__}: can't read code hunk for " \ + "#{file}:#{line}: #{ex}" + ) + end + + return { 1 => '' } if lines.empty? + lines + end + end +end diff --git a/lib/airbrake-ruby/config.rb b/lib/airbrake-ruby/config.rb index abf7aac4..eb6fc5c4 100644 --- a/lib/airbrake-ruby/config.rb +++ b/lib/airbrake-ruby/config.rb @@ -72,6 +72,12 @@ class Config # @since 1.2.0 attr_accessor :whitelist_keys + ## + # @return [Boolean] true if the library should attach code hunks to each + # frame in a backtrace, false otherwise + # @since v3.0.0 + attr_accessor :code_hunks + ## # @param [Hash{Symbol=>Object}] user_config the hash to be used to build the # config @@ -81,6 +87,7 @@ def initialize(user_config = {}) self.proxy = {} self.queue_size = 100 self.workers = 1 + self.code_hunks = false self.logger = Logger.new(STDOUT) logger.level = Logger::WARN diff --git a/lib/airbrake-ruby/nested_exception.rb b/lib/airbrake-ruby/nested_exception.rb index c9d3b6ac..e209eca7 100644 --- a/lib/airbrake-ruby/nested_exception.rb +++ b/lib/airbrake-ruby/nested_exception.rb @@ -11,16 +11,16 @@ class NestedException # can unwrap. Exceptions that have a longer cause chain will be ignored MAX_NESTED_EXCEPTIONS = 3 - def initialize(exception, logger) + def initialize(config, exception) + @config = config @exception = exception - @logger = logger end def as_json unwind_exceptions.map do |exception| { type: exception.class.name, message: exception.message, - backtrace: Backtrace.parse(exception, @logger) } + backtrace: Backtrace.parse(@config, exception) } end end diff --git a/lib/airbrake-ruby/notice.rb b/lib/airbrake-ruby/notice.rb index e335ca2b..f76da0c4 100644 --- a/lib/airbrake-ruby/notice.rb +++ b/lib/airbrake-ruby/notice.rb @@ -68,7 +68,7 @@ def initialize(config, exception, params = {}) @config = config @payload = { - errors: NestedException.new(exception, @config.logger).as_json, + errors: NestedException.new(config, exception).as_json, context: context, environment: { program_name: $PROGRAM_NAME diff --git a/spec/backtrace_spec.rb b/spec/backtrace_spec.rb index 2b975169..cc64530e 100644 --- a/spec/backtrace_spec.rb +++ b/spec/backtrace_spec.rb @@ -1,6 +1,10 @@ require 'spec_helper' RSpec.describe Airbrake::Backtrace do + let(:config) do + Airbrake::Config.new.tap { |c| c.logger = Logger.new('/dev/null') } + end + describe ".parse" do context "UNIX backtrace" do let(:parsed_backtrace) do @@ -23,7 +27,7 @@ it "returns a properly formatted array of hashes" do expect( - described_class.parse(AirbrakeTestError.new, Logger.new('/dev/null')) + described_class.parse(config, AirbrakeTestError.new) ).to eq(parsed_backtrace) end end @@ -44,9 +48,7 @@ end it "returns a properly formatted array of hashes" do - expect( - described_class.parse(ex, Logger.new('/dev/null')) - ).to eq(parsed_backtrace) + expect(described_class.parse(config, ex)).to eq(parsed_backtrace) end end @@ -71,7 +73,7 @@ allow(described_class).to receive(:java_exception?).and_return(true) expect( - described_class.parse(JavaAirbrakeTestError.new, Logger.new('/dev/null')) + described_class.parse(config, JavaAirbrakeTestError.new) ).to eq(backtrace_array) end end @@ -99,10 +101,7 @@ it "returns a properly formatted array of hashes" do allow(described_class).to receive(:java_exception?).and_return(true) - - expect( - described_class.parse(ex, Logger.new('/dev/null')) - ).to eq(parsed_backtrace) + expect(described_class.parse(config, ex)).to eq(parsed_backtrace) end end @@ -126,9 +125,7 @@ let(:ex) { AirbrakeTestError.new.tap { |e| e.set_backtrace(backtrace) } } it "returns a properly formatted array of hashes" do - expect( - described_class.parse(ex, Logger.new('/dev/null')) - ).to eq(parsed_backtrace) + expect(described_class.parse(config, ex)).to eq(parsed_backtrace) end end @@ -153,9 +150,7 @@ end it "returns a properly formatted array of hashes" do - expect( - described_class.parse(ex, Logger.new('/dev/null')) - ).to eq(parsed_backtrace) + expect(described_class.parse(config, ex)).to eq(parsed_backtrace) end end @@ -173,9 +168,7 @@ end it "returns a properly formatted array of hashes" do - expect( - described_class.parse(ex, Logger.new('/dev/null')) - ).to eq(parsed_backtrace) + expect(described_class.parse(config, ex)).to eq(parsed_backtrace) end end end @@ -187,15 +180,15 @@ it "returns array of hashes where each unknown frame is marked as 'function'" do expect( - described_class.parse(ex, Logger.new('/dev/null')) + described_class.parse(config, ex) ).to eq([file: nil, line: nil, function: 'a b c 1 23 321 .rb']) end it "logs unknown frames as errors" do out = StringIO.new - logger = Logger.new(out) + config.logger = Logger.new(out) - expect { described_class.parse(ex, logger) }. + expect { described_class.parse(config, ex) }. to change { out.string }. from(''). to(/ERROR -- : can't parse 'a b c 1 23 321 .rb'/) @@ -216,9 +209,7 @@ end it "returns a properly formatted array of hashes" do - expect( - described_class.parse(ex, Logger.new('/dev/null')) - ).to eq(parsed_backtrace) + expect(described_class.parse(config, ex)).to eq(parsed_backtrace) end end @@ -241,9 +232,7 @@ it "returns a properly formatted array of hashes" do stub_const('OCIError', AirbrakeTestError) - expect( - described_class.parse(ex, Logger.new('/dev/null')) - ).to eq(parsed_backtrace) + expect(described_class.parse(config, ex)).to eq(parsed_backtrace) end end @@ -279,9 +268,7 @@ stub_const('ExecJS::RuntimeError', AirbrakeTestError) stub_const('Airbrake::RUBY_20', false) - expect( - described_class.parse(ex, Logger.new('/dev/null')) - ).to eq(parsed_backtrace) + expect(described_class.parse(config, ex)).to eq(parsed_backtrace) end end @@ -296,12 +283,44 @@ stub_const('ExecJS::RuntimeError', NameError) stub_const('Airbrake::RUBY_20', true) - expect( - described_class.parse(ex, Logger.new('/dev/null')) - ).to eq(parsed_backtrace) + expect(described_class.parse(config, ex)).to eq(parsed_backtrace) end end end end + + context "when code hunks are enabled" do + let(:config) do + config = Airbrake::Config.new + config.logger = Logger.new('/dev/null') + config.code_hunks = true + config + end + + let(:parsed_backtrace) do + [ + { + file: File.join(fixture_path('code.rb')), + line: 94, + function: 'to_json', + code: { + 92 => ' loop do', + 93 => ' begin', + 94 => ' json = @payload.to_json', + 95 => ' rescue *JSON_EXCEPTIONS => ex', + # rubocop:disable Metrics/LineLength,Lint/InterpolationCheck + 96 => ' @config.logger.debug("#{LOG_LABEL} `notice.to_json` failed: #{ex.class}: #{ex}")', + # rubocop:enable Metrics/LineLength,Lint/InterpolationCheck + } + } + ] + end + + it "attaches code to each frame" do + ex = RuntimeError.new + ex.set_backtrace([File.join(fixture_path('code.rb') + ":94:in `to_json'")]) + expect(described_class.parse(config, ex)).to eq(parsed_backtrace) + end + end end end diff --git a/spec/code_hunk_spec.rb b/spec/code_hunk_spec.rb new file mode 100644 index 00000000..0ab2c3b2 --- /dev/null +++ b/spec/code_hunk_spec.rb @@ -0,0 +1,102 @@ +require 'spec_helper' + +RSpec.describe Airbrake::CodeHunk do + let(:config) { Airbrake::Config.new } + + describe "#to_h" do + context "when a file is empty" do + subject { described_class.new(config).get(fixture_path('empty_file.rb'), 1) } + + it { is_expected.to eq(1 => '') } + end + + context "when a file doesn't exist" do + subject { described_class.new(config).get(fixture_path('banana.rb'), 1) } + + it { is_expected.to be_nil } + end + + context "when a file has less than NLINES lines before start line" do + subject { described_class.new(config).get(fixture_path('code.rb'), 1) } + + it do + is_expected.to( + eq( + 1 => 'module Airbrake', + 2 => ' ##', + # rubocop:disable Metrics/LineLength + 3 => ' # Represents a chunk of information that is meant to be either sent to', + # rubocop:enable Metrics/LineLength + ) + ) + end + end + + context "when a file has less than NLINES lines after end line" do + subject { described_class.new(config).get(fixture_path('code.rb'), 222) } + + it do + is_expected.to( + eq( + 220 => ' end', + 221 => 'end' + ) + ) + end + end + + context "when a file has less than NLINES lines before and after" do + subject { described_class.new(config).get(fixture_path('short_file.rb'), 2) } + + it do + is_expected.to( + eq( + 1 => 'module Banana', + 2 => ' attr_reader :bingo', + 3 => 'end' + ) + ) + end + end + + context "when a file has enough lines before and after" do + subject { described_class.new(config).get(fixture_path('code.rb'), 100) } + + it do + is_expected.to( + eq( + 98 => ' return json if json && json.bytesize <= MAX_NOTICE_SIZE', + 99 => ' end', + 100 => '', + 101 => ' break if truncate == 0', + 102 => ' end' + ) + ) + end + end + + context "when a line exceeds the length limit" do + subject { described_class.new(config).get(fixture_path('long_line.txt'), 1) } + + it "strips the line" do + expect(subject[1]).to eq('l' + 'o' * 196 + 'ng') + end + end + + context "when an error occurrs while fetching code" do + before do + expect(File).to receive(:foreach).and_raise(Errno::EACCES) + end + + it "logs error and returns nil" do + out = StringIO.new + config = Airbrake::Config.new + config.logger = Logger.new(out) + expect(described_class.new(config).get(fixture_path('code.rb'), 1)).to( + eq(1 => '') + ) + expect(out.string).to match(/can't read code hunk.+Permission denied/) + end + end + end +end diff --git a/spec/fixtures/code.rb b/spec/fixtures/code.rb new file mode 100644 index 00000000..f76da0c4 --- /dev/null +++ b/spec/fixtures/code.rb @@ -0,0 +1,221 @@ +module Airbrake + ## + # Represents a chunk of information that is meant to be either sent to + # Airbrake or ignored completely. + # + # @since v1.0.0 + class Notice + ## + # @return [Hash{Symbol=>String}] the information about the notifier library + NOTIFIER = { + name: 'airbrake-ruby'.freeze, + version: Airbrake::AIRBRAKE_RUBY_VERSION, + url: 'https://github.com/airbrake/airbrake-ruby'.freeze + }.freeze + + ## + # @return [Hash{Symbol=>String,Hash}] the information to be displayed in the + # Context tab in the dashboard + CONTEXT = { + os: RUBY_PLATFORM, + language: "#{RUBY_ENGINE}/#{RUBY_VERSION}".freeze, + notifier: NOTIFIER + }.freeze + + ## + # @return [Integer] the maxium size of the JSON payload in bytes + MAX_NOTICE_SIZE = 64000 + + ## + # @return [Integer] the maximum size of hashes, arrays and strings in the + # notice. + PAYLOAD_MAX_SIZE = 10000 + + ## + # @return [Array] the list of possible exceptions that might + # be raised when an object is converted to JSON + JSON_EXCEPTIONS = [ + IOError, + NotImplementedError, + JSON::GeneratorError, + Encoding::UndefinedConversionError + ].freeze + + # @return [Array] the list of keys that can be be overwritten with + # {Airbrake::Notice#[]=} + WRITABLE_KEYS = %i[notifier context environment session params].freeze + + ## + # @return [Array] parts of a Notice's payload that can be modified + # by the truncator + TRUNCATABLE_KEYS = %i[errors environment session params].freeze + + ## + # @return [String] the name of the host machine + HOSTNAME = Socket.gethostname.freeze + + ## + # @return [String] + DEFAULT_SEVERITY = 'error'.freeze + + ## + # @since v1.7.0 + # @return [Hash{Symbol=>Object}] the hash with arbitrary objects to be used + # in filters + attr_reader :stash + + def initialize(config, exception, params = {}) + @config = config + + @payload = { + errors: NestedException.new(config, exception).as_json, + context: context, + environment: { + program_name: $PROGRAM_NAME + }, + session: {}, + params: params + } + @stash = { exception: exception } + @truncator = Airbrake::Truncator.new(PAYLOAD_MAX_SIZE) + + extract_custom_attributes(exception) + end + + ## + # Converts the notice to JSON. Calls +to_json+ on each object inside + # notice's payload. Truncates notices, JSON representation of which is + # bigger than {MAX_NOTICE_SIZE}. + # + # @return [Hash{String=>String}, nil] + def to_json + loop do + begin + json = @payload.to_json + rescue *JSON_EXCEPTIONS => ex + @config.logger.debug("#{LOG_LABEL} `notice.to_json` failed: #{ex.class}: #{ex}") + else + return json if json && json.bytesize <= MAX_NOTICE_SIZE + end + + break if truncate == 0 + end + end + + ## + # Ignores a notice. Ignored notices never reach the Airbrake dashboard. + # + # @return [void] + # @see #ignored? + # @note Ignored noticed can't be unignored + def ignore! + @payload = nil + end + + ## + # Checks whether the notice was ignored. + # + # @return [Boolean] + # @see #ignore! + def ignored? + @payload.nil? + end + + ## + # Reads a value from notice's payload. + # @return [Object] + # + # @raise [Airbrake::Error] if the notice is ignored + def [](key) + raise_if_ignored + @payload[key] + end + + ## + # Writes a value to the payload hash. Restricts unrecognized + # writes. + # @example + # notice[:params][:my_param] = 'foobar' + # + # @return [void] + # @raise [Airbrake::Error] if the notice is ignored + # @raise [Airbrake::Error] if the +key+ is not recognized + # @raise [Airbrake::Error] if the root value is not a Hash + def []=(key, value) + raise_if_ignored + + unless WRITABLE_KEYS.include?(key) + raise Airbrake::Error, + ":#{key} is not recognized among #{WRITABLE_KEYS}" + end + + unless value.respond_to?(:to_hash) + raise Airbrake::Error, "Got #{value.class} value, wanted a Hash" + end + + @payload[key] = value.to_hash + end + + private + + def context + { + version: @config.app_version, + # We ensure that root_directory is always a String, so it can always be + # converted to JSON in a predictable manner (when it's a Pathname and in + # Rails environment, it converts to unexpected JSON). + rootDirectory: @config.root_directory.to_s, + environment: @config.environment, + + # Make sure we always send hostname. + hostname: HOSTNAME, + + severity: DEFAULT_SEVERITY + }.merge(CONTEXT).delete_if { |_key, val| val.nil? || val.empty? } + end + + def raise_if_ignored + return unless ignored? + raise Airbrake::Error, 'cannot access ignored notice' + end + + def truncate + TRUNCATABLE_KEYS.each { |key| @truncator.truncate_object(self[key]) } + + new_max_size = @truncator.reduce_max_size + if new_max_size == 0 + @config.logger.error( + "#{LOG_LABEL} truncation failed. File an issue at " \ + "https://github.com/airbrake/airbrake-ruby " \ + "and attach the following payload: #{@payload}" + ) + end + + new_max_size + end + + def extract_custom_attributes(exception) + return unless exception.respond_to?(:to_airbrake) + attributes = nil + + begin + attributes = exception.to_airbrake + rescue StandardError => ex + @config.logger.error( + "#{LOG_LABEL} #{exception.class}#to_airbrake failed: #{ex.class}: #{ex}" + ) + end + + return unless attributes + + begin + @payload.merge!(attributes) + rescue TypeError + @config.logger.error( + "#{LOG_LABEL} #{exception.class}#to_airbrake failed:" \ + " #{attributes} must be a Hash" + ) + end + end + end +end diff --git a/spec/fixtures/empty_file.rb b/spec/fixtures/empty_file.rb new file mode 100644 index 00000000..e69de29b diff --git a/spec/fixtures/long_line.txt b/spec/fixtures/long_line.txt new file mode 100644 index 00000000..19739506 --- /dev/null +++ b/spec/fixtures/long_line.txt @@ -0,0 +1 @@ +loooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooong line diff --git a/spec/fixtures/short_file.rb b/spec/fixtures/short_file.rb new file mode 100644 index 00000000..fa5c1e7b --- /dev/null +++ b/spec/fixtures/short_file.rb @@ -0,0 +1,3 @@ +module Banana + attr_reader :bingo +end diff --git a/spec/helpers.rb b/spec/helpers.rb new file mode 100644 index 00000000..cac0c53f --- /dev/null +++ b/spec/helpers.rb @@ -0,0 +1,5 @@ +module Helpers + def fixture_path(filename) + File.expand_path(File.join('spec', 'fixtures', filename)) + end +end diff --git a/spec/nested_exception_spec.rb b/spec/nested_exception_spec.rb index c3e9ad9b..2d34395b 100644 --- a/spec/nested_exception_spec.rb +++ b/spec/nested_exception_spec.rb @@ -13,7 +13,7 @@ Ruby21Error.raise_error('bingo') end rescue Ruby21Error => ex - nested_exception = described_class.new(ex, Logger.new('/dev/null')) + nested_exception = described_class.new(config, ex) exceptions = nested_exception.as_json expect(exceptions.size).to eq(2) @@ -40,7 +40,7 @@ end end rescue Ruby21Error => ex - nested_exception = described_class.new(ex, Logger.new('/dev/null')) + nested_exception = described_class.new(config, ex) exceptions = nested_exception.as_json expect(exceptions.size).to eq(3) @@ -64,7 +64,7 @@ end rescue Ruby21Error => ex1 ex1.set_backtrace([]) - nested_exception = described_class.new(ex1, Logger.new('/dev/null')) + nested_exception = described_class.new(config, ex1) exceptions = nested_exception.as_json expect(exceptions.size).to eq(2) diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index c233e0fd..08aeef39 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -9,10 +9,13 @@ require 'English' require 'base64' +require 'helpers' + RSpec.configure do |c| c.order = 'random' c.color = true c.disable_monkey_patching! + c.include Helpers end Thread.abort_on_exception = true