rox-rollout 5.0.0 → 5.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.
@@ -0,0 +1,143 @@
1
+ require 'rox/core/analytics/defaults'
2
+ require 'rox/core/analytics/utils'
3
+ require 'rox/core/analytics/response'
4
+ require 'rox/core/analytics/logging'
5
+ require 'rox/core/analytics/backoff_policy'
6
+ require 'net/http'
7
+ require 'net/https'
8
+ require 'json'
9
+
10
+ module Rox
11
+ module Core
12
+ class Analytics
13
+ class Transport
14
+ include Rox::Core::Analytics::Defaults::Request
15
+ include Rox::Core::Analytics::Utils
16
+ include Rox::Core::Analytics::Logging
17
+
18
+ def initialize(device_properties)
19
+ @device_properties = device_properties
20
+ uri = URI.parse(Rox::Core::Environment.analytics_path)
21
+ @headers = {
22
+ 'Accept' => 'application/json',
23
+ 'Content-Type' => 'application/json',
24
+ 'User-Agent' => "ruby/#{device_properties.lib_version}"
25
+ }
26
+ @path = uri.path + '/impression/' + device_properties.rollout_key
27
+ @retries = RETRIES
28
+ @backoff_policy = Rox::Core::Analytics::BackoffPolicy.new
29
+
30
+ http = Net::HTTP.new(uri.host, uri.port)
31
+ http.use_ssl = uri.scheme == 'https'
32
+ http.read_timeout = 8
33
+ http.open_timeout = 4
34
+
35
+ @http = http
36
+ end
37
+
38
+ # Sends a batch of messages to the API
39
+ #
40
+ # @return [Response] API response
41
+ def send(batch)
42
+ logger.debug("Sending request for #{batch.length} items")
43
+
44
+ last_response, exception = retry_with_backoff(@retries) do
45
+ status_code, body = send_request(batch)
46
+ should_retry = should_retry_request?(status_code, body)
47
+ logger.debug("Response status code: #{status_code}")
48
+ logger.debug("Response error: #{body}") if status_code != 200
49
+
50
+ [Response.new(status_code, body), should_retry]
51
+ end
52
+
53
+ if exception
54
+ logger.error(exception.message)
55
+ exception.backtrace.each { |line| logger.error(line) }
56
+ Response.new(-1, exception.to_s)
57
+ else
58
+ last_response
59
+ end
60
+ end
61
+
62
+ # Closes a persistent connection if it exists
63
+ def shutdown
64
+ @http.finish if @http.started?
65
+ end
66
+
67
+ private
68
+
69
+ def should_retry_request?(status_code, body)
70
+ if status_code >= 500
71
+ true # Server error
72
+ elsif status_code == 429
73
+ true # Rate limited
74
+ elsif status_code >= 400
75
+ logger.error(body)
76
+ false # Client error. Do not retry, but log
77
+ else
78
+ false
79
+ end
80
+ end
81
+
82
+ # Takes a block that returns [result, should_retry].
83
+ #
84
+ # Retries upto `retries_remaining` times, if `should_retry` is false or
85
+ # an exception is raised. `@backoff_policy` is used to determine the
86
+ # duration to sleep between attempts
87
+ #
88
+ # Returns [last_result, raised_exception]
89
+ def retry_with_backoff(retries_remaining, &block)
90
+ result, caught_exception = nil
91
+ should_retry = false
92
+
93
+ begin
94
+ result, should_retry = yield
95
+ return [result, nil] unless should_retry
96
+ rescue StandardError => e
97
+ should_retry = true
98
+ caught_exception = e
99
+ end
100
+
101
+ if should_retry && (retries_remaining > 1)
102
+ logger.debug("Retrying request, #{retries_remaining} retries left")
103
+ sleep(@backoff_policy.next_interval.to_f / 1000)
104
+ retry_with_backoff(retries_remaining - 1, &block)
105
+ else
106
+ [result, caught_exception]
107
+ end
108
+ end
109
+
110
+ # Sends a request for the batch, returns [status_code, body]
111
+ def send_request(batch)
112
+ payload = JSON.generate(
113
+ :analyticsVersion => '1.0.0',
114
+ :sdkVersion => @device_properties.lib_version,
115
+ :time => DateTime.now.strftime('%Q').to_i,
116
+ :platform => @device_properties.all_properties[PropertyType::PLATFORM.name],
117
+ :rolloutKey => @device_properties.rollout_key,
118
+ :events => batch
119
+ )
120
+ request = Net::HTTP::Post.new(@path, @headers)
121
+
122
+ if self.class.stub
123
+ logger.debug "stubbed request to #{@path}: #{JSON.generate(batch)}"
124
+
125
+ [200, '{}']
126
+ else
127
+ @http.start unless @http.started? # Maintain a persistent connection
128
+ response = @http.request(request, payload)
129
+ [response.code.to_i, response.body]
130
+ end
131
+ end
132
+
133
+ class << self
134
+ attr_writer :stub
135
+
136
+ def stub
137
+ @stub || ENV['STUB']
138
+ end
139
+ end
140
+ end
141
+ end
142
+ end
143
+ end
@@ -0,0 +1,89 @@
1
+ require 'securerandom'
2
+
3
+ module Rox
4
+ module Core
5
+ class Analytics
6
+ module Utils
7
+ extend self
8
+
9
+ # public: Return a new hash with keys converted from strings to symbols
10
+ #
11
+ def symbolize_keys(hash)
12
+ hash.each_with_object({}) do |(k, v), memo|
13
+ memo[k.to_sym] = v
14
+ end
15
+ end
16
+
17
+ # public: Convert hash keys from strings to symbols in place
18
+ #
19
+ def symbolize_keys!(hash)
20
+ hash.replace symbolize_keys hash
21
+ end
22
+
23
+ # public: Return a new hash with keys as strings
24
+ #
25
+ def stringify_keys(hash)
26
+ hash.each_with_object({}) do |(k, v), memo|
27
+ memo[k.to_s] = v
28
+ end
29
+ end
30
+
31
+ # public: Returns a new hash with all the date values in the into iso8601
32
+ # strings
33
+ #
34
+ def isoify_dates(hash)
35
+ hash.each_with_object({}) do |(k, v), memo|
36
+ memo[k] = datetime_in_iso8601(v)
37
+ end
38
+ end
39
+
40
+ # public: Converts all the date values in the into iso8601 strings in place
41
+ #
42
+ def isoify_dates!(hash)
43
+ hash.replace isoify_dates hash
44
+ end
45
+
46
+ # public: Returns a uid string
47
+ #
48
+ def uid
49
+ arr = SecureRandom.random_bytes(16).unpack('NnnnnN')
50
+ arr[2] = (arr[2] & 0x0fff) | 0x4000
51
+ arr[3] = (arr[3] & 0x3fff) | 0x8000
52
+ '%08x-%04x-%04x-%04x-%04x%08x' % arr
53
+ end
54
+
55
+ def datetime_in_iso8601(datetime)
56
+ case datetime
57
+ when Time
58
+ time_in_iso8601 datetime
59
+ when DateTime
60
+ time_in_iso8601 datetime.to_time
61
+ when Date
62
+ date_in_iso8601 datetime
63
+ else
64
+ datetime
65
+ end
66
+ end
67
+
68
+ def time_in_iso8601(time)
69
+ "#{time.strftime('%Y-%m-%dT%H:%M:%S.%6N')}#{formatted_offset(time, true, 'Z')}"
70
+ end
71
+
72
+ def date_in_iso8601(date)
73
+ date.strftime('%F')
74
+ end
75
+
76
+ def formatted_offset(time, colon = true, alternate_utc_string = nil)
77
+ time.utc? && alternate_utc_string || seconds_to_utc_offset(time.utc_offset, colon)
78
+ end
79
+
80
+ def seconds_to_utc_offset(seconds, colon = true)
81
+ (colon ? UTC_OFFSET_WITH_COLON : UTC_OFFSET_WITHOUT_COLON) % [(seconds < 0 ? '-' : '+'), (seconds.abs / 3600), ((seconds.abs % 3600) / 60)]
82
+ end
83
+
84
+ UTC_OFFSET_WITH_COLON = '%s%02d:%02d'
85
+ UTC_OFFSET_WITHOUT_COLON = UTC_OFFSET_WITH_COLON.sub(':', '')
86
+ end
87
+ end
88
+ end
89
+ end
@@ -0,0 +1,67 @@
1
+ require 'rox/core/analytics/defaults'
2
+ require 'rox/core/analytics/message_batch'
3
+ require 'rox/core/analytics/transport'
4
+ require 'rox/core/analytics/utils'
5
+
6
+ module Rox
7
+ module Core
8
+ class Analytics
9
+ class Worker
10
+ include Rox::Core::Analytics::Utils
11
+ include Rox::Core::Analytics::Defaults
12
+ include Rox::Core::Analytics::Logging
13
+
14
+ # public: Creates a new worker
15
+ #
16
+ # The worker continuously takes messages off the queue
17
+ # and makes requests to the segment.io api
18
+ #
19
+ # queue - Queue synchronized between client and worker
20
+ # @param [Rox::Core::DeviceProperties] device_properties
21
+ #
22
+ def initialize(queue, device_properties)
23
+ @queue = queue
24
+ @device_properties = device_properties
25
+ @on_error = proc { |status, error| }
26
+ batch_size = Defaults::MessageBatch::MAX_SIZE
27
+ @batch = MessageBatch.new(batch_size)
28
+ @lock = Mutex.new
29
+ @transport = Transport.new(device_properties)
30
+ end
31
+
32
+ # public: Continuously runs the loop to check for new events
33
+ #
34
+ def run
35
+ until Thread.current[:should_exit]
36
+ return if @queue.empty?
37
+
38
+ @lock.synchronize do
39
+ consume_message_from_queue! until @batch.full? || @queue.empty?
40
+ end
41
+
42
+ res = @transport.send @batch
43
+ @on_error.call(res.status, res.error) unless res.status == 200
44
+
45
+ @lock.synchronize { @batch.clear }
46
+ end
47
+ ensure
48
+ @transport.shutdown
49
+ end
50
+
51
+ # public: Check whether we have outstanding requests.
52
+ #
53
+ def is_requesting?
54
+ @lock.synchronize { !@batch.empty? }
55
+ end
56
+
57
+ private
58
+
59
+ def consume_message_from_queue!
60
+ @batch << @queue.pop
61
+ rescue MessageBatch::JSONGenerationError => e
62
+ @on_error.call(-1, e.to_s)
63
+ end
64
+ end
65
+ end
66
+ end
67
+ end
@@ -1,4 +1,5 @@
1
1
  require 'rox/core/configuration/configuration_fetched_args'
2
+ require 'rox/core/error_handling/exception_trigger'
2
3
 
3
4
  module Rox
4
5
  module Core
@@ -6,6 +7,7 @@ module Rox
6
7
  def initialize(user_unhandled_error_invoker)
7
8
  @fetched_handlers = []
8
9
  @mutex = Mutex.new
10
+ @internal_handler = nil
9
11
  @user_unhandled_error_invoker = user_unhandled_error_invoker
10
12
  end
11
13
 
@@ -17,6 +19,10 @@ module Rox
17
19
  raise_fetched_event(Rox::Core::ConfigurationFetchedArgs.error(error_details))
18
20
  end
19
21
 
22
+ def register_start_stop_push(block)
23
+ @internal_handler = block
24
+ end
25
+
20
26
  def register_fetched_handler(&block)
21
27
  @mutex.synchronize do
22
28
  @fetched_handlers << block
@@ -24,6 +30,9 @@ module Rox
24
30
  end
25
31
 
26
32
  def raise_fetched_event(args)
33
+ unless @internal_handler.nil?
34
+ @internal_handler.call(args)
35
+ end
27
36
  handlers = []
28
37
  @mutex.synchronize do
29
38
  handlers = @fetched_handlers.clone
@@ -33,7 +42,7 @@ module Rox
33
42
  begin
34
43
  handler.call(args)
35
44
  rescue StandardError => e
36
- user_unhandled_error_invoker.invoke(handler, Rox::Core::CONFIGURATION_FETCHED_HANDLER, e)
45
+ @user_unhandled_error_invoker.invoke(handler, ExceptionTrigger::CONFIGURATION_FETCHED_HANDLER, e)
37
46
  end
38
47
  end
39
48
  end
data/lib/rox/core/core.rb CHANGED
@@ -1,3 +1,4 @@
1
+ require 'rox/core/analytics/client'
1
2
  require 'rox/core/repositories/flag_repository'
2
3
  require 'rox/core/repositories/custom_property_repository'
3
4
  require 'rox/core/repositories/target_group_repository'
@@ -24,7 +25,6 @@ require 'rox/core/security/signature_verifier'
24
25
  require 'rox/core/security/signature_verifier_mock'
25
26
  require 'rox/core/utils/periodic_task'
26
27
  require 'rox/core/client/dynamic_api'
27
- require 'rox/core/analytics'
28
28
  require 'rox/core/error_handling/userspace_unhandled_error_invoker'
29
29
 
30
30
  module Rox
@@ -49,6 +49,7 @@ module Rox
49
49
  @last_configurations = nil
50
50
  @internal_flags = nil
51
51
  @push_updates_listener = nil
52
+ @analytics_client = nil
52
53
  end
53
54
 
54
55
  def userspace_unhandled_error_handler=(handler)
@@ -72,7 +73,10 @@ module Rox
72
73
  # TODO: Analytics.Analytics.Initialize(deviceProperties.RolloutKey, deviceProperties)
73
74
  @internal_flags = InternalFlags.new(@experiment_repository, @parser, @rox_options)
74
75
 
75
- @analytics_client = Analytics.new(sdk_settings.api_key).client
76
+ if !@rox_options.self_managed? && roxy_path.nil?
77
+ @analytics_client = create_analytics_client(device_properties)
78
+ end
79
+
76
80
  # TODO: impressionInvoker = new ImpressionInvoker(internalFlags, customPropertyRepository, deviceProperties, Analytics.Analytics.Client, roxyPath != null);
77
81
  @impression_invoker = ImpressionInvoker.new(@internal_flags, @custom_property_repository, device_properties,
78
82
  @analytics_client, !roxy_path.nil?, @user_unhandled_error_invoker)
@@ -108,7 +112,11 @@ module Rox
108
112
  configuration_fetched_handler = nil
109
113
  configuration_fetched_handler = @rox_options.configuration_fetched_handler unless @rox_options.nil?
110
114
 
111
- @configuration_fetched_invoker.register_fetched_handler(&wrap_configuration_fetched_handler(&configuration_fetched_handler))
115
+ @configuration_fetched_invoker.register_start_stop_push(proc do |args|
116
+ start_or_stop_push_updated_listener unless args.fetcher_status == FetcherStatus::ERROR_FETCHED_FAILED
117
+ end)
118
+
119
+ @configuration_fetched_invoker.register_fetched_handler(&configuration_fetched_handler)
112
120
 
113
121
  @thread = Thread.new do
114
122
  Thread.current.report_on_exception = false if Thread.current.respond_to?(:report_on_exception)
@@ -189,13 +197,6 @@ module Rox
189
197
  @custom_property_repository.add_custom_property_if_not_exists(property)
190
198
  end
191
199
 
192
- def wrap_configuration_fetched_handler(&handler)
193
- lambda do |args|
194
- start_or_stop_push_updated_listener unless args.fetcher_status == FetcherStatus::ERROR_FETCHED_FAILED
195
- handler&.call(args)
196
- end
197
- end
198
-
199
200
  def start_or_stop_push_updated_listener
200
201
  if @internal_flags.enabled?('rox.internal.pushUpdates')
201
202
  if @push_updates_listener.nil?
@@ -229,6 +230,12 @@ module Rox
229
230
  raise ArgumentError, 'Illegal Rollout api key'
230
231
  end
231
232
  end
233
+
234
+ private
235
+
236
+ def create_analytics_client(device_properties)
237
+ Rox::Core::Analytics::Client.new(device_properties)
238
+ end
232
239
  end
233
240
  end
234
241
  end
@@ -26,7 +26,9 @@ module Rox
26
26
 
27
27
  def value(context = nil)
28
28
  merged_context = MergedContext.new(@parser&.global_context, context)
29
- internal_value(merged_context, false)
29
+ return_value = internal_value(merged_context, false)
30
+ send_impressions(return_value, merged_context)
31
+ return_value
30
32
  end
31
33
 
32
34
  def internal_enabled?(context, nil_instead_of_default = false)
@@ -4,7 +4,9 @@ module Rox
4
4
  module Core
5
5
  class RoxDouble < RoxString
6
6
  def value(context = nil)
7
- internal_value(context, false, Float)
7
+ return_value = internal_value(context, false, Float)
8
+ send_impressions(return_value, context)
9
+ return_value
8
10
  end
9
11
  end
10
12
  end
@@ -4,7 +4,9 @@ module Rox
4
4
  module Core
5
5
  class RoxInt < RoxString
6
6
  def value(context = nil)
7
- internal_value(context, false, Integer)
7
+ return_value = internal_value(context, false, Integer)
8
+ send_impressions(return_value, context)
9
+ return_value
8
10
  end
9
11
  end
10
12
  end
@@ -43,7 +43,9 @@ module Rox
43
43
  end
44
44
 
45
45
  def value(context = nil)
46
- internal_value(context, false)
46
+ return_value = internal_value(context, false)
47
+ send_impressions(return_value, context)
48
+ return_value
47
49
  end
48
50
 
49
51
  def internal_value(context, nil_instead_of_default, evaluated_type = String)
@@ -59,7 +61,6 @@ module Rox
59
61
  end
60
62
  end
61
63
 
62
- send_impressions(return_value, merged_context)
63
64
  return_value
64
65
  end
65
66
  end
@@ -1,10 +1,11 @@
1
1
  module Rox
2
2
  module Core
3
3
  class UserspaceHandlerException < StandardError
4
- def initialize(exception_source, exception_trigger, exception)
4
+ attr_accessor :exception_source, :exception_trigger, :original_exception
5
+ def initialize(exception_source, exception_trigger, original_exception)
5
6
  @exception_source = exception_source
6
7
  @exception_trigger = exception_trigger
7
- @exception = exception
8
+ @original_exception = original_exception
8
9
  super('user unhandled exception in roxx expression')
9
10
  end
10
11
  end
@@ -18,7 +18,7 @@ module Rox
18
18
  @mutex = Mutex.new
19
19
  end
20
20
 
21
- def invoke(reporting_value, stickiness_property, context)
21
+ def call_analytics_gateway(reporting_value, stickiness_property, context)
22
22
  begin
23
23
  analytics_enabled = @internal_flags.enabled?('rox.internal.analytics')
24
24
  if analytics_enabled && !@is_roxy
@@ -29,9 +29,9 @@ module Rox
29
29
  distinct_id = prop_value if prop_value.instance_of?(String)
30
30
  end
31
31
 
32
- event_time = (Time.now.to_f * 1000.0).to_i
32
+ event_time = DateTime.now.strftime('%Q').to_i
33
33
  begin
34
- event_time = ENV['rox.analytics.ms'].to_i
34
+ event_time = ENV['rox.analytics.ms'].to_i if ENV['rox.analytics.ms']
35
35
  rescue StandardError
36
36
  end
37
37
 
@@ -39,7 +39,6 @@ module Rox
39
39
  flag: reporting_value.name,
40
40
  value: reporting_value.value,
41
41
  distinctId: distinct_id,
42
- experimentVersion: '0',
43
42
  type: 'IMPRESSION',
44
43
  time: event_time
45
44
  })
@@ -47,7 +46,10 @@ module Rox
47
46
  rescue StandardError => ex
48
47
  Logging.logger.error('Failed to send analytics', ex)
49
48
  end
49
+ end
50
50
 
51
+ def invoke(reporting_value, stickiness_property, context)
52
+ call_analytics_gateway(reporting_value, stickiness_property, context)
51
53
  raise_impression_event(ImpressionArgs.new(reporting_value, context))
52
54
  end
53
55
 
@@ -63,11 +65,13 @@ module Rox
63
65
  handlers = @impression_handlers.clone
64
66
  end
65
67
 
66
- begin
67
- handlers.each { |handler| handler.call(args) }
68
- rescue StandardError => e
69
- user_unhandled_error_invoker.invoke(handler, ExceptionTrigger::IMPRESSION_HANDLER, e)
70
- Logging.logger.error('Impresssion handler exception', ex)
68
+ handlers.each do |handler|
69
+ begin
70
+ handler.call(args)
71
+ rescue StandardError => e
72
+ @user_unhandled_error_invoker.invoke(handler, ExceptionTrigger::IMPRESSION_HANDLER, e)
73
+ Logging.logger.error('Impresssion handler exception', e)
74
+ end
71
75
  end
72
76
  end
73
77
  end
@@ -83,9 +83,10 @@ module Rox
83
83
 
84
84
  def self.state_payload(seralized_flag_repository, serialized_custom_property_repository, device_properties, dev_mode_secret)
85
85
  values = {}
86
+ # be careful adding here properties, md5 signature uses all props
87
+ # to add more payload data, please change the md5_signature to use only the relevant properties (the current ones creating the payload)
86
88
  values[PropertyType::APP_KEY.name] = device_properties.all_properties[PropertyType::APP_KEY.name]
87
89
  values[PropertyType::PLATFORM.name] = device_properties.all_properties[PropertyType::PLATFORM.name]
88
- values.merge!(device_properties.all_properties)
89
90
  values.merge!({ feature_flags: seralized_flag_repository })
90
91
  values.merge!({ custom_properties: serialized_custom_property_repository })
91
92
  values['devModeSecret'] = dev_mode_secret
@@ -1,7 +1,7 @@
1
1
  module Rox
2
2
  module Core
3
3
  class CustomProperty
4
- attr_accessor :name, :type
4
+ attr_accessor :name, :type, :block
5
5
 
6
6
  def initialize(name, type, value = nil, &block)
7
7
  @name = name
@@ -38,13 +38,13 @@ module Rox
38
38
  def get_value_from_dynamic_property_rule_handler(prop_name, context)
39
39
  @dynamic_property_rule_handler.call(prop_name, context)
40
40
  rescue StandardError => e
41
- raise Rox::Core::UserspaceHandlerException, handler, ExceptionTrigger::DYNAMIC_PROPERTIES_RULE, e
41
+ raise Rox::Core::UserspaceHandlerException.new(@dynamic_property_rule_handler, ExceptionTrigger::DYNAMIC_PROPERTIES_RULE, e)
42
42
  end
43
43
 
44
44
  def get_value_from_property(property, context)
45
45
  property.value(context)
46
46
  rescue StandardError => e
47
- raise Rox::Core::UserspaceHandlerException, handler, ExceptionTrigger::CUSTOM_PROPERTY_GENERATOR, e
47
+ raise Rox::Core::UserspaceHandlerException.new(property.block, ExceptionTrigger::CUSTOM_PROPERTY_GENERATOR, e)
48
48
  end
49
49
  end
50
50
  end
@@ -50,7 +50,7 @@ module Rox
50
50
 
51
51
  EvaluationResult.new(result)
52
52
  rescue Rox::Core::UserspaceHandlerException => e
53
- @user_unhandled_error_invoker.invoke(e.exception_source, e.exception_trigger, e.exception)
53
+ @user_unhandled_error_invoker.invoke(e.exception_source, e.exception_trigger, e.original_exception)
54
54
  Logging.logger.warn("Roxx Exception: Failed evaluate expression, user unhandled expression: #{e}")
55
55
  EvaluationResult.new(nil)
56
56
  rescue StandardError => e
@@ -45,6 +45,10 @@ module Rox
45
45
  @core.register(rox_container)
46
46
  end
47
47
 
48
+ def register_with_namespace(namespace, rox_container)
49
+ @core.register_with_namespace(namespace, rox_container)
50
+ end
51
+
48
52
  def use_userspace_unhandled_error_handler(&handler)
49
53
  @core.userspace_unhandled_error_handler = handler
50
54
  end
data/lib/rox/version.rb CHANGED
@@ -1,3 +1,3 @@
1
1
  module Rox
2
- VERSION = '5.0.0'.freeze
2
+ VERSION = '5.1.0'.freeze
3
3
  end
data/rox.gemspec CHANGED
@@ -23,10 +23,8 @@ Gem::Specification.new do |spec|
23
23
  spec.required_ruby_version = '>= 2.3'
24
24
 
25
25
  spec.add_runtime_dependency 'em-eventsource', '~> 0.3.2'
26
- spec.add_runtime_dependency 'analytics-ruby', '~> 2.0.0'
27
26
 
28
-
29
- spec.add_development_dependency 'bundler', '~> 2.2.3'
27
+ spec.add_development_dependency 'bundler', '~> 2.3.3'
30
28
  spec.add_development_dependency 'minitest', '~> 5.11'
31
29
  spec.add_development_dependency 'pry-byebug', '~> 3.7.0'
32
30
  spec.add_development_dependency 'rake', '~> 12.3'