unleash 0.1.6 → 3.2.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.
@@ -3,7 +3,8 @@ require 'tmpdir'
3
3
 
4
4
  module Unleash
5
5
  class Configuration
6
- attr_accessor :url,
6
+ attr_accessor \
7
+ :url,
7
8
  :app_name,
8
9
  :environment,
9
10
  :instance_id,
@@ -19,37 +20,12 @@ module Unleash
19
20
  :log_level
20
21
 
21
22
  def initialize(opts = {})
22
- self.app_name = opts[:app_name] || nil
23
- self.environment = opts[:environment] || 'default'
24
- self.url = opts[:url] || nil
25
- self.instance_id = opts[:instance_id] || SecureRandom.uuid
26
-
27
- if opts[:custom_http_headers].is_a?(Hash) || opts[:custom_http_headers].nil?
28
- self.custom_http_headers = opts[:custom_http_headers] || {}
29
- else
30
- raise ArgumentError, "custom_http_headers must be a hash."
31
- end
32
- self.disable_client = opts[:disable_client] || false
33
- self.disable_metrics = opts[:disable_metrics] || false
34
- self.refresh_interval = opts[:refresh_interval] || 15
35
- self.metrics_interval = opts[:metrics_interval] || 10
36
- self.timeout = opts[:timeout] || 30
37
- self.retry_limit = opts[:retry_limit] || 1
38
-
39
- self.backup_file = opts[:backup_file] || nil
23
+ ensure_valid_opts(opts)
24
+ set_defaults
40
25
 
41
- self.logger = opts[:logger] || Logger.new(STDOUT)
42
- self.log_level = opts[:log_level] || Logger::WARN
43
-
44
-
45
- if opts[:logger].nil?
46
- # on default logger, use custom formatter that includes thread_name:
47
- self.logger.formatter = proc do |severity, datetime, progname, msg|
48
- thread_name = (Thread.current[:name] || "Unleash").rjust(16, ' ')
49
- "[#{datetime.iso8601(6)} #{thread_name} #{severity.ljust(5, ' ')}] : #{msg}\n"
50
- end
51
- end
26
+ initialize_default_logger if opts[:logger].nil?
52
27
 
28
+ merge(opts)
53
29
  refresh_backup_file!
54
30
  end
55
31
 
@@ -60,17 +36,15 @@ module Unleash
60
36
  def validate!
61
37
  return if self.disable_client
62
38
 
63
- raise ArgumentError, "URL and app_name are required parameters." if self.app_name.nil? or self.url.nil?
39
+ raise ArgumentError, "URL and app_name are required parameters." if self.app_name.nil? || self.url.nil?
64
40
  raise ArgumentError, "custom_http_headers must be a hash." unless self.custom_http_headers.is_a?(Hash)
65
41
  end
66
42
 
67
43
  def refresh_backup_file!
68
- if self.backup_file.nil?
69
- self.backup_file = Dir.tmpdir + "/unleash-#{app_name}-repo.json"
70
- end
44
+ self.backup_file = Dir.tmpdir + "/unleash-#{app_name}-repo.json" if self.backup_file.nil?
71
45
  end
72
46
 
73
- def get_http_headers
47
+ def http_headers
74
48
  {
75
49
  'UNLEASH-INSTANCEID' => self.instance_id,
76
50
  'UNLEASH-APPNAME' => self.app_name
@@ -89,5 +63,50 @@ module Unleash
89
63
  self.url + '/client/register'
90
64
  end
91
65
 
66
+ private
67
+
68
+ def ensure_valid_opts(opts)
69
+ unless opts[:custom_http_headers].is_a?(Hash) || opts[:custom_http_headers].nil?
70
+ raise ArgumentError, "custom_http_headers must be a hash."
71
+ end
72
+ end
73
+
74
+ def set_defaults
75
+ self.app_name = nil
76
+ self.environment = 'default'
77
+ self.url = nil
78
+ self.instance_id = SecureRandom.uuid
79
+ self.disable_client = false
80
+ self.disable_metrics = false
81
+ self.refresh_interval = 15
82
+ self.metrics_interval = 10
83
+ self.timeout = 30
84
+ self.retry_limit = 1
85
+ self.backup_file = nil
86
+ self.log_level = Logger::WARN
87
+
88
+ self.custom_http_headers = {}
89
+ end
90
+
91
+ def initialize_default_logger
92
+ self.logger = Logger.new(STDOUT)
93
+
94
+ # on default logger, use custom formatter that includes thread_name:
95
+ self.logger.formatter = proc do |severity, datetime, _progname, msg|
96
+ thread_name = (Thread.current[:name] || "Unleash").rjust(16, ' ')
97
+ "[#{datetime.iso8601(6)} #{thread_name} #{severity.ljust(5, ' ')}] : #{msg}\n"
98
+ end
99
+ end
100
+
101
+ def merge(opts)
102
+ opts.each_pair{ |opt, val| set_option(opt, val) }
103
+ self
104
+ end
105
+
106
+ def set_option(opt, val)
107
+ __send__("#{opt}=", val)
108
+ rescue NoMethodError
109
+ raise ArgumentError, "unknown configuration parameter '#{val}'"
110
+ end
92
111
  end
93
112
  end
@@ -1,23 +1,34 @@
1
1
  module Unleash
2
-
3
2
  class Context
4
3
  attr_accessor :app_name, :environment, :user_id, :session_id, :remote_address, :properties
5
4
 
6
5
  def initialize(params = {})
7
6
  raise ArgumentError, "Unleash::Context must be initialized with a hash." unless params.is_a?(Hash)
8
7
 
9
- self.app_name = params.values_at('appName', :app_name).compact.first || ( !Unleash.configuration.nil? ? Unleash.configuration.app_name : nil )
10
- self.environment = params.values_at('environment', :environment).compact.first || ( !Unleash.configuration.nil? ? Unleash.configuration.environment : 'default' )
11
- self.user_id = params.values_at('userId', :user_id).compact.first || ''
12
- self.session_id = params.values_at('sessionId', :session_id).compact.first || ''
13
- self.remote_address = params.values_at('remoteAddress', :remote_address).compact.first || ''
8
+ self.app_name = value_for('appName', params, Unleash&.configuration&.app_name)
9
+ self.environment = value_for('environment', params, Unleash&.configuration&.environment || 'default')
10
+ self.user_id = value_for('userId', params)
11
+ self.session_id = value_for('sessionId', params)
12
+ self.remote_address = value_for('remoteAddress', params)
14
13
 
15
- properties = params.values_at('properties', :properties).compact.first
14
+ properties = value_for('properties', params)
16
15
  self.properties = properties.is_a?(Hash) ? properties : {}
17
16
  end
18
17
 
19
18
  def to_s
20
19
  "<Context: user_id=#{self.user_id},session_id=#{self.session_id},remote_address=#{self.remote_address},properties=#{self.properties}>"
21
20
  end
21
+
22
+ private
23
+
24
+ # Method to fetch values from hash for two types of keys: string in camelCase and symbol in snake_case
25
+ def value_for(key, params, default_value = '')
26
+ params.values_at(key, underscore(key).to_sym).compact.first || default_value
27
+ end
28
+
29
+ # converts CamelCase to snake_case
30
+ def underscore(camel_cased_word)
31
+ camel_cased_word.to_s.gsub(/(.)([A-Z])/, '\1_\2').downcase
32
+ end
22
33
  end
23
34
  end
@@ -8,64 +8,53 @@ module Unleash
8
8
  class FeatureToggle
9
9
  attr_accessor :name, :enabled, :strategies, :variant_definitions
10
10
 
11
- def initialize(params={})
11
+ def initialize(params = {})
12
12
  params = {} if params.nil?
13
13
 
14
- self.name = params.fetch('name', nil)
15
- self.enabled = params.fetch('enabled', false)
16
-
14
+ self.name = params.fetch('name', nil)
15
+ self.enabled = params.fetch('enabled', false)
17
16
  self.strategies = params.fetch('strategies', [])
18
- .select{ |s| ( s.key?('name') && Unleash::STRATEGIES.key?(s['name'].to_sym) ) }
17
+ .select{ |s| s.has_key?('name') && Unleash::STRATEGIES.has_key?(s['name'].to_sym) }
19
18
  .map{ |s| ActivationStrategy.new(s['name'], s['parameters'] || {}) } || []
20
19
 
21
20
  self.variant_definitions = (params.fetch('variants', []) || [])
22
- .select{ |v| v.is_a?(Hash) && v.key?('name') }
23
- .map{ |v|
21
+ .select{ |v| v.is_a?(Hash) && v.has_key?('name') }
22
+ .map do |v|
24
23
  VariantDefinition.new(
25
24
  v.fetch('name', ''),
26
25
  v.fetch('weight', 0),
27
26
  v.fetch('payload', nil),
28
27
  v.fetch('overrides', [])
29
28
  )
30
- } || []
29
+ end || []
31
30
  end
32
31
 
33
32
  def to_s
34
- "<FeatureToggle: name=#{self.name},enabled=#{self.enabled},strategies=#{self.strategies},variant_definitions=#{self.variant_definitions}>"
33
+ "<FeatureToggle: name=#{name},enabled=#{enabled},strategies=#{strategies},variant_definitions=#{variant_definitions}>"
35
34
  end
36
35
 
37
36
  def is_enabled?(context, default_result)
38
- unless ['NilClass', 'Unleash::Context'].include? context.class.name
39
- Unleash.logger.error "Provided context is not of the correct type #{context.class.name}, please use Unleash::Context. Context set to nil."
40
- context = nil
41
- end
42
-
43
37
  result = am_enabled?(context, default_result)
44
38
 
45
39
  choice = result ? :yes : :no
46
40
  Unleash.toggle_metrics.increment(name, choice) unless Unleash.configuration.disable_metrics
47
41
 
48
- return result
42
+ result
49
43
  end
50
44
 
51
45
  def get_variant(context, fallback_variant = disabled_variant)
52
- unless ['NilClass', 'Unleash::Context'].include? context.class.name
53
- Unleash.logger.error "Provided context is not of the correct type #{context.class.name}, please use Unleash::Context. Context set to nil."
54
- context = nil
55
- end
46
+ raise ArgumentError, "Provided fallback_variant is not of type Unleash::Variant" if fallback_variant.class.name != 'Unleash::Variant'
56
47
 
57
- unless ['Unleash::Variant'].include? fallback_variant.class.name
58
- raise ArgumentError, "Provided fallback_variant is not of the correct type #{fallback_variant.class.name}, please use Unleash::Variant."
59
- end
48
+ context = ensure_valid_context(context)
60
49
 
61
50
  return disabled_variant unless self.enabled && am_enabled?(context, true)
62
- return disabled_variant if get_sum_variant_defs_weights <= 0
51
+ return disabled_variant if sum_variant_defs_weights <= 0
63
52
 
64
53
  variant = variant_from_override_match(context)
65
54
  variant = variant_from_weights(context) if variant.nil?
66
55
 
67
56
  Unleash.toggle_metrics.increment_variant(self.name, variant.name) unless Unleash.configuration.disable_metrics
68
- return variant
57
+ variant
69
58
  end
70
59
 
71
60
  private
@@ -75,56 +64,68 @@ module Unleash
75
64
  result =
76
65
  if self.enabled
77
66
  self.strategies.empty? ||
78
- self.strategies.select{ |s|
79
- strategy = Unleash::STRATEGIES.fetch(s.name.to_sym, :unknown)
80
- r = strategy.is_enabled?(s.params, context)
81
- Unleash.logger.debug "Strategy #{s.name} returned #{r} with context: #{context}" #"for params #{s.params} "
82
- r
83
- }.any?
67
+ self.strategies.any?{ |s| strategy_enabled?(s, context) }
84
68
  else
85
69
  default_result
86
70
  end
87
71
 
88
- Unleash.logger.debug "FeatureToggle (enabled:#{self.enabled} default_result:#{default_result} and Strategies combined returned #{result})"
89
- return result
72
+ Unleash.logger.debug "FeatureToggle (enabled:#{self.enabled} default_result:#{default_result} " \
73
+ "and Strategies combined returned #{result})"
74
+
75
+ result
76
+ end
77
+
78
+ def strategy_enabled?(strategy, context)
79
+ r = Unleash::STRATEGIES.fetch(strategy.name.to_sym, :unknown).is_enabled?(strategy.params, context)
80
+ Unleash.logger.debug "Strategy #{strategy.name} returned #{r} with context: #{context}" # "for params #{strategy.params} "
81
+ r
90
82
  end
91
83
 
92
84
  def disabled_variant
93
85
  Unleash::Variant.new(name: 'disabled', enabled: false)
94
86
  end
95
87
 
96
- def get_sum_variant_defs_weights
97
- self.variant_definitions.map{ |v| v.weight }.reduce(0, :+)
88
+ def sum_variant_defs_weights
89
+ self.variant_definitions.map(&:weight).reduce(0, :+)
98
90
  end
99
91
 
100
92
  def variant_salt(context)
101
93
  return context.user_id unless context.user_id.to_s.empty?
102
94
  return context.session_id unless context.session_id.to_s.empty?
103
95
  return context.remote_address unless context.remote_address.to_s.empty?
104
- return SecureRandom.random_number
96
+
97
+ SecureRandom.random_number
105
98
  end
106
99
 
107
100
  def variant_from_override_match(context)
108
- variant = self.variant_definitions.select{ |vd| vd.override_matches_context?(context) }.first
109
-
101
+ variant = self.variant_definitions.find{ |vd| vd.override_matches_context?(context) }
110
102
  return nil if variant.nil?
103
+
111
104
  Unleash::Variant.new(name: variant.name, enabled: true, payload: variant.payload)
112
105
  end
113
106
 
114
107
  def variant_from_weights(context)
115
- variant_weight = Unleash::Strategy::Util.get_normalized_number(variant_salt(context), self.name, get_sum_variant_defs_weights())
108
+ variant_weight = Unleash::Strategy::Util.get_normalized_number(variant_salt(context), self.name, sum_variant_defs_weights)
116
109
  prev_weights = 0
117
110
 
118
111
  variant_definition = self.variant_definitions
119
- .select{ |v|
112
+ .find do |v|
120
113
  res = (prev_weights + v.weight >= variant_weight)
121
114
  prev_weights += v.weight
122
115
  res
123
- }
124
- .first
116
+ end
125
117
  return disabled_variant if variant_definition.nil?
126
118
 
127
119
  Unleash::Variant.new(name: variant_definition.name, enabled: true, payload: variant_definition.payload)
128
120
  end
121
+
122
+ def ensure_valid_context(context)
123
+ unless ['NilClass', 'Unleash::Context'].include? context.class.name
124
+ Unleash.logger.error "Provided context is not of the correct type #{context.class.name}, " \
125
+ "please use Unleash::Context. Context set to nil."
126
+ context = nil
127
+ end
128
+ context
129
+ end
129
130
  end
130
131
  end
@@ -1,5 +1,4 @@
1
1
  module Unleash
2
-
3
2
  class Metrics
4
3
  attr_accessor :features
5
4
 
@@ -16,13 +15,13 @@ module Unleash
16
15
  def increment(feature, choice)
17
16
  raise "InvalidArgument choice must be :yes or :no" unless [:yes, :no].include? choice
18
17
 
19
- self.features[feature] = {yes: 0, no: 0} unless self.features.include? feature
18
+ self.features[feature] = { yes: 0, no: 0 } unless self.features.include? feature
20
19
  self.features[feature][choice] += 1
21
20
  end
22
21
 
23
22
  def increment_variant(feature, variant)
24
- self.features[feature] = {yes: 0, no: 0} unless self.features.include? feature
25
- self.features[feature]['variant'] = {} unless self.features[feature].include? 'variant'
23
+ self.features[feature] = { yes: 0, no: 0 } unless self.features.include? feature
24
+ self.features[feature]['variant'] = {} unless self.features[feature].include? 'variant'
26
25
  self.features[feature]['variant'][variant] = 0 unless self.features[feature]['variant'].include? variant
27
26
  self.features[feature]['variant'][variant] += 1
28
27
  end
@@ -5,7 +5,6 @@ require 'json'
5
5
  require 'time'
6
6
 
7
7
  module Unleash
8
-
9
8
  class MetricsReporter
10
9
  attr_accessor :last_time
11
10
 
@@ -15,7 +14,11 @@ module Unleash
15
14
 
16
15
  def generate_report
17
16
  now = Time.now
18
- start, stop, self.last_time = self.last_time, now, now
17
+
18
+ start = self.last_time
19
+ stop = now
20
+ self.last_time = now
21
+
19
22
  report = {
20
23
  'appName': Unleash.configuration.app_name,
21
24
  'instanceId': Unleash.configuration.instance_id,
@@ -25,37 +28,21 @@ module Unleash
25
28
  'toggles': Unleash.toggle_metrics.features
26
29
  }
27
30
  }
28
-
29
31
  Unleash.toggle_metrics.reset
30
- return report
32
+
33
+ report
31
34
  end
32
35
 
33
36
  def send
34
37
  Unleash.logger.debug "send() Report"
35
38
 
36
- generated_report = self.generate_report()
39
+ response = Unleash::Util::Http.post(Unleash.configuration.client_metrics_url, self.generate_report.to_json)
37
40
 
38
- uri = URI(Unleash.configuration.client_metrics_url)
39
- http = Net::HTTP.new(uri.host, uri.port)
40
- http.use_ssl = true if uri.scheme == 'https'
41
- http.open_timeout = Unleash.configuration.timeout # in seconds
42
- http.read_timeout = Unleash.configuration.timeout # in seconds
43
-
44
- headers = (Unleash.configuration.get_http_headers || {}).dup
45
- headers['Content-Type'] = 'application/json'
46
- request = Net::HTTP::Post.new(uri.request_uri, headers)
47
- request.body = generated_report.to_json
48
-
49
- Unleash.logger.debug "Report to send: #{request.body}"
50
-
51
- response = http.request(request)
52
-
53
- if ['200','202'].include? response.code
41
+ if ['200', '202'].include? response.code
54
42
  Unleash.logger.debug "Report sent to unleash server sucessfully. Server responded with http code #{response.code}"
55
43
  else
56
44
  Unleash.logger.error "Error when sending report to unleash server. Server responded with http code #{response.code}."
57
45
  end
58
-
59
46
  end
60
47
  end
61
48
  end
@@ -1,5 +1,4 @@
1
1
  module Unleash
2
-
3
2
  class ScheduledExecutor
4
3
  attr_accessor :name, :interval, :max_exceptions, :retry_count, :thread
5
4
 
@@ -15,26 +14,20 @@ module Unleash
15
14
  self.thread = Thread.new do
16
15
  Thread.current[:name] = self.name
17
16
 
17
+ Unleash.logger.debug "thread #{name} loop starting"
18
18
  loop do
19
19
  Unleash.logger.debug "thread #{name} sleeping for #{interval} seconds"
20
20
  sleep interval
21
21
 
22
- Unleash.logger.debug "thread #{name} started"
23
- begin
24
- yield
25
- self.retry_count = 0
26
- rescue Exception => e
27
- self.retry_count += 1
28
- Unleash.logger.error "thread #{name} threw exception #{e.class}:'#{e}' (#{self.retry_count}/#{self.max_exceptions})"
29
- Unleash.logger.error "stacktrace: #{e.backtrace}"
30
- end
22
+ run_blk{ blk.call }
31
23
 
32
- if self.retry_count > self.max_exceptions
33
- Unleash.logger.error "thread #{name} retry_count (#{self.retry_count}) exceeded max_exceptions (#{self.max_exceptions}). Stopping with retries."
24
+ if exceeded_max_exceptions?
25
+ Unleash.logger.error "thread #{name} retry_count (#{self.retry_count}) exceeded " \
26
+ "max_exceptions (#{self.max_exceptions}). Stopping with retries."
34
27
  break
35
28
  end
36
29
  end
37
- Unleash.logger.warn "thread #{name} loop ended"
30
+ Unleash.logger.debug "thread #{name} loop ended"
38
31
  end
39
32
  end
40
33
 
@@ -47,7 +40,27 @@ module Unleash
47
40
  Unleash.logger.warn "thread #{name} will exit!"
48
41
  self.thread.exit
49
42
  self.thread.join if self.running?
43
+ else
44
+ Unleash.logger.info "thread #{name} was already stopped!"
50
45
  end
51
46
  end
47
+
48
+ private
49
+
50
+ def run_blk(&blk)
51
+ Unleash.logger.debug "thread #{name} starting execution"
52
+
53
+ yield(blk)
54
+ self.retry_count = 0
55
+ rescue StandardError => e
56
+ self.retry_count += 1
57
+ Unleash.logger.error "thread #{name} threw exception #{e.class} " \
58
+ " (#{self.retry_count}/#{self.max_exceptions}): '#{e}'"
59
+ Unleash.logger.debug "stacktrace: #{e.backtrace}"
60
+ end
61
+
62
+ def exceeded_max_exceptions?
63
+ self.retry_count > self.max_exceptions
64
+ end
52
65
  end
53
66
  end