statsig 1.25.2 → 1.30.0

Sign up to get free protection for your applications and to get access to all the features.
data/lib/evaluator.rb CHANGED
@@ -1,4 +1,6 @@
1
1
  # typed: false
2
+
3
+ require 'sorbet-runtime'
2
4
  require 'config_result'
3
5
  require 'country_lookup'
4
6
  require 'digest'
@@ -9,21 +11,40 @@ require 'time'
9
11
  require 'ua_parser'
10
12
  require 'evaluation_details'
11
13
  require 'user_agent_parser/operating_system'
14
+ require 'user_persistent_storage_utils'
12
15
 
13
16
  $fetch_from_server = 'fetch_from_server'
14
17
  $type_dynamic_config = 'dynamic_config'
15
18
 
16
19
  module Statsig
17
20
  class Evaluator
21
+ extend T::Sig
22
+
23
+ sig { returns(SpecStore) }
18
24
  attr_accessor :spec_store
19
25
 
20
- def initialize(network, options, error_callback, diagnostics)
21
- @spec_store = Statsig::SpecStore.new(network, options, error_callback, diagnostics)
26
+ sig { returns(StatsigOptions) }
27
+ attr_accessor :options
28
+
29
+ sig { returns(UserPersistentStorageUtils) }
30
+ attr_accessor :persistent_storage_utils
31
+
32
+ sig do
33
+ params(
34
+ store: SpecStore,
35
+ options: StatsigOptions,
36
+ persistent_storage_utils: UserPersistentStorageUtils,
37
+ ).void
38
+ end
39
+ def initialize(store, options, persistent_storage_utils)
22
40
  UAParser.initialize_async
23
41
  CountryLookup.initialize_async
24
42
 
43
+ @spec_store = store
25
44
  @gate_overrides = {}
26
45
  @config_overrides = {}
46
+ @options = options
47
+ @persistent_storage_utils = persistent_storage_utils
27
48
  end
28
49
 
29
50
  def maybe_restart_background_threads
@@ -52,7 +73,8 @@ module Statsig
52
73
  eval_spec(user, @spec_store.get_gate(gate_name))
53
74
  end
54
75
 
55
- def get_config(user, config_name)
76
+ sig { params(user: StatsigUser, config_name: String, user_persisted_values: T.nilable(UserPersistedValues)).returns(ConfigResult) }
77
+ def get_config(user, config_name, user_persisted_values: nil)
56
78
  if @config_overrides.key?(config_name)
57
79
  id_type = @spec_store.has_config?(config_name) ? @spec_store.get_config(config_name)['idType'] : ''
58
80
  return Statsig::ConfigResult.new(
@@ -83,7 +105,26 @@ module Statsig
83
105
  )
84
106
  end
85
107
 
86
- eval_spec(user, @spec_store.get_config(config_name))
108
+ config = @spec_store.get_config(config_name)
109
+
110
+ # If persisted values is provided and the experiment is active, return sticky values if exists.
111
+ if !user_persisted_values.nil? && config['isActive'] == true
112
+ sticky_result = Statsig::ConfigResult.from_user_persisted_values(config_name, user_persisted_values)
113
+ return sticky_result unless sticky_result.nil?
114
+
115
+ # If it doesn't exist, then save to persisted storage if the user was assigned to an experiment group.
116
+ evaluation = eval_spec(user, config)
117
+ if evaluation.is_experiment_group
118
+ @persistent_storage_utils.add_evaluation_to_user_persisted_values(user_persisted_values, config_name, evaluation)
119
+ @persistent_storage_utils.save_to_storage(user, config['idType'], user_persisted_values)
120
+ end
121
+ # Otherwise, remove from persisted storage
122
+ else
123
+ @persistent_storage_utils.remove_experiment_from_storage(user, config['idType'], config_name)
124
+ evaluation = eval_spec(user, config)
125
+ end
126
+
127
+ return evaluation
87
128
  end
88
129
 
89
130
  def get_layer(user, layer_name)
@@ -98,12 +139,32 @@ module Statsig
98
139
  eval_spec(user, @spec_store.get_layer(layer_name))
99
140
  end
100
141
 
101
- def get_client_initialize_response(user)
142
+ def list_gates
143
+ @spec_store.gates.map { |name, _| name }
144
+ end
145
+
146
+ def list_configs
147
+ @spec_store.configs.map { |name, config| name if config['entity'] == 'dynamic_config' }.compact
148
+ end
149
+
150
+ def list_experiments
151
+ @spec_store.configs.map { |name, config| name if config['entity'] == 'experiment' }.compact
152
+ end
153
+
154
+ def list_autotunes
155
+ @spec_store.configs.map { |name, config| name if config['entity'] == 'autotune' }.compact
156
+ end
157
+
158
+ def list_layers
159
+ @spec_store.layers.map { |name, _| name }
160
+ end
161
+
162
+ def get_client_initialize_response(user, hash, client_sdk_key)
102
163
  if @spec_store.is_ready_for_checks == false
103
164
  return nil
104
165
  end
105
166
 
106
- formatter = ClientInitializeHelpers::ResponseFormatter.new(self, user)
167
+ formatter = ClientInitializeHelpers::ResponseFormatter.new(self, user, hash, client_sdk_key)
107
168
 
108
169
  evaluated_keys = {}
109
170
  if user.user_id.nil? == false
@@ -123,6 +184,8 @@ module Statsig
123
184
  "generator" => "statsig-ruby-sdk",
124
185
  "evaluated_keys" => evaluated_keys,
125
186
  "time" => 0,
187
+ "hash_used" => hash,
188
+ "user_hash" => user.to_hash_without_stable_id()
126
189
  }
127
190
  end
128
191
 
@@ -148,6 +211,7 @@ module Statsig
148
211
  @config_overrides[config] = value
149
212
  end
150
213
 
214
+ sig { params(user: StatsigUser, config: Hash).returns(ConfigResult) }
151
215
  def eval_spec(user, config)
152
216
  default_rule_id = 'default'
153
217
  exposures = []
@@ -178,7 +242,8 @@ module Statsig
178
242
  ),
179
243
  is_experiment_group: result.is_experiment_group,
180
244
  group_name: result.group_name,
181
- id_type: config['idType']
245
+ id_type: config['idType'],
246
+ target_app_ids: config['targetAppIDs']
182
247
  )
183
248
  end
184
249
 
@@ -200,7 +265,8 @@ module Statsig
200
265
  @spec_store.init_reason
201
266
  ),
202
267
  group_name: nil,
203
- id_type: config['idType']
268
+ id_type: config['idType'],
269
+ target_app_ids: config['targetAppIDs']
204
270
  )
205
271
  end
206
272
 
@@ -264,7 +330,7 @@ module Statsig
264
330
  operator = condition['operator']
265
331
  additional_values = condition['additionalValues']
266
332
  additional_values = Hash.new unless additional_values.is_a? Hash
267
- idType = condition['idType']
333
+ id_type = condition['idType']
268
334
 
269
335
  return $fetch_from_server unless type.is_a? String
270
336
  type = type.downcase
@@ -302,14 +368,14 @@ module Statsig
302
368
  when 'user_bucket'
303
369
  begin
304
370
  salt = additional_values['salt']
305
- unit_id = get_unit_id(user, idType) || ''
371
+ unit_id = user.get_unit_id(id_type) || ''
306
372
  # there are only 1000 user buckets as opposed to 10k for gate pass %
307
373
  value = compute_user_hash("#{salt}.#{unit_id}") % 1000
308
374
  rescue
309
375
  return false
310
376
  end
311
377
  when 'unit_id'
312
- value = get_unit_id(user, idType)
378
+ value = user.get_unit_id(id_type)
313
379
  else
314
380
  return $fetch_from_server
315
381
  end
@@ -359,7 +425,7 @@ module Statsig
359
425
  when 'none_case_sensitive'
360
426
  return !EvaluationHelpers::match_string_in_array(target, value, false, ->(a, b) { a == b })
361
427
 
362
- #string
428
+ # string
363
429
  when 'str_starts_with_any'
364
430
  return EvaluationHelpers::match_string_in_array(target, value, true, ->(a, b) { a.start_with?(b) })
365
431
  when 'str_ends_with_any'
@@ -468,7 +534,7 @@ module Statsig
468
534
  def eval_pass_percent(user, rule, config_salt)
469
535
  return false unless config_salt.is_a?(String) && !rule['passPercentage'].nil?
470
536
  begin
471
- unit_id = get_unit_id(user, rule['idType']) || ''
537
+ unit_id = user.get_unit_id(rule['idType']) || ''
472
538
  rule_salt = rule['salt'] || rule['id'] || ''
473
539
  hash = compute_user_hash("#{config_salt}.#{rule_salt}.#{unit_id}")
474
540
  return (hash % 10000) < (rule['passPercentage'].to_f * 100)
@@ -477,14 +543,6 @@ module Statsig
477
543
  end
478
544
  end
479
545
 
480
- def get_unit_id(user, id_type)
481
- if id_type.is_a?(String) && id_type.downcase != 'userid'
482
- return nil unless user&.custom_ids.is_a? Hash
483
- return user.custom_ids[id_type] || user.custom_ids[id_type.downcase]
484
- end
485
- user.user_id
486
- end
487
-
488
546
  def compute_user_hash(user_hash)
489
547
  Digest::SHA256.digest(user_hash).unpack('Q>')[0]
490
548
  end
@@ -0,0 +1,70 @@
1
+ # typed: false
2
+
3
+ require 'sorbet-runtime'
4
+
5
+ class FeatureGate
6
+ extend T::Sig
7
+
8
+ sig { returns(String) }
9
+ attr_accessor :name
10
+
11
+ sig { returns(T::Boolean) }
12
+ attr_accessor :value
13
+
14
+ sig { returns(String) }
15
+ attr_accessor :rule_id
16
+
17
+ sig { returns(T.nilable(String)) }
18
+ attr_accessor :group_name
19
+
20
+ sig { returns(String) }
21
+ attr_accessor :id_type
22
+
23
+ sig { returns(T.nilable(Statsig::EvaluationDetails)) }
24
+ attr_accessor :evaluation_details
25
+
26
+ sig { returns(T.nilable(T::Array[String])) }
27
+ attr_accessor :target_app_ids
28
+
29
+ sig do
30
+ params(
31
+ name: String,
32
+ value: T::Boolean,
33
+ rule_id: String,
34
+ group_name: T.nilable(String),
35
+ id_type: String,
36
+ evaluation_details: T.nilable(Statsig::EvaluationDetails),
37
+ target_app_ids: T.nilable(T::Array[String])
38
+ ).void
39
+ end
40
+ def initialize(
41
+ name,
42
+ value: false,
43
+ rule_id: '',
44
+ group_name: nil,
45
+ id_type: '',
46
+ evaluation_details: nil,
47
+ target_app_ids: nil
48
+ )
49
+ @name = name
50
+ @value = value
51
+ @rule_id = rule_id
52
+ @group_name = group_name
53
+ @id_type = id_type
54
+ @evaluation_details = evaluation_details
55
+ @target_app_ids = target_app_ids
56
+ end
57
+
58
+ sig { params(res: Statsig::ConfigResult).returns(FeatureGate) }
59
+ def self.from_config_result(res)
60
+ new(
61
+ res.name,
62
+ value: res.gate_value,
63
+ rule_id: res.rule_id,
64
+ group_name: res.group_name,
65
+ id_type: res.id_type,
66
+ evaluation_details: res.evaluation_details,
67
+ target_app_ids: res.target_app_ids
68
+ )
69
+ end
70
+ end
data/lib/hash_utils.rb ADDED
@@ -0,0 +1,32 @@
1
+ require 'json'
2
+ module Statsig
3
+ class HashUtils
4
+ def self.djb2(input_str)
5
+ hash = 0
6
+ input_str.each_char.each do |c|
7
+ hash = (hash << 5) - hash + c.ord
8
+ hash &= hash
9
+ end
10
+ hash &= 0xFFFFFFFF # Convert to unsigned 32-bit integer
11
+ return hash.to_s
12
+ end
13
+
14
+ def self.djb2ForHash(input_hash)
15
+ return djb2(input_hash.to_json)
16
+ end
17
+
18
+ def self.sha256(input_str)
19
+ return Digest::SHA256.base64digest(input_str)
20
+ end
21
+
22
+ def self.sortHash(input_hash)
23
+ dictionary = input_hash.clone.sort_by { |key| key }.to_h;
24
+ input_hash.each do |key, value|
25
+ if value.is_a?(Hash)
26
+ dictionary[key] = self.sortHash(value)
27
+ end
28
+ end
29
+ return dictionary
30
+ end
31
+ end
32
+ end
data/lib/id_list.rb CHANGED
@@ -19,7 +19,7 @@ module Statsig
19
19
  end
20
20
 
21
21
  def self.new_empty(json)
22
- self.new(json)
22
+ new(json)
23
23
  @size = 0
24
24
  end
25
25
 
@@ -0,0 +1,12 @@
1
+ # typed: true
2
+ module Statsig
3
+ module Interfaces
4
+ class IUserPersistentStorage
5
+ def load(key)
6
+ nil
7
+ end
8
+
9
+ def save(key, data) end
10
+ end
11
+ end
12
+ end
data/lib/layer.rb CHANGED
@@ -17,11 +17,25 @@ class Layer
17
17
  sig { returns(String) }
18
18
  attr_accessor :rule_id
19
19
 
20
- sig { params(name: String, value: T::Hash[String, T.untyped], rule_id: String, exposure_log_func: T.any(Method, Proc, NilClass)).void }
21
- def initialize(name, value = {}, rule_id = '', exposure_log_func = nil)
20
+ sig { returns(String) }
21
+ attr_accessor :group_name
22
+
23
+ sig do
24
+ params(
25
+ name: String,
26
+ value: T::Hash[String, T.untyped],
27
+ rule_id: String,
28
+ group_name: T.nilable(String),
29
+ allocated_experiment: T.nilable(String),
30
+ exposure_log_func: T.any(Method, Proc, NilClass)
31
+ ).void
32
+ end
33
+ def initialize(name, value = {}, rule_id = '', group_name = nil, allocated_experiment = nil, exposure_log_func = nil)
22
34
  @name = name
23
35
  @value = value
24
36
  @rule_id = rule_id
37
+ @group_name = group_name
38
+ @allocated_experiment = allocated_experiment
25
39
  @exposure_log_func = exposure_log_func
26
40
  end
27
41
 
@@ -58,4 +72,4 @@ class Layer
58
72
 
59
73
  @value[index]
60
74
  end
61
- end
75
+ end
data/lib/network.rb CHANGED
@@ -5,8 +5,9 @@ require 'json'
5
5
  require 'securerandom'
6
6
  require 'sorbet-runtime'
7
7
  require 'uri_helper'
8
+ require 'connection_pool'
8
9
 
9
- $retry_codes = [408, 500, 502, 503, 504, 522, 524, 599]
10
+ RETRY_CODES = [408, 500, 502, 503, 504, 522, 524, 599].freeze
10
11
 
11
12
  module Statsig
12
13
  class NetworkError < StandardError
@@ -22,7 +23,6 @@ module Statsig
22
23
  extend T::Sig
23
24
 
24
25
  sig { params(server_secret: String, options: StatsigOptions, backoff_mult: Integer).void }
25
-
26
26
  def initialize(server_secret, options, backoff_mult = 10)
27
27
  super()
28
28
  URIHelper.initialize(options)
@@ -33,29 +33,67 @@ module Statsig
33
33
  @post_logs_retry_backoff = options.post_logs_retry_backoff
34
34
  @post_logs_retry_limit = options.post_logs_retry_limit
35
35
  @session_id = SecureRandom.uuid
36
+ @connection_pool = ConnectionPool.new(size: 3) do
37
+ meta = Statsig.get_statsig_metadata
38
+ client = HTTP.use(:auto_inflate).headers(
39
+ {
40
+ 'STATSIG-API-KEY' => @server_secret,
41
+ 'STATSIG-SERVER-SESSION-ID' => @session_id,
42
+ 'Content-Type' => 'application/json; charset=UTF-8',
43
+ 'STATSIG-SDK-TYPE' => meta['sdkType'],
44
+ 'STATSIG-SDK-VERSION' => meta['sdkVersion'],
45
+ 'STATSIG-SDK-LANGUAGE-VERSION' => meta['languageVersion'],
46
+ 'Accept-Encoding' => 'gzip'
47
+ }
48
+ ).accept(:json)
49
+ if @timeout
50
+ client = client.timeout(@timeout)
51
+ end
52
+
53
+ client
54
+ end
55
+ end
56
+
57
+ sig do
58
+ params(since_time: Integer)
59
+ .returns([T.any(HTTP::Response, NilClass), T.any(StandardError, NilClass)])
60
+ end
61
+ def download_config_specs(since_time)
62
+ get("download_config_specs/#{@server_secret}.json?sinceTime=#{since_time}")
63
+ end
64
+
65
+ class HttpMethod < T::Enum
66
+ enums do
67
+ GET = new
68
+ POST = new
69
+ end
70
+ end
71
+
72
+ sig do
73
+ params(endpoint: String, retries: Integer, backoff: Integer)
74
+ .returns([T.any(HTTP::Response, NilClass), T.any(StandardError, NilClass)])
75
+ end
76
+ def get(endpoint, retries = 0, backoff = 1)
77
+ request(HttpMethod::GET, endpoint, nil, retries, backoff)
36
78
  end
37
79
 
38
- sig { params(endpoint: String, body: String, retries: Integer, backoff: Integer)
39
- .returns([T.any(HTTP::Response, NilClass), T.any(StandardError, NilClass)]) }
80
+ sig do
81
+ params(endpoint: String, body: String, retries: Integer, backoff: Integer)
82
+ .returns([T.any(HTTP::Response, NilClass), T.any(StandardError, NilClass)])
83
+ end
84
+ def post(endpoint, body, retries = 0, backoff = 1)
85
+ request(HttpMethod::POST, endpoint, body, retries, backoff)
86
+ end
40
87
 
41
- def post_helper(endpoint, body, retries = 0, backoff = 1)
88
+ sig do
89
+ params(method: HttpMethod, endpoint: String, body: T.nilable(String), retries: Integer, backoff: Integer)
90
+ .returns([T.any(HTTP::Response, NilClass), T.any(StandardError, NilClass)])
91
+ end
92
+ def request(method, endpoint, body, retries = 0, backoff = 1)
42
93
  if @local_mode
43
94
  return nil, nil
44
95
  end
45
96
 
46
- meta = Statsig.get_statsig_metadata
47
- http = HTTP.headers(
48
- {
49
- "STATSIG-API-KEY" => @server_secret,
50
- "STATSIG-CLIENT-TIME" => (Time.now.to_f * 1000).to_i.to_s,
51
- "STATSIG-SERVER-SESSION-ID" => @session_id,
52
- "Content-Type" => "application/json; charset=UTF-8",
53
- "STATSIG-SDK-TYPE" => meta['sdkType'],
54
- "STATSIG-SDK-VERSION" => meta['sdkVersion'],
55
- }).accept(:json)
56
- if @timeout
57
- http = http.timeout(@timeout)
58
- end
59
97
  backoff_adjusted = backoff > 10 ? backoff += Random.rand(10) : backoff # to deter overlap
60
98
  if @post_logs_retry_backoff
61
99
  if @post_logs_retry_backoff.is_a? Integer
@@ -66,48 +104,59 @@ module Statsig
66
104
  end
67
105
  url = URIHelper.build_url(endpoint)
68
106
  begin
69
- res = http.post(url, body: body)
107
+ res = @connection_pool.with do |conn|
108
+ request = conn.headers('STATSIG-CLIENT-TIME' => (Time.now.to_f * 1000).to_i.to_s)
109
+ case method
110
+ when HttpMethod::GET
111
+ request.get(url)
112
+ when HttpMethod::POST
113
+ request.post(url, body: body)
114
+ end
115
+ end
70
116
  rescue StandardError => e
71
117
  ## network error retry
72
- return nil, e unless retries > 0
118
+ return nil, e unless retries.positive?
119
+
73
120
  sleep backoff_adjusted
74
- return post_helper(endpoint, body, retries - 1, backoff * @backoff_multiplier)
121
+ return request(method, endpoint, body, retries - 1, backoff * @backoff_multiplier)
75
122
  end
76
123
  return res, nil if res.status.success?
77
- return nil, NetworkError.new("Got an exception when making request to #{url}: #{res.to_s}", res.status.to_i) unless retries > 0 && $retry_codes.include?(res.code)
124
+
125
+ unless retries.positive? && RETRY_CODES.include?(res.code)
126
+ return res, NetworkError.new("Got an exception when making request to #{url}: #{res.to_s}",
127
+ res.status.to_i)
128
+ end
129
+
78
130
  ## status code retry
79
131
  sleep backoff_adjusted
80
- post_helper(endpoint, body, retries - 1, backoff * @backoff_multiplier)
132
+ request(method, endpoint, body, retries - 1, backoff * @backoff_multiplier)
81
133
  end
82
134
 
83
135
  def check_gate(user, gate_name)
84
- begin
85
- request_body = JSON.generate({ 'user' => user&.serialize(false), 'gateName' => gate_name })
86
- response, _ = post_helper('check_gate', request_body)
87
- return JSON.parse(response.body) unless response.nil?
88
- false
89
- rescue
90
- return false
91
- end
136
+ request_body = JSON.generate({ 'user' => user&.serialize(false), 'gateName' => gate_name })
137
+ response, = post('check_gate', request_body)
138
+ return JSON.parse(response.body) unless response.nil?
139
+
140
+ false
141
+ rescue StandardError
142
+ false
92
143
  end
93
144
 
94
145
  def get_config(user, dynamic_config_name)
95
- begin
96
- request_body = JSON.generate({ 'user' => user&.serialize(false), 'configName' => dynamic_config_name })
97
- response, _ = post_helper('get_config', request_body)
98
- return JSON.parse(response.body) unless response.nil?
99
- nil
100
- rescue
101
- return nil
102
- end
146
+ request_body = JSON.generate({ 'user' => user&.serialize(false), 'configName' => dynamic_config_name })
147
+ response, = post('get_config', request_body)
148
+ return JSON.parse(response.body) unless response.nil?
149
+
150
+ nil
151
+ rescue StandardError
152
+ nil
103
153
  end
104
154
 
105
155
  def post_logs(events)
106
- begin
107
- json_body = JSON.generate({ 'events' => events, 'statsigMetadata' => Statsig.get_statsig_metadata })
108
- post_helper('log_event', json_body, @post_logs_retry_limit)
109
- rescue
110
- end
156
+ json_body = JSON.generate({ 'events' => events, 'statsigMetadata' => Statsig.get_statsig_metadata })
157
+ post('log_event', json_body, @post_logs_retry_limit)
158
+ rescue StandardError
159
+
111
160
  end
112
161
  end
113
- end
162
+ end