From 5073d9649b49e768adfed0cf7b00f9ce955b7d6a Mon Sep 17 00:00:00 2001 From: Kyrylo Silin Date: Thu, 10 Dec 2015 20:46:26 +0200 Subject: [PATCH] Release v1.0.0.rc.1 --- .gitignore | 5 + .rspec | 1 + .rubocop.yml | 74 ++ CHANGELOG.md | 8 + CONTRIBUTING.md | 84 +++ Gemfile | 5 + LICENSE.md | 21 + README.md | 549 ++++++++++++++ Rakefile | 4 + airbrake-ruby.gemspec | 33 + benchmarks/benchmark_helpers.rb | 154 ++++ benchmarks/build_notice.rb | 37 + benchmarks/notify_async_vs_sync.rb | 26 + benchmarks/notify_async_workers.rb | 58 ++ benchmarks/payload_truncator.rb | 34 + .../payload_truncator_string_encoding.rb | 131 ++++ benchmarks/server.go | 17 + circle.yml | 49 ++ lib/airbrake-ruby.rb | 292 ++++++++ lib/airbrake-ruby/async_sender.rb | 90 +++ lib/airbrake-ruby/backtrace.rb | 75 ++ lib/airbrake-ruby/config.rb | 120 +++ lib/airbrake-ruby/filter_chain.rb | 86 +++ lib/airbrake-ruby/filters.rb | 10 + lib/airbrake-ruby/filters/keys_blacklist.rb | 37 + lib/airbrake-ruby/filters/keys_filter.rb | 65 ++ lib/airbrake-ruby/filters/keys_whitelist.rb | 37 + lib/airbrake-ruby/notice.rb | 207 ++++++ lib/airbrake-ruby/notifier.rb | 145 ++++ lib/airbrake-ruby/payload_truncator.rb | 141 ++++ lib/airbrake-ruby/response.rb | 53 ++ lib/airbrake-ruby/sync_sender.rb | 76 ++ lib/airbrake-ruby/version.rb | 7 + spec/airbrake_spec.rb | 177 +++++ spec/async_sender_spec.rb | 121 +++ spec/backtrace_spec.rb | 77 ++ spec/config_spec.rb | 67 ++ spec/filter_chain_spec.rb | 157 ++++ spec/notice_spec.rb | 190 +++++ spec/notifier_spec.rb | 690 ++++++++++++++++++ spec/notifier_spec/options_spec.rb | 217 ++++++ spec/payload_truncator_spec.rb | 458 ++++++++++++ spec/spec_helper.rb | 98 +++ 43 files changed, 4983 insertions(+) create mode 100644 .gitignore create mode 100644 .rspec create mode 100644 .rubocop.yml create mode 100644 CHANGELOG.md create mode 100644 CONTRIBUTING.md create mode 100644 Gemfile create mode 100644 LICENSE.md create mode 100644 README.md create mode 100644 Rakefile create mode 100644 airbrake-ruby.gemspec create mode 100644 benchmarks/benchmark_helpers.rb create mode 100644 benchmarks/build_notice.rb create mode 100644 benchmarks/notify_async_vs_sync.rb create mode 100644 benchmarks/notify_async_workers.rb create mode 100644 benchmarks/payload_truncator.rb create mode 100644 benchmarks/payload_truncator_string_encoding.rb create mode 100644 benchmarks/server.go create mode 100644 circle.yml create mode 100644 lib/airbrake-ruby.rb create mode 100644 lib/airbrake-ruby/async_sender.rb create mode 100644 lib/airbrake-ruby/backtrace.rb create mode 100644 lib/airbrake-ruby/config.rb create mode 100644 lib/airbrake-ruby/filter_chain.rb create mode 100644 lib/airbrake-ruby/filters.rb create mode 100644 lib/airbrake-ruby/filters/keys_blacklist.rb create mode 100644 lib/airbrake-ruby/filters/keys_filter.rb create mode 100644 lib/airbrake-ruby/filters/keys_whitelist.rb create mode 100644 lib/airbrake-ruby/notice.rb create mode 100644 lib/airbrake-ruby/notifier.rb create mode 100644 lib/airbrake-ruby/payload_truncator.rb create mode 100644 lib/airbrake-ruby/response.rb create mode 100644 lib/airbrake-ruby/sync_sender.rb create mode 100644 lib/airbrake-ruby/version.rb create mode 100644 spec/airbrake_spec.rb create mode 100644 spec/async_sender_spec.rb create mode 100644 spec/backtrace_spec.rb create mode 100644 spec/config_spec.rb create mode 100644 spec/filter_chain_spec.rb create mode 100644 spec/notice_spec.rb create mode 100644 spec/notifier_spec.rb create mode 100644 spec/notifier_spec/options_spec.rb create mode 100644 spec/payload_truncator_spec.rb create mode 100644 spec/spec_helper.rb diff --git a/.gitignore b/.gitignore new file mode 100644 index 00000000..7410b90b --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +*.gem +Gemfile.lock +.bundle +doc +.yardoc diff --git a/.rspec b/.rspec new file mode 100644 index 00000000..372b5acf --- /dev/null +++ b/.rspec @@ -0,0 +1 @@ +--warnings diff --git a/.rubocop.yml b/.rubocop.yml new file mode 100644 index 00000000..a6120019 --- /dev/null +++ b/.rubocop.yml @@ -0,0 +1,74 @@ +# Explanations of all possible options: +# https://github.com/bbatsov/rubocop/blob/master/config/default.yml +AllCops: + DisplayCopNames: true + DisplayStyleGuide: true + +Lint/HandleExceptions: + Enabled: true + +Metrics/MethodLength: + Max: 25 + +Style/SpaceInsideBrackets: + Enabled: true + +Metrics/LineLength: + Max: 90 + +Style/AccessorMethodName: + Enabled: true + +Style/DotPosition: + EnforcedStyle: trailing + +# Details: +# http://c2.com/cgi/wiki?AbcMetric +Metrics/AbcSize: + # The ABC size is a calculated magnitude, so this number can be a Fixnum or + # a Float. + Max: 20 + +Style/StringLiterals: + Enabled: false + +Style/HashSyntax: + EnforcedStyle: ruby19 + +Style/GuardClause: + Enabled: true + +Style/FileName: + Exclude: + - 'lib/airbrake-ruby.rb' + +Style/SpaceInsideHashLiteralBraces: + Enabled: true + +Style/NumericLiterals: + Enabled: false + +Style/ParallelAssignment: + Enabled: true + +Style/SpaceAroundOperators: + Enabled: true + +Style/SignalException: + EnforcedStyle: only_raise + +Style/RedundantSelf: + Enabled: true + +Style/SpaceInsideParens: + Enabled: true + +Style/TrivialAccessors: + AllowPredicates: true + +Style/PredicateName: + Exclude: + - 'lib/airbrake-ruby/async_sender.rb' + +Metrics/ClassLength: + Max: 120 diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 00000000..33947812 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,8 @@ +Airbrake Ruby Changelog +======================= + +### [v1.0.0.rc.1][v1.0.0.rc.1] (December 11, 2015) + +* Initial release + +[v1.0.0.rc.1]: https://github.com/airbrake/airbrake-ruby/releases/tag/v1.0.0.rc.1 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 00000000..eadaf352 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,84 @@ +How to contribute +================= + +Pull requests +------------- + + + +We love your contributions, thanks for taking the time to contribute! + +It's really easy to start contributing, just follow these simple steps: + +1. [Fork][fork-article] the [repo][airbrake-ruby]: + + ![Fork][fork] + +2. Run the test suite to make sure the tests pass: + + ```shell + bundle exec rake + ``` + +3. [Create a separate branch][branch], commit your work and push it to your + fork. If you add comments, please make sure that they are compatible with + [YARD][yard]: + + ``` + git checkout -b my-branch + git commit -am + git push origin my-branch + ``` + +4. Verify that your code doesn't offend Rubocop: + + ``` + bundle exec rubocop + ``` + +5. Run the test suite again (new tests are always welcome): + + ``` + bundle exec rake + ``` + +6. [Make a pull request][pr] + +Submitting issues +----------------- + +Our [issue tracker][issues] is a perfect place for filing bug reports or +discussing possible features. If you report a bug, consider using the following +template (copy-paste friendly): + +``` +* Airbrake version: {YOUR VERSION} +* Ruby version: {YOUR VERSION} +* Framework name & version: {YOUR DATA} + +#### Airbrake config + + # YOUR CONFIG + # + # Make sure to delete any sensitive information + # such as your project id and project key. + +#### Description + +{We would be thankful if you provided steps to reproduce the issue, expected & +actual results, any code snippets or even test repositories, so we could clone +it and test} +``` + +

+ + Build Better Software +

+ +[airbrake-ruby]: https://github.com/airbrake/airbrake-ruby +[fork-article]: https://help.github.com/articles/fork-a-repo +[fork]: https://img-fotki.yandex.ru/get/3800/98991937.1f/0_b5c39_839c8786_orig +[branch]: https://help.github.com/articles/creating-and-deleting-branches-within-your-repository/ +[pr]: https://help.github.com/articles/using-pull-requests +[issues]: https://github.com/airbrake/airbrake-ruby/issues +[yard]: http://yardoc.org/ diff --git a/Gemfile b/Gemfile new file mode 100644 index 00000000..af0be54b --- /dev/null +++ b/Gemfile @@ -0,0 +1,5 @@ +source 'https://rubygems.org' +gemspec + +# Rubocop supports only >=1.9.3 +gem 'rubocop', '~> 0.33', require: false unless RUBY_VERSION == '1.9.2' diff --git a/LICENSE.md b/LICENSE.md new file mode 100644 index 00000000..fe4f1698 --- /dev/null +++ b/LICENSE.md @@ -0,0 +1,21 @@ +The MIT License +=============== + +Copyright © 2015 Airbrake Technologies, Inc. + +Permission is hereby granted, free of charge, to any person obtaining a copy of +this software and associated documentation files (the 'Software'), to deal in +the Software without restriction, including without limitation the rights to +use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software is furnished to do so, +subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 00000000..6a334b71 --- /dev/null +++ b/README.md @@ -0,0 +1,549 @@ +Airbrake Ruby +============= + +[![Build Status](https://circleci.com/gh/airbrake/airbrake-ruby.png?circle-token=b910cfdc764977173c941c4c60ef6b15981001af&style=shield)](https://circleci.com/gh/airbrake/airbrake-ruby) +[![semver]](http://semver.org) + + + +* [Airbrake README][airbrake-gem] +* [Airbrake Ruby README](https://github.com/airbrake/airbrake-ruby) +* [YARD API documentation][yard-api] + +Introduction +------------ + +_Airbrake Ruby_ is a plain Ruby notifier for [Airbrake][airbrake.io], the +leading exception reporting service. Airbrake Ruby provides minimalist API that +enables the ability to send _any_ Ruby exception to the Airbrake dashboard. The +library is extremely lightweight, contains _no_ dependencies and perfectly suits +plain Ruby applications. For apps that are built with _Rails_, _Sinatra_ or any +other Rack-compliant web framework we offer the [`airbrake`][airbrake-gem] gem. +It has additional features such as _reporting of any unhandled exceptions +automatically_, integrations with Resque, Sidekiq, Delayed Job and many more. + +Key features +------------ + +* Uses the new Airbrake JSON API (v3)[[link][notice-v3]] +* Simple, consistent and easy-to-use library API[[link](#api)] +* Awesome performance (check out our benchmarks)[[link](#running-benchmarks)] +* Asynchronous exception reporting[[link](#asynchronous-airbrake-options)] +* Flexible logging support (configure your own logger)[[link](#logger)] +* Flexible configuration options (configure as many Airbrake notifers in one + application as you want)[[link](#configuration)] +* Support for proxying[[link](#proxy)] +* Support for environments[[link](#environment)] +* Filters support (filter out sensitive or unwanted data that shouldn't be sent)[[link](#airbrakeadd_filter)] +* Ability to ignore exceptions based on their class, backtrace or any other + condition[[link](#airbrakeadd_filter)] +* Support for Java exceptions occurring in JRuby +* SSL support (all communication with Airbrake is encrypted by default) +* Support for fatal exceptions (the ones that terminate your program) +* Last but not least, we follow semantic versioning 2.0.0[[link][semver2]] + +Installation +------------ + +### Bundler + +Add the Airbrake Ruby gem to your Gemfile: + +```ruby +gem 'airbrake-ruby', '~> 1.0.0.rc.1' +``` + +### Manual + +Invoke the following command from your terminal: + +```ruby +gem install airbrake-ruby --pre +``` + +Examples +-------- + +### Basic example + +This is the minimal example that you can use to test Airbrake Ruby with your +project. + +```ruby +require 'airbrake-ruby' + +Airbrake.configure do |c| + c.project_id = 105138 + c.project_key = 'fd04e13d806a90f96614ad8e529b2822' +end + +begin + 1/0 +rescue ZeroDivisionError => ex + Airbrake.notify(ex) +end + +puts 'Check your dashboard on http://airbrake.io' +``` + +### Creating a named notifier + +A named notifier can co-exist with the default notifier. You can have as many +notifiers configured differently as you want. + +```ruby +require 'airbrake-ruby' + +# Configure first notifier for Project A. +Airbrake.configure(:project_a) do |c| + c.project_id = 105138 + c.project_key = 'fd04e13d806a90f96614ad8e529b2822' +end + +# Configure second notifier for Project B. +Airbrake.configure(:project_b) do |c| + c.project_id = 123 + c.project_key = '321' +end + +params = { time: Time.now } + +# Send an exception to Project A. +Airbrake.notify('Oops!', params, :project_a) + +# Send an exception to Project B. +Airbrake.notify('Oops!', params, :project_b) +``` + +Configuration +------------- + +Before using the library and its notifiers, you must to configure them. In most +cases, it is sufficient to configure only one, default, notifier. + +```ruby +Airbrake.configure do |c| + c.project_id = 105138 + c.project_key = 'fd04e13d806a90f96614ad8e529b2822' +end +``` + +Many notifiers can co-exist at the same time. To configure a new notifier, +simply provide an argument for the `configure` method. + +```ruby +Airbrake.configure(:my_notifier) do |c| + c.project_id = 105138 + c.project_key = 'fd04e13d806a90f96614ad8e529b2822' +end +``` + +You cannot reconfigure already configured notifiers. + +### Config options + +#### project_id & project_key + +You **must** set both `project_id` & `project_key`. + +To find your `project_id` and `project_key` navigate to your project's _General +Settings_ and copy the values from the right sidebar. + +![][project-idkey] + +```ruby +Airbrake.configure do |c| + c.project_id = 105138 + c.project_key = 'fd04e13d806a90f96614ad8e529b2822' +end +``` + +#### proxy + +If your server is not able to directly reach Airbrake, you can use built-in +proxy. By default, Airbrake Ruby uses direct connection. + +```ruby +Airbrake.configure do |c| + c.proxy = { + host: 'proxy.example.com', + port: 4038, + user: 'john-doe', + password: 'p4ssw0rd' + } +end +``` + +#### logger + +By default, Airbrake Ruby outputs to `STDOUT`. The default logger level is +`Logger::WARN`. It's possible to add your custom logger. + +```ruby +Airbrake.configure do |c| + c.logger = Logger.new('log.txt') +end +``` + +#### app_version + +The version of your application that you can pass to differentiate exceptions +between multiple versions. It's not set by default. + +```ruby +Airbrake.configure do |c| + c.app_version = '1.0.0' +end +``` + +#### host + +By default, it is set to `airbrake.io`. A `host` is a web address containing a +scheme ("http" or "https"), a host and a port. You can omit the port (80 will be +assumed) and the scheme ("https" will be assumed). + +```ruby +Airbrake.configure do |c| + c.host = 'http://localhost:8080' +end +``` + +#### root_directory + +Configures the root directory of your project. Expects a String or a Pathname, +which represents the path to your project. Providing this option helps us to +filter out repetitive data from backtrace frames and link to GitHub files +from our dashboard. + +```ruby +Airbrake.configure do |c| + c.root_directory = '/var/www/project' +end +``` + +#### environment + +Configures the environment the application is running in. Helps the Airbrake +dashboard to distinguish between exceptions occurring in different +environments. By default, it's not set. + +```ruby +Airbrake.configure do |c| + c.environment = :production +end +``` + +#### ignore_environments + +Setting this option allows Airbrake to filter exceptions occurring in unwanted +environments such as `:test`. By default, it is equal to an empty Array, which +means Airbrake Ruby sends exceptions occurring in all environments. + +```ruby +Airbrake.configure do |c| + c.ignore_environments = [:test] +end +``` + +### Asynchronous Airbrake options + +The options listed below apply to [`Airbrake.notify`](#airbrakenotify), they do +not apply to [`Airbrake.notify_sync`](#airbrakenotify_sync). + +#### queue_size + +The size of the notice queue. The default value is 100. You can increase the +value according to your needs. + +```ruby +Airbrake.configure do |c| + c.queue_size = 200 +end +``` + +#### workers + +The number of threads that handle notice sending. The default value is 1. + +```ruby +Airbrake.configure do |c| + c.workers = 5 +end +``` + +API +--- + +### Airbrake + +#### Airbrake.notify + +Sends an exception to Airbrake asynchronously. + +```ruby +Airbrake.notify('App crashed!') +``` + +As the first parameter, accepts: + +* an `Exception` (will be sent directly) +* any object that can be converted to String with `#to_s` (the information from + the object will be used as the message of a `RuntimeException` that we build + internally) +* an `Airbrake::Notice` + +As the second parameter, accepts a hash with additional data. That data will be +displayed in the _Params_ tab in your project's dashboard. + +```ruby +Airbrake.notify('App crashed!', { + anything: 'you', + wish: 'to add' +}) +``` + +#### Airbrake.notify_sync + +Sends an exception to Airbrake synchronously. Returns a Hash with an error ID +and a URL to the error. + +```ruby +Airbrake.notify_sync('App crashed!') +#=> {"id"=>"1516018011377823762", "url"=>"https://airbrake.io/locate/1516018011377823762"} +``` + +Accepts the same parameters as [`Airbrake.notify`](#airbrakenotify). + +#### Airbrake.add_filter + +Runs a callback before `.notify` kicks in. Yields an `Airbrake::Notice`. This is +useful if you want to ignore specific notices or filter the data the notice +contains. + +If you want to ignore a notice, simply mark it with `Notice#ignore!`. This +interrupts the execution chain of the `add_filter` callbacks. Once you ignore +a notice, there's no way to unignore it. + +This example demonstrates how to ignore **all** notices. + +```ruby +Airbrake.add_filter(&:ignore!) +``` + +Instead, you can ignore notices based on some condition. + +```ruby +Airbrake.add_filter do |notice| + notice.ignore! if notice[:error_class] == 'StandardError' +end +``` + +In order to filter a notice, simply change the data you are interested in. + +```ruby +Airbrake.add_filter do |notice| + if notice[:params][:password] + # Filter out password. + notice[:params][:password] = '[Filtered]' + end +end +``` + +##### Using classes for building filters + +For more complex filters you can use the special API. Simply pass an object that +responds to the `#call` method. + +```ruby +class MyFilter + def call(notice) + # ... + end +end + +Airbrake.add_filter(MyFilter.new) +``` + +The library provides two default filters that you can use to filter notices: +[KeysBlacklist][keysblacklist] & [KeysWhitelist][keyswhitelist]. + +##### The KeysBlacklist filter + +The KeysBlacklist filter filters specific keys (parameters, session data, +environment data). Before sending the notice, filtered keys will be substituted +with the `[Filtered]` label. + +It accepts Strings, Symbols & Regexps, which represent keys to be filtered. + +```ruby +Airbrake.blacklist_keys([:email, /credit/i, 'password']) +Airbrake.notify('App crashed!', { + user: 'John', + password: 's3kr3t', + email: 'john@example.com', + credit_card: '5555555555554444' +}) + +# The dashboard will display this parameter as filtered, but other values won't +# be affected: +# { user: 'John', +# password: '[Filtered]', +# email: '[Filtered]', +# credit_card: '[Filtered]' } +``` + +##### The KeysWhitelist filter + +The KeysWhitelist filter allows you to specify which keys should not be +filtered. All other keys will be substituted with the `[Filtered]` label. + +It accepts Strings, Symbols & Regexps, which represent keys the values of which +shouldn't be filtered. + +```ruby +Airbrake.whitelist([:email, /user/i, 'account_id']) +Airbrake.notify(StandardError.new('App crashed!'), { + user: 'John', + password: 's3kr3t', + email: 'john@example.com', + account_id: 42 +}) + +# The dashboard will display this parameter as is, but all other values will be +# filtered: +# { user: 'John', +# password: '[Filtered]', +# email: 'john@example.com', +# account_id: 42 } +``` + +#### Airbrake.build_notice + +Builds an [Airbrake notice][notice-v3]. This is useful, if you want to add or +modify a value only for a specific notice. When you're done modifying the +notice, send it with `Airbrake.notify` or `Airbrake.notify_sync`. + +```ruby +notice = airbrake.build_notice('App crashed!') +notice[:params][:username] = user.name +airbrake.notify_sync(notice) +``` + +#### Airbrake.close + +Makes the notifier a no-op, which means you cannot use the `.notify` and +`.notify_sync` methods anymore. It also stops the notifier's worker threads. + +```ruby +Airbrake.close +Airbrake.notify('App crashed!') #=> raises Airbrake::Error +``` + +If you want to guarantee delivery of all unsent exceptions on program exit, make +sure to `close` your Airbrake notifier. Usually, this can be done with help of +Ruby's `at_exit` hook. + +```ruby +at_exit do + # Closes the default notifier. + Airbrake.close + + # Closes a named notifier. + Airbrake.close(:my_notifier) +end +``` + +### Notice + +#### Notice#ignore! + +Ignores a notice. Ignored notices never reach the Airbrake dashboard. This is +useful in conjunction with `Airbrake.add_filter`. + +```ruby +notice.ignore! +``` + +#### Notice#ignored? + +Checks whether the notice was ignored. + +```ruby +notice.ignored? #=> false +``` + +#### Notice#[] & Notice#[]= + +Accesses a notice's modifiable payload, which can be read or +filtered. Modifiable payload includes: + +* `:errors` +* `:context` +* `:environment` +* `:session` +* `:params` + +```ruby +notice[:params][:my_param] = 'foobar' +``` + +Additional notes +---------------- + +### Exception limit + +The maximum size of an exception is 64KB. Exceptions that exceed this limit +will be truncated to fit the size. + +### Running benchmarks + +To run benchmarks related to asynchronous delivery, make sure to start a web +server on port 8080. We provide a simple server, which can be started with this +command (you need to have the [Go][golang] programming language installed): + +```shell +go run benchmarks/server.go +``` + +In order to run benchmarks against `master`, add the `lib` directory to your +`LOAD_PATH` and choose the benchmark you are interested in: + +```shell +ruby -Ilib benchmarks/notify_async_vs_sync.rb +``` + +Supported Rubies +---------------- + +* CRuby >= 1.9.2 +* JRuby >= 1.9-mode +* Rubinius >= 2.2.10 + +Contact +------- + +In case you have a problem, question or a bug report, feel free to: + +* [file an issue][issues] +* [send us an email](mailto:support@airbrake.io) +* [tweet at us][twitter] +* chat with us (visit [airbrake.io][airbrake.io] and click on the round orange + button in the bottom right corner) + +License +------- + +The project uses the MIT License. See LICENSE.md for details. + +[airbrake.io]: https://airbrake.io +[airbrake-gem]: https://github.com/airbrake/airbrake +[semver2]: http://semver.org/spec/v2.0.0.html +[notice-v3]: https://airbrake.io/docs/#create-notice-v3 +[project-idkey]: https://img-fotki.yandex.ru/get/3907/98991937.1f/0_b558a_c9274e4d_orig +[issues]: https://github.com/airbrake/airbrake-ruby/issues +[twitter]: https://twitter.com/airbrake +[keysblacklist]: https://github.com/airbrake/airbrake-ruby/blob/master/lib/airbrake-ruby/filters/keys_blacklist.rb +[keyswhitelist]: https://github.com/airbrake/airbrake-ruby/blob/master/lib/airbrake-ruby/filters/keys_whitelist.rb +[golang]: https://golang.org/ +[semver]: https://img.shields.io/:semver-1.0.0.rc.1-brightgreen.svg?style=flat +[yard-api]: http://www.rubydoc.info/gems/airbrake-ruby diff --git a/Rakefile b/Rakefile new file mode 100644 index 00000000..0585b9b9 --- /dev/null +++ b/Rakefile @@ -0,0 +1,4 @@ +require 'rspec/core/rake_task' +RSpec::Core::RakeTask.new(:spec) + +task default: :spec diff --git a/airbrake-ruby.gemspec b/airbrake-ruby.gemspec new file mode 100644 index 00000000..718881f9 --- /dev/null +++ b/airbrake-ruby.gemspec @@ -0,0 +1,33 @@ +require './lib/airbrake-ruby/version' + +Gem::Specification.new do |s| + s.name = 'airbrake-ruby' + s.version = Airbrake::AIRBRAKE_RUBY_VERSION.dup + s.date = Time.now.strftime('%Y-%m-%d') + s.summary = 'Ruby notifier for https://airbrake.io' + s.description = < 3' + s.add_development_dependency 'rake', '~> 10' + s.add_development_dependency 'pry', '~> 0' + s.add_development_dependency 'webmock', '~> 1' + s.add_development_dependency 'benchmark-ips', '~> 2' +end diff --git a/benchmarks/benchmark_helpers.rb b/benchmarks/benchmark_helpers.rb new file mode 100644 index 00000000..6f7c0d59 --- /dev/null +++ b/benchmarks/benchmark_helpers.rb @@ -0,0 +1,154 @@ +require 'rbconfig' +require 'benchmark' +require 'benchmark/ips' +require 'webmock' + +require 'airbrake-ruby' + +BIG_EXCEPTION = RuntimeError.new('App crashed!') +# rubocop:disable Metrics/LineLength +BIG_EXCEPTION.set_backtrace([ + "lib/arel/visitors/to_sql.rb:729:in `unsupported'", + "lib/arel/visitors/reduce.rb:13:in `visit'", + "lib/arel/visitors/to_sql.rb:241:in `block in visit_Arel_Nodes_SelectCore'", + "lib/arel/visitors/to_sql.rb:240:in `visit_Arel_Nodes_SelectCore'", + "lib/arel/visitors/to_sql.rb:210:in `block in visit_Arel_Nodes_SelectStatement'", + "lib/arel/visitors/to_sql.rb:209:in `visit_Arel_Nodes_SelectStatement'", + "lib/arel/visitors/reduce.rb:13:in `visit'", + "lib/arel/visitors/reduce.rb:7:in `accept'", + "lib/active_record/connection_adapters/abstract/database_statements.rb:12:in `to_sql'", + "lib/active_record/connection_adapters/abstract/database_statements.rb:32:in `select_all'", + "lib/active_record/connection_adapters/abstract/query_cache.rb:70:in `select_all'", + "lib/active_record/querying.rb:39:in `find_by_sql'", + "lib/active_record/relation.rb:638:in `exec_queries'", + "lib/active_record/relation.rb:514:in `load'", + "lib/active_record/relation.rb:243:in `to_a'", + "lib/bullet/active_record42.rb:29:in `to_a'", + "lib/active_record/relation/delegation.rb:46:in `each'", + "app/controllers/rubric_items_controller.rb:52:in `block in update'", + "lib/active_record/connection_adapters/abstract/database_statements.rb:213:in `block in transaction'", + "lib/active_record/connection_adapters/abstract/transaction.rb:188:in `within_new_transaction'", + "lib/active_record/connection_adapters/abstract/database_statements.rb:213:in `transaction'", + "lib/active_record/transactions.rb:220:in `transaction'", + "app/controllers/rubric_items_controller.rb:51:in `update'", + "lib/action_controller/metal/implicit_render.rb:4:in `send_action'", + "lib/abstract_controller/base.rb:198:in `process_action'", + "lib/action_controller/metal/rendering.rb:10:in `process_action'", + "lib/abstract_controller/callbacks.rb:20:in `block in process_action'", + "lib/active_support/callbacks.rb:117:in `call'", + "lib/active_support/callbacks.rb:555:in `block (2 levels) in compile'", + "lib/active_support/callbacks.rb:505:in `call'", + "lib/active_support/callbacks.rb:92:in `_run_callbacks'", + "lib/active_support/callbacks.rb:776:in `_run_process_action_callbacks'", + "lib/active_support/callbacks.rb:81:in `run_callbacks'", + "lib/abstract_controller/callbacks.rb:19:in `process_action'", + "lib/action_controller/metal/rescue.rb:29:in `process_action'", + "lib/action_controller/metal/instrumentation.rb:32:in `block in process_action'", + "lib/active_support/notifications.rb:164:in `block in instrument'", + "lib/active_support/notifications/instrumenter.rb:20:in `instrument'", + "lib/active_support/notifications.rb:164:in `instrument'", + "lib/action_controller/metal/instrumentation.rb:30:in `process_action'", + "lib/action_controller/metal/params_wrapper.rb:250:in `process_action'", + "lib/active_record/railties/controller_runtime.rb:18:in `process_action'", + "lib/abstract_controller/base.rb:137:in `process'", + "lib/action_view/rendering.rb:30:in `process'", + "lib/action_controller/metal.rb:196:in `dispatch'", + "lib/action_controller/metal/rack_delegation.rb:13:in `dispatch'", + "lib/action_controller/metal.rb:237:in `block in action'", + "lib/action_dispatch/routing/route_set.rb:74:in `dispatch'", + "lib/action_dispatch/routing/route_set.rb:43:in `serve'", + "lib/action_dispatch/journey/router.rb:43:in `block in serve'", + "lib/action_dispatch/journey/router.rb:30:in `serve'", + "lib/action_dispatch/routing/route_set.rb:819:in `call'", + "lib/omniauth/strategy.rb:186:in `call!'", + "lib/omniauth/strategy.rb:164:in `call'", + "lib/omniauth/strategy.rb:186:in `call!'", + "lib/omniauth/strategy.rb:164:in `call'", + "lib/omniauth/builder.rb:59:in `call'", + "lib/meta_request/middlewares/app_request_handler.rb:13:in `call'", + "lib/meta_request/middlewares/meta_request_handler.rb:13:in `call'", + "lib/bullet/rack.rb:12:in `call'", + "lib/rack/livereload.rb:23:in `_call'", + "lib/rack/livereload.rb:14:in `call'", + "lib/rack/etag.rb:24:in `call'", + "lib/rack/conditionalget.rb:38:in `call'", + "lib/rack/head.rb:13:in `call'", + "lib/action_dispatch/middleware/params_parser.rb:27:in `call'", + "lib/action_dispatch/middleware/flash.rb:260:in `call'", + "lib/rack/session/abstract/id.rb:225:in `context'", + "lib/rack/session/abstract/id.rb:220:in `call'", + "lib/action_dispatch/middleware/cookies.rb:560:in `call'", + "lib/active_record/query_cache.rb:36:in `call'", + "lib/active_record/connection_adapters/abstract/connection_pool.rb:649:in `call'", + "lib/active_record/migration.rb:378:in `call'", + "lib/action_dispatch/middleware/callbacks.rb:29:in `block in call'", + "lib/active_support/callbacks.rb:88:in `_run_callbacks'", + "lib/active_support/callbacks.rb:776:in `_run_call_callbacks'", + "lib/active_support/callbacks.rb:81:in `run_callbacks'", + "lib/action_dispatch/middleware/callbacks.rb:27:in `call'", + "lib/action_dispatch/middleware/reloader.rb:73:in `call'", + "lib/action_dispatch/middleware/remote_ip.rb:78:in `call'", + "lib/bugsnag/rack.rb:36:in `call'", + "lib/better_errors/middleware.rb:84:in `protected_app_call'", + "lib/better_errors/middleware.rb:79:in `better_errors_call'", + "lib/better_errors/middleware.rb:57:in `call'", + "lib/rack/contrib/response_headers.rb:17:in `call'", + "lib/meta_request/middlewares/headers.rb:16:in `call'", + "lib/action_dispatch/middleware/debug_exceptions.rb:17:in `call'", + "lib/action_dispatch/middleware/show_exceptions.rb:30:in `call'", + "lib/rails/rack/logger.rb:38:in `call_app'", + "lib/rails/rack/logger.rb:22:in `call'", + "config/initializers/quiet_assets.rb:7:in `call_with_quiet_assets'", + "lib/request_store/middleware.rb:8:in `call'", + "lib/action_dispatch/middleware/request_id.rb:21:in `call'", + "lib/rack/methodoverride.rb:22:in `call'", + "lib/rack/runtime.rb:18:in `call'", + "lib/active_support/cache/strategy/local_cache_middleware.rb:28:in `call'", + "lib/rack/lock.rb:17:in `call'", + "lib/action_dispatch/middleware/static.rb:113:in `call'", + "lib/rack/sendfile.rb:113:in `call'", + "lib/rails/engine.rb:518:in `call'", + "lib/rails/application.rb:164:in `call'", + "lib/rack/content_length.rb:15:in `call'", + "lib/unicorn/http_server.rb:576:in `process_client'", + "lib/unicorn/http_server.rb:670:in `worker_loop'", + "lib/unicorn/http_server.rb:525:in `spawn_missing_workers'", + "lib/unicorn/http_server.rb:140:in `start'", + "lib/unicorn_rails.rb:33:in `run'", + "lib/rack/server.rb:286:in `start'", + "lib/rails/commands/server.rb:80:in `start'", + "lib/rails/commands/commands_tasks.rb:80:in `block in server'", + "lib/rails/commands/commands_tasks.rb:75:in `server'", + "lib/rails/commands/commands_tasks.rb:39:in `run_command!'", + "lib/rails/commands.rb:17:in `'", + "lib/rails/commands.rb:17:in `'", + "lib/spring/client/rails.rb:28:in `call'", + "lib/spring/client/command.rb:7:in `call'", + "lib/spring/client.rb:26:in `run'", + "bin/spring:48:in `'", + "lib/spring/binstub.rb:11:in `'", + "lin/spring:13:in `'", + "bin/rails:3:in `
'"]) + +SMALL_EXCEPTION = RuntimeError.new('App crashed!') +SMALL_EXCEPTION.set_backtrace([ + "/home/kyrylo/code/airbrake/ruby/spec/spec_helper.rb:23:in `'", + "/opt/rubies/ruby-2.2.2/lib/ruby/2.2.0/rubygems/core_ext/kernel_require.rb:54:in `require'", + "/opt/rubies/ruby-2.2.2/lib/ruby/2.2.0/rubygems/core_ext/kernel_require.rb:54:in `require'", + "/home/kyrylo/code/airbrake/ruby/spec/airbrake_spec.rb:1:in `'", + "/home/kyrylo/.gem/ruby/2.2.2/gems/rspec-core-3.3.2/lib/rspec/core/configuration.rb:1327:in `load'", + "/home/kyrylo/.gem/ruby/2.2.2/gems/rspec-core-3.3.2/lib/rspec/core/configuration.rb:1327:in `block in load_spec_files'", + "/home/kyrylo/.gem/ruby/2.2.2/gems/rspec-core-3.3.2/lib/rspec/core/configuration.rb:1325:in `each'", + "/home/kyrylo/.gem/ruby/2.2.2/gems/rspec-core-3.3.2/lib/rspec/core/configuration.rb:1325:in `load_spec_files'", + "/home/kyrylo/.gem/ruby/2.2.2/gems/rspec-core-3.3.2/lib/rspec/core/runner.rb:102:in `setup'", + "/home/kyrylo/.gem/ruby/2.2.2/gems/rspec-core-3.3.2/lib/rspec/core/runner.rb:88:in `run'", + "/home/kyrylo/.gem/ruby/2.2.2/gems/rspec-core-3.3.2/lib/rspec/core/runner.rb:73:in `run'", + "/home/kyrylo/.gem/ruby/2.2.2/gems/rspec-core-3.3.2/lib/rspec/core/runner.rb:41:in `invoke'", + "/home/kyrylo/.gem/ruby/2.2.2/gems/rspec-core-3.3.2/exe/rspec:4:in `
'"]) +# rubocop:enable Metrics/LineLength + +# Make sure we don't send any remote requests. +WebMock.disable_net_connect!(allow_localhost: true) + +# Raise any errors (just in case). +Thread.abort_on_exception = true diff --git a/benchmarks/build_notice.rb b/benchmarks/build_notice.rb new file mode 100644 index 00000000..49ceaa2f --- /dev/null +++ b/benchmarks/build_notice.rb @@ -0,0 +1,37 @@ +require_relative 'benchmark_helpers' + +Airbrake.configure do |c| + c.project_id = 1 + c.project_key = '213' + c.logger = Logger.new('/dev/null') +end + +puts "Calculating iterations/second..." + +Benchmark.ips do |ips| + ips.config(time: 5, warmup: 5) + + ips.report("big Airbrake.build_notice") do + Airbrake.build_notice(BIG_EXCEPTION) + end + + ips.report("small Airbrake.build_notice") do + Airbrake.build_notice(SMALL_EXCEPTION) + end + + ips.compare! +end + +NOTICES = 100_000 + +puts "Calculating times..." + +Benchmark.bmbm do |bm| + bm.report("big Airbrake.build_notice") do + NOTICES.times { Airbrake.build_notice(BIG_EXCEPTION) } + end + + bm.report("small Airbrake.build_notice") do + NOTICES.times { Airbrake.build_notice(SMALL_EXCEPTION) } + end +end diff --git a/benchmarks/notify_async_vs_sync.rb b/benchmarks/notify_async_vs_sync.rb new file mode 100644 index 00000000..89a8bc5b --- /dev/null +++ b/benchmarks/notify_async_vs_sync.rb @@ -0,0 +1,26 @@ +require_relative 'benchmark_helpers' + +# Silence logs. +logger = Logger.new('/dev/null') + +# Setup Airbrake. +Airbrake.configure do |c| + c.project_id = 112261 + c.project_key = 'c7aaceb2ccb579e6b710cea9da22c526' + c.logger = logger + c.host = 'http://localhost:8080' +end + +# The number of notices to process. +NOTICES = 1200 + +# Don't forget to run the server: go run benchmarks/server.go +Benchmark.bm do |bm| + bm.report("Airbrake.notify") do + NOTICES.times { Airbrake.notify(BIG_EXCEPTION) } + end + + bm.report("Airbrake.notify_sync") do + NOTICES.times { Airbrake.notify_sync(BIG_EXCEPTION) } + end +end diff --git a/benchmarks/notify_async_workers.rb b/benchmarks/notify_async_workers.rb new file mode 100644 index 00000000..c494bc3f --- /dev/null +++ b/benchmarks/notify_async_workers.rb @@ -0,0 +1,58 @@ +require_relative 'benchmark_helpers' + +# The number of notices to process. +NOTICES = 1200 + +cores = + case RbConfig::CONFIG['host_os'] + when /linux/ + Dir.glob('/sys/devices/system/cpu/cpu[0-9]*').count + when /darwin|bsd/ + Integer(`sysctl -n hw.ncpu`) + else + 2 + end + +double_cores = 2 * cores + +config_hash = { + project_id: 112261, + project_key: 'c7aaceb2ccb579e6b710cea9da22c526', + logger: Logger.new('/dev/null'), + host: 'http://localhost:8080' +} + +Airbrake.configure(:workers_1) do |c| + c.merge(config_hash.merge(workers: 1)) +end + +Airbrake.configure(:"workers_#{cores}") do |c| + c.merge(config_hash.merge(workers: cores)) +end + +Airbrake.configure(:"workers_#{double_cores}") do |c| + c.merge(config_hash.merge(workers: double_cores)) +end + +def notify_via(notifier) + NOTICES.times do + Airbrake.notify(BIG_EXCEPTION, {}, notifier) + end + + Airbrake.close(notifier) +end + +# Don't forget to run the server: go run benchmarks/server.go +Benchmark.bm do |bm| + bm.report("1 worker Airbrake.notify") do + notify_via(:workers_1) + end + + bm.report("#{cores} workers Airbrake.notify") do + notify_via(:"workers_#{cores}") + end + + bm.report("#{double_cores} workers Airbrake.notify") do + notify_via(:"workers_#{double_cores}") + end +end diff --git a/benchmarks/payload_truncator.rb b/benchmarks/payload_truncator.rb new file mode 100644 index 00000000..89ddbd0f --- /dev/null +++ b/benchmarks/payload_truncator.rb @@ -0,0 +1,34 @@ +require_relative 'benchmark_helpers' + +## +# Generates example errors that should be truncated. +class Payload + def self.generate + 5000.times.map do |i| + { type: "Error#{i}", + message: 'X' * 300, + backtrace: 300.times.map { 'Y' * 300 } } + end + end +end + +# The maximum size of hashes, arrays and strings. +TRUNCATOR_MAX_SIZE = 500 + +# Reduce the logger overhead. +LOGGER = Logger.new('/dev/null') + +truncate_error_payload = Payload.generate +truncate_object_payload = Payload.generate + +truncator = Airbrake::PayloadTruncator.new(TRUNCATOR_MAX_SIZE, LOGGER) + +Benchmark.bm do |bm| + bm.report("PayloadTruncator#truncate_error ") do + truncate_error_payload.each { |error| truncator.truncate_error(error) } + end + + bm.report("PayloadTruncator#truncate_object") do + truncate_object_payload.each { |error| truncator.truncate_object(error) } + end +end diff --git a/benchmarks/payload_truncator_string_encoding.rb b/benchmarks/payload_truncator_string_encoding.rb new file mode 100644 index 00000000..5c4fd7ec --- /dev/null +++ b/benchmarks/payload_truncator_string_encoding.rb @@ -0,0 +1,131 @@ +# coding: utf-8 +require_relative 'benchmark_helpers' + +require 'securerandom' +require 'base64' + +## +# Generates various strings for the benchmark. +module StringGenerator + STRLEN = 32 + + class << self + ## + # @return [String] a UTF-8 string with valid encoding and characters + def utf8 + SecureRandom.hex(STRLEN).encode('utf-8') + end + + ## + # @return [String] a UTF-8 string with invalid encoding and characters. + def invalid_utf8 + [invalid_string, invalid_string, invalid_string].join("").encode('utf-8') + end + + ## + # @return [String] a UTF-8 string with valid encoding and charaters from + # unicode + def unicode_utf8 + "ü ö ä Ä Ü Ö ß привет €25.00 한글".encode('utf-8') + end + + ## + # @return [String] an ASCII-8BIT string with valid encoding and random + # charaters + def ascii_8bit_string + SecureRandom.random_bytes(STRLEN).encode('ascii-8bit') + end + + ## + # @return [String] an ASCII-8BIT string with valid encoding and invalid + # charaters (which means it can't be converted to UTF-8 with plain + # +String#encode+ + def invalid_ascii_8bit_string + Base64.decode64(Base64.encode64(invalid_string).encode('ascii-8bit')) + end + + private + + def invalid_string + "\xD3\xE6\xBC\x9D\xBA" + end + end +end + +## +# Generates arrays of strings for the benchmark. +module BenchmarkCase + ## + # @return [Integer] number of strings to generate + MAX = 100_000 + + class << self + def worst_ascii + generate { StringGenerator.invalid_ascii_8bit_string } + end + + def worst_utf8 + generate { StringGenerator.invalid_utf8 } + end + + def mixed + strings = [] + methods = StringGenerator.singleton_methods(false) + + # How many strings of a certain type should be generated. + part = MAX / methods.size + + methods.each do |method| + strings << part.times.map { StringGenerator.__send__(method) } + end + + strings.flatten + end + + def best + generate { StringGenerator.utf8 } + end + + private + + def generate(&block) + MAX.times.map(&block) + end + end +end + +worst_case_utf8 = BenchmarkCase.worst_utf8 +worst_case_ascii = BenchmarkCase.worst_ascii +mixed_case = BenchmarkCase.mixed +best_case = BenchmarkCase.best + +# Make sure we never truncate strings, +# because this is irrelevant to this benchmark. +MAX_PAYLOAD_SIZE = 1_000_000 +truncator = Airbrake::PayloadTruncator.new(MAX_PAYLOAD_SIZE, Logger.new('/dev/null')) + +Benchmark.bmbm do |bm| + bm.report("(worst case utf8) PayloadTruncator#truncate_string") do + worst_case_utf8.each do |str| + truncator.__send__(:truncate_string, str) + end + end + + bm.report("(worst case ascii) PayloadTruncator#truncate_string") do + worst_case_ascii.each do |str| + truncator.__send__(:truncate_string, str) + end + end + + bm.report("(mixed) PayloadTruncator#truncate_string") do + mixed_case.each do |str| + truncator.__send__(:truncate_string, str) + end + end + + bm.report("(best case) PayloadTruncator#truncate_string") do + best_case.each do |str| + truncator.__send__(:truncate_string, str) + end + end +end diff --git a/benchmarks/server.go b/benchmarks/server.go new file mode 100644 index 00000000..2a60472c --- /dev/null +++ b/benchmarks/server.go @@ -0,0 +1,17 @@ +package main + +import ( + "log" + "net/http" + "time" +) + +func main() { + http.HandleFunc("/", func(w http.ResponseWriter, req *http.Request) { + time.Sleep(50 * time.Millisecond) + w.WriteHeader(http.StatusCreated) + w.Write([]byte(`{"id":"123"}`)) + }) + + log.Fatal(http.ListenAndServe(":8080", nil)) +} diff --git a/circle.yml b/circle.yml new file mode 100644 index 00000000..6aa73f25 --- /dev/null +++ b/circle.yml @@ -0,0 +1,49 @@ +dependencies: + cache_directories: + - vendor + override: + - bundle install --path=vendor + - ? | + case $CIRCLE_NODE_INDEX in + 0) + rvm-exec 1.9.2-p330 bundle install --path=vendor + rvm-exec 2.1.6 bundle install --path=vendor + rvm-exec rbx-2.2.10 bundle install --path=vendor + ;; + 1) + rvm-exec 1.9.3-p551 bundle install --path=vendor + rvm-exec 2.2.3 bundle install --path=vendor + rvm-exec rbx-2.5.2 bundle install --path=vendor + ;; + 2) + rvm-exec 2.0.0-p645 bundle install --path=vendor + rvm-exec jruby-1.7.19 bundle install --path=vendor + ;; + esac + : + parallel: true + +test: + override: + - ? | + set -e + case $CIRCLE_NODE_INDEX in + 0) + rvm-exec 1.9.2-p330 bundle exec rake + rvm-exec 2.1.6 bundle exec rake + rvm-exec rbx-2.2.10 bundle exec rake + ;; + 1) + rvm-exec 1.9.3-p551 bundle exec rake + rvm-exec 2.2.2 bundle exec rake + rvm-exec rbx-2.5.2 bundle exec rake + ;; + 2) + rvm-exec 2.0.0-p645 bundle exec rake + rvm-exec jruby-1.7.19 bundle exec rake + ;; + esac + : + parallel: true + post: + - bundle exec rubocop diff --git a/lib/airbrake-ruby.rb b/lib/airbrake-ruby.rb new file mode 100644 index 00000000..5d2834a4 --- /dev/null +++ b/lib/airbrake-ruby.rb @@ -0,0 +1,292 @@ +require 'net/https' +require 'logger' +require 'json' +require 'thread' +require 'set' +require 'English' + +require 'airbrake-ruby/version' +require 'airbrake-ruby/config' +require 'airbrake-ruby/sync_sender' +require 'airbrake-ruby/async_sender' +require 'airbrake-ruby/response' +require 'airbrake-ruby/notice' +require 'airbrake-ruby/backtrace' +require 'airbrake-ruby/filter_chain' +require 'airbrake-ruby/payload_truncator' +require 'airbrake-ruby/filters' +require 'airbrake-ruby/filters/keys_filter' +require 'airbrake-ruby/filters/keys_whitelist' +require 'airbrake-ruby/filters/keys_blacklist' +require 'airbrake-ruby/notifier' + +## +# This module defines the Airbrake API. The user is meant to interact with +# Airbrake via its public class methods. Before using the library, you must to +# {configure} the default notifier. +# +# The module supports multiple notifiers, each of which can be configured +# differently. By default, every method is invoked in context of the default +# notifier. To use a different notifier, you need to {configure} it first and +# pass the notifier's name as the last argument of the method you're calling. +# +# You can have as many notifiers as you want, but they must have unique names. +# +# @example Configuring multiple notifiers and using them +# # Configure the default notifier. +# Airbrake.configure do |c| +# c.project_id = 113743 +# c.project_key = 'fd04e13d806a90f96614ad8e529b2822' +# end +# +# # Configure a named notifier. +# Airbrake.configure(:my_other_project) do |c| +# c.project_id = 224854 +# c.project_key = '91ac5e4a37496026c6837f63276ed2b6' +# end +# +# # Send an exception via the default notifier. +# Airbrake.notify('Oops!') +# +# # Send an exception via other configured notifier. +# params = {} +# Airbrake.notify('Oops', params, :my_other_project) +# +# @see Airbrake::Notifier +module Airbrake + ## + # The general error that this library uses when it wants to raise. + Error = Class.new(StandardError) + + ## + # @return [String] the label to be prepended to the log output + LOG_LABEL = '**Airbrake:'.freeze + + ## + # A Hash that holds all notifiers. The keys of the Hash are notifier + # names, the values are Airbrake::Notifier instances. + @notifiers = {} + + class << self + ## + # Configures a new +notifier+ with the given name. If the name is not given, + # configures the default notifier. + # + # @example Configuring the default notifier + # Airbrake.configure do |c| + # c.project_id = 113743 + # c.project_key = 'fd04e13d806a90f96614ad8e529b2822' + # end + # + # @example Configuring a named notifier + # # Configure a new Airbrake instance and + # # assign +:my_other_project+ as its name. + # Airbrake.configure(:my_other_project) do |c| + # c.project_id = 224854 + # c.project_key = '91ac5e4a37496026c6837f63276ed2b6' + # end + # + # @param [Symbol] notifier the name to be associated with the notifier + # @yield [config] The configuration object + # @yieldparam config [Airbrake::Config] + # @return [void] + # @raise [Airbrake::Error] when trying to reconfigure already + # existing notifier + # @note There's no way to reconfigure a notifier + # @note There's no way to read config values outside of this library + def configure(notifier = :default) + yield config = Airbrake::Config.new + + if @notifiers.key?(notifier) + raise Airbrake::Error, + "the '#{notifier}' notifier was already configured" + else + @notifiers[notifier] = Notifier.new(config) + end + end + + # @!macro proxy_method + # @param [Symbol] notifier The name of the notifier + # @raise [Airbrake::Error] if +notifier+ doesn't exist + # @see Airbrake::Notifier#$0 + + ## + # Sends an exception to Airbrake asynchronously. + # + # @macro proxy_method + # @example Sending an exception + # Airbrake.notify(RuntimeError.new('Oops!')) + # @example Sending a string + # # Converted to RuntimeError.new('Oops!') internally + # Airbrake.notify('Oops!') + # @example Sending a Notice + # notice = airbrake.build_notice(RuntimeError.new('Oops!')) + # airbrake.notify(notice) + # + # @param [Exception, String, Airbrake::Notice] exception The exception to be + # sent to Airbrake + # @param [Hash] params The additional payload to be sent to Airbrake. Can + # contain any values. The provided values will be displayed in the Params + # tab in your project's dashboard + # @return [nil] + # @see .notify_sync + def notify(exception, params = {}, notifier = :default) + call_notifier(notifier, __method__, exception, params) + end + + ## + # Sends an exception to Airbrake synchronously. + # + # @macro proxy_method + # @example + # Airbrake.notify_sync('App crashed!') + # #=> {"id"=>"123", "url"=>"https://airbrake.io/locate/321"} + # + # @return [Hash{String=>String}] the reponse from the server + # @see .notify + # @since v5.0.0 + def notify_sync(exception, params = {}, notifier = :default) + call_notifier(notifier, __method__, exception, params) + end + + ## + # Runs a callback before {.notify} or {.notify_sync} kicks in. This is + # useful if you want to ignore specific notices or filter the data the + # notice contains. + # + # @macro proxy_method + # @example Ignore all notices + # Airbrake.add_filter(&:ignore!) + # @example Ignore based on some condition + # Airbrake.add_filter do |notice| + # notice.ignore! if notice[:error_class] == 'StandardError' + # end + # @example Ignore with help of a class + # class MyFilter + # def call(notice) + # # ... + # end + # end + # + # Airbrake.add_filter(MyFilter.new) + # + # @param [#call] filter The filter object + # @yield [notice] The notice to filter + # @yieldparam [Airbrake::Notice] + # @yieldreturn [void] + # @return [void] + # @since v5.0.0 + # @note Once a filter was added, there's no way to delete it + def add_filter(filter = nil, notifier = :default, &block) + call_notifier(notifier, __method__, filter, &block) + end + + ## + # Specifies which keys should *not* be filtered. All other keys will be + # substituted with the +[Filtered]+ label. + # + # @macro proxy_method + # @example + # Airbrake.whitelist([:email, /user/i, 'account_id']) + # + # @param [Array] keys The keys, which shouldn't be + # filtered + # @return [void] + # @since v5.0.0 + # @see .blacklist_keys + def whitelist_keys(keys, notifier = :default) + call_notifier(notifier, __method__, keys) + end + + ## + # Specifies which keys *should* be filtered. Such keys will be replaced with + # the +[Filtered]+ label. + # + # @macro proxy_method + # @example + # Airbrake.blacklist_keys([:email, /credit/i, 'password']) + # + # @param [Array] keys The keys, which should be + # filtered + # @return [void] + # @since v5.0.0 + # @see .whitelist_keys + def blacklist_keys(keys, notifier = :default) + call_notifier(notifier, __method__, keys) + end + + ## + # Builds an Airbrake notice. This is useful, if you want to add or modify a + # value only for a specific notice. When you're done modifying the notice, + # send it with {.notify} or {.notify_sync}. + # + # @macro proxy_method + # @example + # notice = airbrake.build_notice('App crashed!') + # notice[:params][:username] = user.name + # airbrake.notify_sync(notice) + # + # @param [Exception] exception The exception on top of which the notice + # should be built + # @param [Hash] params The additional params attached to the notice + # @return [Airbrake::Notice] the notice built with help of the given + # arguments + # @since v5.0.0 + def build_notice(exception, params = {}, notifier = :default) + call_notifier(notifier, __method__, exception, params) + end + + ## + # Makes the notifier a no-op, which means you cannot use the {.notify} and + # {.notify_sync} methods anymore. It also stops the notifier's worker + # threads. + # + # @macro proxy_method + # @example + # Airbrake.close + # Airbrake.notify('App crashed!') #=> raises Airbrake::Error + # + # @return [void] + # @since v5.0.0 + def close(notifier = :default) + call_notifier(notifier, __method__) + end + + ## + # Pings the Airbrake Deploy API endpoint about the occurred deploy. This + # method is used by the airbrake gem for various integrations. + # + # @macro proxy_method + # @param [Hash{Symbol=>String}] deploy_params The params for the API + # @option deploy_params [Symbol] :environment + # @option deploy_params [Symbol] :username + # @option deploy_params [Symbol] :repository + # @option deploy_params [Symbol] :revision + # @option deploy_params [Symbol] :version + # @since v5.0.0 + # @api private + def create_deploy(deploy_params, notifier = :default) + call_notifier(notifier, __method__, deploy_params) + end + + private + + ## + # Calls +method+ on +notifier+ with provided +args+. + # + # @raise [Airbrake::Error] if none of the notifiers exist + def call_notifier(notifier, method, *args) + if @notifiers.key?(notifier) + @notifiers[notifier].__send__(method, *args) + else + raise Airbrake::Error, + "the '#{notifier}' notifier isn't configured" + end + end + end +end + +# Notify of unhandled exceptions, if there were any. +at_exit do + Airbrake.notify_sync($ERROR_INFO) if $ERROR_INFO +end diff --git a/lib/airbrake-ruby/async_sender.rb b/lib/airbrake-ruby/async_sender.rb new file mode 100644 index 00000000..e868b1fb --- /dev/null +++ b/lib/airbrake-ruby/async_sender.rb @@ -0,0 +1,90 @@ +module Airbrake + ## + # Responsible for sending notices to Airbrake asynchronously. The class + # supports an unlimited number of worker threads and an unlimited queue size + # (both values are configurable). + # + # @see SyncSender + class AsyncSender + ## + # @param [Airbrake::Config] config + def initialize(config) + @config = config + @unsent = SizedQueue.new(config.queue_size) + @sender = SyncSender.new(config) + @closed = false + @workers = ThreadGroup.new + + (0...config.workers).each { @workers.add(spawn_worker) } + @workers.enclose + end + + ## + # Asynchronously sends a notice to Airbrake. + # + # @param [Airbrake::Notice] notice A notice that was generated by the + # library + # @return [nil] + def send(notice) + @unsent << notice + nil + end + + ## + # Closes the instance making it a no-op (it shut downs all worker + # threads). Before closing, waits on all unsent notices to be sent. + # + # @return [void] + # @raise [Airbrake::Error] when invoked more than one time + def close + if closed? + raise Airbrake::Error, 'attempted to close already closed sender' + end + + unless @unsent.empty? + msg = "#{LOG_LABEL} waiting to send #{@unsent.size} unsent notice(s)..." + @config.logger.debug(msg + ' (Ctrl-C to abort)') + end + + @config.workers.times { @unsent << :stop } + @workers.list.each(&:join) + @closed = true + + @config.logger.debug("#{LOG_LABEL} closed") + end + + ## + # Checks whether the sender is closed and thus usable. + # @return [Boolean] + def closed? + @closed + end + + ## + # Checks if an active sender has any workers. A sender doesn't have any + # workers only in two cases: when it was closed or when all workers + # crashed. An *active* sender doesn't have any workers only when something + # went wrong. + # + # Workers are expected to crash when you +fork+ the process the workers are + # living in. Another possible scenario is when you close the instance on + # +at_exit+, but some other +at_exit+ hook prevents the process from + # exiting. + # + # @return [Boolean] true if an instance wasn't closed, but has no workers + # @see https://goo.gl/oydz8h Example of at_exit that prevents exit + def has_workers? + !@closed && @workers.list.any? + end + + private + + def spawn_worker + Thread.new do + while (notice = @unsent.pop) != :stop + @sender.send(notice) + end + end + end + end +end diff --git a/lib/airbrake-ruby/backtrace.rb b/lib/airbrake-ruby/backtrace.rb new file mode 100644 index 00000000..6826b982 --- /dev/null +++ b/lib/airbrake-ruby/backtrace.rb @@ -0,0 +1,75 @@ +module Airbrake + ## + # Represents a cross-Ruby backtrace from exceptions (including JRuby Java + # exceptions). Provides information about stack frames (such as line number, + # file and method) in convenient for Airbrake format. + # + # @example + # begin + # raise 'Oops!' + # rescue + # Backtrace.parse($!) + # end + module Backtrace + ## + # @return [Regexp] the pattern that matches standard Ruby stack frames, + # such as ./spec/notice_spec.rb:43:in `block (3 levels) in ' + STACKFRAME_REGEXP = %r{\A + (?.+) # Matches './spec/notice_spec.rb' + : + (?\d+) # Matches '43' + :in\s + `(?.+)' # Matches "`block (3 levels) in '" + \z}x + + ## + # @return [Regexp] the template that matches JRuby Java stack frames, such + # as org.jruby.ast.NewlineNode.interpret(NewlineNode.java:105) + JAVA_STACKFRAME_REGEXP = /\A + (?.+) # Matches 'org.jruby.ast.NewlineNode.interpret + \( + (?[^:]+) # Matches 'NewlineNode.java' + :? + (?\d+)? # Matches '105' + \) + \z/x + + ## + # Parses an exception's backtrace. + # + # @param [Exception] exception The exception, which contains a backtrace to + # parse + # @return [ArrayString,Integer}>] the parsed backtrace + def self.parse(exception) + regexp = if java_exception?(exception) + JAVA_STACKFRAME_REGEXP + else + STACKFRAME_REGEXP + end + + (exception.backtrace || []).map do |stackframe| + stack_frame(regexp.match(stackframe)) + end + end + + ## + # Checks whether the given exception was generated by JRuby's VM. + # + # @param [Exception] exception + # @return [Boolean] + def self.java_exception?(exception) + defined?(Java::JavaLang::Throwable) && + exception.is_a?(Java::JavaLang::Throwable) + end + + class << self + private + + def stack_frame(match) + { file: match[:file], + line: (Integer(match[:line]) if match[:line]), + function: match[:function] } + end + end + end +end diff --git a/lib/airbrake-ruby/config.rb b/lib/airbrake-ruby/config.rb new file mode 100644 index 00000000..d62dda2b --- /dev/null +++ b/lib/airbrake-ruby/config.rb @@ -0,0 +1,120 @@ +module Airbrake + ## + # Represents the Airbrake config. A config contains all the options that you + # can use to configure an Airbrake instance. + class Config + ## + # @return [Integer] the project identificator. This value *must* be set. + attr_accessor :project_id + + ## + # @return [String] the project key. This value *must* be set. + attr_accessor :project_key + + ## + # @return [Hash] the proxy parameters such as (:host, :port, :user and + # :password) + attr_accessor :proxy + + ## + # @return [Logger] the default logger used for debug output + attr_reader :logger + + ## + # @return [String] the version of the user's application + attr_accessor :app_version + + ## + # @return [Integer] the max number of notices that can be queued up + attr_accessor :queue_size + + ## + # @return [Integer] the number of worker threads that process the notice + # queue + attr_accessor :workers + + ## + # @return [String] the host, which provides the API endpoint to which + # exceptions should be sent + attr_accessor :host + + ## + # @return [String, Pathname] the working directory of your project + attr_accessor :root_directory + + ## + # @return [String, Symbol] the environment the application is running in + attr_accessor :environment + + ## + # @return [Array] the array of environments that forbids + # sending exceptions when the application is running in them. Other + # possible environments not listed in the array will allow sending + # occurring exceptions. + attr_accessor :ignore_environments + + ## + # @param [Hash{Symbol=>Object}] user_config the hash to be used to build the + # config + def initialize(user_config = {}) + self.proxy = {} + self.queue_size = 100 + self.workers = 1 + + self.logger = Logger.new(STDOUT) + logger.level = Logger::WARN + + self.project_id = user_config[:project_id] + self.project_key = user_config[:project_key] + self.host = 'https://airbrake.io' + + self.ignore_environments = [] + + merge(user_config) + end + + ## + # The full URL to the Airbrake Notice API. Based on the +:host+ option. + # @return [URI] the endpoint address + def endpoint + @endpoint ||= + begin + self.host = ('https://' << host) if host !~ %r{\Ahttps?://} + api = "/api/v3/projects/#{project_id}/notices?key=#{project_key}" + URI.join(host, api) + end + end + + ## + # Sets the logger. Never allows to assign `nil` as the logger. + # @return [Logger] the logger + def logger=(logger) + @logger = logger || @logger + end + + ## + # Merges the given +config_hash+ with itself. + # + # @example + # config.merge(host: 'localhost:8080') + # + # @return [self] the merged config + def merge(config_hash) + config_hash.each_pair { |option, value| set_option(option, value) } + self + end + + private + + def set_option(option, value) + __send__("#{option}=", value) + rescue NoMethodError + raise Airbrake::Error, "unknown option '#{option}'" + end + + def set_endpoint(id, key, host) + host = ('https://' << host) if host !~ %r{\Ahttps?://} + @endpoint = URI.join(host, "/api/v3/projects/#{id}/notices?key=#{key}") + end + end +end diff --git a/lib/airbrake-ruby/filter_chain.rb b/lib/airbrake-ruby/filter_chain.rb new file mode 100644 index 00000000..7913b780 --- /dev/null +++ b/lib/airbrake-ruby/filter_chain.rb @@ -0,0 +1,86 @@ +module Airbrake + ## + # Represents the mechanism for filtering notices. Defines a few default + # filters. + # @see Airbrake.add_filter + class FilterChain + ## + # Replaces paths to gems with a placeholder. + # @return [Proc] + GEM_ROOT_FILTER = proc do |notice| + return unless defined?(Gem) + + notice[:errors].each do |error| + Gem.path.each do |gem_path| + error[:backtrace].each do |frame| + frame[:file].sub!(/\A#{gem_path}/, '[GEM_ROOT]'.freeze) + end + end + end + end + + ## + # Skip over SystemExit exceptions, because they're just noise. + # @return [Proc] + SYSTEM_EXIT_FILTER = proc do |notice| + if notice[:errors].any? { |error| error[:type] == 'SystemExit' } + notice.ignore! + end + end + + ## + # @param [Airbrake::Config] config + def initialize(config) + @filters = [] + + if config.ignore_environments.any? + add_filter(env_filter(config.environment, config.ignore_environments)) + end + + [SYSTEM_EXIT_FILTER, GEM_ROOT_FILTER].each do |filter| + add_filter(filter) + end + + root_directory = config.root_directory + add_filter(root_directory_filter(root_directory)) if root_directory + end + + ## + # Adds a filter to the filter chain. + # @param [#call] filter The filter object (proc, class, module, etc) + def add_filter(filter) + @filters << filter + end + + ## + # Applies all the filters in the filter chain to the given notice. Does not + # filter ignored notices. + # + # @param [Airbrake::Notice] notice The notice to be filtered + # @return [void] + def refine(notice) + @filters.each do |filter| + break if notice.ignored? + filter.call(notice) + end + end + + private + + def root_directory_filter(root_directory) + proc do |notice| + notice[:errors].each do |error| + error[:backtrace].each do |frame| + frame[:file].sub!(/\A#{root_directory}/, '[PROJECT_ROOT]'.freeze) + end + end + end + end + + def env_filter(environment, ignore_environments) + proc do |notice| + notice.ignore! if ignore_environments.include?(environment) + end + end + end +end diff --git a/lib/airbrake-ruby/filters.rb b/lib/airbrake-ruby/filters.rb new file mode 100644 index 00000000..0423e8f3 --- /dev/null +++ b/lib/airbrake-ruby/filters.rb @@ -0,0 +1,10 @@ +module Airbrake + ## + # Represents a namespace for default Airbrake Ruby filters. + module Filters + ## + # @return [Array] parts of a Notice's payload that can be modified + # by various filters + FILTERABLE_KEYS = [:environment, :session, :params].freeze + end +end diff --git a/lib/airbrake-ruby/filters/keys_blacklist.rb b/lib/airbrake-ruby/filters/keys_blacklist.rb new file mode 100644 index 00000000..7ddd2ec5 --- /dev/null +++ b/lib/airbrake-ruby/filters/keys_blacklist.rb @@ -0,0 +1,37 @@ +module Airbrake + module Filters + ## + # A default Airbrake notice filter. Filters only specific keys listed in the + # list of parameters in the modifiable payload of a notice. + # + # @example + # filter = Airbrake::Filters::KeysBlacklist.new(:email, /credit/i, 'password') + # airbrake.add_filter(filter) + # airbrake.notify(StandardError.new('App crashed!'), { + # user: 'John' + # password: 's3kr3t', + # email: 'john@example.com', + # credit_card: '5555555555554444' + # }) + # + # # The dashboard will display this parameter as is, but all other + # # values will be filtered: + # # { user: 'John', + # # password: '[Filtered]', + # # email: '[Filtered]', + # # credit_card: '[Filtered]' } + # + # @see KeysWhitelist + # @see KeysFilter + class KeysBlacklist + include KeysFilter + + ## + # @return [Boolean] true if the key matches at least one pattern, false + # otherwise + def should_filter?(key) + @patterns.any? { |pattern| key.to_s.match(pattern) } + end + end + end +end diff --git a/lib/airbrake-ruby/filters/keys_filter.rb b/lib/airbrake-ruby/filters/keys_filter.rb new file mode 100644 index 00000000..dd68ce1a --- /dev/null +++ b/lib/airbrake-ruby/filters/keys_filter.rb @@ -0,0 +1,65 @@ +module Airbrake + module Filters + ## + # This is a filter helper that endows a class ability to filter notices' + # payload based on the return value of the +should_filter?+ method that a + # class that includes this module must implement. + # + # @see Notice + # @see KeysWhitelist + # @see KeysBlacklist + module KeysFilter + ## + # Creates a new KeysBlacklist or KeysWhitelist filter that uses the given + # +patterns+ for filtering a notice's payload. + # + # @param [Array] patterns + def initialize(*patterns) + @patterns = patterns.map(&:to_s) + end + + ## + # This is a mandatory method required by any filter integrated with + # FilterChain. + # + # @param [Notice] notice the notice to be filtered + # @return [void] + # @see FilterChain + def call(notice) + FILTERABLE_KEYS.each { |key| filter_hash(notice[key]) } + + return unless notice[:context][:url] + url = URI(notice[:context][:url]) + return if url.nil? || url.query.nil? + + notice[:context][:url] = filter_url_params(url) + end + + ## + # @raise [NotImplementedError] if called directly + def should_filter?(_key) + raise NotImplementedError, 'method must be implemented in the included class' + end + + private + + def filter_hash(hash) + hash.each_key do |key| + if should_filter?(key) + hash[key] = '[Filtered]'.freeze + else + filter_hash(hash[key]) if hash[key].is_a?(Hash) + end + end + end + + def filter_url_params(url) + url.query = Hash[URI.decode_www_form(url.query)].map do |key, val| + should_filter?(key) ? "#{key}=[Filtered]" : "#{key}=#{val}" + end.join('&') + + url.to_s + end + end + end +end diff --git a/lib/airbrake-ruby/filters/keys_whitelist.rb b/lib/airbrake-ruby/filters/keys_whitelist.rb new file mode 100644 index 00000000..3a669295 --- /dev/null +++ b/lib/airbrake-ruby/filters/keys_whitelist.rb @@ -0,0 +1,37 @@ +module Airbrake + module Filters + ## + # A default Airbrake notice filter. Filters everything in the modifiable + # payload of a notice, but specified keys. + # + # @example + # filter = Airbrake::Filters::KeysWhitelist.new(:email, /user/i, 'account_id') + # airbrake.add_filter(filter) + # airbrake.notify(StandardError.new('App crashed!'), { + # user: 'John', + # password: 's3kr3t', + # email: 'john@example.com', + # account_id: 42 + # }) + # + # # The dashboard will display this parameters as filtered, but other + # # values won't be affected: + # # { user: 'John', + # # password: '[Filtered]', + # # email: 'john@example.com', + # # account_id: 42 } + # + # @see KeysBlacklist + # @see KeysFilter + class KeysWhitelist + include KeysFilter + + ## + # @return [Boolean] true if the key doesn't match any pattern, false + # otherwise. + def should_filter?(key) + @patterns.none? { |pattern| key.to_s.match(pattern) } + end + end + end +end diff --git a/lib/airbrake-ruby/notice.rb b/lib/airbrake-ruby/notice.rb new file mode 100644 index 00000000..fe67ac2e --- /dev/null +++ b/lib/airbrake-ruby/notice.rb @@ -0,0 +1,207 @@ +module Airbrake + ## + # Represents a chunk of information that is meant to be either sent to + # Airbrake or ignored completely. + class Notice + # @return [Hash{Symbol=>String}] the information about the notifier library + NOTIFIER = { + name: 'airbrake-ruby'.freeze, + version: Airbrake::AIRBRAKE_RUBY_VERSION, + url: 'https://github.com/airbrake/airbrake-ruby'.freeze + }.freeze + + ## + # @return [Hash{Symbol=>String,Hash}] the information to be displayed in the + # Context tab in the dashboard + CONTEXT = { + os: RUBY_PLATFORM, + language: RUBY_VERSION, + notifier: NOTIFIER + }.freeze + + ## + # @return [Integer] the maxium size of the JSON payload in bytes + MAX_NOTICE_SIZE = 64000 + + ## + # @return [Integer] the maximum number of nested exceptions that a notice + # can unwrap. Exceptions that have a longer cause chain will be ignored + MAX_NESTED_EXCEPTIONS = 3 + + ## + # @return [Integer] the maximum size of hashes, arrays and strings in the + # notice. + PAYLOAD_MAX_SIZE = 10000 + + ## + # @return [Array] the list of possible exceptions that might + # be raised when an object is converted to JSON + JSON_EXCEPTIONS = [ + IOError, + NotImplementedError, + JSON::GeneratorError, + Encoding::UndefinedConversionError + ] + + # @return [Array] the list of keys that can be be overwritten with + # {Airbrake::Notice#[]=} + WRITABLE_KEYS = [ + :notifier, + :context, + :environment, + :session, + :params + ] + + def initialize(config, exception, params = {}) + @config = config + + @private_payload = { + notifier: NOTIFIER + }.freeze + + @modifiable_payload = { + errors: errors(exception), + context: context(params), + environment: {}, + session: {}, + params: params + } + + @truncator = PayloadTruncator.new(PAYLOAD_MAX_SIZE, @config.logger) + end + + ## + # Converts the notice to JSON. Calls +to_json+ on each object inside + # notice's payload. Truncates notices, JSON representation of which is + # bigger than {MAX_NOTICE_SIZE}. + # + # @return [Hash{String=>String}] + def to_json + loop do + begin + json = payload.to_json + rescue *JSON_EXCEPTIONS => ex + @config.logger.debug("#{LOG_LABEL} `notice.to_json` failed: #{ex.to_s.chomp}") + else + return json if json && json.bytesize <= MAX_NOTICE_SIZE + end + + truncate_payload + end + end + + ## + # Ignores a notice. Ignored notices never reach the Airbrake dashboard. + # + # @return [void] + # @see #ignored? + # @note Ignored noticed can't be unignored + def ignore! + @modifiable_payload = nil + end + + ## + # Checks whether the notice was ignored. + # + # @return [Boolean] + # @see #ignore! + def ignored? + @modifiable_payload.nil? + end + + ## + # Reads a value from notice's modifiable payload. + # @return [Object] + # + # @raise [Airbrake::Error] if the notice is ignored + def [](key) + raise_if_ignored + @modifiable_payload[key] + end + + ## + # Writes a value to the modifiable payload hash. Restricts unrecognized + # writes. + # @example + # notice[:params][:my_param] = 'foobar' + # + # @return [void] + # @raise [Airbrake::Error] if the notice is ignored + # @raise [Airbrake::Error] if the +key+ is not recognized + # @raise [Airbrake::Error] if the root value is not a Hash + def []=(key, value) + raise_if_ignored + raise_if_unrecognized_key(key) + raise_if_non_hash_value(value) + + @modifiable_payload[key] = value.to_hash + end + + private + + def context(params) + { version: @config.app_version, + # We ensure that root_directory is always a String, so it can always be + # converted to JSON in a predictable manner (when it's a Pathname and in + # Rails environment, it converts to unexpected JSON). + rootDirectory: @config.root_directory.to_s, + environment: @config.environment, + + # Legacy Airbrake v4 behaviour. + component: params.delete(:component), + action: params.delete(:action) + }.merge(CONTEXT).delete_if { |_key, val| val.nil? || val.empty? } + end + + def raise_if_ignored + return unless self.ignored? + raise Airbrake::Error, 'cannot access ignored notice' + end + + def raise_if_unrecognized_key(key) + return if WRITABLE_KEYS.include?(key) + raise Airbrake::Error, + ":#{key} is not recognized among #{WRITABLE_KEYS}" + end + + def raise_if_non_hash_value(value) + return if value.respond_to?(:to_hash) + raise Airbrake::Error, "Got #{value.class} value, wanted a Hash" + end + + def payload + @modifiable_payload.merge(@private_payload) + end + + def errors(exception) + exception_list = [] + + while exception && exception_list.size < MAX_NESTED_EXCEPTIONS + exception_list << exception + + exception = if exception.respond_to?(:cause) && exception.cause + exception.cause + end + end + + exception_list.map do |e| + { type: e.class.name, + message: e.message, + backtrace: Backtrace.parse(e) } + end + end + + def truncate_payload + @modifiable_payload[:errors].each do |error| + @truncator.truncate_error(error) + end + + Filters::FILTERABLE_KEYS.each do |key| + @truncator.truncate_object(@modifiable_payload[key]) + end + + @truncator.reduce_max_size + end + end +end diff --git a/lib/airbrake-ruby/notifier.rb b/lib/airbrake-ruby/notifier.rb new file mode 100644 index 00000000..c5ef633e --- /dev/null +++ b/lib/airbrake-ruby/notifier.rb @@ -0,0 +1,145 @@ +module Airbrake + ## + # This class is reponsible for sending notices to Airbrake. It supports + # synchronous and asynchronous delivery. + # + # @see Airbrake::Config The list of options + # @api private + # @since v5.0.0 + class Notifier + ## + # Creates a new Airbrake notifier with the given config options. + # + # @example Configuring with a Hash + # airbrake = Airbrake.new(project_id: 123, project_key: '321') + # + # @example Configuring with an Airbrake::Config + # config = Airbrake::Config.new + # config.project_id = 123 + # config.project_key = '321' + # airbake = Airbrake.new(config) + # + # @param [Hash, Airbrake::Config] user_config The config that contains + # information about how the notifier should operate + # @raise [Airbrake::Error] when either +project_id+ or +project_key+ + # is missing (or both) + def initialize(user_config) + @config = (user_config.is_a?(Config) ? user_config : Config.new(user_config)) + + unless [@config.project_id, @config.project_key].all? + raise Airbrake::Error, 'both :project_id and :project_key are required' + end + + @filter_chain = FilterChain.new(@config) + @async_sender = AsyncSender.new(@config) + @sync_sender = SyncSender.new(@config) + end + + ## + # @!macro see_public_api_method + # @see Airbrake.$0 + + ## + # @macro see_public_api_method + def notify(exception, params = {}) + send_notice(exception, params) + nil + end + + ## + # @macro see_public_api_method + def notify_sync(exception, params = {}) + send_notice(exception, params, @sync_sender) + end + + ## + # @macro see_public_api_method + def add_filter(filter = nil, &block) + @filter_chain.add_filter(block_given? ? block : filter) + end + + ## + # @macro see_public_api_method + def whitelist_keys(keys) + add_filter(Filters::KeysWhitelist.new(*keys)) + end + + ## + # @macro see_public_api_method + def blacklist_keys(keys) + add_filter(Filters::KeysBlacklist.new(*keys)) + end + + ## + # @macro see_public_api_method + def build_notice(exception, params = {}) + if @async_sender.closed? + raise Airbrake::Error, + "attempted to build #{exception} with closed Airbrake instance" + end + + if exception.is_a?(Airbrake::Notice) + exception + else + Notice.new(@config, convert_to_exception(exception), params) + end + end + + ## + # @macro see_public_api_method + def close + @async_sender.close + end + + ## + # @macro see_public_api_method + def create_deploy(deploy_params) + deploy_params[:environment] ||= @config.environment + + host = @config.endpoint.to_s.split(@config.endpoint.path).first + path = "/api/v4/projects/#{@config.project_id}/deploys?key=#{@config.project_key}" + + @sync_sender.send(deploy_params, URI.join(host, path)) + end + + private + + def convert_to_exception(ex) + if ex.is_a?(Exception) || Backtrace.java_exception?(ex) + # Manually created exceptions don't have backtraces, so we create a fake + # one, whose first frame points to the place where Airbrake was called + # (normally via `notify`). + ex.set_backtrace(clean_backtrace) unless ex.backtrace + return ex + end + + e = RuntimeError.new(ex.to_s) + e.set_backtrace(clean_backtrace) + e + end + + def send_notice(exception, params, sender = default_sender) + notice = build_notice(exception, params) + @filter_chain.refine(notice) + return if notice.ignored? + + sender.send(notice) + end + + def default_sender + if @async_sender.has_workers? + @async_sender + else + @config.logger.warn( + "#{LOG_LABEL} falling back to sync delivery because there are no " \ + "running async workers" + ) + @sync_sender + end + end + + def clean_backtrace + caller.drop_while { |frame| frame.include?('/lib/airbrake') } + end + end +end diff --git a/lib/airbrake-ruby/payload_truncator.rb b/lib/airbrake-ruby/payload_truncator.rb new file mode 100644 index 00000000..2f87e587 --- /dev/null +++ b/lib/airbrake-ruby/payload_truncator.rb @@ -0,0 +1,141 @@ +module Airbrake + ## + # This class is responsible for truncation of too big objects. Mainly, you + # should use it for simple objects such as strings, hashes, & arrays. + class PayloadTruncator + ## + # @return [Hash] the options for +String#encode+ + ENCODING_OPTIONS = { invalid: :replace, undef: :replace }.freeze + + ## + # @return [String] the temporary encoding to be used when fixing invalid + # strings with +ENCODING_OPTIONS+ + TEMP_ENCODING = (RUBY_VERSION == '1.9.2' ? 'iso-8859-1' : 'utf-16') + + ## + # @param [Integer] max_size maximum size of hashes, arrays and strings + # @param [Logger] logger the logger object + def initialize(max_size, logger) + @max_size = max_size + @logger = logger + end + + ## + # Truncates errors (not exceptions) to fit the limit. + # + # @param [Hash] error + # @option error [Symbol] :message + # @option error [Array] :backtrace + # @return [void] + def truncate_error(error) + if error[:message].length > @max_size + error[:message] = truncate_string(error[:message]) + @logger.info("#{LOG_LABEL} truncated the message of #{error[:type]}") + end + + return if (dropped_frames = error[:backtrace].size - @max_size) < 0 + + error[:backtrace] = error[:backtrace].slice(0, @max_size) + @logger.info("#{LOG_LABEL} dropped #{dropped_frames} frame(s) from #{error[:type]}") + end + + ## + # Performs deep truncation of arrays, hashes and sets. Uses a + # placeholder for recursive objects (`[Circular]`). + # + # @param [Hash,Array] object The object to truncate + # @param [Hash] seen The hash that helps to detect recursion + # @return [void] + def truncate_object(object, seen = {}) + return seen[object] if seen[object] + + seen[object] = '[Circular]'.freeze + truncated = if object.is_a?(Hash) + truncate_hash(object, seen) + elsif object.is_a?(Array) + truncate_array(object, seen) + elsif object.is_a?(Set) + truncate_set(object, seen) + else + raise Airbrake::Error, + "cannot truncate object: #{object} (#{object.class})" + end + seen[object] = truncated + end + + ## + # Reduces maximum allowed size of the truncated object. + # @return [void] + def reduce_max_size + @max_size /= 2 + end + + private + + def truncate(val, seen) + case val + when String + truncate_string(val) + when Array, Hash, Set + truncate_object(val, seen) + when Numeric, TrueClass, FalseClass, Symbol, NilClass + val + else + stringified_val = begin + val.to_json + rescue *Notice::JSON_EXCEPTIONS + val.to_s + end + truncate_string(stringified_val) + end + end + + def truncate_string(str) + replace_invalid_characters!(str) + return str if str.length <= @max_size + str.slice(0, @max_size) + '[Truncated]'.freeze + end + + ## + # Replaces invalid characters in string with arbitrary encoding. + # + # For Ruby 1.9.2 the method converts encoding of +str+ to +iso-8859-1+ to + # avoid a bug when encoding options are no-op, when `#encode` is given the + # same encoding as the receiver's encoding. + # + # For modern Rubies we use UTF-16 as a safe alternative. + # + # @param [String] str The string to replace characters + # @return [void] + # @note This method mutates +str+ for speed + # @see https://github.com/flori/json/commit/3e158410e81f94dbbc3da6b7b35f4f64983aa4e3 + def replace_invalid_characters!(str) + encoding = str.encoding + utf8_string = (encoding == Encoding::UTF_8 || encoding == Encoding::ASCII) + return str if utf8_string && str.valid_encoding? + + str.encode!(TEMP_ENCODING, ENCODING_OPTIONS) if utf8_string + str.encode!('utf-8', ENCODING_OPTIONS) + end + + def truncate_hash(hash, seen) + hash.each_with_index do |(key, val), idx| + if idx < @max_size + hash[key] = truncate(val, seen) + else + hash.delete(key) + end + end + end + + def truncate_array(array, seen) + array.slice(0, @max_size).map! { |val| truncate(val, seen) } + end + + def truncate_set(set, seen) + set.keep_if.with_index { |_val, idx| idx < @max_size }.map! do |val| + truncate(val, seen) + end + end + end +end diff --git a/lib/airbrake-ruby/response.rb b/lib/airbrake-ruby/response.rb new file mode 100644 index 00000000..7905e8cc --- /dev/null +++ b/lib/airbrake-ruby/response.rb @@ -0,0 +1,53 @@ +module Airbrake + ## + # Parses responses coming from the Airbrake API. Handles HTTP errors by + # logging them. + module Response + ## + # @return [Integer] the limit of the response body + TRUNCATE_LIMIT = 100 + + ## + # Parses HTTP responses from the Airbrake API. + # + # @param [Net::HTTPResponse] response + # @param [Logger] logger + # @return [Hash{String=>String}] parsed response + def self.parse(response, logger) + code = response.code.to_i + body = response.body + + begin + case code + when 201 + parsed_body = JSON.parse(body) + logger.debug("#{LOG_LABEL} #{parsed_body}") + parsed_body + when 400, 401, 403, 429 + parsed_body = JSON.parse(body) + logger.error("#{LOG_LABEL} #{parsed_body['error']}") + parsed_body + else + body_msg = truncated_body(body) + logger.error("#{LOG_LABEL} unexpected code (#{code}). Body: #{body_msg}") + { 'error' => body_msg } + end + rescue => ex + body_msg = truncated_body(body) + logger.error("#{LOG_LABEL} error while parsing body (#{ex}). Body: #{body_msg}") + { 'error' => ex } + end + end + + def self.truncated_body(body) + if body.nil? + '[EMPTY_BODY]'.freeze + elsif body.length > TRUNCATE_LIMIT + body[0..TRUNCATE_LIMIT] << '...' + else + body + end + end + private_class_method :truncated_body + end +end diff --git a/lib/airbrake-ruby/sync_sender.rb b/lib/airbrake-ruby/sync_sender.rb new file mode 100644 index 00000000..58b0caa5 --- /dev/null +++ b/lib/airbrake-ruby/sync_sender.rb @@ -0,0 +1,76 @@ +module Airbrake + ## + # Responsible for sending notices to Airbrake synchronously. Supports proxies. + # + # @see AsyncSender + class SyncSender + ## + # @return [String] body for HTTP requests + CONTENT_TYPE = 'application/json'.freeze + + ## + # @return [Array] the errors to be rescued and logged during an HTTP request + HTTP_ERRORS = [ + Timeout::Error, + Net::HTTPBadResponse, + Net::HTTPHeaderSyntaxError, + Errno::ECONNRESET, + Errno::ECONNREFUSED, + EOFError, + OpenSSL::SSL::SSLError + ] + + ## + # @param [Airbrake::Config] config + def initialize(config) + @config = config + end + + ## + # Sends a POST request to the given +endpoint+ with the +notice+ payload. + # + # @param [Airbrake::Notice] notice + # @param [Airbrake::Notice] endpoint + # @return [Hash{String=>String}] the parsed HTTP response + def send(notice, endpoint = @config.endpoint) + response = nil + req = build_post_request(endpoint, notice) + https = build_https(endpoint) + + begin + response = https.request(req) + rescue *HTTP_ERRORS => ex + @config.logger.error("#{LOG_LABEL} HTTP error: #{ex}") + return + end + + Response.parse(response, @config.logger) + end + + private + + def build_https(uri) + Net::HTTP.new(uri.host, uri.port, *proxy_params).tap do |https| + https.use_ssl = uri.is_a?(URI::HTTPS) + end + end + + def build_post_request(uri, notice) + Net::HTTP::Post.new(uri.request_uri).tap do |req| + req.body = notice.to_json + + req['Content-Type'] = CONTENT_TYPE + req['User-Agent'] = + "#{Airbrake::Notice::NOTIFIER[:name]}/#{Airbrake::AIRBRAKE_RUBY_VERSION}" \ + " Ruby/#{RUBY_VERSION}" + end + end + + def proxy_params + [@config.proxy[:host], + @config.proxy[:port], + @config.proxy[:user], + @config.proxy[:password]] + end + end +end diff --git a/lib/airbrake-ruby/version.rb b/lib/airbrake-ruby/version.rb new file mode 100644 index 00000000..9952fd9d --- /dev/null +++ b/lib/airbrake-ruby/version.rb @@ -0,0 +1,7 @@ +## +# Defines version. +module Airbrake + ## + # @return [String] the library version + AIRBRAKE_RUBY_VERSION = '1.0.0.rc.1'.freeze +end diff --git a/spec/airbrake_spec.rb b/spec/airbrake_spec.rb new file mode 100644 index 00000000..5cef6250 --- /dev/null +++ b/spec/airbrake_spec.rb @@ -0,0 +1,177 @@ +require 'spec_helper' + +RSpec.describe Airbrake do + let(:endpoint) do + 'https://airbrake.io/api/v3/projects/113743/notices?key=fd04e13d806a90f96614ad8e529b2822' + end + + before do + described_class.configure do |c| + c.project_id = 113743 + c.project_key = 'fd04e13d806a90f96614ad8e529b2822' + end + + stub_request(:post, endpoint).to_return(status: 201, body: '{}') + end + + after do + described_class.instance_variable_set(:@notifiers, {}) + end + + shared_examples 'error handling' do |method| + it "raises error if there is no notifier when using #{method}" do + described_class.instance_variable_set(:@notifiers, {}) + + expect { described_class.__send__(method, 'bingo') }. + to raise_error(Airbrake::Error, + "the 'default' notifier isn't configured") + end + end + + describe ".notify" do + include_examples 'error handling', :notify + + it "sends exceptions asynchronously" do + described_class.notify('bingo') + sleep 2 + expect(a_request(:post, endpoint)).to have_been_made.once + end + end + + describe ".notify_sync" do + include_examples 'error handling', :notify_sync + + it "sends exceptions synchronously" do + expect(described_class.notify_sync('bingo')).to be_a(Hash) + expect(a_request(:post, endpoint)).to have_been_made.once + end + + context "given the notifier argument" do + it "sends exceptions via that notifier, ignoring other ones" do + bingo_string = StringIO.new + bango_string = StringIO.new + + described_class.configure(:bingo) do |c| + c.project_id = 113743 + c.project_key = 'fd04e13d806a90f96614ad8e529b2822' + c.logger = Logger.new(bingo_string) + end + + described_class.configure(:bango) do |c| + c.project_id = 113743 + c.project_key = 'fd04e13d806a90f96614ad8e529b2822' + c.logger = Logger.new(bango_string) + end + + stub_request(:post, endpoint).to_return(status: 201, body: '{"id":1}') + + described_class.notify_sync('bango', {}, :bango) + expect(bingo_string.string).to be_empty + expect(bango_string.string).to match(/\*\*Airbrake: {"id"=>1}/) + end + end + + describe "clean backtrace" do + shared_examples 'backtrace building' do |msg, argument| + it(msg) do + described_class.notify_sync(argument) + + # rubocop:disable Metrics/LineLength + expected_body = %r| + {"errors":\[{"type":"RuntimeError","message":"bingo","backtrace":\[ + {"file":"[\w/\-\.]+spec/airbrake_spec.rb","line":\d+,"function":"[\w/\s\(\)<>]+"}, + {"file":"\[GEM_ROOT\]/gems/rspec-core-.+/.+","line":\d+,"function":"[\w/\s\(\)<>]+"} + |x + # rubocop:enable Metrics/LineLength + + expect( + a_request(:post, endpoint). + with(body: expected_body) + ).to have_been_made.once + end + end + + context "given a String" do + include_examples( + 'backtrace building', + 'converts it to a RuntimeException and builds a fake backtrace', + 'bingo') + end + + context "given an Exception with missing backtrace" do + include_examples( + 'backtrace building', + 'builds a backtrace for it and sends the notice', + RuntimeError.new('bingo')) + end + end + + context "special params" do + it "sends context/component and doesn't contain params/component" do + described_class.notify_sync('bingo', component: 'bango') + + expect( + a_request(:post, endpoint). + with(body: /"context":{.*"component":"bango".+"params":{}/) + ).to have_been_made.once + end + + it "sends context/action and doesn't contain params/action" do + described_class.notify_sync('bingo', action: 'bango') + + expect( + a_request(:post, endpoint). + with(body: /"context":{.*"action":"bango".+"params":{}/) + ).to have_been_made.once + end + end + end + + describe ".configure" do + context "given an argument" do + it "configures a notifier with the given name" do + described_class.configure(:bingo) do |c| + c.project_id = 123 + c.project_key = '321' + end + + notifiers = described_class.instance_variable_get(:@notifiers) + + expect(notifiers).to be_a(Hash) + expect(notifiers.keys).to eq([:default, :bingo]) + expect(notifiers.values).to all(satisfy { |v| v.is_a?(Airbrake::Notifier) }) + end + + it "raises error when a notifier of the given type was already configured" do + described_class.configure(:bingo) do |c| + c.project_id = 123 + c.project_key = '321' + end + + expect do + described_class.configure(:bingo) do |c| + c.project_id = 123 + c.project_key = '321' + end + end.to raise_error(Airbrake::Error, + "the 'bingo' notifier was already configured") + end + end + end + + describe ".add_filter" do + include_examples 'error handling', :add_filter + end + + describe ".whitelist_keys" do + include_examples 'error handling', :whitelist_keys + end + + describe ".blacklist_keys" do + include_examples 'error handling', :blacklist_keys + end + + describe ".build_notice" do + include_examples 'error handling', :build_notice + end +end diff --git a/spec/async_sender_spec.rb b/spec/async_sender_spec.rb new file mode 100644 index 00000000..f4087902 --- /dev/null +++ b/spec/async_sender_spec.rb @@ -0,0 +1,121 @@ +require 'spec_helper' + +RSpec.describe Airbrake::AsyncSender do + before do + stub_request(:post, /.*/).to_return(status: 201, body: '{}') + @sender = described_class.new(Airbrake::Config.new) + @workers = @sender.instance_variable_get(:@workers) + end + + describe "#new" do + context "workers_count parameter" do + let(:new_workers) { 5 } + let(:config) { Airbrake::Config.new(workers: new_workers) } + + it "spawns alive threads in an enclosed ThreadGroup" do + expect(@workers).to be_a(ThreadGroup) + expect(@workers.list).to all(be_alive) + expect(@workers).to be_enclosed + end + + it "controls the number of spawned threads" do + expect(@workers.list.size).to eq(1) + + sender = described_class.new(config) + workers = sender.instance_variable_get(:@workers) + + expect(workers.list.size).to eq(new_workers) + sender.close + end + end + + context "queue" do + before do + @stdout = StringIO.new + end + + let(:notices) { 1000 } + + let(:config) do + Airbrake::Config.new(logger: Logger.new(@stdout), workers: 3, queue_size: 10) + end + + it "limits the size of the queue, but still sends all notices" do + sender = described_class.new(config) + + notices.times { |i| sender.send(i) } + sender.close + + log = @stdout.string.split("\n") + expect(log.grep(/\*\*Airbrake: \{\}/).size).to eq(notices) + end + end + end + + describe "#close" do + before do + @stderr = StringIO.new + config = Airbrake::Config.new(logger: Logger.new(@stderr)) + @sender = described_class.new(config) + @workers = @sender.instance_variable_get(:@workers).list + end + + context "when there are no unsent notices" do + it "joins the spawned thread" do + expect(@workers).to all(be_alive) + @sender.close + expect(@workers).to all(be_stop) + end + end + + context "when there are some unsent notices" do + before do + 300.times { |i| @sender.send(i) } + expect(@sender.instance_variable_get(:@unsent).size).not_to be_zero + @sender.close + end + + it "warns about the number of notices" do + expect(@stderr.string).to match(/waiting to send \d+ unsent notice/) + end + + it "prints the debug message the correct number of times" do + log = @stderr.string.split("\n") + expect(log.grep(/\*\*Airbrake: \{\}/).size).to eq(300) + end + + it "waits until the unsent notices queue is empty" do + expect(@sender.instance_variable_get(:@unsent).size).to be_zero + end + end + + context "when it was already closed" do + it "doesn't increase the unsent queue size" do + @sender.close + expect(@sender.instance_variable_get(:@unsent).size).to be_zero + + expect { @sender.close }. + to raise_error(Airbrake::Error, 'attempted to close already closed sender') + end + end + end + + describe "#has_workers?" do + it "returns false when the sender is not closed, but has 0 workers" do + sender = described_class.new(Airbrake::Config.new) + expect(sender.has_workers?).to be_truthy + + sender.instance_variable_get(:@workers).list.each(&:kill) + sleep 1 + expect(sender.has_workers?).to be_falsey + end + + it "returns false when the sender is closed" do + sender = described_class.new(Airbrake::Config.new) + expect(sender.has_workers?).to be_truthy + + sender.close + expect(sender.has_workers?).to be_falsey + end + end +end diff --git a/spec/backtrace_spec.rb b/spec/backtrace_spec.rb new file mode 100644 index 00000000..bf9819c7 --- /dev/null +++ b/spec/backtrace_spec.rb @@ -0,0 +1,77 @@ +require 'spec_helper' + +RSpec.describe Airbrake::Backtrace do + describe ".parse" do + context "UNIX backtrace" do + let(:backtrace) { described_class.new(AirbrakeTestError.new) } + + let(:parsed_backtrace) do + # rubocop:disable Metrics/LineLength, Style/HashSyntax, Style/SpaceAroundOperators, Style/SpaceInsideHashLiteralBraces + [{:file=>"/home/kyrylo/code/airbrake/ruby/spec/spec_helper.rb", :line=>23, :function=>""}, + {:file=>"/opt/rubies/ruby-2.2.2/lib/ruby/2.2.0/rubygems/core_ext/kernel_require.rb", :line=>54, :function=>"require"}, + {:file=>"/opt/rubies/ruby-2.2.2/lib/ruby/2.2.0/rubygems/core_ext/kernel_require.rb", :line=>54, :function=>"require"}, + {:file=>"/home/kyrylo/code/airbrake/ruby/spec/airbrake_spec.rb", :line=>1, :function=>""}, + {:file=>"/home/kyrylo/.gem/ruby/2.2.2/gems/rspec-core-3.3.2/lib/rspec/core/configuration.rb", :line=>1327, :function=>"load"}, + {:file=>"/home/kyrylo/.gem/ruby/2.2.2/gems/rspec-core-3.3.2/lib/rspec/core/configuration.rb", :line=>1327, :function=>"block in load_spec_files"}, + {:file=>"/home/kyrylo/.gem/ruby/2.2.2/gems/rspec-core-3.3.2/lib/rspec/core/configuration.rb", :line=>1325, :function=>"each"}, + {:file=>"/home/kyrylo/.gem/ruby/2.2.2/gems/rspec-core-3.3.2/lib/rspec/core/configuration.rb", :line=>1325, :function=>"load_spec_files"}, + {:file=>"/home/kyrylo/.gem/ruby/2.2.2/gems/rspec-core-3.3.2/lib/rspec/core/runner.rb", :line=>102, :function=>"setup"}, + {:file=>"/home/kyrylo/.gem/ruby/2.2.2/gems/rspec-core-3.3.2/lib/rspec/core/runner.rb", :line=>88, :function=>"run"}, + {:file=>"/home/kyrylo/.gem/ruby/2.2.2/gems/rspec-core-3.3.2/lib/rspec/core/runner.rb", :line=>73, :function=>"run"}, + {:file=>"/home/kyrylo/.gem/ruby/2.2.2/gems/rspec-core-3.3.2/lib/rspec/core/runner.rb", :line=>41, :function=>"invoke"}, + {:file=>"/home/kyrylo/.gem/ruby/2.2.2/gems/rspec-core-3.3.2/exe/rspec", :line=>4, :function=>"
"}] + # rubocop:enable Metrics/LineLength, Style/HashSyntax,Style/SpaceAroundOperators, Style/SpaceInsideHashLiteralBraces + end + + it "returns a properly formatted array of hashes" do + expect(described_class.parse(AirbrakeTestError.new)). + to eq(parsed_backtrace) + end + end + + context "Windows backtrace" do + let(:windows_bt) do + ["C:/Program Files/Server/app/models/user.rb:13:in `magic'", + "C:/Program Files/Server/app/controllers/users_controller.rb:8:in `index'"] + end + + let(:ex) { AirbrakeTestError.new.tap { |e| e.set_backtrace(windows_bt) } } + + let(:parsed_backtrace) do + # rubocop:disable Metrics/LineLength, Style/HashSyntax, Style/SpaceInsideHashLiteralBraces, Style/SpaceAroundOperators + [{:file=>"C:/Program Files/Server/app/models/user.rb", :line=>13, :function=>"magic"}, + {:file=>"C:/Program Files/Server/app/controllers/users_controller.rb", :line=>8, :function=>"index"}] + # rubocop:enable Metrics/LineLength, Style/HashSyntax, Style/SpaceInsideHashLiteralBraces, Style/SpaceAroundOperators + end + + it "returns a properly formatted array of hashes" do + expect(described_class.parse(ex)).to eq(parsed_backtrace) + end + end + + context "JRuby Java exceptions" do + let(:backtrace_array) do + # rubocop:disable Metrics/LineLength, Style/HashSyntax, Style/SpaceInsideHashLiteralBraces, Style/SpaceAroundOperators + [{:file=>"InstanceMethodInvoker.java", :line=>26, :function=>"org.jruby.java.invokers.InstanceMethodInvoker.call"}, + {:file=>"Interpreter.java", :line=>126, :function=>"org.jruby.ir.interpreter.Interpreter.INTERPRET_EVAL"}, + {:file=>"RubyKernel$INVOKER$s$0$3$eval19.gen", :line=>nil, :function=>"org.jruby.RubyKernel$INVOKER$s$0$3$eval19.call"}, + {:file=>"RubyKernel$INVOKER$s$0$0$loop.gen", :line=>nil, :function=>"org.jruby.RubyKernel$INVOKER$s$0$0$loop.call"}, + {:file=>"IRBlockBody.java", :line=>139, :function=>"org.jruby.runtime.IRBlockBody.doYield"}, + {:file=>"RubyKernel$INVOKER$s$rbCatch19.gen", :line=>nil, :function=>"org.jruby.RubyKernel$INVOKER$s$rbCatch19.call"}, + {:file=>"/opt/rubies/jruby-9.0.0.0/bin/irb", :line=>nil, :function=>"opt.rubies.jruby_minus_9_dot_0_dot_0_dot_0.bin.irb.invokeOther4:start"}, + {:file=>"/opt/rubies/jruby-9.0.0.0/bin/irb", :line=>13, :function=>"opt.rubies.jruby_minus_9_dot_0_dot_0_dot_0.bin.irb.RUBY$script"}, + {:file=>"Compiler.java", :line=>111, :function=>"org.jruby.ir.Compiler$1.load"}, + {:file=>"Main.java", :line=>225, :function=>"org.jruby.Main.run"}, + {:file=>"Main.java", :line=>197, :function=>"org.jruby.Main.main"}] + # rubocop:enable Metrics/LineLength, Style/HashSyntax, Style/SpaceInsideHashLiteralBraces, Style/SpaceAroundOperators + end + + it "returns a properly formatted array of hashes" do + allow(described_class).to receive(:java_exception?).and_return(true) + + expect(described_class.parse(JavaAirbrakeTestError.new)). + to eq(backtrace_array) + end + end + end +end diff --git a/spec/config_spec.rb b/spec/config_spec.rb new file mode 100644 index 00000000..45562080 --- /dev/null +++ b/spec/config_spec.rb @@ -0,0 +1,67 @@ +require 'spec_helper' + +RSpec.describe Airbrake::Config do + let(:config) { described_class.new } + + describe "#new" do + describe "options" do + it "doesn't set the default project_id" do + expect(config.project_id).to be_nil + end + + it "doesn't set the default project_key" do + expect(config.project_key).to be_nil + end + + it "doesn't set the default proxy" do + expect(config.proxy).to be_empty + end + + it "sets the default logger" do + expect(config.logger).to be_a Logger + end + + it "doesn't set the default app_version" do + expect(config.app_version).to be_nil + end + + it "sets the default host" do + expect(config.host).to eq('https://airbrake.io') + end + + it "sets the default endpoint" do + expect(config.endpoint).not_to be_nil + end + + it "creates a new Config and merges it with the user config" do + cfg = described_class.new(logger: StringIO.new) + expect(cfg.logger).to be_a StringIO + end + + it "raises error on unknown config options" do + expect { described_class.new(unknown_option: true) }. + to raise_error(Airbrake::Error, /unknown option/) + end + + it "sets the default number of workers" do + expect(config.workers).to eq(1) + end + + it "sets the default number of queue size" do + expect(config.queue_size).to eq(100) + end + + it "doesn't set the default root_directory" do + expect(config.root_directory).to be_nil + end + + it "doesn't set the default environment" do + expect(config.environment).to be_nil + end + + it "doesn't set default notify_environments" do + expect(config.ignore_environments).to be_empty + end + end + end +end diff --git a/spec/filter_chain_spec.rb b/spec/filter_chain_spec.rb new file mode 100644 index 00000000..9e2490a6 --- /dev/null +++ b/spec/filter_chain_spec.rb @@ -0,0 +1,157 @@ +require 'spec_helper' + +RSpec.describe Airbrake::FilterChain do + before do + @chain = described_class.new(config) + end + + let(:config) { Airbrake::Config.new } + + describe "#refine" do + describe "execution order" do + let(:notice) do + Airbrake::Notice.new(config, AirbrakeTestError.new) + end + + it "executes filters starting from the oldest" do + nums = [] + + 3.times do |i| + @chain.add_filter(proc { nums << i }) + end + + @chain.refine(notice) + + expect(nums).to eq([0, 1, 2]) + end + + it "stops execution once a notice was ignored" do + nums = [] + + 5.times do |i| + @chain.add_filter(proc do |notice| + nums << i + notice.ignore! if i == 2 + end) + end + + @chain.refine(notice) + + expect(nums).to eq([0, 1, 2]) + end + end + + describe "default backtrace filters" do + let(:ex) { AirbrakeTestError.new.tap { |e| e.set_backtrace(backtrace) } } + let(:notice) { Airbrake::Notice.new(config, ex) } + + before do + Gem.path << '/my/gem/root' << '/my/other/gem/root' + @chain.refine(notice) + @bt = notice[:errors].first[:backtrace].map { |frame| frame[:file] } + end + + shared_examples 'root directories' do |root_directory, bt, expected_bt| + let(:backtrace) { bt } + + before do + config = Airbrake::Config.new(root_directory: root_directory) + chain = described_class.new(config) + chain.refine(notice) + @bt = notice[:errors].first[:backtrace].map { |frame| frame[:file] } + end + + it "filters it out" do + expect(@bt).to eq(expected_bt) + end + end + + # rubocop:disable Metrics/LineLength + context "gem root" do + bt = [ + "/home/kyrylo/code/airbrake/ruby/spec/spec_helper.rb:23:in `'", + "/my/gem/root/gems/rspec-core-3.3.2/lib/rspec/core/configuration.rb:1327:in `load'", + "/opt/rubies/ruby-2.2.2/lib/ruby/2.2.0/rubygems/core_ext/kernel_require.rb:54:in `require'", + "/my/other/gem/root/gems/rspec-core-3.3.2/exe/rspec:4:in `
'" + ] + + expected_bt = [ + "/home/kyrylo/code/airbrake/ruby/spec/spec_helper.rb", + "[GEM_ROOT]/gems/rspec-core-3.3.2/lib/rspec/core/configuration.rb", + "/opt/rubies/ruby-2.2.2/lib/ruby/2.2.0/rubygems/core_ext/kernel_require.rb", + "[GEM_ROOT]/gems/rspec-core-3.3.2/exe/rspec" + ] + + include_examples 'root directories', nil, bt, expected_bt + end + + context "root directory" do + context "when normal string path" do + bt = [ + "/home/kyrylo/code/airbrake/ruby/spec/spec_helper.rb:23:in `'", + "/var/www/project/gems/rspec-core-3.3.2/lib/rspec/core/configuration.rb:1327:in `load'", + "/opt/rubies/ruby-2.2.2/lib/ruby/2.2.0/rubygems/core_ext/kernel_require.rb:54:in `require'", + "/var/www/project/gems/rspec-core-3.3.2/exe/rspec:4:in `
'" + ] + + expected_bt = [ + "/home/kyrylo/code/airbrake/ruby/spec/spec_helper.rb", + "[PROJECT_ROOT]/gems/rspec-core-3.3.2/lib/rspec/core/configuration.rb", + "/opt/rubies/ruby-2.2.2/lib/ruby/2.2.0/rubygems/core_ext/kernel_require.rb", + "[PROJECT_ROOT]/gems/rspec-core-3.3.2/exe/rspec" + ] + + include_examples 'root directories', '/var/www/project', bt, expected_bt + end + + context "when equals to a part of filename" do + bt = [ + "/home/kyrylo/code/airbrake/ruby/spec/spec_helper.rb:23:in `'", + "/var/www/gems/gems/rspec-core-3.3.2/lib/rspec/core/configuration.rb:1327:in `load'", + "/opt/rubies/ruby-2.2.2/lib/ruby/2.2.0/rubygems/core_ext/kernel_require.rb:54:in `require'", + "/var/www/gems/gems/rspec-core-3.3.2/exe/rspec:4:in `
'" + ] + + expected_bt = [ + "/home/kyrylo/code/airbrake/ruby/spec/spec_helper.rb", + "[PROJECT_ROOT]/gems/rspec-core-3.3.2/lib/rspec/core/configuration.rb", + "/opt/rubies/ruby-2.2.2/lib/ruby/2.2.0/rubygems/core_ext/kernel_require.rb", + "[PROJECT_ROOT]/gems/rspec-core-3.3.2/exe/rspec" + ] + + include_examples 'root directories', '/var/www/gems', bt, expected_bt + end + + context "when normal pathname path" do + bt = [ + "/home/kyrylo/code/airbrake/ruby/spec/spec_helper.rb:23:in `'", + "/var/www/project/gems/rspec-core-3.3.2/lib/rspec/core/configuration.rb:1327:in `load'", + "/opt/rubies/ruby-2.2.2/lib/ruby/2.2.0/rubygems/core_ext/kernel_require.rb:54:in `require'", + "/var/www/project/gems/rspec-core-3.3.2/exe/rspec:4:in `
'" + ] + + expected_bt = [ + "/home/kyrylo/code/airbrake/ruby/spec/spec_helper.rb", + "[PROJECT_ROOT]/gems/rspec-core-3.3.2/lib/rspec/core/configuration.rb", + "/opt/rubies/ruby-2.2.2/lib/ruby/2.2.0/rubygems/core_ext/kernel_require.rb", + "[PROJECT_ROOT]/gems/rspec-core-3.3.2/exe/rspec" + ] + + include_examples 'root directories', + Pathname.new('/var/www/project'), bt, expected_bt + end + end + # rubocop:enable Metrics/LineLength + end + + describe "default ignore filters" do + context "system exit filter" do + it "marks SystemExit exceptions as ignored" do + notice = Airbrake::Notice.new(config, SystemExit.new) + expect { @chain.refine(notice) }. + to(change { notice.ignored? }.from(false).to(true)) + end + end + end + end +end diff --git a/spec/notice_spec.rb b/spec/notice_spec.rb new file mode 100644 index 00000000..fc4ef1f2 --- /dev/null +++ b/spec/notice_spec.rb @@ -0,0 +1,190 @@ +require 'spec_helper' + +RSpec.describe Airbrake::Notice do + let(:notice) do + described_class.new(Airbrake::Config.new, AirbrakeTestError.new, bingo: '1') + end + + describe "#new" do + context "nested exceptions" do + it "unwinds nested exceptions" do + begin + begin + raise AirbrakeTestError + rescue AirbrakeTestError + Ruby21Error.raise_error('bingo') + end + rescue Ruby21Error => ex + notice = described_class.new(Airbrake::Config.new, ex) + + expect(notice[:errors].size).to eq(2) + expect(notice[:errors][0][:message]).to eq('bingo') + expect(notice[:errors][1][:message]).to eq('App crashed!') + end + end + + it "unwinds no more than 3 nested exceptions" do + begin + begin + raise AirbrakeTestError + rescue AirbrakeTestError + begin + Ruby21Error.raise_error('bongo') + rescue Ruby21Error + begin + Ruby21Error.raise_error('bango') + rescue Ruby21Error + Ruby21Error.raise_error('bingo') + end + end + end + rescue Ruby21Error => ex + notice = described_class.new(Airbrake::Config.new, ex) + + expect(notice[:errors].size).to eq(3) + expect(notice[:errors][0][:message]).to eq('bingo') + expect(notice[:errors][1][:message]).to eq('bango') + expect(notice[:errors][2][:message]).to eq('bongo') + end + end + end + end + + describe "#to_json" do + context "app_version" do + context "when missing" do + it "doesn't include app_version" do + expect(notice.to_json).not_to match(/"context":{"version":"1.2.3"/) + end + end + + context "when present" do + let(:config) do + Airbrake::Config.new(app_version: '1.2.3', root_directory: '/one/two') + end + + let(:notice) { described_class.new(config, AirbrakeTestError.new) } + + it "includes app_version" do + expect(notice.to_json).to match(/"context":{"version":"1.2.3"/) + end + + it "includes root_directory" do + expect(notice.to_json).to match(%r{"rootDirectory":"/one/two"}) + end + end + end + + context "truncation" do + shared_examples 'payloads' do |size, msg| + it msg do + ex = AirbrakeTestError.new + + backtrace = [] + size.times { backtrace << "bin/rails:3:in `
'" } + ex.set_backtrace(backtrace) + + notice = described_class.new(Airbrake::Config.new, ex) + + expect(notice.to_json.bytesize).to be < 64000 + end + end + + max_msg = 'truncates to the max allowed size' + + context "with an extremely huge payload" do + include_examples 'payloads', 200_000, max_msg + end + + context "with a big payload" do + include_examples 'payloads', 50_000, max_msg + end + + small_msg = "doesn't truncate it" + + context "with a small payload" do + include_examples 'payloads', 1000, small_msg + end + + context "with a tiny payload" do + include_examples 'payloads', 300, small_msg + end + + describe "object replacement with its string version" do + let(:klass) { Class.new {} } + let(:ex) { AirbrakeTestError.new } + let(:params) { { bingo: [Object.new, klass.new] } } + let(:notice) { described_class.new(Airbrake::Config.new, ex, params) } + + before do + backtrace = [] + backtrace_size.times { backtrace << "bin/rails:3:in `
'" } + ex.set_backtrace(backtrace) + end + + context "with payload within the limits" do + let(:backtrace_size) { 1000 } + + it "doesn't happen" do + expect(notice.to_json). + to match(/bingo":\["#","#<#:.+>"/) + end + end + + context "with payload bigger than the limit" do + context "with payload within the limits" do + let(:backtrace_size) { 50_000 } + + it "happens" do + expect(notice.to_json). + to match(/bingo":\[".+Object.+",".+Class.+"/) + end + end + end + end + end + + it "overwrites the 'notifier' payload with the default values" do + notice[:notifier] = { name: 'bingo', bango: 'bongo' } + + expect(notice.to_json). + to match(/"notifier":{"name":"airbrake-ruby","version":".+","url":".+"}/) + end + end + + describe "#[]" do + it "accesses payload" do + expect(notice[:params]).to eq(bingo: '1') + end + + it "raises error if notice is ignored" do + notice.ignore! + expect { notice[:params] }. + to raise_error(Airbrake::Error, 'cannot access ignored notice') + end + end + + describe "#[]=" do + it "sets a payload value" do + hash = { bingo: 'bango' } + notice[:params] = hash + expect(notice[:params]).to equal(hash) + end + + it "raises error if notice is ignored" do + notice.ignore! + expect { notice[:params] = {} }. + to raise_error(Airbrake::Error, 'cannot access ignored notice') + end + + it "raises error when trying to assign unrecognized key" do + expect { notice[:bingo] = 1 }. + to raise_error(Airbrake::Error, /:bingo is not recognized among/) + end + + it "raises when setting non-hash objects as the value" do + expect { notice[:params] = Object.new }. + to raise_error(Airbrake::Error, 'Got Object value, wanted a Hash') + end + end +end diff --git a/spec/notifier_spec.rb b/spec/notifier_spec.rb new file mode 100644 index 00000000..583086cf --- /dev/null +++ b/spec/notifier_spec.rb @@ -0,0 +1,690 @@ +# coding: utf-8 +require 'spec_helper' + +RSpec.describe Airbrake::Notifier do + def expect_a_request_with_body(body) + expect(a_request(:post, endpoint).with(body: body)).to have_been_made.once + end + + let(:project_id) { 105138 } + let(:project_key) { 'fd04e13d806a90f96614ad8e529b2822' } + let(:localhost) { 'http://localhost:8080' } + + let(:endpoint) do + "https://airbrake.io/api/v3/projects/#{project_id}/notices?key=#{project_key}" + end + + let(:airbrake_params) do + { project_id: project_id, + project_key: project_key, + logger: Logger.new(StringIO.new) } + end + + let(:ex) { AirbrakeTestError.new } + + before do + stub_request(:post, endpoint).to_return(status: 201, body: '{}') + @airbrake = described_class.new(airbrake_params) + end + + describe "#new" do + context "raises error if" do + example ":project_id is not provided" do + expect { described_class.new(project_id: project_id) }. + to raise_error(Airbrake::Error, + 'both :project_id and :project_key are required') + end + + example ":project_key is not provided" do + expect { described_class.new(project_key: project_key) }. + to raise_error(Airbrake::Error, + 'both :project_id and :project_key are required') + end + + example "neither :project_id nor :project_key are provided" do + expect { described_class.new({}) }. + to raise_error(Airbrake::Error, + 'both :project_id and :project_key are required') + end + end + + context "when the argument is Airbrake::Config" do + it "uses it instead of the hash" do + airbrake = described_class.new( + Airbrake::Config.new(project_id: 123, project_key: '321') + ) + config = airbrake.instance_variable_get(:@config) + expect(config.project_id).to eq(123) + expect(config.project_key).to eq('321') + end + end + end + + describe "#notify_sync" do + describe "first argument" do + context "when it is a Notice" do + it "sends the argument" do + notice = @airbrake.build_notice(ex) + @airbrake.notify_sync(notice) + + # rubocop:disable Metrics/LineLength + expected_body = %r| + {"errors":\[{"type":"AirbrakeTestError","message":"App\scrashed!","backtrace":\[ + {"file":"[\w/-]+/spec/spec_helper.rb","line":\d+,"function":""}, + {"file":"[\w/\-\.]+/rubygems/core_ext/kernel_require\.rb","line":\d+,"function":"require"}, + {"file":"[\w/\-\.]+/rubygems/core_ext/kernel_require\.rb","line":\d+,"function":"require"} + |x + # rubocop:enable Metrics/LineLength + + expect( + a_request(:post, endpoint). + with(body: expected_body) + ).to have_been_made.once + end + end + end + + describe "request" do + before do + @airbrake.notify_sync(ex, bingo: ['bango'], bongo: 'bish') + end + + it "is being made over HTTPS" do + expect( + a_request(:post, endpoint). + with { |req| req.uri.port == 443 } + ).to have_been_made.once + end + + describe "headers" do + def expect_a_request_with_headers(headers) + expect( + a_request(:post, endpoint). + with(headers: headers) + ).to have_been_made.once + end + + it "POSTs JSON to Airbrake" do + expect_a_request_with_headers('Content-Type' => 'application/json') + end + + it "sets User-Agent" do + ua = "airbrake-ruby/#{Airbrake::AIRBRAKE_RUBY_VERSION} Ruby/#{RUBY_VERSION}" + expect_a_request_with_headers('User-Agent' => ua) + end + end + + describe "body" do + it "features 'notifier'" do + expect_a_request_with_body(/"notifier":{"name":"airbrake-ruby"/) + end + + it "features 'context'" do + expect_a_request_with_body(/"context":{.*"os":"[\w-]+"/) + end + + it "features 'errors'" do + expect_a_request_with_body( + /"errors":\[{"type":"AirbrakeTestError","message":"App crash/ + ) + end + + it "features 'backtrace'" do + expect_a_request_with_body( + %r|"backtrace":\[{"file":"/home/.+/spec/spec_helper.rb"| + ) + end + + it "features 'params'" do + expect_a_request_with_body( + /"params":{"bingo":\["bango"\],"bongo":"bish"}/ + ) + end + end + end + + describe "response body when it is" do + before do + @stdout = StringIO.new + params = { + logger: Logger.new(@stdout).tap { |l| l.level = Logger::DEBUG } + } + @airbrake = described_class.new(airbrake_params.merge(params)) + end + + shared_examples "HTTP codes" do |code, body, expected_output| + it "logs error #{code}" do + stub_request(:post, endpoint).to_return(status: code, body: body) + + expect(@stdout.string).to be_empty + + response = @airbrake.notify_sync(ex) + + expect(@stdout.string).to match(expected_output) + expect(response).to be_a Hash + + if response['error'] + expect(response['error']).to satisfy do |error| + error.is_a?(Exception) || error.is_a?(String) + end + end + end + end + + context "a hash with response and invalid status" do + include_examples 'HTTP codes', 200, + '{"id":"1","url":"https://airbrake.io/locate/1"}', + %r{unexpected code \(200\). Body: .+url":"https://airbrake.+} + end + + context "an empty page" do + include_examples 'HTTP codes', 200, '', + /ERROR -- : .+ unexpected code \(200\). Body: \[EMPTY_BODY\]/ + end + + context "a valid body with code 201" do + include_examples 'HTTP codes', 201, + '{"id":"1","url":"https://airbrake.io/locate/1"}', + %r|DEBUG -- : .+url"=>"https://airbrake.io/locate/1"}| + end + + context "a non-parseable page" do + include_examples 'HTTP codes', 400, 'bingo bango bongo', + /ERROR -- : .+unexpected token at 'bingo.+'\)\. Body: bingo.+/ + end + + context "error 400" do + include_examples 'HTTP codes', 400, '{"error": "Invalid Content-Type header."}', + /ERROR -- : .+ Invalid Content-Type header\./ + end + + context "error 401" do + include_examples 'HTTP codes', 401, + '{"error":"Project not found or access denied."}', + /ERROR -- : .+ Project not found or access denied./ + end + + context "the rate-limit message" do + include_examples 'HTTP codes', 429, '{"error": "Project is rate limited."}', + /ERROR -- : .+ Project is rate limited.+/ + end + + context "the internal server error" do + include_examples 'HTTP codes', 500, 'Internal Server Error', + /ERROR -- : .+ unexpected code \(500\). Body: Internal.+ Error/ + end + + context "too long it truncates it and" do + include_examples 'HTTP codes', 123, '123 ' * 1000, + /ERROR -- : .+ unexpected code \(123\). Body: .+ 123 123 1\.\.\./ + end + end + + describe "connection timeout" do + it "logs the error when it occurs" do + stub_request(:post, endpoint).to_timeout + + stderr = StringIO.new + params = airbrake_params.merge(logger: Logger.new(stderr)) + airbrake = described_class.new(params) + + airbrake.notify_sync(ex) + + expect(stderr.string). + to match(/ERROR -- : .+ HTTP error: execution expired/) + end + end + + describe "unicode payload" do + context "with valid strings" do + it "works correctly" do + @airbrake.notify_sync(ex, unicode: "ü ö ä Ä Ü Ö ß привет €25.00 한글") + + expect( + a_request(:post, endpoint). + with(body: /"unicode":"ü ö ä Ä Ü Ö ß привет €25.00 한글"/) + ).to have_been_made.once + end + end + + context "with invalid strings" do + it "doesn't raise error when string has invalid encoding" do + expect do + @airbrake.notify_sync('bingo', bongo: "bango\xAE") + end.not_to raise_error + end + + it "doesn't raise error when string has valid encoding, but invalid characters" do + # Shenanigans to get a bad ASCII-8BIT string. Direct conversion raises error. + encoded = Base64.encode64("\xD3\xE6\xBC\x9D\xBA").encode!('ASCII-8BIT') + bad_string = Base64.decode64(encoded) + + expect do + @airbrake.notify_sync('bingo', bongo: bad_string) + end.not_to raise_error + end + end + end + + describe "a closed IO object" do + context "outside of the Rails environment" do + it "is not getting truncated" do + @airbrake.notify_sync(ex, bingo: IO.new(0).tap(&:close)) + + expect( + a_request(:post, endpoint).with(body: /"bingo":"#"/) + ).to have_been_made.once + end + end + + context "inside the Rails environment" do + ## + # Instances of this class contain a closed IO object assigned to an instance + # variable. Normally, the JSON gem, which we depend on can parse closed IO + # objects. However, because ActiveSupport monkey-patches #to_json and calls + # #to_a on them, they raise IOError when we try to serialize them. + # + # @see https://goo.gl/0A3xNC + class ObjectWithIoIvars + def initialize + @bongo = Tempfile.new('bongo').tap(&:close) + end + + # @raise [NotImplementedError] when inside a Rails environment + def to_json(*) + raise NotImplementedError + end + end + + ## + # @see ObjectWithIoIvars + class ObjectWithNestedIoIvars + def initialize + @bish = ObjectWithIoIvars.new + end + + # @see ObjectWithIoIvars#to_json + def to_json(*) + raise NotImplementedError + end + end + + shared_examples 'truncation' do |params, expected| + it "filters it out" do + @airbrake.notify_sync(ex, params) + + expect( + a_request(:post, endpoint).with(body: expected) + ).to have_been_made.once + end + end + + context "which is an instance of" do + context "Tempfile" do + params = { bango: Tempfile.new('bongo').tap(&:close) } + include_examples 'truncation', params, /"bango":"#<(Temp)?file:0x.+>"/i + end + + context "a non-IO class but with" do + context "IO ivars" do + params = { bongo: ObjectWithIoIvars.new } + include_examples 'truncation', params, /"bongo":".+ObjectWithIoIvars.+"/ + end + + context "a non-IO ivar, which contains an IO ivar itself" do + params = { bish: ObjectWithNestedIoIvars.new } + include_examples 'truncation', params, /"bish":".+ObjectWithNested.+"/ + end + end + end + + context "which is deeply nested inside a hash" do + params = { bingo: { bango: { bongo: ObjectWithIoIvars.new } } } + include_examples( + 'truncation', + params, + /"params":{"bingo":{"bango":{"bongo":".+ObjectWithIoIvars.+"}}}/ + ) + end + + context "which is deeply nested inside an array" do + params = { bingo: [[ObjectWithIoIvars.new]] } + include_examples( + 'truncation', + params, + /"params":{"bingo":\[\[".+ObjectWithIoIvars.+"\]\]}/ + ) + end + end + end + end + + describe "#notify" do + it "sends an exception asynchronously" do + @airbrake.notify(ex, bingo: 'bango') + + sleep 1 + + expect_a_request_with_body(/params":{"bingo":"bango"}/) + end + + it "returns nil" do + expect(@airbrake.notify(ex)).to be_nil + sleep 1 + end + + it "falls back to synchronous delivery when the async sender is dead" do + out = StringIO.new + + airbrake = described_class.new(airbrake_params.merge(logger: Logger.new(out))) + airbrake. + instance_variable_get(:@async_sender). + instance_variable_get(:@workers). + list. + each(&:kill) + + sleep 1 + + expect(airbrake.notify('bingo')).to be_nil + expect(out.string).to match(/falling back to sync delivery/) + end + end + + describe "#add_filter" do + it "filters notices" do + @airbrake.add_filter do |notice| + if notice[:params][:password] + notice[:params][:password] = '[Filtered]'.freeze + end + end + + @airbrake.notify_sync(ex, password: 's4kr4t') + + expect( + a_request(:post, endpoint). + with(body: /params":{"password":"\[Filtered\]"}/) + ).to have_been_made.once + end + + it "accepts multiple filters" do + [:bingo, :bongo, :bash].each do |key| + @airbrake.add_filter do |notice| + notice[:params][key] = '[Filtered]'.freeze if notice[:params][key] + end + end + + @airbrake.notify_sync(ex, bingo: ['bango'], bongo: 'bish', bash: 'bosh') + + # rubocop:disable Metrics/LineLength + body = /"params":{"bingo":"\[Filtered\]","bongo":"\[Filtered\]","bash":"\[Filtered\]"}/ + # rubocop:enable Metrics/LineLength + + expect( + a_request(:post, endpoint). + with(body: body) + ).to have_been_made.once + end + + it "ignores all notices" do + @airbrake.add_filter(&:ignore!) + + @airbrake.notify_sync(ex) + + expect(a_request(:post, endpoint)).not_to have_been_made + end + + it "ignores specific notices" do + @airbrake.add_filter do |notice| + notice.ignore! if notice[:errors][0][:type] == 'RuntimeError' + end + + @airbrake.notify_sync(RuntimeError.new('Not caring!')) + expect(a_request(:post, endpoint)).not_to have_been_made + + @airbrake.notify_sync(ex) + expect(a_request(:post, endpoint)).to have_been_made.once + end + end + + describe "#blacklist_keys" do + describe "the list of arguments" do + it "accepts regexps" do + @airbrake.blacklist_keys(/\Abin/) + + @airbrake.notify_sync(ex, bingo: 'bango') + + expect( + a_request(:post, endpoint). + with(body: /"params":{"bingo":"\[Filtered\]"}/) + ).to have_been_made.once + end + + it "accepts symbols" do + @airbrake.blacklist_keys(:bingo) + + @airbrake.notify_sync(ex, bingo: 'bango') + + expect( + a_request(:post, endpoint). + with(body: /"params":{"bingo":"\[Filtered\]"}/) + ).to have_been_made.once + end + + it "accepts strings" do + @airbrake.blacklist_keys('bingo') + + @airbrake.notify_sync(ex, bingo: 'bango') + + expect( + a_request(:post, endpoint). + with(body: /"params":{"bingo":"\[Filtered\]"}/) + ).to have_been_made.once + end + end + + describe "hash values" do + context "non-recursive" do + it "filters nested hashes" do + @airbrake.blacklist_keys('bish') + + @airbrake.notify_sync(ex, bongo: { bish: 'bash' }) + + expect( + a_request(:post, endpoint). + with(body: /"params":{"bongo":{"bish":"\[Filtered\]"}}/) + ).to have_been_made.once + end + end + + context "recursive" do + it "filters recursive hashes" do + @airbrake.blacklist_keys('bango') + + bongo = { bingo: {} } + bongo[:bingo][:bango] = bongo + + @airbrake.notify_sync(ex, bongo) + + expect( + a_request(:post, endpoint). + with(body: /"params":{"bingo":{"bango":"\[Filtered\]"}}/) + ).to have_been_made.once + end + end + end + + it "filters query parameters correctly" do + @airbrake.blacklist_keys(%w(bish)) + + notice = @airbrake.build_notice(ex) + notice[:context][:url] = 'http://localhost:3000/crash?foo=bar&baz=bongo&bish=bash' + + @airbrake.notify_sync(notice) + + # rubocop:disable Metrics/LineLength + expected_body = + %r("context":{.*"url":"http://localhost:3000/crash\?foo=bar&baz=bongo&bish=\[Filtered\]".*}) + # rubocop:enable Metrics/LineLength + + expect( + a_request(:post, endpoint). + with(body: expected_body) + ).to have_been_made.once + end + end + + describe "#whitelist_keys" do + describe "the list of arguments" do + it "accepts regexes" do + @airbrake.whitelist_keys(/\Abin/) + + @airbrake.notify_sync(ex, bingo: 'bango', bongo: 'bish', bash: 'bosh') + + body = /"params":{"bingo":"bango","bongo":"\[Filtered\]","bash":"\[Filtered\]"}/ + + expect( + a_request(:post, endpoint). + with(body: body) + ).to have_been_made.once + end + + it "accepts symbols" do + @airbrake.whitelist_keys(:bongo) + + @airbrake.notify_sync(ex, bingo: 'bango', bongo: 'bish', bash: 'bosh') + + body = /"params":{"bingo":"\[Filtered\]","bongo":"bish","bash":"\[Filtered\]"}/ + + expect( + a_request(:post, endpoint). + with(body: body) + ).to have_been_made.once + end + + it "accepts strings" do + @airbrake.whitelist_keys('bash') + + @airbrake.notify_sync(ex, bingo: 'bango', bongo: 'bish', bash: 'bosh') + + body = /"params":{"bingo":"\[Filtered\]","bongo":"\[Filtered\]","bash":"bosh"}/ + + expect( + a_request(:post, endpoint). + with(body: body) + ).to have_been_made.once + end + end + + describe "hash values" do + context "non-recursive" do + it "filters out everything but the provided keys" do + @airbrake.whitelist_keys(%w(bongo bish)) + + @airbrake.notify_sync(ex, bingo: 'bango', bongo: { bish: 'bash' }) + + expect( + a_request(:post, endpoint). + with(body: /"params":{"bingo":"\[Filtered\]","bongo":{"bish":"bash"}}/) + ).to have_been_made.once + end + end + + context "recursive" do + it "errors when nested hashes are not filtered" do + @airbrake.whitelist_keys(%w(bingo bango)) + + bongo = { bingo: {} } + bongo[:bingo][:bango] = bongo + + if RUBY_ENGINE == 'jruby' + # JRuby might raise two different exceptions, which represent the + # same thing. One is a Java exception, the other is a Ruby + # exception. It's probably a JRuby bug: + # https://github.com/jruby/jruby/issues/1903 + begin + expect do + @airbrake.notify_sync(ex, bongo) + end.to raise_error(SystemStackError) + rescue RSpec::Expectations::ExpectationNotMetError + expect do + @airbrake.notify_sync(ex, bongo) + end.to raise_error(java.lang.StackOverflowError) + end + else + expect do + @airbrake.notify_sync(ex, bongo) + end.to raise_error(SystemStackError) + end + end + end + end + + describe "context/url" do + it "filters query parameters correctly" do + @airbrake.whitelist_keys(%w(bish)) + + notice = @airbrake.build_notice(ex) + notice[:context][:url] = 'http://localhost:3000/crash?foo=bar&baz=bongo&bish=bash' + + @airbrake.notify_sync(notice) + + # rubocop:disable Metrics/LineLength + expected_body = + %r("context":{.*"url":"http://localhost:3000/crash\?foo=\[Filtered\]&baz=\[Filtered\]&bish=bash".*}) + # rubocop:enable Metrics/LineLength + + expect( + a_request(:post, endpoint). + with(body: expected_body) + ).to have_been_made.once + end + end + end + + describe "#build_notice" do + it "builds a notice from exception" do + expect(@airbrake.build_notice(ex)).to be_an Airbrake::Notice + end + end + + describe "#close" do + shared_examples 'close' do |method| + it "raises error" do + @airbrake.close + expect { method.call(@airbrake) }. + to raise_error(Airbrake::Error, /closed Airbrake instance/) + end + end + + context "when using #notify" do + include_examples 'close', proc { |a| a.notify(AirbrakeTestError.new) } + end + + context "when using #send_notice" do + include_examples 'close', proc { |a| + notice = a.build_notice(AirbrakeTestError.new) + a.send_notice(notice) + } + end + + context "at program exit when it was closed manually" do + it "doesn't raise error", skip: RUBY_ENGINE == 'jruby' do + expect do + Process.wait(fork { described_class.new(airbrake_params) }) + end.not_to raise_error + end + end + end + + describe "#create_deploy" do + let(:deploy_endpoint) do + "https://airbrake.io/api/v4/projects/#{project_id}/deploys?key=#{project_key}" + end + + it "sends a request to the deploy API" do + stub_request(:post, deploy_endpoint).to_return(status: 201, body: '{"id":"123"}') + @airbrake.create_deploy({}) + expect(a_request(:post, deploy_endpoint)).to have_been_made.once + end + end +end diff --git a/spec/notifier_spec/options_spec.rb b/spec/notifier_spec/options_spec.rb new file mode 100644 index 00000000..fdb242a4 --- /dev/null +++ b/spec/notifier_spec/options_spec.rb @@ -0,0 +1,217 @@ +require 'spec_helper' + +RSpec.describe Airbrake::Notifier do + let(:project_id) { 105138 } + let(:project_key) { 'fd04e13d806a90f96614ad8e529b2822' } + let(:localhost) { 'http://localhost:8080' } + + let(:endpoint) do + "https://airbrake.io/api/v3/projects/#{project_id}/notices?key=#{project_key}" + end + + let(:airbrake_params) do + { project_id: project_id, + project_key: project_key, + logger: Logger.new(StringIO.new) } + end + + let(:ex) { AirbrakeTestError.new } + + before do + stub_request(:post, endpoint).to_return(status: 201, body: '{}') + @airbrake = described_class.new(airbrake_params) + end + + describe "options" do + describe ":host" do + context "when custom" do + shared_examples 'endpoint' do |host, endpoint, title| + example(title) do + stub_request(:post, endpoint).to_return(status: 201, body: '{}') + @airbrake = described_class.new(airbrake_params.merge(host: host)) + @airbrake.notify_sync(ex) + + expect(a_request(:post, endpoint)).to have_been_made.once + end + end + + path = '/api/v3/projects/105138/notices?key=fd04e13d806a90f96614ad8e529b2822' + + context "given a full host" do + include_examples('endpoint', localhost = 'http://localhost:8080', + URI.join(localhost, path), + "sends notices to the specified host's endpoint") + end + + context "given a full host" do + include_examples('endpoint', localhost = 'http://localhost', + URI.join(localhost, path), + "assumes port 80 by default") + end + + context "given a host without scheme" do + include_examples 'endpoint', localhost = 'localhost:8080', + URI.join("https://#{localhost}", path), + "assumes https by default" + end + + context "given only hostname" do + include_examples 'endpoint', localhost = 'localhost', + URI.join("https://#{localhost}", path), + "assumes https and port 80 by default" + end + end + end + + describe ":root_directory" do + it "filters out frames" do + params = airbrake_params.merge(root_directory: '/home/kyrylo/code') + airbrake = described_class.new(params) + airbrake.notify_sync(ex) + + expect( + a_request(:post, endpoint). + with(body: %r|{"file":"\[PROJECT_ROOT\]/airbrake/ruby/spec/airbrake_spec.+|) + ).to have_been_made.once + end + + context "when present and is a" do + shared_examples 'root directory' do |dir| + it "being included into the notice's payload" do + params = airbrake_params.merge(root_directory: dir) + airbrake = described_class.new(params) + airbrake.notify_sync(ex) + + expect( + a_request(:post, endpoint). + with(body: %r{"rootDirectory":"/bingo/bango"}) + ).to have_been_made.once + end + end + + context "String" do + include_examples 'root directory', '/bingo/bango' + end + + context "Pathname" do + include_examples 'root directory', Pathname.new('/bingo/bango') + end + end + end + + describe ":proxy" do + let(:proxy) do + WEBrick::HTTPServer.new( + Port: 0, + Logger: WEBrick::Log.new('/dev/null'), + AccessLog: [] + ) + end + + let(:requests) { Queue.new } + + let(:proxy_params) do + { host: 'localhost', + port: proxy.config[:Port], + user: 'user', + password: 'password' } + end + + before do + proxy.mount_proc '/' do |req, res| + requests << req + res.status = 201 + res.body = "OK\n" + end + + Thread.new { proxy.start } + + params = airbrake_params.merge( + proxy: proxy_params, + host: "http://localhost:#{proxy.config[:Port]}" + ) + + @airbrake = described_class.new(params) + end + + after { proxy.stop } + + it "is being used if configured" do + @airbrake.notify_sync(ex) + + proxied_request = requests.pop + + expect(proxied_request.header['proxy-authorization'].first). + to eq('Basic dXNlcjpwYXNzd29yZA==') + + # rubocop:disable Metrics/LineLength + expect(proxied_request.request_line). + to eq("POST http://localhost:#{proxy.config[:Port]}/api/v3/projects/105138/notices?key=fd04e13d806a90f96614ad8e529b2822 HTTP/1.1\r\n") + # rubocop:enable Metrics/LineLength + end + end + + describe ":environment" do + context "when present" do + it "being included into the notice's payload" do + params = airbrake_params.merge(environment: :production) + airbrake = described_class.new(params) + airbrake.notify_sync(ex) + + expect( + a_request(:post, endpoint). + with(body: /"context":{.*"environment":"production".*}/) + ).to have_been_made.once + end + end + end + + describe ":ignore_environments" do + shared_examples 'sent notice' do |params| + it "sends a notice" do + airbrake = described_class.new(airbrake_params.merge(params)) + airbrake.notify_sync(ex) + + expect(a_request(:post, endpoint)).to have_been_made + end + end + + shared_examples 'ignored notice' do |params| + it "ignores exceptions occurring in envs that were not configured" do + airbrake = described_class.new(airbrake_params.merge(params)) + airbrake.notify_sync(ex) + + expect(a_request(:post, endpoint)).not_to have_been_made + end + end + + context "when env is set and ignore_environments doesn't mention it" do + params = { + environment: :development, + ignore_environments: [:production] + } + + include_examples 'sent notice', params + end + + context "when the current env and notify envs are the same" do + params = { + environment: :development, + ignore_environments: [:production, :development] + } + + include_examples 'ignored notice', params + end + + context "when the current env is not set and notify envs are present" do + params = { ignore_environments: [:production, :development] } + + include_examples 'sent notice', params + end + + context "when the current env is set and notify envs aren't" do + include_examples 'sent notice', environment: :development + end + end + end +end diff --git a/spec/payload_truncator_spec.rb b/spec/payload_truncator_spec.rb new file mode 100644 index 00000000..35e98ef7 --- /dev/null +++ b/spec/payload_truncator_spec.rb @@ -0,0 +1,458 @@ +# coding: utf-8 +require 'spec_helper' + +RSpec.describe Airbrake::PayloadTruncator do + let(:max_size) { 1000 } + let(:truncated_len) { '[Truncated]'.length } + let(:max_len) { max_size + truncated_len } + + before do + @truncator = described_class.new(max_size, Logger.new('/dev/null')) + end + + describe ".truncate_error" do + let(:error) do + { type: 'AirbrakeTestError', message: 'App crashed!', backtrace: [] } + end + + before do + @stdout = StringIO.new + end + + describe "error backtrace" do + before do + backtrace = Array.new(size) do + { file: 'foo.rb', line: 23, function: '
' } + end + + @error = error.merge(backtrace: backtrace) + described_class.new(max_size, Logger.new(@stdout)).truncate_error(@error) + end + + context "when long" do + let(:size) { 2003 } + + it "truncates the backtrace to the max size" do + expect(@error[:backtrace].size).to eq(1000) + end + + it "logs the about the number of truncated frames" do + expect(@stdout.string). + to match(/INFO -- .+ dropped 1003 frame\(s\) from AirbrakeTestError/) + end + end + + context "when short" do + let(:size) { 999 } + + it "does not truncate the backtrace" do + expect(@error[:backtrace].size).to eq(size) + end + + it "doesn't log anything" do + expect(@stdout.string).to be_empty + end + end + end + + describe "error message" do + before do + @error = error.merge(message: message) + described_class.new(max_size, Logger.new(@stdout)).truncate_error(@error) + end + + context "when long" do + let(:message) { 'App crashed!' * 2000 } + + it "truncates the message" do + expect(@error[:message].length).to eq(max_len) + end + + it "logs about the truncated string" do + expect(@stdout.string). + to match(/INFO -- .+ truncated the message of AirbrakeTestError/) + end + end + + context "when short" do + let(:message) { 'App crashed!' } + let(:msg_len) { message.length } + + it "doesn't truncate the message" do + expect(@error[:message].length).to eq(msg_len) + end + + it "doesn't log about the truncated string" do + expect(@stdout.string).to be_empty + end + end + end + end + + describe ".truncate_object" do + describe "given a hash with short values" do + let(:params) do + { bingo: 'bango', bongo: 'bish', bash: 'bosh' } + end + + it "doesn't get truncated" do + @truncator.truncate_object(params) + expect(params).to eq(bingo: 'bango', bongo: 'bish', bash: 'bosh') + end + end + + describe "given a hash with a lot of elements" do + context "the elements of which are also hashes with a lot of elements" do + let(:params) do + Hash[(0...4124).each_cons(2).to_a].tap do |h| + h[0] = Hash[(0...4124).each_cons(2).to_a] + end + end + + it "truncates all the hashes to the max allowed size" do + expect(params.size).to eq(4123) + expect(params[0].size).to eq(4123) + + @truncator.truncate_object(params) + + expect(params.size).to eq(1000) + expect(params[0].size).to eq(1000) + end + end + end + + describe "given a set with a lot of elements" do + context "the elements of which are also sets with a lot of elements" do + let(:params) do + row = (0...4124).each_cons(2) + set = Set.new(row.to_a.unshift(row.to_a)) + { bingo: set } + end + + it "truncates all the sets to the max allowed size" do + expect(params[:bingo].size).to eq(4124) + expect(params[:bingo].to_a[0].size).to eq(4123) + + @truncator.truncate_object(params) + + expect(params[:bingo].size).to eq(1000) + expect(params[:bingo].to_a[0].size).to eq(1000) + end + end + + context "including recursive sets" do + let(:params) do + a = Set.new + a << a << :bango + { bingo: a } + end + + it "prevents recursion" do + @truncator.truncate_object(params) + + expect(params).to eq(bingo: Set.new(['[Circular]', :bango])) + end + end + end + + describe "given an array with a lot of elements" do + context "the elements of which are also arrays with a lot of elements" do + let(:params) do + row = (0...4124).each_cons(2) + { bingo: row.to_a.unshift(row.to_a) } + end + + it "truncates all the arrays to the max allowed size" do + expect(params[:bingo].size).to eq(4124) + expect(params[:bingo][0].size).to eq(4123) + + @truncator.truncate_object(params) + + expect(params[:bingo].size).to eq(1000) + expect(params[:bingo][0].size).to eq(1000) + end + end + end + + describe "given a hash with long values" do + context "which are strings" do + let(:params) do + { bingo: 'bango' * 2000, bongo: 'bish', bash: 'bosh' * 1000 } + end + + it "truncates only long strings" do + expect(params[:bingo].length).to eq(10_000) + expect(params[:bongo].length).to eq(4) + expect(params[:bash].length).to eq(4000) + + @truncator.truncate_object(params) + + expect(params[:bingo].length).to eq(max_len) + expect(params[:bongo].length).to eq(4) + expect(params[:bash].length).to eq(max_len) + end + end + + context "which are arrays" do + context "of long strings" do + let(:params) do + { bingo: ['foo', 'bango' * 2000, 'bar', 'piyo' * 2000, 'baz'], + bongo: 'bish', + bash: 'bosh' * 1000 } + end + + it "truncates long strings in the array, but not short ones" do + expect(params[:bingo].map(&:length)).to eq([3, 10_000, 3, 8_000, 3]) + expect(params[:bongo].length).to eq(4) + expect(params[:bash].length).to eq(4000) + + @truncator.truncate_object(params) + + expect(params[:bingo].map(&:length)).to eq([3, max_len, 3, max_len, 3]) + expect(params[:bongo].length).to eq(4) + expect(params[:bash].length).to eq(max_len) + end + end + + context "of short strings" do + let(:params) do + { bingo: %w(foo bar baz), bango: 'bongo', bish: 'bash' } + end + + it "truncates long strings in the array, but not short ones" do + @truncator.truncate_object(params) + expect(params). + to eq(bingo: %w(foo bar baz), bango: 'bongo', bish: 'bash') + end + end + + context "of hashes" do + context "with long strings" do + let(:params) do + { bingo: [{}, { bango: 'bongo', hoge: { fuga: 'piyo' * 2000 } }], + bish: 'bash', + bosh: 'foo' } + end + + it "truncates the long string" do + expect(params[:bingo][1][:hoge][:fuga].length).to eq(8000) + + @truncator.truncate_object(params) + + expect(params[:bingo][0]).to eq({}) + expect(params[:bingo][1][:bango]).to eq('bongo') + expect(params[:bingo][1][:hoge][:fuga].length).to eq(max_len) + expect(params[:bish]).to eq('bash') + expect(params[:bosh]).to eq('foo') + end + end + + context "with short strings" do + let(:params) do + { bingo: [{}, { bango: 'bongo', hoge: { fuga: 'piyo' } }], + bish: 'bash', + bosh: 'foo' } + end + + it "doesn't truncate the short string" do + expect(params[:bingo][1][:hoge][:fuga].length).to eq(4) + + @truncator.truncate_object(params) + + expect(params[:bingo][0]).to eq({}) + expect(params[:bingo][1][:bango]).to eq('bongo') + expect(params[:bingo][1][:hoge][:fuga].length).to eq(4) + expect(params[:bish]).to eq('bash') + expect(params[:bosh]).to eq('foo') + end + end + + context "with strings that equal to max_size" do + before do + @truncator = described_class.new(max_size, Logger.new('/dev/null')) + end + + let(:params) { { unicode: '1111' } } + let(:max_size) { params[:unicode].size } + + it "is doesn't truncate the string" do + @truncator.truncate_object(params) + + expect(params[:unicode].length).to eq(max_size) + expect(params[:unicode]).to match(/\A1{#{max_size}}\z/) + end + end + end + + context "of recursive hashes" do + let(:params) do + a = { bingo: {} } + a[:bingo][:bango] = a + end + + it "prevents recursion" do + @truncator.truncate_object(params) + + expect(params).to eq(bingo: { bango: '[Circular]' }) + end + end + + context "of arrays" do + context "with long strings" do + let(:params) do + { bingo: ['bango', ['bongo', ['bish' * 2000]]], + bish: 'bash', + bosh: 'foo' } + end + + it "truncates only the long string" do + expect(params[:bingo][1][1][0].length).to eq(8000) + + @truncator.truncate_object(params) + + expect(params[:bingo][1][1][0].length).to eq(max_len) + end + end + end + + context "of recursive arrays" do + let(:params) do + a = [] + a << a << :bango + { bingo: a } + end + + it "prevents recursion" do + @truncator.truncate_object(params) + + expect(params).to eq(bingo: ['[Circular]', :bango]) + end + end + end + + context "which are arbitrary objects" do + context "with default #to_s" do + let(:params) { { bingo: Object.new } } + + it "converts the object to a safe string" do + @truncator.truncate_object(params) + + expect(params[:bingo]).to include('Object') + end + end + + context "with redefined #to_s" do + let(:params) do + obj = Object.new + + def obj.to_s + 'bango' * 2000 + end + + { bingo: obj } + end + + it "truncates the string if it's too long" do + @truncator.truncate_object(params) + + expect(params[:bingo].length).to eq(max_len) + end + end + + context "with other owner than Kernel" do + let(:params) do + mod = Module.new do + def to_s + "I am a fancy object" * 2000 + end + end + + klass = Class.new { include mod } + + { bingo: klass.new } + end + + it "truncates the string it if it's long" do + @truncator.truncate_object(params) + + expect(params[:bingo].length).to eq(max_len) + end + end + end + + context "multiple copies of the same object" do + let(:params) do + bingo = [] + bango = ['bongo'] + bingo << bango << bango + { bish: bingo } + end + + it "are not being truncated" do + @truncator.truncate_object(params) + + expect(params).to eq(bish: [['bongo'], ['bongo']]) + end + end + end + + describe "unicode payload" do + before do + @truncator = described_class.new(max_size - 1, Logger.new('/dev/null')) + end + + describe "truncation" do + let(:params) { { unicode: "€€€€" } } + let(:max_size) { params[:unicode].length } + + it "is performed correctly" do + @truncator.truncate_object(params) + + expect(params[:unicode].length).to eq(max_len - 1) + + if RUBY_VERSION == '1.9.2' + expect(params[:unicode]).to match(/\A?{#{max_size - 1}}\[Truncated\]\z/) + else + expect(params[:unicode]).to match(/\A€{#{max_size - 1}}\[Truncated\]\z/) + end + end + end + + describe "string encoding conversion" do + let(:params) { { unicode: "bad string€\xAE" } } + let(:max_size) { 100 } + + it "converts strings to valid UTF-8" do + @truncator.truncate_object(params) + + if RUBY_VERSION == '1.9.2' + expect(params[:unicode]).to eq('bad string??') + else + expect(params[:unicode]).to match(/\Abad string€[�\?]\z/) + end + + expect { params.to_json }.not_to raise_error + end + + it "converts ASCII-8BIT strings with invalid characters to UTF-8 correctly" do + # Shenanigans to get a bad ASCII-8BIT string. Direct conversion raises error. + encoded = Base64.encode64("\xD3\xE6\xBC\x9D\xBA").encode!('ASCII-8BIT') + bad_string = Base64.decode64(encoded) + + params = { unicode: bad_string } + + @truncator.truncate_object(params) + + expect(params[:unicode]).to match(/[�\?]{4}/) + end + end + end + + describe "given a non-recursible object" do + it "raises error" do + expect { @truncator.truncate_object(:bingo) }. + to raise_error(Airbrake::Error, /cannot truncate object/) + end + end + end +end diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb new file mode 100644 index 00000000..8ef1b8f5 --- /dev/null +++ b/spec/spec_helper.rb @@ -0,0 +1,98 @@ +require 'airbrake-ruby' + +require 'webmock' +require 'webmock/rspec' +require 'pry' + +require 'pathname' +require 'webrick' +require 'English' +require 'base64' + +RSpec.configure do |c| + c.order = 'random' + c.color = true + c.disable_monkey_patching! +end + +Thread.abort_on_exception = true + +WebMock.disable_net_connect!(allow_localhost: true) + +class AirbrakeTestError < RuntimeError + attr_reader :backtrace + + def initialize(*) + super + # rubocop:disable Metrics/LineLength + @backtrace = [ + "/home/kyrylo/code/airbrake/ruby/spec/spec_helper.rb:23:in `'", + "/opt/rubies/ruby-2.2.2/lib/ruby/2.2.0/rubygems/core_ext/kernel_require.rb:54:in `require'", + "/opt/rubies/ruby-2.2.2/lib/ruby/2.2.0/rubygems/core_ext/kernel_require.rb:54:in `require'", + "/home/kyrylo/code/airbrake/ruby/spec/airbrake_spec.rb:1:in `'", + "/home/kyrylo/.gem/ruby/2.2.2/gems/rspec-core-3.3.2/lib/rspec/core/configuration.rb:1327:in `load'", + "/home/kyrylo/.gem/ruby/2.2.2/gems/rspec-core-3.3.2/lib/rspec/core/configuration.rb:1327:in `block in load_spec_files'", + "/home/kyrylo/.gem/ruby/2.2.2/gems/rspec-core-3.3.2/lib/rspec/core/configuration.rb:1325:in `each'", + "/home/kyrylo/.gem/ruby/2.2.2/gems/rspec-core-3.3.2/lib/rspec/core/configuration.rb:1325:in `load_spec_files'", + "/home/kyrylo/.gem/ruby/2.2.2/gems/rspec-core-3.3.2/lib/rspec/core/runner.rb:102:in `setup'", + "/home/kyrylo/.gem/ruby/2.2.2/gems/rspec-core-3.3.2/lib/rspec/core/runner.rb:88:in `run'", + "/home/kyrylo/.gem/ruby/2.2.2/gems/rspec-core-3.3.2/lib/rspec/core/runner.rb:73:in `run'", + "/home/kyrylo/.gem/ruby/2.2.2/gems/rspec-core-3.3.2/lib/rspec/core/runner.rb:41:in `invoke'", + "/home/kyrylo/.gem/ruby/2.2.2/gems/rspec-core-3.3.2/exe/rspec:4:in `
'" + ] + # rubocop:enable Metrics/LineLength + end + + # rubocop:disable Style/AccessorMethodName + def set_backtrace(backtrace) + @backtrace = backtrace + end + # rubocop:enable Style/AccessorMethodName + + def message + 'App crashed!' + end +end + +class JavaAirbrakeTestError < AirbrakeTestError + def initialize(*) + super + # rubocop:disable Metrics/LineLength + @backtrace = [ + "org.jruby.java.invokers.InstanceMethodInvoker.call(InstanceMethodInvoker.java:26)", + "org.jruby.ir.interpreter.Interpreter.INTERPRET_EVAL(Interpreter.java:126)", + "org.jruby.RubyKernel$INVOKER$s$0$3$eval19.call(RubyKernel$INVOKER$s$0$3$eval19.gen)", + "org.jruby.RubyKernel$INVOKER$s$0$0$loop.call(RubyKernel$INVOKER$s$0$0$loop.gen)", + "org.jruby.runtime.IRBlockBody.doYield(IRBlockBody.java:139)", + "org.jruby.RubyKernel$INVOKER$s$rbCatch19.call(RubyKernel$INVOKER$s$rbCatch19.gen)", + "opt.rubies.jruby_minus_9_dot_0_dot_0_dot_0.bin.irb.invokeOther4:start(/opt/rubies/jruby-9.0.0.0/bin/irb)", + "opt.rubies.jruby_minus_9_dot_0_dot_0_dot_0.bin.irb.RUBY$script(/opt/rubies/jruby-9.0.0.0/bin/irb:13)", + "org.jruby.ir.Compiler$1.load(Compiler.java:111)", + "org.jruby.Main.run(Main.java:225)", + "org.jruby.Main.main(Main.java:197)" + ] + # rubocop:enable Metrics/LineLength + end + + def is_a?(*) + true + end +end + +class Ruby21Error < RuntimeError + attr_accessor :cause + + def self.raise_error(msg) + ex = new(msg) + ex.cause = $ERROR_INFO + + raise ex + end +end + +puts <