statsig 1.6.0 → 1.7.0.beta.1

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: a1985e842f0301387596fbd3d8db298ce485c013a502c09a1aff00916882afc0
4
- data.tar.gz: 9cf814e0def7dc3fefd2db9d80f102ec318a41703e6638f66b8a8d844c751959
3
+ metadata.gz: cf5fda9f980ecae86129f6ea0bd4052b6af45cba14c83e38062b495cd06cfeb1
4
+ data.tar.gz: 5b1a231a8af145fec2303fc89998f3e9acf05323cbc55d9fea3850ba841ecd1e
5
5
  SHA512:
6
- metadata.gz: ba2661bcd29ca31b3c458f8046add904029b03ecc0eeeb0c358760467389e58c5f48b5bc39456df1ea2e30051df951359a37e1045283a5d77f911291b0f7bfcd
7
- data.tar.gz: 2631c284b8b792101558e90689353c79aa043a22cbf2f995f045efc4333f74d3d80fcca639ece7f11d734cc4a39b459edd08ce14f6603f86f09ad57afadf2c08
6
+ metadata.gz: 7f1c7cf9a5a549021f02c16bac3d1095143ee9231a3f4165db9040efbf9b16cef083b6955dc5f748dd5fcfe12953064e8c48874a1eb8f477c24f61d061a57890
7
+ data.tar.gz: a5f291bd14edb61286416c293eae1c14f3c519dc1c6548356fe7147667cc33ed6b327488412e2f0b5516139d71d06bacf0db3f6ee907685be9c7e8ca2cc6b119
data/lib/config_result.rb CHANGED
@@ -3,11 +3,13 @@ class ConfigResult
3
3
  attr_accessor :gate_value
4
4
  attr_accessor :json_value
5
5
  attr_accessor :rule_id
6
+ attr_accessor :secondary_exposures
6
7
 
7
- def initialize(name, gate_value = false, json_value = {}, rule_id = '')
8
+ def initialize(name, gate_value = false, json_value = {}, rule_id = '', secondary_exposures = [])
8
9
  @name = name
9
10
  @gate_value = gate_value
10
11
  @json_value = json_value
11
12
  @rule_id = rule_id
13
+ @secondary_exposures = secondary_exposures.is_a?(Array) ? secondary_exposures : []
12
14
  end
13
15
  end
@@ -6,19 +6,11 @@ module EvaluationHelpers
6
6
  func.call(a.to_f, b.to_f) rescue false
7
7
  end
8
8
 
9
- # returns true if array contains value, ignoring case when comparing strings
10
- def self.array_contains(array, value, ignore_case)
11
- return false unless array.is_a?(Array) && !value.nil?
12
- if value.is_a?(String) && match_string_in_array(array, value, ignore_case, ->(a, b) { a == b })
13
- return true
14
- end
15
- return array.include?(value)
16
- end
17
-
18
9
  # returns true if array has any element that evaluates to true with value using func lambda, ignoring case
19
10
  def self.match_string_in_array(array, value, ignore_case, func)
20
- return false unless array.is_a?(Array) && value.is_a?(String)
21
- array.any?{ |s| s.is_a?(String) && ((ignore_case && func.call(value.downcase, s.downcase)) || func.call(value, s)) } rescue false
11
+ return false unless array.is_a?(Array) && !value.nil?
12
+ str_value = value.to_s
13
+ array.any?{ |s| !s.nil? && ((ignore_case && func.call(str_value.downcase, s.to_s.downcase)) || func.call(str_value, s.to_s)) } rescue false
22
14
  end
23
15
 
24
16
  def self.compare_times(a, b, func)
data/lib/evaluator.rb CHANGED
@@ -31,37 +31,53 @@ class Evaluator
31
31
  private
32
32
 
33
33
  def eval_spec(user, config)
34
+ default_rule_id = 'default'
35
+ exposures = []
34
36
  if config['enabled']
35
37
  i = 0
36
38
  until i >= config['rules'].length do
37
39
  rule = config['rules'][i]
38
40
  result = self.eval_rule(user, rule)
39
41
  return $fetch_from_server if result == $fetch_from_server
40
- if result
42
+ exposures = exposures + result['exposures'] if result['exposures'].is_a? Array
43
+ if result['value']
41
44
  pass = self.eval_pass_percent(user, rule, config['salt'])
42
45
  return ConfigResult.new(
43
46
  config['name'],
44
47
  pass,
45
48
  pass ? rule['returnValue'] : config['defaultValue'],
46
49
  rule['id'],
50
+ exposures
47
51
  )
48
52
  end
49
53
 
50
54
  i += 1
51
55
  end
56
+ elsif (default_rule_id = 'disabled')
52
57
  end
53
58
 
54
- ConfigResult.new(config['name'], false, config['defaultValue'], 'default')
59
+ ConfigResult.new(config['name'], false, config['defaultValue'], default_rule_id, exposures)
55
60
  end
56
61
 
57
62
  def eval_rule(user, rule)
63
+ exposures = []
64
+ pass = true
58
65
  i = 0
59
66
  until i >= rule['conditions'].length do
60
67
  result = self.eval_condition(user, rule['conditions'][i])
61
- return result unless result == true
68
+ if result == $fetch_from_server
69
+ return $fetch_from_server
70
+ end
71
+
72
+ if result.is_a?(Hash)
73
+ exposures = exposures + result['exposures'] if result['exposures'].is_a? Array
74
+ pass = false if result['value'] == false
75
+ elsif result == false
76
+ pass = false
77
+ end
62
78
  i += 1
63
79
  end
64
- true
80
+ { 'value' => pass, 'exposures' => exposures }
65
81
  end
66
82
 
67
83
  def eval_condition(user, condition)
@@ -82,7 +98,18 @@ class Evaluator
82
98
  when 'fail_gate', 'pass_gate'
83
99
  other_gate_result = self.check_gate(user, target)
84
100
  return $fetch_from_server if other_gate_result == $fetch_from_server
85
- return type == 'pass_gate' ? other_gate_result.gate_value : !other_gate_result.gate_value
101
+
102
+ gate_value = other_gate_result&.gate_value == true
103
+ new_exposure = {
104
+ 'gate' => target,
105
+ 'gateValue' => gate_value ? 'true' : 'false',
106
+ 'ruleID' => other_gate_result&.rule_id
107
+ }
108
+ exposures = other_gate_result&.secondary_exposures&.append(new_exposure)
109
+ return {
110
+ 'value' => type == 'pass_gate' ? gate_value : !gate_value,
111
+ 'exposures' => exposures
112
+ }
86
113
  when 'ip_based'
87
114
  value = get_value_from_user(user, field) || get_value_from_ip(user, field)
88
115
  return $fetch_from_server if value == $fetch_from_server
@@ -138,13 +165,13 @@ class Evaluator
138
165
 
139
166
  # array operations
140
167
  when 'any'
141
- return EvaluationHelpers::array_contains(target, value, true)
168
+ return EvaluationHelpers::match_string_in_array(target, value, true, ->(a, b) { a == b })
142
169
  when 'none'
143
- return !EvaluationHelpers::array_contains(target, value, true)
170
+ return !EvaluationHelpers::match_string_in_array(target, value, true, ->(a, b) { a == b })
144
171
  when 'any_case_sensitive'
145
- return EvaluationHelpers::array_contains(target, value, false)
172
+ return EvaluationHelpers::match_string_in_array(target, value, false, ->(a, b) { a == b })
146
173
  when 'none_case_sensitive'
147
- return !EvaluationHelpers::array_contains(target, value, false)
174
+ return !EvaluationHelpers::match_string_in_array(target, value, false, ->(a, b) { a == b })
148
175
 
149
176
  #string
150
177
  when 'str_starts_with_any'
data/lib/network.rb CHANGED
@@ -24,14 +24,14 @@ class Network
24
24
  }).accept(:json)
25
25
  begin
26
26
  res = http.post(@api + endpoint, body: body)
27
- rescue
27
+ rescue StandardError => e
28
28
  ## network error retry
29
- return nil unless retries > 0
29
+ return nil, e unless retries > 0
30
30
  sleep backoff
31
31
  return post_helper(endpoint, body, retries - 1, backoff * @backoff_multiplier)
32
32
  end
33
- return res unless !res.status.success?
34
- return nil unless retries > 0 && $retry_codes.include?(res.code)
33
+ return res, nil unless !res.status.success?
34
+ return nil, StandardError.new("Got an exception when making request to #{@api + endpoint}: #{res.to_s}") unless retries > 0 && $retry_codes.include?(res.code)
35
35
  ## status code retry
36
36
  sleep backoff
37
37
  post_helper(endpoint, body, retries - 1, backoff * @backoff_multiplier)
@@ -40,7 +40,7 @@ class Network
40
40
  def check_gate(user, gate_name)
41
41
  begin
42
42
  request_body = JSON.generate({'user' => user&.serialize(false), 'gateName' => gate_name})
43
- response = post_helper('check_gate', request_body)
43
+ response, _ = post_helper('check_gate', request_body)
44
44
  return JSON.parse(response.body) unless response.nil?
45
45
  false
46
46
  rescue
@@ -51,7 +51,7 @@ class Network
51
51
  def get_config(user, dynamic_config_name)
52
52
  begin
53
53
  request_body = JSON.generate({'user' => user&.serialize(false), 'configName' => dynamic_config_name})
54
- response = post_helper('get_config', request_body)
54
+ response, _ = post_helper('get_config', request_body)
55
55
  return JSON.parse(response.body) unless response.nil?
56
56
  nil
57
57
  rescue
@@ -61,13 +61,13 @@ class Network
61
61
 
62
62
  def download_config_specs
63
63
  begin
64
- response = post_helper('download_config_specs', JSON.generate({'sinceTime' => @last_sync_time}))
65
- return nil unless !response.nil?
64
+ response, e = post_helper('download_config_specs', JSON.generate({'sinceTime' => @last_sync_time}))
65
+ return nil, e if response.nil?
66
66
  json_body = JSON.parse(response.body)
67
67
  @last_sync_time = json_body['time']
68
- return json_body
69
- rescue
70
- return nil
68
+ return json_body, nil
69
+ rescue StandardError => e
70
+ return nil, e
71
71
  end
72
72
  end
73
73
 
@@ -75,7 +75,7 @@ class Network
75
75
  Thread.new do
76
76
  loop do
77
77
  sleep 10
78
- specs = download_config_specs
78
+ specs, _ = download_config_specs
79
79
  unless specs.nil?
80
80
  callback.call(specs)
81
81
  end
@@ -86,7 +86,7 @@ class Network
86
86
  def post_logs(events, statsig_metadata)
87
87
  begin
88
88
  json_body = JSON.generate({'events' => events, 'statsigMetadata' => statsig_metadata})
89
- post_helper('log_event', body: json_body, retries: 5)
89
+ post_helper('log_event', json_body, retries: 5)
90
90
  rescue
91
91
  end
92
92
  end
data/lib/statsig.rb CHANGED
@@ -1,13 +1,13 @@
1
1
  require 'statsig_driver'
2
2
 
3
3
  module Statsig
4
- def self.initialize(secret_key, options = nil)
4
+ def self.initialize(secret_key, options = nil, error_callback = nil)
5
5
  unless @shared_instance.nil?
6
6
  puts 'Statsig already initialized.'
7
7
  return @shared_instance
8
8
  end
9
9
 
10
- @shared_instance = StatsigDriver.new(secret_key, options)
10
+ @shared_instance = StatsigDriver.new(secret_key, options, error_callback)
11
11
  end
12
12
 
13
13
  def self.check_gate(user, gate_name)
@@ -8,7 +8,7 @@ require 'statsig_user'
8
8
  require 'spec_store'
9
9
 
10
10
  class StatsigDriver
11
- def initialize(secret_key, options = nil)
11
+ def initialize(secret_key, options = nil, error_callback = nil)
12
12
  super()
13
13
  if !secret_key.is_a?(String) || !secret_key.start_with?('secret-')
14
14
  raise 'Invalid secret key provided. Provide your project secret key from the Statsig console'
@@ -17,7 +17,7 @@ class StatsigDriver
17
17
  raise 'Invalid options provided. Either provide a valid StatsigOptions object or nil'
18
18
  end
19
19
 
20
- @options = options || StatsigOptions.new()
20
+ @options = options || StatsigOptions.new
21
21
  @shutdown = false
22
22
  @secret_key = secret_key
23
23
  @net = Network.new(secret_key, @options.api_url_base)
@@ -27,7 +27,7 @@ class StatsigDriver
27
27
  }
28
28
  @logger = StatsigLogger.new(@net, @statsig_metadata)
29
29
 
30
- downloaded_specs = @net.download_config_specs
30
+ downloaded_specs, e = @net.download_config_specs
31
31
  unless downloaded_specs.nil?
32
32
  @initialized = true
33
33
  end
@@ -36,6 +36,8 @@ class StatsigDriver
36
36
  @evaluator = Evaluator.new(@store)
37
37
 
38
38
  @polling_thread = @net.poll_for_changes(-> (config_specs) { @store.process(config_specs) })
39
+
40
+ error_callback.call(e) unless error_callback.nil?
39
41
  end
40
42
 
41
43
  def check_gate(user, gate_name)
@@ -58,7 +60,7 @@ class StatsigDriver
58
60
  res = check_gate_fallback(user, gate_name)
59
61
  # exposure logged by the server
60
62
  else
61
- @logger.log_gate_exposure(user, res.name, res.gate_value, res.rule_id)
63
+ @logger.log_gate_exposure(user, res.name, res.gate_value, res.rule_id, res.secondary_exposures)
62
64
  end
63
65
 
64
66
  res.gate_value
@@ -84,12 +86,19 @@ class StatsigDriver
84
86
  res = get_config_fallback(user, dynamic_config_name)
85
87
  # exposure logged by the server
86
88
  else
87
- @logger.log_config_exposure(user, res.name, res.rule_id)
89
+ @logger.log_config_exposure(user, res.name, res.rule_id, res.secondary_exposures)
88
90
  end
89
91
 
90
92
  DynamicConfig.new(res.name, res.json_value, res.rule_id)
91
93
  end
92
94
 
95
+ def get_experiment(user, experiment_name)
96
+ if !experiment_name.is_a?(String) || experiment_name.empty?
97
+ raise "Invalid experiment_name provided"
98
+ end
99
+ get_config(user, experiment_name)
100
+ end
101
+
93
102
  def log_event(user, event_name, value = nil, metadata = nil)
94
103
  if !user.nil? && !user.instance_of?(StatsigUser)
95
104
  raise 'Must provide a valid StatsigUser or nil'
data/lib/statsig_event.rb CHANGED
@@ -2,6 +2,7 @@ class StatsigEvent
2
2
  attr_accessor :value
3
3
  attr_accessor :metadata
4
4
  attr_accessor :statsig_metadata
5
+ attr_accessor :secondary_exposures
5
6
  attr_reader :user
6
7
 
7
8
  def initialize(event_name)
@@ -23,6 +24,7 @@ class StatsigEvent
23
24
  'user' => @user,
24
25
  'time' => @time,
25
26
  'statsigMetadata' => @statsig_metadata,
27
+ 'secondaryExposures' => @secondary_exposures
26
28
  }
27
29
  end
28
30
  end
@@ -21,7 +21,7 @@ class StatsigLogger
21
21
  end
22
22
  end
23
23
 
24
- def log_gate_exposure(user, gate_name, value, rule_id)
24
+ def log_gate_exposure(user, gate_name, value, rule_id, secondary_exposures)
25
25
  event = StatsigEvent.new($gate_exposure_event)
26
26
  event.user = user
27
27
  event.metadata = {
@@ -30,10 +30,11 @@ class StatsigLogger
30
30
  'ruleID' => rule_id
31
31
  }
32
32
  event.statsig_metadata = @statsig_metadata
33
+ event.secondary_exposures = secondary_exposures.is_a?(Array) ? secondary_exposures : []
33
34
  log_event(event)
34
35
  end
35
36
 
36
- def log_config_exposure(user, config_name, rule_id)
37
+ def log_config_exposure(user, config_name, rule_id, secondary_exposures)
37
38
  event = StatsigEvent.new($config_exposure_event)
38
39
  event.user = user
39
40
  event.metadata = {
@@ -41,6 +42,7 @@ class StatsigLogger
41
42
  'ruleID' => rule_id
42
43
  }
43
44
  event.statsig_metadata = @statsig_metadata
45
+ event.secondary_exposures = secondary_exposures.is_a?(Array) ? secondary_exposures : []
44
46
  log_event(event)
45
47
  end
46
48
 
@@ -51,11 +53,9 @@ class StatsigLogger
51
53
  if @events.length == 0
52
54
  return
53
55
  end
54
- flush_events = @events.map { |e| e.serialize() }
56
+ flush_events = @events.map { |e| e.serialize }
55
57
  @events = []
56
58
 
57
- Thread.new do
58
- @network.post_logs(flush_events, @statsig_metadata)
59
- end
59
+ @network.post_logs(flush_events, @statsig_metadata)
60
60
  end
61
61
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: statsig
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.6.0
4
+ version: 1.7.0.beta.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Statsig, Inc
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2021-09-09 00:00:00.000000000 Z
11
+ date: 2021-10-12 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: bundler
@@ -141,9 +141,9 @@ required_ruby_version: !ruby/object:Gem::Requirement
141
141
  version: '0'
142
142
  required_rubygems_version: !ruby/object:Gem::Requirement
143
143
  requirements:
144
- - - ">="
144
+ - - ">"
145
145
  - !ruby/object:Gem::Version
146
- version: '0'
146
+ version: 1.3.1
147
147
  requirements: []
148
148
  rubygems_version: 3.2.3
149
149
  signing_key: