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,52 @@
1
+ module Celerbrake
2
+ # PerformanceBreakdown holds data that shows how much time a request spent
3
+ # doing certaing subtasks such as (DB querying, view rendering, etc).
4
+ #
5
+ # @see Celerbrake.notify_breakdown
6
+ # @api public
7
+ # @since v4.2.0
8
+ # rubocop:disable Metrics/ParameterLists
9
+ class PerformanceBreakdown
10
+ include HashKeyable
11
+ include Ignorable
12
+ include Stashable
13
+ include Mergeable
14
+
15
+ attr_accessor :method, :route, :response_type, :groups, :timing, :time
16
+
17
+ def initialize(
18
+ method:,
19
+ route:,
20
+ response_type:,
21
+ groups:,
22
+ timing: nil,
23
+ time: Time.now
24
+ )
25
+ @time_utc = TimeTruncate.utc_truncate_minutes(time)
26
+ @method = method
27
+ @route = route
28
+ @response_type = response_type
29
+ @groups = groups
30
+ @timing = timing
31
+ @time = time
32
+ end
33
+
34
+ def destination
35
+ 'routes-breakdowns'
36
+ end
37
+
38
+ def cargo
39
+ 'routes'
40
+ end
41
+
42
+ def to_h
43
+ {
44
+ 'method' => method,
45
+ 'route' => route,
46
+ 'responseType' => response_type,
47
+ 'time' => @time_utc,
48
+ }.delete_if { |_key, val| val.nil? }
49
+ end
50
+ end
51
+ # rubocop:enable Metrics/ParameterLists
52
+ end
@@ -0,0 +1,177 @@
1
+ module Celerbrake
2
+ # PerformanceNotifier aggregates performance data and periodically sends it to
3
+ # Celerbrake.
4
+ #
5
+ # @api public
6
+ # @since v3.2.0
7
+ # rubocop:disable Metrics/ClassLength
8
+ class PerformanceNotifier
9
+ include Inspectable
10
+ include Loggable
11
+
12
+ def initialize
13
+ @config = Celerbrake::Config.instance
14
+ @flush_period = Celerbrake::Config.instance.performance_stats_flush_period
15
+ @async_sender = AsyncSender.new(:put, self.class.name)
16
+ @sync_sender = SyncSender.new(:put)
17
+ @schedule_flush = nil
18
+ @filter_chain = FilterChain.new
19
+
20
+ @payload = {}.extend(MonitorMixin)
21
+ @has_payload = @payload.new_cond
22
+ end
23
+
24
+ # @param [Hash] metric
25
+ # @see Celerbrake.notify_query
26
+ # @see Celerbrake.notify_request
27
+ def notify(metric)
28
+ @payload.synchronize do
29
+ send_metric(metric, sync: false)
30
+ end
31
+ end
32
+
33
+ # @param [Hash] metric
34
+ # @since v4.10.0
35
+ # @see Celerbrake.notify_queue_sync
36
+ def notify_sync(metric)
37
+ send_metric(metric, sync: true).value
38
+ end
39
+
40
+ # @see Celerbrake.add_performance_filter
41
+ def add_filter(filter = nil, &block)
42
+ @filter_chain.add_filter(block_given? ? block : filter)
43
+ end
44
+
45
+ # @see Celerbrake.delete_performance_filter
46
+ def delete_filter(filter_class)
47
+ @filter_chain.delete_filter(filter_class)
48
+ end
49
+
50
+ def close
51
+ @payload.synchronize do
52
+ @schedule_flush.kill if @schedule_flush
53
+ @sync_sender.close
54
+ @async_sender.close
55
+ end
56
+ end
57
+
58
+ private
59
+
60
+ def schedule_flush
61
+ @schedule_flush ||= Thread.new do
62
+ loop do
63
+ @payload.synchronize do
64
+ @last_flush_time ||= MonotonicTime.time_in_s
65
+
66
+ while (MonotonicTime.time_in_s - @last_flush_time) < @flush_period
67
+ @has_payload.wait(@flush_period)
68
+ end
69
+
70
+ if @payload.none?
71
+ @last_flush_time = nil
72
+ next
73
+ end
74
+
75
+ send(@async_sender, @payload, Celerbrake::Promise.new)
76
+ @payload.clear
77
+ end
78
+ end
79
+ end
80
+ end
81
+
82
+ def send_metric(metric, sync:)
83
+ promise = check_configuration(metric)
84
+ return promise if promise.rejected?
85
+
86
+ @filter_chain.refine(metric)
87
+ if metric.ignored?
88
+ return Promise.new.reject("#{metric.class} was ignored by a filter")
89
+ end
90
+
91
+ update_payload(metric)
92
+ if sync || @flush_period == 0
93
+ send(@sync_sender, @payload, promise)
94
+ else
95
+ @has_payload.signal
96
+ schedule_flush
97
+ end
98
+ end
99
+
100
+ def update_payload(metric)
101
+ if (total_stat = @payload[metric])
102
+ @payload.key(total_stat).merge(metric)
103
+ else
104
+ @payload[metric] = { total: Celerbrake::Stat.new }
105
+ end
106
+
107
+ @payload[metric][:total].increment_ms(metric.timing)
108
+
109
+ metric.groups.each do |name, ms|
110
+ @payload[metric][name] ||= Celerbrake::Stat.new
111
+ @payload[metric][name].increment_ms(ms)
112
+ end
113
+ end
114
+
115
+ def check_configuration(metric)
116
+ promise = @config.check_configuration
117
+ return promise if promise.rejected?
118
+
119
+ promise = @config.check_performance_options(metric)
120
+ return promise if promise.rejected?
121
+
122
+ if metric.timing && metric.timing == 0
123
+ return Promise.new.reject(':timing cannot be zero')
124
+ end
125
+
126
+ Promise.new
127
+ end
128
+
129
+ def send(sender, payload, promise)
130
+ raise "payload cannot be empty. Race?" if payload.none?
131
+
132
+ with_grouped_payload(payload) do |metric_hash, destination|
133
+ url = URI.join(
134
+ @config.apm_host,
135
+ "api/v5/projects/#{@config.project_id}/#{destination}",
136
+ )
137
+
138
+ logger.debug do
139
+ "#{LOG_LABEL} #{self.class.name}##{__method__}: #{metric_hash}"
140
+ end
141
+ sender.send(metric_hash, promise, url)
142
+ end
143
+
144
+ promise
145
+ end
146
+
147
+ def with_grouped_payload(raw_payload)
148
+ grouped_payload = raw_payload.group_by do |metric, _stats|
149
+ [metric.cargo, metric.destination]
150
+ end
151
+
152
+ grouped_payload.each do |(cargo, destination), metrics|
153
+ payload = {}
154
+ payload[cargo] = serialize_metrics(metrics)
155
+ payload['environment'] = @config.environment if @config.environment
156
+
157
+ yield(payload, destination)
158
+ end
159
+ end
160
+
161
+ def serialize_metrics(metrics)
162
+ metrics.map do |metric, stats|
163
+ metric_hash = metric.to_h.merge!(stats[:total].to_h)
164
+
165
+ if metric.groups.any?
166
+ group_stats = stats.reject { |name, _stat| name == :total }
167
+ metric_hash['groups'] = group_stats.merge(group_stats) do |_name, stat|
168
+ stat.to_h
169
+ end
170
+ end
171
+
172
+ metric_hash
173
+ end
174
+ end
175
+ end
176
+ # rubocop:enable Metrics/ClassLength
177
+ end
@@ -0,0 +1,110 @@
1
+ module Celerbrake
2
+ # Represents a simplified promise object (similar to promises found in
3
+ # JavaScript), which allows chaining callbacks that are executed when the
4
+ # promise is either resolved or rejected.
5
+ #
6
+ # @see https://developer.mozilla.org/en/docs/Web/JavaScript/Reference/Global_Objects/Promise
7
+ # @see https://github.com/ruby-concurrency/concurrent-ruby/blob/master/lib/concurrent/promise.rb
8
+ # @since v1.7.0
9
+ class Promise
10
+ def initialize
11
+ @on_resolved = []
12
+ @on_rejected = []
13
+ @value = {}
14
+ @mutex = Mutex.new
15
+ end
16
+
17
+ # Attaches a callback to be executed when the promise is resolved.
18
+ #
19
+ # @example
20
+ # Celerbrake::Promise.new.then { |response| puts response }
21
+ # #=> {"id"=>"00054415-8201-e9c6-65d6-fc4d231d2871",
22
+ # # "url"=>"http://localhost/locate/00054415-8201-e9c6-65d6-fc4d231d2871"}
23
+ #
24
+ # @yield [response]
25
+ # @yieldparam response [Hash<String,String>] Contains the `id` & `url` keys
26
+ # @return [self]
27
+ def then(&block)
28
+ @mutex.synchronize do
29
+ if @value.key?('ok')
30
+ yield(@value['ok'])
31
+ return self
32
+ end
33
+
34
+ @on_resolved << block
35
+ end
36
+
37
+ self
38
+ end
39
+
40
+ # Attaches a callback to be executed when the promise is rejected.
41
+ #
42
+ # @example
43
+ # Celerbrake::Promise.new.rescue { |error| raise error }
44
+ #
45
+ # @yield [error] The error message from the API
46
+ # @yieldparam error [String]
47
+ # @return [self]
48
+ def rescue(&block)
49
+ @mutex.synchronize do
50
+ if @value.key?('error')
51
+ yield(@value['error'])
52
+ return self
53
+ end
54
+
55
+ @on_rejected << block
56
+ end
57
+
58
+ self
59
+ end
60
+
61
+ # @example
62
+ # Celerbrake::Promise.new.resolve('id' => '123')
63
+ #
64
+ # @param reason [Object]
65
+ # @return [self]
66
+ def resolve(reason = 'resolved')
67
+ @mutex.synchronize do
68
+ @value['ok'] = reason
69
+ @on_resolved.each { |callback| callback.call(reason) }
70
+ end
71
+
72
+ self
73
+ end
74
+
75
+ # @example
76
+ # Celerbrake::Promise.new.reject('Something went wrong')
77
+ #
78
+ # @param reason [String]
79
+ # @return [self]
80
+ def reject(reason = 'rejected')
81
+ @mutex.synchronize do
82
+ @value['error'] = reason
83
+ @on_rejected.each { |callback| callback.call(reason) }
84
+ end
85
+
86
+ self
87
+ end
88
+
89
+ # @return [Boolean]
90
+ def rejected?
91
+ @value.key?('error')
92
+ end
93
+
94
+ # @return [Boolean]
95
+ def resolved?
96
+ @value.key?('ok')
97
+ end
98
+
99
+ # @return [Hash<String,String>] either successful response containing the
100
+ # +id+ key or unsuccessful response containing the +error+ key
101
+ # @note This is a non-blocking call!
102
+ # @todo Get rid of this method and use an accessor. The resolved guard is
103
+ # needed for compatibility but it shouldn't exist in the future
104
+ def value
105
+ return @value['ok'] if resolved?
106
+
107
+ @value
108
+ end
109
+ end
110
+ end
@@ -0,0 +1,59 @@
1
+ module Celerbrake
2
+ # Query holds SQL query data that powers SQL query collection.
3
+ #
4
+ # @see Celerbrake.notify_query
5
+ # @api public
6
+ # @since v3.2.0
7
+ # rubocop:disable Metrics/ParameterLists
8
+ class Query
9
+ include HashKeyable
10
+ include Ignorable
11
+ include Stashable
12
+ include Mergeable
13
+ include Grouppable
14
+
15
+ attr_accessor :method, :route, :query, :func, :file, :line, :timing, :time
16
+
17
+ def initialize(
18
+ method:,
19
+ route:,
20
+ query:,
21
+ func: nil,
22
+ file: nil,
23
+ line: nil,
24
+ timing: nil,
25
+ time: Time.now
26
+ )
27
+ @time_utc = TimeTruncate.utc_truncate_minutes(time)
28
+ @method = method
29
+ @route = route
30
+ @query = query
31
+ @func = func
32
+ @file = file
33
+ @line = line
34
+ @timing = timing
35
+ @time = time
36
+ end
37
+
38
+ def destination
39
+ 'queries-stats'
40
+ end
41
+
42
+ def cargo
43
+ 'queries'
44
+ end
45
+
46
+ def to_h
47
+ {
48
+ 'method' => method,
49
+ 'route' => route,
50
+ 'query' => query,
51
+ 'time' => @time_utc,
52
+ 'function' => func,
53
+ 'file' => file,
54
+ 'line' => line,
55
+ }.delete_if { |_key, val| val.nil? }
56
+ end
57
+ # rubocop:enable Metrics/ParameterLists
58
+ end
59
+ end
@@ -0,0 +1,65 @@
1
+ module Celerbrake
2
+ # Queue represents a queue (worker).
3
+ #
4
+ # @see Celerbrake.notify_queue
5
+ # @api public
6
+ # @since v4.9.0
7
+ class Queue
8
+ include HashKeyable
9
+ include Ignorable
10
+ include Stashable
11
+
12
+ attr_accessor :queue, :error_count, :groups, :timing, :time
13
+
14
+ def initialize(
15
+ queue:,
16
+ error_count:,
17
+ groups: {},
18
+ timing: nil,
19
+ time: Time.now
20
+ )
21
+ @time_utc = TimeTruncate.utc_truncate_minutes(time)
22
+ @queue = queue
23
+ @error_count = error_count
24
+ @groups = groups
25
+ @timing = timing
26
+ @time = time
27
+ end
28
+
29
+ def destination
30
+ 'queues-stats'
31
+ end
32
+
33
+ def cargo
34
+ 'queues'
35
+ end
36
+
37
+ def to_h
38
+ {
39
+ 'queue' => queue,
40
+ 'errorCount' => error_count,
41
+ 'time' => @time_utc,
42
+ }
43
+ end
44
+
45
+ def hash
46
+ {
47
+ 'queue' => queue,
48
+ 'time' => @time_utc,
49
+ }.hash
50
+ end
51
+
52
+ def merge(other)
53
+ self.error_count += other.error_count
54
+ end
55
+
56
+ # Queues don't have routes, but we want to define this to make sure our
57
+ # filter API is consistent (other models define this property)
58
+ #
59
+ # @return [String] empty route
60
+ # @see https://github.com/celerbrake/celerbrake-ruby/pull/537
61
+ def route
62
+ ''
63
+ end
64
+ end
65
+ end
@@ -0,0 +1,44 @@
1
+ module Celerbrake
2
+ class RemoteSettings
3
+ # Callback is a class that provides a callback for the config poller, which
4
+ # updates the local config according to the data.
5
+ #
6
+ # @api private
7
+ # @since v5.0.2
8
+ class Callback
9
+ def initialize(config)
10
+ @config = config
11
+ @orig_error_notifications = config.error_notifications
12
+ @orig_performance_stats = config.performance_stats
13
+ end
14
+
15
+ # @param [Celerbrake::RemoteSettings::SettingsData] data
16
+ # @return [void]
17
+ def call(data)
18
+ @config.logger.debug do
19
+ "#{LOG_LABEL} applying remote settings: #{data.to_h}"
20
+ end
21
+
22
+ @config.error_host = data.error_host if data.error_host
23
+ @config.apm_host = data.apm_host if data.apm_host
24
+
25
+ process_error_notifications(data)
26
+ process_performance_stats(data)
27
+ end
28
+
29
+ private
30
+
31
+ def process_error_notifications(data)
32
+ return unless @orig_error_notifications
33
+
34
+ @config.error_notifications = data.error_notifications?
35
+ end
36
+
37
+ def process_performance_stats(data)
38
+ return unless @orig_performance_stats
39
+
40
+ @config.performance_stats = data.performance_stats?
41
+ end
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,116 @@
1
+ module Celerbrake
2
+ class RemoteSettings
3
+ # SettingsData is a container, which wraps JSON payload returned by the
4
+ # remote settings API. It exposes the payload via convenient methods and
5
+ # also ensures that in case some data from the payload is missing, a default
6
+ # value would be returned instead.
7
+ #
8
+ # @example
9
+ # # Create the object and pass initial data (empty hash).
10
+ # settings_data = SettingsData.new({})
11
+ #
12
+ # settings_data.interval #=> 600
13
+ #
14
+ # @since v5.0.0
15
+ # @api private
16
+ class SettingsData
17
+ # @return [Integer] how frequently we should poll the config API
18
+ DEFAULT_INTERVAL = 600
19
+
20
+ # @return [String] API version of the S3 API to poll
21
+ API_VER = '2020-06-18'.freeze
22
+
23
+ # @return [String] what path to poll
24
+ CONFIG_ROUTE_PATTERN =
25
+ "%<host>s/#{API_VER}/config/%<project_id>s/config.json".freeze
26
+
27
+ # @return [Hash{Symbol=>String}] the hash of all supported settings where
28
+ # the value is the name of the setting returned by the API
29
+ SETTINGS = {
30
+ errors: 'errors'.freeze,
31
+ apm: 'apm'.freeze,
32
+ }.freeze
33
+
34
+ # @param [Integer] project_id
35
+ # @param [Hash{String=>Object}] data
36
+ def initialize(project_id, data)
37
+ @project_id = project_id
38
+ @data = data
39
+ end
40
+
41
+ # Merges the given +hash+ with internal data.
42
+ #
43
+ # @param [Hash{String=>Object}] hash
44
+ # @return [self]
45
+ def merge!(hash)
46
+ @data.merge!(hash)
47
+
48
+ self
49
+ end
50
+
51
+ # @return [Integer] how frequently we should poll for the config
52
+ def interval
53
+ return DEFAULT_INTERVAL if !@data.key?('poll_sec') || !@data['poll_sec']
54
+
55
+ @data['poll_sec'] > 0 ? @data['poll_sec'] : DEFAULT_INTERVAL
56
+ end
57
+
58
+ # @param [String] remote_config_host
59
+ # @return [String] where the config is stored on S3.
60
+ def config_route(remote_config_host)
61
+ if @data['config_route'] && !@data['config_route'].empty?
62
+ return "#{remote_config_host.chomp('/')}/#{@data['config_route']}"
63
+ end
64
+
65
+ format(
66
+ CONFIG_ROUTE_PATTERN,
67
+ host: remote_config_host.chomp('/'),
68
+ project_id: @project_id,
69
+ )
70
+ end
71
+
72
+ # @return [Boolean] whether error notifications are enabled
73
+ def error_notifications?
74
+ return true unless (s = find_setting(SETTINGS[:errors]))
75
+
76
+ s['enabled']
77
+ end
78
+
79
+ # @return [Boolean] whether APM is enabled
80
+ def performance_stats?
81
+ return true unless (s = find_setting(SETTINGS[:apm]))
82
+
83
+ s['enabled']
84
+ end
85
+
86
+ # @return [String, nil] the host, which provides the API endpoint to which
87
+ # exceptions should be sent
88
+ def error_host
89
+ return unless (s = find_setting(SETTINGS[:errors]))
90
+
91
+ s['endpoint']
92
+ end
93
+
94
+ # @return [String, nil] the host, which provides the API endpoint to which
95
+ # APM data should be sent
96
+ def apm_host
97
+ return unless (s = find_setting(SETTINGS[:apm]))
98
+
99
+ s['endpoint']
100
+ end
101
+
102
+ # @return [Hash{String=>Object}] raw representation of JSON payload
103
+ def to_h
104
+ @data.dup
105
+ end
106
+
107
+ private
108
+
109
+ def find_setting(name)
110
+ return unless @data.key?('settings')
111
+
112
+ @data['settings'].find { |s| s['name'] == name }
113
+ end
114
+ end
115
+ end
116
+ end