diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..8eb3b06 --- /dev/null +++ b/.gitignore @@ -0,0 +1,12 @@ +/.bundle/ +/.yardoc +/Gemfile.lock +/_yardoc/ +/coverage/ +/doc/ +/pkg/ +/spec/reports/ +/tmp/ + +# rspec failure tracking +.rspec_status diff --git a/.rspec b/.rspec new file mode 100644 index 0000000..8c18f1a --- /dev/null +++ b/.rspec @@ -0,0 +1,2 @@ +--format documentation +--color diff --git a/.rubocop.yml b/.rubocop.yml new file mode 100644 index 0000000..6f2dd3a --- /dev/null +++ b/.rubocop.yml @@ -0,0 +1,11 @@ +Metrics/BlockLength: + Max: 120 + +Metrics/LineLength: + Max: 110 + +Metrics/MethodLength: + Max: 12 + +Style/Documentation: + Enabled: false diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 0000000..7df13ae --- /dev/null +++ b/.travis.yml @@ -0,0 +1,9 @@ +sudo: false +language: ruby +rvm: + - 2.4.0 + - 2.3.3 +before_install: + - gem install bundler -v 1.14.6 +script: + - bundle exec rake diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 0000000..6a99ea1 --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,74 @@ +# Contributor Covenant Code of Conduct + +## Our Pledge + +In the interest of fostering an open and welcoming environment, we as +contributors and maintainers pledge to making participation in our project and +our community a harassment-free experience for everyone, regardless of age, body +size, disability, ethnicity, gender identity and expression, level of experience, +nationality, personal appearance, race, religion, or sexual identity and +orientation. + +## Our Standards + +Examples of behavior that contributes to creating a positive environment +include: + +* Using welcoming and inclusive language +* Being respectful of differing viewpoints and experiences +* Gracefully accepting constructive criticism +* Focusing on what is best for the community +* Showing empathy towards other community members + +Examples of unacceptable behavior by participants include: + +* The use of sexualized language or imagery and unwelcome sexual attention or +advances +* Trolling, insulting/derogatory comments, and personal or political attacks +* Public or private harassment +* Publishing others' private information, such as a physical or electronic + address, without explicit permission +* Other conduct which could reasonably be considered inappropriate in a + professional setting + +## Our Responsibilities + +Project maintainers are responsible for clarifying the standards of acceptable +behavior and are expected to take appropriate and fair corrective action in +response to any instances of unacceptable behavior. + +Project maintainers have the right and responsibility to remove, edit, or +reject comments, commits, code, wiki edits, issues, and other contributions +that are not aligned to this Code of Conduct, or to ban temporarily or +permanently any contributor for other behaviors that they deem inappropriate, +threatening, offensive, or harmful. + +## Scope + +This Code of Conduct applies both within project spaces and in public spaces +when an individual is representing the project or its community. Examples of +representing a project or community include using an official project e-mail +address, posting via an official social media account, or acting as an appointed +representative at an online or offline event. Representation of a project may be +further defined and clarified by project maintainers. + +## Enforcement + +Instances of abusive, harassing, or otherwise unacceptable behavior may be +reported by contacting the project team at k1lowxb@gmail.com. All +complaints will be reviewed and investigated and will result in a response that +is deemed necessary and appropriate to the circumstances. The project team is +obligated to maintain confidentiality with regard to the reporter of an incident. +Further details of specific enforcement policies may be posted separately. + +Project maintainers who do not follow or enforce the Code of Conduct in good +faith may face temporary or permanent repercussions as determined by other +members of the project's leadership. + +## Attribution + +This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, +available at [http://contributor-covenant.org/version/1/4][version] + +[homepage]: http://contributor-covenant.org +[version]: http://contributor-covenant.org/version/1/4/ diff --git a/Gemfile b/Gemfile new file mode 100644 index 0000000..f06c821 --- /dev/null +++ b/Gemfile @@ -0,0 +1,4 @@ +source 'https://rubygems.org' + +# Specify your gem's dependencies in faultline-rack.gemspec +gemspec diff --git a/LICENSE.txt b/LICENSE.txt new file mode 100644 index 0000000..017aba2 --- /dev/null +++ b/LICENSE.txt @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) 2017 k1LoW + +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 0000000..9ca21ad --- /dev/null +++ b/README.md @@ -0,0 +1,34 @@ +# Faultline::Rack + +> [faultline](https://github.com/faultline/faultline) exception and error notifier for Rack application. + +## Installation + +Add this line to your application's Gemfile: + +```ruby +gem 'faultline-rack' +``` + +And then execute: + + $ bundle + +Or install it yourself as: + + $ gem install faultline-rack + +## Usage + +```ruby + +``` + +## References + +- [airbrake/airbrake](https://github.com/airbrake/airbrake) + - Airbrake is licensed under [The MIT License (MIT)](https://github.com/airbrake/airbrake/LICENSE.md). + +## License + +MIT © Ken'ichiro Oyama diff --git a/Rakefile b/Rakefile new file mode 100644 index 0000000..a32f908 --- /dev/null +++ b/Rakefile @@ -0,0 +1,8 @@ +require 'bundler/gem_tasks' +require 'rspec/core/rake_task' +require 'octorelease' +require 'rubocop/rake_task' +RSpec::Core::RakeTask.new(:spec) +RuboCop::RakeTask.new + +task default: %i(spec rubocop) diff --git a/bin/console b/bin/console new file mode 100755 index 0000000..4d48ebd --- /dev/null +++ b/bin/console @@ -0,0 +1,7 @@ +#!/usr/bin/env ruby + +require 'bundler/setup' +require 'faultline/rack' + +require 'pry' +Pry.start diff --git a/bin/setup b/bin/setup new file mode 100755 index 0000000..dce67d8 --- /dev/null +++ b/bin/setup @@ -0,0 +1,8 @@ +#!/usr/bin/env bash +set -euo pipefail +IFS=$'\n\t' +set -vx + +bundle install + +# Do any other automated setup that you need to do here diff --git a/faultline-rack.gemspec b/faultline-rack.gemspec new file mode 100644 index 0000000..1174052 --- /dev/null +++ b/faultline-rack.gemspec @@ -0,0 +1,38 @@ +# coding: utf-8 + +lib = File.expand_path('../lib', __FILE__) +$LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib) +require 'faultline/rack/version' + +Gem::Specification.new do |spec| + spec.name = 'faultline-rack' + spec.version = Faultline::Rack::VERSION + spec.authors = ['k1LoW'] + spec.email = ['k1lowxb@gmail.com'] + + spec.summary = 'faultline exception and error notifier for Rack application' + spec.description = 'faultline exception and error notifier for Rack application' + spec.homepage = 'https://github.com/faultline/faultline-rack' + spec.license = 'MIT' + + spec.files = `git ls-files -z`.split("\x0").reject do |f| + f.match(%r{^(test|spec|features)/}) + end + spec.bindir = 'exe' + spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) } + spec.require_paths = ['lib'] + + spec.add_runtime_dependency 'faultline', '~> 0.1' + spec.add_runtime_dependency 'airbrake', '~> 6.0' + spec.add_runtime_dependency 'rack', '~> 1' + spec.add_development_dependency 'bundler', '~> 1.14' + spec.add_development_dependency 'rake', '~> 10.0' + spec.add_development_dependency 'rspec', '~> 3.0' + spec.add_development_dependency 'rspec-wait', '~> 0' + spec.add_development_dependency 'webmock', '~> 2' + spec.add_development_dependency 'rack-test', '~> 0' + spec.add_development_dependency 'warden', '~> 1.2.6' + spec.add_development_dependency 'rubocop', '~> 0.47.0' + spec.add_development_dependency 'octorelease' + spec.add_development_dependency 'pry' +end diff --git a/lib/faultline/rack.rb b/lib/faultline/rack.rb new file mode 100644 index 0000000..878dadc --- /dev/null +++ b/lib/faultline/rack.rb @@ -0,0 +1,10 @@ +require 'faultline' +require 'rack' +require 'airbrake' +require 'faultline/rack/version' +require 'faultline/rack/middleware' + +module Faultline + module Rack + end +end diff --git a/lib/faultline/rack/middleware.rb b/lib/faultline/rack/middleware.rb new file mode 100644 index 0000000..406eee6 --- /dev/null +++ b/lib/faultline/rack/middleware.rb @@ -0,0 +1,19 @@ +module Faultline + module Rack + class Middleware < Airbrake::Rack::Middleware + def initialize(app, notifier_name = :default) + @app = app + @notifier = Faultline[notifier_name] + + # Prevent adding same filters to the same notifier. + return if @@known_notifiers.include?(notifier_name) + @@known_notifiers << notifier_name + + return unless @notifier + RACK_FILTERS.each do |filter| + @notifier.add_filter(filter.new) + end + end + end + end +end diff --git a/lib/faultline/rack/version.rb b/lib/faultline/rack/version.rb new file mode 100644 index 0000000..7e27ab6 --- /dev/null +++ b/lib/faultline/rack/version.rb @@ -0,0 +1,5 @@ +module Faultline + module Rack + VERSION = '0.1.0'.freeze + end +end diff --git a/spec/apps/rack/dummy_app.rb b/spec/apps/rack/dummy_app.rb new file mode 100644 index 0000000..2bbd74d --- /dev/null +++ b/spec/apps/rack/dummy_app.rb @@ -0,0 +1,17 @@ +DummyApp = Rack::Builder.new do + use Rack::ShowExceptions + use Faultline::Rack::Middleware + use Warden::Manager + + map '/' do + run( + proc do |_env| + [200, { 'Content-Type' => 'text/plain' }, ['Hello from index']] + end + ) + end + + map '/crash' do + run proc { |_env| raise FaultlineTestError } + end +end diff --git a/spec/faultline/rack/middleware_spec.rb b/spec/faultline/rack/middleware_spec.rb new file mode 100644 index 0000000..f246614 --- /dev/null +++ b/spec/faultline/rack/middleware_spec.rb @@ -0,0 +1,145 @@ +require 'spec_helper' + +# rubocop:disable Style/DotPosition +RSpec.describe Faultline::Rack::Middleware do + let(:app) do + proc { |env| [200, env, 'Bingo bango content'] } + end + + let(:faulty_app) do + proc { raise FaultlineTestError } + end + + let(:post_error_endpoint) do + 'https://xxxxxxxxx.execute-api.ap-northeast-1.amazonaws.com/v0/projects/faultline-rack/errors' + end + + let(:middleware) { described_class.new(app) } + + def env_for(url, opts = {}) + Rack::MockRequest.env_for(url, opts) + end + + def wait_for_a_request_with_body(body) + wait_for(a_request(:post, post_error_endpoint).with(body: body)).to have_been_made.once + end + + before do + stub_request(:post, post_error_endpoint).to_return(status: 201, body: '{}') + end + + describe '#new' do + it "doesn't add filters if no notifiers are configured" do + expect do + expect(described_class.new(faulty_app, :unknown_notifier)) + end.not_to raise_error + end + end + + describe '#call' do + context 'when app raises an exception' do + context 'and when the notifier name is specified' do + let(:notifier_name) { :rack_middleware_initialize } + + let(:bingo_post_error_endpoint) do + 'https://xxxxxxxxx.execute-api.ap-northeast-1.amazonaws.com/v1/projects/faultline-bingo/errors' + end + + let(:expected_body) do + /"errors":\[{"type":"FaultlineTestError"/ + end + + before do + Faultline.configure(notifier_name) do |c| + c.project = 'faultline-bingo' + c.api_key = 'xxxxXXXXXxXxXXxxXXXXXXXxxxxXXXXXX' + c.endpoint = 'https://xxxxxxxxx.execute-api.ap-northeast-1.amazonaws.com/v1' + c.logger = Logger.new('/dev/null') + end + + stub_request(:post, bingo_post_error_endpoint).to_return(status: 201, body: '{}') + end + + after { Faultline[notifier_name].close } + + it 'notifies via the specified notifier' do + expect do + described_class.new(faulty_app, notifier_name).call(env_for('/')) + end.to raise_error(FaultlineTestError) + + wait_for( + a_request(:post, bingo_post_error_endpoint). + with(body: expected_body) + ).to have_been_made.once + + expect( + a_request(:post, post_error_endpoint). + with(body: expected_body) + ).not_to have_been_made + end + end + + context 'and when the notifier is not configured' do + it 'rescues the exception, notifies Faultline & re-raises it' do + expect { described_class.new(faulty_app).call(env_for('/')) }. + to raise_error(FaultlineTestError) + + wait_for_a_request_with_body(/"errors":\[{"type":"FaultlineTestError"/) + end + + it 'sends framework version and name' do + expect { described_class.new(faulty_app).call(env_for('/bingo/bango')) }. + to raise_error(FaultlineTestError) + + wait_for_a_request_with_body( + %r("context":{.*"version":"1.2.3 (Rails|Sinatra|Rack\.version)/.+".+}) + ) + end + end + end + + context "when app doesn't raise" do + context 'and previous middleware stored an exception in env' do + shared_examples 'stored exception' do |type| + it "notifies on #{type}, but doesn't raise" do + env = env_for('/').merge(type => FaultlineTestError.new) + described_class.new(app).call(env) + + wait_for_a_request_with_body(/"errors":\[{"type":"FaultlineTestError"/) + end + end + + ['rack.exception', 'action_dispatch.exception', 'sinatra.error'].each do |type| + include_examples 'stored exception', type + end + end + + it "doesn't notify Faultline" do + described_class.new(app).call(env_for('/')) + sleep 1 + expect(a_request(:post, post_error_endpoint)).not_to have_been_made + end + end + + it 'returns a response' do + response = described_class.new(app).call(env_for('/')) + + expect(response[0]).to eq(200) + expect(response[1]).to be_a(Hash) + expect(response[2]).to eq('Bingo bango content') + end + end + + context 'when Faultline is not configured' do + it 'returns nil' do + allow(Faultline[:default]).to receive(:build_notice).and_return(nil) + allow(Faultline[:default]).to receive(:notify) + + expect { described_class.new(faulty_app).call(env_for('/')) }. + to raise_error(FaultlineTestError) + + expect(Faultline[:default]).to have_received(:build_notice) + expect(Faultline[:default]).not_to have_received(:notify) + end + end +end diff --git a/spec/faultline/rack_spec.rb b/spec/faultline/rack_spec.rb new file mode 100644 index 0000000..27b5e56 --- /dev/null +++ b/spec/faultline/rack_spec.rb @@ -0,0 +1,7 @@ +require 'spec_helper' + +RSpec.describe Faultline::Rack do + it 'has a version number' do + expect(Faultline::Rack::VERSION).not_to be nil + end +end diff --git a/spec/integration/rack/rack_spec.rb b/spec/integration/rack/rack_spec.rb new file mode 100644 index 0000000..e7fff54 --- /dev/null +++ b/spec/integration/rack/rack_spec.rb @@ -0,0 +1,17 @@ +require 'spec_helper' +require 'integration/shared_examples/rack_examples' + +RSpec.describe 'Rack integration specs' do + let(:app) { DummyApp } + + include_examples 'rack examples' + + describe 'context payload' do + it 'includes version' do + get '/crash' + wait_for_a_request_with_body( + /"context":{.*"version":"1.2.3 Rack\.version.+Rack\.release/ + ) + end + end +end diff --git a/spec/integration/shared_examples/rack_examples.rb b/spec/integration/shared_examples/rack_examples.rb new file mode 100644 index 0000000..5928c6e --- /dev/null +++ b/spec/integration/shared_examples/rack_examples.rb @@ -0,0 +1,129 @@ +RSpec.shared_examples 'rack examples' do + include Warden::Test::Helpers + + after { Warden.test_reset! } + + let(:post_error_endpoint) do + 'https://xxxxxxxxx.execute-api.ap-northeast-1.amazonaws.com/v0/projects/faultline-rack/errors' + end + + def wait_for_a_request_with_body(body) + wait_for(a_request(:post, post_error_endpoint).with(body: body)).to have_been_made.once + end + + before do + # Make sure the Logger integration doesn't get in the way. + allow_any_instance_of(Logger).to receive(:airbrake_notifier).and_return(nil) + + stub_request(:post, post_error_endpoint).to_return(status: 201, body: '{}') + end + + describe 'application routes' do + describe '/index' do + it 'successfully returns 200 and body' do + get '/' + + expect(last_response.status).to eq(200) + expect(last_response.body).to eq('Hello from index') + + wait_for(a_request(:post, post_error_endpoint)).not_to have_been_made + end + end + + describe '/crash' do + it 'returns 500 and sends a notice to Airbrake' do + get '/crash' + + expect(last_response.status).to eq(500) + wait_for_a_request_with_body(/"errors":\[{"type":"FaultlineTestError"/) + end + end + end + + describe 'context payload' do + context 'when the user is present' do + let(:common_user_params) do + { id: 1, email: 'qa@example.com', username: 'qa-dept' } + end + + before do + login_as(OpenStruct.new(user_params)) + get '/crash' + end + + context 'when the user has first and last names' do + let(:user_params) do + common_user_params.merge(first_name: 'Bingo', last_name: 'Bongo') + end + + it "reports the user's first and last names" do + wait_for_a_request_with_body(/ + "context":{.* + "user":{ + "id":"1", + "name":"Bingo\sBongo", + "username":"qa-dept", + "email":"qa@example.com"} + /x) + end + end + + context 'when the user has only name' do + let(:user_params) do + common_user_params.merge(name: 'Bingo') + end + + it "reports the user's name" do + wait_for_a_request_with_body(/ + "context":{.* + "user":{ + "id":"1", + "name":"Bingo", + "username":"qa-dept", + "email":"qa@example.com"} + /x) + end + end + end + + context 'when additional parameters present' do + before do + get '/crash', nil, 'HTTP_USER_AGENT' => 'Bot', 'HTTP_REFERER' => 'bingo.com' + end + + it 'features url' do + wait_for_a_request_with_body( + %r("context":{.*"url":"http://example\.org/crash".*}) + ) + end + + it 'features hostname' do + wait_for_a_request_with_body(/"context":{.*"hostname":".+".*}/) + end + + it 'features userAgent' do + wait_for_a_request_with_body(/"context":{.*"userAgent":"Bot".*}/) + end + end + end + + describe 'environment payload' do + before do + get '/crash', nil, 'HTTP_REFERER' => 'bingo.com' + end + + it 'features referer' do + wait_for_a_request_with_body(/"environment":{.*"referer":"bingo.com".*}/) + end + + it 'contains HTTP headers' do + wait_for_a_request_with_body( + /"environment":{.*"headers":{.*"CONTENT_LENGTH":"0".*}/ + ) + end + + it 'contains HTTP method' do + wait_for_a_request_with_body(/"environment":{.*"httpMethod":"GET".*}/) + end + end +end diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb new file mode 100644 index 0000000..0da970b --- /dev/null +++ b/spec/spec_helper.rb @@ -0,0 +1,37 @@ +require 'bundler/setup' +require 'webmock' +require 'webmock/rspec' +require 'rspec/wait' +require 'rack' +require 'rack/test' +require 'rake' +require 'pry' +require 'faultline/rack' + +Faultline.configure do |c| + c.project = 'faultline-rack' + c.api_key = 'xxxxXXXXXxXxXXxxXXXXXXXxxxxXXXXXX' + c.endpoint = 'https://xxxxxxxxx.execute-api.ap-northeast-1.amazonaws.com/v0' + c.app_version = '1.2.3' + c.logger = Logger.new('/dev/null') + c.workers = 5 +end + +RSpec.configure do |config| + # Enable flags like --only-failures and --next-failure + config.example_status_persistence_file_path = '.rspec_status' + + config.order = 'random' + config.color = true + config.disable_monkey_patching! + config.wait_timeout = 3 + + config.include Rack::Test::Methods +end + +require 'warden' +require 'apps/rack/dummy_app' + +Thread.abort_on_exception = true + +FaultlineTestError = Class.new(StandardError)