diff --git a/.rubocop_todo.yml b/.rubocop_todo.yml index 253813a5c..7ea007247 100644 --- a/.rubocop_todo.yml +++ b/.rubocop_todo.yml @@ -288,7 +288,7 @@ Metrics/BlockNesting: # Offense count: 3 # Configuration parameters: CountComments. Metrics/ClassLength: - Max: 149 + Max: 170 # Offense count: 12 Metrics/CyclomaticComplexity: @@ -302,7 +302,7 @@ Metrics/MethodLength: # Offense count: 1 # Configuration parameters: CountComments. Metrics/ModuleLength: - Max: 125 + Max: 140 Exclude: - 'lib/bugsnag/helpers.rb' diff --git a/lib/bugsnag.rb b/lib/bugsnag.rb index e217ec161..42cc8d924 100644 --- a/lib/bugsnag.rb +++ b/lib/bugsnag.rb @@ -29,6 +29,10 @@ require "bugsnag/middleware/classify_error" require "bugsnag/middleware/delayed_job" +require "bugsnag/breadcrumbs/validator" +require "bugsnag/breadcrumbs/breadcrumb" +require "bugsnag/breadcrumbs/breadcrumbs" + module Bugsnag LOCK = Mutex.new INTEGRATIONS = [:resque, :sidekiq, :mailman, :delayed_job, :shoryuken, :que] @@ -189,6 +193,36 @@ def load_integration(integration) end end + ## + # Leave a breadcrumb to be attached to subsequent reports + # + # @param name [String] the main breadcrumb name/message + # @param meta_data [Hash] String, Numeric, or Boolean meta data to attach + # @param type [String] the breadcrumb type, from Bugsnag::Breadcrumbs::VALID_BREADCRUMB_TYPES + # @param auto [Symbol] set to :auto if the breadcrumb is automatically created + def leave_breadcrumb(name, meta_data={}, type=Bugsnag::Breadcrumbs::MANUAL_BREADCRUMB_TYPE, auto=:manual) + breadcrumb = Bugsnag::Breadcrumbs::Breadcrumb.new(name, type, meta_data, auto) + validator = Bugsnag::Breadcrumbs::Validator.new(configuration) + + # Initial validation + validator.validate(breadcrumb) + + # Skip if it's already invalid + unless breadcrumb.ignore? + # Run callbacks + configuration.before_breadcrumb_callbacks.each do |c| + (c.arity > 0 ? c.call(breadcrumb) : c.call) + break if breadcrumb.ignore? + end + + # Validate again incase of callback alteration + validator.validate(breadcrumb) + + # Add to breadcrumbs buffer if still valid + configuration.breadcrumbs << breadcrumb unless breadcrumb.ignore? + end + end + private def deliver_notification?(exception, auto_notify) diff --git a/lib/bugsnag/configuration.rb b/lib/bugsnag/configuration.rb index 56a52db84..ab1716bbc 100644 --- a/lib/bugsnag/configuration.rb +++ b/lib/bugsnag/configuration.rb @@ -8,6 +8,7 @@ require "bugsnag/middleware/suggestion_data" require "bugsnag/middleware/classify_error" require "bugsnag/middleware/session_data" +require "bugsnag/middleware/breadcrumbs" require "bugsnag/utility/circular_buffer" require "bugsnag/breadcrumbs/breadcrumbs" @@ -118,6 +119,7 @@ def initialize self.internal_middleware.use Bugsnag::Middleware::SuggestionData self.internal_middleware.use Bugsnag::Middleware::ClassifyError self.internal_middleware.use Bugsnag::Middleware::SessionData + self.internal_middleware.use Bugsnag::Middleware::Breadcrumbs self.middleware = Bugsnag::MiddlewareStack.new self.middleware.use Bugsnag::Middleware::Callbacks diff --git a/lib/bugsnag/middleware/breadcrumbs.rb b/lib/bugsnag/middleware/breadcrumbs.rb new file mode 100644 index 000000000..3b5ffab97 --- /dev/null +++ b/lib/bugsnag/middleware/breadcrumbs.rb @@ -0,0 +1,21 @@ +module Bugsnag::Middleware + ## + # Adds breadcrumbs to the report + class Breadcrumbs + ## + # @param next_callable [#call] the next callable middleware + def initialize(next_callable) + @next = next_callable + end + + ## + # Execute this middleware + # + # @param report [Bugsnag::Report] the report being iterated over + def call(report) + breadcrumbs = report.configuration.breadcrumbs.to_a + report.breadcrumbs = breadcrumbs unless breadcrumbs.empty? + @next.call(report) + end + end +end diff --git a/lib/bugsnag/report.rb b/lib/bugsnag/report.rb index 537d03902..9d5e78a66 100644 --- a/lib/bugsnag/report.rb +++ b/lib/bugsnag/report.rb @@ -23,13 +23,16 @@ class Report attr_accessor :api_key attr_accessor :app_type attr_accessor :app_version + attr_accessor :breadcrumbs attr_accessor :configuration attr_accessor :context attr_accessor :delivery_method attr_accessor :exceptions attr_accessor :hostname attr_accessor :grouping_hash + attr_accessor :message attr_accessor :meta_data + attr_accessor :name attr_accessor :raw_exceptions attr_accessor :release_stage attr_accessor :session @@ -51,9 +54,14 @@ def initialize(exception, passed_configuration, auto_notify=false) self.api_key = configuration.api_key self.app_type = configuration.app_type self.app_version = configuration.app_version + self.breadcrumbs = [] self.delivery_method = configuration.delivery_method self.hostname = configuration.hostname + self.message = defined?(exception.message) ? exception.message : exception.to_s self.meta_data = {} + + # Notified strings display as RuntimeErrors in the dashboard + self.name = exception.is_a?(Exception) ? exception.class.to_s : RuntimeError.to_s self.release_stage = configuration.release_stage self.severity = auto_notify ? "error" : "warning" self.severity_reason = auto_notify ? {:type => UNHANDLED_EXCEPTION} : {:type => HANDLED_EXCEPTION} @@ -110,7 +118,14 @@ def as_json payload_event = Bugsnag::Cleaner.clean_object_encoding(payload_event) # filter out sensitive values in (and cleanup encodings) metaData - payload_event[:metaData] = Bugsnag::Cleaner.new(configuration.meta_data_filters).clean_object(meta_data) + filter_cleaner = Bugsnag::Cleaner.new(configuration.meta_data_filters) + payload_event[:metaData] = filter_cleaner.clean_object(meta_data) + payload_event[:breadcrumbs] = breadcrumbs.map do |breadcrumb| + breadcrumb_hash = breadcrumb.to_h + breadcrumb_hash[:metaData] = filter_cleaner.clean_object(breadcrumb_hash[:metaData]) + breadcrumb_hash + end + payload_event.reject! {|k,v| v.nil? } # return the payload hash @@ -153,6 +168,18 @@ def ignore! @should_ignore = true end + ## + # Generates a summary to be attached as a breadcrumb + # + # @return [Hash] a Hash containing the report's name, message, and severity + def summary + { + :name => name, + :message => message, + :severity => severity + } + end + private def generate_exception_list diff --git a/spec/bugsnag_spec.rb b/spec/bugsnag_spec.rb index 8e8e8a5b5..c83ce288d 100644 --- a/spec/bugsnag_spec.rb +++ b/spec/bugsnag_spec.rb @@ -133,4 +133,153 @@ module Kernel Kernel.send(:remove_const, :REQUIRED) end end + + describe "#leave_breadcrumb" do + + let(:breadcrumbs) { Bugsnag.configuration.breadcrumbs } + let(:timestamp_regex) { /^\d{4}\-\d{2}\-\d{2}T\d{2}:\d{2}:[\d\.]+Z$/ } + + it "requires only a name argument" do + Bugsnag.leave_breadcrumb("TestName") + expect(breadcrumbs.to_a.size).to eq(1) + expect(breadcrumbs.first.to_h).to match({ + :name => "TestName", + :type => Bugsnag::Breadcrumbs::MANUAL_BREADCRUMB_TYPE, + :metaData => {}, + :timestamp => match(timestamp_regex) + }) + end + + it "accepts meta_data" do + Bugsnag.leave_breadcrumb("TestName", { :a => 1, :b => "2" }) + expect(breadcrumbs.to_a.size).to eq(1) + expect(breadcrumbs.first.to_h).to match({ + :name => "TestName", + :type => Bugsnag::Breadcrumbs::MANUAL_BREADCRUMB_TYPE, + :metaData => { :a => 1, :b => "2" }, + :timestamp => match(timestamp_regex) + }) + end + + it "allows different message types" do + Bugsnag.leave_breadcrumb("TestName", {}, Bugsnag::Breadcrumbs::ERROR_BREADCRUMB_TYPE) + expect(breadcrumbs.to_a.size).to eq(1) + expect(breadcrumbs.first.to_h).to match({ + :name => "TestName", + :type => Bugsnag::Breadcrumbs::ERROR_BREADCRUMB_TYPE, + :metaData => {}, + :timestamp => match(timestamp_regex) + }) + end + + it "validates before leaving" do + Bugsnag.leave_breadcrumb( + "123123123123123123123123123123456456456456456456456456456456", + { + :a => 1, + :b => [1, 2, 3, 4], + :c => { + :test => true, + :test2 => false + } + }, + "Not a real type" + ) + expect(breadcrumbs.to_a.size).to eq(1) + expect(breadcrumbs.first.to_h).to match({ + :name => "123123123123123123123123123123", + :type => Bugsnag::Breadcrumbs::MANUAL_BREADCRUMB_TYPE, + :metaData => { + :a => 1 + }, + :timestamp => match(timestamp_regex) + }) + end + + it "runs callbacks before leaving" do + Bugsnag.configuration.before_breadcrumb_callbacks << Proc.new { |breadcrumb| + breadcrumb.meta_data = { + :callback => true + } + } + Bugsnag.leave_breadcrumb("TestName") + expect(breadcrumbs.to_a.size).to eq(1) + expect(breadcrumbs.first.to_h).to match({ + :name => "TestName", + :type => Bugsnag::Breadcrumbs::MANUAL_BREADCRUMB_TYPE, + :metaData => { + :callback => true + }, + :timestamp => match(timestamp_regex) + }) + end + + it "validates after callbacks" do + Bugsnag.configuration.before_breadcrumb_callbacks << Proc.new { |breadcrumb| + breadcrumb.meta_data = { + :int => 1, + :array => [1, 2, 3], + :hash => { + :a => 1, + :b => 2 + } + } + breadcrumb.type = "Not a real type" + breadcrumb.name = "123123123123123123123123123123456456456456456" + } + Bugsnag.leave_breadcrumb("TestName") + expect(breadcrumbs.to_a.size).to eq(1) + expect(breadcrumbs.first.to_h).to match({ + :name => "123123123123123123123123123123", + :type => Bugsnag::Breadcrumbs::MANUAL_BREADCRUMB_TYPE, + :metaData => { + :int => 1 + }, + :timestamp => match(timestamp_regex) + }) + end + + it "doesn't add when ignored by the validator" do + Bugsnag.configuration.automatic_breadcrumb_types = [] + Bugsnag.leave_breadcrumb("TestName", {}, Bugsnag::Breadcrumbs::ERROR_BREADCRUMB_TYPE, :auto) + expect(breadcrumbs.to_a.size).to eq(0) + end + + it "doesn't add if ignored in a callback" do + Bugsnag.configuration.before_breadcrumb_callbacks << Proc.new { |breadcrumb| + breadcrumb.ignore! + } + Bugsnag.leave_breadcrumb("TestName") + expect(breadcrumbs.to_a.size).to eq(0) + end + + it "doesn't add when ignored after the callbacks" do + Bugsnag.configuration.automatic_breadcrumb_types = [ + Bugsnag::Breadcrumbs::MANUAL_BREADCRUMB_TYPE + ] + Bugsnag.configuration.before_breadcrumb_callbacks << Proc.new { |breadcrumb| + breadcrumb.type = Bugsnag::Breadcrumbs::ERROR_BREADCRUMB_TYPE + } + Bugsnag.leave_breadcrumb("TestName", {}, Bugsnag::Breadcrumbs::MANUAL_BREADCRUMB_TYPE, :auto) + expect(breadcrumbs.to_a.size).to eq(0) + end + + it "doesn't call callbacks if ignored early" do + Bugsnag.configuration.automatic_breadcrumb_types = [] + Bugsnag.configuration.before_breadcrumb_callbacks << Proc.new { |breadcrumb| + fail "This shouldn't be called" + } + Bugsnag.leave_breadcrumb("TestName", {}, Bugsnag::Breadcrumbs::ERROR_BREADCRUMB_TYPE, :auto) + end + + it "doesn't continue to call callbacks if ignored in them" do + Bugsnag.configuration.before_breadcrumb_callbacks << Proc.new { |breadcrumb| + breadcrumb.ignore! + } + Bugsnag.configuration.before_breadcrumb_callbacks << Proc.new { |breadcrumb| + fail "This shouldn't be called" + } + Bugsnag.leave_breadcrumb("TestName") + end + end end diff --git a/spec/report_spec.rb b/spec/report_spec.rb index 32bcd4b39..cbb80afee 100644 --- a/spec/report_spec.rb +++ b/spec/report_spec.rb @@ -1092,6 +1092,104 @@ def gloops } end + describe "breadcrumbs" do + let(:timestamp_regex) { /^\d{4}\-\d{2}\-\d{2}T\d{2}:\d{2}:[\d\.]+Z$/ } + + it "includes left breadcrumbs" do + Bugsnag.leave_breadcrumb("Test breadcrumb") + notify_test_exception + expect(Bugsnag).to have_sent_notification{ |payload, headers| + event = get_event_from_payload(payload) + expect(event["breadcrumbs"].size).to eq(1) + expect(event["breadcrumbs"].first).to match({ + "name" => "Test breadcrumb", + "type" => "manual", + "metaData" => {}, + "timestamp" => match(timestamp_regex) + }) + } + end + + it "filters left breadcrumbs" do + Bugsnag.leave_breadcrumb("Test breadcrumb", { + :forbidden_key => false, + :allowed_key => true + }) + Bugsnag.configuration.meta_data_filters << "forbidden" + notify_test_exception + expect(Bugsnag).to have_sent_notification{ |payload, headers| + event = get_event_from_payload(payload) + expect(event["breadcrumbs"].size).to eq(1) + expect(event["breadcrumbs"].first).to match({ + "name" => "Test breadcrumb", + "type" => "manual", + "metaData" => { + "forbidden_key" => "[FILTERED]", + "allowed_key" => true + }, + "timestamp" => match(timestamp_regex) + }) + } + end + + it "defaults to an empty array" do + notify_test_exception + expect(Bugsnag).to have_sent_notification{ |payload, headers| + event = get_event_from_payload(payload) + expect(event["breadcrumbs"].size).to eq(0) + } + end + + it "allows breadcrumbs to be editted in callbacks" do + Bugsnag.leave_breadcrumb("Test breadcrumb") + Bugsnag.before_notify_callbacks << Proc.new { |report| + breadcrumb = report.breadcrumbs.first + breadcrumb.meta_data = {:a => 1, :b => 2} + } + notify_test_exception + expect(Bugsnag).to have_sent_notification{ |payload, headers| + event = get_event_from_payload(payload) + expect(event["breadcrumbs"].size).to eq(1) + expect(event["breadcrumbs"].first).to match({ + "name" => "Test breadcrumb", + "type" => "manual", + "metaData" => {"a" => 1, "b" => 2}, + "timestamp" => match(timestamp_regex) + }) + } + end + end + + describe "#summary" do + it "provides a hash of the name, message, and severity" do + begin + 1/0 + rescue ZeroDivisionError => e + report = Bugsnag::Report.new(e, Bugsnag.configuration) + expect(report.name).to eq("ZeroDivisionError") + expect(report.message).to eq("divided by 0") + + expect(report.summary).to match({ + :name => "ZeroDivisionError", + :message => "divided by 0", + :severity => "warning" + }) + end + end + + it "handles strings" do + report = Bugsnag::Report.new("test string", Bugsnag.configuration) + expect(report.name).to eq("RuntimeError") + expect(report.message).to eq("test string") + + expect(report.summary).to match({ + :name => "RuntimeError", + :message => "test string", + :severity => "warning" + }) + end + end + if defined?(JRUBY_VERSION) it "should work with java.lang.Throwables" do