airbrake-ruby 1.0.0.rc.1

Sign up to get free protection for your applications and to get access to all the features.
@@ -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