statsig 1.9.0 → 1.9.1

Sign up to get free protection for your applications and to get access to all the features.
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