zuora_observability 0.1.0.pre.c → 0.1.0.pre.d

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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: be19d78e7a52c884e0bd5cc705a3725ea9616cd96491116d6ac047abbe8018b3
4
- data.tar.gz: 446dc9c840d1d00cf66c0316d2fca0b38718d2b4ac32bcda718069515283c3f9
3
+ metadata.gz: f4a7c9aeb569ef2885c3f8b298771323d5a2619340b47e390d790966794b67df
4
+ data.tar.gz: 417163de43f269d1c5f82e2bf1c09c5066a5f752bc8e4d77d6bf27435de1d137
5
5
  SHA512:
6
- metadata.gz: a70635409b8672344d7efd3c4025507ca0f450fc64aa78d5d81de9558926d1e5a59716832e7408a83abf24ed6492e4470a80fbb8bf1f84ad6391d1e188eaf94f
7
- data.tar.gz: 7718bfc8650dc24092149163861a6f55ae7164cecda3f91534ee98c92645462195b9be625d0f212df974d2b87d4cc767ef9721d6f45581d47dfa0c878a53fccb
6
+ metadata.gz: e8435b4a4aa06bebf6d0924c8f39d71da9b96debf12582e0b92d147389e8072c8bcc40c13e2978a81cc7c8b0da9efcb540e3fa864a91e90f0b001b76d0c45667
7
+ data.tar.gz: a2adea8aef5fc82317077e4f68eb7061c66ca53991a545642deac1f5367cf35dfbae329fe83ee6feb3aa727e958cc0c6d7cae27c9062852affe29f8048ca6227
data/README.md CHANGED
@@ -4,6 +4,8 @@ A ruby gem to enable observability into rails applications
4
4
 
5
5
  ## Usage
6
6
 
7
+ - [Logging](doc/logging.md)
8
+
7
9
  ### Configuration
8
10
 
9
11
  Observability can be configured through an initializer as shown below.
@@ -3,12 +3,19 @@
3
3
  module ZuoraObservability
4
4
  # Global configuration that can be set in a Rails initializer
5
5
  class Configuration
6
- attr_accessor :json_logging
6
+ attr_reader :custom_payload_hooks
7
+ attr_accessor :zecs_service_hook, :json_logging
7
8
 
8
9
  def initialize
10
+ @custom_payload_hooks = []
11
+ @zecs_service_hook = nil
9
12
  # NOTE(hartley): this checks the var for presence rather than value to
10
13
  # align with how Rails does RAILS_LOG_TO_STDOUT
11
14
  @json_logging = ENV['ZUORA_JSON_LOGGING'].present? ? true : !(Rails.env.development? || Rails.env.test?)
12
15
  end
16
+
17
+ def add_custom_payload_hook(&block)
18
+ custom_payload_hooks << block
19
+ end
13
20
  end
14
21
  end
@@ -5,88 +5,38 @@ module ZuoraObservability
5
5
  class Engine < ::Rails::Engine
6
6
  isolate_namespace ZuoraObservability
7
7
 
8
- REQUEST_HEADERS_TO_IGNORE = %w[
9
- RAW_POST_DATA
10
- REQUEST_METHOD
11
- REQUEST_URI
12
- REQUEST_PATH
13
- PATH_INFO
14
- CONTENT_TYPE
15
- ORIGINAL_FULLPATH
16
- QUERY_STRING
17
- ].freeze
18
-
19
8
  config.generators do |g|
20
9
  g.test_framework :rspec
21
10
  end
22
11
 
23
12
  initializer(:rails_stdout_logging, before: :initialize_logger) do
24
- require 'lograge'
13
+ Rails.application.configure do
14
+ config.logger = ZuoraObservability::Logger.custom_logger(name: 'Rails')
25
15
 
26
- Rails.configuration.logger = ZuoraObservability::Logger.custom_logger(name: "Rails")
16
+ next unless ZuoraObservability.configuration.json_logging
27
17
 
28
- if ZuoraObservability.configuration.json_logging
29
- Rails.configuration.lograge.enabled = true
30
- Rails.configuration.colorize_logging = false
31
- end
18
+ require 'lograge'
19
+ require 'zuora_observability/logging/custom_options'
32
20
 
33
- if Rails.configuration.lograge.enabled
34
- if Rails.configuration.logger.class.to_s == 'Ougai::Logger'
35
- Rails.configuration.lograge.formatter = Class.new do |fmt|
36
- def fmt.call(data)
37
- {
38
- msg: 'Rails Request',
39
- trace_id: data.delete(:trace_id),
40
- zuora_trace_id: data.delete(:zuora_trace_id),
41
- request: data
42
- }.compact
43
- end
44
- end
45
- end
46
- #Rails.configuration.lograge.formatter = Lograge::Formatters::Json.new
47
- Rails.configuration.lograge.custom_options = lambda do |event|
48
- exceptions = %w(controller action format)
49
- items = {
50
- #time: event.time.strftime('%FT%T.%6N'),
51
- params: event.payload[:params].as_json(except: exceptions).to_json.to_s,
52
- trace_id: event.payload[:headers]['action_dispatch.request_id'],
53
- zuora_trace_id: event.payload[:headers]['HTTP_ZUORA_REQUEST_ID']
54
- }
55
- items.merge!({exception_object: event.payload[:exception_object]}) if event.payload[:exception_object].present?
56
- items.merge!({exception: event.payload[:exception]}) if event.payload[:exception].present?
21
+ config.lograge.enabled = true
22
+ config.colorize_logging = false
57
23
 
58
- if event.payload[:headers].present?
59
- # By convertion, headers usually do not have dots. Nginx even rejects headers with dots
60
- # All Rails headers are namespaced, like 'rack.input'.
61
- # Thus, we can obtain the client headers by rejecting dots
62
- request_headers =
63
- event.payload[:headers].env.
64
- reject { |key| key.to_s.include?('.') || REQUEST_HEADERS_TO_IGNORE.include?(key.to_s) }
65
- begin
66
- if request_headers["HTTP_AUTHORIZATION"].present?
67
- if request_headers["HTTP_AUTHORIZATION"].include?("Basic")
68
- user_password = request_headers["HTTP_AUTHORIZATION"].split("Basic").last.strip
69
- user, password = Base64.decode64(user_password).split(":")
70
- request_headers["HTTP_AUTHORIZATION"] = "Basic #{user}:ValueFiltered"
71
- elsif
72
- request_headers["HTTP_AUTHORIZATION"] = "ValueFiltered"
73
- end
74
- end
75
- request_headers["HTTP_API_TOKEN"] = "ValueFiltered" if request_headers["HTTP_API_TOKEN"].present?
76
- rescue
77
- request_headers.delete("HTTP_API_TOKEN")
78
- request_headers.delete("HTTP_AUTHORIZATION")
79
- end
80
- items.merge!({ headers: request_headers.to_s })
81
- end
24
+ config.lograge.formatter = Lograge::Formatters::Raw.new
82
25
 
83
- if Thread.current[:appinstance].present?
84
- items.merge!({connect_user: Thread.current[:appinstance].connect_user, new_session: Thread.current[:appinstance].new_session_message})
85
- if Thread.current[:appinstance].logitems.present? && Thread.current[:appinstance].logitems.class == Hash
86
- items.merge!(Thread.current[:appinstance].logitems)
87
- end
26
+ config.lograge.custom_options = Logging::CustomOptions
27
+ config.lograge.custom_payload do |controller|
28
+ payload = {}
29
+
30
+ ZuoraObservability.configuration.custom_payload_hooks.each do |hook|
31
+ payload.merge!(hook.call(controller))
88
32
  end
89
- return items
33
+
34
+ next payload unless ZuoraObservability.configuration.zecs_service_hook
35
+
36
+ payload[:zecs_service] =
37
+ ZuoraObservability.configuration.zecs_service_hook.call(controller)
38
+
39
+ payload
90
40
  end
91
41
  end
92
42
  end
@@ -4,6 +4,18 @@ module ZuoraObservability
4
4
  # Methods to get information about the application environment
5
5
  class Env
6
6
  class << self
7
+ def name
8
+ ENV['Z_APPLICATION_NAME']
9
+ end
10
+
11
+ def version
12
+ ENV['Z_APPLICATION_VERSION']
13
+ end
14
+
15
+ def environment
16
+ ENV['Z_APPLICATION_ENVIRONMENT']
17
+ end
18
+
7
19
  def app_name
8
20
  # parent_name is deprecated in Rails 6.0, removed in 6.1
9
21
  ENV['DEIS_APP'].presence || Rails.application.class.parent_name
@@ -1,44 +1,63 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'ougai'
3
4
  require 'mono_logger'
5
+ require 'zuora_observability/logging/formatter'
6
+ require 'ougai/formatters/customizable'
4
7
 
5
8
  module ZuoraObservability
6
9
  # A configurable logger that can be used for Rails and additional libraries
7
- module Logger
8
- # NOTE(hartley): replace this with a subclass of Ougai Logger
9
- # - enable silence method for Rails 5.2 asset requests
10
+ class Logger < Ougai::Logger
11
+ # TODO(hartley): enable silence method for Rails 5.2 asset requests
10
12
  # https://github.com/tilfin/ougai/wiki/Use-as-Rails-logger
11
- # - potentially integrate with MonoLogger so no conditional is needed
12
- # https://github.com/tilfin/ougai/issues/74
13
- def self.custom_logger(name: "", level: Rails.logger.present? ? Rails.logger.level : MonoLogger::INFO, type: :ougai)
14
- #puts name + ' - ' + {Logger::WARN => 'Logger::WARN', Logger::ERROR => 'Logger::ERROR', Logger::DEBUG => 'Logger::DEBUG', Logger::INFO => 'Logger::INFO' }[level] + ' - '
13
+ def initialize(logdev, **)
14
+ super
15
+
16
+ # NOTE(hartley): the purpose for the original split between Ougai and
17
+ # MonoLogger was that MonoLogger enables logging in a trap context
18
+ # https://github.com/tilfin/ougai/issues/74
19
+ # By using our own Logger class, we can override the LogDevice created
20
+ # by ruby with MonoLogger's, enabling logging in a trap context
21
+ @logdev = MonoLogger::LocklessLogDevice.new(logdev)
22
+ end
23
+
24
+ def create_formatter
25
+ return ZuoraObservability::Logging::Formatter.new if ZuoraObservability.configuration.json_logging
26
+
27
+ formatter = Ougai::Formatters::Customizable.new(
28
+ format_err: method(:custom_error_formatter),
29
+ format_data: method(:custom_data_formatter),
30
+ format_msg: method(:custom_message_formatter)
31
+ )
32
+ formatter.datetime_format = '%FT%T.%3NZ'
33
+ formatter
34
+ end
35
+
36
+ def custom_message_formatter(severity, datetime, _progname, data)
37
+ msg = data.delete(:msg)
38
+ "#{severity.ljust(6)} #{datetime}: #{msg}"
39
+ end
40
+
41
+ def custom_data_formatter(data)
42
+ data.delete_if do |k|
43
+ %i[app_instance_id tenant_ids organization environment].include? k
44
+ end
45
+
46
+ return nil if data.blank?
47
+
48
+ "#{'DATA'.ljust(6)} #{Time.current.strftime('%FT%T.%3NZ')}: #{data.to_json}"
49
+ end
50
+
51
+ def custom_error_formatter(data)
52
+ return nil unless data.key?(:err)
53
+
54
+ err = data.delete(:err)
55
+ " #{err[:name]} (#{err[:message]})\n #{err[:stack]}"
56
+ end
57
+
58
+ def self.custom_logger(name: '', level: Rails.logger.present? ? Rails.logger.level : MonoLogger::INFO, type: :ougai)
15
59
  if type == :ougai
16
- require 'ougai'
17
- require "ougai/formatters/customizable"
18
- #logger = Ougai::Logger.new(MonoLogger.new(STDOUT))
19
- logger = Ougai::Logger.new(STDOUT)
20
- logger.level = level
21
- if ZuoraObservability.configuration.json_logging
22
- require 'zuora_observability/logging/formatter'
23
- logger.formatter = ZuoraObservability::Logging::Formatter.new(name)
24
- else
25
- logger.formatter = Ougai::Formatters::Customizable.new(
26
- format_err: proc do |data|
27
- next nil unless data.key?(:err)
28
- err = data.delete(:err)
29
- " #{err[:name]} (#{err[:message]})\n #{err[:stack]}"
30
- end,
31
- format_data: proc do |data|
32
- data.delete(:app_instance_id); data.delete(:tenant_ids); data.delete(:organization); data.delete(:environment)
33
- format('%s %s: %s', 'DATA'.ljust(6), Time.now.strftime('%FT%T.%6NZ'), "#{data.to_json}") if data.present?
34
- end,
35
- format_msg: proc do |severity, datetime, _progname, data|
36
- msg = data.delete(:msg)
37
- format('%s %s: %s', severity.ljust(6), datetime, msg)
38
- end
39
- )
40
- logger.formatter.datetime_format = '%FT%T.%6NZ'
41
- end
60
+ logger = new($stdout, level: level, progname: name)
42
61
  else
43
62
  require 'mono_logger'
44
63
  logger = MonoLogger.new(STDOUT)
@@ -63,7 +82,8 @@ module ZuoraObservability
63
82
  end
64
83
  end
65
84
  end
66
- return logger
85
+
86
+ logger
67
87
  end
68
88
  end
69
89
  end
@@ -0,0 +1,82 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ZuoraObservability
4
+ module Logging
5
+ # Custom Options for lograge to add additional fields to logged data
6
+ module CustomOptions
7
+ IGNORE_HEADERS = %w[
8
+ RAW_POST_DATA
9
+ REQUEST_METHOD
10
+ REQUEST_URI
11
+ REQUEST_PATH
12
+ PATH_INFO
13
+ CONTENT_TYPE
14
+ ORIGINAL_FULLPATH
15
+ QUERY_STRING
16
+ ].freeze
17
+
18
+ IGNORE_PARAMS = %w[controller action format].freeze
19
+
20
+ FILTERED = '[FILTERED]'
21
+
22
+ class << self
23
+ def call(event)
24
+ items = {
25
+ msg: 'Rails Request',
26
+ params: event.payload[:params].as_json(except: IGNORE_PARAMS).to_s,
27
+ trace_id: event.payload[:headers]['action_dispatch.request_id'],
28
+ zuora_trace_id: event.payload[:headers]['HTTP_ZUORA_REQUEST_ID'],
29
+ error: event.payload[:exception_object]
30
+ }
31
+
32
+ if event.payload[:headers].present?
33
+ request_headers = filter_request_headers(event.payload[:headers])
34
+
35
+ filtered_headers = filter_sensitive_headers(request_headers)
36
+
37
+ items.merge!({ headers: filtered_headers })
38
+ end
39
+
40
+ if Thread.current[:appinstance].present?
41
+ items.merge!({connect_user: Thread.current[:appinstance].connect_user, new_session: Thread.current[:appinstance].new_session_message})
42
+ if Thread.current[:appinstance].logitems.present? && Thread.current[:appinstance].logitems.class == Hash
43
+ items.merge!(Thread.current[:appinstance].logitems)
44
+ end
45
+ end
46
+
47
+ items
48
+ end
49
+
50
+ private
51
+
52
+ def filter_request_headers(action_dispatch_headers)
53
+ action_dispatch_headers.env.select do |header|
54
+ next false if IGNORE_HEADERS.include? header
55
+
56
+ header =~ /^HTTP_/ || ActionDispatch::Http::Headers::CGI_VARIABLES.include?(header)
57
+ end
58
+ end
59
+
60
+ def filter_sensitive_headers(headers)
61
+ filtered = {}
62
+
63
+ auth = headers['HTTP_AUTHORIZATION']
64
+ filtered['HTTP_AUTHORIZATION'] = filter_auth_header(auth) if auth
65
+
66
+ filtered['HTTP_API_TOKEN'] = FILTERED if headers['HTTP_API_TOKEN']
67
+
68
+ headers.merge(filtered)
69
+ end
70
+
71
+ def filter_auth_header(authorization)
72
+ return FILTERED unless authorization.include? 'Basic'
73
+
74
+ encoded = authorization.split(' ', 2).second
75
+ user, _password = Base64.decode64(encoded).split(':')
76
+
77
+ "Basic #{user}:#{FILTERED}"
78
+ end
79
+ end
80
+ end
81
+ end
82
+ end
@@ -1,45 +1,169 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'ougai/formatters/base'
4
- require 'ougai/formatters/for_json'
5
-
6
3
  module ZuoraObservability
7
4
  module Logging
8
- # A JSON formatter compatible with node-bunyan
9
- class Formatter < Ougai::Formatters::Base
10
- include Ougai::Formatters::ForJson
11
-
12
- # Intialize a formatter
13
- # @param [String] app_name application name (execution program name if nil)
14
- # @param [String] hostname hostname (hostname if nil)
15
- # @param [Hash] opts the initial values of attributes
16
- # @option opts [String] :trace_indent (2) the value of trace_indent attribute
17
- # @option opts [String] :trace_max_lines (100) the value of trace_max_lines attribute
18
- # @option opts [String] :serialize_backtrace (true) the value of serialize_backtrace attribute
19
- # @option opts [String] :jsonize (true) the value of jsonize attribute
20
- # @option opts [String] :with_newline (true) the value of with_newline attribute
21
- def initialize(app_name = nil, hostname = nil, opts = {})
22
- aname, hname, opts = Ougai::Formatters::Base.parse_new_params([app_name, hostname, opts])
23
- super(aname, hname, opts)
24
- init_opts_for_json(opts)
25
- end
5
+ # Formats data into ZECS Logging Standard and dumps as JSON
6
+ # https://tag.pages.gitlab.zeta.tools/standards/logging
7
+ class Formatter < Ougai::Formatters::Bunyan
8
+ ECS_VERSION = '1.5.0'
9
+
10
+ ZECS_TOP_LEVEL_FIELDS = %i[
11
+ error event http organization process url user
12
+ ].freeze
13
+ ECS_HTTP_FIELDS = %i[request response].freeze
26
14
 
27
15
  def _call(severity, time, progname, data)
28
- data.merge!({ message: data.delete(:msg) })
29
- if data[:timestamp].present?
30
- time = data[:timestamp]
31
- data.delete(:timestamp)
32
- end
33
- dump({
34
- name: progname || @app_name,
35
- pid: $$,
36
- level: severity,
37
- timestamp: time.utc.strftime('%FT%T.%6NZ'),
38
- }.merge(data))
16
+ output = base_fields(severity, time, progname, data)
17
+
18
+ other_fields = get_fields_for(ZECS_TOP_LEVEL_FIELDS, data)
19
+ output.merge!(other_fields)
20
+
21
+ zuora_fields = ZuoraFields.call(data)
22
+ output[:zuora] = zuora_fields unless zuora_fields.empty?
23
+
24
+ dump(output)
39
25
  end
40
26
 
27
+ # Note(hartley): Ougai::Formatters::ForJson requires this be present
41
28
  def convert_time(data)
42
- # data[:timestamp] = format_datetime(data[:time])
29
+ data[:@timestamp] = data[:@timestamp].utc.iso8601(3)
30
+ end
31
+
32
+ private
33
+
34
+ def get_fields_for(list, data)
35
+ final_hash = {}
36
+
37
+ list.each do |field|
38
+ field_output = send("#{field}_fields", data)
39
+ final_hash[field] = field_output unless field_output.empty?
40
+ end
41
+
42
+ final_hash
43
+ end
44
+
45
+ # Fields required by ECS
46
+ def base_fields(severity, time, progname, data)
47
+ {
48
+ :@timestamp => time,
49
+ message: data[:msg],
50
+ ecs: { version: ECS_VERSION },
51
+ log: { level: severity, logger: progname || @app_name },
52
+ service: { name: Env.name, version: Env.version },
53
+ trace: { id: data[:trace_id] }
54
+ }
55
+ end
56
+
57
+ # error
58
+ def error_fields(data)
59
+ return {} unless data[:error]
60
+
61
+ {
62
+ message: data[:error].message,
63
+ stack_trace: data[:error].backtrace.join("\n"),
64
+ type: data[:error].class
65
+ }.compact
66
+ end
67
+
68
+ # http
69
+ def http_fields(data)
70
+ get_fields_for(ECS_HTTP_FIELDS, data)
71
+ end
72
+
73
+ # http.request
74
+ def request_fields(data)
75
+ {
76
+ method: data[:method],
77
+ body: ({ content: data[:params] } if data.key? :params)
78
+ }.compact
79
+ end
80
+
81
+ # http.response
82
+ def response_fields(data)
83
+ {
84
+ status_code: data[:status]
85
+ }.compact
86
+ end
87
+
88
+ # organization
89
+ def organization_fields(data)
90
+ {
91
+ name: data[:organization]
92
+ }.compact
93
+ end
94
+
95
+ # process
96
+ def process_fields(_data)
97
+ { id: Process.pid }
98
+ end
99
+
100
+ # url
101
+ def url_fields(data)
102
+ {
103
+ path: data[:path]
104
+ }.compact
105
+ end
106
+
107
+ # user
108
+ def user_fields(data)
109
+ {
110
+ email: data[:email]
111
+ }.compact
112
+ end
113
+
114
+ # event
115
+ def event_fields(data)
116
+ full_action = "#{data[:controller]}##{data[:action]}"
117
+
118
+ {
119
+ action: (full_action unless full_action == '#'),
120
+ duration: data[:duration]
121
+ }.compact
122
+ end
123
+ end
124
+
125
+ # The Zuora Extension to ECS defined by ZECS
126
+ module ZuoraFields
127
+ ZECS_VERSION = '1.0'
128
+
129
+ class << self
130
+ # zuora top level field
131
+ def call(data)
132
+ hash = base_fields(data)
133
+
134
+ z_http = http_fields(data)
135
+ hash[:http] = z_http unless z_http.empty?
136
+
137
+ z_service = service_fields(data)
138
+ hash[Env.name.to_sym] = z_service unless z_service.empty?
139
+
140
+ return {} if hash.empty?
141
+
142
+ hash.merge({ ecs: { version: ZECS_VERSION } })
143
+ end
144
+
145
+ def base_fields(data)
146
+ {
147
+ apartment_id: data[:app_instance_id],
148
+ environment: data[:environment],
149
+ tenant_id: data[:tenant_ids],
150
+ trace_id: data[:zuora_trace_id]
151
+ }.compact
152
+ end
153
+
154
+ # zuora.http
155
+ def http_fields(data)
156
+ z_http_request = { headers: data[:headers] }.compact
157
+
158
+ {
159
+ request: (z_http_request unless z_http_request.empty?)
160
+ }.compact
161
+ end
162
+
163
+ # Service's Custom Fields
164
+ def service_fields(data)
165
+ data[:zecs_service].presence || {}
166
+ end
43
167
  end
44
168
  end
45
169
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module ZuoraObservability
4
- VERSION = '0.1.0-c'
4
+ VERSION = '0.1.0-d'
5
5
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: zuora_observability
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0.pre.c
4
+ version: 0.1.0.pre.d
5
5
  platform: ruby
6
6
  authors:
7
7
  - Hartley McGuire
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2021-01-05 00:00:00.000000000 Z
11
+ date: 2021-01-12 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: lograge
@@ -261,6 +261,7 @@ files:
261
261
  - lib/zuora_observability/engine.rb
262
262
  - lib/zuora_observability/env.rb
263
263
  - lib/zuora_observability/logger.rb
264
+ - lib/zuora_observability/logging/custom_options.rb
264
265
  - lib/zuora_observability/logging/formatter.rb
265
266
  - lib/zuora_observability/metrics.rb
266
267
  - lib/zuora_observability/version.rb
@@ -276,7 +277,7 @@ required_ruby_version: !ruby/object:Gem::Requirement
276
277
  requirements:
277
278
  - - ">="
278
279
  - !ruby/object:Gem::Version
279
- version: '2.4'
280
+ version: '2.5'
280
281
  required_rubygems_version: !ruby/object:Gem::Requirement
281
282
  requirements:
282
283
  - - ">"