airbrake-ruby 3.1.0 → 3.2.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (54) hide show
  1. checksums.yaml +4 -4
  2. data/lib/airbrake-ruby.rb +197 -43
  3. data/lib/airbrake-ruby/config.rb +43 -11
  4. data/lib/airbrake-ruby/deploy_notifier.rb +47 -0
  5. data/lib/airbrake-ruby/filter_chain.rb +32 -50
  6. data/lib/airbrake-ruby/filters/git_repository_filter.rb +9 -1
  7. data/lib/airbrake-ruby/filters/sql_filter.rb +104 -0
  8. data/lib/airbrake-ruby/hash_keyable.rb +37 -0
  9. data/lib/airbrake-ruby/ignorable.rb +44 -0
  10. data/lib/airbrake-ruby/notice.rb +2 -22
  11. data/lib/airbrake-ruby/{notifier.rb → notice_notifier.rb} +66 -46
  12. data/lib/airbrake-ruby/performance_notifier.rb +161 -0
  13. data/lib/airbrake-ruby/stat.rb +56 -0
  14. data/lib/airbrake-ruby/tdigest.rb +393 -0
  15. data/lib/airbrake-ruby/time_truncate.rb +17 -0
  16. data/lib/airbrake-ruby/version.rb +1 -1
  17. data/spec/airbrake_spec.rb +57 -13
  18. data/spec/async_sender_spec.rb +0 -2
  19. data/spec/backtrace_spec.rb +0 -2
  20. data/spec/code_hunk_spec.rb +0 -2
  21. data/spec/config/validator_spec.rb +0 -2
  22. data/spec/config_spec.rb +16 -4
  23. data/spec/deploy_notifier_spec.rb +41 -0
  24. data/spec/file_cache.rb +0 -2
  25. data/spec/filter_chain_spec.rb +1 -7
  26. data/spec/filters/context_filter_spec.rb +0 -2
  27. data/spec/filters/dependency_filter_spec.rb +0 -2
  28. data/spec/filters/exception_attributes_filter_spec.rb +0 -2
  29. data/spec/filters/gem_root_filter_spec.rb +0 -2
  30. data/spec/filters/git_last_checkout_filter_spec.rb +0 -2
  31. data/spec/filters/git_repository_filter.rb +0 -2
  32. data/spec/filters/git_revision_filter_spec.rb +0 -2
  33. data/spec/filters/keys_blacklist_spec.rb +0 -2
  34. data/spec/filters/keys_whitelist_spec.rb +0 -2
  35. data/spec/filters/root_directory_filter_spec.rb +0 -2
  36. data/spec/filters/sql_filter_spec.rb +219 -0
  37. data/spec/filters/system_exit_filter_spec.rb +0 -2
  38. data/spec/filters/thread_filter_spec.rb +0 -2
  39. data/spec/ignorable_spec.rb +14 -0
  40. data/spec/nested_exception_spec.rb +0 -2
  41. data/spec/{notifier_spec.rb → notice_notifier_spec.rb} +24 -114
  42. data/spec/{notifier_spec → notice_notifier_spec}/options_spec.rb +40 -39
  43. data/spec/notice_spec.rb +2 -4
  44. data/spec/performance_notifier_spec.rb +287 -0
  45. data/spec/promise_spec.rb +0 -2
  46. data/spec/response_spec.rb +0 -2
  47. data/spec/stat_spec.rb +35 -0
  48. data/spec/sync_sender_spec.rb +0 -2
  49. data/spec/tdigest_spec.rb +230 -0
  50. data/spec/time_truncate_spec.rb +13 -0
  51. data/spec/truncator_spec.rb +0 -2
  52. metadata +34 -15
  53. data/lib/airbrake-ruby/route_sender.rb +0 -175
  54. data/spec/route_sender_spec.rb +0 -130
@@ -0,0 +1,47 @@
1
+ module Airbrake
2
+ # DeployNotifier sends deploy information to Airbrake. The information
3
+ # consists of:
4
+ # - environment
5
+ # - username
6
+ # - repository
7
+ # - revision
8
+ # - version
9
+ #
10
+ # @api public
11
+ # @since v3.2.0
12
+ class DeployNotifier
13
+ # @param [Airbrake::Config] config
14
+ def initialize(config)
15
+ @config =
16
+ if config.is_a?(Config)
17
+ config
18
+ else
19
+ loc = caller_locations(1..1).first
20
+ signature = "#{self.class.name}##{__method__}"
21
+ warn(
22
+ "#{loc.path}:#{loc.lineno}: warning: passing a Hash to #{signature} " \
23
+ 'is deprecated. Pass `Airbrake::Config` instead'
24
+ )
25
+ Config.new(config)
26
+ end
27
+
28
+ @sender = SyncSender.new(@config)
29
+ end
30
+
31
+ # @see Airbrake.create_deploy
32
+ def notify(deploy_info, promise = Airbrake::Promise.new)
33
+ if @config.ignored_environment?
34
+ return promise.reject("The '#{@config.environment}' environment is ignored")
35
+ end
36
+
37
+ deploy_info[:environment] ||= @config.environment
38
+ @sender.send(
39
+ deploy_info,
40
+ promise,
41
+ URI.join(@config.host, "api/v4/projects/#{@config.project_id}/deploys")
42
+ )
43
+
44
+ promise
45
+ end
46
+ end
47
+ end
@@ -1,29 +1,44 @@
1
1
  module Airbrake
2
- # Represents the mechanism for filtering notices. Defines a few default
3
- # filters.
2
+ # FilterChain represents an ordered array of filters.
3
+ #
4
+ # A filter is an object that responds to <b>#call</b> (typically a Proc or a
5
+ # class that implements the call method). The <b>#call</b> method must accept
6
+ # exactly one argument: an object to be filtered.
7
+ #
8
+ # When you add a new filter to the chain, it gets inserted according to its
9
+ # <b>weight</b>. Smaller weight means the filter will be somewhere in the
10
+ # beginning of the array. Larger - in the end. If a filter doesn't implement
11
+ # weight, the chain assumes it's equal to 0.
12
+ #
13
+ # @example
14
+ # class MyFilter
15
+ # attr_reader :weight
16
+ #
17
+ # def initialize
18
+ # @weight = 1
19
+ # end
20
+ #
21
+ # def call(obj)
22
+ # puts 'Filtering...'
23
+ # obj[:data] = '[Filtered]'
24
+ # end
25
+ # end
26
+ #
27
+ # filter_chain = FilterChain.new
28
+ # filter_chain.add_filter(MyFilter)
29
+ #
30
+ # filter_chain.refine(obj)
31
+ # #=> Filtering...
4
32
  #
5
33
  # @see Airbrake.add_filter
6
34
  # @api private
7
35
  # @since v1.0.0
8
36
  class FilterChain
9
- # @return [Array<Class>] filters to be executed first
10
- DEFAULT_FILTERS = [
11
- Airbrake::Filters::SystemExitFilter,
12
- Airbrake::Filters::GemRootFilter
13
-
14
- # Optional filters (must be included by users):
15
- # Airbrake::Filters::ThreadFilter
16
- ].freeze
17
-
18
37
  # @return [Integer]
19
38
  DEFAULT_WEIGHT = 0
20
39
 
21
- def initialize(config, context)
22
- @config = config
23
- @context = context
40
+ def initialize
24
41
  @filters = []
25
- DEFAULT_FILTERS.each { |f| add_filter(f.new) }
26
- add_default_filters
27
42
  end
28
43
 
29
44
  # Adds a filter to the filter chain. Sorts filters by weight.
@@ -51,6 +66,7 @@ module Airbrake
51
66
  #
52
67
  # @param [Airbrake::Notice] notice The notice to be filtered
53
68
  # @return [void]
69
+ # @todo Make it work with anything, not only notices
54
70
  def refine(notice)
55
71
  @filters.each do |filter|
56
72
  break if notice.ignored?
@@ -75,39 +91,5 @@ module Airbrake
75
91
  end
76
92
  q.text(']')
77
93
  end
78
-
79
- private
80
-
81
- # rubocop:disable Metrics/AbcSize
82
- def add_default_filters
83
- if (whitelist_keys = @config.whitelist_keys).any?
84
- add_filter(
85
- Airbrake::Filters::KeysWhitelist.new(@config.logger, whitelist_keys)
86
- )
87
- end
88
-
89
- if (blacklist_keys = @config.blacklist_keys).any?
90
- add_filter(
91
- Airbrake::Filters::KeysBlacklist.new(@config.logger, blacklist_keys)
92
- )
93
- end
94
-
95
- add_filter(Airbrake::Filters::ContextFilter.new(@context))
96
- add_filter(Airbrake::Filters::ExceptionAttributesFilter.new(@config.logger))
97
-
98
- return unless (root_directory = @config.root_directory)
99
- [
100
- Airbrake::Filters::RootDirectoryFilter,
101
- Airbrake::Filters::GitRevisionFilter,
102
- Airbrake::Filters::GitRepositoryFilter
103
- ].each do |filter|
104
- add_filter(filter.new(root_directory))
105
- end
106
-
107
- add_filter(
108
- Airbrake::Filters::GitLastCheckoutFilter.new(@config.logger, root_directory)
109
- )
110
- end
111
- # rubocop:enable Metrics/AbcSize
112
94
  end
113
95
  end
@@ -11,6 +11,7 @@ module Airbrake
11
11
  def initialize(root_directory)
12
12
  @git_path = File.join(root_directory, '.git')
13
13
  @repository = nil
14
+ @git_version = Gem::Version.new(`git --version`.split.last)
14
15
  @weight = 116
15
16
  end
16
17
 
@@ -25,7 +26,14 @@ module Airbrake
25
26
 
26
27
  return unless File.exist?(@git_path)
27
28
 
28
- @repository = `cd #{@git_path} && git remote get-url origin`.chomp
29
+ @repository =
30
+ if @git_version >= Gem::Version.new('2.7.0')
31
+ `cd #{@git_path} && git config --get remote.origin.url`.chomp
32
+ else
33
+ "`git remote get-url` is unsupported in git #{@git_version}. " \
34
+ 'Consider an upgrade to 2.7+'
35
+ end
36
+
29
37
  return unless @repository
30
38
  notice[:context][:repository] = @repository
31
39
  end
@@ -0,0 +1,104 @@
1
+ module Airbrake
2
+ module Filters
3
+ # SqlFilter filters out sensitive data from {Airbrake::Query}. Sensitive
4
+ # data is everything that is not table names or fields (e.g. column values
5
+ # and such).
6
+ #
7
+ # Supports the following SQL dialects:
8
+ # * PostgreSQL
9
+ # * MySQL
10
+ # * SQLite
11
+ # * Cassandra
12
+ # * Oracle
13
+ #
14
+ # @api private
15
+ # @since v3.2.0
16
+ class SqlFilter
17
+ # @return [String] the label to replace real values of filtered query
18
+ FILTERED = '?'.freeze
19
+
20
+ # @return [String] the string that will replace the query in case we
21
+ # cannot filter it
22
+ ERROR_MSG = 'Error: Airbrake::Query was not filtered'.freeze
23
+
24
+ # @return [Hash{Symbol=>Regexp}] matchers for certain features of SQL
25
+ ALL_FEATURES = {
26
+ # rubocop:disable Metrics/LineLength
27
+ single_quotes: /'(?:[^']|'')*?(?:\\'.*|'(?!'))/,
28
+ double_quotes: /"(?:[^"]|"")*?(?:\\".*|"(?!"))/,
29
+ dollar_quotes: /(\$(?!\d)[^$]*?\$).*?(?:\1|$)/,
30
+ uuids: /\{?(?:[0-9a-fA-F]\-*){32}\}?/,
31
+ numeric_literals: /\b-?(?:[0-9]+\.)?[0-9]+([eE][+-]?[0-9]+)?\b/,
32
+ boolean_literals: /\b(?:true|false|null)\b/i,
33
+ hexadecimal_literals: /0x[0-9a-fA-F]+/,
34
+ comments: /(?:#|--).*?(?=\r|\n|$)/i,
35
+ multi_line_comments: %r{/\*(?:[^/]|/[^*])*?(?:\*/|/\*.*)},
36
+ oracle_quoted_strings: /q'\[.*?(?:\]'|$)|q'\{.*?(?:\}'|$)|q'\<.*?(?:\>'|$)|q'\(.*?(?:\)'|$)/
37
+ # rubocop:enable Metrics/LineLength
38
+ }.freeze
39
+
40
+ # @return [Hash{Symbol=>Array<Symbol>}] a set of features that corresponds
41
+ # to a certain dialect
42
+ DIALECT_FEATURES = {
43
+ default: ALL_FEATURES.keys,
44
+ mysql: %i[
45
+ single_quotes double_quotes numeric_literals boolean_literals
46
+ hexadecimal_literals comments multi_line_comments
47
+ ].freeze,
48
+ postgres: %i[
49
+ single_quotes dollar_quotes uuids numeric_literals boolean_literals
50
+ comments multi_line_comments
51
+ ].freeze,
52
+ sqlite: %i[
53
+ single_quotes numeric_literals boolean_literals hexadecimal_literals
54
+ comments multi_line_comments
55
+ ].freeze,
56
+ oracle: %i[
57
+ single_quotes oracle_quoted_strings numeric_literals comments
58
+ multi_line_comments
59
+ ].freeze,
60
+ cassandra: %i[
61
+ single_quotes uuids numeric_literals boolean_literals
62
+ hexadecimal_literals comments multi_line_comments
63
+ ].freeze
64
+ }.freeze
65
+
66
+ # @return [Hash{Symbol=>Regexp}] a set of regexps to check for unmatches
67
+ # quotes after filtering (should be none)
68
+ UNMATCHED_PAIR = {
69
+ mysql: %r{'|"|/\*|\*/},
70
+ mysql2: %r{'|"|/\*|\*/},
71
+ postgres: %r{'|/\*|\*/|\$(?!\?)},
72
+ sqlite: %r{'|/\*|\*/},
73
+ cassandra: %r{'|/\*|\*/},
74
+ oracle: %r{'|/\*|\*/},
75
+ oracle_enhanced: %r{'|/\*|\*/}
76
+ }.freeze
77
+
78
+ def initialize(dialect)
79
+ @dialect =
80
+ case dialect
81
+ when /mysql/i then :mysql
82
+ when /postgres/i then :postgres
83
+ when /sqlite/i then :sqlite
84
+ when /oracle/i then :oracle
85
+ when /cassandra/i then :cassandra
86
+ else
87
+ :default
88
+ end
89
+
90
+ features = DIALECT_FEATURES[@dialect].map { |f| ALL_FEATURES[f] }
91
+ @regexp = Regexp.union(features)
92
+ end
93
+
94
+ # @param [Airbrake::Query] resource
95
+ def call(resource)
96
+ return unless resource.respond_to?(:query)
97
+
98
+ q = resource.query.gsub(@regexp, FILTERED)
99
+ q = ERROR_MSG if UNMATCHED_PAIR[@dialect] =~ q
100
+ resource.query = q
101
+ end
102
+ end
103
+ end
104
+ end
@@ -0,0 +1,37 @@
1
+ module Airbrake
2
+ # HashKeyable allows instances of the class to be used as a Hash key in a
3
+ # consistent manner.
4
+ #
5
+ # The class that includes it must implement *to_h*, which defines properties
6
+ # that all of the instances must share in order to produce the same {#hash}.
7
+ #
8
+ # @example
9
+ # class C
10
+ # include Airbrake::HashKeyable
11
+ #
12
+ # def initialize(key)
13
+ # @key = key
14
+ # end
15
+ #
16
+ # def to_h
17
+ # { 'key' => @key }
18
+ # end
19
+ # end
20
+ #
21
+ # h = {}
22
+ # h[C.new('key1')] = 1
23
+ # h[C.new('key1')] #=> 1
24
+ # h[C.new('key2')] #=> nil
25
+ module HashKeyable
26
+ # @param [Object] other
27
+ # @return [Boolean]
28
+ def eql?(other)
29
+ other.is_a?(self.class) && other.hash == hash
30
+ end
31
+
32
+ # @return [Integer]
33
+ def hash
34
+ to_h.hash
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,44 @@
1
+ module Airbrake
2
+ # Ignorable contains methods that allow the includee to be ignored.
3
+ #
4
+ # @example
5
+ # class A
6
+ # include Airbrake::Ignorable
7
+ # end
8
+ #
9
+ # a = A.new
10
+ # a.ignore!
11
+ # a.ignored? #=> true
12
+ #
13
+ # @since v3.2.0
14
+ # @api private
15
+ module Ignorable
16
+ attr_accessor :ignored
17
+
18
+ # Checks whether the instance was ignored.
19
+ # @return [Boolean]
20
+ # @see #ignore!
21
+ # rubocop:disable Style/DoubleNegation
22
+ def ignored?
23
+ !!ignored
24
+ end
25
+ # rubocop:enable Style/DoubleNegation
26
+
27
+ # Ignores an instance. Ignored instances must never reach the Airbrake
28
+ # dashboard.
29
+ # @return [void]
30
+ # @see #ignored?
31
+ def ignore!
32
+ self.ignored = true
33
+ end
34
+
35
+ private
36
+
37
+ # A method that is meant to be used as a guard.
38
+ # @raise [Airbrake::Error] when instance is ignored
39
+ def raise_if_ignored
40
+ return unless ignored?
41
+ raise Airbrake::Error, "cannot access ignored #{self.class}"
42
+ end
43
+ end
44
+ end
@@ -49,6 +49,8 @@ module Airbrake
49
49
  # @return [String]
50
50
  DEFAULT_SEVERITY = 'error'.freeze
51
51
 
52
+ include Ignorable
53
+
52
54
  # @since v1.7.0
53
55
  # @return [Hash{Symbol=>Object}] the hash with arbitrary objects to be used
54
56
  # in filters
@@ -91,23 +93,6 @@ module Airbrake
91
93
  end
92
94
  end
93
95
 
94
- # Ignores a notice. Ignored notices never reach the Airbrake dashboard.
95
- #
96
- # @return [void]
97
- # @see #ignored?
98
- # @note Ignored noticed can't be unignored
99
- def ignore!
100
- @payload = nil
101
- end
102
-
103
- # Checks whether the notice was ignored.
104
- #
105
- # @return [Boolean]
106
- # @see #ignore!
107
- def ignored?
108
- @payload.nil?
109
- end
110
-
111
96
  # Reads a value from notice's payload.
112
97
  #
113
98
  # @return [Object]
@@ -160,11 +145,6 @@ module Airbrake
160
145
  }.merge(CONTEXT).delete_if { |_key, val| val.nil? || val.empty? }
161
146
  end
162
147
 
163
- def raise_if_ignored
164
- return unless ignored?
165
- raise Airbrake::Error, 'cannot access ignored notice'
166
- end
167
-
168
148
  def truncate
169
149
  TRUNCATABLE_KEYS.each do |key|
170
150
  @payload[key] = @truncator.truncate(@payload[key])
@@ -1,12 +1,12 @@
1
1
  module Airbrake
2
- # This class is reponsible for sending notices to Airbrake. It supports
2
+ # NoticeNotifier is reponsible for sending notices to Airbrake. It supports
3
3
  # synchronous and asynchronous delivery.
4
4
  #
5
5
  # @see Airbrake::Config The list of options
6
6
  # @since v1.0.0
7
- # @api private
7
+ # @api public
8
8
  # rubocop:disable Metrics/ClassLength
9
- class Notifier
9
+ class NoticeNotifier
10
10
  # @return [String] the label to be prepended to the log output
11
11
  LOG_LABEL = '**Airbrake:'.freeze
12
12
 
@@ -16,31 +16,44 @@ module Airbrake
16
16
  "project_key=\"%<project_key>s\" " \
17
17
  "host=\"%<host>s\" filter_chain=%<filter_chain>s>".freeze
18
18
 
19
- # Creates a new Airbrake notifier with the given config options.
20
- #
21
- # @example Configuring with a Hash
22
- # airbrake = Airbrake.new(project_id: 123, project_key: '321')
19
+ # @return [Array<Class>] filters to be executed first
20
+ DEFAULT_FILTERS = [
21
+ Airbrake::Filters::SystemExitFilter,
22
+ Airbrake::Filters::GemRootFilter
23
+
24
+ # Optional filters (must be included by users):
25
+ # Airbrake::Filters::ThreadFilter
26
+ ].freeze
27
+
28
+ # Creates a new notice notifier with the given config options.
23
29
  #
24
- # @example Configuring with an Airbrake::Config
30
+ # @example
25
31
  # config = Airbrake::Config.new
26
32
  # config.project_id = 123
27
33
  # config.project_key = '321'
28
- # airbake = Airbrake.new(config)
34
+ # notice_notifier = Airbrake::NoticeNotifier.new(config)
29
35
  #
30
- # @param [Hash, Airbrake::Config] user_config The config that contains
31
- # information about how the notifier should operate
32
- # @raise [Airbrake::Error] when either +project_id+ or +project_key+
33
- # is missing (or both)
34
- def initialize(user_config)
35
- @config = (user_config.is_a?(Config) ? user_config : Config.new(user_config))
36
-
37
- raise Airbrake::Error, @config.validation_error_message unless @config.valid?
36
+ # @param [Airbrake::Config] config
37
+ def initialize(config)
38
+ @config =
39
+ if config.is_a?(Config)
40
+ config
41
+ else
42
+ loc = caller_locations(1..1).first
43
+ signature = "#{self.class.name}##{__method__}"
44
+ warn(
45
+ "#{loc.path}:#{loc.lineno}: warning: passing a Hash to #{signature} " \
46
+ 'is deprecated. Pass `Airbrake::Config` instead'
47
+ )
48
+ Config.new(config)
49
+ end
38
50
 
39
51
  @context = {}
40
- @filter_chain = FilterChain.new(@config, @context)
52
+ @filter_chain = FilterChain.new
41
53
  @async_sender = AsyncSender.new(@config)
42
54
  @sync_sender = SyncSender.new(@config)
43
- @route_sender = RouteSender.new(@config)
55
+
56
+ add_default_filters
44
57
  end
45
58
 
46
59
  # @macro see_public_api_method
@@ -83,18 +96,6 @@ module Airbrake
83
96
  @async_sender.close
84
97
  end
85
98
 
86
- # @macro see_public_api_method
87
- def create_deploy(deploy_params)
88
- deploy_params[:environment] ||= @config.environment
89
- promise = Airbrake::Promise.new
90
- @sync_sender.send(
91
- deploy_params,
92
- promise,
93
- URI.join(@config.host, "api/v4/projects/#{@config.project_id}/deploys")
94
- )
95
- promise
96
- end
97
-
98
99
  # @macro see_public_api_method
99
100
  def configured?
100
101
  @config.valid?
@@ -105,21 +106,6 @@ module Airbrake
105
106
  @context.merge!(context)
106
107
  end
107
108
 
108
- # @macro see_public_api_method
109
- def notify_request(request_info)
110
- promise = Airbrake::Promise.new
111
-
112
- if @config.ignored_environment?
113
- return promise.reject("The '#{@config.environment}' environment is ignored")
114
- end
115
-
116
- unless @config.route_stats
117
- return promise.reject("The Route Stats feature is disabled")
118
- end
119
-
120
- @route_sender.notify_request(request_info, promise)
121
- end
122
-
123
109
  # @return [String] customized inspect to lessen the amount of clutter
124
110
  def inspect
125
111
  format(
@@ -193,6 +179,40 @@ module Airbrake
193
179
  return caller_copy if clean_bt.empty?
194
180
  clean_bt
195
181
  end
182
+
183
+ # rubocop:disable Metrics/AbcSize
184
+ def add_default_filters
185
+ DEFAULT_FILTERS.each { |f| add_filter(f.new) }
186
+
187
+ if (whitelist_keys = @config.whitelist_keys).any?
188
+ add_filter(
189
+ Airbrake::Filters::KeysWhitelist.new(@config.logger, whitelist_keys)
190
+ )
191
+ end
192
+
193
+ if (blacklist_keys = @config.blacklist_keys).any?
194
+ add_filter(
195
+ Airbrake::Filters::KeysBlacklist.new(@config.logger, blacklist_keys)
196
+ )
197
+ end
198
+
199
+ add_filter(Airbrake::Filters::ContextFilter.new(@context))
200
+ add_filter(Airbrake::Filters::ExceptionAttributesFilter.new(@config.logger))
201
+
202
+ return unless (root_directory = @config.root_directory)
203
+ [
204
+ Airbrake::Filters::RootDirectoryFilter,
205
+ Airbrake::Filters::GitRevisionFilter,
206
+ Airbrake::Filters::GitRepositoryFilter
207
+ ].each do |filter|
208
+ add_filter(filter.new(root_directory))
209
+ end
210
+
211
+ add_filter(
212
+ Airbrake::Filters::GitLastCheckoutFilter.new(@config.logger, root_directory)
213
+ )
214
+ end
215
+ # rubocop:enable Metrics/AbcSize
196
216
  end
197
217
  # rubocop:enable Metrics/ClassLength
198
218
  end