statsig 1.28.0 → 1.29.0.pre.beta.1

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: 9a8876d235dc185cdc997a812ce941de80f6d5075fb151e8539b65b354419734
4
- data.tar.gz: cc7fb865b12a909eb8bf188d9bc7f0e9ad07a70de6a7ac2e4911b1ceeb830281
3
+ metadata.gz: e279228e9eb701b7bcf06ac68ada05a4403f1d046c4ddc3343c87cf86d6be049
4
+ data.tar.gz: 3b110f44f460c9924aa909c1e9723094189a0fe4a20c6f7572c997cf40968e29
5
5
  SHA512:
6
- metadata.gz: c030c79c5d3b98f9f8b6fba4a2afafebe97ddb3522e4b2788c4b1ec4065bf1e3de7f1f667586a3b34bf8a457d784179fa54745a7bfb949914c33079aa8558e95
7
- data.tar.gz: d85ada516a510f48e93109c01c3023c1566f3b5b8afc86c83f44b7ce6179946290e80df3c1ed899efd6cf642282e4e3fd5601987ccdaf3844b841acd8ea5af6f
6
+ metadata.gz: a4871a883213685bc1d6c537367f4de78cacc81b0cec6929853b57c80439895e0ed49d797ef5bb7733c5dce73df9c0bb1aa5ef4b8c3563351468b557f3e473b4
7
+ data.tar.gz: fbdc2952ed68a92640268c6931b59521e641d22448c2c8a897521637951d7c20ea6f0f6d4354252c184a8a1c0b9e03861f9b047c9634165998f6a9218660e7e6
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
@@ -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/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
@@ -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)
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-beta.1',
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")
@@ -149,7 +137,7 @@ class StatsigDriver
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
143
  Layer.new(res.name, res.json_value, res.rule_id, exposure_log_func)
@@ -158,7 +146,6 @@ class StatsigDriver
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)
@@ -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.
@@ -106,6 +107,11 @@ 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),
@@ -127,7 +133,8 @@ 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
140
 
@@ -151,7 +158,9 @@ class StatsigOptions
151
158
  disable_sorbet_logging_handlers: false,
152
159
  network_timeout: nil,
153
160
  post_logs_retry_limit: 3,
154
- post_logs_retry_backoff: nil)
161
+ post_logs_retry_backoff: nil,
162
+ user_persistent_storage: nil
163
+ )
155
164
  @environment = environment.is_a?(Hash) ? environment : nil
156
165
  @api_url_base = api_url_base
157
166
  @api_url_download_config_specs = api_url_download_config_specs
@@ -172,5 +181,7 @@ class StatsigOptions
172
181
  @network_timeout = network_timeout
173
182
  @post_logs_retry_limit = post_logs_retry_limit
174
183
  @post_logs_retry_backoff = post_logs_retry_backoff
184
+ @user_persistent_storage = user_persistent_storage
185
+
175
186
  end
176
- end
187
+ 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
@@ -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.pre.beta.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Statsig, Inc
8
- autorequire:
8
+ autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2023-11-03 00:00:00.000000000 Z
11
+ date: 2023-11-11 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,11 +346,12 @@ 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
351
353
  metadata: {}
352
- post_install_message:
354
+ post_install_message:
353
355
  rdoc_options: []
354
356
  require_paths:
355
357
  - lib
@@ -360,12 +362,12 @@ required_ruby_version: !ruby/object:Gem::Requirement
360
362
  version: 2.5.0
361
363
  required_rubygems_version: !ruby/object:Gem::Requirement
362
364
  requirements:
363
- - - ">="
365
+ - - ">"
364
366
  - !ruby/object:Gem::Version
365
- version: '0'
367
+ version: 1.3.1
366
368
  requirements: []
367
- rubygems_version: 3.2.33
368
- signing_key:
369
+ rubygems_version: 3.3.7
370
+ signing_key:
369
371
  specification_version: 4
370
372
  summary: Statsig server SDK for Ruby
371
373
  test_files: []