statsig 1.9.0 → 1.9.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: a832901a7d272c5b27408418836ded1042f720bfc0a374489ed0a81c476a1606
4
- data.tar.gz: c89426f15ab949980299bfc21931466d076e8b23fbe26ddc290e13d1d92cb948
3
+ metadata.gz: 72321b660214bc1a0c54ffd5718115ef713be11fd0f37fe9e18429539edad855
4
+ data.tar.gz: 3583760dd5c9ae9aab5005bc07e7673088de8af39b7cbecec032b332249b1c6a
5
5
  SHA512:
6
- metadata.gz: c92f462bf372241bdd214a9a153b597792970b70631b8631be3df01b189953b6574016eedf437e397e07f62c8ef8f1c3e7ab7834f10080febe9843514cb059e0
7
- data.tar.gz: 228deaad6a9c224c80321ddc6bd33ab13a4cd6eae86cc83f822f5d8325f25aaa13bfd0903369468b3664d28847b3239df27a446b32b349e5b3d987f77ef3fb74
6
+ metadata.gz: bf06b989ebade0705ef7ee377455b87c6b408ce38aabfae9ccc6c4f95f6b7bc0a4e3fb9393d5473b0e32f2e04f901df730666f00a27b2f0d2a709338b31ca2bc
7
+ data.tar.gz: 68e77d69004f9d96d82e011ad67822732c9889871eb0e39990b88c9f109b4f84d991c55a680cd5434381c285e35e725d08e7438ee5fe1bc32809f0dd9eb78141
data/lib/config_result.rb CHANGED
@@ -6,15 +6,19 @@ module Statsig
6
6
  attr_accessor :json_value
7
7
  attr_accessor :rule_id
8
8
  attr_accessor :secondary_exposures
9
+ attr_accessor :undelegated_sec_exps
9
10
  attr_accessor :config_delegate
11
+ attr_accessor :explicit_parameters
10
12
 
11
- def initialize(name, gate_value = false, json_value = {}, rule_id = '', secondary_exposures = [], config_delegate = '')
13
+ def initialize(name, gate_value = false, json_value = {}, rule_id = '', secondary_exposures = [], undelegated_sec_exps = [], config_delegate = '', explicit_parameters = [])
12
14
  @name = name
13
15
  @gate_value = gate_value
14
16
  @json_value = json_value
15
17
  @rule_id = rule_id
16
18
  @secondary_exposures = secondary_exposures.is_a?(Array) ? secondary_exposures : []
19
+ @undelegated_sec_exps = undelegated_sec_exps.is_a?(Array) ? undelegated_sec_exps : []
17
20
  @config_delegate = config_delegate
21
+ @explicit_parameters = explicit_parameters
18
22
  end
19
23
  end
20
24
  end
data/lib/evaluator.rb CHANGED
@@ -7,7 +7,7 @@ require 'time'
7
7
  require 'user_agent_parser'
8
8
  require 'user_agent_parser/operating_system'
9
9
 
10
- $fetch_from_server = :fetch_from_server
10
+ $fetch_from_server = 'fetch_from_server'
11
11
  $type_dynamic_config = 'dynamic_config'
12
12
 
13
13
  module Statsig
@@ -48,17 +48,21 @@ module Statsig
48
48
  until i >= config['rules'].length do
49
49
  rule = config['rules'][i]
50
50
  result = eval_rule(user, rule)
51
- return $fetch_from_server if result == $fetch_from_server
51
+ return $fetch_from_server if result.to_s == $fetch_from_server
52
52
  exposures = exposures + result.secondary_exposures
53
53
  if result.gate_value
54
+
55
+ if (delegated_result = eval_delegate(config['name'], user, rule, exposures))
56
+ return delegated_result
57
+ end
58
+
54
59
  pass = eval_pass_percent(user, rule, config['salt'])
55
60
  return Statsig::ConfigResult.new(
56
61
  config['name'],
57
62
  pass,
58
63
  pass ? result.json_value : config['defaultValue'],
59
64
  result.rule_id,
60
- exposures,
61
- result.config_delegate
65
+ exposures
62
66
  )
63
67
  end
64
68
 
@@ -77,7 +81,7 @@ module Statsig
77
81
  i = 0
78
82
  until i >= rule['conditions'].length do
79
83
  result = eval_condition(user, rule['conditions'][i])
80
- if result == $fetch_from_server
84
+ if result.to_s == $fetch_from_server
81
85
  return $fetch_from_server
82
86
  end
83
87
 
@@ -90,17 +94,24 @@ module Statsig
90
94
  i += 1
91
95
  end
92
96
 
93
- delegate = rule['configDelegate']
94
- if pass and @spec_store.get_config(delegate)
95
- delegated_result = self.eval_spec(user, @spec_store.get_config(delegate))
96
- delegated_result.config_delegate = delegate
97
- delegated_result.secondary_exposures = exposures + delegated_result.secondary_exposures
98
- return delegated_result
99
- end
100
-
101
97
  Statsig::ConfigResult.new('', pass, rule['returnValue'], rule['id'], exposures)
102
98
  end
103
99
 
100
+ def eval_delegate(name, user, rule, exposures)
101
+ return nil unless (delegate = rule['configDelegate'])
102
+ return nil unless (config = @spec_store.get_config(delegate))
103
+
104
+ delegated_result = self.eval_spec(user, config)
105
+ return $fetch_from_server if delegated_result.to_s == $fetch_from_server
106
+
107
+ delegated_result.name = name
108
+ delegated_result.config_delegate = delegate
109
+ delegated_result.secondary_exposures = exposures + delegated_result.secondary_exposures
110
+ delegated_result.undelegated_sec_exps = exposures
111
+ delegated_result.explicit_parameters = config['explicitParameters']
112
+ delegated_result
113
+ end
114
+
104
115
  def eval_condition(user, condition)
105
116
  value = nil
106
117
  field = condition['field']
@@ -119,7 +130,7 @@ module Statsig
119
130
  return true
120
131
  when 'fail_gate', 'pass_gate'
121
132
  other_gate_result = check_gate(user, target)
122
- return $fetch_from_server if other_gate_result == $fetch_from_server
133
+ return $fetch_from_server if other_gate_result.to_s == $fetch_from_server
123
134
 
124
135
  gate_value = other_gate_result&.gate_value == true
125
136
  new_exposure = {
@@ -134,10 +145,10 @@ module Statsig
134
145
  }
135
146
  when 'ip_based'
136
147
  value = get_value_from_user(user, field) || get_value_from_ip(user, field)
137
- return $fetch_from_server if value == $fetch_from_server
148
+ return $fetch_from_server if value.to_s == $fetch_from_server
138
149
  when 'ua_based'
139
150
  value = get_value_from_user(user, field) || get_value_from_ua(user, field)
140
- return $fetch_from_server if value == $fetch_from_server
151
+ return $fetch_from_server if value.to_s == $fetch_from_server
141
152
  when 'user_field'
142
153
  value = get_value_from_user(user, field)
143
154
  when 'environment_field'
@@ -159,7 +170,7 @@ module Statsig
159
170
  return $fetch_from_server
160
171
  end
161
172
 
162
- return $fetch_from_server if value == $fetch_from_server || !operator.is_a?(String)
173
+ return $fetch_from_server if value.to_s == $fetch_from_server || !operator.is_a?(String)
163
174
  operator = operator.downcase
164
175
 
165
176
  case operator
@@ -229,13 +240,15 @@ module Statsig
229
240
  return EvaluationHelpers::compare_times(value, target, ->(a, b) { a.year == b.year && a.month == b.month && a.day == b.day })
230
241
  when 'in_segment_list', 'not_in_segment_list'
231
242
  begin
232
- id_list = (@spec_store.get_id_list(target) || {:ids => {}})[:ids]
233
- hashed_id = Digest::SHA256.base64digest(value.to_s)[0, 8]
234
- is_in_list = id_list.is_a?(Hash) && id_list[hashed_id] == true
235
-
236
- return is_in_list if operator == 'in_segment_list'
237
- return !is_in_list
238
- rescue StandardError => e
243
+ is_in_list = false
244
+ id_list = @spec_store.get_id_list(target)
245
+ if id_list.is_a? IDList
246
+ hashed_id = Digest::SHA256.base64digest(value.to_s)[0, 8]
247
+ is_in_list = id_list.ids.include?(hashed_id)
248
+ end
249
+ return is_in_list if operator == 'in_segment_list'
250
+ return !is_in_list
251
+ rescue
239
252
  return false
240
253
  end
241
254
  else
@@ -309,7 +322,7 @@ module Statsig
309
322
  def eval_pass_percent(user, rule, config_salt)
310
323
  return false unless config_salt.is_a?(String) && !rule['passPercentage'].nil?
311
324
  begin
312
- unit_id = get_unit_id(user, rule['id_type']) || ''
325
+ unit_id = get_unit_id(user, rule['idType']) || ''
313
326
  rule_salt = rule['salt'] || rule['id'] || ''
314
327
  hash = compute_user_hash("#{config_salt}.#{rule_salt}.#{unit_id}")
315
328
  return (hash % 10000) < (rule['passPercentage'].to_f * 100)
data/lib/id_list.rb ADDED
@@ -0,0 +1,36 @@
1
+ module Statsig
2
+ class IDList
3
+ attr_accessor :name
4
+ attr_accessor :size
5
+ attr_accessor :creation_time
6
+ attr_accessor :url
7
+ attr_accessor :file_id
8
+ attr_accessor :ids
9
+
10
+ def initialize(json, ids = Set.new)
11
+ @name = json['name'] || ''
12
+ @size = json['size'] || 0
13
+ @creation_time = json['creationTime'] || 0
14
+ @url = json['url']
15
+ @file_id = json['fileID']
16
+
17
+ @ids = ids
18
+ end
19
+
20
+ def self.new_empty(json)
21
+ self.new(json)
22
+ @size = 0
23
+ end
24
+
25
+ def ==(other)
26
+ return false if other.nil?
27
+
28
+ self.name == other.name &&
29
+ self.size == other.size &&
30
+ self.creation_time == other.creation_time &&
31
+ self.url == other.url &&
32
+ self.file_id == other.file_id &&
33
+ self.ids == other.ids
34
+ end
35
+ end
36
+ end
data/lib/layer.rb CHANGED
@@ -2,14 +2,20 @@ class Layer
2
2
  attr_accessor :name
3
3
  attr_accessor :rule_id
4
4
 
5
- def initialize(name, value = {}, rule_id = '')
5
+ def initialize(name, value = {}, rule_id = '', exposure_log_func = nil)
6
6
  @name = name
7
7
  @value = value
8
8
  @rule_id = rule_id
9
+ @exposure_log_func = exposure_log_func
9
10
  end
10
11
 
11
12
  def get(index, default_value)
12
13
  return default_value if @value.nil? || !@value.key?(index)
14
+
15
+ if @exposure_log_func.is_a? Proc
16
+ @exposure_log_func.call(self, index)
17
+ end
18
+
13
19
  @value[index]
14
20
  end
15
21
  end
data/lib/network.rb CHANGED
@@ -1,5 +1,6 @@
1
1
  require 'http'
2
2
  require 'json'
3
+ require 'securerandom'
3
4
 
4
5
  $retry_codes = [408, 500, 502, 503, 504, 522, 524, 599]
5
6
 
@@ -13,12 +14,14 @@ module Statsig
13
14
  @server_secret = server_secret
14
15
  @api = api
15
16
  @backoff_multiplier = backoff_mult
17
+ @session_id = SecureRandom.uuid
16
18
  end
17
19
 
18
20
  def post_helper(endpoint, body, retries = 0, backoff = 1)
19
21
  http = HTTP.headers(
20
22
  {"STATSIG-API-KEY" => @server_secret,
21
23
  "STATSIG-CLIENT-TIME" => (Time.now.to_f * 1000).to_s,
24
+ "STATSIG-SERVER-SESSION-ID" => @session_id,
22
25
  "Content-Type" => "application/json; charset=UTF-8"
23
26
  }).accept(:json)
24
27
  begin
data/lib/spec_store.rb CHANGED
@@ -1,6 +1,8 @@
1
1
  require 'net/http'
2
2
  require 'uri'
3
3
 
4
+ require 'id_list'
5
+
4
6
  module Statsig
5
7
  class SpecStore
6
8
  def initialize(network, error_callback = nil, config_sync_interval = 10, id_lists_sync_interval = 60)
@@ -16,7 +18,7 @@ module Statsig
16
18
  }
17
19
  e = download_config_specs
18
20
  error_callback.call(e) unless error_callback.nil?
19
- download_id_lists
21
+ get_id_lists
20
22
 
21
23
  @config_sync_thread = sync_config_specs
22
24
  @id_lists_sync_thread = sync_id_lists
@@ -73,7 +75,7 @@ module Statsig
73
75
  Thread.new do
74
76
  loop do
75
77
  sleep @id_lists_sync_interval
76
- download_id_lists
78
+ get_id_lists
77
79
  end
78
80
  end
79
81
  end
@@ -112,53 +114,98 @@ module Statsig
112
114
  @store[:gates] = new_gates
113
115
  @store[:configs] = new_configs
114
116
  @store[:layers] = new_layers
117
+ end
115
118
 
116
- new_id_lists = specs_json['id_lists']
117
- if new_id_lists.is_a? Hash
118
- new_id_lists.each do |list_name, _|
119
- unless @store[:id_lists].key?(list_name)
120
- @store[:id_lists][list_name] = { :ids => {}, :time => 0 }
121
- end
119
+ def get_id_lists
120
+ response, e = @network.post_helper('get_id_lists', JSON.generate({'statsigMetadata' => Statsig.get_statsig_metadata}))
121
+ if !e.nil? || response.nil?
122
+ return
123
+ end
124
+
125
+ begin
126
+ server_id_lists = JSON.parse(response)
127
+ local_id_lists = @store[:id_lists]
128
+ if !server_id_lists.is_a?(Hash) || !local_id_lists.is_a?(Hash)
129
+ return
122
130
  end
131
+ threads = []
132
+
133
+ server_id_lists.each do |list_name, list|
134
+ server_list = IDList.new(list)
135
+ local_list = get_id_list(list_name)
136
+
137
+ unless local_list.is_a? IDList
138
+ local_list = IDList.new(list)
139
+ local_list.size = 0
140
+ local_id_lists[list_name] = local_list
141
+ end
123
142
 
124
- @store[:id_lists].each do |list_name, _|
125
- unless new_id_lists.key?(list_name)
126
- @store[:id_lists].delete(list_name)
143
+ # skip if server list is invalid
144
+ if server_list.url.nil? || server_list.creation_time < local_list.creation_time || server_list.file_id.nil?
145
+ next
146
+ end
147
+
148
+ # skip if server list returns a newer file
149
+ if server_list.file_id != local_list.file_id && server_list.creation_time >= local_list.creation_time
150
+ local_list = IDList.new(list)
151
+ local_list.size = 0
152
+ local_id_lists[list_name] = local_list
153
+ end
154
+
155
+ # skip if server list is no bigger than local list, which means nothing new to read
156
+ if server_list.size <= local_list.size
157
+ next
158
+ end
159
+
160
+ threads << Thread.new do
161
+ download_single_id_list(local_list)
162
+ end
163
+ end
164
+ threads.each(&:join)
165
+ delete_lists = []
166
+ local_id_lists.each do |list_name, list|
167
+ unless server_id_lists.key? list_name
168
+ delete_lists.push list_name
127
169
  end
128
170
  end
171
+ delete_lists.each do |list_name|
172
+ local_id_lists.delete list_name
173
+ end
174
+ rescue
175
+ # Ignored, will try again
129
176
  end
130
177
  end
131
178
 
132
- def download_id_lists
133
- if @store[:id_lists].is_a? Hash
134
- threads = []
135
- id_lists = @store[:id_lists]
136
- id_lists.each do |list_name, list|
137
- threads << Thread.new do
138
- response, e = @network.post_helper('download_id_list', JSON.generate({'listName' => list_name, 'statsigMetadata' => Statsig.get_statsig_metadata, 'sinceTime' => list['time'] || 0 }))
139
- if e.nil? && !response.nil?
140
- begin
141
- data = JSON.parse(response)
142
- if data['add_ids'].is_a? Array
143
- data['add_ids'].each do |id|
144
- list[:ids][id] = true
145
- end
146
- end
147
- if data['remove_ids'].is_a? Array
148
- data['remove_ids'].each do |id|
149
- list[:ids]&.delete(id)
150
- end
151
- end
152
- if data['time'].is_a? Numeric
153
- list[:time] = data['time']
154
- end
155
- rescue
156
- # Ignored
157
- end
158
- end
179
+ def download_single_id_list(list)
180
+ nil unless list.is_a? IDList
181
+ http = HTTP.headers({'Range' => "bytes=#{list&.size || 0}-"}).accept(:json)
182
+ begin
183
+ res = http.get(list.url)
184
+ nil unless res.status.success?
185
+ content_length = Integer(res['content-length'])
186
+ nil if content_length.nil? || content_length <= 0
187
+ content = res.body.to_s
188
+ unless content.is_a?(String) && (content[0] == '-' || content[0] == '+')
189
+ @store[:id_lists].delete(list.name)
190
+ return
191
+ end
192
+ ids_clone = list.ids # clone the list, operate on the new list, and swap out the old list, so the operation is thread-safe
193
+ lines = content.split(/\r?\n/)
194
+ lines.each do |li|
195
+ line = li.strip
196
+ next if line.length <= 1
197
+ op = line[0]
198
+ id = line[1..]
199
+ if op == '+'
200
+ ids_clone.add(id)
201
+ elsif op == '-'
202
+ ids_clone.delete(id)
159
203
  end
160
204
  end
161
- threads.each(&:join)
205
+ list.ids = ids_clone
206
+ list.size = list.size + content_length
207
+ rescue
208
+ nil
162
209
  end
163
210
  end
164
211
  end
data/lib/statsig.rb CHANGED
@@ -22,12 +22,12 @@ module Statsig
22
22
 
23
23
  def self.get_experiment(user, experiment_name)
24
24
  ensure_initialized
25
- @shared_instance&.get_config(user, experiment_name)
25
+ @shared_instance&.get_experiment(user, experiment_name)
26
26
  end
27
27
 
28
28
  def self.get_layer(user, layer_name)
29
29
  ensure_initialized
30
- @shared_instance&.get_config(user, layer_name)
30
+ @shared_instance&.get_layer(user, layer_name)
31
31
  end
32
32
 
33
33
  def self.log_event(user, event_name, value, metadata)
@@ -6,6 +6,8 @@ require 'statsig_logger'
6
6
  require 'statsig_options'
7
7
  require 'statsig_user'
8
8
  require 'spec_store'
9
+ require 'dynamic_config'
10
+ require 'layer'
9
11
 
10
12
  class StatsigDriver
11
13
  def initialize(secret_key, options = nil, error_callback = nil)
@@ -67,11 +69,11 @@ class StatsigDriver
67
69
  end
68
70
  res = get_config_fallback(user, res.config_delegate)
69
71
  # exposure logged by the server
70
- else
71
- @logger.log_layer_exposure(user, res.name, res.rule_id, res.secondary_exposures, res.config_delegate)
72
72
  end
73
73
 
74
- Layer.new(res.name, res.json_value, res.rule_id)
74
+ Layer.new(res.name, res.json_value, res.rule_id, lambda { |layer, parameter_name|
75
+ @logger.log_layer_exposure(user, layer, parameter_name, res)
76
+ })
75
77
  end
76
78
 
77
79
  def log_event(user, event_name, value = nil, metadata = nil)
@@ -101,7 +103,7 @@ class StatsigDriver
101
103
  def verify_inputs(user, config_name, variable_name)
102
104
  validate_user(user)
103
105
  if !config_name.is_a?(String) || config_name.empty?
104
- raise "Invalid " + variable_name +" provided"
106
+ raise "Invalid #{variable_name} provided"
105
107
  end
106
108
 
107
109
  check_shutdown
@@ -44,16 +44,26 @@ module Statsig
44
44
  log_event(event)
45
45
  end
46
46
 
47
- def log_layer_exposure(user, config_name, rule_id, secondary_exposures, allocated_experiment)
47
+ def log_layer_exposure(user, layer, parameter_name, config_evaluation)
48
+ exposures = config_evaluation.undelegated_sec_exps
49
+ allocated_experiment = ''
50
+ is_explicit = config_evaluation.explicit_parameters.include? parameter_name
51
+ if is_explicit
52
+ allocated_experiment = config_evaluation.config_delegate
53
+ exposures = config_evaluation.secondary_exposures
54
+ end
55
+
48
56
  event = StatsigEvent.new($layer_exposure_event)
49
57
  event.user = user
50
58
  event.metadata = {
51
- 'config' => config_name,
52
- 'ruleID' => rule_id,
53
- 'allocatedExperiment' => allocated_experiment
59
+ 'config' => layer.name,
60
+ 'ruleID' => layer.rule_id,
61
+ 'allocatedExperiment' => allocated_experiment,
62
+ 'parameterName' => parameter_name,
63
+ 'isExplicitParameter' => String(is_explicit)
54
64
  }
55
65
  event.statsig_metadata = Statsig.get_statsig_metadata
56
- event.secondary_exposures = secondary_exposures.is_a?(Array) ? secondary_exposures : []
66
+ event.secondary_exposures = exposures.is_a?(Array) ? exposures : []
57
67
  log_event(event)
58
68
  end
59
69
 
@@ -2,7 +2,7 @@ class StatsigOptions
2
2
  attr_reader :environment
3
3
  attr_reader :api_url_base
4
4
 
5
- def initialize(environment = nil, api_url_base = 'https://api.statsig.com/v1')
5
+ def initialize(environment = nil, api_url_base = 'https://statsigapi.net/v1')
6
6
  @environment = environment.is_a?(Hash) ? environment : nil
7
7
  @api_url_base = api_url_base
8
8
  end
data/lib/statsig_user.rb CHANGED
@@ -54,7 +54,7 @@ class StatsigUser
54
54
  if for_logging
55
55
  hash.delete('privateAttributes')
56
56
  end
57
- hash
57
+ hash.compact
58
58
  end
59
59
 
60
60
  def value_lookup
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.9.0
4
+ version: 1.9.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: 2022-03-22 00:00:00.000000000 Z
11
+ date: 2022-04-26 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: bundler
@@ -118,6 +118,7 @@ files:
118
118
  - lib/dynamic_config.rb
119
119
  - lib/evaluation_helpers.rb
120
120
  - lib/evaluator.rb
121
+ - lib/id_list.rb
121
122
  - lib/layer.rb
122
123
  - lib/network.rb
123
124
  - lib/spec_store.rb