airbrake-ruby 1.0.0.rc.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,145 @@
1
+ module Airbrake
2
+ ##
3
+ # This class is reponsible for sending notices to Airbrake. It supports
4
+ # synchronous and asynchronous delivery.
5
+ #
6
+ # @see Airbrake::Config The list of options
7
+ # @api private
8
+ # @since v5.0.0
9
+ class Notifier
10
+ ##
11
+ # Creates a new Airbrake notifier with the given config options.
12
+ #
13
+ # @example Configuring with a Hash
14
+ # airbrake = Airbrake.new(project_id: 123, project_key: '321')
15
+ #
16
+ # @example Configuring with an Airbrake::Config
17
+ # config = Airbrake::Config.new
18
+ # config.project_id = 123
19
+ # config.project_key = '321'
20
+ # airbake = Airbrake.new(config)
21
+ #
22
+ # @param [Hash, Airbrake::Config] user_config The config that contains
23
+ # information about how the notifier should operate
24
+ # @raise [Airbrake::Error] when either +project_id+ or +project_key+
25
+ # is missing (or both)
26
+ def initialize(user_config)
27
+ @config = (user_config.is_a?(Config) ? user_config : Config.new(user_config))
28
+
29
+ unless [@config.project_id, @config.project_key].all?
30
+ raise Airbrake::Error, 'both :project_id and :project_key are required'
31
+ end
32
+
33
+ @filter_chain = FilterChain.new(@config)
34
+ @async_sender = AsyncSender.new(@config)
35
+ @sync_sender = SyncSender.new(@config)
36
+ end
37
+
38
+ ##
39
+ # @!macro see_public_api_method
40
+ # @see Airbrake.$0
41
+
42
+ ##
43
+ # @macro see_public_api_method
44
+ def notify(exception, params = {})
45
+ send_notice(exception, params)
46
+ nil
47
+ end
48
+
49
+ ##
50
+ # @macro see_public_api_method
51
+ def notify_sync(exception, params = {})
52
+ send_notice(exception, params, @sync_sender)
53
+ end
54
+
55
+ ##
56
+ # @macro see_public_api_method
57
+ def add_filter(filter = nil, &block)
58
+ @filter_chain.add_filter(block_given? ? block : filter)
59
+ end
60
+
61
+ ##
62
+ # @macro see_public_api_method
63
+ def whitelist_keys(keys)
64
+ add_filter(Filters::KeysWhitelist.new(*keys))
65
+ end
66
+
67
+ ##
68
+ # @macro see_public_api_method
69
+ def blacklist_keys(keys)
70
+ add_filter(Filters::KeysBlacklist.new(*keys))
71
+ end
72
+
73
+ ##
74
+ # @macro see_public_api_method
75
+ def build_notice(exception, params = {})
76
+ if @async_sender.closed?
77
+ raise Airbrake::Error,
78
+ "attempted to build #{exception} with closed Airbrake instance"
79
+ end
80
+
81
+ if exception.is_a?(Airbrake::Notice)
82
+ exception
83
+ else
84
+ Notice.new(@config, convert_to_exception(exception), params)
85
+ end
86
+ end
87
+
88
+ ##
89
+ # @macro see_public_api_method
90
+ def close
91
+ @async_sender.close
92
+ end
93
+
94
+ ##
95
+ # @macro see_public_api_method
96
+ def create_deploy(deploy_params)
97
+ deploy_params[:environment] ||= @config.environment
98
+
99
+ host = @config.endpoint.to_s.split(@config.endpoint.path).first
100
+ path = "/api/v4/projects/#{@config.project_id}/deploys?key=#{@config.project_key}"
101
+
102
+ @sync_sender.send(deploy_params, URI.join(host, path))
103
+ end
104
+
105
+ private
106
+
107
+ def convert_to_exception(ex)
108
+ if ex.is_a?(Exception) || Backtrace.java_exception?(ex)
109
+ # Manually created exceptions don't have backtraces, so we create a fake
110
+ # one, whose first frame points to the place where Airbrake was called
111
+ # (normally via `notify`).
112
+ ex.set_backtrace(clean_backtrace) unless ex.backtrace
113
+ return ex
114
+ end
115
+
116
+ e = RuntimeError.new(ex.to_s)
117
+ e.set_backtrace(clean_backtrace)
118
+ e
119
+ end
120
+
121
+ def send_notice(exception, params, sender = default_sender)
122
+ notice = build_notice(exception, params)
123
+ @filter_chain.refine(notice)
124
+ return if notice.ignored?
125
+
126
+ sender.send(notice)
127
+ end
128
+
129
+ def default_sender
130
+ if @async_sender.has_workers?
131
+ @async_sender
132
+ else
133
+ @config.logger.warn(
134
+ "#{LOG_LABEL} falling back to sync delivery because there are no " \
135
+ "running async workers"
136
+ )
137
+ @sync_sender
138
+ end
139
+ end
140
+
141
+ def clean_backtrace
142
+ caller.drop_while { |frame| frame.include?('/lib/airbrake') }
143
+ end
144
+ end
145
+ end
@@ -0,0 +1,141 @@
1
+ module Airbrake
2
+ ##
3
+ # This class is responsible for truncation of too big objects. Mainly, you
4
+ # should use it for simple objects such as strings, hashes, & arrays.
5
+ class PayloadTruncator
6
+ ##
7
+ # @return [Hash] the options for +String#encode+
8
+ ENCODING_OPTIONS = { invalid: :replace, undef: :replace }.freeze
9
+
10
+ ##
11
+ # @return [String] the temporary encoding to be used when fixing invalid
12
+ # strings with +ENCODING_OPTIONS+
13
+ TEMP_ENCODING = (RUBY_VERSION == '1.9.2' ? 'iso-8859-1' : 'utf-16')
14
+
15
+ ##
16
+ # @param [Integer] max_size maximum size of hashes, arrays and strings
17
+ # @param [Logger] logger the logger object
18
+ def initialize(max_size, logger)
19
+ @max_size = max_size
20
+ @logger = logger
21
+ end
22
+
23
+ ##
24
+ # Truncates errors (not exceptions) to fit the limit.
25
+ #
26
+ # @param [Hash] error
27
+ # @option error [Symbol] :message
28
+ # @option error [Array<String>] :backtrace
29
+ # @return [void]
30
+ def truncate_error(error)
31
+ if error[:message].length > @max_size
32
+ error[:message] = truncate_string(error[:message])
33
+ @logger.info("#{LOG_LABEL} truncated the message of #{error[:type]}")
34
+ end
35
+
36
+ return if (dropped_frames = error[:backtrace].size - @max_size) < 0
37
+
38
+ error[:backtrace] = error[:backtrace].slice(0, @max_size)
39
+ @logger.info("#{LOG_LABEL} dropped #{dropped_frames} frame(s) from #{error[:type]}")
40
+ end
41
+
42
+ ##
43
+ # Performs deep truncation of arrays, hashes and sets. Uses a
44
+ # placeholder for recursive objects (`[Circular]`).
45
+ #
46
+ # @param [Hash,Array] object The object to truncate
47
+ # @param [Hash] seen The hash that helps to detect recursion
48
+ # @return [void]
49
+ def truncate_object(object, seen = {})
50
+ return seen[object] if seen[object]
51
+
52
+ seen[object] = '[Circular]'.freeze
53
+ truncated = if object.is_a?(Hash)
54
+ truncate_hash(object, seen)
55
+ elsif object.is_a?(Array)
56
+ truncate_array(object, seen)
57
+ elsif object.is_a?(Set)
58
+ truncate_set(object, seen)
59
+ else
60
+ raise Airbrake::Error,
61
+ "cannot truncate object: #{object} (#{object.class})"
62
+ end
63
+ seen[object] = truncated
64
+ end
65
+
66
+ ##
67
+ # Reduces maximum allowed size of the truncated object.
68
+ # @return [void]
69
+ def reduce_max_size
70
+ @max_size /= 2
71
+ end
72
+
73
+ private
74
+
75
+ def truncate(val, seen)
76
+ case val
77
+ when String
78
+ truncate_string(val)
79
+ when Array, Hash, Set
80
+ truncate_object(val, seen)
81
+ when Numeric, TrueClass, FalseClass, Symbol, NilClass
82
+ val
83
+ else
84
+ stringified_val = begin
85
+ val.to_json
86
+ rescue *Notice::JSON_EXCEPTIONS
87
+ val.to_s
88
+ end
89
+ truncate_string(stringified_val)
90
+ end
91
+ end
92
+
93
+ def truncate_string(str)
94
+ replace_invalid_characters!(str)
95
+ return str if str.length <= @max_size
96
+ str.slice(0, @max_size) + '[Truncated]'.freeze
97
+ end
98
+
99
+ ##
100
+ # Replaces invalid characters in string with arbitrary encoding.
101
+ #
102
+ # For Ruby 1.9.2 the method converts encoding of +str+ to +iso-8859-1+ to
103
+ # avoid a bug when encoding options are no-op, when `#encode` is given the
104
+ # same encoding as the receiver's encoding.
105
+ #
106
+ # For modern Rubies we use UTF-16 as a safe alternative.
107
+ #
108
+ # @param [String] str The string to replace characters
109
+ # @return [void]
110
+ # @note This method mutates +str+ for speed
111
+ # @see https://github.com/flori/json/commit/3e158410e81f94dbbc3da6b7b35f4f64983aa4e3
112
+ def replace_invalid_characters!(str)
113
+ encoding = str.encoding
114
+ utf8_string = (encoding == Encoding::UTF_8 || encoding == Encoding::ASCII)
115
+ return str if utf8_string && str.valid_encoding?
116
+
117
+ str.encode!(TEMP_ENCODING, ENCODING_OPTIONS) if utf8_string
118
+ str.encode!('utf-8', ENCODING_OPTIONS)
119
+ end
120
+
121
+ def truncate_hash(hash, seen)
122
+ hash.each_with_index do |(key, val), idx|
123
+ if idx < @max_size
124
+ hash[key] = truncate(val, seen)
125
+ else
126
+ hash.delete(key)
127
+ end
128
+ end
129
+ end
130
+
131
+ def truncate_array(array, seen)
132
+ array.slice(0, @max_size).map! { |val| truncate(val, seen) }
133
+ end
134
+
135
+ def truncate_set(set, seen)
136
+ set.keep_if.with_index { |_val, idx| idx < @max_size }.map! do |val|
137
+ truncate(val, seen)
138
+ end
139
+ end
140
+ end
141
+ end
@@ -0,0 +1,53 @@
1
+ module Airbrake
2
+ ##
3
+ # Parses responses coming from the Airbrake API. Handles HTTP errors by
4
+ # logging them.
5
+ module Response
6
+ ##
7
+ # @return [Integer] the limit of the response body
8
+ TRUNCATE_LIMIT = 100
9
+
10
+ ##
11
+ # Parses HTTP responses from the Airbrake API.
12
+ #
13
+ # @param [Net::HTTPResponse] response
14
+ # @param [Logger] logger
15
+ # @return [Hash{String=>String}] parsed response
16
+ def self.parse(response, logger)
17
+ code = response.code.to_i
18
+ body = response.body
19
+
20
+ begin
21
+ case code
22
+ when 201
23
+ parsed_body = JSON.parse(body)
24
+ logger.debug("#{LOG_LABEL} #{parsed_body}")
25
+ parsed_body
26
+ when 400, 401, 403, 429
27
+ parsed_body = JSON.parse(body)
28
+ logger.error("#{LOG_LABEL} #{parsed_body['error']}")
29
+ parsed_body
30
+ else
31
+ body_msg = truncated_body(body)
32
+ logger.error("#{LOG_LABEL} unexpected code (#{code}). Body: #{body_msg}")
33
+ { 'error' => body_msg }
34
+ end
35
+ rescue => ex
36
+ body_msg = truncated_body(body)
37
+ logger.error("#{LOG_LABEL} error while parsing body (#{ex}). Body: #{body_msg}")
38
+ { 'error' => ex }
39
+ end
40
+ end
41
+
42
+ def self.truncated_body(body)
43
+ if body.nil?
44
+ '[EMPTY_BODY]'.freeze
45
+ elsif body.length > TRUNCATE_LIMIT
46
+ body[0..TRUNCATE_LIMIT] << '...'
47
+ else
48
+ body
49
+ end
50
+ end
51
+ private_class_method :truncated_body
52
+ end
53
+ end
@@ -0,0 +1,76 @@
1
+ module Airbrake
2
+ ##
3
+ # Responsible for sending notices to Airbrake synchronously. Supports proxies.
4
+ #
5
+ # @see AsyncSender
6
+ class SyncSender
7
+ ##
8
+ # @return [String] body for HTTP requests
9
+ CONTENT_TYPE = 'application/json'.freeze
10
+
11
+ ##
12
+ # @return [Array] the errors to be rescued and logged during an HTTP request
13
+ HTTP_ERRORS = [
14
+ Timeout::Error,
15
+ Net::HTTPBadResponse,
16
+ Net::HTTPHeaderSyntaxError,
17
+ Errno::ECONNRESET,
18
+ Errno::ECONNREFUSED,
19
+ EOFError,
20
+ OpenSSL::SSL::SSLError
21
+ ]
22
+
23
+ ##
24
+ # @param [Airbrake::Config] config
25
+ def initialize(config)
26
+ @config = config
27
+ end
28
+
29
+ ##
30
+ # Sends a POST request to the given +endpoint+ with the +notice+ payload.
31
+ #
32
+ # @param [Airbrake::Notice] notice
33
+ # @param [Airbrake::Notice] endpoint
34
+ # @return [Hash{String=>String}] the parsed HTTP response
35
+ def send(notice, endpoint = @config.endpoint)
36
+ response = nil
37
+ req = build_post_request(endpoint, notice)
38
+ https = build_https(endpoint)
39
+
40
+ begin
41
+ response = https.request(req)
42
+ rescue *HTTP_ERRORS => ex
43
+ @config.logger.error("#{LOG_LABEL} HTTP error: #{ex}")
44
+ return
45
+ end
46
+
47
+ Response.parse(response, @config.logger)
48
+ end
49
+
50
+ private
51
+
52
+ def build_https(uri)
53
+ Net::HTTP.new(uri.host, uri.port, *proxy_params).tap do |https|
54
+ https.use_ssl = uri.is_a?(URI::HTTPS)
55
+ end
56
+ end
57
+
58
+ def build_post_request(uri, notice)
59
+ Net::HTTP::Post.new(uri.request_uri).tap do |req|
60
+ req.body = notice.to_json
61
+
62
+ req['Content-Type'] = CONTENT_TYPE
63
+ req['User-Agent'] =
64
+ "#{Airbrake::Notice::NOTIFIER[:name]}/#{Airbrake::AIRBRAKE_RUBY_VERSION}" \
65
+ " Ruby/#{RUBY_VERSION}"
66
+ end
67
+ end
68
+
69
+ def proxy_params
70
+ [@config.proxy[:host],
71
+ @config.proxy[:port],
72
+ @config.proxy[:user],
73
+ @config.proxy[:password]]
74
+ end
75
+ end
76
+ end
@@ -0,0 +1,7 @@
1
+ ##
2
+ # Defines version.
3
+ module Airbrake
4
+ ##
5
+ # @return [String] the library version
6
+ AIRBRAKE_RUBY_VERSION = '1.0.0.rc.1'.freeze
7
+ end
@@ -0,0 +1,177 @@
1
+ require 'spec_helper'
2
+
3
+ RSpec.describe Airbrake do
4
+ let(:endpoint) do
5
+ 'https://airbrake.io/api/v3/projects/113743/notices?key=fd04e13d806a90f96614ad8e529b2822'
6
+ end
7
+
8
+ before do
9
+ described_class.configure do |c|
10
+ c.project_id = 113743
11
+ c.project_key = 'fd04e13d806a90f96614ad8e529b2822'
12
+ end
13
+
14
+ stub_request(:post, endpoint).to_return(status: 201, body: '{}')
15
+ end
16
+
17
+ after do
18
+ described_class.instance_variable_set(:@notifiers, {})
19
+ end
20
+
21
+ shared_examples 'error handling' do |method|
22
+ it "raises error if there is no notifier when using #{method}" do
23
+ described_class.instance_variable_set(:@notifiers, {})
24
+
25
+ expect { described_class.__send__(method, 'bingo') }.
26
+ to raise_error(Airbrake::Error,
27
+ "the 'default' notifier isn't configured")
28
+ end
29
+ end
30
+
31
+ describe ".notify" do
32
+ include_examples 'error handling', :notify
33
+
34
+ it "sends exceptions asynchronously" do
35
+ described_class.notify('bingo')
36
+ sleep 2
37
+ expect(a_request(:post, endpoint)).to have_been_made.once
38
+ end
39
+ end
40
+
41
+ describe ".notify_sync" do
42
+ include_examples 'error handling', :notify_sync
43
+
44
+ it "sends exceptions synchronously" do
45
+ expect(described_class.notify_sync('bingo')).to be_a(Hash)
46
+ expect(a_request(:post, endpoint)).to have_been_made.once
47
+ end
48
+
49
+ context "given the notifier argument" do
50
+ it "sends exceptions via that notifier, ignoring other ones" do
51
+ bingo_string = StringIO.new
52
+ bango_string = StringIO.new
53
+
54
+ described_class.configure(:bingo) do |c|
55
+ c.project_id = 113743
56
+ c.project_key = 'fd04e13d806a90f96614ad8e529b2822'
57
+ c.logger = Logger.new(bingo_string)
58
+ end
59
+
60
+ described_class.configure(:bango) do |c|
61
+ c.project_id = 113743
62
+ c.project_key = 'fd04e13d806a90f96614ad8e529b2822'
63
+ c.logger = Logger.new(bango_string)
64
+ end
65
+
66
+ stub_request(:post, endpoint).to_return(status: 201, body: '{"id":1}')
67
+
68
+ described_class.notify_sync('bango', {}, :bango)
69
+ expect(bingo_string.string).to be_empty
70
+ expect(bango_string.string).to match(/\*\*Airbrake: {"id"=>1}/)
71
+ end
72
+ end
73
+
74
+ describe "clean backtrace" do
75
+ shared_examples 'backtrace building' do |msg, argument|
76
+ it(msg) do
77
+ described_class.notify_sync(argument)
78
+
79
+ # rubocop:disable Metrics/LineLength
80
+ expected_body = %r|
81
+ {"errors":\[{"type":"RuntimeError","message":"bingo","backtrace":\[
82
+ {"file":"[\w/\-\.]+spec/airbrake_spec.rb","line":\d+,"function":"[\w/\s\(\)<>]+"},
83
+ {"file":"\[GEM_ROOT\]/gems/rspec-core-.+/.+","line":\d+,"function":"[\w/\s\(\)<>]+"}
84
+ |x
85
+ # rubocop:enable Metrics/LineLength
86
+
87
+ expect(
88
+ a_request(:post, endpoint).
89
+ with(body: expected_body)
90
+ ).to have_been_made.once
91
+ end
92
+ end
93
+
94
+ context "given a String" do
95
+ include_examples(
96
+ 'backtrace building',
97
+ 'converts it to a RuntimeException and builds a fake backtrace',
98
+ 'bingo')
99
+ end
100
+
101
+ context "given an Exception with missing backtrace" do
102
+ include_examples(
103
+ 'backtrace building',
104
+ 'builds a backtrace for it and sends the notice',
105
+ RuntimeError.new('bingo'))
106
+ end
107
+ end
108
+
109
+ context "special params" do
110
+ it "sends context/component and doesn't contain params/component" do
111
+ described_class.notify_sync('bingo', component: 'bango')
112
+
113
+ expect(
114
+ a_request(:post, endpoint).
115
+ with(body: /"context":{.*"component":"bango".+"params":{}/)
116
+ ).to have_been_made.once
117
+ end
118
+
119
+ it "sends context/action and doesn't contain params/action" do
120
+ described_class.notify_sync('bingo', action: 'bango')
121
+
122
+ expect(
123
+ a_request(:post, endpoint).
124
+ with(body: /"context":{.*"action":"bango".+"params":{}/)
125
+ ).to have_been_made.once
126
+ end
127
+ end
128
+ end
129
+
130
+ describe ".configure" do
131
+ context "given an argument" do
132
+ it "configures a notifier with the given name" do
133
+ described_class.configure(:bingo) do |c|
134
+ c.project_id = 123
135
+ c.project_key = '321'
136
+ end
137
+
138
+ notifiers = described_class.instance_variable_get(:@notifiers)
139
+
140
+ expect(notifiers).to be_a(Hash)
141
+ expect(notifiers.keys).to eq([:default, :bingo])
142
+ expect(notifiers.values).to all(satisfy { |v| v.is_a?(Airbrake::Notifier) })
143
+ end
144
+
145
+ it "raises error when a notifier of the given type was already configured" do
146
+ described_class.configure(:bingo) do |c|
147
+ c.project_id = 123
148
+ c.project_key = '321'
149
+ end
150
+
151
+ expect do
152
+ described_class.configure(:bingo) do |c|
153
+ c.project_id = 123
154
+ c.project_key = '321'
155
+ end
156
+ end.to raise_error(Airbrake::Error,
157
+ "the 'bingo' notifier was already configured")
158
+ end
159
+ end
160
+ end
161
+
162
+ describe ".add_filter" do
163
+ include_examples 'error handling', :add_filter
164
+ end
165
+
166
+ describe ".whitelist_keys" do
167
+ include_examples 'error handling', :whitelist_keys
168
+ end
169
+
170
+ describe ".blacklist_keys" do
171
+ include_examples 'error handling', :blacklist_keys
172
+ end
173
+
174
+ describe ".build_notice" do
175
+ include_examples 'error handling', :build_notice
176
+ end
177
+ end