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,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