diff --git a/.rubocop_todo.yml b/.rubocop_todo.yml index d4608df2e..a1e491717 100644 --- a/.rubocop_todo.yml +++ b/.rubocop_todo.yml @@ -289,6 +289,9 @@ Metrics/BlockNesting: # Configuration parameters: CountComments. Metrics/ClassLength: Max: 149 + Exclude: + - 'lib/bugsnag/report.rb' + - 'lib/bugsnag/configuration.rb' # Offense count: 12 Metrics/CyclomaticComplexity: diff --git a/features/fixtures/docker-compose.yml b/features/fixtures/docker-compose.yml index cbdc0ec58..b1a4477eb 100644 --- a/features/fixtures/docker-compose.yml +++ b/features/fixtures/docker-compose.yml @@ -4,6 +4,9 @@ services: redis: image: redis + mongo: + image: mongo + plain-ruby: build: context: plain @@ -117,6 +120,7 @@ services: - BUGSNAG_SESSION_ENDPOINT - BUGSNAG_TIMEOUT - CALLBACK_INITIATOR + - SQL_ONLY_BREADCRUMBS - USE_DEFAULT_AUTO_CAPTURE_SESSIONS ports: - target: 3000 @@ -129,6 +133,8 @@ services: args: - RUBY_VERSION - APP_PATH + depends_on: + - mongo environment: - BUGSNAG_API_KEY - http_proxy @@ -153,6 +159,7 @@ services: - BUGSNAG_SESSION_ENDPOINT - BUGSNAG_TIMEOUT - CALLBACK_INITIATOR + - SQL_ONLY_BREADCRUMBS - USE_DEFAULT_AUTO_CAPTURE_SESSIONS ports: - target: 3000 @@ -165,6 +172,8 @@ services: args: - RUBY_VERSION - APP_PATH + depends_on: + - mongo environment: - BUGSNAG_API_KEY - http_proxy @@ -189,6 +198,7 @@ services: - BUGSNAG_SESSION_ENDPOINT - BUGSNAG_TIMEOUT - CALLBACK_INITIATOR + - SQL_ONLY_BREADCRUMBS - USE_DEFAULT_AUTO_CAPTURE_SESSIONS ports: - target: 3000 diff --git a/features/fixtures/rails3/app/app/controllers/breadcrumbs_controller.rb b/features/fixtures/rails3/app/app/controllers/breadcrumbs_controller.rb new file mode 100644 index 000000000..4d92615a0 --- /dev/null +++ b/features/fixtures/rails3/app/app/controllers/breadcrumbs_controller.rb @@ -0,0 +1,19 @@ +class BreadcrumbsController < ApplicationController + def handled + Bugsnag.notify("Request breadcrumb") + render json: {} + end + + def sql_breadcrumb + User.where(:email => "foo").as_json + Bugsnag.notify("SQL breadcrumb") + render json: {} + end + + def cache_read + Rails.cache.write('test', true) + Rails.cache.read('test') + Bugsnag.notify("Cache breadcrumb") + render json: {} + end +end diff --git a/features/fixtures/rails3/app/config/initializers/bugsnag.rb b/features/fixtures/rails3/app/config/initializers/bugsnag.rb index 972abde2a..b6334dcfd 100644 --- a/features/fixtures/rails3/app/config/initializers/bugsnag.rb +++ b/features/fixtures/rails3/app/config/initializers/bugsnag.rb @@ -11,4 +11,10 @@ config.release_stage = ENV["BUGSNAG_RELEASE_STAGE"] if ENV.include? "BUGSNAG_RELEASE_STAGE" config.send_code = ENV["BUGSNAG_SEND_CODE"] != "false" config.send_environment = ENV["BUGSNAG_SEND_ENVIRONMENT"] == "true" -end + + if ENV["SQL_ONLY_BREADCRUMBS"] == "true" + config.before_breadcrumb_callbacks << Proc.new do |breadcrumb| + breadcrumb.ignore! unless breadcrumb.meta_data[:event_name] == "sql.active_record" && breadcrumb.meta_data[:name] == "User Load" + end + end +end \ No newline at end of file diff --git a/features/fixtures/rails3/app/config/routes.rb b/features/fixtures/rails3/app/config/routes.rb index f001ae542..4c1e0dfee 100644 --- a/features/fixtures/rails3/app/config/routes.rb +++ b/features/fixtures/rails3/app/config/routes.rb @@ -14,5 +14,6 @@ get "/send_code/(:action)", controller: 'send_code' get "/send_environment/(:action)", controller: 'send_environment' get "/warden/(:action)", controller: 'warden' + get "/breadcrumbs/(:action)", controller: 'breadcrumbs' get "/(:action)", controller: 'application' end diff --git a/features/fixtures/rails4/app/Gemfile b/features/fixtures/rails4/app/Gemfile index 4e130191e..b81ecd1d2 100644 --- a/features/fixtures/rails4/app/Gemfile +++ b/features/fixtures/rails4/app/Gemfile @@ -39,4 +39,8 @@ end # Added at 2018-03-26 15:02:53 +0100 by amoinet: gem 'bugsnag', path: '/bugsnag' -gem 'devise' \ No newline at end of file +gem 'devise' + +gem "mongoid", '~> 5.4.0' + +gem "nokogiri", "1.6.8" \ No newline at end of file diff --git a/features/fixtures/rails4/app/app/controllers/breadcrumbs_controller.rb b/features/fixtures/rails4/app/app/controllers/breadcrumbs_controller.rb new file mode 100644 index 000000000..7ebdd6194 --- /dev/null +++ b/features/fixtures/rails4/app/app/controllers/breadcrumbs_controller.rb @@ -0,0 +1,26 @@ +class BreadcrumbsController < ApplicationController + def handled + Bugsnag.notify("Request breadcrumb") + render json: {} + end + + def sql_breadcrumb + User.find_by(email: "foo") + Bugsnag.notify("SQL breadcrumb") + render json: {} + end + + def active_job + Thread.new { NotifyJob.perform_later }.join + render json: {} + end + + def cache_read + Thread.new { + Rails.cache.write('test', true) + Rails.cache.read('test') + Bugsnag.notify("Cache breadcrumb") + }.join + render json: {} + end +end diff --git a/features/fixtures/rails4/app/app/controllers/mongo_controller.rb b/features/fixtures/rails4/app/app/controllers/mongo_controller.rb new file mode 100644 index 000000000..f129ac57e --- /dev/null +++ b/features/fixtures/rails4/app/app/controllers/mongo_controller.rb @@ -0,0 +1,23 @@ +class MongoController < ApplicationController + + def success_crash + doc = MongoModel.create(string_field: "String") + doc.save + "Statement".prepnd("Failing") + end + + def get_crash + MongoModel.where(string_field: true).as_json + MongoModel.any_of({string_field: true}, {numeric_field: 123}).as_json + "Statement".prepnd("Failing") + end + + def failure_crash + begin + Mongoid::Clients.default.database.command(:bogus => 1) + rescue + end + + "Statement".prepnd("Failing") + end +end diff --git a/features/fixtures/rails4/app/app/jobs/application_job.rb b/features/fixtures/rails4/app/app/jobs/application_job.rb new file mode 100644 index 000000000..a009ace51 --- /dev/null +++ b/features/fixtures/rails4/app/app/jobs/application_job.rb @@ -0,0 +1,2 @@ +class ApplicationJob < ActiveJob::Base +end diff --git a/features/fixtures/rails4/app/app/jobs/notify_job.rb b/features/fixtures/rails4/app/app/jobs/notify_job.rb new file mode 100644 index 000000000..56c5dfcbc --- /dev/null +++ b/features/fixtures/rails4/app/app/jobs/notify_job.rb @@ -0,0 +1,5 @@ +class NotifyJob < ApplicationJob + def perform + Bugsnag.notify("Failed") + end +end \ No newline at end of file diff --git a/features/fixtures/rails4/app/app/models/mongo_model.rb b/features/fixtures/rails4/app/app/models/mongo_model.rb new file mode 100644 index 000000000..5e9bad828 --- /dev/null +++ b/features/fixtures/rails4/app/app/models/mongo_model.rb @@ -0,0 +1,6 @@ +class MongoModel + include Mongoid::Document + + field :string_field, type: String + field :numeric_field, type: Integer +end \ No newline at end of file diff --git a/features/fixtures/rails4/app/config/initializers/bugsnag.rb b/features/fixtures/rails4/app/config/initializers/bugsnag.rb index 972abde2a..95788bbbc 100644 --- a/features/fixtures/rails4/app/config/initializers/bugsnag.rb +++ b/features/fixtures/rails4/app/config/initializers/bugsnag.rb @@ -11,4 +11,10 @@ config.release_stage = ENV["BUGSNAG_RELEASE_STAGE"] if ENV.include? "BUGSNAG_RELEASE_STAGE" config.send_code = ENV["BUGSNAG_SEND_CODE"] != "false" config.send_environment = ENV["BUGSNAG_SEND_ENVIRONMENT"] == "true" + + if ENV["SQL_ONLY_BREADCRUMBS"] == "true" + config.before_breadcrumb_callbacks << Proc.new do |breadcrumb| + breadcrumb.ignore! unless breadcrumb.meta_data[:event_name] == "sql.active_record" && breadcrumb.meta_data[:name] == "User Load" + end + end end diff --git a/features/fixtures/rails4/app/config/mongoid.yml b/features/fixtures/rails4/app/config/mongoid.yml new file mode 100644 index 000000000..48c56952c --- /dev/null +++ b/features/fixtures/rails4/app/config/mongoid.yml @@ -0,0 +1,22 @@ +development: + # Configure available database clients. (required) + clients: + # Defines the default client. (required) + default: + # Defines the name of the default database that Mongoid can connect to. + # (required). + database: rails4_development + # Provides the hosts the default client can connect to. Must be an array + # of host:port pairs. (required) + hosts: + - mongo:27017 +test: + clients: + default: + database: rails4_test + hosts: + - mongo:27017 + options: + read: + mode: :primary + max_pool_size: 1 diff --git a/features/fixtures/rails4/app/config/routes.rb b/features/fixtures/rails4/app/config/routes.rb index 0ae34dd06..d289c55f1 100644 --- a/features/fixtures/rails4/app/config/routes.rb +++ b/features/fixtures/rails4/app/config/routes.rb @@ -16,4 +16,6 @@ get "/send_code/(:action)", controller: 'send_code' get "/send_environment/(:action)", controller: 'send_environment' get "/devise/(:action)", controller: 'devise' + get "/breadcrumbs/(:action)", controller: 'breadcrumbs' + get "/mongo/(:action)", controller: 'mongo' end diff --git a/features/fixtures/rails5/app/Gemfile b/features/fixtures/rails5/app/Gemfile index 822bdee38..d241e3c0f 100644 --- a/features/fixtures/rails5/app/Gemfile +++ b/features/fixtures/rails5/app/Gemfile @@ -45,3 +45,7 @@ gem 'bugsnag', path: '/bugsnag' # Added at 2018-04-26 10:20:10 +0100 by amoinet: gem "clearance", "~> 1.16" + +gem "mongoid" + +gem "nokogiri", "1.6.8" diff --git a/features/fixtures/rails5/app/app/controllers/breadcrumbs_controller.rb b/features/fixtures/rails5/app/app/controllers/breadcrumbs_controller.rb new file mode 100644 index 000000000..226b2d9d8 --- /dev/null +++ b/features/fixtures/rails5/app/app/controllers/breadcrumbs_controller.rb @@ -0,0 +1,24 @@ +class BreadcrumbsController < ApplicationController + def handled + Bugsnag.notify("Request breadcrumb") + render json: {} + end + + def sql_breadcrumb + User.find_by(email: "foo") + Bugsnag.notify("SQL breadcrumb") + render json: {} + end + + def active_job + NotifyJob.perform_later + render json: {} + end + + def cache_read + Rails.cache.write('test', true) + Rails.cache.read('test') + Bugsnag.notify("Cache breadcrumb") + render json: {} + end +end diff --git a/features/fixtures/rails5/app/app/controllers/mongo_controller.rb b/features/fixtures/rails5/app/app/controllers/mongo_controller.rb new file mode 100644 index 000000000..e6a10fbe4 --- /dev/null +++ b/features/fixtures/rails5/app/app/controllers/mongo_controller.rb @@ -0,0 +1,22 @@ +class MongoController < ApplicationController + def success_crash + doc = MongoModel.create(string_field: "String") + doc.save + "Statement".prepnd("Failing") + end + + def get_crash + MongoModel.where(string_field: true).as_json + MongoModel.any_of({string_field: true}, {numeric_field: 123}).as_json + "Statement".prepnd("Failing") + end + + def failure_crash + begin + Mongoid::Clients.default.database.command(:bogus => 1) + rescue + end + + "Statement".prepnd("Failing") + end +end diff --git a/features/fixtures/rails5/app/app/jobs/notify_job.rb b/features/fixtures/rails5/app/app/jobs/notify_job.rb new file mode 100644 index 000000000..56c5dfcbc --- /dev/null +++ b/features/fixtures/rails5/app/app/jobs/notify_job.rb @@ -0,0 +1,5 @@ +class NotifyJob < ApplicationJob + def perform + Bugsnag.notify("Failed") + end +end \ No newline at end of file diff --git a/features/fixtures/rails5/app/app/models/mongo_model.rb b/features/fixtures/rails5/app/app/models/mongo_model.rb new file mode 100644 index 000000000..5e9bad828 --- /dev/null +++ b/features/fixtures/rails5/app/app/models/mongo_model.rb @@ -0,0 +1,6 @@ +class MongoModel + include Mongoid::Document + + field :string_field, type: String + field :numeric_field, type: Integer +end \ No newline at end of file diff --git a/features/fixtures/rails5/app/config/initializers/bugsnag.rb b/features/fixtures/rails5/app/config/initializers/bugsnag.rb index 972abde2a..95788bbbc 100644 --- a/features/fixtures/rails5/app/config/initializers/bugsnag.rb +++ b/features/fixtures/rails5/app/config/initializers/bugsnag.rb @@ -11,4 +11,10 @@ config.release_stage = ENV["BUGSNAG_RELEASE_STAGE"] if ENV.include? "BUGSNAG_RELEASE_STAGE" config.send_code = ENV["BUGSNAG_SEND_CODE"] != "false" config.send_environment = ENV["BUGSNAG_SEND_ENVIRONMENT"] == "true" + + if ENV["SQL_ONLY_BREADCRUMBS"] == "true" + config.before_breadcrumb_callbacks << Proc.new do |breadcrumb| + breadcrumb.ignore! unless breadcrumb.meta_data[:event_name] == "sql.active_record" && breadcrumb.meta_data[:name] == "User Load" + end + end end diff --git a/features/fixtures/rails5/app/config/mongoid.yml b/features/fixtures/rails5/app/config/mongoid.yml new file mode 100644 index 000000000..fa5e39c33 --- /dev/null +++ b/features/fixtures/rails5/app/config/mongoid.yml @@ -0,0 +1,23 @@ +development: + # Configure available database clients. (required) + clients: + # Defines the default client. (required) + default: + # Defines the name of the default database that Mongoid can connect to. + # (required). + database: rails5_development + # Provides the hosts the default client can connect to. Must be an array + # of host:port pairs. (required) + hosts: + - mongo:27017 + +test: + clients: + default: + database: rails5_test + hosts: + - mongo:27017 + options: + read: + mode: :primary + max_pool_size: 1 diff --git a/features/fixtures/rails5/app/config/routes.rb b/features/fixtures/rails5/app/config/routes.rb index 25fbf1837..fc7cbfff7 100644 --- a/features/fixtures/rails5/app/config/routes.rb +++ b/features/fixtures/rails5/app/config/routes.rb @@ -50,4 +50,13 @@ get 'clearance/create', to: 'clearance#create' get 'clearance/unhandled', to: 'clearance#unhandled' get 'clearance/handled', to: 'clearance#handled' + + get 'breadcrumbs/handled', to: 'breadcrumbs#handled' + get 'breadcrumbs/sql_breadcrumb', to: 'breadcrumbs#sql_breadcrumb' + get 'breadcrumbs/active_job', to: 'breadcrumbs#active_job' + get 'breadcrumbs/cache_read', to: 'breadcrumbs#cache_read' + + get 'mongo/success_crash', to: 'mongo#success_crash' + get 'mongo/get_crash', to: 'mongo#get_crash' + get 'mongo/failure_crash', to: 'mongo#failure_crash' end diff --git a/features/rails_features/breadcrumbs.feature b/features/rails_features/breadcrumbs.feature new file mode 100644 index 000000000..9b77dffda --- /dev/null +++ b/features/rails_features/breadcrumbs.feature @@ -0,0 +1,135 @@ +Feature: Rails automatic breadcrumbs + +Background: + Given I set environment variable "BUGSNAG_API_KEY" to "a35a2a72bd230ac0aa0f52715bbdc6aa" + And I set environment variable "APP_PATH" to "/usr/src" + And I configure the bugsnag endpoint + +Scenario Outline: Request breadcrumb + Given I set environment variable "RUBY_VERSION" to "" + And I start the service "rails" + And I wait for the app to respond on port "6128" + When I navigate to the route "/breadcrumbs/handled" on port "6128" + Then I should receive a request + And the request is a valid for the error reporting API + And the request used the "Ruby Bugsnag Notifier" notifier + And the request contained the api key "a35a2a72bd230ac0aa0f52715bbdc6aa" + And the event has a "request" breadcrumb named "Controller started processing" + And the event "breadcrumbs.0.timestamp" is a timestamp + And the event "breadcrumbs.0.metaData.controller" equals "BreadcrumbsController" + And the event "breadcrumbs.0.metaData.action" equals "handled" + And the event "breadcrumbs.0.metaData.method" equals "GET" + And the event "breadcrumbs.0.metaData.path" equals "/breadcrumbs/handled" + And the event "breadcrumbs.0.metaData.event_name" equals "start_processing.action_controller" + And the event "breadcrumbs.0.metaData.event_id" is not null + + Examples: + | ruby_version | rails_version | + | 2.0 | 3 | + | 2.1 | 3 | + | 2.2 | 3 | + | 2.2 | 4 | + | 2.2 | 5 | + | 2.3 | 3 | + | 2.3 | 4 | + | 2.3 | 5 | + | 2.4 | 3 | + | 2.4 | 5 | + | 2.5 | 3 | + | 2.5 | 5 | + +Scenario Outline: SQL Breadcrumb without bindings + Given I set environment variable "RUBY_VERSION" to "" + And I set environment variable "SQL_ONLY_BREADCRUMBS" to "true" + And I start the service "rails" + And I wait for the app to respond on port "6128" + When I navigate to the route "/breadcrumbs/sql_breadcrumb" on port "6128" + Then I should receive a request + And the request is a valid for the error reporting API + And the request used the "Ruby Bugsnag Notifier" notifier + And the request contained the api key "a35a2a72bd230ac0aa0f52715bbdc6aa" + And the event has a "process" breadcrumb named "ActiveRecord SQL query" + And the event "breadcrumbs.0.timestamp" is a timestamp + And the event "breadcrumbs.0.metaData.name" equals "User Load" + And the event "breadcrumbs.0.metaData.connection_id" is not null + And the event "breadcrumbs.0.metaData.event_name" equals "sql.active_record" + And the event "breadcrumbs.0.metaData.event_id" is not null + + Examples: + | ruby_version | rails_version | + | 2.0 | 3 | + | 2.1 | 3 | + | 2.2 | 3 | + | 2.2 | 4 | + | 2.3 | 3 | + | 2.3 | 4 | + | 2.4 | 3 | + | 2.5 | 3 | + +Scenario Outline: SQL Breadcrumb with bindings + Given I set environment variable "RUBY_VERSION" to "" + And I set environment variable "SQL_ONLY_BREADCRUMBS" to "true" + And I start the service "rails" + And I wait for the app to respond on port "6128" + When I navigate to the route "/breadcrumbs/sql_breadcrumb" on port "6128" + Then I should receive a request + And the request is a valid for the error reporting API + And the request used the "Ruby Bugsnag Notifier" notifier + And the request contained the api key "a35a2a72bd230ac0aa0f52715bbdc6aa" + And the event has a "process" breadcrumb named "ActiveRecord SQL query" + And the event "breadcrumbs.0.timestamp" is a timestamp + And the event "breadcrumbs.0.metaData.name" equals "User Load" + And the event "breadcrumbs.0.metaData.connection_id" is not null + And the event "breadcrumbs.0.metaData.event_name" equals "sql.active_record" + And the event "breadcrumbs.0.metaData.event_id" is not null + And the event "breadcrumbs.0.metaData.binds" equals "{"email":"?","LIMIT":"?"}" + + Examples: + | ruby_version | rails_version | + | 2.2 | 5 | + | 2.3 | 5 | + | 2.4 | 5 | + | 2.5 | 5 | + +Scenario Outline: Active job breadcrumb + Given I set environment variable "RUBY_VERSION" to "" + And I start the service "rails" + And I wait for the app to respond on port "6128" + When I navigate to the route "/breadcrumbs/active_job" on port "6128" + Then I should receive a request + And the request is a valid for the error reporting API + And the request used the "Ruby Bugsnag Notifier" notifier + And the request contained the api key "a35a2a72bd230ac0aa0f52715bbdc6aa" + And the event has a "process" breadcrumb named "Start perform ActiveJob" + And the event "breadcrumbs.0.timestamp" is a timestamp + And the event "breadcrumbs.0.metaData.event_name" equals "perform_start.active_job" + And the event "breadcrumbs.0.metaData.event_id" is not null + + Examples: + | ruby_version | rails_version | + | 2.2 | 4 | + | 2.2 | 5 | + | 2.3 | 4 | + | 2.3 | 5 | + | 2.4 | 5 | + | 2.5 | 5 | + +Scenario Outline: Cache read + Given I set environment variable "RUBY_VERSION" to "" + And I start the service "rails" + And I wait for the app to respond on port "6128" + When I navigate to the route "/breadcrumbs/cache_read" on port "6128" + Then I should receive a request + And the request is a valid for the error reporting API + And the request used the "Ruby Bugsnag Notifier" notifier + And the request contained the api key "a35a2a72bd230ac0aa0f52715bbdc6aa" + And the event has a "process" breadcrumb named "Read cache" + + Examples: + | ruby_version | rails_version | + | 2.2 | 4 | + | 2.2 | 5 | + | 2.3 | 4 | + | 2.3 | 5 | + | 2.4 | 5 | + | 2.5 | 5 | diff --git a/features/rails_features/mongo_breadcrumbs.feature b/features/rails_features/mongo_breadcrumbs.feature new file mode 100644 index 000000000..7085e039a --- /dev/null +++ b/features/rails_features/mongo_breadcrumbs.feature @@ -0,0 +1,100 @@ +Feature: Mongo automatic breadcrumbs + +Background: + Given I set environment variable "BUGSNAG_API_KEY" to "a35a2a72bd230ac0aa0f52715bbdc6aa" + And I set environment variable "APP_PATH" to "/usr/src" + And I configure the bugsnag endpoint + +Scenario Outline: Successful breadcrumbs + Given I set environment variable "RUBY_VERSION" to "" + And I start the service "rails" + And I wait for the app to respond on port "6128" + When I navigate to the route "/mongo/success_crash" on port "6128" + Then I should receive a request + And the request is a valid for the error reporting API + And the request used the "Ruby Bugsnag Notifier" notifier + And the request contained the api key "a35a2a72bd230ac0aa0f52715bbdc6aa" + And the event has a "process" breadcrumb named "Mongo query succeeded" + And the event "breadcrumbs.1.timestamp" is a timestamp + And the event "breadcrumbs.1.metaData.event_name" equals "mongo.succeeded" + And the event "breadcrumbs.1.metaData.command_name" equals "insert" + And the event "breadcrumbs.1.metaData.database_name" equals "rails_development" + And the event "breadcrumbs.1.metaData.operation_id" is not null + And the event "breadcrumbs.1.metaData.request_id" is not null + And the event "breadcrumbs.1.metaData.duration" is not null + And the event "breadcrumbs.1.metaData.collection" equals "mongo_models" + + Examples: + | ruby_version | rails_version | + | 2.2 | 4 | + | 2.2 | 5 | + | 2.3 | 4 | + | 2.3 | 5 | + | 2.4 | 5 | + | 2.5 | 5 | + +Scenario Outline: Breadcrumb with filter parameters + Given I set environment variable "RUBY_VERSION" to "" + And I start the service "rails" + And I wait for the app to respond on port "6128" + When I navigate to the route "/mongo/get_crash" on port "6128" + Then I should receive a request + And the request is a valid for the error reporting API + And the request used the "Ruby Bugsnag Notifier" notifier + And the request contained the api key "a35a2a72bd230ac0aa0f52715bbdc6aa" + And the event has a "process" breadcrumb named "Mongo query succeeded" + And the event "breadcrumbs.1.timestamp" is a timestamp + And the event "breadcrumbs.1.metaData.event_name" equals "mongo.succeeded" + And the event "breadcrumbs.1.metaData.command_name" equals "find" + And the event "breadcrumbs.1.metaData.database_name" equals "rails_development" + And the event "breadcrumbs.1.metaData.operation_id" is not null + And the event "breadcrumbs.1.metaData.request_id" is not null + And the event "breadcrumbs.1.metaData.duration" is not null + And the event "breadcrumbs.1.metaData.collection" equals "mongo_models" + And the event "breadcrumbs.1.metaData.filter" equals "{"string_field":"?"}" + And the event "breadcrumbs.2.timestamp" is a timestamp + And the event "breadcrumbs.2.metaData.event_name" equals "mongo.succeeded" + And the event "breadcrumbs.2.metaData.command_name" equals "find" + And the event "breadcrumbs.2.metaData.database_name" equals "rails_development" + And the event "breadcrumbs.2.metaData.operation_id" is not null + And the event "breadcrumbs.2.metaData.request_id" is not null + And the event "breadcrumbs.2.metaData.duration" is not null + And the event "breadcrumbs.2.metaData.collection" equals "mongo_models" + And the event "breadcrumbs.2.metaData.filter" equals "{"$or":[{"string_field":"?"},{"numeric_field":"?"}]}" + + Examples: + | ruby_version | rails_version | + | 2.2 | 4 | + | 2.2 | 5 | + | 2.3 | 4 | + | 2.3 | 5 | + | 2.4 | 5 | + | 2.5 | 5 | + +Scenario Outline: Failure breadcrumbs + Given I set environment variable "RUBY_VERSION" to "" + And I start the service "rails" + And I wait for the app to respond on port "6128" + When I navigate to the route "/mongo/failure_crash" on port "6128" + Then I should receive a request + And the request is a valid for the error reporting API + And the request used the "Ruby Bugsnag Notifier" notifier + And the request contained the api key "a35a2a72bd230ac0aa0f52715bbdc6aa" + And the event has a "process" breadcrumb named "Mongo query failed" + And the event "breadcrumbs.1.timestamp" is a timestamp + And the event "breadcrumbs.1.metaData.event_name" equals "mongo.failed" + And the event "breadcrumbs.1.metaData.command_name" equals "bogus" + And the event "breadcrumbs.1.metaData.database_name" equals "rails_development" + And the event "breadcrumbs.1.metaData.operation_id" is not null + And the event "breadcrumbs.1.metaData.request_id" is not null + And the event "breadcrumbs.1.metaData.duration" is not null + And the event "breadcrumbs.1.metaData.collection" equals 1 + + Examples: + | ruby_version | rails_version | + | 2.2 | 4 | + | 2.2 | 5 | + | 2.3 | 4 | + | 2.3 | 5 | + | 2.4 | 5 | + | 2.5 | 5 | \ No newline at end of file diff --git a/lib/bugsnag.rb b/lib/bugsnag.rb index b57a293df..b20f3c5e0 100644 --- a/lib/bugsnag.rb +++ b/lib/bugsnag.rb @@ -29,9 +29,13 @@ 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] + INTEGRATIONS = [:resque, :sidekiq, :mailman, :delayed_job, :shoryuken, :que, :mongo] NIL_EXCEPTION_DESCRIPTION = "'nil' was notified as an exception" @@ -112,6 +116,8 @@ def notify(exception, auto_notify=false, &block) options = {:headers => report.headers} payload = ::JSON.dump(Bugsnag::Helpers.trim_if_needed(report.as_json)) Bugsnag::Delivery[configuration.delivery_method].deliver(configuration.notify_endpoint, payload, configuration, options) + report_summary = report.summary + leave_breadcrumb(report_summary[:error_class], report_summary, Bugsnag::Breadcrumbs::ERROR_BREADCRUMB_TYPE, :auto) end end @@ -190,6 +196,39 @@ 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 + + # Return early if ignored + return if breadcrumb.ignore? + + # Validate again in case 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/breadcrumbs/breadcrumb.rb b/lib/bugsnag/breadcrumbs/breadcrumb.rb new file mode 100644 index 000000000..bbae2e656 --- /dev/null +++ b/lib/bugsnag/breadcrumbs/breadcrumb.rb @@ -0,0 +1,76 @@ +module Bugsnag::Breadcrumbs + class Breadcrumb + # @return [String] the breadcrumb name + attr_accessor :name + + # @return [String] the breadcrumb type + attr_accessor :type + + # @return [Hash, nil] metadata hash containing strings, numbers, or booleans, or nil + attr_accessor :meta_data + + # @return [Boolean] set to `true` if the breadcrumb was automatically generated + attr_reader :auto + + # @return [Time] a Time object referring to breadcrumb creation time + attr_reader :timestamp + + ## + # Creates a breadcrumb + # + # This will not have been validated, which must occur before this is attached to a report + # + # @api private + # + # @param name [String] the breadcrumb name + # @param type [String] the breadcrumb type from Bugsnag::Breadcrumbs::VALID_BREADCRUMB_TYPES + # @param meta_data [Hash, nil] a hash containing strings, numbers, or booleans, or nil + # @param auto [Symbol] set to `:auto` if the breadcrumb is automatically generated + def initialize(name, type, meta_data, auto) + @should_ignore = false + self.name = name + self.type = type + self.meta_data = meta_data + + # Use the symbol comparison to improve readability of breadcrumb creation + @auto = auto == :auto + + # Store it as a timestamp for now + @timestamp = Time.now.utc + end + + ## + # Flags the breadcrumb to be ignored + # + # Ignored breadcrumbs will not be attached to a report + def ignore! + @should_ignore = true + end + + ## + # Checks if the `ignore!` method has been called + # + # Ignored breadcrumbs will not be attached to a report + # + # @return [True] if `ignore!` has been called + # @return [nil] if `ignore` has not been called + def ignore? + @should_ignore + end + + ## + # Outputs the breadcrumb data in a formatted hash + # + # These adhere to the breadcrumb format as defined in the Bugsnag error reporting API + # + # @return [Hash] Hash representation of the breadcrumb + def to_h + { + :name => @name, + :type => @type, + :metaData => @meta_data, + :timestamp => @timestamp.iso8601 + } + end + end +end diff --git a/lib/bugsnag/breadcrumbs/breadcrumbs.rb b/lib/bugsnag/breadcrumbs/breadcrumbs.rb new file mode 100644 index 000000000..24f140e1d --- /dev/null +++ b/lib/bugsnag/breadcrumbs/breadcrumbs.rb @@ -0,0 +1,14 @@ +module Bugsnag::Breadcrumbs + MAX_NAME_LENGTH = 30 + + VALID_BREADCRUMB_TYPES = [ + ERROR_BREADCRUMB_TYPE = "error", + MANUAL_BREADCRUMB_TYPE = "manual", + NAVIGATION_BREADCRUMB_TYPE = "navigation", + REQUEST_BREADCRUMB_TYPE = "request", + PROCESS_BREADCRUMB_TYPE = "process", + LOG_BREADCRUMB_TYPE = "log", + USER_BREADCRUMB_TYPE = "user", + STATE_BREADCRUMB_TYPE = "state" + ].freeze +end diff --git a/lib/bugsnag/breadcrumbs/validator.rb b/lib/bugsnag/breadcrumbs/validator.rb new file mode 100644 index 000000000..2cea4ba7e --- /dev/null +++ b/lib/bugsnag/breadcrumbs/validator.rb @@ -0,0 +1,59 @@ +require 'bugsnag/breadcrumbs/breadcrumbs' + +module Bugsnag::Breadcrumbs + ## + # Validates a given breadcrumb before it is stored + class Validator + ## + # @param configuration [Bugsnag::Configuration] The current configuration + def initialize(configuration) + @configuration = configuration + end + + ## + # Validates a given breadcrumb. + # + # @param breadcrumb [Bugsnag::Breadcrumbs::Breadcrumb] the breadcrumb to be validated + def validate(breadcrumb) + # Check name length + if breadcrumb.name.size > Bugsnag::Breadcrumbs::MAX_NAME_LENGTH + @configuration.warn("Breadcrumb name trimmed to length #{Bugsnag::Breadcrumbs::MAX_NAME_LENGTH}. Original name: #{breadcrumb.name}") + breadcrumb.name = breadcrumb.name.slice(0...Bugsnag::Breadcrumbs::MAX_NAME_LENGTH) + end + + # Check meta_data hash doesn't contain complex values + breadcrumb.meta_data = breadcrumb.meta_data.select do |k, v| + if valid_meta_data_type?(v) + true + else + @configuration.warn("Breadcrumb #{breadcrumb.name} meta_data #{k}:#{v} has been dropped for having an invalid data type") + false + end + end + + # Check type is valid, set to manual otherwise + unless Bugsnag::Breadcrumbs::VALID_BREADCRUMB_TYPES.include?(breadcrumb.type) + @configuration.warn("Invalid type: #{breadcrumb.type} for breadcrumb: #{breadcrumb.name}, defaulting to #{Bugsnag::Breadcrumbs::MANUAL_BREADCRUMB_TYPE}") + breadcrumb.type = Bugsnag::Breadcrumbs::MANUAL_BREADCRUMB_TYPE + end + + # If auto is true, check type is in enabled_automatic_breadcrumb_types + return unless breadcrumb.auto && !@configuration.enabled_automatic_breadcrumb_types.include?(breadcrumb.type) + + @configuration.warn("Automatic breadcrumb of type #{breadcrumb.type} ignored: #{breadcrumb.name}") + breadcrumb.ignore! + end + + private + + ## + # Tests whether the meta_data types are non-complex objects. + # + # Acceptable types are String, Numeric, TrueClass, FalseClass, and nil. + # + # @param value [Object] the object to be type checked + def valid_meta_data_type?(value) + value.nil? || value.is_a?(String) || value.is_a?(Numeric) || value.is_a?(FalseClass) || value.is_a?(TrueClass) + end + end +end diff --git a/lib/bugsnag/configuration.rb b/lib/bugsnag/configuration.rb index 13a0d8349..b51880887 100644 --- a/lib/bugsnag/configuration.rb +++ b/lib/bugsnag/configuration.rb @@ -8,6 +8,9 @@ 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" module Bugsnag class Configuration @@ -48,6 +51,18 @@ class Configuration # @return [Boolean] whether any sessions types will be delivered attr_reader :enable_sessions + ## + # @return [Array] strings indicating allowable automatic breadcrumb types + attr_accessor :enabled_automatic_breadcrumb_types + + ## + # @return [Array<#call>] callables to be run before a breadcrumb is logged + attr_accessor :before_breadcrumb_callbacks + + ## + # @return [Integer] the maximum allowable amount of breadcrumbs per thread + attr_reader :max_breadcrumbs + API_KEY_REGEX = /[0-9a-f]{32}/i THREAD_LOCAL_NAME = "bugsnag_req_data" @@ -64,6 +79,8 @@ class Configuration "rack.request.form_vars" ].freeze + DEFAULT_MAX_BREADCRUMBS = 25 + alias :track_sessions :auto_capture_sessions alias :track_sessions= :auto_capture_sessions= @@ -80,6 +97,14 @@ def initialize self.notify_release_stages = nil self.auto_capture_sessions = true + # All valid breadcrumb types should be allowable initially + self.enabled_automatic_breadcrumb_types = Bugsnag::Breadcrumbs::VALID_BREADCRUMB_TYPES.dup + self.before_breadcrumb_callbacks = [] + + # Store max_breadcrumbs here instead of outputting breadcrumbs.max_items + # to avoid infinite recursion when creating breadcrumb buffer + @max_breadcrumbs = DEFAULT_MAX_BREADCRUMBS + # These are set exclusively using the "set_endpoints" method @notify_endpoint = DEFAULT_NOTIFY_ENDPOINT @session_endpoint = DEFAULT_SESSION_ENDPOINT @@ -111,6 +136,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 @@ -207,6 +233,22 @@ def parse_proxy(uri) end ## + # Sets the maximum allowable amount of breadcrumbs + # + # @param [Integer] the new maximum breadcrumb limit + def max_breadcrumbs=(new_max_breadcrumbs) + @max_breadcrumbs = new_max_breadcrumbs + breadcrumbs.max_items = new_max_breadcrumbs + end + + ## + # Returns the breadcrumb circular buffer + # + # @return [Bugsnag::Utility::CircularBuffer] a thread based circular buffer containing breadcrumbs + def breadcrumbs + request_data[:breadcrumbs] ||= Bugsnag::Utility::CircularBuffer.new(@max_breadcrumbs) + end + # Sets the notification endpoint # # @param new_notify_endpoint [String] The URL to deliver error notifications to diff --git a/lib/bugsnag/integrations/mongo.rb b/lib/bugsnag/integrations/mongo.rb new file mode 100644 index 000000000..78ef1f80c --- /dev/null +++ b/lib/bugsnag/integrations/mongo.rb @@ -0,0 +1,132 @@ +require 'mongo' +require 'bugsnag/breadcrumbs/breadcrumbs' + +module Bugsnag + ## + # Subscribes to, and creates breadcrumbs from, mongo_ruby_driver events + # + # @api private + class MongoBreadcrumbSubscriber + MONGO_MESSAGE_PREFIX = "Mongo query " + MONGO_EVENT_PREFIX = "mongo." + MONGO_COMMAND_KEY = :bugsnag_mongo_commands + MAX_FILTER_DEPTH = 5 + + ## + # Listens to the 'started' event, storing the command for later usage + # + # @param event [Mongo::Event::Base] the mongo_ruby_driver generated event + def started(event) + leave_command(event) + end + + ## + # Listens to the 'succeeded' event, leaving a breadcrumb + # + # @param event [Mongo::Event::Base] the mongo_ruby_driver generated event + def succeeded(event) + leave_mongo_breadcrumb("succeeded", event) + end + + ## + # Listens to the 'failed' event, leaving a breadcrumb + # + # @param event [Mongo::Event::Base] the mongo_ruby_driver generated event + def failed(event) + leave_mongo_breadcrumb("failed", event) + end + + private + + ## + # Generates breadcrumb data from an event + # + # @param event_name [String] the type of event + # @param event [Mongo::Event::Base] the mongo_ruby_driver generated event + def leave_mongo_breadcrumb(event_name, event) + message = MONGO_MESSAGE_PREFIX + event_name + meta_data = { + :event_name => MONGO_EVENT_PREFIX + event_name, + :command_name => event.command_name, + :database_name => event.database_name, + :operation_id => event.operation_id, + :request_id => event.request_id, + :duration => event.duration + } + if (command = pop_command(event.request_id)) + collection_key = event.command_name == "getMore" ? "collection" : event.command_name + meta_data[:collection] = command[collection_key] + unless command["filter"].nil? + filter = sanitize_filter_hash(command["filter"]) + meta_data[:filter] = JSON.dump(filter) + end + end + meta_data[:message] = event.message if defined?(event.message) + + Bugsnag.leave_breadcrumb(message, meta_data, Bugsnag::Breadcrumbs::PROCESS_BREADCRUMB_TYPE, :auto) + end + + ## + # Removes values from filter hashes, replacing them with '?' + # + # @param filter_hash [Hash] the filter hash for the mongo transaction + # @param depth [Integer] the current filter depth + # + # @return [Hash] the filtered hash + def sanitize_filter_hash(filter_hash, depth = 0) + filter_hash.each_with_object({}) do |(key, value), output| + output[key] = sanitize_filter_value(value, depth) + end + end + + ## + # Transforms a value element into a useful, redacted, version + # + # @param value [Object] the filter value + # @param depth [Integer] the current filter depth + # + # @return [Array, Hash, String] the sanitized value + def sanitize_filter_value(value, depth) + depth += 1 + if depth >= MAX_FILTER_DEPTH + '[MAX_FILTER_DEPTH_REACHED]' + elsif value.is_a?(Array) + value.map { |array_value| sanitize_filter_value(array_value, depth) } + elsif value.is_a?(Hash) + sanitize_filter_hash(value, depth) + else + '?' + end + end + + ## + # Stores the mongo command in the request data by the request_id + # + # @param event [Mongo::Event::Base] the mongo_ruby_driver generated event + def leave_command(event) + event_commands[event.request_id] = event.command + end + + ## + # Removes and retrieves a stored command from the request data + # + # @param request_id [String] the id of the mongo_ruby_driver event + # + # @return [Hash, nil] the requested command, or nil if not found + def pop_command(request_id) + event_commands.delete(request_id) + end + + ## + # Provides access to a thread-based mongo event command hash + # + # @return [Hash] the hash of mongo event commands + def event_commands + Bugsnag.configuration.request_data[MONGO_COMMAND_KEY] ||= {} + end + end +end + +## +# Add the subscriber to the global Mongo monitoring object +Mongo::Monitoring::Global.subscribe(Mongo::Monitoring::COMMAND, Bugsnag::MongoBreadcrumbSubscriber.new) diff --git a/lib/bugsnag/integrations/rails/rails_breadcrumbs.rb b/lib/bugsnag/integrations/rails/rails_breadcrumbs.rb new file mode 100644 index 000000000..696161ad1 --- /dev/null +++ b/lib/bugsnag/integrations/rails/rails_breadcrumbs.rb @@ -0,0 +1,118 @@ +require "bugsnag/breadcrumbs/breadcrumbs" + +module Bugsnag::Rails + DEFAULT_RAILS_BREADCRUMBS = [ + { + :id => "perform_action.action_cable", + :message => "Perform ActionCable", + :type => Bugsnag::Breadcrumbs::PROCESS_BREADCRUMB_TYPE, + :allowed_data => [ + :channel_class, + :action + ] + }, + { + :id => "perform_start.active_job", + :message => "Start perform ActiveJob", + :type => Bugsnag::Breadcrumbs::PROCESS_BREADCRUMB_TYPE, + :allowed_data => [] + }, + { + :id => "cache_read.active_support", + :message => "Read cache", + :type => Bugsnag::Breadcrumbs::PROCESS_BREADCRUMB_TYPE, + :allowed_data => [ + :key, + :hit, + :super_operation + ] + }, + { + :id => "cache_fetch_hit.active_support", + :message => "Fetch cache hit", + :type => Bugsnag::Breadcrumbs::PROCESS_BREADCRUMB_TYPE, + :allowed_data => [ + :key + ] + }, + { + :id => "sql.active_record", + :message => "ActiveRecord SQL query", + :type => Bugsnag::Breadcrumbs::PROCESS_BREADCRUMB_TYPE, + :allowed_data => [ + :name, + :connection_id, + :cached + ] + }, + { + :id => "start_processing.action_controller", + :message => "Controller started processing", + :type => Bugsnag::Breadcrumbs::REQUEST_BREADCRUMB_TYPE, + :allowed_data => [ + :controller, + :action, + :method, + :path + ] + }, + { + :id => "process_action.action_controller", + :message => "Controller action processed", + :type => Bugsnag::Breadcrumbs::REQUEST_BREADCRUMB_TYPE, + :allowed_data => [ + :controller, + :action, + :method, + :status, + :db_runtime + ] + }, + { + :id => "redirect_to.action_controller", + :message => "Controller redirect", + :type => Bugsnag::Breadcrumbs::REQUEST_BREADCRUMB_TYPE, + :allowed_data => [ + :status, + :location + ] + }, + { + :id => "halted_callback.action_controller", + :message => "Controller halted via callback", + :type => Bugsnag::Breadcrumbs::REQUEST_BREADCRUMB_TYPE, + :allowed_data => [ + :filter + ] + }, + { + :id => "render_template.action_view", + :message => "ActionView template rendered", + :type => Bugsnag::Breadcrumbs::REQUEST_BREADCRUMB_TYPE, + :allowed_data => [ + :identifier, + :layout + ] + }, + { + :id => "render_partial.action_view", + :message => "ActionView partial rendered", + :type => Bugsnag::Breadcrumbs::REQUEST_BREADCRUMB_TYPE, + :allowed_data => [ + :identifier + ] + }, + { + :id => "deliver.action_mailer", + :message => "ActionMail delivered", + :type => Bugsnag::Breadcrumbs::REQUEST_BREADCRUMB_TYPE, + :allowed_data => [ + :mailer, + :message_id, + :from, + :date, + :perform_deliveries + ] + } + ] +end diff --git a/lib/bugsnag/integrations/railtie.rb b/lib/bugsnag/integrations/railtie.rb index 500ad1298..294e94975 100644 --- a/lib/bugsnag/integrations/railtie.rb +++ b/lib/bugsnag/integrations/railtie.rb @@ -1,12 +1,14 @@ # Rails 3.x hooks +require "json" require "rails" require "bugsnag" require "bugsnag/middleware/rails3_request" require "bugsnag/middleware/rack_request" +require "bugsnag/integrations/rails/rails_breadcrumbs" module Bugsnag - class Railtie < Rails::Railtie + class Railtie < ::Rails::Railtie FRAMEWORK_ATTRIBUTES = { :framework => "Rails" @@ -38,6 +40,8 @@ class Railtie < Rails::Railtie include Bugsnag::Rails::ActiveRecordRescue end + Bugsnag::Rails::DEFAULT_RAILS_BREADCRUMBS.each { |event| event_subscription(event) } + Bugsnag.configuration.app_type = "rails" end @@ -63,5 +67,28 @@ class Railtie < Rails::Railtie app.config.middleware.use Bugsnag::Rack end end + + ## + # Subscribes to an ActiveSupport event, leaving a breadcrumb when it triggers + # + # @api private + # @param event [Hash] details of the event to subscribe to + def event_subscription(event) + ActiveSupport::Notifications.subscribe(event[:id]) do |*, event_id, data| + filtered_data = data.slice(*event[:allowed_data]) + filtered_data[:event_name] = event[:id] + filtered_data[:event_id] = event_id + if event[:id] == "sql.active_record" + binds = data[:binds].each_with_object({}) { |bind, output| output[bind.name] = '?' if defined?(bind.name) } + filtered_data[:binds] = JSON.dump(binds) unless binds.empty? + end + Bugsnag.leave_breadcrumb( + event[:message], + filtered_data, + event[:type], + :auto + ) + end + end end end 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..c48666340 100644 --- a/lib/bugsnag/report.rb +++ b/lib/bugsnag/report.rb @@ -23,6 +23,7 @@ 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 @@ -51,6 +52,7 @@ 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.meta_data = {} @@ -110,7 +112,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 +162,26 @@ def ignore! @should_ignore = true end + ## + # Generates a summary to be attached as a breadcrumb + # + # @return [Hash] a Hash containing the report's error class, error message, and severity + def summary + # Guard against the exceptions array being removed/changed or emptied here + if exceptions.respond_to?(:first) && exceptions.first + { + :error_class => exceptions.first[:errorClass], + :message => exceptions.first[:message], + :severity => severity + } + else + { + :error_class => "Unknown", + :severity => severity + } + end + end + private def generate_exception_list diff --git a/lib/bugsnag/utility/circular_buffer.rb b/lib/bugsnag/utility/circular_buffer.rb new file mode 100644 index 000000000..08d39b0fe --- /dev/null +++ b/lib/bugsnag/utility/circular_buffer.rb @@ -0,0 +1,62 @@ +module Bugsnag::Utility + ## + # A container class with a maximum size, that removes oldest items as required. + # + # @api private + class CircularBuffer + include Enumerable + + # @return [Integer] the current maximum allowable number of items + attr_reader :max_items + + ## + # @param max_items [Integer] the initial maximum number of items + def initialize(max_items = 25) + @max_items = max_items + @buffer = [] + end + + ## + # Adds an item to the circular buffer + # + # If this causes the buffer to exceed its maximum items, the oldest item will be removed + # + # @param item [Object] the item to add to the buffer + # @return [self] returns itself to allow method chaining + def <<(item) + @buffer << item + trim_buffer + self + end + + ## + # Iterates over the buffer + # + # @yield [Object] sequentially gives stored items to the block + def each(&block) + @buffer.each(&block) + end + + ## + # Sets the maximum allowable number of items + # + # If the current number of items exceeds the new maximum, oldest items will be removed + # until this is no longer the case + # + # @param new_max_items [Integer] the new allowed item maximum + def max_items=(new_max_items) + @max_items = new_max_items + trim_buffer + end + + private + + ## + # Trims the buffer down to the current maximum allowable item number + def trim_buffer + trim_size = @buffer.size - @max_items + trim_size = 0 if trim_size < 0 + @buffer.shift(trim_size) + end + end +end diff --git a/spec/breadcrumbs/breadcrumb_spec.rb b/spec/breadcrumbs/breadcrumb_spec.rb new file mode 100644 index 000000000..3f315b327 --- /dev/null +++ b/spec/breadcrumbs/breadcrumb_spec.rb @@ -0,0 +1,93 @@ +# encoding: utf-8 + +require 'spec_helper' + +require 'bugsnag/breadcrumbs/breadcrumb' + +RSpec.describe Bugsnag::Breadcrumbs::Breadcrumb do + describe "#name" do + it "is assigned in #initialize" do + breadcrumb = Bugsnag::Breadcrumbs::Breadcrumb.new("my message", nil, nil, nil) + + expect(breadcrumb.name).to eq("my message") + end + end + + describe "#type" do + it "is assigned in #initialize" do + breadcrumb = Bugsnag::Breadcrumbs::Breadcrumb.new(nil, "test type", nil, nil) + + expect(breadcrumb.type).to eq("test type") + end + end + + describe "#meta_data" do + it "is assigned in #initialize" do + breadcrumb = Bugsnag::Breadcrumbs::Breadcrumb.new(nil, nil, {:a => 1, :b => 2}, nil) + + expect(breadcrumb.meta_data).to eq({:a => 1, :b => 2}) + end + end + + describe "#auto" do + it "defaults to false" do + breadcrumb = Bugsnag::Breadcrumbs::Breadcrumb.new(nil, nil, nil, nil) + + expect(breadcrumb.auto).to eq(false) + end + + it "is true if auto argument == :auto" do + breadcrumb = Bugsnag::Breadcrumbs::Breadcrumb.new(nil, nil, nil, :auto) + + expect(breadcrumb.auto).to eq(true) + end + + it "is false if auto argument is anything else" do + breadcrumb = Bugsnag::Breadcrumbs::Breadcrumb.new(nil, nil, nil, :manual) + + expect(breadcrumb.auto).to eq(false) + end + end + + describe "#timestamp" do + it "is stored as a timestamp" do + breadcrumb = Bugsnag::Breadcrumbs::Breadcrumb.new(nil, nil, nil, nil) + + expect(breadcrumb.timestamp).to be_within(0.5).of Time.now.utc + end + end + + describe "#ignore?" do + it "is not true by default" do + breadcrumb = Bugsnag::Breadcrumbs::Breadcrumb.new("my message", "test type", {:a => 1, :b => 2}, :manual) + + expect(breadcrumb.ignore?).to eq(false) + end + + it "is able to be set" do + breadcrumb = Bugsnag::Breadcrumbs::Breadcrumb.new("my message", "test type", {:a => 1, :b => 2}, :manual) + breadcrumb.ignore! + + expect(breadcrumb.ignore?).to eq(true) + end + end + + describe "#to_h" do + it "outputs as a hash" do + breadcrumb = Bugsnag::Breadcrumbs::Breadcrumb.new("my message", "test type", {:a => 1, :b => 2}, :manual) + output = breadcrumb.to_h + + timestamp_regex = /^\d{4}\-\d{2}\-\d{2}T\d{2}:\d{2}:[\d\.]+Z$/ + + expect(output).to match( + :name => "my message", + :type => "test type", + :metaData => { + :a => 1, + :b => 2 + }, + :timestamp => eq(breadcrumb.timestamp.iso8601) + ) + end + end +end \ No newline at end of file diff --git a/spec/breadcrumbs/validator_spec.rb b/spec/breadcrumbs/validator_spec.rb new file mode 100644 index 000000000..386c49333 --- /dev/null +++ b/spec/breadcrumbs/validator_spec.rb @@ -0,0 +1,200 @@ +# encoding: utf-8 +require 'spec_helper' + +require 'bugsnag/breadcrumbs/breadcrumb' +require 'bugsnag/breadcrumbs/validator' + +RSpec.describe Bugsnag::Breadcrumbs::Validator do + let(:enabled_automatic_breadcrumb_types) { Bugsnag::Breadcrumbs::VALID_BREADCRUMB_TYPES } + let(:auto) { false } + let(:name) { "Valid message" } + let(:type) { Bugsnag::Breadcrumbs::ERROR_BREADCRUMB_TYPE } + let(:meta_data) { {} } + + describe "#validate" do + it "does not 'ignore!' a valid breadcrumb" do + config = instance_double(Bugsnag::Configuration) + allow(config).to receive(:enabled_automatic_breadcrumb_types).and_return(enabled_automatic_breadcrumb_types) + validator = Bugsnag::Breadcrumbs::Validator.new(config) + + breadcrumb = instance_double(Bugsnag::Breadcrumbs::Breadcrumb, { + :auto => auto, + :name => name, + :type => type, + :meta_data => meta_data, + :meta_data= => nil + }) + + expect(breadcrumb).to_not receive(:ignore!) + expect(config).to_not receive(:warn) + + validator.validate(breadcrumb) + end + + it "trims long messages to length and warns" do + config = instance_double(Bugsnag::Configuration) + allow(config).to receive(:enabled_automatic_breadcrumb_types).and_return(enabled_automatic_breadcrumb_types) + validator = Bugsnag::Breadcrumbs::Validator.new(config) + + name = "1234567890123456789012345678901234567890" + + breadcrumb = instance_double(Bugsnag::Breadcrumbs::Breadcrumb, { + :auto => auto, + :name => name, + :type => type, + :meta_data => meta_data, + :meta_data= => nil + }) + + expect(breadcrumb).to_not receive(:ignore!) + expect(breadcrumb).to receive(:name=).with("123456789012345678901234567890") + expected_string = "Breadcrumb name trimmed to length 30. Original name: #{name}" + expect(config).to receive(:warn).with(expected_string) + + validator.validate(breadcrumb) + # Check the original message has not been modified + expect(name).to eq("1234567890123456789012345678901234567890") + end + + describe "tests meta_data types" do + it "accepts Strings, Numerics, Booleans, & nil" do + config = instance_double(Bugsnag::Configuration) + allow(config).to receive(:enabled_automatic_breadcrumb_types).and_return(enabled_automatic_breadcrumb_types) + validator = Bugsnag::Breadcrumbs::Validator.new(config) + + meta_data = { + :string => "This is a string", + :integer => 12345, + :float => 12345.6789, + :false => false, + :true => true, + :nil => nil + } + + breadcrumb = instance_double(Bugsnag::Breadcrumbs::Breadcrumb, { + :auto => auto, + :name => name, + :type => type, + :meta_data => meta_data, + :meta_data= => nil + }) + + expect(breadcrumb).to_not receive(:ignore!) + expect(config).to_not receive(:warn) + + validator.validate(breadcrumb) + end + + it "rejects Arrays, Hashes, and non-primitive objects" do + config = instance_double(Bugsnag::Configuration) + allow(config).to receive(:enabled_automatic_breadcrumb_types).and_return(enabled_automatic_breadcrumb_types) + validator = Bugsnag::Breadcrumbs::Validator.new(config) + + class TestClass + end + + meta_data = { + :fine => 1, + :array => [1, 2, 3], + :hash => { + :a => 1 + }, + :object => TestClass.new + } + + breadcrumb = instance_double(Bugsnag::Breadcrumbs::Breadcrumb, { + :auto => auto, + :name => name, + :type => type, + :meta_data => meta_data + }) + + expect(breadcrumb).to_not receive(:ignore!) + expected_string_1 = "Breadcrumb #{breadcrumb.name} meta_data array:#{meta_data[:array]} has been dropped for having an invalid data type" + expected_string_2 = "Breadcrumb #{breadcrumb.name} meta_data hash:#{meta_data[:hash]} has been dropped for having an invalid data type" + expected_string_3 = "Breadcrumb #{breadcrumb.name} meta_data object:#{ meta_data[:object]} has been dropped for having an invalid data type" + expect(config).to receive(:warn).with(expected_string_1) + expect(config).to receive(:warn).with(expected_string_2) + expect(config).to receive(:warn).with(expected_string_3) + + # Confirms that the meta_data is being filtered + expect(breadcrumb).to receive(:meta_data=).with({ + :fine => 1 + }) + + validator.validate(breadcrumb) + end + end + + it "tests type, defaulting to 'manual' if invalid" do + config = instance_double(Bugsnag::Configuration) + allow(config).to receive(:enabled_automatic_breadcrumb_types).and_return(enabled_automatic_breadcrumb_types) + validator = Bugsnag::Breadcrumbs::Validator.new(config) + + type = "Not a valid type" + + breadcrumb = instance_double(Bugsnag::Breadcrumbs::Breadcrumb, { + :auto => auto, + :name => name, + :type => type, + :meta_data => meta_data, + :meta_data= => nil + }) + + expect(breadcrumb).to receive(:type=).with(Bugsnag::Breadcrumbs::MANUAL_BREADCRUMB_TYPE) + expect(breadcrumb).to_not receive(:ignore!) + expected_string = "Invalid type: #{type} for breadcrumb: #{breadcrumb.name}, defaulting to #{Bugsnag::Breadcrumbs::MANUAL_BREADCRUMB_TYPE}" + expect(config).to receive(:warn).with(expected_string) + + validator.validate(breadcrumb) + end + + describe "with enabled_automatic_breadcrumb_types set" do + it "rejects automatic breadcrumbs with rejected types" do + config = instance_double(Bugsnag::Configuration) + allowed_breadcrumb_types = [] + allow(config).to receive(:enabled_automatic_breadcrumb_types).and_return(allowed_breadcrumb_types) + validator = Bugsnag::Breadcrumbs::Validator.new(config) + + auto = true + type = Bugsnag::Breadcrumbs::ERROR_BREADCRUMB_TYPE + + breadcrumb = instance_double(Bugsnag::Breadcrumbs::Breadcrumb, { + :auto => auto, + :name => name, + :type => type, + :meta_data => meta_data, + :meta_data= => nil + }) + + expect(breadcrumb).to receive(:ignore!) + expected_string = "Automatic breadcrumb of type #{Bugsnag::Breadcrumbs::ERROR_BREADCRUMB_TYPE} ignored: #{breadcrumb.name}" + expect(config).to receive(:warn).with(expected_string) + + validator.validate(breadcrumb) + end + + it "does not reject manual breadcrumbs with rejected types" do + config = instance_double(Bugsnag::Configuration) + allowed_breadcrumb_types = [] + allow(config).to receive(:enabled_automatic_breadcrumb_types).and_return(allowed_breadcrumb_types) + validator = Bugsnag::Breadcrumbs::Validator.new(config) + + type = Bugsnag::Breadcrumbs::ERROR_BREADCRUMB_TYPE + + breadcrumb = instance_double(Bugsnag::Breadcrumbs::Breadcrumb, { + :auto => auto, + :name => name, + :type => type, + :meta_data => meta_data, + :meta_data= => nil + }) + + expect(breadcrumb).to_not receive(:ignore!) + expect(config).to_not receive(:warn) + + validator.validate(breadcrumb) + end + end + end +end \ No newline at end of file diff --git a/spec/bugsnag_spec.rb b/spec/bugsnag_spec.rb index e5815be85..73e4b1237 100644 --- a/spec/bugsnag_spec.rb +++ b/spec/bugsnag_spec.rb @@ -2,6 +2,10 @@ require 'spec_helper' describe Bugsnag do + + let(:breadcrumbs) { Bugsnag.configuration.breadcrumbs } + let(:timestamp_regex) { /^\d{4}\-\d{2}\-\d{2}T\d{2}:\d{2}:[\d\.]+Z$/ } + describe 'notify' do before do Bugsnag.configuration.logger = spy('logger') @@ -16,6 +20,42 @@ notify_test_exception({severity: 'info'}) expect(Bugsnag.configuration.logger).to have_received(:warn) end + + it 'leaves a breadcrumb after exception delivery' do + begin + 1/0 + rescue ZeroDivisionError => e + Bugsnag.notify(e) + sent_time = Time.now.utc + end + expect(breadcrumbs.to_a.size).to eq(1) + breadcrumb = breadcrumbs.to_a.first + expect(breadcrumb.name).to eq('ZeroDivisionError') + expect(breadcrumb.type).to eq(Bugsnag::Breadcrumbs::ERROR_BREADCRUMB_TYPE) + expect(breadcrumb.auto).to eq(true) + expect(breadcrumb.meta_data).to eq({ + :error_class => 'ZeroDivisionError', + :message => 'divided by 0', + :severity => 'warning' + }) + expect(breadcrumb.timestamp).to be_within(1).of(sent_time) + end + + it 'leave a RuntimeError breadcrumb after string delivery' do + Bugsnag.notify('notified string') + sent_time = Time.now.utc + expect(breadcrumbs.to_a.size).to eq(1) + breadcrumb = breadcrumbs.to_a.first + expect(breadcrumb.name).to eq('RuntimeError') + expect(breadcrumb.type).to eq(Bugsnag::Breadcrumbs::ERROR_BREADCRUMB_TYPE) + expect(breadcrumb.auto).to eq(true) + expect(breadcrumb.meta_data).to eq({ + :error_class => 'RuntimeError', + :message => 'notified string', + :severity => 'warning' + }) + expect(breadcrumb.timestamp).to be_within(1).of(sent_time) + end end describe '#configure' do @@ -178,4 +218,149 @@ module Kernel Kernel.send(:remove_const, :REQUIRED) end end + + describe ".leave_breadcrumb" do + 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 do |breadcrumb| + breadcrumb.meta_data = { + :callback => true + } + end + 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 do |breadcrumb| + breadcrumb.meta_data = { + :int => 1, + :array => [1, 2, 3], + :hash => { + :a => 1, + :b => 2 + } + } + breadcrumb.type = "Not a real type" + breadcrumb.name = "123123123123123123123123123123456456456456456" + end + 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.enabled_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 do |breadcrumb| + breadcrumb.ignore! + end + 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.enabled_automatic_breadcrumb_types = [ + Bugsnag::Breadcrumbs::MANUAL_BREADCRUMB_TYPE + ] + Bugsnag.configuration.before_breadcrumb_callbacks << Proc.new do |breadcrumb| + breadcrumb.type = Bugsnag::Breadcrumbs::ERROR_BREADCRUMB_TYPE + end + 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.enabled_automatic_breadcrumb_types = [] + Bugsnag.configuration.before_breadcrumb_callbacks << Proc.new do |breadcrumb| + fail "This shouldn't be called" + end + 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 do |breadcrumb| + breadcrumb.ignore! + end + Bugsnag.configuration.before_breadcrumb_callbacks << Proc.new do |breadcrumb| + fail "This shouldn't be called" + end + Bugsnag.leave_breadcrumb("TestName") + end + end end diff --git a/spec/configuration_spec.rb b/spec/configuration_spec.rb index be8f744bc..aa8cbb08d 100644 --- a/spec/configuration_spec.rb +++ b/spec/configuration_spec.rb @@ -305,4 +305,95 @@ def debug(name, &block) expect(subject.ignore_classes).to eq(Set.new([SystemExit, SignalException])) end + describe "#breadcrumbs" do + it "first returns a new circular buffer" do + buffer = subject.breadcrumbs + + expect(buffer).to be_a(Bugsnag::Utility::CircularBuffer) + expect(buffer.to_a).to eq([]) + end + + it "returns the same buffer in repeated calls" do + buffer = subject.breadcrumbs + buffer << 1 + second_buffer = subject.breadcrumbs + + expect(second_buffer.to_a).to eq([1]) + end + + it "returns a different buffer on different threads" do + buffer = subject.breadcrumbs + buffer << 1 + + second_buffer = nil + Thread.new { second_buffer = subject.breadcrumbs; second_buffer << 2 }.join + + expect(buffer.to_a).to eq([1]) + expect(second_buffer.to_a).to eq([2]) + end + + it "sets max_items to the current max_breadcrumbs size" do + expect(subject.breadcrumbs.max_items).to eq(subject.max_breadcrumbs) + end + end + + describe "#max_breadcrumbs" do + it "defaults to DEFAULT_MAX_BREADCRUMBS" do + expect(subject.max_breadcrumbs).to eq(Bugsnag::Configuration::DEFAULT_MAX_BREADCRUMBS) + end + end + + describe "#max_breadcrumbs=" do + it "sets the value of max_breadcrumbs" do + subject.max_breadcrumbs = 10 + expect(subject.max_breadcrumbs).to eq(10) + end + + it "sets the max_items property of the breadcrumbs buffer" do + buffer = subject.breadcrumbs + + expect(buffer.max_items).to eq(Bugsnag::Configuration::DEFAULT_MAX_BREADCRUMBS) + + subject.max_breadcrumbs = 5 + + expect(buffer.max_items).to eq(5) + end + end + + describe "#enabled_automatic_breadcrumb_types" do + it "defaults to Bugsnag::Breadcrumbs::VALID_BREADCRUMB_TYPES" do + expect(subject.enabled_automatic_breadcrumb_types).to eq(Bugsnag::Breadcrumbs::VALID_BREADCRUMB_TYPES) + end + + it "is an editable array" do + subject.enabled_automatic_breadcrumb_types << "Some custom type" + expect(subject.enabled_automatic_breadcrumb_types).to include("Some custom type") + end + end + + describe "#before_breadcrumb_callbacks" do + it "initially returns an empty array" do + expect(subject.before_breadcrumb_callbacks).to eq([]) + end + + it "stores the array between subsequent calls" do + first_call = subject.before_breadcrumb_callbacks + first_call << 1 + + second_call = subject.before_breadcrumb_callbacks + + expect(second_call).to eq([1]) + end + + it "stays the same across threads" do + first_array = subject.before_breadcrumb_callbacks + first_array << 1 + + second_array = nil + Thread.new { second_array = subject.before_breadcrumb_callbacks; second_array << 2}.join + + expect(first_array).to eq([1, 2]) + expect(second_array).to eq([1, 2]) + end + end end diff --git a/spec/integrations/mongo_spec.rb b/spec/integrations/mongo_spec.rb new file mode 100644 index 000000000..fc2594ebe --- /dev/null +++ b/spec/integrations/mongo_spec.rb @@ -0,0 +1,262 @@ +# encoding: utf-8 +require 'spec_helper' + +describe 'Bugsnag::MongoBreadcrumbSubscriber', :order => :defined do + before do + unless defined?(::Mongo) + @mocked_mongo = true + module ::Mongo + module Monitoring + COMMAND = 'Command' + module Global + end + end + end + module Kernel + alias_method :old_require, :require + def require(path) + old_require(path) unless path == 'mongo' + end + end + end + end + + it "should subscribe to the mongo monitoring service" do + expect(::Mongo::Monitoring::Global).to receive(:subscribe) do |command, subscriber| + expect(command).to eq(::Mongo::Monitoring::COMMAND) + expect(subscriber).to be_an_instance_of(::Bugsnag::MongoBreadcrumbSubscriber) + end + load './lib/bugsnag/integrations/mongo.rb' + end + + context "with the module loaded" do + before do + allow(::Mongo::Monitoring::Global).to receive(:subscribe) + require './lib/bugsnag/integrations/mongo' + end + + let(:subscriber) { Bugsnag::MongoBreadcrumbSubscriber.new } + + describe "#started" do + it "calls #leave_command with the event" do + event = double + expect(subscriber).to receive(:leave_command).with(event) + subscriber.started(event) + end + end + + describe "#succeeded" do + it "calls #leave_mongo_breadcrumb with the event_name and event" do + event = double + expect(subscriber).to receive(:leave_mongo_breadcrumb).with("succeeded", event) + subscriber.succeeded(event) + end + end + + describe "#failed" do + it "calls #leave_mongo_breadcrumb with the event_name and event" do + event = double + expect(subscriber).to receive(:leave_mongo_breadcrumb).with("failed", event) + subscriber.failed(event) + end + end + + describe "#leave_mongo_breadcrumb" do + let(:event) { double( + :command_name => "command", + :database_name => "database", + :operation_id => "1234567890", + :request_id => "123456", + :duration => "123.456" + ) } + let(:event_name) { "event_name" } + it "leaves a breadcrumb with relevant meta_data, message, type, and automatic notation" do + expect(Bugsnag).to receive(:leave_breadcrumb).with( + "Mongo query #{event_name}", + { + :event_name => "mongo.#{event_name}", + :command_name => "command", + :database_name => "database", + :operation_id => "1234567890", + :request_id => "123456", + :duration => "123.456" + }, + "process", + :auto + ) + subscriber.send(:leave_mongo_breadcrumb, event_name, event) + end + + it "adds message data if present" do + allow(event).to receive(:message).and_return("This is a message") + expect(Bugsnag).to receive(:leave_breadcrumb).with( + "Mongo query #{event_name}", + { + :event_name => "mongo.#{event_name}", + :command_name => "command", + :database_name => "database", + :operation_id => "1234567890", + :request_id => "123456", + :duration => "123.456", + :message => "This is a message" + }, + "process", + :auto + ) + subscriber.send(:leave_mongo_breadcrumb, event_name, event) + end + + context "command data is present" do + let(:command) { + { + "command" => "collection_name_command", + "collection" => "collection_name_getMore", + "filter" => nil + } + } + + it "adds the collection name" do + expect(subscriber).to receive(:pop_command).with("123456").and_return(command) + expect(Bugsnag).to receive(:leave_breadcrumb).with( + "Mongo query #{event_name}", + { + :event_name => "mongo.#{event_name}", + :command_name => "command", + :database_name => "database", + :operation_id => "1234567890", + :request_id => "123456", + :duration => "123.456", + :collection => "collection_name_command" + }, + "process", + :auto + ) + subscriber.send(:leave_mongo_breadcrumb, event_name, event) + end + + it "adds the correct collection name for 'getMore' commands" do + allow(event).to receive(:command_name).and_return("getMore") + expect(subscriber).to receive(:pop_command).with("123456").and_return(command) + expect(Bugsnag).to receive(:leave_breadcrumb).with( + "Mongo query #{event_name}", + { + :event_name => "mongo.#{event_name}", + :command_name => "getMore", + :database_name => "database", + :operation_id => "1234567890", + :request_id => "123456", + :duration => "123.456", + :collection => "collection_name_getMore" + }, + "process", + :auto + ) + subscriber.send(:leave_mongo_breadcrumb, event_name, event) + end + + it "adds a JSON string of filter data" do + command["filter"] = {"a" => 1, "b" => 2, "$or" => [{"c" => 3}, {"d" => 4}]} + expect(subscriber).to receive(:pop_command).with("123456").and_return(command) + expect(Bugsnag).to receive(:leave_breadcrumb).with( + "Mongo query #{event_name}", + { + :event_name => "mongo.#{event_name}", + :command_name => "command", + :database_name => "database", + :operation_id => "1234567890", + :request_id => "123456", + :duration => "123.456", + :collection => "collection_name_command", + :filter => '{"a":"?","b":"?","$or":[{"c":"?"},{"d":"?"}]}' + }, + "process", + :auto + ) + subscriber.send(:leave_mongo_breadcrumb, event_name, event) + end + end + end + + describe "#sanitize_filter_hash" do + it "calls into #sanitize_filter_value with the value from each {k,v} pair" do + expect(subscriber.send(:sanitize_filter_hash, {:a => 1, :b => 2})).to eq({:a => '?', :b => '?'}) + end + end + + describe "#sanitize_filter_value" do + it "returns '?' for strings, numbers, booleans, and nil" do + expect(subscriber.send(:sanitize_filter_value, 523, 0)).to eq('?') + expect(subscriber.send(:sanitize_filter_value, "string", 0)).to eq('?') + expect(subscriber.send(:sanitize_filter_value, true, 0)).to eq('?') + expect(subscriber.send(:sanitize_filter_value, nil, 0)).to eq('?') + end + + it "is recursive and iterative for array values" do + expect(subscriber.send(:sanitize_filter_value, [1, [2, [3]]], 0)).to eq(['?', ['?', ['?']]]) + end + + it "calls #sanitize_filter_hash for hash values" do + expect(subscriber).to receive(:sanitize_filter_hash).with({:a => 1}, 1) + subscriber.send(:sanitize_filter_value, {:a => 1}, 0) + end + + it "returns [MAX_FILTER_DEPTH_REACHED] if the filter depth is exceeded" do + expect(subscriber.send(:sanitize_filter_value, 1, 4)).to eq('[MAX_FILTER_DEPTH_REACHED]') + end + end + + describe "#leave_command" do + it "extracts and stores the command by request_id" do + request_id = "123456" + command = "this is a command string" + event = double(:command => command, :request_id => request_id) + + subscriber.send(:leave_command, event) + command_hash = Bugsnag.configuration.request_data[Bugsnag::MongoBreadcrumbSubscriber::MONGO_COMMAND_KEY] + expect(command_hash[request_id]).to eq(command) + end + end + + describe "#pop_command" do + let(:request_id) { "123456" } + let(:command) { "this is a command string" } + before do + event = double(:command => command, :request_id => request_id) + subscriber.send(:leave_command, event) + end + + it "returns the command given a request_id" do + expect(subscriber.send(:pop_command, request_id)).to eq(command) + end + + it "removes the command from the request_data" do + subscriber.send(:pop_command, request_id) + command_hash = Bugsnag.configuration.request_data[Bugsnag::MongoBreadcrumbSubscriber::MONGO_COMMAND_KEY] + expect(command_hash).not_to have_key(request_id) + end + + it "returns nil if the request_id is not found" do + expect(subscriber.send(:pop_command, "09876")).to be_nil + end + end + + describe "#event_commands" do + it "returns a hash" do + expect(subscriber.send(:event_commands)).to be_a(Hash) + end + + it "is stored in request data" do + subscriber.send(:event_commands)[:key] = "value" + command_hash = Bugsnag.configuration.request_data[Bugsnag::MongoBreadcrumbSubscriber::MONGO_COMMAND_KEY] + expect(command_hash[:key]).to eq("value") + end + end + end + + after do + Object.send(:remove_const, :Mongo) if @mocked_mongo + module Kernel + alias_method :require, :old_require + end + end +end diff --git a/spec/report_spec.rb b/spec/report_spec.rb index 39e528819..96c62d0ae 100644 --- a/spec/report_spec.rb +++ b/spec/report_spec.rb @@ -1101,6 +1101,155 @@ 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.summary).to eq({ + :error_class => "ZeroDivisionError", + :message => "divided by 0", + :severity => "warning" + }) + end + end + + it "handles strings" do + report = Bugsnag::Report.new("test string", Bugsnag.configuration) + + expect(report.summary).to eq({ + :error_class => "RuntimeError", + :message => "test string", + :severity => "warning" + }) + end + + it "handles error edge cases" do + report = Bugsnag::Report.new(Timeout::Error, Bugsnag.configuration) + + expect(report.summary).to eq({ + :error_class => "Timeout::Error", + :message => "Timeout::Error", + :severity => "warning" + }) + end + + it "handles empty exceptions" do + begin + 1/0 + rescue ZeroDivisionError => e + report = Bugsnag::Report.new(e, Bugsnag.configuration) + + report.exceptions = [] + + expect(report.summary).to eq({ + :error_class => "Unknown", + :severity => "warning" + }) + end + end + + it "handles removed exceptions" do + begin + 1/0 + rescue ZeroDivisionError => e + report = Bugsnag::Report.new(e, Bugsnag.configuration) + + report.exceptions = nil + + expect(report.summary).to eq({ + :error_class => "Unknown", + :severity => "warning" + }) + end + end + + it "handles exceptions being replaced" do + begin + 1/0 + rescue ZeroDivisionError => e + report = Bugsnag::Report.new(e, Bugsnag.configuration) + + report.exceptions = "no one should ever do this" + + expect(report.summary).to eq({ + :error_class => "Unknown", + :severity => "warning" + }) + end + end + end + if defined?(JRUBY_VERSION) it "should work with java.lang.Throwables" do diff --git a/spec/utility/circular_buffer_spec.rb b/spec/utility/circular_buffer_spec.rb new file mode 100644 index 000000000..e64063fcf --- /dev/null +++ b/spec/utility/circular_buffer_spec.rb @@ -0,0 +1,98 @@ +# encoding: utf-8 + +require 'spec_helper' +require 'bugsnag/utility/circular_buffer' + +RSpec.describe Bugsnag::Utility::CircularBuffer do + describe "#initialize" do + it "contains no items" do + buffer = Bugsnag::Utility::CircularBuffer.new + + expect(buffer.to_a).to eq([]) + end + end + + describe "#max_items" do + it "defaults to 25" do + buffer = Bugsnag::Utility::CircularBuffer.new + + expect(buffer.max_items).to equal 25 + end + + it "can be set during #initialize" do + buffer = Bugsnag::Utility::CircularBuffer.new(10) + + expect(buffer.max_items).to equal 10 + end + end + + describe "#max_items=" do + it "changes #max_items" do + buffer = Bugsnag::Utility::CircularBuffer.new(10) + buffer.max_items = 17 + + expect(buffer.max_items).to equal(17) + end + + it "shifts any excess items when reduced" do + buffer = Bugsnag::Utility::CircularBuffer.new(10) + (0...10).each { |x| buffer << x } + buffer.max_items = 3 + + expect(buffer.to_a).to eq([7, 8, 9]) + end + + it "increases the maximum capacity" do + buffer = Bugsnag::Utility::CircularBuffer.new(3) + buffer << 1 + buffer << 2 + buffer << 3 + + expect(buffer.to_a).to eq([1, 2, 3]) + + buffer.max_items = 5 + buffer << 4 + buffer << 5 + + expect(buffer.to_a).to eq([1, 2, 3, 4, 5]) + end + end + + describe "#<<" do + it "adds items to the buffer" do + buffer = Bugsnag::Utility::CircularBuffer.new + buffer << 1 + expect(buffer.to_a).to eq([1]) + end + + it "shifts items it #max_items is exceeded" do + buffer = Bugsnag::Utility::CircularBuffer.new(5) + (0...10).each { |x| buffer << x } + + expect(buffer.to_a).to eq([5, 6, 7, 8, 9]) + end + + it "can be chained" do + buffer = Bugsnag::Utility::CircularBuffer.new + buffer << 1 << 2 << 3 << 4 << 5 + + expect(buffer.to_a).to eq([1, 2, 3, 4, 5]) + end + end + + describe "#each" do + it "can be iterated over" do + buffer = Bugsnag::Utility::CircularBuffer.new + buffer << 1 + buffer << 2 + buffer << 3 + + output = [] + buffer.each do |x| + output << x + end + + expect(output).to eq([1, 2, 3]) + end + end +end \ No newline at end of file