statsig 1.7.0 → 1.8.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.
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