├── spec ├── fixtures │ ├── project_root │ │ ├── empty_file.rb │ │ ├── short_file.rb │ │ ├── vendor │ │ │ └── bundle │ │ │ │ └── ignored_file.rb │ │ └── long_line.txt │ └── notroot.txt ├── helpers.rb ├── request_spec.rb ├── query_spec.rb ├── performance_breakdown_spec.rb ├── ignorable_spec.rb ├── loggable_spec.rb ├── queue_spec.rb ├── filters │ ├── dependency_filter_spec.rb │ ├── system_exit_filter_spec.rb │ ├── context_filter_spec.rb │ ├── exception_attributes_filter_spec.rb │ ├── root_directory_filter_spec.rb │ ├── gem_root_filter_spec.rb │ ├── git_repository_filter_spec.rb │ ├── git_last_checkout_filter_spec.rb │ └── git_revision_filter_spec.rb ├── stashable_spec.rb ├── monotonic_time_spec.rb ├── benchmark_spec.rb ├── file_cache_spec.rb ├── stat_spec.rb ├── time_truncate_spec.rb ├── inspectable_spec.rb ├── context_spec.rb ├── deploy_notifier_spec.rb ├── async_sender_spec.rb ├── backlog_spec.rb ├── filter_chain_spec.rb ├── response_spec.rb ├── spec_helper.rb ├── code_hunk_spec.rb ├── timed_trace_spec.rb ├── nested_exception_spec.rb ├── remote_settings │ └── callback_spec.rb ├── config │ └── processor_spec.rb └── thread_pool_spec.rb ├── .rspec ├── .hound.yml ├── .gitignore ├── .github ├── dependabot.yml └── workflows │ └── test.yml ├── benchmarks ├── tdigest.rb ├── server.go ├── notify_async_vs_sync.rb ├── truncator.rb ├── build_notice.rb ├── performance.rb ├── notify_async_workers.rb └── truncator_string_encoding.rb ├── lib └── airbrake-ruby │ ├── mergeable.rb │ ├── grouppable.rb │ ├── stashable.rb │ ├── time_truncate.rb │ ├── version.rb │ ├── filters │ ├── system_exit_filter.rb │ ├── context_filter.rb │ ├── root_directory_filter.rb │ ├── dependency_filter.rb │ ├── gem_root_filter.rb │ ├── exception_attributes_filter.rb │ ├── keys_allowlist.rb │ ├── keys_blocklist.rb │ ├── git_revision_filter.rb │ ├── git_repository_filter.rb │ ├── thread_filter.rb │ ├── git_last_checkout_filter.rb │ ├── sql_filter.rb │ └── keys_filter.rb │ ├── benchmark.rb │ ├── loggable.rb │ ├── deploy_notifier.rb │ ├── hash_keyable.rb │ ├── ignorable.rb │ ├── request.rb │ ├── context.rb │ ├── file_cache.rb │ ├── monotonic_time.rb │ ├── performance_breakdown.rb │ ├── remote_settings │ ├── callback.rb │ └── settings_data.rb │ ├── query.rb │ ├── inspectable.rb │ ├── timed_trace.rb │ ├── code_hunk.rb │ ├── queue.rb │ ├── async_sender.rb │ ├── nested_exception.rb │ ├── stat.rb │ ├── config │ ├── processor.rb │ └── validator.rb │ ├── promise.rb │ ├── backlog.rb │ ├── filter_chain.rb │ ├── remote_settings.rb │ ├── truncator.rb │ ├── sync_sender.rb │ ├── notice_notifier.rb │ ├── thread_pool.rb │ ├── response.rb │ ├── notice.rb │ └── performance_notifier.rb ├── Gemfile ├── LICENSE.md ├── Rakefile ├── airbrake-ruby.gemspec ├── CONTRIBUTING.md └── .rubocop.yml /spec/fixtures/project_root/empty_file.rb: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.rspec: -------------------------------------------------------------------------------- 1 | --warnings 2 | --require spec_helper 3 | -------------------------------------------------------------------------------- /.hound.yml: -------------------------------------------------------------------------------- 1 | ruby: 2 | config_file: .rubocop.yml 3 | -------------------------------------------------------------------------------- /spec/fixtures/notroot.txt: -------------------------------------------------------------------------------- 1 | This 2 | file 3 | is 4 | not 5 | inside 6 | root 7 | directory 8 | -------------------------------------------------------------------------------- /spec/fixtures/project_root/short_file.rb: -------------------------------------------------------------------------------- 1 | module Banana 2 | attr_reader :bingo 3 | end 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | Gemfile.lock 2 | .bundle 3 | doc 4 | .yardoc 5 | pkg 6 | coverage 7 | .ruby-version 8 | .ruby-gemset 9 | config 10 | README 11 | -------------------------------------------------------------------------------- /spec/fixtures/project_root/vendor/bundle/ignored_file.rb: -------------------------------------------------------------------------------- 1 | module IgnoredFile 2 | def ignore_me 3 | puts 'Anybody here?' 4 | end 5 | end 6 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: bundler 4 | directory: "/" 5 | schedule: 6 | interval: daily 7 | time: "19:00" 8 | timezone: US/Central 9 | -------------------------------------------------------------------------------- /spec/fixtures/project_root/long_line.txt: -------------------------------------------------------------------------------- 1 | loooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooong line 2 | -------------------------------------------------------------------------------- /benchmarks/tdigest.rb: -------------------------------------------------------------------------------- 1 | require_relative 'benchmark_helpers' 2 | 3 | Benchmark.ips do |ips| 4 | ips.report do 5 | tdigest = Airbrake::TDigest.new(0.05) 6 | 100.times { tdigest.push(rand(1..200)) } 7 | tdigest.compress! 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /spec/helpers.rb: -------------------------------------------------------------------------------- 1 | module Helpers 2 | def fixture_path(filename) 3 | File.expand_path(File.join('spec', 'fixtures', filename)) 4 | end 5 | 6 | def project_root_path(filename) 7 | fixture_path(File.join('project_root', filename)) 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /spec/request_spec.rb: -------------------------------------------------------------------------------- 1 | RSpec.describe Airbrake::Request do 2 | describe "#stash" do 3 | subject do 4 | described_class.new(method: 'GET', route: '/', status_code: 200) 5 | end 6 | 7 | it { is_expected.to respond_to(:stash) } 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /spec/query_spec.rb: -------------------------------------------------------------------------------- 1 | RSpec.describe Airbrake::Query do 2 | describe "#stash" do 3 | subject do 4 | described_class.new( 5 | method: 'GET', route: '/', query: '', 6 | ) 7 | end 8 | 9 | it { is_expected.to respond_to(:stash) } 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /spec/performance_breakdown_spec.rb: -------------------------------------------------------------------------------- 1 | RSpec.describe Airbrake::PerformanceBreakdown do 2 | describe "#stash" do 3 | subject do 4 | described_class.new( 5 | method: 'GET', route: '/', response_type: '', groups: {}, 6 | ) 7 | end 8 | 9 | it { is_expected.to respond_to(:stash) } 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /lib/airbrake-ruby/mergeable.rb: -------------------------------------------------------------------------------- 1 | module Airbrake 2 | # Mergeable adds the `#merge` method, so that we don't need to define it in 3 | # all of performance models every time we add a model. 4 | # 5 | # @since v4.9.0 6 | # @api private 7 | module Mergeable 8 | def merge(_other) 9 | nil 10 | end 11 | end 12 | end 13 | -------------------------------------------------------------------------------- /lib/airbrake-ruby/grouppable.rb: -------------------------------------------------------------------------------- 1 | module Airbrake 2 | # Grouppable adds the `#groups` method, so that we don't need to define it in 3 | # all of performance models every time we add a model without groups. 4 | # 5 | # @since v4.9.0 6 | # @api private 7 | module Grouppable 8 | def groups 9 | {} 10 | end 11 | end 12 | end 13 | -------------------------------------------------------------------------------- /spec/ignorable_spec.rb: -------------------------------------------------------------------------------- 1 | RSpec.describe Airbrake::Ignorable do 2 | let(:klass) do 3 | mod = subject 4 | Class.new { include(mod) } 5 | end 6 | 7 | it "ignores includee" do 8 | instance = klass.new 9 | expect(instance).not_to be_ignored 10 | 11 | instance.ignore! 12 | expect(instance).to be_ignored 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org' 2 | gemspec 3 | 4 | gem 'rubocop', '~> 1.16', require: false 5 | gem 'rubocop-rake', '~> 0.5', require: false 6 | gem 'rubocop-rspec', '~> 2.3', require: false 7 | 8 | gem 'simplecov', '~> 0.16', require: false 9 | 10 | gem 'webrick', '~> 1.7' if Gem::Version.new(RUBY_VERSION) >= Gem::Version.new('3.0') 11 | gem 'yard', '0.9.28' 12 | -------------------------------------------------------------------------------- /benchmarks/server.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "log" 5 | "net/http" 6 | "time" 7 | ) 8 | 9 | func main() { 10 | http.HandleFunc("/", func(w http.ResponseWriter, req *http.Request) { 11 | time.Sleep(50 * time.Millisecond) 12 | w.WriteHeader(http.StatusCreated) 13 | w.Write([]byte(`{"id":"123"}`)) 14 | }) 15 | 16 | log.Fatal(http.ListenAndServe(":8080", nil)) 17 | } 18 | -------------------------------------------------------------------------------- /lib/airbrake-ruby/stashable.rb: -------------------------------------------------------------------------------- 1 | module Airbrake 2 | # Stashable should be included in any class that wants the ability to stash 3 | # arbitrary objects. It is mainly used by data objects that users can access 4 | # through filters. 5 | # 6 | # @since v4.4.0 7 | # @api private 8 | module Stashable 9 | # @return [Hash{Symbol=>Object}] the hash with arbitrary objects to be used 10 | # in filters 11 | def stash 12 | @stash ||= {} 13 | end 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /spec/loggable_spec.rb: -------------------------------------------------------------------------------- 1 | RSpec.describe Airbrake::Loggable do 2 | describe ".instance" do 3 | it "returns a logger" do 4 | expect(described_class.instance).to be_a(Logger) 5 | end 6 | end 7 | 8 | describe "#logger" do 9 | subject(:class_with_logger) do 10 | Class.new { include Airbrake::Loggable }.new 11 | end 12 | 13 | it "returns a logger that has Logger::WARN severity" do 14 | expect(class_with_logger.logger.level).to eq(Logger::WARN) 15 | end 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /spec/queue_spec.rb: -------------------------------------------------------------------------------- 1 | RSpec.describe Airbrake::Queue do 2 | subject { described_class.new(queue: 'bananas', error_count: 0) } 3 | 4 | describe "#ignore" do 5 | it { is_expected.to respond_to(:ignore!) } 6 | end 7 | 8 | describe "#stash" do 9 | it { is_expected.to respond_to(:stash) } 10 | end 11 | 12 | describe "#route" do 13 | it "always returns an empty route" do 14 | queue = described_class.new(queue: 'a', error_count: 0) 15 | expect(queue.route).to be_empty 16 | end 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /lib/airbrake-ruby/time_truncate.rb: -------------------------------------------------------------------------------- 1 | module Airbrake 2 | # TimeTruncate contains methods for truncating time. 3 | # 4 | # @api private 5 | # @since v3.2.0 6 | module TimeTruncate 7 | # Truncate +time+ to floor minute and turn it into an RFC3339 timestamp. 8 | # 9 | # @param [Time, Integer, Float] time 10 | # @return [String] 11 | def self.utc_truncate_minutes(time) 12 | tm = Time.at(time).getutc 13 | 14 | Time.utc(tm.year, tm.month, tm.day, tm.hour, tm.min).to_datetime.rfc3339 15 | end 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /spec/filters/dependency_filter_spec.rb: -------------------------------------------------------------------------------- 1 | RSpec.describe Airbrake::Filters::DependencyFilter do 2 | subject(:dependency_filter) { described_class.new } 3 | 4 | let(:notice) { Airbrake::Notice.new(AirbrakeTestError.new) } 5 | 6 | describe "#call" do 7 | it "attaches loaded dependencies to context/versions/dependencies" do 8 | dependency_filter.call(notice) 9 | expect(notice[:context][:versions][:dependencies]).to include( 10 | 'airbrake-ruby' => Airbrake::AIRBRAKE_RUBY_VERSION, 11 | ) 12 | end 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /lib/airbrake-ruby/version.rb: -------------------------------------------------------------------------------- 1 | # We use Semantic Versioning v2.0.0 2 | # More information: http://semver.org/ 3 | module Airbrake 4 | # @return [String] the library version 5 | # @api public 6 | AIRBRAKE_RUBY_VERSION = '6.2.2'.freeze 7 | 8 | # @return [Hash{Symbol=>String}] the information about the notifier library 9 | # @since v5.0.0 10 | # @api public 11 | NOTIFIER_INFO = { 12 | name: 'airbrake-ruby'.freeze, 13 | version: Airbrake::AIRBRAKE_RUBY_VERSION, 14 | url: 'https://github.com/airbrake/airbrake-ruby'.freeze, 15 | }.freeze 16 | end 17 | -------------------------------------------------------------------------------- /spec/stashable_spec.rb: -------------------------------------------------------------------------------- 1 | RSpec.describe Airbrake::Stashable do 2 | let(:klass) do 3 | mod = described_class 4 | Class.new { include(mod) } 5 | end 6 | 7 | describe "#stash" do 8 | subject(:instance) { klass.new } 9 | 10 | it "returns a hash" do 11 | expect(instance.stash).to be_a(Hash) 12 | end 13 | 14 | it "returns an empty hash" do 15 | expect(instance.stash).to be_empty 16 | end 17 | 18 | it "remembers what was put in the stash" do 19 | instance.stash[:foo] = 1 20 | expect(instance.stash[:foo]).to eq(1) 21 | end 22 | end 23 | end 24 | -------------------------------------------------------------------------------- /lib/airbrake-ruby/filters/system_exit_filter.rb: -------------------------------------------------------------------------------- 1 | module Airbrake 2 | module Filters 3 | # Skip over SystemExit exceptions, because they're just noise. 4 | # @api private 5 | class SystemExitFilter 6 | # @return [String] 7 | SYSTEM_EXIT_TYPE = 'SystemExit'.freeze 8 | 9 | # @return [Integer] 10 | attr_reader :weight 11 | 12 | def initialize 13 | @weight = 130 14 | end 15 | 16 | # @macro call_filter 17 | def call(notice) 18 | return if notice[:errors].none? { |error| error[:type] == SYSTEM_EXIT_TYPE } 19 | 20 | notice.ignore! 21 | end 22 | end 23 | end 24 | end 25 | -------------------------------------------------------------------------------- /spec/filters/system_exit_filter_spec.rb: -------------------------------------------------------------------------------- 1 | RSpec.describe Airbrake::Filters::SystemExitFilter do 2 | subject(:system_exit_filter) { described_class.new } 3 | 4 | it "marks SystemExit exceptions as ignored" do 5 | notice = Airbrake::Notice.new(SystemExit.new) 6 | expect { system_exit_filter.call(notice) }.to( 7 | change { notice.ignored? }.from(false).to(true), 8 | ) 9 | end 10 | 11 | it "doesn't mark non SystemExit exceptions as ignored" do 12 | notice = Airbrake::Notice.new(AirbrakeTestError.new) 13 | expect(notice).not_to be_ignored 14 | expect { system_exit_filter.call(notice) }.not_to(change { notice.ignored? }) 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /benchmarks/notify_async_vs_sync.rb: -------------------------------------------------------------------------------- 1 | require_relative 'benchmark_helpers' 2 | 3 | # Silence logs. 4 | logger = Logger.new('/dev/null') 5 | 6 | # Setup Airbrake. 7 | Airbrake.configure do |c| 8 | c.project_id = 112261 9 | c.project_key = 'c7aaceb2ccb579e6b710cea9da22c526' 10 | c.logger = logger 11 | c.host = 'http://localhost:8080' 12 | end 13 | 14 | # The number of notices to process. 15 | NOTICES = 1200 16 | 17 | # Don't forget to run the server: go run benchmarks/server.go 18 | Benchmark.bm do |bm| 19 | bm.report("Airbrake.notify") do 20 | NOTICES.times { Airbrake.notify(BIG_EXCEPTION) } 21 | end 22 | 23 | bm.report("Airbrake.notify_sync") do 24 | NOTICES.times { Airbrake.notify_sync(BIG_EXCEPTION) } 25 | end 26 | end 27 | -------------------------------------------------------------------------------- /benchmarks/truncator.rb: -------------------------------------------------------------------------------- 1 | require_relative 'benchmark_helpers' 2 | 3 | # Generates example errors that should be truncated. 4 | class Payload 5 | def self.generate 6 | Array.new(5000) do |i| 7 | { type: "Error#{i}", 8 | message: 'X' * 300, 9 | backtrace: Array.new(300) { 'Y' * 300 } } 10 | end 11 | end 12 | end 13 | 14 | # The maximum size of hashes, arrays and strings. 15 | TRUNCATOR_MAX_SIZE = 500 16 | 17 | # Reduce the logger overhead. 18 | LOGGER = Logger.new('/dev/null') 19 | 20 | truncate_payload = Payload.generate 21 | truncator = Airbrake::Truncator.new(TRUNCATOR_MAX_SIZE) 22 | 23 | Benchmark.bm do |bm| 24 | bm.report("Truncator#truncate") do 25 | truncate_payload.each { |error| truncator.truncate(error) } 26 | end 27 | end 28 | -------------------------------------------------------------------------------- /lib/airbrake-ruby/filters/context_filter.rb: -------------------------------------------------------------------------------- 1 | module Airbrake 2 | module Filters 3 | # Adds user context to the notice object. Clears the context after it's 4 | # attached. 5 | # 6 | # @api private 7 | # @since v2.9.0 8 | class ContextFilter 9 | # @return [Integer] 10 | attr_reader :weight 11 | 12 | def initialize 13 | @weight = 119 14 | @mutex = Mutex.new 15 | end 16 | 17 | # @macro call_filter 18 | def call(notice) 19 | @mutex.synchronize do 20 | return if Airbrake::Context.current.empty? 21 | 22 | notice[:params][:airbrake_context] = Airbrake::Context.current.to_h 23 | Airbrake::Context.current.clear 24 | end 25 | end 26 | end 27 | end 28 | end 29 | -------------------------------------------------------------------------------- /spec/monotonic_time_spec.rb: -------------------------------------------------------------------------------- 1 | RSpec.describe Airbrake::MonotonicTime do 2 | subject(:monotonic_time) { described_class } 3 | 4 | describe ".time_in_ms" do 5 | it "returns monotonic time in milliseconds" do 6 | expect(monotonic_time.time_in_ms).to be_a(Float) 7 | end 8 | 9 | it "always returns time in the future" do 10 | old_time = monotonic_time.time_in_ms 11 | expect(monotonic_time.time_in_ms).to be > old_time 12 | end 13 | end 14 | 15 | describe ".time_in_s" do 16 | it "returns monotonic time in seconds" do 17 | expect(monotonic_time.time_in_s).to be_a(Float) 18 | end 19 | 20 | it "always returns time in the future" do 21 | old_time = monotonic_time.time_in_s 22 | expect(monotonic_time.time_in_s).to be > old_time 23 | end 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /lib/airbrake-ruby/filters/root_directory_filter.rb: -------------------------------------------------------------------------------- 1 | module Airbrake 2 | module Filters 3 | # Replaces root directory with a label. 4 | # @api private 5 | class RootDirectoryFilter 6 | # @return [String] 7 | PROJECT_ROOT_LABEL = '/PROJECT_ROOT'.freeze 8 | 9 | # @return [Integer] 10 | attr_reader :weight 11 | 12 | def initialize(root_directory) 13 | @root_directory = root_directory 14 | @weight = 100 15 | end 16 | 17 | # @macro call_filter 18 | def call(notice) 19 | notice[:errors].each do |error| 20 | error[:backtrace].each do |frame| 21 | next unless (file = frame[:file]) 22 | 23 | file.sub!(/\A#{@root_directory}/, PROJECT_ROOT_LABEL) 24 | end 25 | end 26 | end 27 | end 28 | end 29 | end 30 | -------------------------------------------------------------------------------- /lib/airbrake-ruby/filters/dependency_filter.rb: -------------------------------------------------------------------------------- 1 | module Airbrake 2 | module Filters 3 | # Attaches loaded dependencies to the notice object. 4 | # 5 | # @api private 6 | # @since v2.10.0 7 | class DependencyFilter 8 | def initialize 9 | @weight = 117 10 | end 11 | 12 | # @macro call_filter 13 | def call(notice) 14 | deps = {} 15 | Gem.loaded_specs.map.with_object(deps) do |(name, spec), h| 16 | h[name] = "#{spec.version}#{git_version(spec)}" 17 | end 18 | 19 | notice[:context][:versions] = {} unless notice[:context].key?(:versions) 20 | notice[:context][:versions][:dependencies] = deps 21 | end 22 | 23 | private 24 | 25 | def git_version(spec) 26 | return unless spec.respond_to?(:git_version) || spec.git_version 27 | 28 | spec.git_version.to_s 29 | end 30 | end 31 | end 32 | end 33 | -------------------------------------------------------------------------------- /benchmarks/build_notice.rb: -------------------------------------------------------------------------------- 1 | require_relative 'benchmark_helpers' 2 | 3 | Airbrake.configure do |c| 4 | c.project_id = 1 5 | c.project_key = '213' 6 | c.logger = Logger.new('/dev/null') 7 | end 8 | 9 | puts "Calculating iterations/second..." 10 | 11 | Benchmark.ips do |ips| 12 | ips.config(time: 5, warmup: 5) 13 | 14 | ips.report("big Airbrake.build_notice") do 15 | Airbrake.build_notice(BIG_EXCEPTION) 16 | end 17 | 18 | ips.report("small Airbrake.build_notice") do 19 | Airbrake.build_notice(SMALL_EXCEPTION) 20 | end 21 | 22 | ips.compare! 23 | end 24 | 25 | NOTICES = 100_000 26 | 27 | puts "Calculating times..." 28 | 29 | Benchmark.bmbm do |bm| 30 | bm.report("big Airbrake.build_notice") do 31 | NOTICES.times { Airbrake.build_notice(BIG_EXCEPTION) } 32 | end 33 | 34 | bm.report("small Airbrake.build_notice") do 35 | NOTICES.times { Airbrake.build_notice(SMALL_EXCEPTION) } 36 | end 37 | end 38 | -------------------------------------------------------------------------------- /lib/airbrake-ruby/benchmark.rb: -------------------------------------------------------------------------------- 1 | module Airbrake 2 | # Benchmark benchmarks Ruby code. 3 | # 4 | # @since v4.2.4 5 | # @api public 6 | class Benchmark 7 | # Measures monotonic time for the given operation. 8 | # 9 | # @yieldreturn [void] 10 | def self.measure 11 | benchmark = new 12 | 13 | yield 14 | 15 | benchmark.stop 16 | benchmark.duration 17 | end 18 | 19 | # @return [Float] 20 | attr_reader :duration 21 | 22 | # @since v4.3.0 23 | def initialize 24 | @start = MonotonicTime.time_in_ms 25 | @duration = 0.0 26 | end 27 | 28 | # Stops the benchmark and stores `duration`. 29 | # 30 | # @since v4.3.0 31 | # @return [Boolean] true for the first invocation, false in all other cases 32 | def stop 33 | return false if @duration > 0.0 34 | 35 | @duration = MonotonicTime.time_in_ms - @start 36 | true 37 | end 38 | end 39 | end 40 | -------------------------------------------------------------------------------- /lib/airbrake-ruby/loggable.rb: -------------------------------------------------------------------------------- 1 | module Airbrake 2 | # Loggable is included into any class that wants to be able to log. 3 | # 4 | # By default, Loggable defines a null logger that doesn't do anything. You are 5 | # supposed to overwrite it via the {instance} method before calling {logger}. 6 | # 7 | # @example 8 | # class A 9 | # include Loggable 10 | # 11 | # def initialize 12 | # logger.debug('Initialized A') 13 | # end 14 | # end 15 | # 16 | # @since v4.0.0 17 | # @api private 18 | module Loggable 19 | class << self 20 | # @return [Logger] 21 | attr_writer :instance 22 | 23 | # @return [Logger] 24 | def instance 25 | @instance ||= ::Logger.new(File::NULL).tap { |l| l.level = ::Logger::WARN } 26 | end 27 | end 28 | 29 | # @return [Logger] standard Ruby logger object 30 | def logger 31 | Loggable.instance 32 | end 33 | end 34 | end 35 | -------------------------------------------------------------------------------- /spec/benchmark_spec.rb: -------------------------------------------------------------------------------- 1 | RSpec.describe Airbrake::Benchmark do 2 | subject(:benchmark) { described_class.new } 3 | 4 | describe ".measure" do 5 | it "returns measured performance time" do 6 | expect(described_class.measure { '10' * 10 }).to be_a(Numeric) 7 | end 8 | end 9 | 10 | describe "#stop" do 11 | before { benchmark } 12 | 13 | context "when called one time" do 14 | its(:stop) { is_expected.to be(true) } 15 | end 16 | 17 | context "when called twice or more" do 18 | before { benchmark.stop } 19 | 20 | its(:stop) { is_expected.to be(false) } 21 | end 22 | end 23 | 24 | describe "#duration" do 25 | context "when #stop wasn't called yet" do 26 | its(:duration) { is_expected.to be_zero } 27 | end 28 | 29 | context "when #stop was called" do 30 | before { benchmark.stop } 31 | 32 | its(:duration) { is_expected.to be > 0 } 33 | end 34 | end 35 | end 36 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: airbrake-ruby 2 | 3 | on: [push] 4 | 5 | permissions: 6 | contents: read 7 | 8 | jobs: 9 | test: 10 | strategy: 11 | matrix: 12 | os: [ubuntu-latest] 13 | ruby: [2.5, 2.6, 2.7, 3.0, 3.1, head, jruby, jruby-head] 14 | 15 | runs-on: ${{ matrix.os }} 16 | 17 | steps: 18 | - uses: actions/checkout@v3 19 | 20 | - uses: ruby/setup-ruby@v1 21 | with: 22 | ruby-version: ${{ matrix.ruby }} 23 | bundler-cache: true 24 | 25 | - name: Rubocop lint 26 | run: if [[ "$RUBY_ENGINE" == "ruby" ]]; then bundle exec rubocop; fi 27 | 28 | - name: YARD lint 29 | run: | 30 | touch README # Workaround for "incorrect" anchor links in README.md 31 | bundle exec yardoc --fail-on-warning --no-progress --readme=README 32 | 33 | - name: Display Ruby version 34 | run: ruby -v 35 | 36 | - name: Test 37 | run: bundle exec rake 38 | -------------------------------------------------------------------------------- /lib/airbrake-ruby/deploy_notifier.rb: -------------------------------------------------------------------------------- 1 | module Airbrake 2 | # DeployNotifier sends deploy information to Airbrake. The information 3 | # consists of: 4 | # - environment 5 | # - username 6 | # - repository 7 | # - revision 8 | # - version 9 | # 10 | # @api public 11 | # @since v3.2.0 12 | class DeployNotifier 13 | include Inspectable 14 | 15 | def initialize 16 | @config = Airbrake::Config.instance 17 | @sender = SyncSender.new 18 | end 19 | 20 | # @see Airbrake.notify_deploy 21 | def notify(deploy_info) 22 | promise = @config.check_configuration 23 | return promise if promise.rejected? 24 | 25 | promise = Airbrake::Promise.new 26 | deploy_info[:environment] ||= @config.environment 27 | @sender.send( 28 | deploy_info, 29 | promise, 30 | URI.join(@config.error_host, "api/v4/projects/#{@config.project_id}/deploys"), 31 | ) 32 | 33 | promise 34 | end 35 | end 36 | end 37 | -------------------------------------------------------------------------------- /lib/airbrake-ruby/hash_keyable.rb: -------------------------------------------------------------------------------- 1 | module Airbrake 2 | # HashKeyable allows instances of the class to be used as a Hash key in a 3 | # consistent manner. 4 | # 5 | # The class that includes it must implement *to_h*, which defines properties 6 | # that all of the instances must share in order to produce the same {#hash}. 7 | # 8 | # @example 9 | # class C 10 | # include Airbrake::HashKeyable 11 | # 12 | # def initialize(key) 13 | # @key = key 14 | # end 15 | # 16 | # def to_h 17 | # { 'key' => @key } 18 | # end 19 | # end 20 | # 21 | # h = {} 22 | # h[C.new('key1')] = 1 23 | # h[C.new('key1')] #=> 1 24 | # h[C.new('key2')] #=> nil 25 | module HashKeyable 26 | # @param [Object] other 27 | # @return [Boolean] 28 | def eql?(other) 29 | other.is_a?(self.class) && other.hash == hash 30 | end 31 | 32 | # @return [Integer] 33 | def hash 34 | to_h.hash 35 | end 36 | end 37 | end 38 | -------------------------------------------------------------------------------- /lib/airbrake-ruby/filters/gem_root_filter.rb: -------------------------------------------------------------------------------- 1 | module Airbrake 2 | module Filters 3 | # Replaces paths to gems with a placeholder. 4 | # @api private 5 | class GemRootFilter 6 | # @return [String] 7 | GEM_ROOT_LABEL = '/GEM_ROOT'.freeze 8 | 9 | # @return [Integer] 10 | attr_reader :weight 11 | 12 | def initialize 13 | @weight = 120 14 | end 15 | 16 | # @macro call_filter 17 | def call(notice) 18 | return unless defined?(Gem) 19 | 20 | notice[:errors].each do |error| 21 | Gem.path.each do |gem_path| 22 | error[:backtrace].each do |frame| 23 | # If the frame is unparseable, then 'file' is nil, thus nothing to 24 | # filter (all frame's data is in 'function' instead). 25 | next unless (file = frame[:file]) 26 | 27 | frame[:file] = file.sub(/\A#{gem_path}/, GEM_ROOT_LABEL) 28 | end 29 | end 30 | end 31 | end 32 | end 33 | end 34 | end 35 | -------------------------------------------------------------------------------- /spec/file_cache_spec.rb: -------------------------------------------------------------------------------- 1 | RSpec.describe Airbrake::FileCache do 2 | before { described_class.reset } 3 | 4 | after { described_class.reset } 5 | 6 | describe ".[]=" do 7 | context "when cache limit isn't reached" do 8 | before do 9 | stub_const("#{described_class.name}::MAX_SIZE", 10) 10 | end 11 | 12 | it "adds objects" do 13 | described_class[:banana] = 1 14 | described_class[:mango] = 2 15 | 16 | expect(described_class[:banana]).to eq(1) 17 | expect(described_class[:mango]).to eq(2) 18 | end 19 | end 20 | 21 | context "when cache limit is reached" do 22 | before do 23 | stub_const("#{described_class.name}::MAX_SIZE", 1) 24 | end 25 | 26 | it "replaces old objects with new ones" do 27 | described_class[:banana] = 1 28 | described_class[:mango] = 2 29 | 30 | expect(described_class[:banana]).to be_nil 31 | expect(described_class[:mango]).to eq(2) 32 | end 33 | end 34 | end 35 | end 36 | -------------------------------------------------------------------------------- /spec/stat_spec.rb: -------------------------------------------------------------------------------- 1 | RSpec.describe Airbrake::Stat do 2 | subject(:stat) { described_class.new } 3 | 4 | describe "#to_h" do 5 | it "converts to a hash" do 6 | expect(stat.to_h).to eq( 7 | 'count' => 0, 8 | 'sum' => 0.0, 9 | 'sumsq' => 0.0, 10 | 'tdigest' => 'AAAAAkA0AAAAAAAAAAAAAA==', 11 | ) 12 | end 13 | end 14 | 15 | describe "#increment_ms" do 16 | before { stat.increment_ms(1000) } 17 | 18 | its(:sum) { is_expected.to eq(1000) } 19 | its(:sumsq) { is_expected.to eq(1000000) } 20 | 21 | it "updates tdigest" do 22 | expect(stat.tdigest.size).to eq(1) 23 | end 24 | end 25 | 26 | describe "#inspect" do 27 | it "provides custom inspect output" do 28 | expect(stat.inspect).to eq( 29 | '#', 30 | ) 31 | end 32 | end 33 | 34 | describe "#pretty_print" do 35 | it "is an alias of #inspect" do 36 | expect(stat.method(:pretty_print)).to eql(stat.method(:inspect)) 37 | end 38 | end 39 | end 40 | -------------------------------------------------------------------------------- /spec/time_truncate_spec.rb: -------------------------------------------------------------------------------- 1 | RSpec.describe Airbrake::TimeTruncate do 2 | time = Time.new(2018, 1, 1, 0, 0, 20, 0) 3 | time_with_zone = Time.new(2018, 1, 1, 0, 0, 20, '-05:00') 4 | 5 | describe "#utc_truncate_minutes" do 6 | shared_examples 'time conversion' do |t| 7 | it "truncates the time to the floor minute and returns an RFC3339 timestamp" do 8 | expect(described_class.utc_truncate_minutes(t)) 9 | .to eq('2018-01-01T00:00:00+00:00') 10 | end 11 | 12 | it "converts time with zone to UTC" do 13 | expect(described_class.utc_truncate_minutes(time_with_zone)) 14 | .to eq('2018-01-01T05:00:00+00:00') 15 | end 16 | end 17 | 18 | context "when the time argument is a Time object" do 19 | include_examples 'time conversion', time 20 | end 21 | 22 | context "when the time argument is a Float" do 23 | include_examples 'time conversion', time.to_f 24 | end 25 | 26 | context "when the time argument is an Integer" do 27 | include_examples 'time conversion', time.to_i 28 | end 29 | end 30 | end 31 | -------------------------------------------------------------------------------- /lib/airbrake-ruby/ignorable.rb: -------------------------------------------------------------------------------- 1 | module Airbrake 2 | # Ignorable contains methods that allow the includee to be ignored. 3 | # 4 | # @example 5 | # class A 6 | # include Airbrake::Ignorable 7 | # end 8 | # 9 | # a = A.new 10 | # a.ignore! 11 | # a.ignored? #=> true 12 | # 13 | # @since v3.2.0 14 | # @api private 15 | module Ignorable 16 | attr_accessor :ignored 17 | 18 | # Checks whether the instance was ignored. 19 | # @return [Boolean] 20 | # @see #ignore! 21 | def ignored? 22 | !!ignored 23 | end 24 | 25 | # Ignores an instance. Ignored instances must never reach the Airbrake 26 | # dashboard. 27 | # @return [void] 28 | # @see #ignored? 29 | def ignore! 30 | self.ignored = true 31 | end 32 | 33 | private 34 | 35 | # A method that is meant to be used as a guard. 36 | # @raise [Airbrake::Error] when instance is ignored 37 | def raise_if_ignored 38 | return unless ignored? 39 | 40 | raise Airbrake::Error, "cannot access ignored #{self.class}" 41 | end 42 | end 43 | end 44 | -------------------------------------------------------------------------------- /benchmarks/performance.rb: -------------------------------------------------------------------------------- 1 | require_relative 'benchmark_helpers' 2 | 3 | Airbrake.configure do |c| 4 | c.project_id = 1 5 | c.project_key = '123' 6 | c.performance_stats = true 7 | c.performance_stats_flush_period = 0 8 | c.host = 'http://localhost:8080' 9 | end 10 | 11 | query = { 12 | method: 'GET', 13 | route: '/things/1', 14 | query: 'SELECT * FROM foos', 15 | func: 'foo', 16 | file: 'foo.rb', 17 | line: 123, 18 | timing: 200, 19 | } 20 | 21 | request = { 22 | method: 'GET', 23 | route: '/things/1', 24 | status_code: 200, 25 | timing: 200, 26 | } 27 | 28 | breakdown = { 29 | method: 'GET', 30 | route: '/things/1', 31 | response_type: 'json', 32 | groups: { db: 24.0, view: 0.4 }, 33 | timing: 200, 34 | } 35 | 36 | Benchmark.ips do |ips| 37 | ips.report('Airbrake.notify_query') do 38 | Airbrake.notify_query(query) 39 | end 40 | 41 | ips.report('Airbrake.notify_request') do 42 | Airbrake.notify_request(request) 43 | end 44 | 45 | ips.report('Airbrake.notify_performance_breakdown') do 46 | Airbrake.notify_performance_breakdown(breakdown) 47 | end 48 | end 49 | -------------------------------------------------------------------------------- /lib/airbrake-ruby/request.rb: -------------------------------------------------------------------------------- 1 | module Airbrake 2 | # Request holds request data that powers route stats. 3 | # 4 | # @see Airbrake.notify_request 5 | # @api public 6 | # @since v3.2.0 7 | class Request 8 | include HashKeyable 9 | include Ignorable 10 | include Stashable 11 | include Mergeable 12 | include Grouppable 13 | 14 | attr_accessor :method, :route, :status_code, :timing, :time 15 | 16 | def initialize( 17 | method:, 18 | route:, 19 | status_code:, 20 | timing: nil, 21 | time: Time.now 22 | ) 23 | @time_utc = TimeTruncate.utc_truncate_minutes(time) 24 | @method = method 25 | @route = route 26 | @status_code = status_code 27 | @timing = timing 28 | @time = time 29 | end 30 | 31 | def destination 32 | 'routes-stats' 33 | end 34 | 35 | def cargo 36 | 'routes' 37 | end 38 | 39 | def to_h 40 | { 41 | 'method' => method, 42 | 'route' => route, 43 | 'statusCode' => status_code, 44 | 'time' => @time_utc, 45 | }.delete_if { |_key, val| val.nil? } 46 | end 47 | end 48 | end 49 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | The MIT License 2 | =============== 3 | 4 | Copyright © 2022 Airbrake Technologies, Inc. 5 | 6 | Permission is hereby granted, free of charge, to any person obtaining a copy of 7 | this software and associated documentation files (the 'Software'), to deal in 8 | the Software without restriction, including without limitation the rights to 9 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of 10 | the Software, and to permit persons to whom the Software is furnished to do so, 11 | subject to the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be included in all 14 | copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS 18 | FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 19 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER 20 | IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 21 | CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /spec/filters/context_filter_spec.rb: -------------------------------------------------------------------------------- 1 | RSpec.describe Airbrake::Filters::ContextFilter do 2 | let(:notice) { Airbrake::Notice.new(AirbrakeTestError.new) } 3 | 4 | context "when the current context is empty" do 5 | it "doesn't merge anything with params" do 6 | described_class.new.call(notice) 7 | expect(notice[:params]).to be_empty 8 | end 9 | end 10 | 11 | context "when the current context has some data" do 12 | it "merges the data with params" do 13 | Airbrake.merge_context(apples: 'oranges') 14 | described_class.new.call(notice) 15 | expect(notice[:params]).to eq(airbrake_context: { apples: 'oranges' }) 16 | end 17 | 18 | it "clears the data from the current context" do 19 | context = { apples: 'oranges' } 20 | Airbrake.merge_context(context) 21 | described_class.new.call(notice) 22 | expect(Airbrake::Context.current).to be_empty 23 | end 24 | 25 | it "does not mutate the provided context object" do 26 | context = { apples: 'oranges' } 27 | Airbrake.merge_context(context) 28 | described_class.new.call(notice) 29 | expect(context).to match(apples: 'oranges') 30 | end 31 | end 32 | end 33 | -------------------------------------------------------------------------------- /spec/inspectable_spec.rb: -------------------------------------------------------------------------------- 1 | RSpec.describe Airbrake::Inspectable do 2 | let(:klass) do 3 | mod = subject 4 | Class.new do 5 | include(mod) 6 | 7 | def initialize 8 | @config = Airbrake::Config.new 9 | @filter_chain = nil 10 | end 11 | end 12 | end 13 | 14 | describe "#inspect" do 15 | it "displays object information" do 16 | instance = klass.new 17 | expect(instance.inspect).to match(/ 18 | #<:0x\w+\s 19 | project_id=""\s 20 | project_key=""\s 21 | host="http.+"\s 22 | filter_chain=nil> 23 | /x) 24 | end 25 | end 26 | 27 | describe "#pretty_print" do 28 | it "displays object information in a beautiful way" do 29 | q = PP.new 30 | 31 | instance = klass.new 32 | # Guarding is needed to fix JRuby failure: 33 | # NoMethodError: undefined method `[]' for nil:NilClass 34 | q.guard_inspect_key { instance.pretty_print(q) } 35 | 36 | expect(q.output).to match(/ 37 | #<:0x\w+\s 38 | project_id=""\s 39 | project_key=""\s 40 | host="http.+"\s 41 | filter_chain=nil 42 | /x) 43 | end 44 | end 45 | end 46 | -------------------------------------------------------------------------------- /lib/airbrake-ruby/context.rb: -------------------------------------------------------------------------------- 1 | module Airbrake 2 | # Represents a thread-safe Airbrake context object, which carries arbitrary 3 | # information added via {Airbrake.merge_context} calls. 4 | # 5 | # @example 6 | # Airbrake::Context.current.merge!(foo: 'bar') 7 | # 8 | # @api private 9 | # @since v5.2.1 10 | class Context 11 | # Returns current, thread-local, context. 12 | # @return [self] 13 | def self.current 14 | Thread.current[:airbrake_context] ||= new 15 | end 16 | 17 | def initialize 18 | @mutex = Mutex.new 19 | @context = {} 20 | end 21 | 22 | # Merges the given context with the current one. 23 | # 24 | # @param [Hash{Object=>Object}] other 25 | # @return [void] 26 | def merge!(other) 27 | @mutex.synchronize do 28 | @context.merge!(other) 29 | end 30 | end 31 | 32 | # @return [Hash] duplicated Hash context 33 | def to_h 34 | @mutex.synchronize do 35 | @context.dup 36 | end 37 | end 38 | 39 | # @return [Hash] clears (resets) the current context 40 | def clear 41 | @mutex.synchronize do 42 | @context.clear 43 | end 44 | end 45 | 46 | # @return [Boolean] checks whether the context has any data 47 | def empty? 48 | @context.empty? 49 | end 50 | end 51 | end 52 | -------------------------------------------------------------------------------- /lib/airbrake-ruby/file_cache.rb: -------------------------------------------------------------------------------- 1 | module Airbrake 2 | # Extremely simple global cache. 3 | # 4 | # @api private 5 | # @since v2.4.1 6 | module FileCache 7 | # @return [Integer] 8 | MAX_SIZE = 50 9 | 10 | # @return [Mutex] 11 | MUTEX = Mutex.new 12 | 13 | # Associates the value given by +value+ with the key given by +key+. Deletes 14 | # entries that exceed +MAX_SIZE+. 15 | # 16 | # @param [Object] key 17 | # @param [Object] value 18 | # @return [Object] the corresponding value 19 | def self.[]=(key, value) 20 | MUTEX.synchronize do 21 | data[key] = value 22 | data.delete(data.keys.first) if data.size > MAX_SIZE 23 | end 24 | end 25 | 26 | # Retrieve an object from the cache. 27 | # 28 | # @param [Object] key 29 | # @return [Object] the corresponding value 30 | def self.[](key) 31 | MUTEX.synchronize do 32 | data[key] 33 | end 34 | end 35 | 36 | # Checks whether the cache is empty. Needed only for the test suite. 37 | # 38 | # @return [Boolean] 39 | def self.empty? 40 | data.empty? 41 | end 42 | 43 | # @since v4.7.0 44 | # @return [void] 45 | def self.reset 46 | @data = {} 47 | end 48 | 49 | def self.data 50 | @data ||= {} 51 | end 52 | private_class_method :data 53 | end 54 | end 55 | -------------------------------------------------------------------------------- /lib/airbrake-ruby/monotonic_time.rb: -------------------------------------------------------------------------------- 1 | module Airbrake 2 | # MonotonicTime is a helper for getting monotonic time suitable for 3 | # performance measurements. It guarantees that the time is strictly linearly 4 | # increasing (unlike realtime). 5 | # 6 | # @example 7 | # MonotonicTime.time_in_ms #=> 287138801.144576 8 | # 9 | # @see http://pubs.opengroup.org/onlinepubs/9699919799/functions/clock_getres.html 10 | # @since v4.2.4 11 | # @api private 12 | module MonotonicTime 13 | class << self 14 | # @return [Integer] current monotonic time in milliseconds 15 | def time_in_ms 16 | time_in_nanoseconds / (10.0**6) 17 | end 18 | 19 | # @return [Integer] current monotonic time in seconds 20 | def time_in_s 21 | time_in_nanoseconds / (10.0**9) 22 | end 23 | 24 | private 25 | 26 | if defined?(Process::CLOCK_MONOTONIC) 27 | 28 | def time_in_nanoseconds 29 | Process.clock_gettime(Process::CLOCK_MONOTONIC, :nanosecond) 30 | end 31 | 32 | elsif RUBY_ENGINE == 'jruby' 33 | 34 | def time_in_nanoseconds 35 | java.lang.System.nanoTime 36 | end 37 | 38 | else 39 | 40 | def time_in_nanoseconds 41 | time = Time.now 42 | (time.to_i * (10**9)) + time.nsec 43 | end 44 | 45 | end 46 | end 47 | end 48 | end 49 | -------------------------------------------------------------------------------- /lib/airbrake-ruby/filters/exception_attributes_filter.rb: -------------------------------------------------------------------------------- 1 | module Airbrake 2 | module Filters 3 | # ExceptionAttributesFilter attempts to call `#to_airbrake` on the stashed 4 | # exception and attaches returned data to the notice object. 5 | # 6 | # @api private 7 | # @since v2.10.0 8 | class ExceptionAttributesFilter 9 | include Loggable 10 | 11 | def initialize 12 | @weight = 118 13 | end 14 | 15 | # @macro call_filter 16 | def call(notice) # rubocop:disable Metrics/AbcSize 17 | exception = notice.stash[:exception] 18 | return unless exception.respond_to?(:to_airbrake) 19 | 20 | attributes = nil 21 | begin 22 | attributes = exception.to_airbrake 23 | rescue StandardError => ex 24 | logger.error( 25 | "#{LOG_LABEL} #{exception.class}#to_airbrake failed. #{ex.class}: #{ex}", 26 | ) 27 | end 28 | 29 | unless attributes.is_a?(Hash) 30 | logger.error( 31 | "#{LOG_LABEL} #{self.class}: wanted Hash, got #{attributes.class}", 32 | ) 33 | return 34 | end 35 | 36 | attributes.each do |key, attrs| 37 | if notice[key] 38 | notice[key].merge!(attrs) 39 | else 40 | notice[key] = attrs 41 | end 42 | end 43 | end 44 | end 45 | end 46 | end 47 | -------------------------------------------------------------------------------- /lib/airbrake-ruby/performance_breakdown.rb: -------------------------------------------------------------------------------- 1 | module Airbrake 2 | # PerformanceBreakdown holds data that shows how much time a request spent 3 | # doing certaing subtasks such as (DB querying, view rendering, etc). 4 | # 5 | # @see Airbrake.notify_breakdown 6 | # @api public 7 | # @since v4.2.0 8 | # rubocop:disable Metrics/ParameterLists 9 | class PerformanceBreakdown 10 | include HashKeyable 11 | include Ignorable 12 | include Stashable 13 | include Mergeable 14 | 15 | attr_accessor :method, :route, :response_type, :groups, :timing, :time 16 | 17 | def initialize( 18 | method:, 19 | route:, 20 | response_type:, 21 | groups:, 22 | timing: nil, 23 | time: Time.now 24 | ) 25 | @time_utc = TimeTruncate.utc_truncate_minutes(time) 26 | @method = method 27 | @route = route 28 | @response_type = response_type 29 | @groups = groups 30 | @timing = timing 31 | @time = time 32 | end 33 | 34 | def destination 35 | 'routes-breakdowns' 36 | end 37 | 38 | def cargo 39 | 'routes' 40 | end 41 | 42 | def to_h 43 | { 44 | 'method' => method, 45 | 'route' => route, 46 | 'responseType' => response_type, 47 | 'time' => @time_utc, 48 | }.delete_if { |_key, val| val.nil? } 49 | end 50 | end 51 | # rubocop:enable Metrics/ParameterLists 52 | end 53 | -------------------------------------------------------------------------------- /lib/airbrake-ruby/remote_settings/callback.rb: -------------------------------------------------------------------------------- 1 | module Airbrake 2 | class RemoteSettings 3 | # Callback is a class that provides a callback for the config poller, which 4 | # updates the local config according to the data. 5 | # 6 | # @api private 7 | # @since v5.0.2 8 | class Callback 9 | def initialize(config) 10 | @config = config 11 | @orig_error_notifications = config.error_notifications 12 | @orig_performance_stats = config.performance_stats 13 | end 14 | 15 | # @param [Airbrake::RemoteSettings::SettingsData] data 16 | # @return [void] 17 | def call(data) 18 | @config.logger.debug do 19 | "#{LOG_LABEL} applying remote settings: #{data.to_h}" 20 | end 21 | 22 | @config.error_host = data.error_host if data.error_host 23 | @config.apm_host = data.apm_host if data.apm_host 24 | 25 | process_error_notifications(data) 26 | process_performance_stats(data) 27 | end 28 | 29 | private 30 | 31 | def process_error_notifications(data) 32 | return unless @orig_error_notifications 33 | 34 | @config.error_notifications = data.error_notifications? 35 | end 36 | 37 | def process_performance_stats(data) 38 | return unless @orig_performance_stats 39 | 40 | @config.performance_stats = data.performance_stats? 41 | end 42 | end 43 | end 44 | end 45 | -------------------------------------------------------------------------------- /lib/airbrake-ruby/query.rb: -------------------------------------------------------------------------------- 1 | module Airbrake 2 | # Query holds SQL query data that powers SQL query collection. 3 | # 4 | # @see Airbrake.notify_query 5 | # @api public 6 | # @since v3.2.0 7 | # rubocop:disable Metrics/ParameterLists 8 | class Query 9 | include HashKeyable 10 | include Ignorable 11 | include Stashable 12 | include Mergeable 13 | include Grouppable 14 | 15 | attr_accessor :method, :route, :query, :func, :file, :line, :timing, :time 16 | 17 | def initialize( 18 | method:, 19 | route:, 20 | query:, 21 | func: nil, 22 | file: nil, 23 | line: nil, 24 | timing: nil, 25 | time: Time.now 26 | ) 27 | @time_utc = TimeTruncate.utc_truncate_minutes(time) 28 | @method = method 29 | @route = route 30 | @query = query 31 | @func = func 32 | @file = file 33 | @line = line 34 | @timing = timing 35 | @time = time 36 | end 37 | 38 | def destination 39 | 'queries-stats' 40 | end 41 | 42 | def cargo 43 | 'queries' 44 | end 45 | 46 | def to_h 47 | { 48 | 'method' => method, 49 | 'route' => route, 50 | 'query' => query, 51 | 'time' => @time_utc, 52 | 'function' => func, 53 | 'file' => file, 54 | 'line' => line, 55 | }.delete_if { |_key, val| val.nil? } 56 | end 57 | # rubocop:enable Metrics/ParameterLists 58 | end 59 | end 60 | -------------------------------------------------------------------------------- /lib/airbrake-ruby/inspectable.rb: -------------------------------------------------------------------------------- 1 | module Airbrake 2 | # Inspectable provides custom inspect methods that reduce clutter printed in 3 | # REPLs for notifier objects. These custom methods display only essential 4 | # information such as project id/key and filters. 5 | # 6 | # @since v3.2.6 7 | # @api private 8 | module Inspectable 9 | # @return [String] inspect output template 10 | INSPECT_TEMPLATE = 11 | "#<%s:0x%s project_id=\"%s\" " \ 12 | "project_key=\"%s\" " \ 13 | "host=\"%s\" filter_chain=%s>".freeze 14 | 15 | # @return [String] customized inspect to lessen the amount of clutter 16 | def inspect 17 | format( 18 | INSPECT_TEMPLATE, 19 | classname: self.class.name, 20 | id: (object_id << 1).to_s(16).rjust(16, '0'), 21 | project_id: @config.project_id, 22 | project_key: @config.project_key, 23 | host: @config.host, 24 | filter_chain: @filter_chain.inspect, 25 | ) 26 | end 27 | 28 | # @return [String] {#inspect} for PrettyPrint 29 | def pretty_print(q) 30 | q.text("#<#{self.class}:0x#{(object_id << 1).to_s(16).rjust(16, '0')} ") 31 | q.text( 32 | "project_id=\"#{@config.project_id}\" project_key=\"#{@config.project_key}\" " \ 33 | "host=\"#{@config.host}\" filter_chain=", 34 | ) 35 | q.pp(@filter_chain) 36 | q.text('>') 37 | end 38 | end 39 | end 40 | -------------------------------------------------------------------------------- /lib/airbrake-ruby/timed_trace.rb: -------------------------------------------------------------------------------- 1 | module Airbrake 2 | # TimedTrace represents a chunk of code performance of which was measured and 3 | # stored under a label. The chunk is called a "span". 4 | # 5 | # @example 6 | # timed_trace = TimedTrace.new 7 | # timed_trace.span('http request') do 8 | # http.get('example.com') 9 | # end 10 | # timed_trace.spans #=> { 'http request' => 0.123 } 11 | # 12 | # @api public 13 | # @since v4.3.0 14 | class TimedTrace 15 | # @param [String] label 16 | # @return [Airbrake::TimedTrace] 17 | def self.span(label, &block) 18 | new.tap { |timed_trace| timed_trace.span(label, &block) } 19 | end 20 | 21 | def initialize 22 | @spans = {} 23 | end 24 | 25 | # @param [String] label 26 | # @return [Boolean] 27 | def span(label) 28 | start_span(label) 29 | yield 30 | stop_span(label) 31 | end 32 | 33 | # @param [String] label 34 | # @return [Boolean] 35 | def start_span(label) 36 | return false if @spans.key?(label) 37 | 38 | @spans[label] = Airbrake::Benchmark.new 39 | true 40 | end 41 | 42 | # @param [String] label 43 | # @return [Boolean] 44 | def stop_span(label) 45 | return false unless @spans.key?(label) 46 | 47 | @spans[label].stop 48 | true 49 | end 50 | 51 | # @return [HashFloat>] 52 | def spans 53 | @spans.transform_values(&:duration) 54 | end 55 | end 56 | end 57 | -------------------------------------------------------------------------------- /lib/airbrake-ruby/filters/keys_allowlist.rb: -------------------------------------------------------------------------------- 1 | module Airbrake 2 | module Filters 3 | # A default Airbrake notice filter. Filters everything in the payload of a 4 | # notice, but specified keys. 5 | # 6 | # @example 7 | # filter = Airbrake::Filters::KeysAllowlist.new( 8 | # [:email, /credit/i, 'password'] 9 | # ) 10 | # airbrake.add_filter(filter) 11 | # airbrake.notify(StandardError.new('App crashed!'), { 12 | # user: 'John', 13 | # password: 's3kr3t', 14 | # email: 'john@example.com', 15 | # account_id: 42 16 | # }) 17 | # 18 | # # The dashboard will display this parameters as filtered, but other 19 | # # values won't be affected: 20 | # # { user: 'John', 21 | # # password: '[Filtered]', 22 | # # email: 'john@example.com', 23 | # # account_id: 42 } 24 | # 25 | # @see KeysBlocklist 26 | # @see KeysFilter 27 | class KeysAllowlist 28 | include KeysFilter 29 | 30 | def initialize(*) 31 | super 32 | @weight = -100 33 | end 34 | 35 | # @return [Boolean] true if the key doesn't match any pattern, false 36 | # otherwise. 37 | def should_filter?(key) 38 | @patterns.none? do |pattern| 39 | if pattern.is_a?(Regexp) 40 | key.match(pattern) 41 | else 42 | key.to_s == pattern.to_s 43 | end 44 | end 45 | end 46 | end 47 | end 48 | end 49 | -------------------------------------------------------------------------------- /benchmarks/notify_async_workers.rb: -------------------------------------------------------------------------------- 1 | require_relative 'benchmark_helpers' 2 | 3 | # The number of notices to process. 4 | NOTICES = 1200 5 | 6 | cores = 7 | case RbConfig::CONFIG['host_os'] 8 | when /linux/ 9 | Dir.glob('/sys/devices/system/cpu/cpu[0-9]*').count 10 | when /darwin|bsd/ 11 | Integer(`sysctl -n hw.ncpu`) 12 | else 13 | 2 14 | end 15 | 16 | double_cores = 2 * cores 17 | 18 | config_hash = { 19 | project_id: 112261, 20 | project_key: 'c7aaceb2ccb579e6b710cea9da22c526', 21 | logger: Logger.new('/dev/null'), 22 | host: 'http://localhost:8080', 23 | } 24 | 25 | Airbrake.configure(:workers_1) do |c| 26 | c.merge(config_hash.merge(workers: 1)) 27 | end 28 | 29 | Airbrake.configure(:"workers_#{cores}") do |c| 30 | c.merge(config_hash.merge(workers: cores)) 31 | end 32 | 33 | Airbrake.configure(:"workers_#{double_cores}") do |c| 34 | c.merge(config_hash.merge(workers: double_cores)) 35 | end 36 | 37 | def notify_via(notifier) 38 | NOTICES.times do 39 | Airbrake.notify(BIG_EXCEPTION, {}, notifier) 40 | end 41 | 42 | Airbrake.close(notifier) 43 | end 44 | 45 | # Don't forget to run the server: go run benchmarks/server.go 46 | Benchmark.bm do |bm| 47 | bm.report("1 worker Airbrake.notify") do 48 | notify_via(:workers_1) 49 | end 50 | 51 | bm.report("#{cores} workers Airbrake.notify") do 52 | notify_via(:"workers_#{cores}") 53 | end 54 | 55 | bm.report("#{double_cores} workers Airbrake.notify") do 56 | notify_via(:"workers_#{double_cores}") 57 | end 58 | end 59 | -------------------------------------------------------------------------------- /lib/airbrake-ruby/code_hunk.rb: -------------------------------------------------------------------------------- 1 | module Airbrake 2 | # Represents a small hunk of code consisting of a base line and a couple lines 3 | # around it 4 | # @api private 5 | class CodeHunk 6 | # @return [Integer] the maximum length of a line 7 | MAX_LINE_LEN = 200 8 | 9 | # @return [Integer] how many lines should be read around the base line 10 | NLINES = 2 11 | 12 | include Loggable 13 | 14 | # @param [String] file The file to read 15 | # @param [Integer] line The base line in the file 16 | # @return [Hash{Integer=>String}, nil] lines of code around the base line 17 | def get(file, line) 18 | return unless File.exist?(file) 19 | return unless line 20 | 21 | lines = get_lines(file, [line - NLINES, 1].max, line + NLINES) || {} 22 | return { 1 => '' } if lines.empty? 23 | 24 | lines 25 | end 26 | 27 | private 28 | 29 | def get_from_cache(file) 30 | Airbrake::FileCache[file] ||= File.foreach(file) 31 | rescue StandardError => ex 32 | logger.error( 33 | "#{self.class.name}: can't read code hunk for #{file}: #{ex}", 34 | ) 35 | nil 36 | end 37 | 38 | def get_lines(file, start_line, end_line) 39 | return unless (cached_file = get_from_cache(file)) 40 | 41 | lines = {} 42 | cached_file.with_index(1) do |l, i| 43 | next if i < start_line 44 | break if i > end_line 45 | 46 | lines[i] = l[0...MAX_LINE_LEN].rstrip 47 | end 48 | lines 49 | end 50 | end 51 | end 52 | -------------------------------------------------------------------------------- /lib/airbrake-ruby/queue.rb: -------------------------------------------------------------------------------- 1 | module Airbrake 2 | # Queue represents a queue (worker). 3 | # 4 | # @see Airbrake.notify_queue 5 | # @api public 6 | # @since v4.9.0 7 | class Queue 8 | include HashKeyable 9 | include Ignorable 10 | include Stashable 11 | 12 | attr_accessor :queue, :error_count, :groups, :timing, :time 13 | 14 | def initialize( 15 | queue:, 16 | error_count:, 17 | groups: {}, 18 | timing: nil, 19 | time: Time.now 20 | ) 21 | @time_utc = TimeTruncate.utc_truncate_minutes(time) 22 | @queue = queue 23 | @error_count = error_count 24 | @groups = groups 25 | @timing = timing 26 | @time = time 27 | end 28 | 29 | def destination 30 | 'queues-stats' 31 | end 32 | 33 | def cargo 34 | 'queues' 35 | end 36 | 37 | def to_h 38 | { 39 | 'queue' => queue, 40 | 'errorCount' => error_count, 41 | 'time' => @time_utc, 42 | } 43 | end 44 | 45 | def hash 46 | { 47 | 'queue' => queue, 48 | 'time' => @time_utc, 49 | }.hash 50 | end 51 | 52 | def merge(other) 53 | self.error_count += other.error_count 54 | end 55 | 56 | # Queues don't have routes, but we want to define this to make sure our 57 | # filter API is consistent (other models define this property) 58 | # 59 | # @return [String] empty route 60 | # @see https://github.com/airbrake/airbrake-ruby/pull/537 61 | def route 62 | '' 63 | end 64 | end 65 | end 66 | -------------------------------------------------------------------------------- /lib/airbrake-ruby/filters/keys_blocklist.rb: -------------------------------------------------------------------------------- 1 | module Airbrake 2 | module Filters 3 | # A default Airbrake notice filter. Filters only specific keys listed in the 4 | # list of parameters in the payload of a notice. 5 | # 6 | # @example 7 | # filter = Airbrake::Filters::KeysBlocklist.new( 8 | # [:email, /credit/i, 'password'] 9 | # ) 10 | # airbrake.add_filter(filter) 11 | # airbrake.notify(StandardError.new('App crashed!'), { 12 | # user: 'John' 13 | # password: 's3kr3t', 14 | # email: 'john@example.com', 15 | # credit_card: '5555555555554444' 16 | # }) 17 | # 18 | # # The dashboard will display this parameter as is, but all other 19 | # # values will be filtered: 20 | # # { user: 'John', 21 | # # password: '[Filtered]', 22 | # # email: '[Filtered]', 23 | # # credit_card: '[Filtered]' } 24 | # 25 | # @see KeysAllowlist 26 | # @see KeysFilter 27 | # @api private 28 | class KeysBlocklist 29 | include KeysFilter 30 | 31 | def initialize(*) 32 | super 33 | @weight = -110 34 | end 35 | 36 | # @return [Boolean] true if the key matches at least one pattern, false 37 | # otherwise 38 | def should_filter?(key) 39 | @patterns.any? do |pattern| 40 | if pattern.is_a?(Regexp) 41 | key.match(pattern) 42 | else 43 | key.to_s == pattern.to_s 44 | end 45 | end 46 | end 47 | end 48 | end 49 | end 50 | -------------------------------------------------------------------------------- /spec/filters/exception_attributes_filter_spec.rb: -------------------------------------------------------------------------------- 1 | RSpec.describe Airbrake::Filters::ExceptionAttributesFilter do 2 | subject(:exception_attributes_filter) { described_class.new } 3 | 4 | describe "#call" do 5 | let(:notice) { Airbrake::Notice.new(ex) } 6 | 7 | context "when #to_airbrake returns a non-Hash object" do 8 | let(:ex) do 9 | Class.new(AirbrakeTestError) do 10 | def to_airbrake 11 | Object.new 12 | end 13 | end.new 14 | end 15 | 16 | it "doesn't raise" do 17 | expect { exception_attributes_filter.call(notice) }.not_to raise_error 18 | expect(notice[:params]).to be_empty 19 | end 20 | end 21 | 22 | context "when #to_airbrake errors out" do 23 | let(:ex) do 24 | Class.new(AirbrakeTestError) do 25 | def to_airbrake 26 | 1 / 0 27 | end 28 | end.new 29 | end 30 | 31 | it "doesn't raise" do 32 | expect { exception_attributes_filter.call(notice) }.not_to raise_error 33 | expect(notice[:params]).to be_empty 34 | end 35 | end 36 | 37 | context "when #to_airbrake returns a hash" do 38 | let(:ex) do 39 | Class.new(AirbrakeTestError) do 40 | def to_airbrake 41 | { params: { foo: '1' } } 42 | end 43 | end.new 44 | end 45 | 46 | it "merges parameters with the notice" do 47 | exception_attributes_filter.call(notice) 48 | expect(notice[:params]).to eq(foo: '1') 49 | end 50 | end 51 | end 52 | end 53 | -------------------------------------------------------------------------------- /spec/context_spec.rb: -------------------------------------------------------------------------------- 1 | RSpec.describe Airbrake::Context do 2 | subject(:context) { described_class.current } 3 | 4 | before { described_class.current.clear } 5 | 6 | after { described_class.current.clear } 7 | 8 | describe "#merge!" do 9 | it "merges the given context with the current one" do 10 | context.merge!(apples: 'oranges') 11 | expect(context.to_h).to match(apples: 'oranges') 12 | end 13 | end 14 | 15 | describe "#clear" do 16 | it "clears the context" do 17 | context.merge!(apples: 'oranges') 18 | context.clear 19 | expect(context.to_h).to be_empty 20 | end 21 | end 22 | 23 | describe "#to_h" do 24 | it "returns a hash representation of the context" do 25 | expect(context.to_h).to be_a(Hash) 26 | end 27 | end 28 | 29 | describe "#empty?" do 30 | context "when the context has data" do 31 | it "returns true" do 32 | context.merge!(apples: 'oranges') 33 | expect(context).not_to be_empty 34 | end 35 | end 36 | 37 | context "when the context has NO data" do 38 | it "returns false" do 39 | expect(context).to be_empty 40 | end 41 | end 42 | end 43 | 44 | context "when another thread is spawned" do 45 | it "doesn't clash with other threads' contexts" do 46 | described_class.current.merge!(apples: 'oranges') 47 | th = Thread.new do 48 | described_class.current.merge!(foos: 'bars') 49 | end 50 | th.join 51 | expect(described_class.current.to_h).to match(apples: 'oranges') 52 | end 53 | end 54 | end 55 | -------------------------------------------------------------------------------- /lib/airbrake-ruby/async_sender.rb: -------------------------------------------------------------------------------- 1 | module Airbrake 2 | # Responsible for sending notices to Airbrake asynchronously. 3 | # 4 | # @see SyncSender 5 | # @api private 6 | # @since v1.0.0 7 | class AsyncSender 8 | def initialize(method = :post, name = 'async-sender') 9 | @config = Airbrake::Config.instance 10 | @sync_sender = SyncSender.new(method) 11 | @name = name 12 | end 13 | 14 | # Asynchronously sends a notice to Airbrake. 15 | # 16 | # @param [Airbrake::Notice] data Whatever needs to be sent 17 | # @param [Airbrake::Promise] promise 18 | # @param [URI] endpoint Where to send +data+ 19 | # @return [Airbrake::Promise] 20 | def send(data, promise, endpoint = @config.error_endpoint) 21 | unless thread_pool << [data, promise, endpoint] 22 | return promise.reject( 23 | "AsyncSender has reached its capacity of #{@config.queue_size}", 24 | ) 25 | end 26 | 27 | promise 28 | end 29 | 30 | # @return [void] 31 | def close 32 | @sync_sender.close 33 | thread_pool.close 34 | end 35 | 36 | # @return [Boolean] 37 | def closed? 38 | thread_pool.closed? 39 | end 40 | 41 | # @return [Boolean] 42 | def has_workers? 43 | thread_pool.has_workers? 44 | end 45 | 46 | private 47 | 48 | def thread_pool 49 | @thread_pool ||= ThreadPool.new( 50 | name: @name, 51 | worker_size: @config.workers, 52 | queue_size: @config.queue_size, 53 | block: proc { |args| @sync_sender.send(*args) }, 54 | ) 55 | end 56 | end 57 | end 58 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | require 'rspec/core/rake_task' 2 | require 'rubygems/package_task' 3 | 4 | RSpec::Core::RakeTask.new(:spec) 5 | task default: :spec 6 | 7 | # rubocop:disable Security/Eval 8 | def modify_base_gemspec(&block) 9 | eval(File.read('airbrake-ruby.gemspec')).tap(&block) 10 | end 11 | # rubocop:enable Security/Eval 12 | 13 | namespace :ruby do 14 | spec = modify_base_gemspec do |s| 15 | # We keep this dependency in Gemfile, so we can run CI builds. When we 16 | # generate gems, duplicate dependencies are not allowed. 17 | s.dependencies.delete_if { |d| d.name == 'rbtree-jruby' || 'rbtree3' } 18 | 19 | s.platform = Gem::Platform::RUBY 20 | s.add_dependency('rbtree3', '~> 0.5') 21 | end 22 | 23 | Gem::PackageTask.new(spec) do |pkg| 24 | pkg.need_zip = false 25 | pkg.need_tar = false 26 | end 27 | end 28 | 29 | namespace :jruby do 30 | spec = modify_base_gemspec do |s| 31 | # We keep this dependency in Gemfile, so we can run CI builds. When we 32 | # generate gems, duplicate dependencies are not allowed. 33 | s.dependencies.delete_if { |d| d.name == 'rbtree-jruby' || 'rbtree3' } 34 | 35 | s.platform = 'java' 36 | s.add_dependency('rbtree-jruby', '~> 0.2') 37 | end 38 | 39 | Gem::PackageTask.new(spec) do |pkg| 40 | pkg.need_zip = false 41 | pkg.need_tar = false 42 | end 43 | end 44 | 45 | desc 'Build all platform gems at once' 46 | task gems: %w[ruby:clobber_package ruby:gem jruby:gem] 47 | 48 | desc 'Build and push platform gems' 49 | task pushgems: :gems do 50 | chdir("#{File.dirname(__FILE__)}/pkg") do 51 | Dir['*.gem'].each { |gem| sh "gem push #{gem}" } 52 | end 53 | end 54 | -------------------------------------------------------------------------------- /spec/filters/root_directory_filter_spec.rb: -------------------------------------------------------------------------------- 1 | RSpec.describe Airbrake::Filters::RootDirectoryFilter do 2 | subject(:root_directory_filter) { described_class.new(root_directory) } 3 | 4 | let(:root_directory) { '/var/www/project' } 5 | let(:notice) { Airbrake::Notice.new(AirbrakeTestError.new) } 6 | 7 | it "replaces root directory in the backtrace with a label" do 8 | # rubocop:disable Layout/LineLength 9 | notice[:errors].first[:backtrace] = [ 10 | { file: "/home/kyrylo/code/airbrake/ruby/spec/spec_helper.rb" }, 11 | { file: "#{root_directory}/gems/rspec-core-3.3.2/lib/rspec/core/configuration.rb " }, 12 | { file: "/opt/rubies/ruby-2.2.2/lib/ruby/2.2.0/rubygems/core_ext/kernel_require.rb" }, 13 | { file: "#{root_directory}/gems/rspec-core-3.3.2/exe/rspec" }, 14 | ] 15 | # rubocop:enable Layout/LineLength 16 | 17 | root_directory_filter.call(notice) 18 | 19 | # rubocop:disable Layout/LineLength 20 | expect(notice[:errors].first[:backtrace]).to( 21 | eq( 22 | [ 23 | { file: "/home/kyrylo/code/airbrake/ruby/spec/spec_helper.rb" }, 24 | { file: "/PROJECT_ROOT/gems/rspec-core-3.3.2/lib/rspec/core/configuration.rb " }, 25 | { file: "/opt/rubies/ruby-2.2.2/lib/ruby/2.2.0/rubygems/core_ext/kernel_require.rb" }, 26 | { file: "/PROJECT_ROOT/gems/rspec-core-3.3.2/exe/rspec" }, 27 | ], 28 | ), 29 | ) 30 | # rubocop:enable Layout/LineLength 31 | end 32 | 33 | it "does not filter file when it is nil" do 34 | expect(notice[:errors].first[:file]).to be_nil 35 | expect { root_directory_filter.call(notice) }.not_to( 36 | change { notice[:errors].first[:file] }, 37 | ) 38 | end 39 | end 40 | -------------------------------------------------------------------------------- /spec/filters/gem_root_filter_spec.rb: -------------------------------------------------------------------------------- 1 | RSpec.describe Airbrake::Filters::GemRootFilter do 2 | subject(:gem_root_filter) { described_class.new } 3 | 4 | let(:notice) { Airbrake::Notice.new(AirbrakeTestError.new) } 5 | let(:root1) { '/my/gem/root' } 6 | let(:root2) { '/my/other/gem/root' } 7 | 8 | before { Gem.path << root1 << root2 } 9 | 10 | after { 2.times { Gem.path.pop } } 11 | 12 | it "replaces gem root in the backtrace with a label" do 13 | # rubocop:disable Layout/LineLength 14 | notice[:errors].first[:backtrace] = [ 15 | { file: "/home/kyrylo/code/airbrake/ruby/spec/spec_helper.rb" }, 16 | { file: "#{root1}/gems/rspec-core-3.3.2/lib/rspec/core/configuration.rb" }, 17 | { file: "/opt/rubies/ruby-2.2.2/lib/ruby/2.2.0/rubygems/core_ext/kernel_require.rb" }, 18 | { file: "#{root2}/gems/rspec-core-3.3.2/exe/rspec" }, 19 | ] 20 | # rubocop:enable Layout/LineLength 21 | 22 | gem_root_filter.call(notice) 23 | 24 | # rubocop:disable Layout/LineLength 25 | expect(notice[:errors].first[:backtrace]).to( 26 | eq( 27 | [ 28 | { file: "/home/kyrylo/code/airbrake/ruby/spec/spec_helper.rb" }, 29 | { file: "/GEM_ROOT/gems/rspec-core-3.3.2/lib/rspec/core/configuration.rb" }, 30 | { file: "/opt/rubies/ruby-2.2.2/lib/ruby/2.2.0/rubygems/core_ext/kernel_require.rb" }, 31 | { file: "/GEM_ROOT/gems/rspec-core-3.3.2/exe/rspec" }, 32 | ], 33 | ), 34 | ) 35 | # rubocop:enable Layout/LineLength 36 | end 37 | 38 | it "does not filter file when it is nil" do 39 | expect(notice[:errors].first[:file]).to be_nil 40 | expect { gem_root_filter.call(notice) }.not_to( 41 | change { notice[:errors].first[:file] }, 42 | ) 43 | end 44 | end 45 | -------------------------------------------------------------------------------- /airbrake-ruby.gemspec: -------------------------------------------------------------------------------- 1 | require './lib/airbrake-ruby/version' 2 | 3 | Gem::Specification.new do |s| 4 | s.name = 'airbrake-ruby' 5 | s.version = Airbrake::AIRBRAKE_RUBY_VERSION.dup 6 | s.summary = 'Ruby notifier for https://airbrake.io' 7 | s.description = < 'true', 28 | } 29 | 30 | if defined?(JRuby) 31 | s.add_dependency 'rbtree-jruby', '~> 0.2' 32 | else 33 | s.add_dependency 'rbtree3', '~> 0.6' 34 | end 35 | 36 | s.add_development_dependency 'rspec', '~> 3' 37 | s.add_development_dependency 'rspec-its', '~> 1.2' 38 | s.add_development_dependency 'rake', '~> 13' 39 | s.add_development_dependency 'pry', '~> 0' 40 | s.add_development_dependency 'webmock', '~> 3.8' 41 | s.add_development_dependency 'benchmark-ips', '~> 2' 42 | s.add_development_dependency 'yard', '~> 0.9' 43 | end 44 | -------------------------------------------------------------------------------- /spec/deploy_notifier_spec.rb: -------------------------------------------------------------------------------- 1 | RSpec.describe Airbrake::DeployNotifier do 2 | before do 3 | Airbrake::Config.instance = Airbrake::Config.new(project_id: 1, project_key: '123') 4 | end 5 | 6 | describe "#notify" do 7 | subject(:deploy_notifier) { described_class.new } 8 | 9 | it "returns a promise" do 10 | stub_request(:post, 'https://api.airbrake.io/api/v4/projects/1/deploys') 11 | .to_return(status: 201, body: '{}') 12 | expect(deploy_notifier.notify({})).to be_an(Airbrake::Promise) 13 | end 14 | 15 | context "when config is invalid" do 16 | before { Airbrake::Config.instance.merge(project_id: nil) } 17 | 18 | it "returns a rejected promise" do 19 | promise = deploy_notifier.notify({}) 20 | expect(promise).to be_rejected 21 | end 22 | end 23 | 24 | context "when environment is configured" do 25 | before { Airbrake::Config.instance.merge(environment: 'fooenv') } 26 | 27 | it "prefers the passed environment to the config env" do 28 | expect_any_instance_of(Airbrake::SyncSender).to receive(:send).with( 29 | { environment: 'barenv' }, 30 | instance_of(Airbrake::Promise), 31 | URI('https://api.airbrake.io/api/v4/projects/1/deploys'), 32 | ) 33 | deploy_notifier.notify(environment: 'barenv') 34 | end 35 | end 36 | 37 | context "when environment is not configured" do 38 | before { Airbrake::Config.instance.merge(environment: 'fooenv') } 39 | 40 | it "sets the environment from the config" do 41 | expect_any_instance_of(Airbrake::SyncSender).to receive(:send).with( 42 | { environment: 'fooenv' }, 43 | instance_of(Airbrake::Promise), 44 | URI('https://api.airbrake.io/api/v4/projects/1/deploys'), 45 | ) 46 | deploy_notifier.notify({}) 47 | end 48 | end 49 | end 50 | end 51 | -------------------------------------------------------------------------------- /lib/airbrake-ruby/filters/git_revision_filter.rb: -------------------------------------------------------------------------------- 1 | module Airbrake 2 | module Filters 3 | # Attaches current git revision to `context`. 4 | # @api private 5 | # @since v2.11.0 6 | class GitRevisionFilter 7 | # @return [Integer] 8 | attr_reader :weight 9 | 10 | # @return [String] 11 | PREFIX = 'ref: '.freeze 12 | 13 | # @param [String] root_directory 14 | def initialize(root_directory) 15 | @git_path = File.join(root_directory, '.git') 16 | @revision = nil 17 | @weight = 116 18 | end 19 | 20 | # @macro call_filter 21 | def call(notice) 22 | return if notice[:context].key?(:revision) 23 | 24 | if @revision 25 | notice[:context][:revision] = @revision 26 | return 27 | end 28 | 29 | return unless File.exist?(@git_path) 30 | 31 | @revision = find_revision 32 | return unless @revision 33 | 34 | notice[:context][:revision] = @revision 35 | end 36 | 37 | private 38 | 39 | def find_revision 40 | head_path = File.join(@git_path, 'HEAD') 41 | return unless File.exist?(head_path) 42 | 43 | head = File.read(head_path) 44 | return head unless head.start_with?(PREFIX) 45 | 46 | head = head.chomp[PREFIX.size..-1] 47 | 48 | ref_path = File.join(@git_path, head) 49 | return File.read(ref_path).chomp if File.exist?(ref_path) 50 | 51 | find_from_packed_refs(head) 52 | end 53 | 54 | def find_from_packed_refs(head) 55 | packed_refs_path = File.join(@git_path, 'packed-refs') 56 | return head unless File.exist?(packed_refs_path) 57 | 58 | File.readlines(packed_refs_path).each do |line| 59 | next if %w[# ^].include?(line[0]) 60 | next unless (parts = line.split).size == 2 61 | return parts.first if parts.last == head 62 | end 63 | 64 | nil 65 | end 66 | end 67 | end 68 | end 69 | -------------------------------------------------------------------------------- /lib/airbrake-ruby/nested_exception.rb: -------------------------------------------------------------------------------- 1 | module Airbrake 2 | # A class that is capable of unwinding nested exceptions and representing them 3 | # as JSON-like hash. 4 | # 5 | # @api private 6 | # @since v1.0.4 7 | class NestedException 8 | # @return [Integer] the maximum number of nested exceptions that a notice 9 | # can unwrap. Exceptions that have a longer cause chain will be ignored 10 | MAX_NESTED_EXCEPTIONS = 3 11 | 12 | # On Ruby 3.1+, the error highlighting gem can produce messages that can 13 | # span multiple lines. We don't display multiline error messages in the 14 | # title of the notice in the Airbrake dashboard. Therefore, we want to strip 15 | # out the higlighting part so that the errors look consistent. The full 16 | # message with the exception will be attached to the notice body. 17 | # 18 | # @return [String] 19 | RUBY_31_ERROR_HIGHLIGHTING_DIVIDER = "\n\n".freeze 20 | 21 | # @return [Hash] the options for +String#encode+ 22 | ENCODING_OPTIONS = { invalid: :replace, undef: :replace }.freeze 23 | 24 | def initialize(exception) 25 | @exception = exception 26 | end 27 | 28 | def as_json 29 | unwind_exceptions.map do |exception| 30 | { type: exception.class.name, 31 | message: message(exception), 32 | backtrace: Backtrace.parse(exception) } 33 | end 34 | end 35 | 36 | private 37 | 38 | def unwind_exceptions 39 | exception_list = [] 40 | exception = @exception 41 | 42 | while exception && exception_list.size < MAX_NESTED_EXCEPTIONS 43 | exception_list << exception 44 | exception = (exception.cause if exception.respond_to?(:cause)) 45 | end 46 | 47 | exception_list 48 | end 49 | 50 | def message(exception) 51 | return unless (msg = exception.message) 52 | 53 | msg 54 | .encode(Encoding::UTF_8, **ENCODING_OPTIONS) 55 | .split(RUBY_31_ERROR_HIGHLIGHTING_DIVIDER) 56 | .first 57 | end 58 | end 59 | end 60 | -------------------------------------------------------------------------------- /spec/async_sender_spec.rb: -------------------------------------------------------------------------------- 1 | RSpec.describe Airbrake::AsyncSender do 2 | let(:endpoint) { 'https://api.airbrake.io/api/v3/projects/1/notices' } 3 | let(:queue_size) { 10 } 4 | let(:notice) { Airbrake::Notice.new(AirbrakeTestError.new) } 5 | 6 | before do 7 | stub_request(:post, endpoint).to_return(status: 201, body: '{}') 8 | Airbrake::Config.instance = Airbrake::Config.new( 9 | project_id: '1', 10 | workers: 3, 11 | queue_size: 10, 12 | ) 13 | end 14 | 15 | describe "#send" do 16 | subject(:async_sender) { described_class.new } 17 | 18 | context "when sender has the capacity to send" do 19 | it "sends notices to Airbrake" do 20 | 2.times { async_sender.send(notice, Airbrake::Promise.new) } 21 | async_sender.close 22 | 23 | expect(a_request(:post, endpoint)).to have_been_made.twice 24 | end 25 | 26 | it "returns a resolved promise" do 27 | promise = Airbrake::Promise.new 28 | async_sender.send(notice, promise) 29 | async_sender.close 30 | 31 | expect(promise).to be_resolved 32 | end 33 | end 34 | 35 | context "when sender has exceeded the capacity to send" do 36 | before do 37 | Airbrake::Config.instance = Airbrake::Config.new( 38 | project_id: '1', 39 | workers: 0, 40 | queue_size: 1, 41 | ) 42 | end 43 | 44 | it "doesn't send the exceeded notices to Airbrake" do 45 | 15.times { async_sender.send(notice, Airbrake::Promise.new) } 46 | async_sender.close 47 | 48 | expect(a_request(:post, endpoint)).not_to have_been_made 49 | end 50 | 51 | it "returns a rejected promise" do 52 | promise = nil 53 | 15.times do 54 | promise = async_sender.send(notice, Airbrake::Promise.new) 55 | end 56 | async_sender.close 57 | 58 | expect(promise).to be_rejected 59 | expect(promise.value).to eq( 60 | 'error' => "AsyncSender has reached its capacity of 1", 61 | ) 62 | end 63 | end 64 | end 65 | end 66 | -------------------------------------------------------------------------------- /lib/airbrake-ruby/stat.rb: -------------------------------------------------------------------------------- 1 | require 'base64' 2 | 3 | module Airbrake 4 | # Stat is a data structure that allows accumulating performance data (route 5 | # performance, SQL query performance and such). It's powered by TDigests. 6 | # 7 | # Usually, one Stat corresponds to one metric (route or query, 8 | # etc.). Incrementing a stat means pushing new performance statistics. 9 | # 10 | # @example 11 | # stat = Airbrake::Stat.new 12 | # stat.increment_ms(2000) 13 | # stat.to_h # Pack and serialize data so it can be transmitted. 14 | # 15 | # @since v3.2.0 16 | class Stat 17 | attr_accessor :sum, :sumsq, :tdigest 18 | 19 | # @param [Float] sum The sum of duration in milliseconds 20 | # @param [Float] sumsq The squared sum of duration in milliseconds 21 | # @param [TDigest::TDigest] tdigest Packed durations. By default, 22 | # compression is 20 23 | def initialize(sum: 0.0, sumsq: 0.0, tdigest: TDigest.new(0.05)) 24 | @sum = sum 25 | @sumsq = sumsq 26 | @tdigest = tdigest 27 | @mutex = Mutex.new 28 | end 29 | 30 | # @return [Hash{String=>Object}] stats as a hash with compressed TDigest 31 | # (serialized as base64) 32 | def to_h 33 | @mutex.synchronize do 34 | tdigest.compress! 35 | { 36 | 'count' => tdigest.size, 37 | 'sum' => sum, 38 | 'sumsq' => sumsq, 39 | 'tdigest' => Base64.strict_encode64(tdigest.as_small_bytes), 40 | } 41 | end 42 | end 43 | 44 | # Increments tdigest timings and updates tdigest with given +ms+ value. 45 | # 46 | # @param [Float] ms 47 | # @return [void] 48 | def increment_ms(ms) 49 | @mutex.synchronize do 50 | self.sum += ms 51 | self.sumsq += ms * ms 52 | 53 | tdigest.push(ms) 54 | end 55 | end 56 | 57 | # We define custom inspect so that we weed out uninformative TDigest, which 58 | # is also very slow to dump when we log Airbrake::Stat. 59 | # 60 | # @return [String] 61 | def inspect 62 | "#" 63 | end 64 | alias pretty_print inspect 65 | end 66 | end 67 | -------------------------------------------------------------------------------- /lib/airbrake-ruby/filters/git_repository_filter.rb: -------------------------------------------------------------------------------- 1 | module Airbrake 2 | module Filters 3 | # Attaches git repository URL to `context`. 4 | # @api private 5 | # @since v2.12.0 6 | class GitRepositoryFilter 7 | # @return [Integer] 8 | attr_reader :weight 9 | 10 | # @param [String] root_directory 11 | def initialize(root_directory) 12 | @git_path = File.join(root_directory, '.git') 13 | @repository = nil 14 | @git_version = detect_git_version 15 | @weight = 116 16 | end 17 | 18 | # @macro call_filter 19 | def call(notice) 20 | return if notice[:context].key?(:repository) 21 | 22 | attach_repository(notice) 23 | end 24 | 25 | def attach_repository(notice) 26 | if @repository 27 | notice[:context][:repository] = @repository 28 | return 29 | end 30 | 31 | return unless File.exist?(@git_path) 32 | return unless @git_version 33 | 34 | @repository = 35 | if @git_version >= Gem::Version.new('2.7.0') 36 | `cd #{@git_path} && git config --get remote.origin.url`.chomp 37 | else 38 | "`git remote get-url` is unsupported in git #{@git_version}. " \ 39 | 'Consider an upgrade to 2.7+' 40 | end 41 | 42 | return unless @repository 43 | 44 | notice[:context][:repository] = @repository 45 | end 46 | 47 | private 48 | 49 | def detect_git_version 50 | return unless which('git') 51 | 52 | begin 53 | Gem::Version.new(`git --version`.split[2]) 54 | rescue Errno::EAGAIN 55 | # Bugfix for the case when the system cannot allocate memory for 56 | # a fork() call: https://github.com/airbrake/airbrake-ruby/issues/680 57 | nil 58 | end 59 | end 60 | 61 | # Cross-platform way to tell if an executable is accessible. 62 | def which(cmd) 63 | exts = ENV['PATHEXT'] ? ENV['PATHEXT'].split(';') : [''] 64 | ENV['PATH'].split(File::PATH_SEPARATOR).find do |path| 65 | exts.find do |ext| 66 | exe = File.join(path, "#{cmd}#{ext}") 67 | File.executable?(exe) && !File.directory?(exe) 68 | end 69 | end 70 | end 71 | end 72 | end 73 | end 74 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | How to contribute 2 | ================= 3 | 4 | Pull requests 5 | ------------- 6 | 7 | We love your contributions, thanks for taking the time to contribute! 8 | 9 | It's really easy to start contributing, just follow these simple steps: 10 | 11 | 1. [Fork][fork-article] the [repo][airbrake-ruby]: 12 | 13 | 2. Run the test suite to make sure the tests pass: 14 | 15 | ```shell 16 | bundle exec rake 17 | ``` 18 | 19 | 3. [Create a separate branch][branch], commit your work and push it to your 20 | fork. If you add comments, please make sure that they are compatible with 21 | [YARD][yard]: 22 | 23 | ``` 24 | git checkout -b my-branch 25 | git commit -am 26 | git push origin my-branch 27 | ``` 28 | 29 | 4. Verify that your code doesn't offend Rubocop: 30 | 31 | ``` 32 | bundle exec rubocop 33 | ``` 34 | 35 | 5. Verify that your code's documentation is correct: 36 | 37 | ``` 38 | bundle exec yardoc --fail-on-warning --no-progress --readme=README 39 | ``` 40 | 41 | 6. Run the test suite again (new tests are always welcome): 42 | 43 | ``` 44 | bundle exec rake 45 | ``` 46 | 47 | 7. [Make a pull request][pr] 48 | 49 | Submitting issues 50 | ----------------- 51 | 52 | Our [issue tracker][issues] is a perfect place for filing bug reports or 53 | discussing possible features. If you report a bug, consider using the following 54 | template (copy-paste friendly): 55 | 56 | ``` 57 | * Airbrake version: {YOUR VERSION} 58 | * Ruby version: {YOUR VERSION} 59 | * Framework name & version: {YOUR DATA} 60 | 61 | #### Airbrake config 62 | 63 | # YOUR CONFIG 64 | # 65 | # Make sure to delete any sensitive information 66 | # such as your project id and project key. 67 | 68 | #### Description 69 | 70 | {We would be thankful if you provided steps to reproduce the issue, expected & 71 | actual results, any code snippets or even test repositories, so we could clone 72 | it and test} 73 | ``` 74 | 75 | [airbrake-ruby]: https://github.com/airbrake/airbrake-ruby 76 | [fork-article]: https://help.github.com/articles/fork-a-repo 77 | [branch]: https://help.github.com/articles/creating-and-deleting-branches-within-your-repository/ 78 | [pr]: https://help.github.com/articles/using-pull-requests 79 | [issues]: https://github.com/airbrake/airbrake-ruby/issues 80 | [yard]: http://yardoc.org/ 81 | -------------------------------------------------------------------------------- /spec/backlog_spec.rb: -------------------------------------------------------------------------------- 1 | RSpec.describe Airbrake::Backlog do 2 | subject(:backlog) { described_class.new(sync_sender, 0.1) } 3 | 4 | let(:sync_sender) { Airbrake::SyncSender.new } 5 | let(:error_endpoint) { '/error' } 6 | let(:event_endpoint) { '/event' } 7 | let(:promise) { an_instance_of(Airbrake::Promise) } 8 | 9 | before { allow(sync_sender).to receive(:send) } 10 | 11 | after { backlog.close } 12 | 13 | describe "#<<" do 14 | it "returns self" do 15 | expect(backlog << 1).to eq(backlog) 16 | end 17 | 18 | it "waits for the data to be processed" do 19 | backlog << [1, error_endpoint] << [2, event_endpoint] 20 | 21 | expect(sync_sender).not_to have_received(:send) 22 | .with(1, promise, error_endpoint) 23 | expect(sync_sender).not_to have_received(:send) 24 | .with(2, promise, event_endpoint) 25 | end 26 | 27 | it "processed the data on flush" do 28 | backlog << [1, error_endpoint] << [2, event_endpoint] 29 | 30 | sleep 0.2 31 | 32 | expect(sync_sender).to have_received(:send) 33 | .with(1, promise, error_endpoint) 34 | expect(sync_sender).to have_received(:send) 35 | .with(2, promise, event_endpoint) 36 | end 37 | 38 | it "clears the queue after flushing" do 39 | backlog << [1, error_endpoint] << [2, event_endpoint] 40 | 41 | sleep 0.2 42 | 43 | backlog << [3, event_endpoint] << [4, error_endpoint] 44 | 45 | sleep 0.2 46 | 47 | expect(sync_sender).to have_received(:send) 48 | .with(3, promise, event_endpoint) 49 | expect(sync_sender).to have_received(:send) 50 | .with(4, promise, error_endpoint) 51 | end 52 | 53 | it "doesn't append an already appended item" do 54 | backlog << [1, error_endpoint] << [1, error_endpoint] << [1, error_endpoint] 55 | 56 | sleep 0.2 57 | 58 | expect(sync_sender).to have_received(:send) 59 | .with(1, promise, error_endpoint).once 60 | end 61 | 62 | context "when then backlog reaches its capacity of 100" do 63 | before { allow(Airbrake::Loggable.instance).to receive(:error) } 64 | 65 | it "logs errors" do 66 | 102.times { |i| backlog << [i, error_endpoint] } 67 | 68 | sleep 0.2 69 | 70 | expect(Airbrake::Loggable.instance).to have_received(:error).with( 71 | '**Airbrake: Airbrake::Backlog full', 72 | ).twice 73 | end 74 | end 75 | end 76 | end 77 | -------------------------------------------------------------------------------- /spec/filters/git_repository_filter_spec.rb: -------------------------------------------------------------------------------- 1 | RSpec.describe Airbrake::Filters::GitRepositoryFilter do 2 | subject(:git_repository_filter) { described_class.new('.') } 3 | 4 | let(:notice) { Airbrake::Notice.new(AirbrakeTestError.new) } 5 | 6 | describe "#initialize" do 7 | it "parses standard git version" do 8 | allow_any_instance_of(Kernel) 9 | .to receive(:`).and_return('git version 2.18.0') 10 | expect { git_repository_filter }.not_to raise_error 11 | end 12 | 13 | it "parses release candidate git version" do 14 | allow_any_instance_of(Kernel) 15 | .to receive(:`).and_return('git version 2.21.0-rc0') 16 | expect { git_repository_filter }.not_to raise_error 17 | end 18 | 19 | it "parses git version with brackets" do 20 | allow_any_instance_of(Kernel) 21 | .to receive(:`).and_return('git version 2.17.2 (Apple Git-113)') 22 | expect { git_repository_filter }.not_to raise_error 23 | end 24 | 25 | context "when Errno::EAGAIN is raised when detecting git version" do 26 | it "doesn't attach anything to context/repository" do 27 | allow_any_instance_of(Kernel).to receive(:`).and_raise(Errno::EAGAIN) 28 | git_repository_filter.call(notice) 29 | expect(notice[:context][:repository]).to be_nil 30 | end 31 | end 32 | end 33 | 34 | context "when context/repository is defined" do 35 | it "doesn't attach anything to context/repository" do 36 | notice[:context][:repository] = 'git@github.com:kyrylo/test.git' 37 | git_repository_filter.call(notice) 38 | expect(notice[:context][:repository]).to eq('git@github.com:kyrylo/test.git') 39 | end 40 | end 41 | 42 | context "when .git directory doesn't exist" do 43 | subject(:git_repository_filter) { described_class.new('root/dir') } 44 | 45 | it "doesn't attach anything to context/repository" do 46 | git_repository_filter.call(notice) 47 | expect(notice[:context][:repository]).to be_nil 48 | end 49 | end 50 | 51 | context "when .git directory exists" do 52 | it "attaches context/repository" do 53 | git_repository_filter.call(notice) 54 | expect(notice[:context][:repository]).to match( 55 | 'github.com/airbrake/airbrake-ruby', 56 | ) 57 | end 58 | end 59 | 60 | context "when git is not in PATH" do 61 | let!(:path) { ENV.fetch('PATH', nil) } 62 | 63 | before { ENV['PATH'] = '' } 64 | 65 | after { ENV['PATH'] = path } 66 | 67 | it "does not attach context/repository" do 68 | git_repository_filter.call(notice) 69 | expect(notice[:context][:repository]).to be_nil 70 | end 71 | end 72 | end 73 | -------------------------------------------------------------------------------- /lib/airbrake-ruby/filters/thread_filter.rb: -------------------------------------------------------------------------------- 1 | module Airbrake 2 | module Filters 3 | # Attaches thread & fiber local variables along with general thread 4 | # information. 5 | # @api private 6 | class ThreadFilter 7 | # @return [Integer] 8 | attr_reader :weight 9 | 10 | # @return [Array] the list of classes that can be safely converted 11 | # to JSON 12 | SAFE_CLASSES = [ 13 | NilClass, 14 | TrueClass, 15 | FalseClass, 16 | String, 17 | Symbol, 18 | Regexp, 19 | Numeric, 20 | ].freeze 21 | 22 | # Variables starting with this prefix are not attached to a notice. 23 | # @see https://github.com/airbrake/airbrake-ruby/issues/229 24 | # @return [String] 25 | IGNORE_PREFIX = '_'.freeze 26 | 27 | def initialize 28 | @weight = 110 29 | end 30 | 31 | # @macro call_filter 32 | def call(notice) 33 | th = Thread.current 34 | thread_info = {} 35 | 36 | if (vars = thread_variables(th)).any? 37 | thread_info[:thread_variables] = vars 38 | end 39 | 40 | if (vars = fiber_variables(th)).any? 41 | thread_info[:fiber_variables] = vars 42 | end 43 | 44 | if (name = th.name) 45 | thread_info[:name] = name 46 | end 47 | 48 | add_thread_info(th, thread_info) 49 | 50 | notice[:params][:thread] = thread_info 51 | end 52 | 53 | private 54 | 55 | def thread_variables(th) 56 | th.thread_variables.map.with_object({}) do |var, h| 57 | next if var.to_s.start_with?(IGNORE_PREFIX) 58 | 59 | h[var] = sanitize_value(th.thread_variable_get(var)) 60 | end 61 | end 62 | 63 | def fiber_variables(th) 64 | th.keys.map.with_object({}) do |key, h| 65 | next if key.to_s.start_with?(IGNORE_PREFIX) 66 | 67 | h[key] = sanitize_value(th[key]) 68 | end 69 | end 70 | 71 | def add_thread_info(th, thread_info) 72 | thread_info[:self] = th.inspect 73 | thread_info[:group] = th.group.list.map(&:inspect) 74 | thread_info[:priority] = th.priority 75 | 76 | thread_info[:safe_level] = th.safe_level if Airbrake::HAS_SAFE_LEVEL 77 | end 78 | 79 | def sanitize_value(value) 80 | return value if SAFE_CLASSES.any? { |klass| value.is_a?(klass) } 81 | 82 | case value 83 | when Array 84 | value = value.map { |elem| sanitize_value(elem) } 85 | when Hash 86 | value.transform_values { |v| sanitize_value(v) } 87 | else 88 | value.to_s 89 | end 90 | end 91 | end 92 | end 93 | end 94 | -------------------------------------------------------------------------------- /lib/airbrake-ruby/config/processor.rb: -------------------------------------------------------------------------------- 1 | module Airbrake 2 | class Config 3 | # Processor is a helper class, which is responsible for setting default 4 | # config values, default notifier filters and remote configuration changes. 5 | # 6 | # @since v5.0.0 7 | # @api private 8 | class Processor 9 | # @param [Airbrake::Config] config 10 | # @return [Airbrake::Config::Processor] 11 | def self.process(config) 12 | new(config).process 13 | end 14 | 15 | # @param [Airbrake::Config] config 16 | def initialize(config) 17 | @config = config 18 | @blocklist_keys = @config.blocklist_keys 19 | @allowlist_keys = @config.allowlist_keys 20 | @project_id = @config.project_id 21 | @poll_callback = Airbrake::RemoteSettings::Callback.new(config) 22 | end 23 | 24 | # @param [Airbrake::NoticeNotifier] notifier 25 | # @return [void] 26 | def process_blocklist(notifier) 27 | return if @blocklist_keys.none? 28 | 29 | blocklist = Airbrake::Filters::KeysBlocklist.new(@blocklist_keys) 30 | notifier.add_filter(blocklist) 31 | end 32 | 33 | # @param [Airbrake::NoticeNotifier] notifier 34 | # @return [void] 35 | def process_allowlist(notifier) 36 | return if @allowlist_keys.none? 37 | 38 | allowlist = Airbrake::Filters::KeysAllowlist.new(@allowlist_keys) 39 | notifier.add_filter(allowlist) 40 | end 41 | 42 | # @return [Airbrake::RemoteSettings] 43 | def process_remote_configuration 44 | return unless @config.remote_config 45 | return unless @project_id 46 | 47 | # Never poll remote configuration in the test environment. 48 | return if @config.environment == 'test' 49 | 50 | # If the current environment is ignored, don't try to poll remote 51 | # configuration. 52 | return if @config.ignore_environments.include?(@config.environment) 53 | 54 | RemoteSettings.poll(@project_id, @config.remote_config_host) do |data| 55 | @poll_callback.call(data) 56 | end 57 | end 58 | 59 | # @param [Airbrake::NoticeNotifier] notifier 60 | # @return [void] 61 | def add_filters(notifier) 62 | return unless @config.root_directory 63 | 64 | [ 65 | Airbrake::Filters::RootDirectoryFilter, 66 | Airbrake::Filters::GitRevisionFilter, 67 | Airbrake::Filters::GitRepositoryFilter, 68 | Airbrake::Filters::GitLastCheckoutFilter, 69 | ].each do |filter| 70 | next if notifier.has_filter?(filter) 71 | 72 | notifier.add_filter(filter.new(@config.root_directory)) 73 | end 74 | end 75 | end 76 | end 77 | end 78 | -------------------------------------------------------------------------------- /lib/airbrake-ruby/filters/git_last_checkout_filter.rb: -------------------------------------------------------------------------------- 1 | require 'date' 2 | 3 | module Airbrake 4 | module Filters 5 | # Attaches git checkout info to `context`. The info includes: 6 | # * username 7 | # * email 8 | # * revision 9 | # * time 10 | # 11 | # This information is used to track deploys automatically. 12 | # 13 | # @api private 14 | # @since v2.12.0 15 | class GitLastCheckoutFilter 16 | # @return [Integer] 17 | attr_reader :weight 18 | 19 | # @return [Integer] least possible amount of columns in git's `logs/HEAD` 20 | # file (checkout information is omitted) 21 | MIN_HEAD_COLS = 6 22 | 23 | include Loggable 24 | 25 | # @param [String] root_directory 26 | def initialize(root_directory) 27 | @git_path = File.join(root_directory, '.git') 28 | @weight = 116 29 | @last_checkout = nil 30 | @deploy_username = ENV.fetch('AIRBRAKE_DEPLOY_USERNAME', nil) 31 | end 32 | 33 | # @macro call_filter 34 | def call(notice) 35 | return if notice[:context].key?(:lastCheckout) 36 | 37 | if @last_checkout 38 | notice[:context][:lastCheckout] = @last_checkout 39 | return 40 | end 41 | 42 | return unless File.exist?(@git_path) 43 | return unless (checkout = last_checkout) 44 | 45 | notice[:context][:lastCheckout] = checkout 46 | end 47 | 48 | private 49 | 50 | def last_checkout 51 | return unless (line = last_checkout_line) 52 | 53 | parts = line.chomp.split("\t").first.split 54 | if parts.size < MIN_HEAD_COLS 55 | logger.error( 56 | "#{LOG_LABEL} Airbrake::#{self.class.name}: can't parse line: #{line}", 57 | ) 58 | return 59 | end 60 | 61 | author = parts[2..-4] 62 | @last_checkout = { 63 | username: @deploy_username || author[0..1].join(' '), 64 | email: parts[-3][1..-2], 65 | revision: parts[1], 66 | time: timestamp(parts[-2].to_i), 67 | } 68 | end 69 | 70 | def last_checkout_line 71 | head_path = File.join(@git_path, 'logs', 'HEAD') 72 | return unless File.exist?(head_path) 73 | 74 | last_line = nil 75 | File.foreach(head_path) do |line| 76 | last_line = line if checkout_line?(line) 77 | end 78 | last_line 79 | end 80 | 81 | def checkout_line?(line) 82 | line.include?("\tclone:") || 83 | line.include?("\tpull:") || 84 | line.include?("\tcheckout:") 85 | end 86 | 87 | def timestamp(utime) 88 | Time.at(utime).to_datetime.rfc3339 89 | end 90 | end 91 | end 92 | end 93 | -------------------------------------------------------------------------------- /.rubocop.yml: -------------------------------------------------------------------------------- 1 | require: 2 | - rubocop-rake 3 | - rubocop-rspec 4 | 5 | # Explanations of all possible options: 6 | # https://github.com/bbatsov/rubocop/blob/master/config/default.yml 7 | AllCops: 8 | DisplayCopNames: true 9 | DisplayStyleGuide: true 10 | TargetRubyVersion: 2.5 11 | Exclude: 12 | - 'pkg/**/*' 13 | - 'vendor/**/*' 14 | - 'spec/fixtures/**/*' 15 | NewCops: enable 16 | 17 | Metrics/MethodLength: 18 | Max: 25 19 | 20 | Layout/LineLength: 21 | Max: 90 22 | 23 | # Details: 24 | # http://c2.com/cgi/wiki?AbcMetric 25 | Metrics/AbcSize: 26 | # The ABC size is a calculated magnitude, so this number can be a Fixnum or 27 | # a Float. 28 | Max: 20 29 | 30 | Style/StringLiterals: 31 | Enabled: false 32 | 33 | Style/HashSyntax: 34 | EnforcedStyle: ruby19 35 | 36 | Naming/FileName: 37 | Exclude: 38 | - 'lib/airbrake-ruby.rb' 39 | 40 | Style/NumericLiterals: 41 | Enabled: false 42 | 43 | Style/SignalException: 44 | EnforcedStyle: only_raise 45 | 46 | Naming/PredicateName: 47 | Exclude: 48 | - 'lib/airbrake-ruby/async_sender.rb' 49 | - 'lib/airbrake-ruby/thread_pool.rb' 50 | 51 | Metrics/ClassLength: 52 | Max: 120 53 | 54 | # TODO: enable this when Ruby 3.0 is out. 55 | Style/FrozenStringLiteralComment: 56 | Enabled: false 57 | 58 | Layout/FirstArrayElementIndentation: 59 | EnforcedStyle: consistent 60 | 61 | Style/NumericPredicate: 62 | Enabled: false 63 | 64 | Naming/VariableNumber: 65 | Enabled: false 66 | 67 | Style/SafeNavigation: 68 | Enabled: false 69 | 70 | Metrics/BlockLength: 71 | CountComments: false 72 | Max: 25 73 | Exclude: 74 | - 'Rakefile' 75 | - '**/*.rake' 76 | - 'spec/**/*.rb' 77 | - 'airbrake-ruby.gemspec' 78 | 79 | Layout/HeredocIndentation: 80 | Enabled: false 81 | 82 | Gemspec/OrderedDependencies: 83 | Enabled: false 84 | 85 | Style/FormatStringToken: 86 | Enabled: false 87 | 88 | Naming/MethodParameterName: 89 | Enabled: false 90 | 91 | Gemspec/RequiredRubyVersion: 92 | Enabled: false 93 | 94 | Style/TrailingCommaInArguments: 95 | EnforcedStyleForMultiline: comma 96 | 97 | Style/TrailingCommaInArrayLiteral: 98 | EnforcedStyleForMultiline: comma 99 | 100 | Style/TrailingCommaInHashLiteral: 101 | EnforcedStyleForMultiline: comma 102 | 103 | Naming/RescuedExceptionsVariableName: 104 | Enabled: false 105 | 106 | Lint/RaiseException: 107 | Enabled: true 108 | 109 | Lint/StructNewOverride: 110 | Enabled: true 111 | 112 | Style/HashEachMethods: 113 | Enabled: true 114 | 115 | Style/HashTransformKeys: 116 | Enabled: true 117 | 118 | Style/HashTransformValues: 119 | Enabled: true 120 | 121 | RSpec/ExampleLength: 122 | Enabled: false 123 | 124 | RSpec/MultipleExpectations: 125 | Max: 2 126 | 127 | RSpec/ContextWording: 128 | Enabled: false 129 | 130 | RSpec/FilePath: 131 | Enabled: false 132 | 133 | RSpec/NestedGroups: 134 | Enabled: false 135 | 136 | RSpec/MultipleMemoizedHelpers: 137 | Max: 6 138 | 139 | RSpec/AnyInstance: 140 | Enabled: false 141 | 142 | Layout/LineEndStringConcatenationIndentation: 143 | Enabled: false 144 | 145 | Style/OpenStructUse: 146 | Exclude: 147 | - 'spec/**/*' 148 | -------------------------------------------------------------------------------- /lib/airbrake-ruby/config/validator.rb: -------------------------------------------------------------------------------- 1 | module Airbrake 2 | class Config 3 | # Validator validates values of {Airbrake::Config} options. A valid config 4 | # is a config that guarantees that data can be sent to Airbrake given its 5 | # configuration. 6 | # 7 | # @api private 8 | # @since v1.5.0 9 | class Validator 10 | # @return [Array] the list of allowed types to configure the 11 | # environment option 12 | VALID_ENV_TYPES = [NilClass, String, Symbol].freeze 13 | 14 | class << self 15 | # @param [Airbrake::Config] config 16 | # @since v4.1.0 17 | def validate(config) 18 | promise = Airbrake::Promise.new 19 | 20 | unless valid_project_id?(config) 21 | return promise.reject(':project_id is required') 22 | end 23 | 24 | unless valid_project_key?(config) 25 | return promise.reject(':project_key is required') 26 | end 27 | 28 | unless valid_environment?(config) 29 | return promise.reject( 30 | "the 'environment' option must be configured " \ 31 | "with a Symbol (or String), but '#{config.environment.class}' was " \ 32 | "provided: #{config.environment}", 33 | ) 34 | end 35 | 36 | promise.resolve(:ok) 37 | end 38 | 39 | # Whether the given +config+ allows sending data to Airbrake. It doesn't 40 | # matter if it's valid or invalid. 41 | # 42 | # @param [Airbrake::Config] config 43 | # @since v4.1.0 44 | def check_notify_ability(config) 45 | promise = Airbrake::Promise.new 46 | 47 | unless config.error_notifications 48 | return promise.reject('error notifications are disabled') 49 | end 50 | 51 | if ignored_environment?(config) 52 | return promise.reject( 53 | "current environment '#{config.environment}' is ignored", 54 | ) 55 | end 56 | 57 | promise.resolve(:ok) 58 | end 59 | 60 | private 61 | 62 | def valid_project_id?(config) 63 | return true if config.project_id.to_i > 0 64 | 65 | false 66 | end 67 | 68 | def valid_project_key?(config) 69 | return false unless config.project_key.is_a?(String) 70 | return false if config.project_key.empty? 71 | 72 | true 73 | end 74 | 75 | def valid_environment?(config) 76 | VALID_ENV_TYPES.any? { |type| config.environment.is_a?(type) } 77 | end 78 | 79 | def ignored_environment?(config) 80 | if config.ignore_environments.any? && config.environment.nil? 81 | config.logger.warn( 82 | "#{LOG_LABEL} the 'environment' option is not set, " \ 83 | "'ignore_environments' has no effect", 84 | ) 85 | end 86 | 87 | return false if config.ignore_environments.none? || !config.environment 88 | 89 | env = config.environment.to_s 90 | config.ignore_environments.any? do |pattern| 91 | pattern.is_a?(Regexp) ? env.match(pattern) : env == pattern.to_s 92 | end 93 | end 94 | end 95 | end 96 | end 97 | end 98 | -------------------------------------------------------------------------------- /lib/airbrake-ruby/promise.rb: -------------------------------------------------------------------------------- 1 | module Airbrake 2 | # Represents a simplified promise object (similar to promises found in 3 | # JavaScript), which allows chaining callbacks that are executed when the 4 | # promise is either resolved or rejected. 5 | # 6 | # @see https://developer.mozilla.org/en/docs/Web/JavaScript/Reference/Global_Objects/Promise 7 | # @see https://github.com/ruby-concurrency/concurrent-ruby/blob/master/lib/concurrent/promise.rb 8 | # @since v1.7.0 9 | class Promise 10 | def initialize 11 | @on_resolved = [] 12 | @on_rejected = [] 13 | @value = {} 14 | @mutex = Mutex.new 15 | end 16 | 17 | # Attaches a callback to be executed when the promise is resolved. 18 | # 19 | # @example 20 | # Airbrake::Promise.new.then { |response| puts response } 21 | # #=> {"id"=>"00054415-8201-e9c6-65d6-fc4d231d2871", 22 | # # "url"=>"http://localhost/locate/00054415-8201-e9c6-65d6-fc4d231d2871"} 23 | # 24 | # @yield [response] 25 | # @yieldparam response [Hash] Contains the `id` & `url` keys 26 | # @return [self] 27 | def then(&block) 28 | @mutex.synchronize do 29 | if @value.key?('ok') 30 | yield(@value['ok']) 31 | return self 32 | end 33 | 34 | @on_resolved << block 35 | end 36 | 37 | self 38 | end 39 | 40 | # Attaches a callback to be executed when the promise is rejected. 41 | # 42 | # @example 43 | # Airbrake::Promise.new.rescue { |error| raise error } 44 | # 45 | # @yield [error] The error message from the API 46 | # @yieldparam error [String] 47 | # @return [self] 48 | def rescue(&block) 49 | @mutex.synchronize do 50 | if @value.key?('error') 51 | yield(@value['error']) 52 | return self 53 | end 54 | 55 | @on_rejected << block 56 | end 57 | 58 | self 59 | end 60 | 61 | # @example 62 | # Airbrake::Promise.new.resolve('id' => '123') 63 | # 64 | # @param reason [Object] 65 | # @return [self] 66 | def resolve(reason = 'resolved') 67 | @mutex.synchronize do 68 | @value['ok'] = reason 69 | @on_resolved.each { |callback| callback.call(reason) } 70 | end 71 | 72 | self 73 | end 74 | 75 | # @example 76 | # Airbrake::Promise.new.reject('Something went wrong') 77 | # 78 | # @param reason [String] 79 | # @return [self] 80 | def reject(reason = 'rejected') 81 | @mutex.synchronize do 82 | @value['error'] = reason 83 | @on_rejected.each { |callback| callback.call(reason) } 84 | end 85 | 86 | self 87 | end 88 | 89 | # @return [Boolean] 90 | def rejected? 91 | @value.key?('error') 92 | end 93 | 94 | # @return [Boolean] 95 | def resolved? 96 | @value.key?('ok') 97 | end 98 | 99 | # @return [Hash] either successful response containing the 100 | # +id+ key or unsuccessful response containing the +error+ key 101 | # @note This is a non-blocking call! 102 | # @todo Get rid of this method and use an accessor. The resolved guard is 103 | # needed for compatibility but it shouldn't exist in the future 104 | def value 105 | return @value['ok'] if resolved? 106 | 107 | @value 108 | end 109 | end 110 | end 111 | -------------------------------------------------------------------------------- /lib/airbrake-ruby/backlog.rb: -------------------------------------------------------------------------------- 1 | module Airbrake 2 | # Backlog accepts notices and APM events and synchronously sends them in the 3 | # background at regular intervals. The backlog is a queue of data that failed 4 | # to be sent due to some error. In a nutshell, it's a retry mechanism. 5 | # 6 | # @api private 7 | # @since v6.2.0 8 | class Backlog 9 | include Loggable 10 | 11 | # @return [Integer] how many records to keep in the backlog 12 | BACKLOG_SIZE = 100 13 | 14 | # @return [Integer] flush period in seconds 15 | TWO_MINUTES = 60 * 2 16 | 17 | def initialize(sync_sender, flush_period = TWO_MINUTES) 18 | @sync_sender = sync_sender 19 | @flush_period = flush_period 20 | @queue = SizedQueue.new(BACKLOG_SIZE).extend(MonitorMixin) 21 | @has_backlog_data = @queue.new_cond 22 | @schedule_flush = nil 23 | 24 | @seen = Set.new 25 | end 26 | 27 | # Appends data to the backlog. Once appended, the flush schedule will 28 | # start. Chainable. 29 | # 30 | # @example 31 | # backlog << [{ 'data' => 1 }, 'https://airbrake.io/api'] 32 | # 33 | # @param [Array<#to_json, String>] data An array of two elements, where the 34 | # first element is the data we are sending and the second element is the 35 | # URL that we are sending to 36 | # @return [self] 37 | def <<(data) 38 | @queue.synchronize do 39 | return self if @seen.include?(data) 40 | 41 | @seen << data 42 | 43 | begin 44 | @queue.push(data, true) 45 | rescue ThreadError 46 | logger.error("#{LOG_LABEL} Airbrake::Backlog full") 47 | return self 48 | end 49 | 50 | @has_backlog_data.signal 51 | schedule_flush 52 | 53 | self 54 | end 55 | end 56 | 57 | # Closes all the resources that this sender has allocated. 58 | # 59 | # @return [void] 60 | # @since v6.2.0 61 | def close 62 | @queue.synchronize do 63 | if @schedule_flush 64 | @schedule_flush.kill 65 | logger.debug("#{LOG_LABEL} Airbrake::Backlog closed") 66 | end 67 | end 68 | end 69 | 70 | private 71 | 72 | def schedule_flush 73 | @schedule_flush ||= Thread.new do 74 | loop do 75 | @queue.synchronize do 76 | wait 77 | next if @queue.empty? 78 | 79 | flush 80 | end 81 | end 82 | end 83 | end 84 | 85 | def wait 86 | @has_backlog_data.wait(@flush_period) while time_elapsed < @flush_period 87 | @last_flush = nil 88 | end 89 | 90 | def time_elapsed 91 | MonotonicTime.time_in_s - last_flush 92 | end 93 | 94 | def last_flush 95 | @last_flush ||= MonotonicTime.time_in_s 96 | end 97 | 98 | def flush 99 | unless @queue.empty? 100 | logger.debug( 101 | "#{LOG_LABEL} Airbrake::Backlog flushing #{@queue.size} messages", 102 | ) 103 | end 104 | 105 | failed = 0 106 | 107 | until @queue.empty? 108 | data, endpoint = @queue.pop 109 | promise = Airbrake::Promise.new 110 | @sync_sender.send(data, promise, endpoint) 111 | failed += 1 if promise.rejected? 112 | end 113 | 114 | if failed > 0 115 | logger.debug( 116 | "#{LOG_LABEL} Airbrake::Backlog #{failed} messages were not flushed", 117 | ) 118 | end 119 | 120 | @seen.clear 121 | end 122 | end 123 | end 124 | -------------------------------------------------------------------------------- /spec/filter_chain_spec.rb: -------------------------------------------------------------------------------- 1 | RSpec.describe Airbrake::FilterChain do 2 | subject(:filter_chain) { described_class.new } 3 | 4 | let(:notice) { Airbrake::Notice.new(AirbrakeTestError.new) } 5 | 6 | describe "#refine" do 7 | let(:filter) do 8 | Class.new do 9 | attr_reader :weight 10 | 11 | def initialize(weight) 12 | @weight = weight 13 | end 14 | 15 | def call(notice) 16 | notice[:params][:bingo] << @weight 17 | end 18 | end 19 | end 20 | 21 | it "executes filters from heaviest to lightest" do 22 | notice[:params][:bingo] = [] 23 | 24 | (0...3).reverse_each { |i| filter_chain.add_filter(filter.new(i)) } 25 | filter_chain.refine(notice) 26 | 27 | expect(notice[:params][:bingo]).to eq([2, 1, 0]) 28 | end 29 | 30 | it "stops execution once a notice was ignored" do 31 | f2 = filter.new(2) 32 | allow(f2).to receive(:call) 33 | 34 | f1 = proc { |notice| notice.ignore! } 35 | 36 | f0 = filter.new(-1) 37 | allow(f0).to receive(:call) 38 | 39 | [f2, f1, f0].each { |f| filter_chain.add_filter(f) } 40 | 41 | filter_chain.refine(notice) 42 | 43 | expect(f2).to have_received(:call) 44 | expect(f0).not_to have_received(:call) 45 | end 46 | end 47 | 48 | describe "#delete_filter" do 49 | let(:filter) do 50 | Class.new do 51 | class << self 52 | def name 53 | 'FooFilter' 54 | end 55 | end 56 | 57 | def initialize(foo) 58 | @foo = foo 59 | end 60 | 61 | def call(notice) 62 | notice[:params][:foo] << @foo 63 | end 64 | end 65 | end 66 | 67 | it "deletes a class filter" do 68 | notice[:params][:foo] = [] 69 | 70 | f1 = filter.new(1) 71 | filter_chain.add_filter(f1) 72 | 73 | foo_filter_mock = double 74 | allow(foo_filter_mock).to receive(:name).at_least(:once).and_return('FooFilter') 75 | filter_chain.delete_filter(foo_filter_mock) 76 | 77 | expect(foo_filter_mock).to have_received(:name) 78 | 79 | f2 = filter.new(2) 80 | filter_chain.add_filter(f2) 81 | 82 | filter_chain.refine(notice) 83 | expect(notice[:params][:foo]).to eq([2]) 84 | end 85 | end 86 | 87 | describe "#inspect" do 88 | it "returns a string representation of an empty FilterChain" do 89 | expect(filter_chain.inspect).to eq('[]') 90 | end 91 | 92 | it "returns a string representation of a non-empty FilterChain" do 93 | filter_chain.add_filter(proc {}) 94 | expect(filter_chain.inspect).to eq('[Proc]') 95 | end 96 | end 97 | 98 | describe "#includes?" do 99 | context "when a custom filter class is included in the filter chain" do 100 | it "returns true" do 101 | klass = Class.new 102 | 103 | filter_chain.add_filter(klass.new) 104 | expect(filter_chain.includes?(klass)).to be(true) 105 | end 106 | end 107 | 108 | context "when Proc filter class is included in the filter chain" do 109 | it "returns true" do 110 | filter_chain.add_filter(proc {}) 111 | expect(filter_chain.includes?(Proc)).to be(true) 112 | end 113 | end 114 | 115 | context "when filter class is NOT included in the filter chain" do 116 | it "returns false" do 117 | klass = Class.new 118 | 119 | filter_chain.add_filter(proc {}) 120 | expect(filter_chain.includes?(klass)).to be(false) 121 | end 122 | end 123 | end 124 | end 125 | -------------------------------------------------------------------------------- /lib/airbrake-ruby/filter_chain.rb: -------------------------------------------------------------------------------- 1 | module Airbrake 2 | # FilterChain represents an ordered array of filters. 3 | # 4 | # A filter is an object that responds to #call (typically a Proc or a 5 | # class that implements the call method). The #call method must accept 6 | # exactly one argument: an object to be filtered. 7 | # 8 | # When you add a new filter to the chain, it gets inserted according to its 9 | # weight. Smaller weight means the filter will be somewhere in the 10 | # beginning of the array. Larger - in the end. If a filter doesn't implement 11 | # weight, the chain assumes it's equal to 0. 12 | # 13 | # @example 14 | # class MyFilter 15 | # attr_reader :weight 16 | # 17 | # def initialize 18 | # @weight = 1 19 | # end 20 | # 21 | # def call(obj) 22 | # puts 'Filtering...' 23 | # obj[:data] = '[Filtered]' 24 | # end 25 | # end 26 | # 27 | # filter_chain = FilterChain.new 28 | # filter_chain.add_filter(MyFilter) 29 | # 30 | # filter_chain.refine(obj) 31 | # #=> Filtering... 32 | # 33 | # @see Airbrake.add_filter 34 | # @api private 35 | # @since v1.0.0 36 | class FilterChain 37 | # @return [Integer] 38 | DEFAULT_WEIGHT = 0 39 | 40 | def initialize 41 | @filters = [] 42 | end 43 | 44 | # Adds a filter to the filter chain. Sorts filters by weight. 45 | # 46 | # @param [#call] filter The filter object (proc, class, module, etc) 47 | # @return [void] 48 | def add_filter(filter) 49 | @filters = (@filters << filter).sort_by do |f| 50 | f.respond_to?(:weight) ? f.weight : DEFAULT_WEIGHT 51 | end.reverse! 52 | end 53 | 54 | # Deletes a filter from the the filter chain. 55 | # 56 | # @param [Class] filter_class The class of the filter you want to delete 57 | # @return [void] 58 | # @since v3.1.0 59 | def delete_filter(filter_class) 60 | # rubocop:disable Style/ClassEqualityComparison 61 | index = @filters.index { |f| f.class.name == filter_class.name } 62 | # rubocop:enable Style/ClassEqualityComparison 63 | @filters.delete_at(index) if index 64 | end 65 | 66 | # Applies all the filters in the filter chain to the given notice. Does not 67 | # filter ignored notices. 68 | # 69 | # @param [Airbrake::Notice] notice The notice to be filtered 70 | # @return [void] 71 | # @todo Make it work with anything, not only notices 72 | def refine(notice) 73 | @filters.each do |filter| 74 | break if notice.ignored? 75 | 76 | filter.call(notice) 77 | end 78 | end 79 | 80 | # @return [String] customized inspect to lessen the amount of clutter 81 | def inspect 82 | filter_classes.to_s 83 | end 84 | 85 | # @return [String] {#inspect} for PrettyPrint 86 | def pretty_print(q) 87 | q.text('[') 88 | 89 | # Make nesting of the first element consistent on JRuby and MRI. 90 | q.nest(2) { q.breakable } if @filters.any? 91 | 92 | q.nest(2) do 93 | q.seplist(@filters) { |f| q.pp(f.class) } 94 | end 95 | q.text(']') 96 | end 97 | 98 | # @param [Class] filter_class 99 | # @return [Boolean] true if the current chain has an instance of the given 100 | # class, false otherwise 101 | # @since v4.14.0 102 | def includes?(filter_class) 103 | filter_classes.include?(filter_class) 104 | end 105 | 106 | private 107 | 108 | def filter_classes 109 | @filters.map(&:class) 110 | end 111 | end 112 | end 113 | -------------------------------------------------------------------------------- /benchmarks/truncator_string_encoding.rb: -------------------------------------------------------------------------------- 1 | require_relative 'benchmark_helpers' 2 | 3 | require 'securerandom' 4 | require 'base64' 5 | 6 | # Generates various strings for the benchmark. 7 | module StringGenerator 8 | STRLEN = 32 9 | 10 | class << self 11 | # @return [String] a UTF-8 string with valid encoding and characters 12 | def utf8 13 | SecureRandom.hex(STRLEN).encode('utf-8') 14 | end 15 | 16 | # @return [String] a UTF-8 string with invalid encoding and characters. 17 | def invalid_utf8 18 | [invalid_string, invalid_string, invalid_string].join.encode('utf-8') 19 | end 20 | 21 | # @return [String] a UTF-8 string with valid encoding and charaters from 22 | # unicode 23 | def unicode_utf8 24 | "ü ö ä Ä Ü Ö ß привет €25.00 한글".encode('utf-8') 25 | end 26 | 27 | # @return [String] an ASCII-8BIT string with valid encoding and random 28 | # charaters 29 | def ascii_8bit_string 30 | SecureRandom.random_bytes(STRLEN).encode('ascii-8bit') 31 | end 32 | 33 | # @return [String] an ASCII-8BIT string with valid encoding and invalid 34 | # charaters (which means it can't be converted to UTF-8 with plain 35 | # +String#encode+ 36 | def invalid_ascii_8bit_string 37 | Base64.decode64(Base64.encode64(invalid_string).encode('ascii-8bit')) 38 | end 39 | 40 | private 41 | 42 | def invalid_string 43 | "\xD3\xE6\xBC\x9D\xBA" 44 | end 45 | end 46 | end 47 | 48 | # Generates arrays of strings for the benchmark. 49 | module BenchmarkCase 50 | # @return [Integer] number of strings to generate 51 | MAX = 100_000 52 | 53 | class << self 54 | def worst_ascii 55 | generate { StringGenerator.invalid_ascii_8bit_string } 56 | end 57 | 58 | def worst_utf8 59 | generate { StringGenerator.invalid_utf8 } 60 | end 61 | 62 | def mixed 63 | strings = [] 64 | methods = StringGenerator.singleton_methods(false) 65 | 66 | # How many strings of a certain type should be generated. 67 | part = MAX / methods.size 68 | 69 | methods.each do |method| 70 | strings << Array.new(part) { StringGenerator.__send__(method) } 71 | end 72 | 73 | strings.flatten 74 | end 75 | 76 | def best 77 | generate { StringGenerator.utf8 } 78 | end 79 | 80 | private 81 | 82 | def generate(&block) 83 | Array.new(MAX, &block) 84 | end 85 | end 86 | end 87 | 88 | worst_case_utf8 = BenchmarkCase.worst_utf8 89 | worst_case_ascii = BenchmarkCase.worst_ascii 90 | mixed_case = BenchmarkCase.mixed 91 | best_case = BenchmarkCase.best 92 | 93 | # Make sure we never truncate strings, 94 | # because this is irrelevant to this benchmark. 95 | MAX_PAYLOAD_SIZE = 1_000_000 96 | truncator = Airbrake::Truncator.new(MAX_PAYLOAD_SIZE) 97 | 98 | Benchmark.bmbm do |bm| 99 | bm.report("(worst case utf8) Truncator#truncate_string") do 100 | worst_case_utf8.each do |str| 101 | truncator.__send__(:truncate_string, str) 102 | end 103 | end 104 | 105 | bm.report("(worst case ascii) Truncator#truncate_string") do 106 | worst_case_ascii.each do |str| 107 | truncator.__send__(:truncate_string, str) 108 | end 109 | end 110 | 111 | bm.report("(mixed) Truncator#truncate_string") do 112 | mixed_case.each do |str| 113 | truncator.__send__(:truncate_string, str) 114 | end 115 | end 116 | 117 | bm.report("(best case) Truncator#truncate_string") do 118 | best_case.each do |str| 119 | truncator.__send__(:truncate_string, str) 120 | end 121 | end 122 | end 123 | -------------------------------------------------------------------------------- /spec/response_spec.rb: -------------------------------------------------------------------------------- 1 | RSpec.describe Airbrake::Response do 2 | describe ".parse" do 3 | [200, 201, 204].each do |code| 4 | context "when response code is #{code}" do 5 | before do 6 | allow(Airbrake::Loggable.instance).to receive(:debug) 7 | end 8 | 9 | it "logs response body" do 10 | described_class.parse(OpenStruct.new(code: code, body: '{}')) 11 | 12 | expect(Airbrake::Loggable.instance).to have_received(:debug).with( 13 | /Airbrake::Response \(#{code}\): {}/, 14 | ) 15 | end 16 | end 17 | end 18 | 19 | [400, 401, 403, 420].each do |code| 20 | context "when response code is #{code}" do 21 | before do 22 | allow(Airbrake::Loggable.instance).to receive(:error) 23 | end 24 | 25 | it "logs response message" do 26 | described_class.parse( 27 | OpenStruct.new(code: code, body: '{"message":"foo"}'), 28 | ) 29 | 30 | expect(Airbrake::Loggable.instance).to have_received(:error).with( 31 | /Airbrake: foo/, 32 | ) 33 | end 34 | end 35 | end 36 | 37 | context "when response code is 429" do 38 | let(:response) { OpenStruct.new(code: 429, body: '{"message":"rate limited"}') } 39 | 40 | before do 41 | allow(Airbrake::Loggable.instance).to receive(:error) 42 | end 43 | 44 | it "logs response message" do 45 | described_class.parse(response) 46 | expect(Airbrake::Loggable.instance).to have_received(:error).with( 47 | /Airbrake: rate limited/, 48 | ) 49 | end 50 | 51 | it "returns an error response" do 52 | time = Time.now 53 | allow(Time).to receive(:now).and_return(time) 54 | 55 | resp = described_class.parse(response) 56 | expect(resp).to include( 57 | 'error' => '**Airbrake: rate limited', 58 | 'rate_limit_reset' => time, 59 | ) 60 | end 61 | end 62 | 63 | context "when response code is unhandled" do 64 | let(:response) { OpenStruct.new(code: 500, body: 'foo') } 65 | 66 | before do 67 | allow(Airbrake::Loggable.instance).to receive(:error) 68 | end 69 | 70 | it "logs response body" do 71 | described_class.parse(response) 72 | expect(Airbrake::Loggable.instance).to have_received(:error).with( 73 | /Airbrake: unexpected code \(500\)\. Body: foo/, 74 | ) 75 | end 76 | 77 | it "returns an error response" do 78 | resp = described_class.parse(response) 79 | expect(resp).to eq('code' => 500, 'error' => 'foo') 80 | end 81 | 82 | it "truncates body" do 83 | response.body *= 1000 84 | resp = described_class.parse(response) 85 | expect(resp).to eq('code' => 500, 'error' => "#{'foo' * 33}fo...") 86 | end 87 | end 88 | 89 | context "when response body can't be parsed as JSON" do 90 | let(:response) { OpenStruct.new(code: 201, body: 'foo') } 91 | 92 | before do 93 | allow(Airbrake::Loggable.instance).to receive(:error) 94 | end 95 | 96 | it "logs response body" do 97 | described_class.parse(response) 98 | expect(Airbrake::Loggable.instance).to have_received(:error).with( 99 | /Airbrake: error while parsing body \(.*unexpected token.*\)\. Body: foo/, 100 | ) 101 | end 102 | 103 | it "returns an error message" do 104 | expect(described_class.parse(response)['error']).to match( 105 | /\A#/, 106 | ) 107 | end 108 | end 109 | end 110 | end 111 | -------------------------------------------------------------------------------- /spec/filters/git_last_checkout_filter_spec.rb: -------------------------------------------------------------------------------- 1 | RSpec.describe Airbrake::Filters::GitLastCheckoutFilter do 2 | subject(:git_last_checkout_filter) { described_class.new('.') } 3 | 4 | let(:notice) { Airbrake::Notice.new(AirbrakeTestError.new) } 5 | let(:git_path) { subject.instance_variable_get(:@git_path) } 6 | let(:head_path) { "#{git_path}/logs/HEAD" } 7 | let(:git_info) do 8 | OpenStruct.new( 9 | name: 'Arthur', 10 | email: 'arthur@airbrake.io', 11 | last_revision: 'ab12' * 10, # must be 40 chars long 12 | ) 13 | end 14 | let(:line_one) do 15 | "asdf2345 #{git_info.last_revision} #{git_info.name} <#{git_info.email}> 1679087797 -0700\tclone: from github.com:my_user/fun_repo.git" 16 | end 17 | let(:line_two) do 18 | "#{git_info.last_revision} dfgh3456 #{git_info.name} <#{git_info.email}> 1679087824 -0700\tpush: moving from main to spike-5" 19 | end 20 | 21 | before do 22 | allow(File).to receive(:exists?).with(git_path).and_return(true) 23 | allow(File).to receive(:exists?).with(head_path).and_return(true) 24 | allow(File).to receive(:foreach).with(head_path) 25 | .and_yield(line_one) 26 | .and_yield(line_two) 27 | end 28 | 29 | context "when context/lastCheckout is defined" do 30 | it "doesn't attach anything to context/lastCheckout" do 31 | notice[:context][:lastCheckout] = '123' 32 | git_last_checkout_filter.call(notice) 33 | expect(notice[:context][:lastCheckout]).to eq('123') 34 | end 35 | end 36 | 37 | context "when .git directory doesn't exist" do 38 | subject(:git_last_checkout_without_git_dir_filter) { described_class.new('root/dir') } 39 | 40 | before do 41 | allow(File).to receive(:exists?).with(git_path).and_return(false) 42 | end 43 | 44 | it "doesn't attach anything to context/lastCheckout" do 45 | git_last_checkout_without_git_dir_filter.call(notice) 46 | expect(notice[:context][:lastCheckout]).to be_nil 47 | end 48 | end 49 | 50 | context "when .git directory exists" do 51 | context "when AIRBRAKE_DEPLOY_USERNAME env variable is set" do 52 | before do 53 | ENV['AIRBRAKE_DEPLOY_USERNAME'] = 'deployer' 54 | end 55 | 56 | it "attaches username from the environment" do 57 | # reinitialize since username is dependent on env 58 | described_class.new('.').call(notice) 59 | expect(notice[:context][:lastCheckout][:username]).to eq('deployer') 60 | end 61 | end 62 | 63 | context "when AIRBRAKE_DEPLOY_USERNAME env variable is NOT set" do 64 | before { ENV['AIRBRAKE_DEPLOY_USERNAME'] = nil } 65 | 66 | it "attaches last checkouted username" do 67 | # reinitialize since username is dependent on env 68 | described_class.new('.').call(notice) 69 | username = notice[:context][:lastCheckout][:username] 70 | expect(username).to eq(git_info.name) 71 | expect(username).not_to be_empty 72 | expect(username).not_to be_nil 73 | end 74 | end 75 | 76 | it "attaches last checkouted email" do 77 | git_last_checkout_filter.call(notice) 78 | expect(notice[:context][:lastCheckout][:email]).to(match(/\A\w+@[\w\-.]+\z/)) 79 | end 80 | 81 | it "attaches last checkouted revision" do 82 | git_last_checkout_filter.call(notice) 83 | expect(notice[:context][:lastCheckout][:revision]).to eq(git_info.last_revision) 84 | expect(notice[:context][:lastCheckout][:revision]).not_to be_empty 85 | expect(notice[:context][:lastCheckout][:revision].size).to eq(40) 86 | end 87 | 88 | it "attaches last checkouted time" do 89 | git_last_checkout_filter.call(notice) 90 | expect(notice[:context][:lastCheckout][:time]).not_to be_empty 91 | expect(notice[:context][:lastCheckout][:time].size).to eq(25) 92 | end 93 | end 94 | end 95 | -------------------------------------------------------------------------------- /lib/airbrake-ruby/remote_settings.rb: -------------------------------------------------------------------------------- 1 | module Airbrake 2 | # RemoteSettings polls the remote config of the passed project at fixed 3 | # intervals. The fetched config is yielded as a callback parameter so that the 4 | # invoker can define read config values. Supports proxies. 5 | # 6 | # @example Disable/enable error notifications based on the remote value 7 | # RemoteSettings.poll do |data| 8 | # config.error_notifications = data.error_notifications? 9 | # end 10 | # 11 | # @since v5.0.0 12 | # @api private 13 | class RemoteSettings 14 | include Airbrake::Loggable 15 | 16 | # @return [Hash{Symbol=>String}] metadata to be attached to every GET 17 | # request 18 | QUERY_PARAMS = URI.encode_www_form( 19 | notifier_name: Airbrake::NOTIFIER_INFO[:name], 20 | notifier_version: Airbrake::NOTIFIER_INFO[:version], 21 | os: RUBY_PLATFORM, 22 | language: "#{RUBY_ENGINE}/#{RUBY_VERSION}".freeze, 23 | ).freeze 24 | 25 | # @return [String] 26 | HTTP_OK = '200'.freeze 27 | 28 | # Polls remote config of the given project. 29 | # 30 | # @param [Integer] project_id 31 | # @param [String] host 32 | # @yield [data] 33 | # @yieldparam data [Airbrake::RemoteSettings::SettingsData] 34 | # @return [Airbrake::RemoteSettings] 35 | def self.poll(project_id, host, &block) 36 | new(project_id, host, &block).poll 37 | end 38 | 39 | # @param [Integer] project_id 40 | # @yield [data] 41 | # @yieldparam data [Airbrake::RemoteSettings::SettingsData] 42 | def initialize(project_id, host, &block) 43 | @data = SettingsData.new(project_id, {}) 44 | @host = host 45 | @block = block 46 | @config = Airbrake::Config.instance 47 | @poll = nil 48 | end 49 | 50 | # Polls remote config of the given project in background. 51 | # 52 | # @return [self] 53 | def poll 54 | @poll ||= Thread.new do 55 | @block.call(@data) 56 | 57 | loop do 58 | @block.call(@data.merge!(fetch_config)) 59 | sleep(@data.interval) 60 | end 61 | end 62 | 63 | self 64 | end 65 | 66 | # Stops the background poller thread. 67 | # 68 | # @return [void] 69 | def stop_polling 70 | @poll.kill if @poll 71 | end 72 | 73 | private 74 | 75 | def fetch_config 76 | uri = build_config_uri 77 | https = build_https(uri) 78 | req = Net::HTTP::Get.new(uri.request_uri) 79 | response = nil 80 | 81 | begin 82 | response = https.request(req) 83 | rescue StandardError => ex 84 | reason = "#{LOG_LABEL} HTTP error: #{ex}" 85 | logger.error(reason) 86 | return {} 87 | end 88 | 89 | unless response.code == HTTP_OK 90 | logger.error(response.body) 91 | return {} 92 | end 93 | 94 | json = nil 95 | begin 96 | json = JSON.parse(response.body) 97 | rescue JSON::ParserError => ex 98 | logger.error(ex) 99 | return {} 100 | end 101 | 102 | json 103 | end 104 | 105 | def build_config_uri 106 | uri = URI(@data.config_route(@host)) 107 | uri.query = QUERY_PARAMS 108 | uri 109 | end 110 | 111 | def build_https(uri) 112 | Net::HTTP.new(uri.host, uri.port, *proxy_params).tap do |https| 113 | https.use_ssl = uri.is_a?(URI::HTTPS) 114 | if @config.timeout 115 | https.open_timeout = @config.timeout 116 | https.read_timeout = @config.timeout 117 | end 118 | end 119 | end 120 | 121 | def proxy_params 122 | return unless @config.proxy.key?(:host) 123 | 124 | [@config.proxy[:host], @config.proxy[:port], @config.proxy[:user], 125 | @config.proxy[:password]] 126 | end 127 | end 128 | end 129 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | require 'simplecov' 2 | SimpleCov.start if ENV['COVERAGE'] 3 | 4 | require 'airbrake-ruby' 5 | 6 | require 'rspec/its' 7 | 8 | require 'webmock' 9 | require 'webmock/rspec' 10 | require 'pry' 11 | 12 | require 'pathname' 13 | require 'webrick' 14 | require 'English' 15 | require 'base64' 16 | 17 | require 'helpers' 18 | 19 | RSpec.configure do |c| 20 | c.order = 'random' 21 | c.color = true 22 | c.disable_monkey_patching! 23 | c.include Helpers 24 | end 25 | 26 | Thread.abort_on_exception = true 27 | 28 | WebMock.disable_net_connect!(allow_localhost: true) 29 | 30 | class AirbrakeTestError < RuntimeError 31 | attr_reader :backtrace 32 | 33 | def initialize(*) 34 | super 35 | # rubocop:disable Layout/LineLength 36 | @backtrace = [ 37 | "/home/kyrylo/code/airbrake/ruby/spec/spec_helper.rb:23:in `'", 38 | "/opt/rubies/ruby-2.2.2/lib/ruby/2.2.0/rubygems/core_ext/kernel_require.rb:54:in `require'", 39 | "/opt/rubies/ruby-2.2.2/lib/ruby/2.2.0/rubygems/core_ext/kernel_require.rb:54:in `require'", 40 | "/home/kyrylo/code/airbrake/ruby/spec/airbrake_spec.rb:1:in `'", 41 | "/home/kyrylo/.gem/ruby/2.2.2/gems/rspec-core-3.3.2/lib/rspec/core/configuration.rb:1327:in `load'", 42 | "/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'", 43 | "/home/kyrylo/.gem/ruby/2.2.2/gems/rspec-core-3.3.2/lib/rspec/core/configuration.rb:1325:in `each'", 44 | "/home/kyrylo/.gem/ruby/2.2.2/gems/rspec-core-3.3.2/lib/rspec/core/configuration.rb:1325:in `load_spec_files'", 45 | "/home/kyrylo/.gem/ruby/2.2.2/gems/rspec-core-3.3.2/lib/rspec/core/runner.rb:102:in `setup'", 46 | "/home/kyrylo/.gem/ruby/2.2.2/gems/rspec-core-3.3.2/lib/rspec/core/runner.rb:88:in `run'", 47 | "/home/kyrylo/.gem/ruby/2.2.2/gems/rspec-core-3.3.2/lib/rspec/core/runner.rb:73:in `run'", 48 | "/home/kyrylo/.gem/ruby/2.2.2/gems/rspec-core-3.3.2/lib/rspec/core/runner.rb:41:in `invoke'", 49 | "/home/kyrylo/.gem/ruby/2.2.2/gems/rspec-core-3.3.2/exe/rspec:4:in `
'", 50 | ] 51 | # rubocop:enable Layout/LineLength 52 | end 53 | 54 | # rubocop:disable Naming/AccessorMethodName 55 | def set_backtrace(backtrace) 56 | @backtrace = backtrace 57 | end 58 | # rubocop:enable Naming/AccessorMethodName 59 | 60 | def message 61 | 'App crashed!' 62 | end 63 | end 64 | 65 | class JavaAirbrakeTestError < AirbrakeTestError 66 | def initialize(*) 67 | super 68 | # rubocop:disable Layout/LineLength 69 | @backtrace = [ 70 | "org.jruby.java.invokers.InstanceMethodInvoker.call(InstanceMethodInvoker.java:26)", 71 | "org.jruby.ir.interpreter.Interpreter.INTERPRET_EVAL(Interpreter.java:126)", 72 | "org.jruby.RubyKernel$INVOKER$s$0$3$eval19.call(RubyKernel$INVOKER$s$0$3$eval19.gen)", 73 | "org.jruby.RubyKernel$INVOKER$s$0$0$loop.call(RubyKernel$INVOKER$s$0$0$loop.gen)", 74 | "org.jruby.runtime.IRBlockBody.doYield(IRBlockBody.java:139)", 75 | "org.jruby.RubyKernel$INVOKER$s$rbCatch19.call(RubyKernel$INVOKER$s$rbCatch19.gen)", 76 | "opt.rubies.jruby_minus_9_dot_0_dot_0_dot_0.bin.irb.invokeOther4:start(/opt/rubies/jruby-9.0.0.0/bin/irb)", 77 | "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)", 78 | "org.jruby.ir.Compiler$1.load(Compiler.java:111)", 79 | "org.jruby.Main.run(Main.java:225)", 80 | "org.jruby.Main.main(Main.java:197)", 81 | ] 82 | # rubocop:enable Layout/LineLength 83 | end 84 | 85 | def is_a?(*) 86 | true 87 | end 88 | end 89 | 90 | class Ruby21Error < RuntimeError 91 | attr_accessor :cause 92 | 93 | def self.raise_error(msg) 94 | ex = new(msg) 95 | ex.cause = $ERROR_INFO 96 | 97 | raise ex 98 | end 99 | end 100 | -------------------------------------------------------------------------------- /spec/code_hunk_spec.rb: -------------------------------------------------------------------------------- 1 | RSpec.describe Airbrake::CodeHunk do 2 | subject(:code_hunk) { described_class.new } 3 | 4 | after do 5 | %w[empty_file.rb code.rb banana.rb short_file.rb long_line.txt].each do |f| 6 | Airbrake::FileCache[project_root_path(f)] = nil 7 | end 8 | end 9 | 10 | describe "#to_h" do 11 | context "when file is empty" do 12 | subject do 13 | described_class.new.get(project_root_path('empty_file.rb'), 1) 14 | end 15 | 16 | it { is_expected.to eq(1 => '') } 17 | end 18 | 19 | context "when line is nil" do 20 | subject { described_class.new.get(project_root_path('code.rb'), nil) } 21 | 22 | it { is_expected.to be_nil } 23 | end 24 | 25 | context "when a file doesn't exist" do 26 | subject { described_class.new.get(project_root_path('banana.rb'), 1) } 27 | 28 | it { is_expected.to be_nil } 29 | end 30 | 31 | context "when a file has less than NLINES lines before start line" do 32 | subject(:code_hunk) do 33 | described_class.new.get(project_root_path('code.rb'), 1) 34 | end 35 | 36 | it do 37 | expect(code_hunk).to( 38 | eq( 39 | 1 => 'module Airbrake', 40 | 2 => ' ##', 41 | # rubocop:disable Layout/LineLength 42 | 3 => ' # Represents a chunk of information that is meant to be either sent to', 43 | # rubocop:enable Layout/LineLength 44 | ), 45 | ) 46 | end 47 | end 48 | 49 | context "when a file has less than NLINES lines after end line" do 50 | subject(:code_hunk) do 51 | described_class.new.get(project_root_path('code.rb'), 222) 52 | end 53 | 54 | it do 55 | expect(code_hunk).to( 56 | eq( 57 | 220 => ' end', 58 | 221 => 'end', 59 | ), 60 | ) 61 | end 62 | end 63 | 64 | context "when a file has less than NLINES lines before and after" do 65 | subject(:code_hunk) do 66 | described_class.new.get(project_root_path('short_file.rb'), 2) 67 | end 68 | 69 | it do 70 | expect(code_hunk).to( 71 | eq( 72 | 1 => 'module Banana', 73 | 2 => ' attr_reader :bingo', 74 | 3 => 'end', 75 | ), 76 | ) 77 | end 78 | end 79 | 80 | context "when a file has enough lines before and after" do 81 | subject(:code_hunk) do 82 | described_class.new.get(project_root_path('code.rb'), 100) 83 | end 84 | 85 | it do 86 | expect(code_hunk).to( 87 | eq( 88 | 98 => ' return json if json && json.bytesize <= MAX_NOTICE_SIZE', 89 | 99 => ' end', 90 | 100 => '', 91 | 101 => ' break if truncate == 0', 92 | 102 => ' end', 93 | ), 94 | ) 95 | end 96 | end 97 | 98 | context "when a line exceeds the length limit" do 99 | subject(:code_hunk) do 100 | described_class.new.get(project_root_path('long_line.txt'), 1) 101 | end 102 | 103 | it "strips the line" do 104 | expect(code_hunk[1]).to eq("l#{'o' * 196}ng") 105 | end 106 | end 107 | 108 | context "when an error occurrs while fetching code" do 109 | before do 110 | allow(Airbrake::Loggable.instance).to receive(:error) 111 | allow(Airbrake::FileCache).to receive(:[]).and_raise(Errno::EACCES) 112 | end 113 | 114 | it "logs error and returns nil" do 115 | expect(code_hunk.get(project_root_path('code.rb'), 1)).to( 116 | eq(1 => ''), 117 | ) 118 | expect(Airbrake::Loggable.instance).to have_received(:error).with( 119 | /can't read code hunk.+Permission denied/, 120 | ) 121 | end 122 | end 123 | end 124 | end 125 | -------------------------------------------------------------------------------- /lib/airbrake-ruby/remote_settings/settings_data.rb: -------------------------------------------------------------------------------- 1 | module Airbrake 2 | class RemoteSettings 3 | # SettingsData is a container, which wraps JSON payload returned by the 4 | # remote settings API. It exposes the payload via convenient methods and 5 | # also ensures that in case some data from the payload is missing, a default 6 | # value would be returned instead. 7 | # 8 | # @example 9 | # # Create the object and pass initial data (empty hash). 10 | # settings_data = SettingsData.new({}) 11 | # 12 | # settings_data.interval #=> 600 13 | # 14 | # @since v5.0.0 15 | # @api private 16 | class SettingsData 17 | # @return [Integer] how frequently we should poll the config API 18 | DEFAULT_INTERVAL = 600 19 | 20 | # @return [String] API version of the S3 API to poll 21 | API_VER = '2020-06-18'.freeze 22 | 23 | # @return [String] what path to poll 24 | CONFIG_ROUTE_PATTERN = 25 | "%s/#{API_VER}/config/%s/config.json".freeze 26 | 27 | # @return [Hash{Symbol=>String}] the hash of all supported settings where 28 | # the value is the name of the setting returned by the API 29 | SETTINGS = { 30 | errors: 'errors'.freeze, 31 | apm: 'apm'.freeze, 32 | }.freeze 33 | 34 | # @param [Integer] project_id 35 | # @param [Hash{String=>Object}] data 36 | def initialize(project_id, data) 37 | @project_id = project_id 38 | @data = data 39 | end 40 | 41 | # Merges the given +hash+ with internal data. 42 | # 43 | # @param [Hash{String=>Object}] hash 44 | # @return [self] 45 | def merge!(hash) 46 | @data.merge!(hash) 47 | 48 | self 49 | end 50 | 51 | # @return [Integer] how frequently we should poll for the config 52 | def interval 53 | return DEFAULT_INTERVAL if !@data.key?('poll_sec') || !@data['poll_sec'] 54 | 55 | @data['poll_sec'] > 0 ? @data['poll_sec'] : DEFAULT_INTERVAL 56 | end 57 | 58 | # @param [String] remote_config_host 59 | # @return [String] where the config is stored on S3. 60 | def config_route(remote_config_host) 61 | if @data['config_route'] && !@data['config_route'].empty? 62 | return "#{remote_config_host.chomp('/')}/#{@data['config_route']}" 63 | end 64 | 65 | format( 66 | CONFIG_ROUTE_PATTERN, 67 | host: remote_config_host.chomp('/'), 68 | project_id: @project_id, 69 | ) 70 | end 71 | 72 | # @return [Boolean] whether error notifications are enabled 73 | def error_notifications? 74 | return true unless (s = find_setting(SETTINGS[:errors])) 75 | 76 | s['enabled'] 77 | end 78 | 79 | # @return [Boolean] whether APM is enabled 80 | def performance_stats? 81 | return true unless (s = find_setting(SETTINGS[:apm])) 82 | 83 | s['enabled'] 84 | end 85 | 86 | # @return [String, nil] the host, which provides the API endpoint to which 87 | # exceptions should be sent 88 | def error_host 89 | return unless (s = find_setting(SETTINGS[:errors])) 90 | 91 | s['endpoint'] 92 | end 93 | 94 | # @return [String, nil] the host, which provides the API endpoint to which 95 | # APM data should be sent 96 | def apm_host 97 | return unless (s = find_setting(SETTINGS[:apm])) 98 | 99 | s['endpoint'] 100 | end 101 | 102 | # @return [Hash{String=>Object}] raw representation of JSON payload 103 | def to_h 104 | @data.dup 105 | end 106 | 107 | private 108 | 109 | def find_setting(name) 110 | return unless @data.key?('settings') 111 | 112 | @data['settings'].find { |s| s['name'] == name } 113 | end 114 | end 115 | end 116 | end 117 | -------------------------------------------------------------------------------- /spec/timed_trace_spec.rb: -------------------------------------------------------------------------------- 1 | RSpec.describe Airbrake::TimedTrace do 2 | subject(:timed_trace) { described_class.new } 3 | 4 | describe ".span" do 5 | it "returns a timed trace" do 6 | expect(described_class.span('operation') { anything }).to be_a(described_class) 7 | end 8 | 9 | it "returns a timed trace with a stopped span" do 10 | timed_trace = described_class.span('operation') { anything } 11 | expect(timed_trace.spans).to match('operation' => be > 0) 12 | end 13 | end 14 | 15 | describe "#span" do 16 | it "captures a span" do 17 | timed_trace.span('operation') { anything } 18 | expect(timed_trace.spans).to match('operation' => be > 0) 19 | end 20 | end 21 | 22 | describe "#start_span" do 23 | context "when called once" do 24 | it "returns true" do 25 | expect(timed_trace.start_span('operation')).to be(true) 26 | end 27 | end 28 | 29 | context "when called multiple times" do 30 | before { timed_trace.start_span('operation') } 31 | 32 | it "returns false" do 33 | expect(timed_trace.start_span('operation')).to be(false) 34 | end 35 | end 36 | 37 | context "when another span was started" do 38 | before { timed_trace.start_span('operation') } 39 | 40 | it "returns true" do 41 | expect(timed_trace.start_span('another operation')).to be(true) 42 | end 43 | end 44 | 45 | context "when #spans was called" do 46 | before { timed_trace.start_span('operation') } 47 | 48 | it "returns spans with zero values" do 49 | expect(timed_trace.spans).to eq('operation' => 0.0) 50 | end 51 | end 52 | end 53 | 54 | describe "#stop_span" do 55 | context "when #start_span wasn't invoked" do 56 | it "returns false" do 57 | expect(timed_trace.stop_span('operation')).to be(false) 58 | end 59 | end 60 | 61 | context "when #start_span was invoked" do 62 | before { timed_trace.start_span('operation') } 63 | 64 | it "returns true" do 65 | expect(timed_trace.stop_span('operation')).to be(true) 66 | end 67 | end 68 | 69 | context "when multiple spans were started" do 70 | before do 71 | timed_trace.start_span('operation') 72 | timed_trace.start_span('another operation') 73 | end 74 | 75 | context "and when stopping in LIFO order" do 76 | it "returns true for all spans" do 77 | expect(timed_trace.stop_span('another operation')).to be(true) 78 | expect(timed_trace.stop_span('operation')).to be(true) 79 | end 80 | end 81 | 82 | context "and when stopping in FIFO order" do 83 | it "returns true for all spans" do 84 | expect(timed_trace.stop_span('operation')).to be(true) 85 | expect(timed_trace.stop_span('another operation')).to be(true) 86 | end 87 | end 88 | end 89 | end 90 | 91 | describe "#spans" do 92 | context "when no spans were captured" do 93 | it "returns an empty hash" do 94 | expect(timed_trace.spans).to eq({}) 95 | end 96 | end 97 | 98 | context "when a span was captured" do 99 | before do 100 | timed_trace.start_span('operation') 101 | timed_trace.stop_span('operation') 102 | end 103 | 104 | it "returns a Hash with the corresponding span" do 105 | timed_trace.stop_span('operation') 106 | expect(timed_trace.spans).to match('operation' => be > 0) 107 | end 108 | end 109 | 110 | context "when multiple spans were captured" do 111 | before do 112 | timed_trace.start_span('operation') 113 | timed_trace.stop_span('operation') 114 | 115 | timed_trace.start_span('another operation') 116 | timed_trace.stop_span('another operation') 117 | end 118 | 119 | it "returns a Hash with all spans" do 120 | expect(timed_trace.spans).to match( 121 | 'operation' => be > 0, 122 | 'another operation' => be > 0, 123 | ) 124 | end 125 | end 126 | end 127 | end 128 | -------------------------------------------------------------------------------- /lib/airbrake-ruby/truncator.rb: -------------------------------------------------------------------------------- 1 | module Airbrake 2 | # This class is responsible for truncation of too big objects. Mainly, you 3 | # should use it for simple objects such as strings, hashes, & arrays. 4 | # 5 | # @api private 6 | # @since v1.0.0 7 | class Truncator 8 | # @return [Hash] the options for +String#encode+ 9 | ENCODING_OPTIONS = { invalid: :replace, undef: :replace }.freeze 10 | 11 | # @return [String] the temporary encoding to be used when fixing invalid 12 | # strings with +ENCODING_OPTIONS+ 13 | TEMP_ENCODING = 'utf-16'.freeze 14 | 15 | # @return [Array] encodings that are eligible for fixing invalid 16 | # characters 17 | SUPPORTED_ENCODINGS = [Encoding::UTF_8, Encoding::ASCII].freeze 18 | 19 | # @return [String] what to append when something is a circular reference 20 | CIRCULAR = '[Circular]'.freeze 21 | 22 | # @return [String] what to append when something is truncated 23 | TRUNCATED = '[Truncated]'.freeze 24 | 25 | # @return [Array] The types that can contain references to itself 26 | CIRCULAR_TYPES = [Array, Hash, Set].freeze 27 | 28 | # @param [Integer] max_size maximum size of hashes, arrays and strings 29 | def initialize(max_size) 30 | @max_size = max_size 31 | end 32 | 33 | # Performs deep truncation of arrays, hashes, sets & strings. Uses a 34 | # placeholder for recursive objects (`[Circular]`). 35 | # 36 | # @param [Object] object The object to truncate 37 | # @param [Set] seen The cache that helps to detect recursion 38 | # @return [Object] truncated object 39 | def truncate(object, seen = Set.new) 40 | if seen.include?(object.object_id) 41 | return CIRCULAR if CIRCULAR_TYPES.any? { |t| object.is_a?(t) } 42 | 43 | return object 44 | end 45 | truncate_object(object, seen << object.object_id) 46 | end 47 | 48 | # Reduces maximum allowed size of hashes, arrays, sets & strings by half. 49 | # @return [Integer] current +max_size+ value 50 | def reduce_max_size 51 | @max_size /= 2 52 | end 53 | 54 | private 55 | 56 | def truncate_object(object, seen) 57 | case object 58 | when Hash then truncate_hash(object, seen) 59 | when Array then truncate_array(object, seen) 60 | when Set then truncate_set(object, seen) 61 | when String then truncate_string(object) 62 | when Numeric, TrueClass, FalseClass, Symbol, NilClass then object 63 | else 64 | truncate_string(stringify_object(object)) 65 | end 66 | end 67 | 68 | def truncate_string(str) 69 | fixed_str = replace_invalid_characters(str) 70 | return fixed_str if fixed_str.length <= @max_size 71 | 72 | (fixed_str.slice(0, @max_size) + TRUNCATED).freeze 73 | end 74 | 75 | def stringify_object(object) 76 | object.to_json 77 | rescue *Notice::JSON_EXCEPTIONS 78 | object.to_s 79 | end 80 | 81 | def truncate_hash(hash, seen) 82 | truncated_hash = {} 83 | hash.each_with_index do |(key, val), idx| 84 | break if idx + 1 > @max_size 85 | 86 | truncated_hash[key] = truncate(val, seen) 87 | end 88 | 89 | truncated_hash.freeze 90 | end 91 | 92 | def truncate_array(array, seen) 93 | array.slice(0, @max_size).map! { |elem| truncate(elem, seen) }.freeze 94 | end 95 | 96 | def truncate_set(set, seen) 97 | truncated_set = Set.new 98 | 99 | set.each do |elem| 100 | truncated_set << truncate(elem, seen) 101 | break if truncated_set.size >= @max_size 102 | end 103 | 104 | truncated_set.freeze 105 | end 106 | 107 | # Replaces invalid characters in a string with arbitrary encoding. 108 | # 109 | # @param [String] str The string to replace characters 110 | # @return [String] a UTF-8 encoded string 111 | # @see https://github.com/flori/json/commit/3e158410e81f94dbbc3da6b7b35f4f64983aa4e3 112 | def replace_invalid_characters(str) 113 | utf8_string = SUPPORTED_ENCODINGS.include?(str.encoding) 114 | return str if utf8_string && str.valid_encoding? 115 | 116 | temp_str = str.dup 117 | temp_str.encode!(TEMP_ENCODING, **ENCODING_OPTIONS) if utf8_string 118 | temp_str.encode!('utf-8', **ENCODING_OPTIONS) 119 | end 120 | end 121 | end 122 | -------------------------------------------------------------------------------- /spec/nested_exception_spec.rb: -------------------------------------------------------------------------------- 1 | RSpec.describe Airbrake::NestedException do 2 | describe "#as_json" do 3 | context "given exceptions with backtraces" do 4 | # rubocop:disable RSpec/MultipleExpectations 5 | it "unwinds nested exceptions" do 6 | begin 7 | raise AirbrakeTestError 8 | rescue AirbrakeTestError 9 | Ruby21Error.raise_error('bingo') 10 | end 11 | rescue Ruby21Error => ex 12 | nested_exception = described_class.new(ex) 13 | exceptions = nested_exception.as_json 14 | 15 | expect(exceptions.size).to eq(2) 16 | expect(exceptions[0][:message]).to eq('bingo') 17 | expect(exceptions[1][:message]).to eq('App crashed!') 18 | expect(exceptions[0][:backtrace]).not_to be_empty 19 | expect(exceptions[1][:backtrace]).not_to be_empty 20 | end 21 | # rubocop:enable RSpec/MultipleExpectations 22 | 23 | # rubocop:disable RSpec/MultipleExpectations 24 | it "unwinds no more than 3 nested exceptions" do 25 | begin 26 | raise AirbrakeTestError 27 | rescue AirbrakeTestError 28 | begin 29 | Ruby21Error.raise_error('bongo') 30 | rescue Ruby21Error 31 | begin 32 | Ruby21Error.raise_error('bango') 33 | rescue Ruby21Error 34 | Ruby21Error.raise_error('bingo') 35 | end 36 | end 37 | end 38 | rescue Ruby21Error => ex 39 | nested_exception = described_class.new(ex) 40 | exceptions = nested_exception.as_json 41 | 42 | expect(exceptions.size).to eq(3) 43 | expect(exceptions[0][:message]).to eq('bingo') 44 | expect(exceptions[1][:message]).to eq('bango') 45 | expect(exceptions[2][:message]).to eq('bongo') 46 | expect(exceptions[0][:backtrace]).not_to be_empty 47 | expect(exceptions[1][:backtrace]).not_to be_empty 48 | end 49 | # rubocop:enable RSpec/MultipleExpectations 50 | 51 | context "and when the exception message contains error highlighting" do 52 | it "strips the highlighting part from the message" do 53 | raise "undefined method `[]' for nil:NilClass\n\n " \ 54 | "data[:result].first[:first_name]\n ^^^^^^^^^^^^^" 55 | rescue StandardError => ex 56 | nested_exception = described_class.new(ex) 57 | exceptions = nested_exception.as_json 58 | 59 | expect(exceptions.size).to eq(1) 60 | expect(exceptions[0][:message]) 61 | .to eq("undefined method `[]' for nil:NilClass") 62 | end 63 | end 64 | end 65 | 66 | context "given exceptions without backtraces" do 67 | # rubocop:disable RSpec/MultipleExpectations 68 | it "sets backtrace to nil" do 69 | begin 70 | raise AirbrakeTestError 71 | rescue AirbrakeTestError => ex2 72 | ex2.set_backtrace([]) 73 | Ruby21Error.raise_error('bingo') 74 | end 75 | rescue Ruby21Error => ex1 76 | ex1.set_backtrace([]) 77 | nested_exception = described_class.new(ex1) 78 | exceptions = nested_exception.as_json 79 | 80 | expect(exceptions.size).to eq(2) 81 | expect(exceptions[0][:backtrace]).to be_empty 82 | expect(exceptions[1][:backtrace]).to be_empty 83 | end 84 | # rubocop:enable RSpec/MultipleExpectations 85 | end 86 | end 87 | 88 | context "when the exception's message contains invalid characters" do 89 | it "replaces those characters without failing" do 90 | JSON.parse(Marshal.dump(Time.now)) 91 | rescue JSON::ParserError => e 92 | exceptions = described_class.new(e).as_json 93 | expect(exceptions.first[:message]).to match('unexpected token at') 94 | else 95 | raise 'expected JSON.parse to raise JSON::ParserError but nothing was raised' 96 | end 97 | end 98 | 99 | context "when the exception's message is nil" do 100 | subject(:exception) { Class.new(StandardError) { def message; end }.new } 101 | 102 | it "leaves the message field empty" do 103 | expect(described_class.new(exception).as_json).to eq( 104 | [ 105 | { 106 | backtrace: [], 107 | message: nil, 108 | type: nil, 109 | }, 110 | ], 111 | ) 112 | end 113 | end 114 | end 115 | -------------------------------------------------------------------------------- /lib/airbrake-ruby/sync_sender.rb: -------------------------------------------------------------------------------- 1 | module Airbrake 2 | # Responsible for sending data to Airbrake synchronously via PUT or POST 3 | # methods. Supports proxies. 4 | # 5 | # @see AsyncSender 6 | # @api private 7 | # @since v1.0.0 8 | class SyncSender 9 | # @return [String] body for HTTP requests 10 | CONTENT_TYPE = 'application/json'.freeze 11 | 12 | # @return [Array] response codes that are good to be backlogged 13 | # @since v6.2.0 14 | BACKLOGGABLE_STATUS_CODES = [ 15 | Response::BAD_REQUEST, 16 | Response::FORBIDDEN, 17 | Response::ENHANCE_YOUR_CALM, 18 | Response::REQUEST_TIMEOUT, 19 | Response::CONFLICT, 20 | Response::TOO_MANY_REQUESTS, 21 | Response::INTERNAL_SERVER_ERROR, 22 | Response::BAD_GATEWAY, 23 | Response::GATEWAY_TIMEOUT, 24 | ].freeze 25 | 26 | include Loggable 27 | 28 | # @param [Symbol] method HTTP method to use to send payload 29 | def initialize(method = :post) 30 | @config = Airbrake::Config.instance 31 | @method = method 32 | @rate_limit_reset = Time.now 33 | @backlog = Backlog.new(self) if @config.backlog 34 | end 35 | 36 | # Sends a POST or PUT request to the given +endpoint+ with the +data+ payload. 37 | # 38 | # @param [#to_json] data 39 | # @param [URI::HTTPS] endpoint 40 | # @return [Hash{String=>String}] the parsed HTTP response 41 | def send(data, promise, endpoint = @config.error_endpoint) 42 | return promise if rate_limited_ip?(promise) 43 | 44 | req = build_request(endpoint, data) 45 | return promise if missing_body?(req, promise) 46 | 47 | begin 48 | response = build_https(endpoint).request(req) 49 | rescue StandardError => ex 50 | reason = "#{LOG_LABEL} HTTP error: #{ex}" 51 | logger.error(reason) 52 | return promise.reject(reason) 53 | end 54 | 55 | parsed_resp = Response.parse(response) 56 | handle_rate_limit(parsed_resp) 57 | @backlog << [data, endpoint] if add_to_backlog?(parsed_resp) 58 | 59 | return promise.reject(parsed_resp['error']) if parsed_resp.key?('error') 60 | 61 | promise.resolve(parsed_resp) 62 | end 63 | 64 | # Closes all the resources that this sender has allocated. 65 | # 66 | # @return [void] 67 | # @since v6.2.0 68 | def close 69 | @backlog.close 70 | end 71 | 72 | private 73 | 74 | def build_https(uri) 75 | Net::HTTP.new(uri.host, uri.port, *proxy_params).tap do |https| 76 | https.use_ssl = uri.is_a?(URI::HTTPS) 77 | if @config.timeout 78 | https.open_timeout = @config.timeout 79 | https.read_timeout = @config.timeout 80 | end 81 | end 82 | end 83 | 84 | def build_request(uri, data) 85 | req = 86 | if @method == :put 87 | Net::HTTP::Put.new(uri.request_uri) 88 | else 89 | Net::HTTP::Post.new(uri.request_uri) 90 | end 91 | 92 | build_request_body(req, data) 93 | end 94 | 95 | def build_request_body(req, data) 96 | req.body = data.to_json 97 | 98 | req['Authorization'] = "Bearer #{@config.project_key}" 99 | req['Content-Type'] = CONTENT_TYPE 100 | req['User-Agent'] = 101 | "#{Airbrake::NOTIFIER_INFO[:name]}/#{Airbrake::AIRBRAKE_RUBY_VERSION} " \ 102 | "Ruby/#{RUBY_VERSION}" 103 | 104 | req 105 | end 106 | 107 | def handle_rate_limit(parsed_resp) 108 | return unless parsed_resp.key?('rate_limit_reset') 109 | 110 | @rate_limit_reset = parsed_resp['rate_limit_reset'] 111 | end 112 | 113 | def add_to_backlog?(parsed_resp) 114 | return unless @backlog 115 | return unless parsed_resp.key?('code') 116 | 117 | BACKLOGGABLE_STATUS_CODES.include?(parsed_resp['code']) 118 | end 119 | 120 | def proxy_params 121 | return unless @config.proxy.key?(:host) 122 | 123 | [@config.proxy[:host], @config.proxy[:port], @config.proxy[:user], 124 | @config.proxy[:password]] 125 | end 126 | 127 | def rate_limited_ip?(promise) 128 | rate_limited = Time.now < @rate_limit_reset 129 | promise.reject("#{LOG_LABEL} IP is rate limited") if rate_limited 130 | rate_limited 131 | end 132 | 133 | def missing_body?(req, promise) 134 | missing = req.body.nil? 135 | 136 | if missing 137 | reason = "#{LOG_LABEL} data was not sent because of missing body" 138 | logger.error(reason) 139 | promise.reject(reason) 140 | end 141 | 142 | missing 143 | end 144 | end 145 | end 146 | -------------------------------------------------------------------------------- /lib/airbrake-ruby/notice_notifier.rb: -------------------------------------------------------------------------------- 1 | module Airbrake 2 | # NoticeNotifier is reponsible for sending notices to Airbrake. It supports 3 | # synchronous and asynchronous delivery. 4 | # 5 | # @see Airbrake::Config The list of options 6 | # @since v1.0.0 7 | # @api public 8 | class NoticeNotifier 9 | # @return [Array] filters to be executed first 10 | DEFAULT_FILTERS = [ 11 | Airbrake::Filters::SystemExitFilter, 12 | Airbrake::Filters::GemRootFilter, 13 | 14 | # Optional filters (must be included by users): 15 | # Airbrake::Filters::ThreadFilter 16 | ].freeze 17 | 18 | include Inspectable 19 | include Loggable 20 | 21 | def initialize 22 | @config = Airbrake::Config.instance 23 | @filter_chain = FilterChain.new 24 | @async_sender = AsyncSender.new(:post, self.class.name) 25 | @sync_sender = SyncSender.new 26 | 27 | DEFAULT_FILTERS.each { |filter| add_filter(filter.new) } 28 | 29 | add_filter(Airbrake::Filters::ContextFilter.new) 30 | add_filter(Airbrake::Filters::ExceptionAttributesFilter.new) 31 | end 32 | 33 | # @see Airbrake.notify 34 | def notify(exception, params = {}, &block) 35 | send_notice(exception, params, default_sender, &block) 36 | end 37 | 38 | # @see Airbrake.notify_sync 39 | def notify_sync(exception, params = {}, &block) 40 | send_notice(exception, params, @sync_sender, &block).value 41 | end 42 | 43 | # @see Airbrake.add_filte 44 | def add_filter(filter = nil, &block) 45 | @filter_chain.add_filter(block_given? ? block : filter) 46 | end 47 | 48 | # @see Airbrake.delete_filter 49 | def delete_filter(filter_class) 50 | @filter_chain.delete_filter(filter_class) 51 | end 52 | 53 | # @see Airbrake.build_notice 54 | def build_notice(exception, params = {}) 55 | if @async_sender.closed? 56 | raise Airbrake::Error, 57 | "Airbrake is closed; can't build exception: " \ 58 | "#{exception.class}: #{exception}" 59 | end 60 | 61 | if exception.is_a?(Airbrake::Notice) 62 | exception[:params].merge!(params) 63 | exception 64 | else 65 | Notice.new(convert_to_exception(exception), params.dup) 66 | end 67 | end 68 | 69 | # @see Airbrake.close 70 | def close 71 | @sync_sender.close 72 | @async_sender.close 73 | end 74 | 75 | # @see Airbrake.configured? 76 | def configured? 77 | @config.valid? 78 | end 79 | 80 | # @see Airbrake.merge_context 81 | def merge_context(context) 82 | Airbrake::Context.current.merge!(context) 83 | end 84 | 85 | # @return [Boolean] 86 | # @since v4.14.0 87 | def has_filter?(filter_class) # rubocop:disable Naming/PredicateName 88 | @filter_chain.includes?(filter_class) 89 | end 90 | 91 | private 92 | 93 | def convert_to_exception(ex) 94 | if ex.is_a?(Exception) || Backtrace.java_exception?(ex) 95 | # Manually created exceptions don't have backtraces, so we create a fake 96 | # one, whose first frame points to the place where Airbrake was called 97 | # (normally via `notify`). 98 | ex.set_backtrace(clean_backtrace) unless ex.backtrace 99 | return ex 100 | end 101 | 102 | e = RuntimeError.new(ex.to_s) 103 | e.set_backtrace(clean_backtrace) 104 | e 105 | end 106 | 107 | def send_notice(exception, params, sender) 108 | promise = @config.check_configuration 109 | return promise if promise.rejected? 110 | 111 | notice = build_notice(exception, params) 112 | yield notice if block_given? 113 | @filter_chain.refine(notice) 114 | 115 | promise = Airbrake::Promise.new 116 | return promise.reject("#{notice} was marked as ignored") if notice.ignored? 117 | 118 | sender.send(notice, promise) 119 | end 120 | 121 | def default_sender 122 | return @async_sender if @async_sender.has_workers? 123 | 124 | logger.warn( 125 | "#{LOG_LABEL} falling back to sync delivery because there are no " \ 126 | "running async workers", 127 | ) 128 | @sync_sender 129 | end 130 | 131 | def clean_backtrace 132 | caller_copy = Kernel.caller 133 | clean_bt = caller_copy.drop_while { |frame| frame.include?('/lib/airbrake') } 134 | 135 | # If true, then it's likely an internal library error. In this case return 136 | # at least some backtrace to simplify debugging. 137 | return caller_copy if clean_bt.empty? 138 | 139 | clean_bt 140 | end 141 | end 142 | end 143 | -------------------------------------------------------------------------------- /lib/airbrake-ruby/thread_pool.rb: -------------------------------------------------------------------------------- 1 | module Airbrake 2 | # ThreadPool implements a simple thread pool that can configure the number of 3 | # worker threads and the size of the queue to process. 4 | # 5 | # @example 6 | # # Initialize a new thread pool with 5 workers and a queue size of 100. Set 7 | # # the block to be run concurrently. 8 | # thread_pool = ThreadPool.new( 9 | # name: 'performance-notifier', 10 | # worker_size: 5, 11 | # queue_size: 100, 12 | # block: proc { |message| print "ECHO: #{message}..."} 13 | # ) 14 | # 15 | # # Send work. 16 | # 10.times { |i| thread_pool << i } 17 | # #=> ECHO: 0...ECHO: 1...ECHO: 2... 18 | # 19 | # @api private 20 | # @since v4.6.1 21 | class ThreadPool 22 | include Loggable 23 | 24 | # @return [ThreadGroup] the list of workers 25 | # @note This is exposed for eaiser unit testing 26 | attr_reader :workers 27 | 28 | def initialize(worker_size:, queue_size:, block:, name: nil) 29 | @name = name 30 | @worker_size = worker_size 31 | @queue_size = queue_size 32 | @block = block 33 | 34 | @queue = SizedQueue.new(queue_size) 35 | @workers = ThreadGroup.new 36 | @mutex = Mutex.new 37 | @pid = nil 38 | @closed = false 39 | 40 | has_workers? 41 | end 42 | 43 | # Adds a new message to the thread pool. Rejects messages if the queue is at 44 | # its capacity. 45 | # 46 | # @param [Object] message The message that gets passed to the block 47 | # @return [Boolean] true if the message was successfully sent to the pool, 48 | # false if the queue is full 49 | def <<(message) 50 | if backlog >= @queue_size 51 | logger.info do 52 | "#{LOG_LABEL} ThreadPool has reached its capacity of " \ 53 | "#{@queue_size} and the following message will not be " \ 54 | "processed: #{message.inspect}" 55 | end 56 | return false 57 | end 58 | 59 | @queue << message 60 | true 61 | end 62 | 63 | # @return [Integer] how big the queue is at the moment 64 | def backlog 65 | @queue.size 66 | end 67 | 68 | # Checks if a thread pool has any workers. A thread pool doesn't have any 69 | # workers only in two cases: when it was closed or when all workers 70 | # crashed. An *active* thread pool doesn't have any workers only when 71 | # something went wrong. 72 | # 73 | # Workers are expected to crash when you +fork+ the process the workers are 74 | # living in. In this case we detect a +fork+ and try to revive them here. 75 | # 76 | # Another possible scenario that crashes workers is when you close the 77 | # instance on +at_exit+, but some other +at_exit+ hook prevents the process 78 | # from exiting. 79 | # 80 | # @return [Boolean] true if an instance wasn't closed, but has no workers 81 | # @see https://goo.gl/oydz8h Example of at_exit that prevents exit 82 | def has_workers? 83 | @mutex.synchronize do 84 | return false if @closed 85 | 86 | if @pid != Process.pid && @workers.list.empty? 87 | @pid = Process.pid 88 | @workers = ThreadGroup.new 89 | spawn_workers 90 | end 91 | 92 | !@closed && @workers.list.any? 93 | end 94 | end 95 | 96 | # Closes the thread pool making it a no-op (it shut downs all worker 97 | # threads). Before closing, waits on all unprocessed tasks to be processed. 98 | # 99 | # @return [void] 100 | # @raise [Airbrake::Error] when invoked more than one time 101 | def close 102 | threads = @mutex.synchronize do 103 | raise Airbrake::Error, 'this thread pool is closed already' if @closed 104 | 105 | unless @queue.empty? 106 | msg = "#{LOG_LABEL} waiting to process #{@queue.size} task(s)..." 107 | logger.debug("#{msg} (Ctrl-C to abort)") 108 | end 109 | 110 | @worker_size.times { @queue << :stop } 111 | @closed = true 112 | @workers.list.dup 113 | end 114 | 115 | threads.each(&:join) 116 | logger.debug("#{LOG_LABEL} #{@name} thread pool closed") 117 | end 118 | 119 | def closed? 120 | @closed 121 | end 122 | 123 | def spawn_workers 124 | @worker_size.times { @workers.add(spawn_worker) } 125 | end 126 | 127 | private 128 | 129 | def spawn_worker 130 | Thread.new do 131 | while (message = @queue.pop) 132 | break if message == :stop 133 | 134 | @block.call(message) 135 | end 136 | end 137 | end 138 | end 139 | end 140 | -------------------------------------------------------------------------------- /lib/airbrake-ruby/response.rb: -------------------------------------------------------------------------------- 1 | module Airbrake 2 | # Parses responses coming from the Airbrake API. Handles HTTP errors by 3 | # logging them. 4 | # 5 | # @api private 6 | # @since v1.0.0 7 | module Response 8 | # @return [Integer] the limit of the response body 9 | TRUNCATE_LIMIT = 100 10 | 11 | # @return [Integer] HTTP code returned when the server cannot or will not 12 | # process the request due to something that is perceived to be a client 13 | # error 14 | # @since v6.2.0 15 | BAD_REQUEST = 400 16 | 17 | # @return [Integer] HTTP code returned when client request has not been 18 | # completed because it lacks valid authentication credentials for the 19 | # requested resource 20 | # @since v6.2.0 21 | UNAUTHORIZED = 401 22 | 23 | # @return [Integer] HTTP code returned when the server understands the 24 | # request but refuses to authorize it 25 | # @since v6.2.0 26 | FORBIDDEN = 403 27 | 28 | # @return [Integer] HTTP code returned when the server would like to shut 29 | # down this unused connection 30 | # @since v6.2.0 31 | REQUEST_TIMEOUT = 408 32 | 33 | # @return [Integer] HTTP code returned when there's a request conflict with 34 | # the current state of the target resource 35 | # @since v6.2.0 36 | CONFLICT = 409 37 | 38 | # @return [Integer] 39 | # @since v6.2.0 40 | ENHANCE_YOUR_CALM = 420 41 | 42 | # @return [Integer] HTTP code returned when an IP sends over 10k/min notices 43 | TOO_MANY_REQUESTS = 429 44 | 45 | # @return [Integer] HTTP code returned when the server encountered an 46 | # unexpected condition that prevented it from fulfilling the request 47 | # @since v6.2.0 48 | INTERNAL_SERVER_ERROR = 500 49 | 50 | # @return [Integer] HTTP code returened when the server, while acting as a 51 | # gateway or proxy, received an invalid response from the upstream server 52 | # @since v6.2.0 53 | BAD_GATEWAY = 502 54 | 55 | # @return [Integer] HTTP code returened when the server, while acting as a 56 | # gateway or proxy, did not get a response in time from the upstream 57 | # server that it needed in order to complete the request 58 | # @since v6.2.0 59 | GATEWAY_TIMEOUT = 504 60 | 61 | class << self 62 | include Loggable 63 | end 64 | 65 | # Parses HTTP responses from the Airbrake API. 66 | # 67 | # @param [Net::HTTPResponse] response 68 | # @return [Hash{String=>String}] parsed response 69 | # rubocop:disable Metrics/MethodLength, Metrics/AbcSize 70 | def self.parse(response) 71 | code = response.code.to_i 72 | body = response.body 73 | 74 | begin 75 | case code 76 | when 200, 204 77 | logger.debug("#{LOG_LABEL} #{name} (#{code}): #{body}") 78 | { response.msg => response.body } 79 | when 201 80 | parsed_body = JSON.parse(body) 81 | logger.debug("#{LOG_LABEL} #{name} (#{code}): #{parsed_body}") 82 | parsed_body 83 | when BAD_REQUEST, UNAUTHORIZED, FORBIDDEN, ENHANCE_YOUR_CALM 84 | parsed_body = JSON.parse(body) 85 | logger.error("#{LOG_LABEL} #{parsed_body['message']}") 86 | parsed_body.merge('code' => code, 'error' => parsed_body['message']) 87 | when TOO_MANY_REQUESTS 88 | parsed_body = JSON.parse(body) 89 | msg = "#{LOG_LABEL} #{parsed_body['message']}" 90 | logger.error(msg) 91 | { 92 | 'code' => code, 93 | 'error' => msg, 94 | 'rate_limit_reset' => rate_limit_reset(response), 95 | } 96 | else 97 | body_msg = truncated_body(body) 98 | logger.error("#{LOG_LABEL} unexpected code (#{code}). Body: #{body_msg}") 99 | { 'code' => code, 'error' => body_msg } 100 | end 101 | rescue StandardError => ex 102 | body_msg = truncated_body(body) 103 | logger.error("#{LOG_LABEL} error while parsing body (#{ex}). Body: #{body_msg}") 104 | { 'code' => code, 'error' => ex.inspect } 105 | end 106 | end 107 | # rubocop:enable Metrics/MethodLength, Metrics/AbcSize 108 | 109 | def self.truncated_body(body) 110 | if body.nil? 111 | '[EMPTY_BODY]'.freeze 112 | elsif body.length > TRUNCATE_LIMIT 113 | body[0..TRUNCATE_LIMIT] << '...' 114 | else 115 | body 116 | end 117 | end 118 | private_class_method :truncated_body 119 | 120 | def self.rate_limit_reset(response) 121 | Time.now + response['X-RateLimit-Delay'].to_i 122 | end 123 | private_class_method :rate_limit_reset 124 | end 125 | end 126 | -------------------------------------------------------------------------------- /spec/filters/git_revision_filter_spec.rb: -------------------------------------------------------------------------------- 1 | RSpec.describe Airbrake::Filters::GitRevisionFilter do 2 | subject(:git_revision_filter) { described_class.new('root/dir') } 3 | 4 | # 'let!', not 'let' to make sure Notice doesn't call File.exist? with 5 | # unexpected arguments. 6 | let!(:notice) { Airbrake::Notice.new(AirbrakeTestError.new) } 7 | 8 | context "when context/revision is defined" do 9 | it "doesn't attach anything to context/revision" do 10 | notice[:context][:revision] = '1.2.3' 11 | git_revision_filter.call(notice) 12 | expect(notice[:context][:revision]).to eq('1.2.3') 13 | end 14 | end 15 | 16 | context "when .git directory doesn't exist" do 17 | it "doesn't attach anything to context/revision" do 18 | git_revision_filter.call(notice) 19 | expect(notice[:context][:revision]).to be_nil 20 | end 21 | end 22 | 23 | context "when .git directory exists" do 24 | before do 25 | allow(File).to receive(:exist?).with('root/dir/.git').and_return(true) 26 | end 27 | 28 | context "and when HEAD doesn't exist" do 29 | before do 30 | allow(File).to receive(:exist?).with('root/dir/.git/HEAD').and_return(false) 31 | end 32 | 33 | it "doesn't attach anything to context/revision" do 34 | git_revision_filter.call(notice) 35 | expect(notice[:context][:revision]).to be_nil 36 | end 37 | end 38 | 39 | context "and when HEAD exists" do 40 | before do 41 | allow(File).to receive(:exist?).with('root/dir/.git/HEAD').and_return(true) 42 | end 43 | 44 | context "and also when HEAD doesn't start with 'ref: '" do 45 | before do 46 | allow(File).to( 47 | receive(:read).with('root/dir/.git/HEAD').and_return('refs/foo'), 48 | ) 49 | end 50 | 51 | it "attaches the content of HEAD to context/revision" do 52 | git_revision_filter.call(notice) 53 | expect(notice[:context][:revision]).to eq('refs/foo') 54 | end 55 | end 56 | 57 | context "and also when HEAD starts with 'ref: '" do 58 | before do 59 | allow(File).to( 60 | receive(:read).with('root/dir/.git/HEAD').and_return("ref: refs/foo\n"), 61 | ) 62 | end 63 | 64 | context "when the ref exists" do 65 | before do 66 | allow(File).to( 67 | receive(:exist?).with('root/dir/.git/refs/foo').and_return(true), 68 | ) 69 | allow(File).to( 70 | receive(:read).with('root/dir/.git/refs/foo').and_return("d34db33f\n"), 71 | ) 72 | end 73 | 74 | it "attaches the revision from the ref to context/revision" do 75 | git_revision_filter.call(notice) 76 | expect(notice[:context][:revision]).to eq('d34db33f') 77 | end 78 | end 79 | 80 | context "when the ref doesn't exist" do 81 | before do 82 | allow(File).to( 83 | receive(:exist?).with('root/dir/.git/refs/foo').and_return(false), 84 | ) 85 | end 86 | 87 | context "and when '.git/packed-refs' exists" do 88 | before do 89 | allow(File).to( 90 | receive(:exist?).with('root/dir/.git/packed-refs').and_return(true), 91 | ) 92 | allow(File).to( 93 | receive(:readlines).with('root/dir/.git/packed-refs').and_return( 94 | [ 95 | "# pack-refs with: peeled fully-peeled\n", 96 | "ccb316eecff79c7528d1ad43e5fa165f7a44d52e refs/tags/v3.0.30\n", 97 | "^d358900f73ee5bfd6ca3a592cf23ac6e82df83c1", 98 | "d34db33f refs/foo\n", 99 | ], 100 | ), 101 | ) 102 | end 103 | 104 | it "attaches the revision from 'packed-refs' to context/revision" do 105 | git_revision_filter.call(notice) 106 | expect(notice[:context][:revision]).to eq('d34db33f') 107 | end 108 | end 109 | 110 | context "and when '.git/packed-refs' doesn't exist" do 111 | before do 112 | allow(File).to( 113 | receive(:exist?).with('root/dir/.git/packed-refs').and_return(false), 114 | ) 115 | end 116 | 117 | it "attaches the content of HEAD to context/revision" do 118 | git_revision_filter.call(notice) 119 | expect(notice[:context][:revision]).to eq('refs/foo') 120 | end 121 | end 122 | end 123 | end 124 | end 125 | end 126 | end 127 | -------------------------------------------------------------------------------- /lib/airbrake-ruby/filters/sql_filter.rb: -------------------------------------------------------------------------------- 1 | module Airbrake 2 | module Filters 3 | # SqlFilter filters out sensitive data from {Airbrake::Query}. Sensitive 4 | # data is everything that is not table names or fields (e.g. column values 5 | # and such). 6 | # 7 | # Supports the following SQL dialects: 8 | # * PostgreSQL 9 | # * MySQL 10 | # * SQLite 11 | # * Cassandra 12 | # * Oracle 13 | # 14 | # @api private 15 | # @since v3.2.0 16 | class SqlFilter 17 | # @return [String] the label to replace real values of filtered query 18 | FILTERED = '?'.freeze 19 | 20 | # @return [String] the string that will replace the query in case we 21 | # cannot filter it 22 | ERROR_MSG = 'Error: Airbrake::Query was not filtered'.freeze 23 | 24 | # @return [Hash{Symbol=>Regexp}] matchers for certain features of SQL 25 | ALL_FEATURES = { 26 | # rubocop:disable Layout/LineLength 27 | single_quotes: /'(?:[^']|'')*?(?:\\'.*|'(?!'))/, 28 | double_quotes: /"(?:[^"]|"")*?(?:\\".*|"(?!"))/, 29 | dollar_quotes: /(\$(?!\d)[^$]*?\$).*?(?:\1|$)/, 30 | uuids: /\{?(?:[0-9a-fA-F]-*){32}\}?/, 31 | numeric_literals: /\b-?(?:[0-9]+\.)?[0-9]+([eE][+-]?[0-9]+)?\b/, 32 | boolean_literals: /\b(?:true|false|null)\b/i, 33 | hexadecimal_literals: /0x[0-9a-fA-F]+/, 34 | comments: /(?:#|--).*?(?=\r|\n|$)/i, 35 | multi_line_comments: %r{/\*(?:[^/]|/[^*])*?(?:\*/|/\*.*)}, 36 | oracle_quoted_strings: /q'\[.*?(?:\]'|$)|q'\{.*?(?:\}'|$)|q'<.*?(?:>'|$)|q'\(.*?(?:\)'|$)/, 37 | # rubocop:enable Layout/LineLength 38 | }.freeze 39 | 40 | # @return [Regexp] the regexp that is applied after the feature regexps 41 | # were used 42 | POST_FILTER = /(?<=[values|in ]\().+(?=\))/i.freeze 43 | 44 | # @return [Hash{Symbol=>Array}] a set of features that corresponds 45 | # to a certain dialect 46 | DIALECT_FEATURES = { 47 | default: ALL_FEATURES.keys, 48 | mysql: %i[ 49 | single_quotes double_quotes numeric_literals boolean_literals 50 | hexadecimal_literals comments multi_line_comments 51 | ].freeze, 52 | postgres: %i[ 53 | single_quotes dollar_quotes uuids numeric_literals boolean_literals 54 | comments multi_line_comments 55 | ].freeze, 56 | sqlite: %i[ 57 | single_quotes numeric_literals boolean_literals hexadecimal_literals 58 | comments multi_line_comments 59 | ].freeze, 60 | oracle: %i[ 61 | single_quotes oracle_quoted_strings numeric_literals comments 62 | multi_line_comments 63 | ].freeze, 64 | cassandra: %i[ 65 | single_quotes uuids numeric_literals boolean_literals 66 | hexadecimal_literals comments multi_line_comments 67 | ].freeze, 68 | }.freeze 69 | 70 | # @return [Hash{Symbol=>Regexp}] a set of regexps to check for unmatches 71 | # quotes after filtering (should be none) 72 | UNMATCHED_PAIR = { 73 | mysql: %r{'|"|/\*|\*/}, 74 | mysql2: %r{'|"|/\*|\*/}, 75 | postgres: %r{'|/\*|\*/|\$(?!\?)}, 76 | sqlite: %r{'|/\*|\*/}, 77 | cassandra: %r{'|/\*|\*/}, 78 | oracle: %r{'|/\*|\*/}, 79 | oracle_enhanced: %r{'|/\*|\*/}, 80 | }.freeze 81 | 82 | # @return [Array] the list of queries to be ignored 83 | IGNORED_QUERIES = [ 84 | /\ACOMMIT/i, 85 | /\ABEGIN/i, 86 | /\ASET/i, 87 | /\ASHOW/i, 88 | /\AWITH/i, 89 | /FROM pg_attribute/i, 90 | /FROM pg_index/i, 91 | /FROM pg_class/i, 92 | /FROM pg_type/i, 93 | ].freeze 94 | 95 | def initialize(dialect) 96 | @dialect = 97 | case dialect 98 | when /mysql/i then :mysql 99 | when /postgres/i then :postgres 100 | when /sqlite/i then :sqlite 101 | when /oracle/i then :oracle 102 | when /cassandra/i then :cassandra 103 | else 104 | :default 105 | end 106 | 107 | features = DIALECT_FEATURES[@dialect].map { |f| ALL_FEATURES[f] } 108 | @regexp = Regexp.union(features) 109 | end 110 | 111 | # @param [Airbrake::Query] metric 112 | def call(metric) 113 | return unless metric.respond_to?(:query) 114 | 115 | query = metric.query 116 | if IGNORED_QUERIES.any? { |q| q =~ query } 117 | metric.ignore! 118 | return 119 | end 120 | 121 | q = query.gsub(@regexp, FILTERED) 122 | q.gsub!(POST_FILTER, FILTERED) if q =~ POST_FILTER 123 | q = ERROR_MSG if UNMATCHED_PAIR[@dialect] =~ q 124 | metric.query = q 125 | end 126 | end 127 | end 128 | end 129 | -------------------------------------------------------------------------------- /lib/airbrake-ruby/filters/keys_filter.rb: -------------------------------------------------------------------------------- 1 | module Airbrake 2 | # Namespace for all standard filters. Custom filters can also go under this 3 | # namespace. 4 | module Filters 5 | # This is a filter helper that endows a class ability to filter notices' 6 | # payload based on the return value of the +should_filter?+ method that a 7 | # class that includes this module must implement. 8 | # 9 | # @see Notice 10 | # @see KeysAllowlist 11 | # @see KeysBlocklist 12 | # @api private 13 | module KeysFilter 14 | # @return [String] The label to replace real values of filtered payload 15 | FILTERED = '[Filtered]'.freeze 16 | 17 | # @return [Array] the array of classes instances of 18 | # which can compared with payload keys 19 | VALID_PATTERN_CLASSES = [String, Symbol, Regexp].freeze 20 | 21 | # @return [Array] parts of a Notice's payload that can be modified 22 | # by blocklist/allowlist filters 23 | FILTERABLE_KEYS = %i[environment session params].freeze 24 | 25 | # @return [Array] parts of a Notice's *context* payload that can 26 | # be modified by blocklist/allowlist filters 27 | FILTERABLE_CONTEXT_KEYS = %i[ 28 | user 29 | 30 | # Provided by Airbrake::Rack::HttpHeadersFilter 31 | headers 32 | referer 33 | httpMethod 34 | 35 | # Provided by Airbrake::Rack::ContextFilter 36 | userAddr 37 | userAgent 38 | ].freeze 39 | 40 | include Loggable 41 | 42 | # @return [Integer] 43 | attr_reader :weight 44 | 45 | # Creates a new KeysBlocklist or KeysAllowlist filter that uses the given 46 | # +patterns+ for filtering a notice's payload. 47 | # 48 | # @param [Array] patterns 49 | def initialize(patterns) 50 | @patterns = patterns 51 | @valid_patterns = false 52 | end 53 | 54 | # @!macro call_filter 55 | # This is a mandatory method required by any filter integrated with 56 | # FilterChain. 57 | # 58 | # @param [Notice] notice the notice to be filtered 59 | # @return [void] 60 | # @see FilterChain 61 | def call(notice) 62 | unless @valid_patterns 63 | eval_proc_patterns! 64 | validate_patterns 65 | end 66 | 67 | FILTERABLE_KEYS.each do |key| 68 | notice[key] = filter_hash(notice[key]) 69 | end 70 | 71 | FILTERABLE_CONTEXT_KEYS.each { |key| filter_context_key(notice, key) } 72 | 73 | return unless notice[:context][:url] 74 | 75 | filter_url(notice) 76 | end 77 | 78 | # @raise [NotImplementedError] if called directly 79 | def should_filter?(_key) 80 | raise NotImplementedError, 'method must be implemented in the included class' 81 | end 82 | 83 | private 84 | 85 | def filter_hash(hash) # rubocop:disable Metrics/AbcSize 86 | return hash unless hash.is_a?(Hash) 87 | 88 | hash_copy = hash.dup 89 | 90 | hash.each_key do |key| 91 | if should_filter?(key.to_s) 92 | hash_copy[key] = FILTERED 93 | elsif hash_copy[key].is_a?(Hash) 94 | hash_copy[key] = filter_hash(hash_copy[key]) 95 | elsif hash[key].is_a?(Array) 96 | hash_copy[key].each_with_index do |h, i| 97 | hash_copy[key][i] = filter_hash(h) 98 | end 99 | end 100 | end 101 | 102 | hash_copy 103 | end 104 | 105 | def filter_url_params(url) 106 | url.query = URI.decode_www_form(url.query).to_h.map do |key, val| 107 | should_filter?(key) ? "#{key}=[Filtered]" : "#{key}=#{val}" 108 | end.join('&') 109 | 110 | url.to_s 111 | end 112 | 113 | def filter_url(notice) 114 | begin 115 | url = URI(notice[:context][:url]) 116 | rescue URI::InvalidURIError 117 | return 118 | end 119 | 120 | return unless url.query 121 | 122 | notice[:context][:url] = filter_url_params(url) 123 | end 124 | 125 | def eval_proc_patterns! 126 | return unless @patterns.any? { |pattern| pattern.is_a?(Proc) } 127 | 128 | @patterns = @patterns.flat_map do |pattern| 129 | next(pattern) unless pattern.respond_to?(:call) 130 | 131 | pattern.call 132 | end 133 | end 134 | 135 | def validate_patterns 136 | @valid_patterns = @patterns.all? do |pattern| 137 | VALID_PATTERN_CLASSES.any? { |c| pattern.is_a?(c) } 138 | end 139 | 140 | return if @valid_patterns 141 | 142 | logger.error( 143 | "#{LOG_LABEL} one of the patterns in #{self.class} is invalid. " \ 144 | "Known patterns: #{@patterns}", 145 | ) 146 | end 147 | 148 | def filter_context_key(notice, key) 149 | return unless notice[:context][key] 150 | return if notice[:context][key] == FILTERED 151 | unless should_filter?(key) 152 | return notice[:context][key] = filter_hash(notice[:context][key]) 153 | end 154 | 155 | notice[:context][key] = FILTERED 156 | end 157 | end 158 | end 159 | end 160 | -------------------------------------------------------------------------------- /lib/airbrake-ruby/notice.rb: -------------------------------------------------------------------------------- 1 | module Airbrake 2 | # Represents a chunk of information that is meant to be either sent to 3 | # Airbrake or ignored completely. 4 | # 5 | # @since v1.0.0 6 | class Notice 7 | # @return [Hash{Symbol=>String,Hash}] the information to be displayed in the 8 | # Context tab in the dashboard 9 | CONTEXT = { 10 | os: RUBY_PLATFORM, 11 | language: "#{RUBY_ENGINE}/#{RUBY_VERSION}".freeze, 12 | notifier: Airbrake::NOTIFIER_INFO, 13 | }.freeze 14 | 15 | # @return [Integer] the maxium size of the JSON payload in bytes 16 | MAX_NOTICE_SIZE = 64000 17 | 18 | # @return [Integer] the maximum size of hashes, arrays and strings in the 19 | # notice. 20 | PAYLOAD_MAX_SIZE = 10000 21 | 22 | # @return [Array] the list of possible exceptions that might 23 | # be raised when an object is converted to JSON 24 | JSON_EXCEPTIONS = [ 25 | IOError, 26 | NotImplementedError, 27 | JSON::GeneratorError, 28 | Encoding::UndefinedConversionError, 29 | ].freeze 30 | 31 | # @return [Array] the list of keys that can be be overwritten with 32 | # {Airbrake::Notice#[]=} 33 | WRITABLE_KEYS = %i[notifier context environment session params].freeze 34 | 35 | # @return [Array] parts of a Notice's payload that can be modified 36 | # by the truncator 37 | TRUNCATABLE_KEYS = %i[errors environment session params].freeze 38 | 39 | # @return [String] the name of the host machine 40 | HOSTNAME = Socket.gethostname.freeze 41 | 42 | # @return [String] 43 | DEFAULT_SEVERITY = 'error'.freeze 44 | 45 | include Ignorable 46 | include Loggable 47 | include Stashable 48 | 49 | # @api private 50 | def initialize(exception, params = {}) 51 | @config = Airbrake::Config.instance 52 | @truncator = Airbrake::Truncator.new(PAYLOAD_MAX_SIZE) 53 | 54 | @payload = { 55 | errors: NestedException.new(exception).as_json, 56 | context: context(exception), 57 | environment: { 58 | program_name: $PROGRAM_NAME, 59 | }, 60 | session: {}, 61 | params: params, 62 | } 63 | 64 | stash[:exception] = exception 65 | end 66 | 67 | # Converts the notice to JSON. Calls +to_json+ on each object inside 68 | # notice's payload. Truncates notices, JSON representation of which is 69 | # bigger than {MAX_NOTICE_SIZE}. 70 | # 71 | # @return [Hash{String=>String}, nil] 72 | # @api private 73 | def to_json(*_args) 74 | loop do 75 | begin 76 | json = @payload.to_json 77 | rescue *JSON_EXCEPTIONS => ex 78 | logger.debug("#{LOG_LABEL} `notice.to_json` failed: #{ex.class}: #{ex}") 79 | else 80 | return json if json && json.bytesize <= MAX_NOTICE_SIZE 81 | end 82 | 83 | break if truncate == 0 84 | end 85 | end 86 | 87 | # Reads a value from notice's payload. 88 | # 89 | # @return [Object] 90 | # @raise [Airbrake::Error] if the notice is ignored 91 | def [](key) 92 | raise_if_ignored 93 | @payload[key] 94 | end 95 | 96 | # Writes a value to the payload hash. Restricts unrecognized writes. 97 | # 98 | # @example 99 | # notice[:params][:my_param] = 'foobar' 100 | # 101 | # @return [void] 102 | # @raise [Airbrake::Error] if the notice is ignored 103 | # @raise [Airbrake::Error] if the +key+ is not recognized 104 | # @raise [Airbrake::Error] if the root value is not a Hash 105 | def []=(key, value) 106 | raise_if_ignored 107 | 108 | unless WRITABLE_KEYS.include?(key) 109 | raise Airbrake::Error, 110 | ":#{key} is not recognized among #{WRITABLE_KEYS}" 111 | end 112 | 113 | unless value.respond_to?(:to_hash) 114 | raise Airbrake::Error, "Got #{value.class} value, wanted a Hash" 115 | end 116 | 117 | @payload[key] = value.to_hash 118 | end 119 | 120 | private 121 | 122 | def context(exception) 123 | { 124 | version: @config.app_version, 125 | versions: @config.versions, 126 | # We ensure that root_directory is always a String, so it can always be 127 | # converted to JSON in a predictable manner (when it's a Pathname and in 128 | # Rails environment, it converts to unexpected JSON). 129 | rootDirectory: @config.root_directory.to_s, 130 | environment: @config.environment, 131 | 132 | # Make sure we always send hostname. 133 | hostname: HOSTNAME, 134 | 135 | severity: DEFAULT_SEVERITY, 136 | error_message: @truncator.truncate(exception.message), 137 | }.merge(CONTEXT).delete_if { |_key, val| val.nil? || val.empty? } 138 | end 139 | 140 | def truncate 141 | TRUNCATABLE_KEYS.each do |key| 142 | @payload[key] = @truncator.truncate(@payload[key]) 143 | end 144 | 145 | new_max_size = @truncator.reduce_max_size 146 | if new_max_size == 0 147 | logger.error( 148 | "#{LOG_LABEL} truncation failed. File an issue at " \ 149 | "https://github.com/airbrake/airbrake-ruby " \ 150 | "and attach the following payload: #{@payload}", 151 | ) 152 | end 153 | 154 | new_max_size 155 | end 156 | end 157 | end 158 | -------------------------------------------------------------------------------- /lib/airbrake-ruby/performance_notifier.rb: -------------------------------------------------------------------------------- 1 | module Airbrake 2 | # PerformanceNotifier aggregates performance data and periodically sends it to 3 | # Airbrake. 4 | # 5 | # @api public 6 | # @since v3.2.0 7 | # rubocop:disable Metrics/ClassLength 8 | class PerformanceNotifier 9 | include Inspectable 10 | include Loggable 11 | 12 | def initialize 13 | @config = Airbrake::Config.instance 14 | @flush_period = Airbrake::Config.instance.performance_stats_flush_period 15 | @async_sender = AsyncSender.new(:put, self.class.name) 16 | @sync_sender = SyncSender.new(:put) 17 | @schedule_flush = nil 18 | @filter_chain = FilterChain.new 19 | 20 | @payload = {}.extend(MonitorMixin) 21 | @has_payload = @payload.new_cond 22 | end 23 | 24 | # @param [Hash] metric 25 | # @see Airbrake.notify_query 26 | # @see Airbrake.notify_request 27 | def notify(metric) 28 | @payload.synchronize do 29 | send_metric(metric, sync: false) 30 | end 31 | end 32 | 33 | # @param [Hash] metric 34 | # @since v4.10.0 35 | # @see Airbrake.notify_queue_sync 36 | def notify_sync(metric) 37 | send_metric(metric, sync: true).value 38 | end 39 | 40 | # @see Airbrake.add_performance_filter 41 | def add_filter(filter = nil, &block) 42 | @filter_chain.add_filter(block_given? ? block : filter) 43 | end 44 | 45 | # @see Airbrake.delete_performance_filter 46 | def delete_filter(filter_class) 47 | @filter_chain.delete_filter(filter_class) 48 | end 49 | 50 | def close 51 | @payload.synchronize do 52 | @schedule_flush.kill if @schedule_flush 53 | @sync_sender.close 54 | @async_sender.close 55 | end 56 | end 57 | 58 | private 59 | 60 | def schedule_flush 61 | @schedule_flush ||= Thread.new do 62 | loop do 63 | @payload.synchronize do 64 | @last_flush_time ||= MonotonicTime.time_in_s 65 | 66 | while (MonotonicTime.time_in_s - @last_flush_time) < @flush_period 67 | @has_payload.wait(@flush_period) 68 | end 69 | 70 | if @payload.none? 71 | @last_flush_time = nil 72 | next 73 | end 74 | 75 | send(@async_sender, @payload, Airbrake::Promise.new) 76 | @payload.clear 77 | end 78 | end 79 | end 80 | end 81 | 82 | def send_metric(metric, sync:) 83 | promise = check_configuration(metric) 84 | return promise if promise.rejected? 85 | 86 | @filter_chain.refine(metric) 87 | if metric.ignored? 88 | return Promise.new.reject("#{metric.class} was ignored by a filter") 89 | end 90 | 91 | update_payload(metric) 92 | if sync || @flush_period == 0 93 | send(@sync_sender, @payload, promise) 94 | else 95 | @has_payload.signal 96 | schedule_flush 97 | end 98 | end 99 | 100 | def update_payload(metric) 101 | if (total_stat = @payload[metric]) 102 | @payload.key(total_stat).merge(metric) 103 | else 104 | @payload[metric] = { total: Airbrake::Stat.new } 105 | end 106 | 107 | @payload[metric][:total].increment_ms(metric.timing) 108 | 109 | metric.groups.each do |name, ms| 110 | @payload[metric][name] ||= Airbrake::Stat.new 111 | @payload[metric][name].increment_ms(ms) 112 | end 113 | end 114 | 115 | def check_configuration(metric) 116 | promise = @config.check_configuration 117 | return promise if promise.rejected? 118 | 119 | promise = @config.check_performance_options(metric) 120 | return promise if promise.rejected? 121 | 122 | if metric.timing && metric.timing == 0 123 | return Promise.new.reject(':timing cannot be zero') 124 | end 125 | 126 | Promise.new 127 | end 128 | 129 | def send(sender, payload, promise) 130 | raise "payload cannot be empty. Race?" if payload.none? 131 | 132 | with_grouped_payload(payload) do |metric_hash, destination| 133 | url = URI.join( 134 | @config.apm_host, 135 | "api/v5/projects/#{@config.project_id}/#{destination}", 136 | ) 137 | 138 | logger.debug do 139 | "#{LOG_LABEL} #{self.class.name}##{__method__}: #{metric_hash}" 140 | end 141 | sender.send(metric_hash, promise, url) 142 | end 143 | 144 | promise 145 | end 146 | 147 | def with_grouped_payload(raw_payload) 148 | grouped_payload = raw_payload.group_by do |metric, _stats| 149 | [metric.cargo, metric.destination] 150 | end 151 | 152 | grouped_payload.each do |(cargo, destination), metrics| 153 | payload = {} 154 | payload[cargo] = serialize_metrics(metrics) 155 | payload['environment'] = @config.environment if @config.environment 156 | 157 | yield(payload, destination) 158 | end 159 | end 160 | 161 | def serialize_metrics(metrics) 162 | metrics.map do |metric, stats| 163 | metric_hash = metric.to_h.merge!(stats[:total].to_h) 164 | 165 | if metric.groups.any? 166 | group_stats = stats.reject { |name, _stat| name == :total } 167 | metric_hash['groups'] = group_stats.merge(group_stats) do |_name, stat| 168 | stat.to_h 169 | end 170 | end 171 | 172 | metric_hash 173 | end 174 | end 175 | end 176 | # rubocop:enable Metrics/ClassLength 177 | end 178 | -------------------------------------------------------------------------------- /spec/remote_settings/callback_spec.rb: -------------------------------------------------------------------------------- 1 | RSpec.describe Airbrake::RemoteSettings::Callback do 2 | describe "#call" do 3 | let(:logger) { Logger.new(File::NULL) } 4 | 5 | let(:config) do 6 | Airbrake::Config.new( 7 | project_id: 123, 8 | logger: logger, 9 | ) 10 | end 11 | 12 | let(:data) do 13 | instance_double(Airbrake::RemoteSettings::SettingsData) 14 | end 15 | 16 | before do 17 | allow(data).to receive(:to_h) 18 | allow(data).to receive(:error_host) 19 | allow(data).to receive(:apm_host) 20 | allow(data).to receive(:error_notifications?) 21 | allow(data).to receive(:performance_stats?) 22 | end 23 | 24 | it "logs given data" do 25 | allow(logger).to receive(:debug) 26 | 27 | described_class.new(config).call(data) 28 | 29 | expect(logger).to have_received(:debug) do |&block| 30 | expect(block.call).to match(/applying remote settings/) 31 | end 32 | end 33 | 34 | context "when the config disables error notifications" do 35 | before do 36 | config.error_notifications = false 37 | allow(data).to receive(:error_notifications?).and_return(true) 38 | end 39 | 40 | # rubocop:disable RSpec/MultipleExpectations 41 | it "keeps the option disabled forever" do 42 | callback = described_class.new(config) 43 | 44 | callback.call(data) 45 | expect(config.error_notifications).to be(false) 46 | 47 | callback.call(data) 48 | expect(config.error_notifications).to be(false) 49 | 50 | callback.call(data) 51 | expect(config.error_notifications).to be(false) 52 | end 53 | # rubocop:enable RSpec/MultipleExpectations 54 | end 55 | 56 | context "when the config enables error notifications" do 57 | before { config.error_notifications = true } 58 | 59 | # rubocop:disable RSpec/MultipleExpectations 60 | it "can disable and enable error notifications" do 61 | callback = described_class.new(config) 62 | 63 | allow(data).to receive(:error_notifications?).and_return(false) 64 | 65 | callback.call(data) 66 | expect(config.error_notifications).to be(false) 67 | 68 | allow(data).to receive(:error_notifications?).and_return(true) 69 | 70 | callback.call(data) 71 | expect(config.error_notifications).to be(true) 72 | 73 | expect(data).to have_received(:error_notifications?).twice 74 | end 75 | # rubocop:enable RSpec/MultipleExpectations 76 | end 77 | 78 | context "when the config disables performance_stats" do 79 | before do 80 | config.performance_stats = false 81 | allow(data).to receive(:performance_stats?).and_return(true) 82 | end 83 | 84 | # rubocop:disable RSpec/MultipleExpectations 85 | it "keeps the option disabled forever" do 86 | callback = described_class.new(config) 87 | 88 | callback.call(data) 89 | expect(config.performance_stats).to be(false) 90 | 91 | callback.call(data) 92 | expect(config.performance_stats).to be(false) 93 | 94 | callback.call(data) 95 | expect(config.performance_stats).to be(false) 96 | end 97 | # rubocop:enable RSpec/MultipleExpectations 98 | end 99 | 100 | context "when the config enables performance stats" do 101 | before { config.performance_stats = true } 102 | 103 | # rubocop:disable RSpec/MultipleExpectations 104 | it "can disable and enable performance_stats" do 105 | callback = described_class.new(config) 106 | 107 | allow(data).to receive(:performance_stats?).and_return(false) 108 | 109 | callback.call(data) 110 | expect(config.performance_stats).to be(false) 111 | 112 | allow(data).to receive(:performance_stats?).and_return(true) 113 | 114 | callback.call(data) 115 | expect(config.performance_stats).to be(true) 116 | 117 | expect(data).to have_received(:performance_stats?).twice 118 | end 119 | # rubocop:enable RSpec/MultipleExpectations 120 | end 121 | 122 | context "when error_host returns a value" do 123 | it "sets the error_host option" do 124 | config.error_host = 'http://api.airbrake.io' 125 | allow(data).to receive(:error_host).and_return('https://api.example.com') 126 | 127 | described_class.new(config).call(data) 128 | expect(config.error_host).to eq('https://api.example.com') 129 | end 130 | end 131 | 132 | context "when error_host returns nil" do 133 | it "doesn't modify the error_host option" do 134 | config.error_host = 'http://api.airbrake.io' 135 | allow(data).to receive(:error_host).and_return(nil) 136 | 137 | described_class.new(config).call(data) 138 | expect(config.error_host).to eq('http://api.airbrake.io') 139 | end 140 | end 141 | 142 | context "when apm_host returns a value" do 143 | it "sets the apm_host option" do 144 | config.apm_host = 'http://api.airbrake.io' 145 | allow(data).to receive(:apm_host).and_return('https://api.example.com') 146 | 147 | described_class.new(config).call(data) 148 | expect(config.apm_host).to eq('https://api.example.com') 149 | end 150 | end 151 | 152 | context "when apm_host returns nil" do 153 | it "doesn't modify the apm_host option" do 154 | config.apm_host = 'http://api.airbrake.io' 155 | allow(data).to receive(:apm_host).and_return(nil) 156 | 157 | described_class.new(config).call(data) 158 | expect(config.apm_host).to eq('http://api.airbrake.io') 159 | end 160 | end 161 | end 162 | end 163 | -------------------------------------------------------------------------------- /spec/config/processor_spec.rb: -------------------------------------------------------------------------------- 1 | RSpec.describe Airbrake::Config::Processor do 2 | let(:notifier) { Airbrake::NoticeNotifier.new } 3 | 4 | describe "#process_blocklist" do 5 | let(:config) { Airbrake::Config.new(blocklist_keys: %w[a b c]) } 6 | 7 | context "when there ARE blocklist keys" do 8 | it "adds the blocklist filter" do 9 | described_class.new(config).process_blocklist(notifier) 10 | expect(notifier.has_filter?(Airbrake::Filters::KeysBlocklist)).to be(true) 11 | end 12 | end 13 | 14 | context "when there are NO blocklist keys" do 15 | let(:config) { Airbrake::Config.new(blocklist_keys: %w[]) } 16 | 17 | it "doesn't add the blocklist filter" do 18 | described_class.new(config).process_blocklist(notifier) 19 | expect(notifier.has_filter?(Airbrake::Filters::KeysBlocklist)) 20 | .to be(false) 21 | end 22 | end 23 | end 24 | 25 | describe "#process_allowlist" do 26 | let(:config) { Airbrake::Config.new(allowlist_keys: %w[a b c]) } 27 | 28 | context "when there ARE allowlist keys" do 29 | it "adds the allowlist filter" do 30 | described_class.new(config).process_allowlist(notifier) 31 | expect(notifier.has_filter?(Airbrake::Filters::KeysAllowlist)).to be(true) 32 | end 33 | end 34 | 35 | context "when there are NO allowlist keys" do 36 | let(:config) { Airbrake::Config.new(allowlist_keys: %w[]) } 37 | 38 | it "doesn't add the allowlist filter" do 39 | described_class.new(config).process_allowlist(notifier) 40 | expect(notifier.has_filter?(Airbrake::Filters::KeysAllowlist)) 41 | .to be(false) 42 | end 43 | end 44 | end 45 | 46 | describe "#process_remote_configuration" do 47 | before do 48 | allow(Airbrake::RemoteSettings).to receive(:poll) 49 | end 50 | 51 | context "when the config doesn't define a project_id" do 52 | let(:config) { Airbrake::Config.new(project_id: nil) } 53 | 54 | it "doesn't set remote settings" do 55 | described_class.new(config).process_remote_configuration 56 | 57 | expect(Airbrake::RemoteSettings).not_to have_received(:poll) 58 | end 59 | end 60 | 61 | context "when the config sets environment to 'test'" do 62 | let(:config) { Airbrake::Config.new(project_id: 123, environment: 'test') } 63 | 64 | it "doesn't set remote settings" do 65 | described_class.new(config).process_remote_configuration 66 | 67 | expect(Airbrake::RemoteSettings).not_to have_received(:poll) 68 | end 69 | end 70 | 71 | context "when the config sets :ignore_environments and :environment matches" do 72 | let(:config) do 73 | Airbrake::Config.new( 74 | project_id: 123, 75 | ignore_environments: %w[dev], 76 | environment: 'dev', 77 | ) 78 | end 79 | 80 | it "doesn't set remote settings" do 81 | described_class.new(config).process_remote_configuration 82 | 83 | expect(Airbrake::RemoteSettings).not_to have_received(:poll) 84 | end 85 | end 86 | 87 | context "when the config defines a project_id" do 88 | let(:config) do 89 | Airbrake::Config.new(project_id: 123, environment: 'not-test') 90 | end 91 | 92 | it "sets remote settings" do 93 | described_class.new(config).process_remote_configuration 94 | 95 | expect(Airbrake::RemoteSettings).to have_received(:poll) 96 | end 97 | end 98 | 99 | context "when the config disables the remote_config option" do 100 | let(:config) { Airbrake::Config.new(project_id: 123, remote_config: false) } 101 | 102 | it "doesn't set remote settings" do 103 | described_class.new(config).process_remote_configuration 104 | 105 | expect(Airbrake::RemoteSettings).not_to have_received(:poll) 106 | end 107 | end 108 | end 109 | 110 | describe "#add_filters" do 111 | context "when there's a root directory" do 112 | let(:config) { Airbrake::Config.new(root_directory: '/abc') } 113 | 114 | it "adds RootDirectoryFilter" do 115 | described_class.new(config).add_filters(notifier) 116 | expect(notifier.has_filter?(Airbrake::Filters::RootDirectoryFilter)) 117 | .to be(true) 118 | end 119 | 120 | it "adds GitRevisionFilter" do 121 | described_class.new(config).add_filters(notifier) 122 | expect(notifier.has_filter?(Airbrake::Filters::GitRevisionFilter)) 123 | .to be(true) 124 | end 125 | 126 | it "adds GitRepositoryFilter" do 127 | described_class.new(config).add_filters(notifier) 128 | expect(notifier.has_filter?(Airbrake::Filters::GitRepositoryFilter)) 129 | .to be(true) 130 | end 131 | 132 | it "adds GitLastCheckoutFilter" do 133 | described_class.new(config).add_filters(notifier) 134 | expect(notifier.has_filter?(Airbrake::Filters::GitLastCheckoutFilter)) 135 | .to be(true) 136 | end 137 | end 138 | 139 | context "when there's NO root directory" do 140 | let(:config) { Airbrake::Config.new(root_directory: nil) } 141 | 142 | it "doesn't add RootDirectoryFilter" do 143 | described_class.new(config).add_filters(notifier) 144 | expect(notifier.has_filter?(Airbrake::Filters::RootDirectoryFilter)) 145 | .to be(false) 146 | end 147 | 148 | it "doesn't add GitRevisionFilter" do 149 | described_class.new(config).add_filters(notifier) 150 | expect(notifier.has_filter?(Airbrake::Filters::GitRevisionFilter)) 151 | .to be(false) 152 | end 153 | 154 | it "doesn't add GitRepositoryFilter" do 155 | described_class.new(config).add_filters(notifier) 156 | expect(notifier.has_filter?(Airbrake::Filters::GitRepositoryFilter)) 157 | .to be(false) 158 | end 159 | 160 | it "doesn't add GitLastCheckoutFilter" do 161 | described_class.new(config).add_filters(notifier) 162 | expect(notifier.has_filter?(Airbrake::Filters::GitLastCheckoutFilter)) 163 | .to be(false) 164 | end 165 | end 166 | end 167 | end 168 | -------------------------------------------------------------------------------- /spec/thread_pool_spec.rb: -------------------------------------------------------------------------------- 1 | RSpec.describe Airbrake::ThreadPool do 2 | subject(:thread_pool) do 3 | described_class.new( 4 | worker_size: worker_size, 5 | queue_size: queue_size, 6 | block: proc { |message| tasks << message }, 7 | ) 8 | end 9 | 10 | let(:tasks) { [] } 11 | let(:worker_size) { 1 } 12 | let(:queue_size) { 2 } 13 | 14 | describe "#<<" do 15 | it "returns true" do 16 | retval = thread_pool << 1 17 | thread_pool.close 18 | expect(retval).to be(true) 19 | end 20 | 21 | it "performs work in background" do 22 | thread_pool << 2 23 | thread_pool << 1 24 | thread_pool.close 25 | 26 | expect(tasks).to eq([2, 1]) 27 | end 28 | 29 | context "when the queue is full" do 30 | subject(:full_thread_pool) do 31 | described_class.new( 32 | worker_size: 1, 33 | queue_size: 1, 34 | block: proc { |message| tasks << message }, 35 | ) 36 | end 37 | 38 | before do 39 | # rubocop:disable RSpec/SubjectStub 40 | allow(full_thread_pool).to receive(:backlog).and_return(queue_size) 41 | # rubocop:enable RSpec/SubjectStub 42 | end 43 | 44 | it "returns false" do 45 | retval = full_thread_pool << 1 46 | full_thread_pool.close 47 | expect(retval).to be(false) 48 | end 49 | 50 | it "discards tasks" do 51 | 200.times { full_thread_pool << 1 } 52 | full_thread_pool.close 53 | 54 | expect(tasks.size).to be_zero 55 | end 56 | 57 | it "logs discarded tasks" do 58 | allow(Airbrake::Loggable.instance).to receive(:info) 59 | 60 | 15.times { full_thread_pool << 1 } 61 | full_thread_pool.close 62 | 63 | expect(Airbrake::Loggable.instance) 64 | .to have_received(:info).exactly(15).times 65 | end 66 | end 67 | end 68 | 69 | describe "#backlog" do 70 | let(:worker_size) { 0 } 71 | 72 | it "returns the size of the queue" do 73 | thread_pool << 1 74 | expect(thread_pool.backlog).to eq(1) 75 | end 76 | end 77 | 78 | describe "#has_workers?" do 79 | it "returns false when the thread pool is not closed, but has 0 workers" do 80 | thread_pool.workers.list.each do |worker| 81 | worker.kill.join 82 | end 83 | expect(thread_pool).not_to have_workers 84 | end 85 | 86 | it "returns false when the thread pool is closed" do 87 | thread_pool.close 88 | expect(thread_pool).not_to have_workers 89 | end 90 | 91 | describe "forking behavior" do 92 | before do 93 | skip('fork() is unsupported on JRuby') if %w[jruby].include?(RUBY_ENGINE) 94 | unless Process.respond_to?(:last_status) 95 | skip('Process.last_status is unsupported on this Ruby') 96 | end 97 | end 98 | 99 | # rubocop:disable RSpec/MultipleExpectations 100 | it "respawns workers on fork()" do 101 | pid = fork { expect(thread_pool).to have_workers } 102 | Process.wait(pid) 103 | thread_pool.close 104 | 105 | expect(Process.last_status).to be_success 106 | expect(thread_pool).not_to have_workers 107 | end 108 | # rubocop:enable RSpec/MultipleExpectations 109 | 110 | it "ensures that a new thread group is created per process" do 111 | thread_pool << 1 112 | pid = fork { thread_pool.has_workers? } 113 | Process.wait(pid) 114 | thread_pool.close 115 | 116 | expect(Process.last_status).to be_success 117 | end 118 | end 119 | end 120 | 121 | describe "#close" do 122 | context "when there's no work to do" do 123 | it "joins the spawned thread" do 124 | workers = thread_pool.workers.list 125 | expect(workers).to all(be_alive) 126 | 127 | thread_pool.close 128 | expect(workers).to all(be_stop) 129 | end 130 | end 131 | 132 | context "when there's some work to do" do 133 | it "logs how many tasks are left to process" do 134 | allow(Airbrake::Loggable.instance).to receive(:debug) 135 | 136 | thread_pool = described_class.new( 137 | name: 'foo', worker_size: 0, queue_size: 2, block: proc {}, 138 | ) 139 | 140 | 2.times { thread_pool << 1 } 141 | thread_pool.close 142 | 143 | expect(Airbrake::Loggable.instance).to have_received(:debug).with( 144 | /waiting to process \d+ task\(s\)/, 145 | ) 146 | expect(Airbrake::Loggable.instance).to have_received(:debug).with(/foo.+closed/) 147 | end 148 | 149 | it "waits until the queue gets empty" do 150 | thread_pool = described_class.new( 151 | worker_size: 1, queue_size: 2, block: proc {}, 152 | ) 153 | 154 | 10.times { thread_pool << 1 } 155 | thread_pool.close 156 | expect(thread_pool.backlog).to be_zero 157 | end 158 | end 159 | 160 | context "when it was already closed" do 161 | it "doesn't increase the queue size" do 162 | begin 163 | thread_pool.close 164 | rescue Airbrake::Error 165 | nil 166 | end 167 | 168 | expect(thread_pool.backlog).to be_zero 169 | end 170 | 171 | it "raises error" do 172 | thread_pool.close 173 | expect { thread_pool.close }.to raise_error( 174 | Airbrake::Error, 'this thread pool is closed already' 175 | ) 176 | end 177 | end 178 | end 179 | 180 | describe "#spawn_workers" do 181 | after { thread_pool.close } 182 | 183 | let(:worker_size) { 3 } 184 | 185 | # We avoid enclosed thread groups since they cause issues for anyone using timeout 0.3.1 186 | # More info: https://github.com/airbrake/airbrake-ruby/issues/713 187 | it "spawns an unenclosed thread group" do 188 | expect(thread_pool.workers).to be_a(ThreadGroup) 189 | expect(thread_pool.workers).not_to be_enclosed 190 | end 191 | 192 | it "spawns threads that are alive" do 193 | expect(thread_pool.workers.list).to all(be_alive) 194 | end 195 | 196 | it "spawns exactly `workers_size` workers" do 197 | expect(thread_pool.workers.list.size).to eq(3) 198 | end 199 | end 200 | end 201 | --------------------------------------------------------------------------------