celerbrake-ruby 0.1.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 (58) hide show
  1. checksums.yaml +7 -0
  2. data/lib/celerbrake-ruby/async_sender.rb +57 -0
  3. data/lib/celerbrake-ruby/backlog.rb +123 -0
  4. data/lib/celerbrake-ruby/backtrace.rb +197 -0
  5. data/lib/celerbrake-ruby/benchmark.rb +39 -0
  6. data/lib/celerbrake-ruby/code_hunk.rb +51 -0
  7. data/lib/celerbrake-ruby/config/processor.rb +77 -0
  8. data/lib/celerbrake-ruby/config/validator.rb +97 -0
  9. data/lib/celerbrake-ruby/config.rb +291 -0
  10. data/lib/celerbrake-ruby/context.rb +51 -0
  11. data/lib/celerbrake-ruby/deploy_notifier.rb +36 -0
  12. data/lib/celerbrake-ruby/file_cache.rb +54 -0
  13. data/lib/celerbrake-ruby/filter_chain.rb +112 -0
  14. data/lib/celerbrake-ruby/filters/context_filter.rb +28 -0
  15. data/lib/celerbrake-ruby/filters/dependency_filter.rb +32 -0
  16. data/lib/celerbrake-ruby/filters/exception_attributes_filter.rb +46 -0
  17. data/lib/celerbrake-ruby/filters/gem_root_filter.rb +34 -0
  18. data/lib/celerbrake-ruby/filters/git_last_checkout_filter.rb +92 -0
  19. data/lib/celerbrake-ruby/filters/git_repository_filter.rb +73 -0
  20. data/lib/celerbrake-ruby/filters/git_revision_filter.rb +68 -0
  21. data/lib/celerbrake-ruby/filters/keys_allowlist.rb +48 -0
  22. data/lib/celerbrake-ruby/filters/keys_blocklist.rb +49 -0
  23. data/lib/celerbrake-ruby/filters/keys_filter.rb +159 -0
  24. data/lib/celerbrake-ruby/filters/root_directory_filter.rb +29 -0
  25. data/lib/celerbrake-ruby/filters/sql_filter.rb +128 -0
  26. data/lib/celerbrake-ruby/filters/system_exit_filter.rb +24 -0
  27. data/lib/celerbrake-ruby/filters/thread_filter.rb +93 -0
  28. data/lib/celerbrake-ruby/grouppable.rb +12 -0
  29. data/lib/celerbrake-ruby/hash_keyable.rb +37 -0
  30. data/lib/celerbrake-ruby/ignorable.rb +43 -0
  31. data/lib/celerbrake-ruby/inspectable.rb +39 -0
  32. data/lib/celerbrake-ruby/loggable.rb +34 -0
  33. data/lib/celerbrake-ruby/mergeable.rb +12 -0
  34. data/lib/celerbrake-ruby/monotonic_time.rb +48 -0
  35. data/lib/celerbrake-ruby/nested_exception.rb +59 -0
  36. data/lib/celerbrake-ruby/notice.rb +157 -0
  37. data/lib/celerbrake-ruby/notice_notifier.rb +142 -0
  38. data/lib/celerbrake-ruby/performance_breakdown.rb +52 -0
  39. data/lib/celerbrake-ruby/performance_notifier.rb +177 -0
  40. data/lib/celerbrake-ruby/promise.rb +110 -0
  41. data/lib/celerbrake-ruby/query.rb +59 -0
  42. data/lib/celerbrake-ruby/queue.rb +65 -0
  43. data/lib/celerbrake-ruby/remote_settings/callback.rb +44 -0
  44. data/lib/celerbrake-ruby/remote_settings/settings_data.rb +116 -0
  45. data/lib/celerbrake-ruby/remote_settings.rb +128 -0
  46. data/lib/celerbrake-ruby/request.rb +48 -0
  47. data/lib/celerbrake-ruby/response.rb +125 -0
  48. data/lib/celerbrake-ruby/stashable.rb +15 -0
  49. data/lib/celerbrake-ruby/stat.rb +66 -0
  50. data/lib/celerbrake-ruby/sync_sender.rb +145 -0
  51. data/lib/celerbrake-ruby/tdigest.rb +379 -0
  52. data/lib/celerbrake-ruby/thread_pool.rb +139 -0
  53. data/lib/celerbrake-ruby/time_truncate.rb +17 -0
  54. data/lib/celerbrake-ruby/timed_trace.rb +56 -0
  55. data/lib/celerbrake-ruby/truncator.rb +121 -0
  56. data/lib/celerbrake-ruby/version.rb +16 -0
  57. data/lib/celerbrake-ruby.rb +592 -0
  58. metadata +251 -0
@@ -0,0 +1,379 @@
1
+ require 'rbtree'
2
+
3
+ module Celerbrake
4
+ # Ruby implementation of Ted Dunning's t-digest data structure.
5
+ #
6
+ # This implementation is imported from https://github.com/castle/tdigest with
7
+ # custom modifications. Huge thanks to Castle for the implementation :beer:
8
+ #
9
+ # The difference is that we pack with Big Endian (unlike Native Endian in
10
+ # Castle's version). Our backend does not permit little endian.
11
+ #
12
+ # @see https://github.com/tdunning/t-digest
13
+ # @see https://github.com/castle/tdigest
14
+ # @api private
15
+ # @since v3.2.0
16
+ #
17
+ # rubocop:disable Metrics/ClassLength
18
+ class TDigest
19
+ VERBOSE_ENCODING = 1
20
+ SMALL_ENCODING = 2
21
+
22
+ # Centroid represents a number of data points.
23
+ # @api private
24
+ # @since v3.2.0
25
+ class Centroid
26
+ attr_accessor :mean, :n, :cumn, :mean_cumn
27
+
28
+ def initialize(mean, n, cumn, mean_cumn = nil)
29
+ @mean = mean
30
+ @n = n
31
+ @cumn = cumn
32
+ @mean_cumn = mean_cumn
33
+ end
34
+
35
+ def as_json(_ = nil)
36
+ { m: mean, n: n }
37
+ end
38
+ end
39
+
40
+ attr_accessor :centroids
41
+ attr_reader :size
42
+
43
+ def initialize(delta = 0.01, k = 25, cx = 1.1)
44
+ @delta = delta
45
+ @k = k
46
+ @cx = cx
47
+ @centroids = RBTree.new
48
+ @size = 0
49
+ @last_cumulate = 0
50
+ end
51
+
52
+ def +(other)
53
+ # Uses delta, k and cx from the caller
54
+ t = self.class.new(@delta, @k, @cx)
55
+ data = centroids.values + other.centroids.values
56
+ t.push_centroid(data.delete_at(rand(data.length))) while data.any?
57
+ t
58
+ end
59
+
60
+ def as_bytes
61
+ # compression as defined by Java implementation
62
+ size = @centroids.size
63
+ output = [VERBOSE_ENCODING, compression, size]
64
+ output += @centroids.each_value.map(&:mean)
65
+ output += @centroids.each_value.map(&:n)
66
+ output.pack("NGNG#{size}N#{size}")
67
+ end
68
+
69
+ # rubocop:disable Metrics/AbcSize
70
+ def as_small_bytes
71
+ size = @centroids.size
72
+ output = [self.class::SMALL_ENCODING, compression, size]
73
+ x = 0
74
+ # delta encoding allows saving 4-bytes floats
75
+ mean_arr = @centroids.each_value.map do |c|
76
+ val = c.mean - x
77
+ x = c.mean
78
+ val
79
+ end
80
+ output += mean_arr
81
+ # Variable length encoding of numbers
82
+ c_arr = @centroids.each_value.with_object([]) do |c, arr|
83
+ k = 0
84
+ n = c.n
85
+ while n < 0 || n > 0x7f
86
+ b = 0x80 | (0x7f & n)
87
+ arr << b
88
+ n = n >> 7
89
+ k += 1
90
+ raise 'Unreasonable large number' if k > 6
91
+ end
92
+ arr << n
93
+ end
94
+ output += c_arr
95
+ output.pack("NGNg#{size}C#{size}")
96
+ end
97
+ # rubocop:enable Metrics/AbcSize
98
+
99
+ def as_json(_ = nil)
100
+ @centroids.each_value.map(&:as_json)
101
+ end
102
+
103
+ def bound_mean(x)
104
+ upper = @centroids.upper_bound(x)
105
+ lower = @centroids.lower_bound(x)
106
+ [lower[1], upper[1]]
107
+ end
108
+
109
+ def bound_mean_cumn(cumn)
110
+ last_c = nil
111
+ bounds = []
112
+ @centroids.each_value do |v|
113
+ if v.mean_cumn == cumn
114
+ bounds << v
115
+ break
116
+ elsif v.mean_cumn > cumn
117
+ bounds << last_c
118
+ bounds << v
119
+ break
120
+ else
121
+ last_c = v
122
+ end
123
+ end
124
+ # If still no results, pick lagging value if any
125
+ bounds << last_c if bounds.empty? && !last_c.nil?
126
+
127
+ bounds
128
+ end
129
+
130
+ def compress!
131
+ points = to_a
132
+ reset!
133
+ push_centroid(points.shuffle)
134
+ _cumulate(exact: true, force: true)
135
+ nil
136
+ end
137
+
138
+ def compression
139
+ 1 / @delta
140
+ end
141
+
142
+ def find_nearest(x)
143
+ return if size == 0
144
+
145
+ upper_key, upper = @centroids.upper_bound(x)
146
+ lower_key, lower = @centroids.lower_bound(x)
147
+ return lower unless upper_key
148
+ return upper unless lower_key
149
+
150
+ if (lower_key - x).abs < (upper_key - x).abs
151
+ lower
152
+ else
153
+ upper
154
+ end
155
+ end
156
+
157
+ def merge!(other)
158
+ push_centroid(other.centroids.values.shuffle)
159
+ self
160
+ end
161
+
162
+ # rubocop:disable Metrics/PerceivedComplexity, Metrics/AbcSize
163
+ # rubocop:disable Metrics/CyclomaticComplexity
164
+ def p_rank(x)
165
+ is_array = x.is_a? Array
166
+ x = [x] unless is_array
167
+
168
+ min = @centroids.first
169
+ max = @centroids.last
170
+
171
+ x.map! do |item|
172
+ if size == 0
173
+ nil
174
+ elsif item < min[1].mean
175
+ 0.0
176
+ elsif item > max[1].mean
177
+ 1.0
178
+ else
179
+ _cumulate(exact: true)
180
+ bound = bound_mean(item)
181
+ lower, upper = bound
182
+ mean_cumn = lower.mean_cumn
183
+ if lower != upper
184
+ mean_cumn += (item - lower.mean) * (upper.mean_cumn - lower.mean_cumn) \
185
+ / (upper.mean - lower.mean)
186
+ end
187
+ mean_cumn / @size
188
+ end
189
+ end
190
+ is_array ? x : x.first
191
+ end
192
+ # rubocop:enable Metrics/PerceivedComplexity, Metrics/AbcSize
193
+ # rubocop:enable Metrics/CyclomaticComplexity
194
+
195
+ # rubocop:disable Metrics/PerceivedComplexity, Metrics/CyclomaticComplexity
196
+ # rubocop:disable Metrics/AbcSize
197
+ def percentile(p)
198
+ is_array = p.is_a? Array
199
+ p = [p] unless is_array
200
+ p.map! do |item|
201
+ unless (0..1).cover?(item)
202
+ raise ArgumentError, "p should be in [0,1], got #{item}"
203
+ end
204
+
205
+ if size == 0
206
+ nil
207
+ else
208
+ _cumulate(exact: true)
209
+ h = @size * item
210
+ lower, upper = bound_mean_cumn(h)
211
+ if lower.nil? && upper.nil?
212
+ nil
213
+ elsif upper == lower || lower.nil? || upper.nil?
214
+ (lower || upper).mean
215
+ elsif h == lower.mean_cumn
216
+ lower.mean
217
+ else
218
+ upper.mean
219
+ end
220
+ end
221
+ end
222
+ is_array ? p : p.first
223
+ end
224
+ # rubocop:enable Metrics/PerceivedComplexity, Metrics/CyclomaticComplexity
225
+ # rubocop:enable Metrics/AbcSize
226
+
227
+ def push(x, n = 1)
228
+ x = [x] unless x.is_a? Array
229
+ x.each { |value| _digest(value, n) }
230
+ end
231
+
232
+ def push_centroid(c)
233
+ c = [c] unless c.is_a? Array
234
+ c.each { |centroid| _digest(centroid.mean, centroid.n) }
235
+ end
236
+
237
+ def reset!
238
+ @centroids.clear
239
+ @size = 0
240
+ @last_cumulate = 0
241
+ end
242
+
243
+ def to_a
244
+ @centroids.each_value.to_a
245
+ end
246
+
247
+ # rubocop:disable Metrics/PerceivedComplexity, Metrics/MethodLength
248
+ # rubocop:disable Metrics/CyclomaticComplexity, Metrics/AbcSize
249
+ def self.from_bytes(bytes)
250
+ format, compression, size = bytes.unpack('NGN')
251
+ tdigest = new(1 / compression)
252
+
253
+ start_idx = 16 # after header
254
+ case format
255
+ when VERBOSE_ENCODING
256
+ array = bytes[start_idx..-1].unpack("G#{size}N#{size}")
257
+ means, counts = array.each_slice(size).to_a if array.any?
258
+ when SMALL_ENCODING
259
+ means = bytes[start_idx..(start_idx + (4 * size))].unpack("g#{size}")
260
+ # Decode delta encoding of means
261
+ x = 0
262
+ means.map! do |m|
263
+ m += x
264
+ x = m
265
+ m
266
+ end
267
+ counts_bytes = bytes[(start_idx + (4 * size))..-1].unpack('C*')
268
+ counts = []
269
+ # Decode variable length integer bytes
270
+ size.times do
271
+ v = counts_bytes.shift
272
+ z = 0x7f & v
273
+ shift = 7
274
+ while (v & 0x80) != 0
275
+ raise 'Shift too large in decode' if shift > 28
276
+
277
+ v = counts_bytes.shift || 0
278
+ z += (v & 0x7f) << shift
279
+ shift += 7
280
+ end
281
+ counts << z
282
+ end
283
+ # This shouldn't happen
284
+ raise 'Mismatch' unless counts.size == means.size
285
+ else
286
+ raise 'Unknown compression format'
287
+ end
288
+
289
+ means.zip(counts).each { |val| tdigest.push(val[0], val[1]) } if means && counts
290
+
291
+ tdigest
292
+ end
293
+ # rubocop:enable Metrics/PerceivedComplexity, Metrics/MethodLength
294
+ # rubocop:enable Metrics/CyclomaticComplexity, Metrics/AbcSize
295
+
296
+ def self.from_json(array)
297
+ tdigest = new
298
+ # Handle both string and symbol keys
299
+ array.each { |a| tdigest.push(a['m'] || a[:m], a['n'] || a[:n]) }
300
+ tdigest
301
+ end
302
+
303
+ private
304
+
305
+ def _add_weight(centroid, x, n)
306
+ unless x == centroid.mean
307
+ centroid.mean += n * (x - centroid.mean) / (centroid.n + n)
308
+ end
309
+
310
+ _cumulate(exact: false, force: true) if centroid.mean_cumn.nil?
311
+
312
+ centroid.cumn += n
313
+ centroid.mean_cumn += n / 2.0
314
+ centroid.n += n
315
+ end
316
+
317
+ # rubocop:disable Metrics/PerceivedComplexity, Metrics/CyclomaticComplexity
318
+ def _cumulate(exact: false, force: false)
319
+ unless force
320
+ factor = if @last_cumulate == 0
321
+ Float::INFINITY
322
+ else
323
+ (@size.to_f / @last_cumulate)
324
+ end
325
+ return if @size == @last_cumulate || (!exact && @cx && @cx > factor)
326
+ end
327
+
328
+ cumn = 0
329
+ @centroids.each_value do |c|
330
+ c.mean_cumn = cumn + (c.n / 2.0)
331
+ cumn = c.cumn = cumn + c.n
332
+ end
333
+ @size = @last_cumulate = cumn
334
+ nil
335
+ end
336
+ # rubocop:enable Metrics/PerceivedComplexity, Metrics/CyclomaticComplexity
337
+
338
+ # rubocop:disable Metrics/PerceivedComplexity, Metrics/CyclomaticComplexity
339
+ # rubocop:disable Metrics/AbcSize
340
+ def _digest(x, n)
341
+ # Use 'first' and 'last' instead of min/max because of performance reasons
342
+ # This works because RBTree is sorted
343
+ min = min.last if (min = @centroids.first)
344
+ max = max.last if (max = @centroids.last)
345
+ nearest = find_nearest(x)
346
+
347
+ @size += n
348
+
349
+ if nearest && nearest.mean == x
350
+ _add_weight(nearest, x, n)
351
+ elsif nearest == min
352
+ @centroids[x] = Centroid.new(x, n, 0)
353
+ elsif nearest == max
354
+ @centroids[x] = Centroid.new(x, n, @size)
355
+ else
356
+ p = nearest.mean_cumn.to_f / @size
357
+ max_n = (4 * @size * @delta * p * (1 - p)).floor
358
+ if max_n - nearest.n >= n
359
+ _add_weight(nearest, x, n)
360
+ else
361
+ @centroids[x] = Centroid.new(x, n, nearest.cumn)
362
+ end
363
+ end
364
+
365
+ _cumulate(exact: false)
366
+
367
+ # If the number of centroids has grown to a very large size,
368
+ # it may be due to values being inserted in sorted order.
369
+ # We combat that by replaying the centroids in random order,
370
+ # which is what compress! does
371
+ compress! if @centroids.size > (@k / @delta)
372
+
373
+ nil
374
+ end
375
+ # rubocop:enable Metrics/PerceivedComplexity, Metrics/CyclomaticComplexity,
376
+ # rubocop:enable Metrics/AbcSize
377
+ end
378
+ # rubocop:enable Metrics/ClassLength
379
+ end
@@ -0,0 +1,139 @@
1
+ module Celerbrake
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
+ # name: 'performance-notifier',
10
+ # worker_size: 5,
11
+ # queue_size: 100,
12
+ # block: proc { |message| print "ECHO: #{message}..."}
13
+ # )
14
+ #
15
+ # # Send work.
16
+ # 10.times { |i| thread_pool << i }
17
+ # #=> ECHO: 0...ECHO: 1...ECHO: 2...
18
+ #
19
+ # @api private
20
+ # @since v4.6.1
21
+ class ThreadPool
22
+ include Loggable
23
+
24
+ # @return [ThreadGroup] the list of workers
25
+ # @note This is exposed for eaiser unit testing
26
+ attr_reader :workers
27
+
28
+ def initialize(worker_size:, queue_size:, block:, name: nil)
29
+ @name = name
30
+ @worker_size = worker_size
31
+ @queue_size = queue_size
32
+ @block = block
33
+
34
+ @queue = SizedQueue.new(queue_size)
35
+ @workers = ThreadGroup.new
36
+ @mutex = Mutex.new
37
+ @pid = nil
38
+ @closed = false
39
+
40
+ has_workers?
41
+ end
42
+
43
+ # Adds a new message to the thread pool. Rejects messages if the queue is at
44
+ # its capacity.
45
+ #
46
+ # @param [Object] message The message that gets passed to the block
47
+ # @return [Boolean] true if the message was successfully sent to the pool,
48
+ # false if the queue is full
49
+ def <<(message)
50
+ if backlog >= @queue_size
51
+ logger.info do
52
+ "#{LOG_LABEL} ThreadPool has reached its capacity of " \
53
+ "#{@queue_size} and the following message will not be " \
54
+ "processed: #{message.inspect}"
55
+ end
56
+ return false
57
+ end
58
+
59
+ @queue << message
60
+ true
61
+ end
62
+
63
+ # @return [Integer] how big the queue is at the moment
64
+ def backlog
65
+ @queue.size
66
+ end
67
+
68
+ # Checks if a thread pool has any workers. A thread pool doesn't have any
69
+ # workers only in two cases: when it was closed or when all workers
70
+ # crashed. An *active* thread pool doesn't have any workers only when
71
+ # something went wrong.
72
+ #
73
+ # Workers are expected to crash when you +fork+ the process the workers are
74
+ # living in. In this case we detect a +fork+ and try to revive them here.
75
+ #
76
+ # Another possible scenario that crashes workers is when you close the
77
+ # instance on +at_exit+, but some other +at_exit+ hook prevents the process
78
+ # from exiting.
79
+ #
80
+ # @return [Boolean] true if an instance wasn't closed, but has no workers
81
+ # @see https://goo.gl/oydz8h Example of at_exit that prevents exit
82
+ def has_workers?
83
+ @mutex.synchronize do
84
+ return false if @closed
85
+
86
+ if @pid != Process.pid && @workers.list.empty?
87
+ @pid = Process.pid
88
+ @workers = ThreadGroup.new
89
+ spawn_workers
90
+ end
91
+
92
+ !@closed && @workers.list.any?
93
+ end
94
+ end
95
+
96
+ # Closes the thread pool making it a no-op (it shut downs all worker
97
+ # threads). Before closing, waits on all unprocessed tasks to be processed.
98
+ #
99
+ # @return [void]
100
+ # @raise [Celerbrake::Error] when invoked more than one time
101
+ def close
102
+ threads = @mutex.synchronize do
103
+ raise Celerbrake::Error, 'this thread pool is closed already' if @closed
104
+
105
+ unless @queue.empty?
106
+ msg = "#{LOG_LABEL} waiting to process #{@queue.size} task(s)..."
107
+ logger.debug("#{msg} (Ctrl-C to abort)")
108
+ end
109
+
110
+ @worker_size.times { @queue << :stop }
111
+ @closed = true
112
+ @workers.list.dup
113
+ end
114
+
115
+ threads.each(&:join)
116
+ logger.debug("#{LOG_LABEL} #{@name} thread pool closed")
117
+ end
118
+
119
+ def closed?
120
+ @closed
121
+ end
122
+
123
+ def spawn_workers
124
+ @worker_size.times { @workers.add(spawn_worker) }
125
+ end
126
+
127
+ private
128
+
129
+ def spawn_worker
130
+ Thread.new do
131
+ while (message = @queue.pop)
132
+ break if message == :stop
133
+
134
+ @block.call(message)
135
+ end
136
+ end
137
+ end
138
+ end
139
+ end
@@ -0,0 +1,17 @@
1
+ module Celerbrake
2
+ # TimeTruncate contains methods for truncating time.
3
+ #
4
+ # @api private
5
+ # @since v3.2.0
6
+ module TimeTruncate
7
+ # Truncate +time+ to floor minute and turn it into an RFC3339 timestamp.
8
+ #
9
+ # @param [Time, Integer, Float] time
10
+ # @return [String]
11
+ def self.utc_truncate_minutes(time)
12
+ tm = Time.at(time).getutc
13
+
14
+ Time.utc(tm.year, tm.month, tm.day, tm.hour, tm.min).to_datetime.rfc3339
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,56 @@
1
+ module Celerbrake
2
+ # TimedTrace represents a chunk of code performance of which was measured and
3
+ # stored under a label. The chunk is called a "span".
4
+ #
5
+ # @example
6
+ # timed_trace = TimedTrace.new
7
+ # timed_trace.span('http request') do
8
+ # http.get('example.com')
9
+ # end
10
+ # timed_trace.spans #=> { 'http request' => 0.123 }
11
+ #
12
+ # @api public
13
+ # @since v4.3.0
14
+ class TimedTrace
15
+ # @param [String] label
16
+ # @return [Celerbrake::TimedTrace]
17
+ def self.span(label, &block)
18
+ new.tap { |timed_trace| timed_trace.span(label, &block) }
19
+ end
20
+
21
+ def initialize
22
+ @spans = {}
23
+ end
24
+
25
+ # @param [String] label
26
+ # @return [Boolean]
27
+ def span(label)
28
+ start_span(label)
29
+ yield
30
+ stop_span(label)
31
+ end
32
+
33
+ # @param [String] label
34
+ # @return [Boolean]
35
+ def start_span(label)
36
+ return false if @spans.key?(label)
37
+
38
+ @spans[label] = Celerbrake::Benchmark.new
39
+ true
40
+ end
41
+
42
+ # @param [String] label
43
+ # @return [Boolean]
44
+ def stop_span(label)
45
+ return false unless @spans.key?(label)
46
+
47
+ @spans[label].stop
48
+ true
49
+ end
50
+
51
+ # @return [Hash<String=>Float>]
52
+ def spans
53
+ @spans.transform_values(&:duration)
54
+ end
55
+ end
56
+ end
@@ -0,0 +1,121 @@
1
+ module Celerbrake
2
+ # This class is responsible for truncation of too big objects. Mainly, you
3
+ # should use it for simple objects such as strings, hashes, & arrays.
4
+ #
5
+ # @api private
6
+ # @since v1.0.0
7
+ class Truncator
8
+ # @return [Hash] the options for +String#encode+
9
+ ENCODING_OPTIONS = { invalid: :replace, undef: :replace }.freeze
10
+
11
+ # @return [String] the temporary encoding to be used when fixing invalid
12
+ # strings with +ENCODING_OPTIONS+
13
+ TEMP_ENCODING = 'utf-16'.freeze
14
+
15
+ # @return [Array<Encoding>] encodings that are eligible for fixing invalid
16
+ # characters
17
+ SUPPORTED_ENCODINGS = [Encoding::UTF_8, Encoding::ASCII].freeze
18
+
19
+ # @return [String] what to append when something is a circular reference
20
+ CIRCULAR = '[Circular]'.freeze
21
+
22
+ # @return [String] what to append when something is truncated
23
+ TRUNCATED = '[Truncated]'.freeze
24
+
25
+ # @return [Array<Class>] The types that can contain references to itself
26
+ CIRCULAR_TYPES = [Array, Hash, Set].freeze
27
+
28
+ # @param [Integer] max_size maximum size of hashes, arrays and strings
29
+ def initialize(max_size)
30
+ @max_size = max_size
31
+ end
32
+
33
+ # Performs deep truncation of arrays, hashes, sets & strings. Uses a
34
+ # placeholder for recursive objects (`[Circular]`).
35
+ #
36
+ # @param [Object] object The object to truncate
37
+ # @param [Set] seen The cache that helps to detect recursion
38
+ # @return [Object] truncated object
39
+ def truncate(object, seen = Set.new)
40
+ if seen.include?(object.object_id)
41
+ return CIRCULAR if CIRCULAR_TYPES.any? { |t| object.is_a?(t) }
42
+
43
+ return object
44
+ end
45
+ truncate_object(object, seen << object.object_id)
46
+ end
47
+
48
+ # Reduces maximum allowed size of hashes, arrays, sets & strings by half.
49
+ # @return [Integer] current +max_size+ value
50
+ def reduce_max_size
51
+ @max_size /= 2
52
+ end
53
+
54
+ private
55
+
56
+ def truncate_object(object, seen)
57
+ case object
58
+ when Hash then truncate_hash(object, seen)
59
+ when Array then truncate_array(object, seen)
60
+ when Set then truncate_set(object, seen)
61
+ when String then truncate_string(object)
62
+ when Numeric, TrueClass, FalseClass, Symbol, NilClass then object
63
+ else
64
+ truncate_string(stringify_object(object))
65
+ end
66
+ end
67
+
68
+ def truncate_string(str)
69
+ fixed_str = replace_invalid_characters(str)
70
+ return fixed_str if fixed_str.length <= @max_size
71
+
72
+ (fixed_str.slice(0, @max_size) + TRUNCATED).freeze
73
+ end
74
+
75
+ def stringify_object(object)
76
+ object.to_json
77
+ rescue *Notice::JSON_EXCEPTIONS
78
+ object.to_s
79
+ end
80
+
81
+ def truncate_hash(hash, seen)
82
+ truncated_hash = {}
83
+ hash.each_with_index do |(key, val), idx|
84
+ break if idx + 1 > @max_size
85
+
86
+ truncated_hash[key] = truncate(val, seen)
87
+ end
88
+
89
+ truncated_hash.freeze
90
+ end
91
+
92
+ def truncate_array(array, seen)
93
+ array.slice(0, @max_size).map! { |elem| truncate(elem, seen) }.freeze
94
+ end
95
+
96
+ def truncate_set(set, seen)
97
+ truncated_set = Set.new
98
+
99
+ set.each do |elem|
100
+ truncated_set << truncate(elem, seen)
101
+ break if truncated_set.size >= @max_size
102
+ end
103
+
104
+ truncated_set.freeze
105
+ end
106
+
107
+ # Replaces invalid characters in a string with arbitrary encoding.
108
+ #
109
+ # @param [String] str The string to replace characters
110
+ # @return [String] a UTF-8 encoded string
111
+ # @see https://github.com/flori/json/commit/3e158410e81f94dbbc3da6b7b35f4f64983aa4e3
112
+ def replace_invalid_characters(str)
113
+ utf8_string = SUPPORTED_ENCODINGS.include?(str.encoding)
114
+ return str if utf8_string && str.valid_encoding?
115
+
116
+ temp_str = str.dup
117
+ temp_str.encode!(TEMP_ENCODING, **ENCODING_OPTIONS) if utf8_string
118
+ temp_str.encode!('utf-8', **ENCODING_OPTIONS)
119
+ end
120
+ end
121
+ end