airbrake-ruby 4.1.0 → 5.0.0

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.
Files changed (94) hide show
  1. checksums.yaml +5 -5
  2. data/lib/airbrake-ruby/async_sender.rb +22 -96
  3. data/lib/airbrake-ruby/backtrace.rb +8 -7
  4. data/lib/airbrake-ruby/benchmark.rb +39 -0
  5. data/lib/airbrake-ruby/code_hunk.rb +1 -1
  6. data/lib/airbrake-ruby/config/processor.rb +84 -0
  7. data/lib/airbrake-ruby/config/validator.rb +9 -3
  8. data/lib/airbrake-ruby/config.rb +76 -20
  9. data/lib/airbrake-ruby/deploy_notifier.rb +1 -1
  10. data/lib/airbrake-ruby/file_cache.rb +6 -0
  11. data/lib/airbrake-ruby/filter_chain.rb +16 -1
  12. data/lib/airbrake-ruby/filters/dependency_filter.rb +1 -0
  13. data/lib/airbrake-ruby/filters/exception_attributes_filter.rb +2 -2
  14. data/lib/airbrake-ruby/filters/gem_root_filter.rb +1 -0
  15. data/lib/airbrake-ruby/filters/git_last_checkout_filter.rb +5 -5
  16. data/lib/airbrake-ruby/filters/git_repository_filter.rb +3 -0
  17. data/lib/airbrake-ruby/filters/git_revision_filter.rb +2 -0
  18. data/lib/airbrake-ruby/filters/{keys_whitelist.rb → keys_allowlist.rb} +3 -3
  19. data/lib/airbrake-ruby/filters/{keys_blacklist.rb → keys_blocklist.rb} +3 -3
  20. data/lib/airbrake-ruby/filters/keys_filter.rb +39 -20
  21. data/lib/airbrake-ruby/filters/root_directory_filter.rb +1 -0
  22. data/lib/airbrake-ruby/filters/sql_filter.rb +30 -6
  23. data/lib/airbrake-ruby/filters/system_exit_filter.rb +1 -0
  24. data/lib/airbrake-ruby/filters/thread_filter.rb +4 -2
  25. data/lib/airbrake-ruby/grouppable.rb +12 -0
  26. data/lib/airbrake-ruby/ignorable.rb +1 -0
  27. data/lib/airbrake-ruby/inspectable.rb +2 -2
  28. data/lib/airbrake-ruby/loggable.rb +2 -2
  29. data/lib/airbrake-ruby/mergeable.rb +12 -0
  30. data/lib/airbrake-ruby/monotonic_time.rb +48 -0
  31. data/lib/airbrake-ruby/notice.rb +10 -20
  32. data/lib/airbrake-ruby/notice_notifier.rb +23 -42
  33. data/lib/airbrake-ruby/performance_breakdown.rb +52 -0
  34. data/lib/airbrake-ruby/performance_notifier.rb +126 -49
  35. data/lib/airbrake-ruby/promise.rb +1 -0
  36. data/lib/airbrake-ruby/query.rb +26 -11
  37. data/lib/airbrake-ruby/queue.rb +65 -0
  38. data/lib/airbrake-ruby/remote_settings/settings_data.rb +120 -0
  39. data/lib/airbrake-ruby/remote_settings.rb +145 -0
  40. data/lib/airbrake-ruby/request.rb +20 -6
  41. data/lib/airbrake-ruby/stashable.rb +15 -0
  42. data/lib/airbrake-ruby/stat.rb +34 -24
  43. data/lib/airbrake-ruby/sync_sender.rb +3 -2
  44. data/lib/airbrake-ruby/tdigest.rb +43 -58
  45. data/lib/airbrake-ruby/thread_pool.rb +138 -0
  46. data/lib/airbrake-ruby/timed_trace.rb +58 -0
  47. data/lib/airbrake-ruby/truncator.rb +10 -4
  48. data/lib/airbrake-ruby/version.rb +11 -1
  49. data/lib/airbrake-ruby.rb +219 -53
  50. data/spec/airbrake_spec.rb +428 -9
  51. data/spec/async_sender_spec.rb +26 -110
  52. data/spec/backtrace_spec.rb +44 -44
  53. data/spec/benchmark_spec.rb +33 -0
  54. data/spec/code_hunk_spec.rb +11 -11
  55. data/spec/config/processor_spec.rb +209 -0
  56. data/spec/config/validator_spec.rb +23 -6
  57. data/spec/config_spec.rb +77 -7
  58. data/spec/deploy_notifier_spec.rb +2 -2
  59. data/spec/{file_cache.rb → file_cache_spec.rb} +2 -4
  60. data/spec/filter_chain_spec.rb +28 -1
  61. data/spec/filters/dependency_filter_spec.rb +1 -1
  62. data/spec/filters/gem_root_filter_spec.rb +9 -9
  63. data/spec/filters/git_last_checkout_filter_spec.rb +21 -4
  64. data/spec/filters/git_repository_filter.rb +1 -1
  65. data/spec/filters/git_revision_filter_spec.rb +13 -11
  66. data/spec/filters/{keys_whitelist_spec.rb → keys_allowlist_spec.rb} +29 -28
  67. data/spec/filters/{keys_blacklist_spec.rb → keys_blocklist_spec.rb} +39 -29
  68. data/spec/filters/root_directory_filter_spec.rb +9 -9
  69. data/spec/filters/sql_filter_spec.rb +110 -55
  70. data/spec/filters/system_exit_filter_spec.rb +1 -1
  71. data/spec/filters/thread_filter_spec.rb +33 -31
  72. data/spec/fixtures/project_root/code.rb +9 -9
  73. data/spec/loggable_spec.rb +17 -0
  74. data/spec/monotonic_time_spec.rb +23 -0
  75. data/spec/{notice_notifier_spec → notice_notifier}/options_spec.rb +19 -21
  76. data/spec/notice_notifier_spec.rb +20 -80
  77. data/spec/notice_spec.rb +9 -11
  78. data/spec/performance_breakdown_spec.rb +11 -0
  79. data/spec/performance_notifier_spec.rb +360 -85
  80. data/spec/query_spec.rb +11 -0
  81. data/spec/queue_spec.rb +18 -0
  82. data/spec/remote_settings/settings_data_spec.rb +365 -0
  83. data/spec/remote_settings_spec.rb +230 -0
  84. data/spec/request_spec.rb +9 -0
  85. data/spec/response_spec.rb +8 -8
  86. data/spec/spec_helper.rb +9 -13
  87. data/spec/stashable_spec.rb +23 -0
  88. data/spec/stat_spec.rb +17 -15
  89. data/spec/sync_sender_spec.rb +14 -12
  90. data/spec/tdigest_spec.rb +6 -6
  91. data/spec/thread_pool_spec.rb +187 -0
  92. data/spec/timed_trace_spec.rb +125 -0
  93. data/spec/truncator_spec.rb +12 -12
  94. metadata +55 -18
@@ -0,0 +1,145 @@
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.
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
+ # When {#poll} is called, it will try to load remote settings from disk, so
12
+ # that it doesn't wait on the result from the API call.
13
+ #
14
+ # When {#stop_polling} is called, the current config will be dumped to disk.
15
+ #
16
+ # @since 5.0.0
17
+ # @api private
18
+ class RemoteSettings
19
+ include Airbrake::Loggable
20
+
21
+ # @return [String] the path to the persistent config
22
+ CONFIG_DUMP_PATH = File.join(
23
+ File.expand_path(__dir__),
24
+ '../../config/config.json',
25
+ ).freeze
26
+
27
+ # @return [Hash{Symbol=>String}] metadata to be attached to every GET
28
+ # request
29
+ QUERY_PARAMS = URI.encode_www_form(
30
+ notifier_name: Airbrake::NOTIFIER_INFO[:name],
31
+ notifier_version: Airbrake::NOTIFIER_INFO[:version],
32
+ os: RUBY_PLATFORM,
33
+ language: "#{RUBY_ENGINE}/#{RUBY_VERSION}".freeze,
34
+ ).freeze
35
+
36
+ # Polls remote config of the given project.
37
+ #
38
+ # @param [Integer] project_id
39
+ # @param [String] host
40
+ # @yield [data]
41
+ # @yieldparam data [Airbrake::RemoteSettings::SettingsData]
42
+ # @return [Airbrake::RemoteSettings]
43
+ def self.poll(project_id, host, &block)
44
+ new(project_id, host, &block).poll
45
+ end
46
+
47
+ # @param [Integer] project_id
48
+ # @yield [data]
49
+ # @yieldparam data [Airbrake::RemoteSettings::SettingsData]
50
+ def initialize(project_id, host, &block)
51
+ @data = SettingsData.new(project_id, {})
52
+ @host = host
53
+ @block = block
54
+ @poll = nil
55
+ end
56
+
57
+ # Polls remote config of the given project in background. Loads local config
58
+ # first (if exists).
59
+ #
60
+ # @return [self]
61
+ def poll
62
+ @poll ||= Thread.new do
63
+ begin
64
+ load_config
65
+ rescue StandardError => ex
66
+ logger.error("#{LOG_LABEL} config loading failed: #{ex}")
67
+ end
68
+
69
+ @block.call(@data)
70
+
71
+ loop do
72
+ @block.call(@data.merge!(fetch_config))
73
+ sleep(@data.interval)
74
+ end
75
+ end
76
+
77
+ self
78
+ end
79
+
80
+ # Stops the background poller thread. Dumps current config to disk.
81
+ #
82
+ # @return [void]
83
+ def stop_polling
84
+ @poll.kill if @poll
85
+
86
+ begin
87
+ dump_config
88
+ rescue StandardError => ex
89
+ logger.error("#{LOG_LABEL} config dumping failed: #{ex}")
90
+ end
91
+ end
92
+
93
+ private
94
+
95
+ def fetch_config
96
+ response = nil
97
+ begin
98
+ response = Net::HTTP.get(build_config_uri)
99
+ rescue StandardError => ex
100
+ logger.error(ex)
101
+ return {}
102
+ end
103
+
104
+ # AWS S3 API returns XML when request is not valid. In this case we just
105
+ # print the returned body and exit the method.
106
+ if response.start_with?('<?xml ')
107
+ logger.error(response)
108
+ return {}
109
+ end
110
+
111
+ json = nil
112
+ begin
113
+ json = JSON.parse(response)
114
+ rescue JSON::ParserError => ex
115
+ logger.error(ex)
116
+ return {}
117
+ end
118
+
119
+ json
120
+ end
121
+
122
+ def build_config_uri
123
+ uri = URI(@data.config_route(@host))
124
+ uri.query = QUERY_PARAMS
125
+ uri
126
+ end
127
+
128
+ def load_config
129
+ config_dir = File.dirname(CONFIG_DUMP_PATH)
130
+ Dir.mkdir(config_dir) unless File.directory?(config_dir)
131
+
132
+ return unless File.exist?(CONFIG_DUMP_PATH)
133
+
134
+ config = File.read(CONFIG_DUMP_PATH)
135
+ @data.merge!(JSON.parse(config))
136
+ end
137
+
138
+ def dump_config
139
+ config_dir = File.dirname(CONFIG_DUMP_PATH)
140
+ Dir.mkdir(config_dir) unless File.directory?(config_dir)
141
+
142
+ File.write(CONFIG_DUMP_PATH, JSON.dump(@data.to_h))
143
+ end
144
+ end
145
+ end
@@ -4,21 +4,35 @@ module Airbrake
4
4
  # @see Airbrake.notify_request
5
5
  # @api public
6
6
  # @since v3.2.0
7
- Request = Struct.new(:method, :route, :status_code, :start_time, :end_time) do
7
+ class Request
8
8
  include HashKeyable
9
9
  include Ignorable
10
+ include Stashable
11
+ include Mergeable
12
+ include Grouppable
13
+
14
+ attr_accessor :method, :route, :status_code, :timing, :time
10
15
 
11
16
  def initialize(
12
17
  method:,
13
18
  route:,
14
19
  status_code:,
15
- start_time:,
16
- end_time: Time.now
20
+ timing: nil,
21
+ time: Time.now
17
22
  )
18
- super(method, route, status_code, start_time, end_time)
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'
19
33
  end
20
34
 
21
- def name
35
+ def cargo
22
36
  'routes'
23
37
  end
24
38
 
@@ -27,7 +41,7 @@ module Airbrake
27
41
  'method' => method,
28
42
  'route' => route,
29
43
  'statusCode' => status_code,
30
- 'time' => TimeTruncate.utc_truncate_minutes(start_time)
44
+ 'time' => @time_utc,
31
45
  }.delete_if { |_key, val| val.nil? }
32
46
  end
33
47
  end
@@ -0,0 +1,15 @@
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
@@ -9,48 +9,58 @@ module Airbrake
9
9
  #
10
10
  # @example
11
11
  # stat = Airbrake::Stat.new
12
- # stat.increment(Time.now - 200)
12
+ # stat.increment_ms(2000)
13
13
  # stat.to_h # Pack and serialize data so it can be transmitted.
14
14
  #
15
15
  # @since v3.2.0
16
- Stat = Struct.new(:count, :sum, :sumsq, :tdigest) do
17
- # @param [Integer] count How many times this stat was incremented
16
+ class Stat
17
+ attr_accessor :sum, :sumsq, :tdigest
18
+
18
19
  # @param [Float] sum The sum of duration in milliseconds
19
20
  # @param [Float] sumsq The squared sum of duration in milliseconds
20
21
  # @param [TDigest::TDigest] tdigest Packed durations. By default,
21
22
  # compression is 20
22
- def initialize(count: 0, sum: 0.0, sumsq: 0.0, tdigest: TDigest.new(0.05))
23
- super(count, sum, sumsq, tdigest)
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
24
28
  end
25
29
 
26
30
  # @return [Hash{String=>Object}] stats as a hash with compressed TDigest
27
31
  # (serialized as base64)
28
32
  def to_h
29
- tdigest.compress!
30
- {
31
- 'count' => count,
32
- 'sum' => sum,
33
- 'sumsq' => sumsq,
34
- 'tdigest' => Base64.strict_encode64(tdigest.as_small_bytes)
35
- }
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
36
42
  end
37
43
 
38
- # Increments count and updates performance with the difference of +end_time+
39
- # and +start_time+.
44
+ # Increments tdigest timings and updates tdigest with given +ms+ value.
40
45
  #
41
- # @param [Date] start_time
42
- # @param [Date] end_time
46
+ # @param [Float] ms
43
47
  # @return [void]
44
- def increment(start_time, end_time = nil)
45
- end_time ||= Time.new
46
-
47
- self.count += 1
48
+ def increment_ms(ms)
49
+ @mutex.synchronize do
50
+ self.sum += ms
51
+ self.sumsq += ms * ms
48
52
 
49
- ms = (end_time - start_time) * 1000
50
- self.sum += ms
51
- self.sumsq += ms * ms
53
+ tdigest.push(ms)
54
+ end
55
+ end
52
56
 
53
- tdigest.push(ms)
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
+ "#<struct Airbrake::Stat count=#{tdigest.size}, sum=#{sum}, sumsq=#{sumsq}>"
54
63
  end
64
+ alias pretty_print inspect
55
65
  end
56
66
  end
@@ -23,7 +23,7 @@ module Airbrake
23
23
  # @param [#to_json] data
24
24
  # @param [URI::HTTPS] endpoint
25
25
  # @return [Hash{String=>String}] the parsed HTTP response
26
- def send(data, promise, endpoint = @config.endpoint)
26
+ def send(data, promise, endpoint = @config.error_endpoint)
27
27
  return promise if rate_limited_ip?(promise)
28
28
 
29
29
  response = nil
@@ -47,6 +47,7 @@ module Airbrake
47
47
  end
48
48
 
49
49
  return promise.reject(parsed_resp['error']) if parsed_resp.key?('error')
50
+
50
51
  promise.resolve(parsed_resp)
51
52
  end
52
53
 
@@ -79,7 +80,7 @@ module Airbrake
79
80
  req['Authorization'] = "Bearer #{@config.project_key}"
80
81
  req['Content-Type'] = CONTENT_TYPE
81
82
  req['User-Agent'] =
82
- "#{Airbrake::Notice::NOTIFIER[:name]}/#{Airbrake::AIRBRAKE_RUBY_VERSION}" \
83
+ "#{Airbrake::NOTIFIER_INFO[:name]}/#{Airbrake::AIRBRAKE_RUBY_VERSION}" \
83
84
  " Ruby/#{RUBY_VERSION}"
84
85
 
85
86
  req
@@ -37,14 +37,15 @@ module Airbrake
37
37
  end
38
38
 
39
39
  attr_accessor :centroids
40
+ attr_reader :size
41
+
40
42
  def initialize(delta = 0.01, k = 25, cx = 1.1)
41
43
  @delta = delta
42
44
  @k = k
43
45
  @cx = cx
44
46
  @centroids = RBTree.new
45
- @nreset = 0
46
- @n = 0
47
- reset!
47
+ @size = 0
48
+ @last_cumulate = 0
48
49
  end
49
50
 
50
51
  def +(other)
@@ -59,8 +60,8 @@ module Airbrake
59
60
  # compression as defined by Java implementation
60
61
  size = @centroids.size
61
62
  output = [VERBOSE_ENCODING, compression, size]
62
- output += @centroids.map { |_, c| c.mean }
63
- output += @centroids.map { |_, c| c.n }
63
+ output += @centroids.each_value.map(&:mean)
64
+ output += @centroids.each_value.map(&:n)
64
65
  output.pack("NGNG#{size}N#{size}")
65
66
  end
66
67
 
@@ -70,14 +71,14 @@ module Airbrake
70
71
  output = [self.class::SMALL_ENCODING, compression, size]
71
72
  x = 0
72
73
  # delta encoding allows saving 4-bytes floats
73
- mean_arr = @centroids.map do |_, c|
74
+ mean_arr = @centroids.each_value.map do |c|
74
75
  val = c.mean - x
75
76
  x = c.mean
76
77
  val
77
78
  end
78
79
  output += mean_arr
79
80
  # Variable length encoding of numbers
80
- c_arr = @centroids.each_with_object([]) do |(_, c), arr|
81
+ c_arr = @centroids.each_value.each_with_object([]) do |c, arr|
81
82
  k = 0
82
83
  n = c.n
83
84
  while n < 0 || n > 0x7f
@@ -95,7 +96,7 @@ module Airbrake
95
96
  # rubocop:enable Metrics/AbcSize
96
97
 
97
98
  def as_json(_ = nil)
98
- @centroids.map { |_, c| c.as_json }
99
+ @centroids.each_value.map(&:as_json)
99
100
  end
100
101
 
101
102
  def bound_mean(x)
@@ -138,21 +139,17 @@ module Airbrake
138
139
  end
139
140
 
140
141
  def find_nearest(x)
141
- return nil if size == 0
142
-
143
- ceil = @centroids.upper_bound(x)
144
- floor = @centroids.lower_bound(x)
145
-
146
- return floor[1] if ceil.nil?
147
- return ceil[1] if floor.nil?
142
+ return if size == 0
148
143
 
149
- ceil_key = ceil[0]
150
- floor_key = floor[0]
144
+ upper_key, upper = @centroids.upper_bound(x)
145
+ lower_key, lower = @centroids.lower_bound(x)
146
+ return lower unless upper_key
147
+ return upper unless lower_key
151
148
 
152
- if (floor_key - x).abs < (ceil_key - x).abs
153
- floor[1]
149
+ if (lower_key - x).abs < (upper_key - x).abs
150
+ lower
154
151
  else
155
- ceil[1]
152
+ upper
156
153
  end
157
154
  end
158
155
 
@@ -186,7 +183,7 @@ module Airbrake
186
183
  mean_cumn += (item - lower.mean) * (upper.mean_cumn - lower.mean_cumn) \
187
184
  / (upper.mean - lower.mean)
188
185
  end
189
- mean_cumn / @n
186
+ mean_cumn / @size
190
187
  end
191
188
  end
192
189
  is_array ? x : x.first
@@ -203,11 +200,12 @@ module Airbrake
203
200
  unless (0..1).cover?(item)
204
201
  raise ArgumentError, "p should be in [0,1], got #{item}"
205
202
  end
203
+
206
204
  if size == 0
207
205
  nil
208
206
  else
209
207
  _cumulate(true)
210
- h = @n * item
208
+ h = @size * item
211
209
  lower, upper = bound_mean_cumn(h)
212
210
  if lower.nil? && upper.nil?
213
211
  nil
@@ -237,17 +235,12 @@ module Airbrake
237
235
 
238
236
  def reset!
239
237
  @centroids.clear
240
- @n = 0
241
- @nreset += 1
238
+ @size = 0
242
239
  @last_cumulate = 0
243
240
  end
244
241
 
245
- def size
246
- @n || 0
247
- end
248
-
249
242
  def to_a
250
- @centroids.map { |_, c| c }
243
+ @centroids.each_value.to_a
251
244
  end
252
245
 
253
246
  # rubocop:disable Metrics/PerceivedComplexity, Metrics/MethodLength
@@ -279,6 +272,7 @@ module Airbrake
279
272
  shift = 7
280
273
  while (v & 0x80) != 0
281
274
  raise 'Shift too large in decode' if shift > 28
275
+
282
276
  v = counts_bytes.shift || 0
283
277
  z += (v & 0x7f) << shift
284
278
  shift += 7
@@ -307,16 +301,16 @@ module Airbrake
307
301
 
308
302
  private
309
303
 
310
- def _add_weight(nearest, x, n)
311
- nearest.mean += n * (x - nearest.mean) / (nearest.n + n) unless x == nearest.mean
312
-
313
- _cumulate(false, true) if nearest.mean_cumn.nil?
304
+ def _add_weight(centroid, x, n)
305
+ unless x == centroid.mean
306
+ centroid.mean += n * (x - centroid.mean) / (centroid.n + n)
307
+ end
314
308
 
315
- nearest.cumn += n
316
- nearest.mean_cumn += n / 2.0
317
- nearest.n += n
309
+ _cumulate(false, true) if centroid.mean_cumn.nil?
318
310
 
319
- nil
311
+ centroid.cumn += n
312
+ centroid.mean_cumn += n / 2.0
313
+ centroid.n += n
320
314
  end
321
315
 
322
316
  # rubocop:disable Metrics/PerceivedComplexity, Metrics/CyclomaticComplexity
@@ -325,17 +319,17 @@ module Airbrake
325
319
  factor = if @last_cumulate == 0
326
320
  Float::INFINITY
327
321
  else
328
- (@n.to_f / @last_cumulate)
322
+ (@size.to_f / @last_cumulate)
329
323
  end
330
- return if @n == @last_cumulate || (!exact && @cx && @cx > factor)
324
+ return if @size == @last_cumulate || (!exact && @cx && @cx > factor)
331
325
  end
332
326
 
333
327
  cumn = 0
334
- @centroids.each do |_, c|
328
+ @centroids.each_value do |c|
335
329
  c.mean_cumn = cumn + c.n / 2.0
336
330
  cumn = c.cumn = cumn + c.n
337
331
  end
338
- @n = @last_cumulate = cumn
332
+ @size = @last_cumulate = cumn
339
333
  nil
340
334
  end
341
335
  # rubocop:enable Metrics/PerceivedComplexity, Metrics/CyclomaticComplexity
@@ -345,28 +339,25 @@ module Airbrake
345
339
  def _digest(x, n)
346
340
  # Use 'first' and 'last' instead of min/max because of performance reasons
347
341
  # This works because RBTree is sorted
348
- min = @centroids.first
349
- max = @centroids.last
350
-
351
- min = min.nil? ? nil : min[1]
352
- max = max.nil? ? nil : max[1]
342
+ min = min.last if (min = @centroids.first)
343
+ max = max.last if (max = @centroids.last)
353
344
  nearest = find_nearest(x)
354
345
 
355
- @n += n
346
+ @size += n
356
347
 
357
348
  if nearest && nearest.mean == x
358
349
  _add_weight(nearest, x, n)
359
350
  elsif nearest == min
360
- _new_centroid(x, n, 0)
351
+ @centroids[x] = Centroid.new(x, n, 0)
361
352
  elsif nearest == max
362
- _new_centroid(x, n, @n)
353
+ @centroids[x] = Centroid.new(x, n, @size)
363
354
  else
364
- p = nearest.mean_cumn.to_f / @n
365
- max_n = (4 * @n * @delta * p * (1 - p)).floor
355
+ p = nearest.mean_cumn.to_f / @size
356
+ max_n = (4 * @size * @delta * p * (1 - p)).floor
366
357
  if max_n - nearest.n >= n
367
358
  _add_weight(nearest, x, n)
368
359
  else
369
- _new_centroid(x, n, nearest.cumn)
360
+ @centroids[x] = Centroid.new(x, n, nearest.cumn)
370
361
  end
371
362
  end
372
363
 
@@ -382,12 +373,6 @@ module Airbrake
382
373
  end
383
374
  # rubocop:enable Metrics/PerceivedComplexity, Metrics/CyclomaticComplexity,
384
375
  # rubocop:enable Metrics/AbcSize
385
-
386
- def _new_centroid(x, n, cumn)
387
- c = Centroid.new(x, n, cumn)
388
- @centroids[x] = c
389
- c
390
- end
391
376
  end
392
377
  # rubocop:enable Metrics/ClassLength
393
378
  end
@@ -0,0 +1,138 @@
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
+ # worker_size: 5,
10
+ # queue_size: 100,
11
+ # block: proc { |message| print "ECHO: #{message}..."}
12
+ # )
13
+ #
14
+ # # Send work.
15
+ # 10.times { |i| thread_pool << i }
16
+ # #=> ECHO: 0...ECHO: 1...ECHO: 2...
17
+ #
18
+ # @api private
19
+ # @since v4.6.1
20
+ class ThreadPool
21
+ include Loggable
22
+
23
+ # @return [ThreadGroup] the list of workers
24
+ # @note This is exposed for eaiser unit testing
25
+ attr_reader :workers
26
+
27
+ def initialize(worker_size:, queue_size:, block:)
28
+ @worker_size = worker_size
29
+ @queue_size = queue_size
30
+ @block = block
31
+
32
+ @queue = SizedQueue.new(queue_size)
33
+ @workers = ThreadGroup.new
34
+ @mutex = Mutex.new
35
+ @pid = nil
36
+ @closed = false
37
+
38
+ has_workers?
39
+ end
40
+
41
+ # Adds a new message to the thread pool. Rejects messages if the queue is at
42
+ # its capacity.
43
+ #
44
+ # @param [Object] message The message that gets passed to the block
45
+ # @return [Boolean] true if the message was successfully sent to the pool,
46
+ # false if the queue is full
47
+ def <<(message)
48
+ if backlog >= @queue_size
49
+ logger.error(
50
+ "#{LOG_LABEL} ThreadPool has reached its capacity of " \
51
+ "#{@queue_size} and the following message will not be " \
52
+ "processed: #{message.inspect}",
53
+ )
54
+ return false
55
+ end
56
+
57
+ @queue << message
58
+ true
59
+ end
60
+
61
+ # @return [Integer] how big the queue is at the moment
62
+ def backlog
63
+ @queue.size
64
+ end
65
+
66
+ # Checks if a thread pool has any workers. A thread pool doesn't have any
67
+ # workers only in two cases: when it was closed or when all workers
68
+ # crashed. An *active* thread pool doesn't have any workers only when
69
+ # something went wrong.
70
+ #
71
+ # Workers are expected to crash when you +fork+ the process the workers are
72
+ # living in. In this case we detect a +fork+ and try to revive them here.
73
+ #
74
+ # Another possible scenario that crashes workers is when you close the
75
+ # instance on +at_exit+, but some other +at_exit+ hook prevents the process
76
+ # from exiting.
77
+ #
78
+ # @return [Boolean] true if an instance wasn't closed, but has no workers
79
+ # @see https://goo.gl/oydz8h Example of at_exit that prevents exit
80
+ def has_workers?
81
+ @mutex.synchronize do
82
+ return false if @closed
83
+
84
+ if @pid != Process.pid && @workers.list.empty?
85
+ @pid = Process.pid
86
+ @workers = ThreadGroup.new
87
+ spawn_workers
88
+ end
89
+
90
+ !@closed && @workers.list.any?
91
+ end
92
+ end
93
+
94
+ # Closes the thread pool making it a no-op (it shut downs all worker
95
+ # threads). Before closing, waits on all unprocessed tasks to be processed.
96
+ #
97
+ # @return [void]
98
+ # @raise [Airbrake::Error] when invoked more than one time
99
+ def close
100
+ threads = @mutex.synchronize do
101
+ raise Airbrake::Error, 'this thread pool is closed already' if @closed
102
+
103
+ unless @queue.empty?
104
+ msg = "#{LOG_LABEL} waiting to process #{@queue.size} task(s)..."
105
+ logger.debug(msg + ' (Ctrl-C to abort)')
106
+ end
107
+
108
+ @worker_size.times { @queue << :stop }
109
+ @closed = true
110
+ @workers.list.dup
111
+ end
112
+
113
+ threads.each(&:join)
114
+ logger.debug("#{LOG_LABEL} thread pool closed")
115
+ end
116
+
117
+ def closed?
118
+ @closed
119
+ end
120
+
121
+ def spawn_workers
122
+ @worker_size.times { @workers.add(spawn_worker) }
123
+ @workers.enclose
124
+ end
125
+
126
+ private
127
+
128
+ def spawn_worker
129
+ Thread.new do
130
+ while (message = @queue.pop)
131
+ break if message == :stop
132
+
133
+ @block.call(message)
134
+ end
135
+ end
136
+ end
137
+ end
138
+ end