statsig 1.28.0 → 1.29.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: 9a8876d235dc185cdc997a812ce941de80f6d5075fb151e8539b65b354419734
4
- data.tar.gz: cc7fb865b12a909eb8bf188d9bc7f0e9ad07a70de6a7ac2e4911b1ceeb830281
3
+ metadata.gz: '093cc05e0b8f3bb7e2f0f019cf36ad33e5abc4c4349d2fb1faa056c1ff5aa341'
4
+ data.tar.gz: d66640f8b0c317ca018ff4ccc77d1d370af89b1c84416508631deb610236d519
5
5
  SHA512:
6
- metadata.gz: c030c79c5d3b98f9f8b6fba4a2afafebe97ddb3522e4b2788c4b1ec4065bf1e3de7f1f667586a3b34bf8a457d784179fa54745a7bfb949914c33079aa8558e95
7
- data.tar.gz: d85ada516a510f48e93109c01c3023c1566f3b5b8afc86c83f44b7ce6179946290e80df3c1ed899efd6cf642282e4e3fd5601987ccdaf3844b841acd8ea5af6f
6
+ metadata.gz: f634575320cd1ababfa25816ee8ac5fe27e34c761895b4ba6b20957363e487c295767d65e123ab7d306aa1ce2f535f55cc58570f8767790948d2647ab321150b
7
+ data.tar.gz: d563727b175a23afa50cb68cbaf91eb49435bae46b1a48b72d9bf0ff38febeefa86b886c34cc2632f37e357c44f96a1e2586050b41e9ac156697f4105c4f9f0b
@@ -42,7 +42,7 @@ module ClientInitializeHelpers
42
42
  target_app_id = @evaluator.spec_store.get_app_id_for_sdk_key(@client_sdk_key)
43
43
  config_target_apps = config_spec['targetAppIDs']
44
44
 
45
- unless target_app_id.nil? || config_target_apps.nil? || config_target_apps.include?(target_app_id)
45
+ unless target_app_id.nil? || (!config_target_apps.nil? && config_target_apps.include?(target_app_id))
46
46
  return nil
47
47
  end
48
48
 
data/lib/config_result.rb CHANGED
@@ -1,6 +1,11 @@
1
1
  # typed: true
2
+
3
+ require 'sorbet-runtime'
4
+
2
5
  module Statsig
3
6
  class ConfigResult
7
+ extend T::Sig
8
+
4
9
  attr_accessor :name
5
10
  attr_accessor :gate_value
6
11
  attr_accessor :json_value
@@ -20,7 +25,7 @@ module Statsig
20
25
  json_value = {},
21
26
  rule_id = '',
22
27
  secondary_exposures = [],
23
- config_delegate = '',
28
+ config_delegate = nil,
24
29
  explicit_parameters = [],
25
30
  is_experiment_group: false,
26
31
  evaluation_details: nil,
@@ -39,5 +44,39 @@ module Statsig
39
44
  @group_name = group_name
40
45
  @id_type = id_type
41
46
  end
47
+
48
+ sig { params(config_name: String, user_persisted_values: UserPersistedValues).returns(T.nilable(ConfigResult)) }
49
+ def self.from_user_persisted_values(config_name, user_persisted_values)
50
+ sticky_values = user_persisted_values[config_name]
51
+ return nil if sticky_values.nil?
52
+
53
+ from_hash(config_name, sticky_values)
54
+ end
55
+
56
+ sig { params(config_name: String, hash: Hash).returns(ConfigResult) }
57
+ def self.from_hash(config_name, hash)
58
+ new(
59
+ config_name,
60
+ hash['gate_value'],
61
+ hash['json_value'],
62
+ hash['rule_id'],
63
+ hash['secondary_exposures'],
64
+ evaluation_details: EvaluationDetails.persisted(hash['config_sync_time'], hash['init_time']),
65
+ group_name: hash['group_name']
66
+ )
67
+ end
68
+
69
+ sig { returns(Hash) }
70
+ def to_hash
71
+ {
72
+ json_value: @json_value,
73
+ gate_value: @gate_value,
74
+ rule_id: @rule_id,
75
+ secondary_exposures: @secondary_exposures,
76
+ config_sync_time: @evaluation_details.config_sync_time,
77
+ init_time: @init_time,
78
+ group_name: @group_name
79
+ }
80
+ end
42
81
  end
43
- end
82
+ end
@@ -26,13 +26,26 @@ class DynamicConfig
26
26
  sig { returns(String) }
27
27
  attr_accessor :id_type
28
28
 
29
- sig { params(name: String, value: T::Hash[String, T.untyped], rule_id: String, group_name: T.nilable(String), id_type: String).void }
30
- def initialize(name, value = {}, rule_id = '', group_name = nil, id_type = '')
29
+ sig { returns(T.nilable(Statsig::EvaluationDetails)) }
30
+ attr_accessor :evaluation_details
31
+
32
+ sig do
33
+ params(
34
+ name: String,
35
+ value: T::Hash[String, T.untyped],
36
+ rule_id: String,
37
+ group_name: T.nilable(String),
38
+ id_type: String,
39
+ evaluation_details: T.nilable(Statsig::EvaluationDetails)
40
+ ).void
41
+ end
42
+ def initialize(name, value = {}, rule_id = '', group_name = nil, id_type = '', evaluation_details = nil)
31
43
  @name = name
32
44
  @value = value
33
45
  @rule_id = rule_id
34
46
  @group_name = group_name
35
47
  @id_type = id_type
48
+ @evaluation_details = evaluation_details
36
49
  end
37
50
 
38
51
  sig { params(index: String, default_value: T.untyped).returns(T.untyped) }
@@ -2,12 +2,13 @@
2
2
  module Statsig
3
3
 
4
4
  module EvaluationReason
5
- NETWORK = "Network"
6
- LOCAL_OVERRIDE = "LocalOverride"
7
- UNRECOGNIZED = "Unrecognized"
8
- UNINITIALIZED = "Uninitialized"
9
- BOOTSTRAP = "Bootstrap"
10
- DATA_ADAPTER = "DataAdapter"
5
+ NETWORK = 'Network'.freeze
6
+ LOCAL_OVERRIDE = 'LocalOverride'.freeze
7
+ UNRECOGNIZED = 'Unrecognized'.freeze
8
+ UNINITIALIZED = 'Uninitialized'.freeze
9
+ BOOTSTRAP = 'Bootstrap'.freeze
10
+ DATA_ADAPTER = 'DataAdapter'.freeze
11
+ PERSISTED = 'Persisted'.freeze
11
12
  end
12
13
 
13
14
  class EvaluationDetails
@@ -38,5 +39,9 @@ module Statsig
38
39
  def self.local_override(config_sync_time, init_time)
39
40
  EvaluationDetails.new(config_sync_time, init_time, EvaluationReason::LOCAL_OVERRIDE)
40
41
  end
42
+
43
+ def self.persisted(config_sync_time, init_time)
44
+ EvaluationDetails.new(config_sync_time, init_time, EvaluationReason::PERSISTED)
45
+ end
41
46
  end
42
- end
47
+ end
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,44 @@ 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, error_boundary, logger)
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
+ network: Network,
35
+ options: StatsigOptions,
36
+ error_callback: T.any(Method, Proc, NilClass),
37
+ diagnostics: Diagnostics,
38
+ error_boundary: ErrorBoundary,
39
+ logger: StatsigLogger,
40
+ persistent_storage_utils: UserPersistentStorageUtils,
41
+ ).void
42
+ end
43
+ def initialize(network, options, error_callback, diagnostics, error_boundary, logger, persistent_storage_utils)
21
44
  @spec_store = Statsig::SpecStore.new(network, options, error_callback, diagnostics, error_boundary, logger)
22
45
  UAParser.initialize_async
23
46
  CountryLookup.initialize_async
24
47
 
25
48
  @gate_overrides = {}
26
49
  @config_overrides = {}
50
+ @options = options
51
+ @persistent_storage_utils = persistent_storage_utils
27
52
  end
28
53
 
29
54
  def maybe_restart_background_threads
@@ -52,7 +77,8 @@ module Statsig
52
77
  eval_spec(user, @spec_store.get_gate(gate_name))
53
78
  end
54
79
 
55
- def get_config(user, config_name)
80
+ sig { params(user: StatsigUser, config_name: String, user_persisted_values: T.nilable(UserPersistedValues)).returns(ConfigResult) }
81
+ def get_config(user, config_name, user_persisted_values: nil)
56
82
  if @config_overrides.key?(config_name)
57
83
  id_type = @spec_store.has_config?(config_name) ? @spec_store.get_config(config_name)['idType'] : ''
58
84
  return Statsig::ConfigResult.new(
@@ -83,7 +109,26 @@ module Statsig
83
109
  )
84
110
  end
85
111
 
86
- eval_spec(user, @spec_store.get_config(config_name))
112
+ config = @spec_store.get_config(config_name)
113
+
114
+ # If persisted values is provided and the experiment is active, return sticky values if exists.
115
+ if !user_persisted_values.nil? && config['isActive'] == true
116
+ sticky_result = Statsig::ConfigResult.from_user_persisted_values(config_name, user_persisted_values)
117
+ return sticky_result unless sticky_result.nil?
118
+
119
+ # If it doesn't exist, then save to persisted storage if the user was assigned to an experiment group.
120
+ evaluation = eval_spec(user, config)
121
+ if evaluation.is_experiment_group
122
+ @persistent_storage_utils.add_evaluation_to_user_persisted_values(user_persisted_values, config_name, evaluation)
123
+ @persistent_storage_utils.save_to_storage(user, config['idType'], user_persisted_values)
124
+ end
125
+ # Otherwise, remove from persisted storage
126
+ else
127
+ @persistent_storage_utils.remove_experiment_from_storage(user, config['idType'], config_name)
128
+ evaluation = eval_spec(user, config)
129
+ end
130
+
131
+ return evaluation
87
132
  end
88
133
 
89
134
  def get_layer(user, layer_name)
@@ -150,6 +195,7 @@ module Statsig
150
195
  @config_overrides[config] = value
151
196
  end
152
197
 
198
+ sig { params(user: StatsigUser, config: Hash).returns(ConfigResult) }
153
199
  def eval_spec(user, config)
154
200
  default_rule_id = 'default'
155
201
  exposures = []
@@ -266,7 +312,7 @@ module Statsig
266
312
  operator = condition['operator']
267
313
  additional_values = condition['additionalValues']
268
314
  additional_values = Hash.new unless additional_values.is_a? Hash
269
- idType = condition['idType']
315
+ id_type = condition['idType']
270
316
 
271
317
  return $fetch_from_server unless type.is_a? String
272
318
  type = type.downcase
@@ -304,14 +350,14 @@ module Statsig
304
350
  when 'user_bucket'
305
351
  begin
306
352
  salt = additional_values['salt']
307
- unit_id = get_unit_id(user, idType) || ''
353
+ unit_id = user.get_unit_id(id_type) || ''
308
354
  # there are only 1000 user buckets as opposed to 10k for gate pass %
309
355
  value = compute_user_hash("#{salt}.#{unit_id}") % 1000
310
356
  rescue
311
357
  return false
312
358
  end
313
359
  when 'unit_id'
314
- value = get_unit_id(user, idType)
360
+ value = user.get_unit_id(id_type)
315
361
  else
316
362
  return $fetch_from_server
317
363
  end
@@ -361,7 +407,7 @@ module Statsig
361
407
  when 'none_case_sensitive'
362
408
  return !EvaluationHelpers::match_string_in_array(target, value, false, ->(a, b) { a == b })
363
409
 
364
- #string
410
+ # string
365
411
  when 'str_starts_with_any'
366
412
  return EvaluationHelpers::match_string_in_array(target, value, true, ->(a, b) { a.start_with?(b) })
367
413
  when 'str_ends_with_any'
@@ -470,7 +516,7 @@ module Statsig
470
516
  def eval_pass_percent(user, rule, config_salt)
471
517
  return false unless config_salt.is_a?(String) && !rule['passPercentage'].nil?
472
518
  begin
473
- unit_id = get_unit_id(user, rule['idType']) || ''
519
+ unit_id = user.get_unit_id(rule['idType']) || ''
474
520
  rule_salt = rule['salt'] || rule['id'] || ''
475
521
  hash = compute_user_hash("#{config_salt}.#{rule_salt}.#{unit_id}")
476
522
  return (hash % 10000) < (rule['passPercentage'].to_f * 100)
@@ -479,14 +525,6 @@ module Statsig
479
525
  end
480
526
  end
481
527
 
482
- def get_unit_id(user, id_type)
483
- if id_type.is_a?(String) && id_type.downcase != 'userid'
484
- return nil unless user&.custom_ids.is_a? Hash
485
- return user.custom_ids[id_type] || user.custom_ids[id_type.downcase]
486
- end
487
- user.user_id
488
- end
489
-
490
528
  def compute_user_hash(user_hash)
491
529
  Digest::SHA256.digest(user_hash).unpack('Q>')[0]
492
530
  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
@@ -53,11 +53,42 @@ module Statsig
53
53
  end
54
54
  end
55
55
 
56
+ sig do
57
+ params(since_time: Integer)
58
+ .returns([T.any(HTTP::Response, NilClass), T.any(StandardError, NilClass)])
59
+ end
60
+ def download_config_specs(since_time)
61
+ get("download_config_specs/#{@server_secret}.json?sinceTime=#{since_time}")
62
+ end
63
+
64
+ class HttpMethod < T::Enum
65
+ enums do
66
+ GET = new
67
+ POST = new
68
+ end
69
+ end
70
+
71
+ sig do
72
+ params(endpoint: String, retries: Integer, backoff: Integer)
73
+ .returns([T.any(HTTP::Response, NilClass), T.any(StandardError, NilClass)])
74
+ end
75
+ def get(endpoint, retries = 0, backoff = 1)
76
+ request(HttpMethod::GET, endpoint, nil, retries, backoff)
77
+ end
78
+
56
79
  sig do
57
80
  params(endpoint: String, body: String, retries: Integer, backoff: Integer)
58
81
  .returns([T.any(HTTP::Response, NilClass), T.any(StandardError, NilClass)])
59
82
  end
60
- def post_helper(endpoint, body, retries = 0, backoff = 1)
83
+ def post(endpoint, body, retries = 0, backoff = 1)
84
+ request(HttpMethod::POST, endpoint, body, retries, backoff)
85
+ end
86
+
87
+ sig do
88
+ params(method: HttpMethod, endpoint: String, body: T.nilable(String), retries: Integer, backoff: Integer)
89
+ .returns([T.any(HTTP::Response, NilClass), T.any(StandardError, NilClass)])
90
+ end
91
+ def request(method, endpoint, body, retries = 0, backoff = 1)
61
92
  if @local_mode
62
93
  return nil, nil
63
94
  end
@@ -73,14 +104,20 @@ module Statsig
73
104
  url = URIHelper.build_url(endpoint)
74
105
  begin
75
106
  res = @connection_pool.with do |conn|
76
- conn.headers('STATSIG-CLIENT-TIME' => (Time.now.to_f * 1000).to_i.to_s).post(url, body: body)
107
+ request = conn.headers('STATSIG-CLIENT-TIME' => (Time.now.to_f * 1000).to_i.to_s)
108
+ case method
109
+ when HttpMethod::GET
110
+ request.get(url)
111
+ when HttpMethod::POST
112
+ request.post(url, body: body)
113
+ end
77
114
  end
78
115
  rescue StandardError => e
79
116
  ## network error retry
80
117
  return nil, e unless retries.positive?
81
118
 
82
119
  sleep backoff_adjusted
83
- return post_helper(endpoint, body, retries - 1, backoff * @backoff_multiplier)
120
+ return request(method, endpoint, body, retries - 1, backoff * @backoff_multiplier)
84
121
  end
85
122
  return res, nil if res.status.success?
86
123
 
@@ -91,12 +128,12 @@ module Statsig
91
128
 
92
129
  ## status code retry
93
130
  sleep backoff_adjusted
94
- post_helper(endpoint, body, retries - 1, backoff * @backoff_multiplier)
131
+ request(method, endpoint, body, retries - 1, backoff * @backoff_multiplier)
95
132
  end
96
133
 
97
134
  def check_gate(user, gate_name)
98
135
  request_body = JSON.generate({ 'user' => user&.serialize(false), 'gateName' => gate_name })
99
- response, = post_helper('check_gate', request_body)
136
+ response, = post('check_gate', request_body)
100
137
  return JSON.parse(response.body) unless response.nil?
101
138
 
102
139
  false
@@ -106,7 +143,7 @@ module Statsig
106
143
 
107
144
  def get_config(user, dynamic_config_name)
108
145
  request_body = JSON.generate({ 'user' => user&.serialize(false), 'configName' => dynamic_config_name })
109
- response, = post_helper('get_config', request_body)
146
+ response, = post('get_config', request_body)
110
147
  return JSON.parse(response.body) unless response.nil?
111
148
 
112
149
  nil
@@ -116,7 +153,7 @@ module Statsig
116
153
 
117
154
  def post_logs(events)
118
155
  json_body = JSON.generate({ 'events' => events, 'statsigMetadata' => Statsig.get_statsig_metadata })
119
- post_helper('log_event', json_body, @post_logs_retry_limit)
156
+ post('log_event', json_body, @post_logs_retry_limit)
120
157
  rescue StandardError
121
158
 
122
159
  end
data/lib/spec_store.rb CHANGED
@@ -4,6 +4,7 @@ require 'uri'
4
4
  require 'evaluation_details'
5
5
  require 'id_list'
6
6
  require 'concurrent-ruby'
7
+ require 'hash_utils'
7
8
 
8
9
  module Statsig
9
10
  class SpecStore
@@ -28,7 +29,8 @@ module Statsig
28
29
  :layers => {},
29
30
  :id_lists => {},
30
31
  :experiment_to_layer => {},
31
- :sdk_keys_to_app_ids => {}
32
+ :sdk_keys_to_app_ids => {},
33
+ :hashed_sdk_keys_to_app_ids => {}
32
34
  }
33
35
  @diagnostics = diagnostics
34
36
  @error_boundary = error_boundary
@@ -127,10 +129,18 @@ module Statsig
127
129
  @specs[:sdk_keys_to_app_ids].key?(sdk_key)
128
130
  end
129
131
 
132
+ def has_hashed_sdk_key?(hashed_sdk_key)
133
+ @specs[:hashed_sdk_keys_to_app_ids].key?(hashed_sdk_key)
134
+ end
135
+
130
136
  def get_app_id_for_sdk_key(sdk_key)
131
137
  if sdk_key.nil?
132
138
  return nil
133
139
  end
140
+ hashed_sdk_key = Statsig::HashUtils.djb2(sdk_key)
141
+ if has_hashed_sdk_key?(hashed_sdk_key)
142
+ return @specs[:hashed_sdk_keys_to_app_ids][hashed_sdk_key]
143
+ end
134
144
  return nil unless has_sdk_key?(sdk_key)
135
145
  @specs[:sdk_keys_to_app_ids][sdk_key]
136
146
  end
@@ -228,7 +238,7 @@ module Statsig
228
238
 
229
239
  error = nil
230
240
  begin
231
- response, e = @network.post_helper('download_config_specs', JSON.generate({ 'sinceTime' => @last_config_sync_time }))
241
+ response, e = @network.download_config_specs(@last_config_sync_time)
232
242
  code = response&.status.to_i
233
243
  if e.is_a? NetworkError
234
244
  code = e.http_code
@@ -293,6 +303,7 @@ module Statsig
293
303
  @specs[:layers] = new_layers
294
304
  @specs[:experiment_to_layer] = new_exp_to_layer
295
305
  @specs[:sdk_keys_to_app_ids] = specs_json['sdk_keys_to_app_ids'] || {}
306
+ @specs[:hashed_sdk_keys_to_app_ids] = specs_json['hashed_sdk_keys_to_app_ids'] || {}
296
307
 
297
308
  unless from_adapter
298
309
  save_config_specs_to_storage_adapter(specs_string)
@@ -323,7 +334,7 @@ module Statsig
323
334
 
324
335
  def get_id_lists_from_network
325
336
  tracker = @diagnostics.track('get_id_list_sources', 'network_request')
326
- response, e = @network.post_helper('get_id_lists', JSON.generate({ 'statsigMetadata' => Statsig.get_statsig_metadata }))
337
+ response, e = @network.post('get_id_lists', JSON.generate({ 'statsigMetadata' => Statsig.get_statsig_metadata }))
327
338
  code = response&.status.to_i
328
339
  if e.is_a? NetworkError
329
340
  code = e.http_code
data/lib/statsig.rb CHANGED
@@ -26,15 +26,20 @@ module Statsig
26
26
  @shared_instance = StatsigDriver.new(secret_key, options, error_callback)
27
27
  end
28
28
 
29
- sig { params(user: StatsigUser, gate_name: String).returns(T::Boolean) }
29
+ class CheckGateOptions < T::Struct
30
+ prop :disable_log_exposure, T::Boolean, default: false
31
+ end
32
+
33
+ sig { params(user: StatsigUser, gate_name: String, options: CheckGateOptions).returns(T::Boolean) }
30
34
  ##
31
35
  # Gets the boolean result of a gate, evaluated against the given user. An exposure event will automatically be logged for the gate.
32
36
  #
33
37
  # @param user A StatsigUser object used for the evaluation
34
38
  # @param gate_name The name of the gate being checked
35
- def self.check_gate(user, gate_name)
39
+ # @param options Additional options for evaluating the gate
40
+ def self.check_gate(user, gate_name, options = CheckGateOptions.new)
36
41
  ensure_initialized
37
- @shared_instance&.check_gate(user, gate_name)
42
+ @shared_instance&.check_gate(user, gate_name, options)
38
43
  end
39
44
 
40
45
  sig { params(user: StatsigUser, gate_name: String).returns(T::Boolean) }
@@ -45,7 +50,7 @@ module Statsig
45
50
  # @param gate_name The name of the gate being checked
46
51
  def self.check_gate_with_exposure_logging_disabled(user, gate_name)
47
52
  ensure_initialized
48
- @shared_instance&.check_gate(user, gate_name, StatsigDriver::CheckGateOptions.new(log_exposure: false))
53
+ @shared_instance&.check_gate(user, gate_name, CheckGateOptions.new(disable_log_exposure: true))
49
54
  end
50
55
 
51
56
  sig { params(user: StatsigUser, gate_name: String).void }
@@ -59,15 +64,20 @@ module Statsig
59
64
  @shared_instance&.manually_log_gate_exposure(user, gate_name)
60
65
  end
61
66
 
62
- sig { params(user: StatsigUser, dynamic_config_name: String).returns(DynamicConfig) }
67
+ class GetConfigOptions < T::Struct
68
+ prop :disable_log_exposure, T::Boolean, default: false
69
+ end
70
+
71
+ sig { params(user: StatsigUser, dynamic_config_name: String, options: GetConfigOptions).returns(DynamicConfig) }
63
72
  ##
64
73
  # Get the values of a dynamic config, evaluated against the given user. An exposure event will automatically be logged for the dynamic config.
65
74
  #
66
75
  # @param user A StatsigUser object used for the evaluation
67
76
  # @param dynamic_config_name The name of the dynamic config
68
- def self.get_config(user, dynamic_config_name)
77
+ # @param options Additional options for evaluating the config
78
+ def self.get_config(user, dynamic_config_name, options = GetConfigOptions.new)
69
79
  ensure_initialized
70
- @shared_instance&.get_config(user, dynamic_config_name)
80
+ @shared_instance&.get_config(user, dynamic_config_name, options)
71
81
  end
72
82
 
73
83
  sig { params(user: StatsigUser, dynamic_config_name: String).returns(DynamicConfig) }
@@ -78,7 +88,7 @@ module Statsig
78
88
  # @param dynamic_config_name The name of the dynamic config
79
89
  def self.get_config_with_exposure_logging_disabled(user, dynamic_config_name)
80
90
  ensure_initialized
81
- @shared_instance&.get_config(user, dynamic_config_name, StatsigDriver::GetConfigOptions.new(log_exposure: false))
91
+ @shared_instance&.get_config(user, dynamic_config_name, GetConfigOptions.new(disable_log_exposure: true))
82
92
  end
83
93
 
84
94
  sig { params(user: StatsigUser, dynamic_config: String).void }
@@ -92,15 +102,21 @@ module Statsig
92
102
  @shared_instance&.manually_log_config_exposure(user, dynamic_config)
93
103
  end
94
104
 
95
- sig { params(user: StatsigUser, experiment_name: String).returns(DynamicConfig) }
105
+ class GetExperimentOptions < T::Struct
106
+ prop :disable_log_exposure, T::Boolean, default: false
107
+ prop :user_persisted_values, T.nilable(T::Hash[String, Hash]), default: nil
108
+ end
109
+
110
+ sig { params(user: StatsigUser, experiment_name: String, options: GetExperimentOptions).returns(DynamicConfig) }
96
111
  ##
97
112
  # Get the values of an experiment, evaluated against the given user. An exposure event will automatically be logged for the experiment.
98
113
  #
99
114
  # @param user A StatsigUser object used for the evaluation
100
115
  # @param experiment_name The name of the experiment
101
- def self.get_experiment(user, experiment_name)
116
+ # @param options Additional options for evaluating the experiment
117
+ def self.get_experiment(user, experiment_name, options = GetExperimentOptions.new)
102
118
  ensure_initialized
103
- @shared_instance&.get_experiment(user, experiment_name)
119
+ @shared_instance&.get_experiment(user, experiment_name, options)
104
120
  end
105
121
 
106
122
  sig { params(user: StatsigUser, experiment_name: String).returns(DynamicConfig) }
@@ -111,7 +127,7 @@ module Statsig
111
127
  # @param experiment_name The name of the experiment
112
128
  def self.get_experiment_with_exposure_logging_disabled(user, experiment_name)
113
129
  ensure_initialized
114
- @shared_instance&.get_experiment(user, experiment_name, StatsigDriver::GetExperimentOptions.new(log_exposure: false))
130
+ @shared_instance&.get_experiment(user, experiment_name, GetExperimentOptions.new(disable_log_exposure: true))
115
131
  end
116
132
 
117
133
  sig { params(user: StatsigUser, experiment_name: String).void }
@@ -125,16 +141,26 @@ module Statsig
125
141
  @shared_instance&.manually_log_config_exposure(user, experiment_name)
126
142
  end
127
143
 
128
- sig { params(user: StatsigUser, layer_name: String).returns(Layer) }
144
+ sig { params(user: StatsigUser, id_type: String).returns(UserPersistedValues) }
145
+ def self.get_user_persisted_values(user, id_type)
146
+ ensure_initialized
147
+ @shared_instance&.get_user_persisted_values(user, id_type)
148
+ end
149
+
150
+ class GetLayerOptions < T::Struct
151
+ prop :disable_log_exposure, T::Boolean, default: false
152
+ end
153
+
154
+ sig { params(user: StatsigUser, layer_name: String, options: GetLayerOptions).returns(Layer) }
129
155
  ##
130
156
  # Get the values of a layer, evaluated against the given user.
131
157
  # Exposure events will be fired when get or get_typed is called on the resulting Layer class.
132
158
  #
133
159
  # @param user A StatsigUser object used for the evaluation
134
160
  # @param layer_name The name of the layer
135
- def self.get_layer(user, layer_name)
161
+ def self.get_layer(user, layer_name, options = GetLayerOptions.new)
136
162
  ensure_initialized
137
- @shared_instance&.get_layer(user, layer_name)
163
+ @shared_instance&.get_layer(user, layer_name, options)
138
164
  end
139
165
 
140
166
  sig { params(user: StatsigUser, layer_name: String).returns(Layer) }
@@ -145,7 +171,7 @@ module Statsig
145
171
  # @param layer_name The name of the layer
146
172
  def self.get_layer_with_exposure_logging_disabled(user, layer_name)
147
173
  ensure_initialized
148
- @shared_instance&.get_layer(user, layer_name, StatsigDriver::GetLayerOptions.new(log_exposure: false))
174
+ @shared_instance&.get_layer(user, layer_name, GetLayerOptions.new(disable_log_exposure: true))
149
175
  end
150
176
 
151
177
  sig { params(user: StatsigUser, layer_name: String, parameter_name: String).void }
@@ -239,7 +265,7 @@ module Statsig
239
265
  def self.get_statsig_metadata
240
266
  {
241
267
  'sdkType' => 'ruby-server',
242
- 'sdkVersion' => '1.28.0',
268
+ 'sdkVersion' => '1.29.0',
243
269
  }
244
270
  end
245
271
 
@@ -252,7 +278,6 @@ module Statsig
252
278
  end
253
279
 
254
280
  sig { params(options: T.any(StatsigOptions, NilClass)).void }
255
-
256
281
  def self.bind_sorbet_loggers(options)
257
282
  if options&.disable_sorbet_logging_handlers == true
258
283
  return
@@ -19,7 +19,6 @@ class StatsigDriver
19
19
  extend T::Sig
20
20
 
21
21
  sig { params(secret_key: String, options: T.any(StatsigOptions, NilClass), error_callback: T.any(Method, Proc, NilClass)).void }
22
-
23
22
  def initialize(secret_key, options = nil, error_callback = nil)
24
23
  unless secret_key.start_with?('secret-')
25
24
  raise Statsig::ValueError.new('Invalid secret key provided. Provide your project secret key from the Statsig console')
@@ -38,20 +37,16 @@ class StatsigDriver
38
37
  @secret_key = secret_key
39
38
  @net = Statsig::Network.new(secret_key, @options)
40
39
  @logger = Statsig::StatsigLogger.new(@net, @options, @err_boundary)
41
- @evaluator = Statsig::Evaluator.new(@net, @options, error_callback, @diagnostics, @err_boundary, @logger)
40
+ @persistent_storage_utils = Statsig::UserPersistentStorageUtils.new(@options)
41
+ @evaluator = Statsig::Evaluator.new(@net, @options, error_callback, @diagnostics, @err_boundary, @logger, @persistent_storage_utils)
42
42
  tracker.end(success: true)
43
43
 
44
44
  @logger.log_diagnostics_event(@diagnostics)
45
45
  }, caller: __method__.to_s)
46
46
  end
47
47
 
48
- class CheckGateOptions < T::Struct
49
- prop :log_exposure, T::Boolean, default: true
50
- end
51
-
52
- sig { params(user: StatsigUser, gate_name: String, options: CheckGateOptions).returns(T::Boolean) }
53
-
54
- def check_gate(user, gate_name, options = CheckGateOptions.new)
48
+ sig { params(user: StatsigUser, gate_name: String, options: Statsig::CheckGateOptions).returns(T::Boolean) }
49
+ def check_gate(user, gate_name, options = Statsig::CheckGateOptions.new)
55
50
  @err_boundary.capture(task: lambda {
56
51
  run_with_diagnostics(task: lambda {
57
52
  user = verify_inputs(user, gate_name, "gate_name")
@@ -65,7 +60,7 @@ class StatsigDriver
65
60
  res = check_gate_fallback(user, gate_name)
66
61
  # exposure logged by the server
67
62
  else
68
- if options.log_exposure
63
+ if !options.disable_log_exposure
69
64
  @logger.log_gate_exposure(user, res.name, res.gate_value, res.rule_id, res.secondary_exposures, res.evaluation_details)
70
65
  end
71
66
  end
@@ -76,7 +71,6 @@ class StatsigDriver
76
71
  end
77
72
 
78
73
  sig { params(user: StatsigUser, gate_name: String).void }
79
-
80
74
  def manually_log_gate_exposure(user, gate_name)
81
75
  @err_boundary.capture(task: lambda {
82
76
  res = @evaluator.check_gate(user, gate_name)
@@ -85,38 +79,27 @@ class StatsigDriver
85
79
  })
86
80
  end
87
81
 
88
- class GetConfigOptions < T::Struct
89
- prop :log_exposure, T::Boolean, default: true
90
- end
91
-
92
- sig { params(user: StatsigUser, dynamic_config_name: String, options: GetConfigOptions).returns(DynamicConfig) }
93
-
94
- def get_config(user, dynamic_config_name, options = GetConfigOptions.new)
82
+ sig { params(user: StatsigUser, dynamic_config_name: String, options: Statsig::GetConfigOptions).returns(DynamicConfig) }
83
+ def get_config(user, dynamic_config_name, options = Statsig::GetConfigOptions.new)
95
84
  @err_boundary.capture(task: lambda {
96
85
  run_with_diagnostics(task: lambda {
97
86
  user = verify_inputs(user, dynamic_config_name, "dynamic_config_name")
98
- get_config_impl(user, dynamic_config_name, options)
87
+ get_config_impl(user, dynamic_config_name, options.disable_log_exposure)
99
88
  }, caller: __method__.to_s)
100
89
  }, recover: -> { DynamicConfig.new(dynamic_config_name) }, caller: __method__.to_s)
101
90
  end
102
91
 
103
- class GetExperimentOptions < T::Struct
104
- prop :log_exposure, T::Boolean, default: true
105
- end
106
-
107
- sig { params(user: StatsigUser, experiment_name: String, options: GetExperimentOptions).returns(DynamicConfig) }
108
-
109
- def get_experiment(user, experiment_name, options = GetExperimentOptions.new)
92
+ sig { params(user: StatsigUser, experiment_name: String, options: Statsig::GetExperimentOptions).returns(DynamicConfig) }
93
+ def get_experiment(user, experiment_name, options = Statsig::GetExperimentOptions.new)
110
94
  @err_boundary.capture(task: lambda {
111
95
  run_with_diagnostics(task: lambda {
112
96
  user = verify_inputs(user, experiment_name, "experiment_name")
113
- get_config_impl(user, experiment_name, options)
97
+ get_config_impl(user, experiment_name, options.disable_log_exposure, user_persisted_values: options.user_persisted_values)
114
98
  }, caller: __method__.to_s)
115
99
  }, recover: -> { DynamicConfig.new(experiment_name) }, caller: __method__.to_s)
116
100
  end
117
101
 
118
102
  sig { params(user: StatsigUser, config_name: String).void }
119
-
120
103
  def manually_log_config_exposure(user, config_name)
121
104
  @err_boundary.capture(task: lambda {
122
105
  res = @evaluator.get_config(user, config_name)
@@ -125,13 +108,18 @@ class StatsigDriver
125
108
  }, caller: __method__.to_s)
126
109
  end
127
110
 
128
- class GetLayerOptions < T::Struct
129
- prop :log_exposure, T::Boolean, default: true
130
- end
111
+ sig { params(user: StatsigUser, id_type: String).returns(Statsig::UserPersistedValues) }
112
+ def get_user_persisted_values(user, id_type)
113
+ @err_boundary.capture(task: lambda {
114
+ persisted_values = @persistent_storage_utils.get_user_persisted_values(user, id_type)
115
+ return {} if persisted_values.nil?
131
116
 
132
- sig { params(user: StatsigUser, layer_name: String, options: GetLayerOptions).returns(Layer) }
117
+ persisted_values
118
+ }, caller: __method__.to_s)
119
+ end
133
120
 
134
- def get_layer(user, layer_name, options = GetLayerOptions.new)
121
+ sig { params(user: StatsigUser, layer_name: String, options: Statsig::GetLayerOptions).returns(Layer) }
122
+ def get_layer(user, layer_name, options = Statsig::GetLayerOptions.new)
135
123
  @err_boundary.capture(task: lambda {
136
124
  run_with_diagnostics(task: lambda {
137
125
  user = verify_inputs(user, layer_name, "layer_name")
@@ -142,27 +130,26 @@ class StatsigDriver
142
130
  end
143
131
 
144
132
  if res == $fetch_from_server
145
- if res.config_delegate.empty?
133
+ if res.config_delegate.nil?
146
134
  return Layer.new(layer_name)
147
135
  end
148
136
  res = get_config_fallback(user, res.config_delegate)
149
137
  # exposure logged by the server
150
138
  end
151
139
 
152
- exposure_log_func = options.log_exposure ? lambda { |layer, parameter_name|
140
+ exposure_log_func = !options.disable_log_exposure ? lambda { |layer, parameter_name|
153
141
  @logger.log_layer_exposure(user, layer, parameter_name, res)
154
142
  } : nil
155
- Layer.new(res.name, res.json_value, res.rule_id, exposure_log_func)
143
+ Layer.new(res.name, res.json_value, res.rule_id, res.group_name, res.config_delegate, exposure_log_func)
156
144
  }, caller: __method__.to_s)
157
145
  }, recover: lambda { Layer.new(layer_name) }, caller: __method__.to_s)
158
146
  end
159
147
 
160
148
  sig { params(user: StatsigUser, layer_name: String, parameter_name: String).void }
161
-
162
149
  def manually_log_layer_parameter_exposure(user, layer_name, parameter_name)
163
150
  @err_boundary.capture(task: lambda {
164
151
  res = @evaluator.get_layer(user, layer_name)
165
- layer = Layer.new(layer_name, res.json_value, res.rule_id)
152
+ layer = Layer.new(layer_name, res.json_value, res.rule_id, res.group_name, res.config_delegate)
166
153
  context = { 'is_manual_exposure' => true }
167
154
  @logger.log_layer_exposure(user, layer, parameter_name, res, context)
168
155
  }, caller: __method__.to_s)
@@ -260,7 +247,6 @@ class StatsigDriver
260
247
  end
261
248
 
262
249
  sig { params(user: StatsigUser, config_name: String, variable_name: String).returns(StatsigUser) }
263
-
264
250
  def verify_inputs(user, config_name, variable_name)
265
251
  validate_user(user)
266
252
  if !config_name.is_a?(String) || config_name.empty?
@@ -272,8 +258,16 @@ class StatsigDriver
272
258
  normalize_user(user)
273
259
  end
274
260
 
275
- def get_config_impl(user, config_name, options)
276
- res = @evaluator.get_config(user, config_name)
261
+ sig do
262
+ params(
263
+ user: StatsigUser,
264
+ config_name: String,
265
+ disable_log_exposure: T::Boolean,
266
+ user_persisted_values: T.nilable(Statsig::UserPersistedValues)
267
+ ).returns(DynamicConfig)
268
+ end
269
+ def get_config_impl(user, config_name, disable_log_exposure, user_persisted_values: nil)
270
+ res = @evaluator.get_config(user, config_name, user_persisted_values: user_persisted_values)
277
271
  if res.nil?
278
272
  res = Statsig::ConfigResult.new(config_name)
279
273
  end
@@ -282,12 +276,12 @@ class StatsigDriver
282
276
  res = get_config_fallback(user, config_name)
283
277
  # exposure logged by the server
284
278
  else
285
- if options.log_exposure
279
+ if !disable_log_exposure
286
280
  @logger.log_config_exposure(user, res.name, res.rule_id, res.secondary_exposures, res.evaluation_details)
287
281
  end
288
282
  end
289
283
 
290
- DynamicConfig.new(res.name, res.json_value, res.rule_id, res.group_name, res.id_type)
284
+ DynamicConfig.new(res.name, res.json_value, res.rule_id, res.group_name, res.id_type, res.evaluation_details)
291
285
  end
292
286
 
293
287
  def validate_user(user)
@@ -2,6 +2,7 @@
2
2
 
3
3
  require 'sorbet-runtime'
4
4
  require_relative 'interfaces/data_store'
5
+ require_relative 'interfaces/user_persistent_storage'
5
6
 
6
7
  ##
7
8
  # Configuration options for the Statsig SDK.
@@ -21,7 +22,7 @@ class StatsigOptions
21
22
 
22
23
  # The base url used specifically to call download_config_specs.
23
24
  # Takes precedence over api_url_base
24
- sig { returns(T.any(String, NilClass)) }
25
+ sig { returns(String) }
25
26
  attr_accessor :api_url_download_config_specs
26
27
 
27
28
  sig { returns(T.any(Float, Integer)) }
@@ -106,10 +107,15 @@ class StatsigOptions
106
107
  # which overrides the default backoff time between retries
107
108
  attr_accessor :post_logs_retry_backoff
108
109
 
110
+ sig { returns(T.any(Statsig::Interfaces::IUserPersistentStorage, NilClass)) }
111
+ # A storage adapter for persisted values. Can be used for sticky bucketing users in experiments.
112
+ # Implements Statsig::Interfaces::IUserPersistentStorage.
113
+ attr_accessor :user_persistent_storage
114
+
109
115
  sig do
110
116
  params(
111
117
  environment: T.any(T::Hash[String, String], NilClass),
112
- api_url_base: String,
118
+ api_url_base: T.nilable(String),
113
119
  api_url_download_config_specs: T.any(String, NilClass),
114
120
  rulesets_sync_interval: T.any(Float, Integer),
115
121
  idlists_sync_interval: T.any(Float, Integer),
@@ -127,13 +133,13 @@ class StatsigOptions
127
133
  disable_sorbet_logging_handlers: T::Boolean,
128
134
  network_timeout: T.any(Integer, NilClass),
129
135
  post_logs_retry_limit: Integer,
130
- post_logs_retry_backoff: T.any(Method, Proc, Integer, NilClass)
136
+ post_logs_retry_backoff: T.any(Method, Proc, Integer, NilClass),
137
+ user_persistent_storage: T.any(Statsig::Interfaces::IUserPersistentStorage, NilClass)
131
138
  ).void
132
139
  end
133
-
134
140
  def initialize(
135
141
  environment = nil,
136
- api_url_base = 'https://statsigapi.net/v1',
142
+ api_url_base = nil,
137
143
  api_url_download_config_specs: nil,
138
144
  rulesets_sync_interval: 10,
139
145
  idlists_sync_interval: 60,
@@ -151,10 +157,12 @@ class StatsigOptions
151
157
  disable_sorbet_logging_handlers: false,
152
158
  network_timeout: nil,
153
159
  post_logs_retry_limit: 3,
154
- post_logs_retry_backoff: nil)
160
+ post_logs_retry_backoff: nil,
161
+ user_persistent_storage: nil
162
+ )
155
163
  @environment = environment.is_a?(Hash) ? environment : nil
156
- @api_url_base = api_url_base
157
- @api_url_download_config_specs = api_url_download_config_specs
164
+ @api_url_base = api_url_base || 'https://statsigapi.net/v1'
165
+ @api_url_download_config_specs = api_url_download_config_specs || api_url_base || 'https://api.statsigcdn.com/v1'
158
166
  @rulesets_sync_interval = rulesets_sync_interval
159
167
  @idlists_sync_interval = idlists_sync_interval
160
168
  @disable_rulesets_sync = disable_rulesets_sync
@@ -172,5 +180,7 @@ class StatsigOptions
172
180
  @network_timeout = network_timeout
173
181
  @post_logs_retry_limit = post_logs_retry_limit
174
182
  @post_logs_retry_backoff = post_logs_retry_backoff
183
+ @user_persistent_storage = user_persistent_storage
184
+
175
185
  end
176
- end
186
+ end
data/lib/statsig_user.rb CHANGED
@@ -49,7 +49,6 @@ class StatsigUser
49
49
  attr_accessor :private_attributes
50
50
 
51
51
  sig { returns(T.any(T::Hash[String, T.untyped], NilClass)) }
52
-
53
52
  def custom
54
53
  @custom
55
54
  end
@@ -61,7 +60,6 @@ class StatsigUser
61
60
  end
62
61
 
63
62
  sig { params(user_hash: T.any(T::Hash[T.any(String, Symbol), T.untyped], NilClass)).void }
64
-
65
63
  def initialize(user_hash)
66
64
  the_hash = user_hash
67
65
  begin
@@ -166,6 +164,15 @@ class StatsigUser
166
164
  }
167
165
  end
168
166
 
167
+ def get_unit_id(id_type)
168
+ if id_type.is_a?(String) && id_type.downcase != 'userid'
169
+ return nil unless @custom_ids.is_a? Hash
170
+
171
+ return @custom_ids[id_type] || @custom_ids[id_type.downcase]
172
+ end
173
+ @user_id
174
+ end
175
+
169
176
  private
170
177
 
171
178
  sig {
@@ -189,5 +196,4 @@ class StatsigUser
189
196
 
190
197
  nil
191
198
  end
192
-
193
- end
199
+ end
data/lib/uri_helper.rb CHANGED
@@ -17,7 +17,7 @@ class URIHelper
17
17
  sig { params(endpoint: String).returns(String) }
18
18
  def build_url(endpoint)
19
19
  api = @options.api_url_base
20
- if endpoint == 'download_config_specs' && !@options.api_url_download_config_specs.nil?
20
+ if endpoint.include?('download_config_specs')
21
21
  api = T.must(@options.api_url_download_config_specs)
22
22
  end
23
23
  unless api.end_with?('/')
@@ -0,0 +1,106 @@
1
+ # typed: false
2
+
3
+ require 'sorbet-runtime'
4
+ require 'statsig_options'
5
+
6
+ module Statsig
7
+ UserPersistedValues = T.type_alias { T::Hash[String, Hash] }
8
+
9
+ class UserPersistentStorageUtils
10
+ extend T::Sig
11
+
12
+ sig { returns(T::Hash[String, UserPersistedValues]) }
13
+ attr_accessor :cache
14
+
15
+ sig { returns(T.nilable(Interfaces::IUserPersistentStorage)) }
16
+ attr_accessor :storage
17
+
18
+ sig { params(options: StatsigOptions).void }
19
+ def initialize(options)
20
+ @storage = options.user_persistent_storage
21
+ @cache = {}
22
+ end
23
+
24
+ sig { params(user: StatsigUser, id_type: String).returns(T.nilable(UserPersistedValues)) }
25
+ def get_user_persisted_values(user, id_type)
26
+ key = self.class.get_storage_key(user, id_type)
27
+ return @cache[key] unless @cache[key].nil?
28
+
29
+ return load_from_storage(key)
30
+ end
31
+
32
+ sig { params(key: String).returns(T.nilable(UserPersistedValues)) }
33
+ def load_from_storage(key)
34
+ return if @storage.nil?
35
+
36
+ begin
37
+ storage_values = @storage.load(key)
38
+ rescue StandardError => e
39
+ puts "Failed to load key (#{key}) from user_persisted_storage (#{e.message})"
40
+ return nil
41
+ end
42
+
43
+ unless storage_values.nil?
44
+ parsed_values = self.class.parse(storage_values)
45
+ unless parsed_values.nil?
46
+ @cache[key] = parsed_values
47
+ return @cache[key]
48
+ end
49
+ end
50
+ return nil
51
+ end
52
+
53
+ sig { params(user: StatsigUser, id_type: String, user_persisted_values: UserPersistedValues).void }
54
+ def save_to_storage(user, id_type, user_persisted_values)
55
+ return if @storage.nil?
56
+
57
+ key = self.class.get_storage_key(user, id_type)
58
+ stringified = self.class.stringify(user_persisted_values)
59
+ return if stringified.nil?
60
+
61
+ begin
62
+ @storage.save(key, stringified)
63
+ rescue StandardError => e
64
+ puts "Failed to save key (#{key}) to user_persisted_storage (#{e.message})"
65
+ end
66
+ end
67
+
68
+ sig { params(user: StatsigUser, id_type: String, config_name: String).void }
69
+ def remove_experiment_from_storage(user, id_type, config_name)
70
+ persisted_values = get_user_persisted_values(user, id_type)
71
+ unless persisted_values.nil?
72
+ persisted_values.delete(config_name)
73
+ save_to_storage(user, id_type, persisted_values)
74
+ end
75
+ end
76
+
77
+ sig { params(user_persisted_values: T.nilable(UserPersistedValues), config_name: String, evaluation: ConfigResult).void }
78
+ def add_evaluation_to_user_persisted_values(user_persisted_values, config_name, evaluation)
79
+ if user_persisted_values.nil?
80
+ user_persisted_values = {}
81
+ end
82
+ user_persisted_values[config_name] = evaluation.to_hash
83
+ end
84
+
85
+ private
86
+
87
+ sig { params(values_string: String).returns(T.nilable(UserPersistedValues)) }
88
+ def self.parse(values_string)
89
+ return JSON.parse(values_string)
90
+ rescue JSON::ParserError
91
+ return nil
92
+ end
93
+
94
+ sig { params(values_object: UserPersistedValues).returns(T.nilable(String)) }
95
+ def self.stringify(values_object)
96
+ return JSON.generate(values_object)
97
+ rescue StandardError
98
+ return nil
99
+ end
100
+
101
+ sig { params(user: StatsigUser, id_type: String).returns(String) }
102
+ def self.get_storage_key(user, id_type)
103
+ "#{user.get_unit_id(id_type)}:#{id_type}"
104
+ end
105
+ end
106
+ end
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.28.0
4
+ version: 1.29.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: 2023-11-03 00:00:00.000000000 Z
11
+ date: 2023-12-13 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: bundler
@@ -333,6 +333,7 @@ files:
333
333
  - lib/hash_utils.rb
334
334
  - lib/id_list.rb
335
335
  - lib/interfaces/data_store.rb
336
+ - lib/interfaces/user_persistent_storage.rb
336
337
  - lib/layer.rb
337
338
  - lib/network.rb
338
339
  - lib/spec_store.rb
@@ -345,6 +346,7 @@ files:
345
346
  - lib/statsig_user.rb
346
347
  - lib/ua_parser.rb
347
348
  - lib/uri_helper.rb
349
+ - lib/user_persistent_storage_utils.rb
348
350
  homepage: https://rubygems.org/gems/statsig
349
351
  licenses:
350
352
  - ISC