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,120 @@
1
+ module Airbrake
2
+ ##
3
+ # Represents the Airbrake config. A config contains all the options that you
4
+ # can use to configure an Airbrake instance.
5
+ class Config
6
+ ##
7
+ # @return [Integer] the project identificator. This value *must* be set.
8
+ attr_accessor :project_id
9
+
10
+ ##
11
+ # @return [String] the project key. This value *must* be set.
12
+ attr_accessor :project_key
13
+
14
+ ##
15
+ # @return [Hash] the proxy parameters such as (:host, :port, :user and
16
+ # :password)
17
+ attr_accessor :proxy
18
+
19
+ ##
20
+ # @return [Logger] the default logger used for debug output
21
+ attr_reader :logger
22
+
23
+ ##
24
+ # @return [String] the version of the user's application
25
+ attr_accessor :app_version
26
+
27
+ ##
28
+ # @return [Integer] the max number of notices that can be queued up
29
+ attr_accessor :queue_size
30
+
31
+ ##
32
+ # @return [Integer] the number of worker threads that process the notice
33
+ # queue
34
+ attr_accessor :workers
35
+
36
+ ##
37
+ # @return [String] the host, which provides the API endpoint to which
38
+ # exceptions should be sent
39
+ attr_accessor :host
40
+
41
+ ##
42
+ # @return [String, Pathname] the working directory of your project
43
+ attr_accessor :root_directory
44
+
45
+ ##
46
+ # @return [String, Symbol] the environment the application is running in
47
+ attr_accessor :environment
48
+
49
+ ##
50
+ # @return [Array<String, Symbol>] the array of environments that forbids
51
+ # sending exceptions when the application is running in them. Other
52
+ # possible environments not listed in the array will allow sending
53
+ # occurring exceptions.
54
+ attr_accessor :ignore_environments
55
+
56
+ ##
57
+ # @param [Hash{Symbol=>Object}] user_config the hash to be used to build the
58
+ # config
59
+ def initialize(user_config = {})
60
+ self.proxy = {}
61
+ self.queue_size = 100
62
+ self.workers = 1
63
+
64
+ self.logger = Logger.new(STDOUT)
65
+ logger.level = Logger::WARN
66
+
67
+ self.project_id = user_config[:project_id]
68
+ self.project_key = user_config[:project_key]
69
+ self.host = 'https://airbrake.io'
70
+
71
+ self.ignore_environments = []
72
+
73
+ merge(user_config)
74
+ end
75
+
76
+ ##
77
+ # The full URL to the Airbrake Notice API. Based on the +:host+ option.
78
+ # @return [URI] the endpoint address
79
+ def endpoint
80
+ @endpoint ||=
81
+ begin
82
+ self.host = ('https://' << host) if host !~ %r{\Ahttps?://}
83
+ api = "/api/v3/projects/#{project_id}/notices?key=#{project_key}"
84
+ URI.join(host, api)
85
+ end
86
+ end
87
+
88
+ ##
89
+ # Sets the logger. Never allows to assign `nil` as the logger.
90
+ # @return [Logger] the logger
91
+ def logger=(logger)
92
+ @logger = logger || @logger
93
+ end
94
+
95
+ ##
96
+ # Merges the given +config_hash+ with itself.
97
+ #
98
+ # @example
99
+ # config.merge(host: 'localhost:8080')
100
+ #
101
+ # @return [self] the merged config
102
+ def merge(config_hash)
103
+ config_hash.each_pair { |option, value| set_option(option, value) }
104
+ self
105
+ end
106
+
107
+ private
108
+
109
+ def set_option(option, value)
110
+ __send__("#{option}=", value)
111
+ rescue NoMethodError
112
+ raise Airbrake::Error, "unknown option '#{option}'"
113
+ end
114
+
115
+ def set_endpoint(id, key, host)
116
+ host = ('https://' << host) if host !~ %r{\Ahttps?://}
117
+ @endpoint = URI.join(host, "/api/v3/projects/#{id}/notices?key=#{key}")
118
+ end
119
+ end
120
+ end
@@ -0,0 +1,86 @@
1
+ module Airbrake
2
+ ##
3
+ # Represents the mechanism for filtering notices. Defines a few default
4
+ # filters.
5
+ # @see Airbrake.add_filter
6
+ class FilterChain
7
+ ##
8
+ # Replaces paths to gems with a placeholder.
9
+ # @return [Proc]
10
+ GEM_ROOT_FILTER = proc do |notice|
11
+ return unless defined?(Gem)
12
+
13
+ notice[:errors].each do |error|
14
+ Gem.path.each do |gem_path|
15
+ error[:backtrace].each do |frame|
16
+ frame[:file].sub!(/\A#{gem_path}/, '[GEM_ROOT]'.freeze)
17
+ end
18
+ end
19
+ end
20
+ end
21
+
22
+ ##
23
+ # Skip over SystemExit exceptions, because they're just noise.
24
+ # @return [Proc]
25
+ SYSTEM_EXIT_FILTER = proc do |notice|
26
+ if notice[:errors].any? { |error| error[:type] == 'SystemExit' }
27
+ notice.ignore!
28
+ end
29
+ end
30
+
31
+ ##
32
+ # @param [Airbrake::Config] config
33
+ def initialize(config)
34
+ @filters = []
35
+
36
+ if config.ignore_environments.any?
37
+ add_filter(env_filter(config.environment, config.ignore_environments))
38
+ end
39
+
40
+ [SYSTEM_EXIT_FILTER, GEM_ROOT_FILTER].each do |filter|
41
+ add_filter(filter)
42
+ end
43
+
44
+ root_directory = config.root_directory
45
+ add_filter(root_directory_filter(root_directory)) if root_directory
46
+ end
47
+
48
+ ##
49
+ # Adds a filter to the filter chain.
50
+ # @param [#call] filter The filter object (proc, class, module, etc)
51
+ def add_filter(filter)
52
+ @filters << filter
53
+ end
54
+
55
+ ##
56
+ # Applies all the filters in the filter chain to the given notice. Does not
57
+ # filter ignored notices.
58
+ #
59
+ # @param [Airbrake::Notice] notice The notice to be filtered
60
+ # @return [void]
61
+ def refine(notice)
62
+ @filters.each do |filter|
63
+ break if notice.ignored?
64
+ filter.call(notice)
65
+ end
66
+ end
67
+
68
+ private
69
+
70
+ def root_directory_filter(root_directory)
71
+ proc do |notice|
72
+ notice[:errors].each do |error|
73
+ error[:backtrace].each do |frame|
74
+ frame[:file].sub!(/\A#{root_directory}/, '[PROJECT_ROOT]'.freeze)
75
+ end
76
+ end
77
+ end
78
+ end
79
+
80
+ def env_filter(environment, ignore_environments)
81
+ proc do |notice|
82
+ notice.ignore! if ignore_environments.include?(environment)
83
+ end
84
+ end
85
+ end
86
+ end
@@ -0,0 +1,10 @@
1
+ module Airbrake
2
+ ##
3
+ # Represents a namespace for default Airbrake Ruby filters.
4
+ module Filters
5
+ ##
6
+ # @return [Array<Symbol>] parts of a Notice's payload that can be modified
7
+ # by various filters
8
+ FILTERABLE_KEYS = [:environment, :session, :params].freeze
9
+ end
10
+ end
@@ -0,0 +1,37 @@
1
+ module Airbrake
2
+ module Filters
3
+ ##
4
+ # A default Airbrake notice filter. Filters only specific keys listed in the
5
+ # list of parameters in the modifiable payload of a notice.
6
+ #
7
+ # @example
8
+ # filter = Airbrake::Filters::KeysBlacklist.new(:email, /credit/i, 'password')
9
+ # airbrake.add_filter(filter)
10
+ # airbrake.notify(StandardError.new('App crashed!'), {
11
+ # user: 'John'
12
+ # password: 's3kr3t',
13
+ # email: 'john@example.com',
14
+ # credit_card: '5555555555554444'
15
+ # })
16
+ #
17
+ # # The dashboard will display this parameter as is, but all other
18
+ # # values will be filtered:
19
+ # # { user: 'John',
20
+ # # password: '[Filtered]',
21
+ # # email: '[Filtered]',
22
+ # # credit_card: '[Filtered]' }
23
+ #
24
+ # @see KeysWhitelist
25
+ # @see KeysFilter
26
+ class KeysBlacklist
27
+ include KeysFilter
28
+
29
+ ##
30
+ # @return [Boolean] true if the key matches at least one pattern, false
31
+ # otherwise
32
+ def should_filter?(key)
33
+ @patterns.any? { |pattern| key.to_s.match(pattern) }
34
+ end
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,65 @@
1
+ module Airbrake
2
+ module Filters
3
+ ##
4
+ # This is a filter helper that endows a class ability to filter notices'
5
+ # payload based on the return value of the +should_filter?+ method that a
6
+ # class that includes this module must implement.
7
+ #
8
+ # @see Notice
9
+ # @see KeysWhitelist
10
+ # @see KeysBlacklist
11
+ module KeysFilter
12
+ ##
13
+ # Creates a new KeysBlacklist or KeysWhitelist filter that uses the given
14
+ # +patterns+ for filtering a notice's payload.
15
+ #
16
+ # @param [Array<String,Regexp,Symbol>] patterns
17
+ def initialize(*patterns)
18
+ @patterns = patterns.map(&:to_s)
19
+ end
20
+
21
+ ##
22
+ # This is a mandatory method required by any filter integrated with
23
+ # FilterChain.
24
+ #
25
+ # @param [Notice] notice the notice to be filtered
26
+ # @return [void]
27
+ # @see FilterChain
28
+ def call(notice)
29
+ FILTERABLE_KEYS.each { |key| filter_hash(notice[key]) }
30
+
31
+ return unless notice[:context][:url]
32
+ url = URI(notice[:context][:url])
33
+ return if url.nil? || url.query.nil?
34
+
35
+ notice[:context][:url] = filter_url_params(url)
36
+ end
37
+
38
+ ##
39
+ # @raise [NotImplementedError] if called directly
40
+ def should_filter?(_key)
41
+ raise NotImplementedError, 'method must be implemented in the included class'
42
+ end
43
+
44
+ private
45
+
46
+ def filter_hash(hash)
47
+ hash.each_key do |key|
48
+ if should_filter?(key)
49
+ hash[key] = '[Filtered]'.freeze
50
+ else
51
+ filter_hash(hash[key]) if hash[key].is_a?(Hash)
52
+ end
53
+ end
54
+ end
55
+
56
+ def filter_url_params(url)
57
+ url.query = Hash[URI.decode_www_form(url.query)].map do |key, val|
58
+ should_filter?(key) ? "#{key}=[Filtered]" : "#{key}=#{val}"
59
+ end.join('&')
60
+
61
+ url.to_s
62
+ end
63
+ end
64
+ end
65
+ end
@@ -0,0 +1,37 @@
1
+ module Airbrake
2
+ module Filters
3
+ ##
4
+ # A default Airbrake notice filter. Filters everything in the modifiable
5
+ # payload of a notice, but specified keys.
6
+ #
7
+ # @example
8
+ # filter = Airbrake::Filters::KeysWhitelist.new(:email, /user/i, 'account_id')
9
+ # airbrake.add_filter(filter)
10
+ # airbrake.notify(StandardError.new('App crashed!'), {
11
+ # user: 'John',
12
+ # password: 's3kr3t',
13
+ # email: 'john@example.com',
14
+ # account_id: 42
15
+ # })
16
+ #
17
+ # # The dashboard will display this parameters as filtered, but other
18
+ # # values won't be affected:
19
+ # # { user: 'John',
20
+ # # password: '[Filtered]',
21
+ # # email: 'john@example.com',
22
+ # # account_id: 42 }
23
+ #
24
+ # @see KeysBlacklist
25
+ # @see KeysFilter
26
+ class KeysWhitelist
27
+ include KeysFilter
28
+
29
+ ##
30
+ # @return [Boolean] true if the key doesn't match any pattern, false
31
+ # otherwise.
32
+ def should_filter?(key)
33
+ @patterns.none? { |pattern| key.to_s.match(pattern) }
34
+ end
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,207 @@
1
+ module Airbrake
2
+ ##
3
+ # Represents a chunk of information that is meant to be either sent to
4
+ # Airbrake or ignored completely.
5
+ class Notice
6
+ # @return [Hash{Symbol=>String}] the information about the notifier library
7
+ NOTIFIER = {
8
+ name: 'airbrake-ruby'.freeze,
9
+ version: Airbrake::AIRBRAKE_RUBY_VERSION,
10
+ url: 'https://github.com/airbrake/airbrake-ruby'.freeze
11
+ }.freeze
12
+
13
+ ##
14
+ # @return [Hash{Symbol=>String,Hash}] the information to be displayed in the
15
+ # Context tab in the dashboard
16
+ CONTEXT = {
17
+ os: RUBY_PLATFORM,
18
+ language: RUBY_VERSION,
19
+ notifier: NOTIFIER
20
+ }.freeze
21
+
22
+ ##
23
+ # @return [Integer] the maxium size of the JSON payload in bytes
24
+ MAX_NOTICE_SIZE = 64000
25
+
26
+ ##
27
+ # @return [Integer] the maximum number of nested exceptions that a notice
28
+ # can unwrap. Exceptions that have a longer cause chain will be ignored
29
+ MAX_NESTED_EXCEPTIONS = 3
30
+
31
+ ##
32
+ # @return [Integer] the maximum size of hashes, arrays and strings in the
33
+ # notice.
34
+ PAYLOAD_MAX_SIZE = 10000
35
+
36
+ ##
37
+ # @return [Array<StandardError>] the list of possible exceptions that might
38
+ # be raised when an object is converted to JSON
39
+ JSON_EXCEPTIONS = [
40
+ IOError,
41
+ NotImplementedError,
42
+ JSON::GeneratorError,
43
+ Encoding::UndefinedConversionError
44
+ ]
45
+
46
+ # @return [Array<Symbol>] the list of keys that can be be overwritten with
47
+ # {Airbrake::Notice#[]=}
48
+ WRITABLE_KEYS = [
49
+ :notifier,
50
+ :context,
51
+ :environment,
52
+ :session,
53
+ :params
54
+ ]
55
+
56
+ def initialize(config, exception, params = {})
57
+ @config = config
58
+
59
+ @private_payload = {
60
+ notifier: NOTIFIER
61
+ }.freeze
62
+
63
+ @modifiable_payload = {
64
+ errors: errors(exception),
65
+ context: context(params),
66
+ environment: {},
67
+ session: {},
68
+ params: params
69
+ }
70
+
71
+ @truncator = PayloadTruncator.new(PAYLOAD_MAX_SIZE, @config.logger)
72
+ end
73
+
74
+ ##
75
+ # Converts the notice to JSON. Calls +to_json+ on each object inside
76
+ # notice's payload. Truncates notices, JSON representation of which is
77
+ # bigger than {MAX_NOTICE_SIZE}.
78
+ #
79
+ # @return [Hash{String=>String}]
80
+ def to_json
81
+ loop do
82
+ begin
83
+ json = payload.to_json
84
+ rescue *JSON_EXCEPTIONS => ex
85
+ @config.logger.debug("#{LOG_LABEL} `notice.to_json` failed: #{ex.to_s.chomp}")
86
+ else
87
+ return json if json && json.bytesize <= MAX_NOTICE_SIZE
88
+ end
89
+
90
+ truncate_payload
91
+ end
92
+ end
93
+
94
+ ##
95
+ # Ignores a notice. Ignored notices never reach the Airbrake dashboard.
96
+ #
97
+ # @return [void]
98
+ # @see #ignored?
99
+ # @note Ignored noticed can't be unignored
100
+ def ignore!
101
+ @modifiable_payload = nil
102
+ end
103
+
104
+ ##
105
+ # Checks whether the notice was ignored.
106
+ #
107
+ # @return [Boolean]
108
+ # @see #ignore!
109
+ def ignored?
110
+ @modifiable_payload.nil?
111
+ end
112
+
113
+ ##
114
+ # Reads a value from notice's modifiable payload.
115
+ # @return [Object]
116
+ #
117
+ # @raise [Airbrake::Error] if the notice is ignored
118
+ def [](key)
119
+ raise_if_ignored
120
+ @modifiable_payload[key]
121
+ end
122
+
123
+ ##
124
+ # Writes a value to the modifiable payload hash. Restricts unrecognized
125
+ # writes.
126
+ # @example
127
+ # notice[:params][:my_param] = 'foobar'
128
+ #
129
+ # @return [void]
130
+ # @raise [Airbrake::Error] if the notice is ignored
131
+ # @raise [Airbrake::Error] if the +key+ is not recognized
132
+ # @raise [Airbrake::Error] if the root value is not a Hash
133
+ def []=(key, value)
134
+ raise_if_ignored
135
+ raise_if_unrecognized_key(key)
136
+ raise_if_non_hash_value(value)
137
+
138
+ @modifiable_payload[key] = value.to_hash
139
+ end
140
+
141
+ private
142
+
143
+ def context(params)
144
+ { version: @config.app_version,
145
+ # We ensure that root_directory is always a String, so it can always be
146
+ # converted to JSON in a predictable manner (when it's a Pathname and in
147
+ # Rails environment, it converts to unexpected JSON).
148
+ rootDirectory: @config.root_directory.to_s,
149
+ environment: @config.environment,
150
+
151
+ # Legacy Airbrake v4 behaviour.
152
+ component: params.delete(:component),
153
+ action: params.delete(:action)
154
+ }.merge(CONTEXT).delete_if { |_key, val| val.nil? || val.empty? }
155
+ end
156
+
157
+ def raise_if_ignored
158
+ return unless self.ignored?
159
+ raise Airbrake::Error, 'cannot access ignored notice'
160
+ end
161
+
162
+ def raise_if_unrecognized_key(key)
163
+ return if WRITABLE_KEYS.include?(key)
164
+ raise Airbrake::Error,
165
+ ":#{key} is not recognized among #{WRITABLE_KEYS}"
166
+ end
167
+
168
+ def raise_if_non_hash_value(value)
169
+ return if value.respond_to?(:to_hash)
170
+ raise Airbrake::Error, "Got #{value.class} value, wanted a Hash"
171
+ end
172
+
173
+ def payload
174
+ @modifiable_payload.merge(@private_payload)
175
+ end
176
+
177
+ def errors(exception)
178
+ exception_list = []
179
+
180
+ while exception && exception_list.size < MAX_NESTED_EXCEPTIONS
181
+ exception_list << exception
182
+
183
+ exception = if exception.respond_to?(:cause) && exception.cause
184
+ exception.cause
185
+ end
186
+ end
187
+
188
+ exception_list.map do |e|
189
+ { type: e.class.name,
190
+ message: e.message,
191
+ backtrace: Backtrace.parse(e) }
192
+ end
193
+ end
194
+
195
+ def truncate_payload
196
+ @modifiable_payload[:errors].each do |error|
197
+ @truncator.truncate_error(error)
198
+ end
199
+
200
+ Filters::FILTERABLE_KEYS.each do |key|
201
+ @truncator.truncate_object(@modifiable_payload[key])
202
+ end
203
+
204
+ @truncator.reduce_max_size
205
+ end
206
+ end
207
+ end