statsig 1.7.0 → 1.8.0

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: d24b945d9500781cda02ab594f003360d81c5a1bca99f366a2ceb95013b53dad
4
- data.tar.gz: 63c9ea1a9d6347ecec9bff74949665b2af20bfe774e21a6851e96ba9b7eea397
3
+ metadata.gz: 4268658d92961175796c877e357cc3cf18f40656cf8aa272034fd4953bdb641e
4
+ data.tar.gz: e0a62e60cdca3b8b7ef8fe0302032badb88f8b9b5d86635bf20f79dc7fc011da
5
5
  SHA512:
6
- metadata.gz: e47350b9ba8e20f659b65a909955d40e66bf2397306aa798aaab60e73fb3b58fd1d3cade5ea0077fd37f847c5f6e976f7ecbfc978db4830884e463bfa6656574
7
- data.tar.gz: 8e8b11dd34de6f35585c916d3125be3a36f6f897b69facb1788364702bcd15d1729109d8ad8aadfda98f9909a7f053077433ba6221f3ca7c27d2b5686214fda6
6
+ metadata.gz: e40a7d816b2c36ec211272bfa21e1bacb035ae7a6a9a1366eb4bae9b0af917f1694f66a1d35f146018b49be6c0df1ec07fe549612d3a49e54c69ffeeb7e3e733
7
+ data.tar.gz: a53f398b7574bdaa47f26b5dcda0d2e11f83fbc85401b071592198ae24b59dbaec21cc74ec04f102ad909a06aeb0f552be1a49838241b035a2153b81c3250c04
data/lib/evaluator.rb CHANGED
@@ -11,11 +11,11 @@ $fetch_from_server = :fetch_from_server
11
11
  $type_dynamic_config = 'dynamic_config'
12
12
 
13
13
  class Evaluator
14
- def initialize(store)
15
- @spec_store = store
16
- @initialized = true
14
+ def initialize(network, error_callback)
15
+ @spec_store = SpecStore.new(network, error_callback)
17
16
  @ua_parser = UserAgentParser::Parser.new
18
17
  CountryLookup.initialize
18
+ @initialized = true
19
19
  end
20
20
 
21
21
  def check_gate(user, gate_name)
@@ -28,6 +28,10 @@ class Evaluator
28
28
  eval_spec(user, @spec_store.get_config(config_name))
29
29
  end
30
30
 
31
+ def shutdown
32
+ @spec_store.shutdown
33
+ end
34
+
31
35
  private
32
36
 
33
37
  def eval_spec(user, config)
@@ -53,7 +57,8 @@ class Evaluator
53
57
 
54
58
  i += 1
55
59
  end
56
- elsif (default_rule_id = 'disabled')
60
+ else
61
+ default_rule_id = 'disabled'
57
62
  end
58
63
 
59
64
  ConfigResult.new(config['name'], false, config['defaultValue'], default_rule_id, exposures)
@@ -88,6 +93,7 @@ class Evaluator
88
93
  operator = condition['operator']
89
94
  additional_values = condition['additionalValues']
90
95
  additional_values = Hash.new unless additional_values.is_a? Hash
96
+ idType = condition['idType']
91
97
 
92
98
  return $fetch_from_server unless type.is_a? String
93
99
  type = type.downcase
@@ -125,12 +131,14 @@ class Evaluator
125
131
  when 'user_bucket'
126
132
  begin
127
133
  salt = additional_values['salt']
128
- user_id = user.user_id || ''
134
+ unit_id = get_unit_id(user, idType) || ''
129
135
  # there are only 1000 user buckets as opposed to 10k for gate pass %
130
- value = compute_user_hash("#{salt}.#{user_id}") % 1000
136
+ value = compute_user_hash("#{salt}.#{unit_id}") % 1000
131
137
  rescue
132
138
  return false
133
139
  end
140
+ when 'unit_id'
141
+ value = get_unit_id(user, idType)
134
142
  else
135
143
  return $fetch_from_server
136
144
  end
@@ -196,6 +204,17 @@ class Evaluator
196
204
  return EvaluationHelpers::compare_times(value, target, ->(a, b) { a > b })
197
205
  when 'on'
198
206
  return EvaluationHelpers::compare_times(value, target, ->(a, b) { a.year == b.year && a.month == b.month && a.day == b.day })
207
+ when 'in_segment_list', 'not_in_segment_list'
208
+ begin
209
+ id_list = (@spec_store.get_id_list(target) || {:ids => {}})[:ids]
210
+ hashed_id = Digest::SHA256.base64digest(value.to_s)[0, 8]
211
+ is_in_list = id_list.is_a?(Hash) && id_list[hashed_id] == true
212
+
213
+ return is_in_list if operator == 'in_segment_list'
214
+ return !is_in_list
215
+ rescue StandardError => e
216
+ return false
217
+ end
199
218
  else
200
219
  return $fetch_from_server
201
220
  end
@@ -267,15 +286,23 @@ class Evaluator
267
286
  def eval_pass_percent(user, rule, config_salt)
268
287
  return false unless config_salt.is_a?(String) && !rule['passPercentage'].nil?
269
288
  begin
270
- user_id = user.user_id || ''
289
+ unit_id = get_unit_id(user, rule['id_type']) || ''
271
290
  rule_salt = rule['salt'] || rule['id'] || ''
272
- hash = compute_user_hash("#{config_salt}.#{rule_salt}.#{user_id}")
291
+ hash = compute_user_hash("#{config_salt}.#{rule_salt}.#{unit_id}")
273
292
  return (hash % 10000) < (rule['passPercentage'].to_f * 100)
274
293
  rescue
275
294
  return false
276
295
  end
277
296
  end
278
297
 
298
+ def get_unit_id(user, id_type)
299
+ if id_type.is_a?(String) && id_type.downcase != 'userid'
300
+ return nil unless user&.custom_ids.is_a? Hash
301
+ return user.custom_ids[id_type] || user.custom_ids[id_type.downcase]
302
+ end
303
+ user.user_id
304
+ end
305
+
279
306
  def compute_user_hash(user_hash)
280
307
  Digest::SHA256.digest(user_hash).unpack('Q>')[0]
281
308
  end
data/lib/network.rb CHANGED
@@ -12,7 +12,6 @@ class Network
12
12
  end
13
13
  @server_secret = server_secret
14
14
  @api = api
15
- @last_sync_time = 0
16
15
  @backoff_multiplier = backoff_mult
17
16
  end
18
17
 
@@ -59,33 +58,9 @@ class Network
59
58
  end
60
59
  end
61
60
 
62
- def download_config_specs
61
+ def post_logs(events)
63
62
  begin
64
- response, e = post_helper('download_config_specs', JSON.generate({'sinceTime' => @last_sync_time}))
65
- return nil, e if response.nil?
66
- json_body = JSON.parse(response.body)
67
- @last_sync_time = json_body['time']
68
- return json_body, nil
69
- rescue StandardError => e
70
- return nil, e
71
- end
72
- end
73
-
74
- def poll_for_changes(callback)
75
- Thread.new do
76
- loop do
77
- sleep 10
78
- specs, _ = download_config_specs
79
- unless specs.nil?
80
- callback.call(specs)
81
- end
82
- end
83
- end
84
- end
85
-
86
- def post_logs(events, statsig_metadata)
87
- begin
88
- json_body = JSON.generate({'events' => events, 'statsigMetadata' => statsig_metadata})
63
+ json_body = JSON.generate({'events' => events, 'statsigMetadata' => Statsig.get_statsig_metadata})
89
64
  post_helper('log_event', json_body, retries: 5)
90
65
  rescue
91
66
  end
data/lib/spec_store.rb CHANGED
@@ -2,40 +2,35 @@ require 'net/http'
2
2
  require 'uri'
3
3
 
4
4
  class SpecStore
5
- def initialize(specs_json)
5
+ def initialize(network, error_callback = nil, config_sync_interval = 10, id_lists_sync_interval = 60)
6
+ @network = network
6
7
  @last_sync_time = 0
8
+ @config_sync_interval = config_sync_interval
9
+ @id_lists_sync_interval = id_lists_sync_interval
7
10
  @store = {
8
11
  :gates => {},
9
12
  :configs => {},
13
+ :id_lists => {},
10
14
  }
11
- process(specs_json)
12
- end
13
-
14
- def process(specs_json)
15
- if specs_json.nil?
16
- return
17
- end
15
+ e = download_config_specs
16
+ error_callback.call(e) unless error_callback.nil?
17
+ download_id_lists
18
18
 
19
- @last_sync_time = specs_json['time'] || @last_sync_time
20
- return unless specs_json['has_updates'] == true &&
21
- !specs_json['feature_gates'].nil? &&
22
- !specs_json['dynamic_configs'].nil?
23
-
24
- @store = {
25
- :gates => {},
26
- :configs => {},
27
- }
19
+ @config_sync_thread = sync_config_specs
20
+ @id_lists_sync_thread = sync_id_lists
21
+ end
28
22
 
29
- specs_json['feature_gates'].map{|gate| @store[:gates][gate['name']] = gate }
30
- specs_json['dynamic_configs'].map{|config| @store[:configs][config['name']] = config }
23
+ def shutdown
24
+ @config_sync_thread&.exit
25
+ @id_lists_sync_thread&.exit
31
26
  end
32
27
 
33
28
  def has_gate?(gate_name)
34
- return @store[:gates].key?(gate_name)
29
+ @store[:gates].key?(gate_name)
35
30
  end
36
31
 
37
32
  def has_config?(config_name)
38
- return @store[:configs].key?(config_name)
33
+ @store[:configs].key?(config_name)
39
34
  end
40
35
 
41
36
  def get_gate(gate_name)
@@ -48,4 +43,108 @@ class SpecStore
48
43
  @store[:configs][config_name]
49
44
  end
50
45
 
46
+ def get_id_list(list_name)
47
+ @store[:id_lists][list_name]
48
+ end
49
+
50
+ private
51
+
52
+ def sync_config_specs
53
+ Thread.new do
54
+ loop do
55
+ sleep @config_sync_interval
56
+ download_config_specs
57
+ end
58
+ end
59
+ end
60
+
61
+ def sync_id_lists
62
+ Thread.new do
63
+ loop do
64
+ sleep @id_lists_sync_interval
65
+ download_id_lists
66
+ end
67
+ end
68
+ end
69
+
70
+ def download_config_specs
71
+ begin
72
+ response, e = @network.post_helper('download_config_specs', JSON.generate({'sinceTime' => @last_sync_time}))
73
+ if e.nil?
74
+ process(JSON.parse(response.body))
75
+ else
76
+ e
77
+ end
78
+ rescue StandardError => e
79
+ e
80
+ end
81
+ end
82
+
83
+ def process(specs_json)
84
+ if specs_json.nil?
85
+ return
86
+ end
87
+
88
+ @last_sync_time = specs_json['time'] || @last_sync_time
89
+ return unless specs_json['has_updates'] == true &&
90
+ !specs_json['feature_gates'].nil? &&
91
+ !specs_json['dynamic_configs'].nil?
92
+
93
+ new_gates = {}
94
+ new_configs = {}
95
+
96
+ specs_json['feature_gates'].map{|gate| new_gates[gate['name']] = gate }
97
+ specs_json['dynamic_configs'].map{|config| new_configs[config['name']] = config }
98
+ @store[:gates] = new_gates
99
+ @store[:configs] = new_configs
100
+
101
+ new_id_lists = specs_json['id_lists']
102
+ if new_id_lists.is_a? Hash
103
+ new_id_lists.each do |list_name, _|
104
+ unless @store[:id_lists].key?(list_name)
105
+ @store[:id_lists][list_name] = { :ids => {}, :time => 0 }
106
+ end
107
+ end
108
+
109
+ @store[:id_lists].each do |list_name, _|
110
+ unless new_id_lists.key?(list_name)
111
+ @store[:id_lists].delete(list_name)
112
+ end
113
+ end
114
+ end
115
+ end
116
+
117
+ def download_id_lists
118
+ if @store[:id_lists].is_a? Hash
119
+ threads = []
120
+ id_lists = @store[:id_lists]
121
+ id_lists.each do |list_name, list|
122
+ threads << Thread.new do
123
+ response, e = @network.post_helper('download_id_list', JSON.generate({'listName' => list_name, 'statsigMetadata' => Statsig.get_statsig_metadata, 'sinceTime' => list['time'] || 0 }))
124
+ if e.nil? && !response.nil?
125
+ begin
126
+ data = JSON.parse(response)
127
+ if data['add_ids'].is_a? Array
128
+ data['add_ids'].each do |id|
129
+ list[:ids][id] = true
130
+ end
131
+ end
132
+ if data['remove_ids'].is_a? Array
133
+ data['remove_ids'].each do |id|
134
+ list[:ids]&.delete(id)
135
+ end
136
+ end
137
+ if data['time'].is_a? Numeric
138
+ list[:time] = data['time']
139
+ end
140
+ rescue
141
+ # Ignored
142
+ end
143
+ end
144
+ end
145
+ end
146
+ threads.each(&:join)
147
+ end
148
+ end
149
+
51
150
  end
data/lib/statsig.rb CHANGED
@@ -37,6 +37,13 @@ module Statsig
37
37
  @shared_instance = nil
38
38
  end
39
39
 
40
+ def self.get_statsig_metadata
41
+ {
42
+ 'sdkType' => 'ruby-server',
43
+ 'sdkVersion' => '1.8.0',
44
+ }
45
+ end
46
+
40
47
  private
41
48
 
42
49
  def self.ensure_initialized
@@ -21,23 +21,8 @@ class StatsigDriver
21
21
  @shutdown = false
22
22
  @secret_key = secret_key
23
23
  @net = Network.new(secret_key, @options.api_url_base)
24
- @statsig_metadata = {
25
- 'sdkType' => 'ruby-server',
26
- 'sdkVersion' => Gem::Specification::load('statsig.gemspec')&.version,
27
- }
28
- @logger = StatsigLogger.new(@net, @statsig_metadata)
29
-
30
- downloaded_specs, e = @net.download_config_specs
31
- unless downloaded_specs.nil?
32
- @initialized = true
33
- end
34
-
35
- @store = SpecStore.new(downloaded_specs)
36
- @evaluator = Evaluator.new(@store)
37
-
38
- @polling_thread = @net.poll_for_changes(-> (config_specs) { @store.process(config_specs) })
39
-
40
- error_callback.call(e) unless error_callback.nil?
24
+ @logger = StatsigLogger.new(@net)
25
+ @evaluator = Evaluator.new(@net, error_callback)
41
26
  end
42
27
 
43
28
  def check_gate(user, gate_name)
@@ -47,9 +32,6 @@ class StatsigDriver
47
32
  raise 'Invalid gate_name provided'
48
33
  end
49
34
  check_shutdown
50
- unless @initialized
51
- return false
52
- end
53
35
 
54
36
  res = @evaluator.check_gate(user, gate_name)
55
37
  if res.nil?
@@ -73,9 +55,6 @@ class StatsigDriver
73
55
  raise "Invalid dynamic_config_name provided"
74
56
  end
75
57
  check_shutdown
76
- unless @initialized
77
- return DynamicConfig.new(dynamic_config_name)
78
- end
79
58
 
80
59
  res = @evaluator.get_config(user, dynamic_config_name)
81
60
  if res.nil?
@@ -111,14 +90,14 @@ class StatsigDriver
111
90
  event.user = user
112
91
  event.value = value
113
92
  event.metadata = metadata
114
- event.statsig_metadata = @statsig_metadata
93
+ event.statsig_metadata = Statsig.get_statsig_metadata
115
94
  @logger.log_event(event)
116
95
  end
117
96
 
118
97
  def shutdown
119
98
  @shutdown = true
120
99
  @logger.flush(true)
121
- @polling_thread&.exit
100
+ @evaluator.shutdown
122
101
  end
123
102
 
124
103
  private
@@ -4,9 +4,8 @@ $gate_exposure_event = 'statsig::gate_exposure'
4
4
  $config_exposure_event = 'statsig::config_exposure'
5
5
 
6
6
  class StatsigLogger
7
- def initialize(network, statsig_metadata)
7
+ def initialize(network)
8
8
  @network = network
9
- @statsig_metadata = statsig_metadata
10
9
  @events = []
11
10
  @background_flush = Thread.new do
12
11
  sleep 60
@@ -29,7 +28,7 @@ class StatsigLogger
29
28
  'gateValue' => value.to_s,
30
29
  'ruleID' => rule_id
31
30
  }
32
- event.statsig_metadata = @statsig_metadata
31
+ event.statsig_metadata = Statsig.get_statsig_metadata
33
32
  event.secondary_exposures = secondary_exposures.is_a?(Array) ? secondary_exposures : []
34
33
  log_event(event)
35
34
  end
@@ -41,7 +40,7 @@ class StatsigLogger
41
40
  'config' => config_name,
42
41
  'ruleID' => rule_id
43
42
  }
44
- event.statsig_metadata = @statsig_metadata
43
+ event.statsig_metadata = Statsig.get_statsig_metadata
45
44
  event.secondary_exposures = secondary_exposures.is_a?(Array) ? secondary_exposures : []
46
45
  log_event(event)
47
46
  end
@@ -56,6 +55,6 @@ class StatsigLogger
56
55
  flush_events = @events.map { |e| e.serialize }
57
56
  @events = []
58
57
 
59
- @network.post_logs(flush_events, @statsig_metadata)
58
+ @network.post_logs(flush_events)
60
59
  end
61
60
  end
data/lib/statsig_user.rb CHANGED
@@ -7,6 +7,7 @@ class StatsigUser
7
7
  attr_accessor :locale
8
8
  attr_accessor :app_version
9
9
  attr_accessor :statsig_environment
10
+ attr_accessor :custom_ids
10
11
  attr_accessor :private_attributes
11
12
 
12
13
  def custom
@@ -28,9 +29,11 @@ class StatsigUser
28
29
  @country = user_hash['country']
29
30
  @locale = user_hash['locale']
30
31
  @app_version = user_hash['appVersion'] || user_hash['app_version']
31
- @custom = user_hash['custom']
32
+ @custom = user_hash['custom'] if user_hash['custom'].is_a? Hash
32
33
  @statsig_environment = user_hash['statsigEnvironment']
33
- @private_attributes = user_hash['privateAttributes']
34
+ @private_attributes = user_hash['privateAttributes'] if user_hash['privateAttributes'].is_a? Hash
35
+ custom_ids = user_hash['customIDs'] || user_hash['custom_ids']
36
+ @custom_ids = custom_ids if custom_ids.is_a? Hash
34
37
  end
35
38
  end
36
39
 
@@ -46,6 +49,7 @@ class StatsigUser
46
49
  'custom' => @custom,
47
50
  'statsigEnvironment' => @statsig_environment,
48
51
  'privateAttributes' => @private_attributes,
52
+ 'customIDs' => @custom_ids,
49
53
  }
50
54
  if for_logging
51
55
  hash.delete('privateAttributes')
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.7.0
4
+ version: 1.8.0
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-10-28 00:00:00.000000000 Z
11
+ date: 2021-12-18 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: bundler