statsig 1.27.0 → 1.29.0.pre.beta.1

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: f4d0e32be6be9e6110bf6237eb4ba33728dfde66385272bb9a15412662455873
4
- data.tar.gz: '03408269143b235e02fc8be9f650ed2d4fc778ddf3cc7ffb7242ed9b9494ab73'
3
+ metadata.gz: e279228e9eb701b7bcf06ac68ada05a4403f1d046c4ddc3343c87cf86d6be049
4
+ data.tar.gz: 3b110f44f460c9924aa909c1e9723094189a0fe4a20c6f7572c997cf40968e29
5
5
  SHA512:
6
- metadata.gz: dac08983696816714e346ff1381fa2e83847375d5cee5bd33eb533a83e92be53f33a8fc5217a63b6856f723ca9063a631d4beee260bff95673329f6bfcdd5097
7
- data.tar.gz: 8ad5c492697b2b8cb7c06eac670b03230190dbbe9210f4f8ab9bd951bd73ed0c3c91f45353fc4466d2d1295c09366ae95cae109521d98d1041c629d61202c092
6
+ metadata.gz: a4871a883213685bc1d6c537367f4de78cacc81b0cec6929853b57c80439895e0ed49d797ef5bb7733c5dce73df9c0bb1aa5ef4b8c3563351468b557f3e473b4
7
+ data.tar.gz: fbdc2952ed68a92640268c6931b59521e641d22448c2c8a897521637951d7c20ea6f0f6d4354252c184a8a1c0b9e03861f9b047c9634165998f6a9218660e7e6
@@ -1,6 +1,7 @@
1
1
  # typed: true
2
2
 
3
3
  require_relative 'hash_utils'
4
+ require 'sorbet-runtime'
4
5
 
5
6
  $empty_eval_result = {
6
7
  :gate_value => false,
@@ -12,6 +13,8 @@ $empty_eval_result = {
12
13
 
13
14
  module ClientInitializeHelpers
14
15
  class ResponseFormatter
16
+ extend T::Sig
17
+
15
18
  def initialize(evaluator, user, hash, client_sdk_key)
16
19
  @evaluator = evaluator
17
20
  @user = user
@@ -28,6 +31,13 @@ module ClientInitializeHelpers
28
31
 
29
32
  private
30
33
 
34
+ sig { params(secondary_exposures: T::Array[T::Hash[String, String]]).returns(T::Array[T::Hash[String, String]]) }
35
+ def filter_segments_from_secondary_exposures(secondary_exposures)
36
+ secondary_exposures.reject do |exposure|
37
+ exposure['gate'].to_s.start_with?('segment:')
38
+ end
39
+ end
40
+
31
41
  def to_response(config_name, config_spec)
32
42
  target_app_id = @evaluator.spec_store.get_app_id_for_sdk_key(@client_sdk_key)
33
43
  config_target_apps = config_spec['targetAppIDs']
@@ -49,7 +59,7 @@ module ClientInitializeHelpers
49
59
  :id_type => eval_result.id_type,
50
60
  :config_delegate => eval_result.config_delegate,
51
61
  :is_experiment_group => eval_result.is_experiment_group,
52
- :secondary_exposures => eval_result.secondary_exposures,
62
+ :secondary_exposures => filter_segments_from_secondary_exposures(eval_result.secondary_exposures),
53
63
  :undelegated_sec_exps => eval_result.undelegated_sec_exps
54
64
  }
55
65
 
@@ -94,7 +104,7 @@ module ClientInitializeHelpers
94
104
  "name" => hashed_name,
95
105
  "rule_id" => safe_eval_result[:rule_id],
96
106
  "secondary_exposures" => clean_exposures(safe_eval_result[:secondary_exposures])
97
- })]
107
+ }).compact]
98
108
  end
99
109
 
100
110
  def clean_exposures(exposures)
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)
@@ -123,7 +168,8 @@ module Statsig
123
168
  "generator" => "statsig-ruby-sdk",
124
169
  "evaluated_keys" => evaluated_keys,
125
170
  "time" => 0,
126
- "hash_used" => hash
171
+ "hash_used" => hash,
172
+ "user_hash" => user.to_hash_without_stable_id()
127
173
  }
128
174
  end
129
175
 
@@ -149,6 +195,7 @@ module Statsig
149
195
  @config_overrides[config] = value
150
196
  end
151
197
 
198
+ sig { params(user: StatsigUser, config: Hash).returns(ConfigResult) }
152
199
  def eval_spec(user, config)
153
200
  default_rule_id = 'default'
154
201
  exposures = []
@@ -200,7 +247,7 @@ module Statsig
200
247
  @spec_store.initial_config_sync_time,
201
248
  @spec_store.init_reason
202
249
  ),
203
- group_name: 'default',
250
+ group_name: nil,
204
251
  id_type: config['idType']
205
252
  )
206
253
  end
@@ -265,7 +312,7 @@ module Statsig
265
312
  operator = condition['operator']
266
313
  additional_values = condition['additionalValues']
267
314
  additional_values = Hash.new unless additional_values.is_a? Hash
268
- idType = condition['idType']
315
+ id_type = condition['idType']
269
316
 
270
317
  return $fetch_from_server unless type.is_a? String
271
318
  type = type.downcase
@@ -303,14 +350,14 @@ module Statsig
303
350
  when 'user_bucket'
304
351
  begin
305
352
  salt = additional_values['salt']
306
- unit_id = get_unit_id(user, idType) || ''
353
+ unit_id = user.get_unit_id(id_type) || ''
307
354
  # there are only 1000 user buckets as opposed to 10k for gate pass %
308
355
  value = compute_user_hash("#{salt}.#{unit_id}") % 1000
309
356
  rescue
310
357
  return false
311
358
  end
312
359
  when 'unit_id'
313
- value = get_unit_id(user, idType)
360
+ value = user.get_unit_id(id_type)
314
361
  else
315
362
  return $fetch_from_server
316
363
  end
@@ -360,7 +407,7 @@ module Statsig
360
407
  when 'none_case_sensitive'
361
408
  return !EvaluationHelpers::match_string_in_array(target, value, false, ->(a, b) { a == b })
362
409
 
363
- #string
410
+ # string
364
411
  when 'str_starts_with_any'
365
412
  return EvaluationHelpers::match_string_in_array(target, value, true, ->(a, b) { a.start_with?(b) })
366
413
  when 'str_ends_with_any'
@@ -469,7 +516,7 @@ module Statsig
469
516
  def eval_pass_percent(user, rule, config_salt)
470
517
  return false unless config_salt.is_a?(String) && !rule['passPercentage'].nil?
471
518
  begin
472
- unit_id = get_unit_id(user, rule['idType']) || ''
519
+ unit_id = user.get_unit_id(rule['idType']) || ''
473
520
  rule_salt = rule['salt'] || rule['id'] || ''
474
521
  hash = compute_user_hash("#{config_salt}.#{rule_salt}.#{unit_id}")
475
522
  return (hash % 10000) < (rule['passPercentage'].to_f * 100)
@@ -478,14 +525,6 @@ module Statsig
478
525
  end
479
526
  end
480
527
 
481
- def get_unit_id(user, id_type)
482
- if id_type.is_a?(String) && id_type.downcase != 'userid'
483
- return nil unless user&.custom_ids.is_a? Hash
484
- return user.custom_ids[id_type] || user.custom_ids[id_type.downcase]
485
- end
486
- user.user_id
487
- end
488
-
489
528
  def compute_user_hash(user_hash)
490
529
  Digest::SHA256.digest(user_hash).unpack('Q>')[0]
491
530
  end
data/lib/hash_utils.rb CHANGED
@@ -1,3 +1,4 @@
1
+ require 'json'
1
2
  module Statsig
2
3
  class HashUtils
3
4
  def self.djb2(input_str)
@@ -10,8 +11,22 @@ module Statsig
10
11
  return hash.to_s
11
12
  end
12
13
 
14
+ def self.djb2ForHash(input_hash)
15
+ return djb2(input_hash.to_json)
16
+ end
17
+
13
18
  def self.sha256(input_str)
14
19
  return Digest::SHA256.base64digest(input_str)
15
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
16
31
  end
17
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/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.27.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
@@ -103,6 +101,49 @@ class StatsigUser
103
101
  hash.compact
104
102
  end
105
103
 
104
+ def to_hash_without_stable_id()
105
+ hash = {}
106
+ if @user_id != nil
107
+ hash['userID'] = @user_id
108
+ end
109
+ if @email != nil
110
+ hash['email'] = @email
111
+ end
112
+ if @ip != nil
113
+ hash['ip'] = @ip
114
+ end
115
+ if @user_agent != nil
116
+ hash['userAgent'] = @user_agent
117
+ end
118
+ if @country != nil
119
+ hash['country'] = @country
120
+ end
121
+ if @locale != nil
122
+ hash['locale'] = @locale
123
+ end
124
+ if @app_version != nil
125
+ hash['appVersion'] = @app_version
126
+ end
127
+ if @custom != nil
128
+ hash['custom'] = Statsig::HashUtils.sortHash(@custom)
129
+ end
130
+ if @statsig_environment != nil
131
+ hash['statsigEnvironment'] = @statsig_environment.clone.sort_by { |key| key }.to_h
132
+ end
133
+ if @private_attributes != nil
134
+ hash['privateAttributes'] = Statsig::HashUtils.sortHash(@private_attributes)
135
+ end
136
+ custom_ids = {}
137
+ if @custom_ids != nil
138
+ custom_ids = @custom_ids.clone
139
+ if custom_ids.key?("stableID")
140
+ custom_ids.delete("stableID")
141
+ end
142
+ end
143
+ hash['customIDs'] = custom_ids.sort_by { |key| key }.to_h
144
+ return Statsig::HashUtils.djb2ForHash(hash.sort_by { |key| key }.to_h)
145
+ end
146
+
106
147
  def value_lookup
107
148
  {
108
149
  'userID' => @user_id,
@@ -123,6 +164,15 @@ class StatsigUser
123
164
  }
124
165
  end
125
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
+
126
176
  private
127
177
 
128
178
  sig {
@@ -146,5 +196,4 @@ class StatsigUser
146
196
 
147
197
  nil
148
198
  end
149
-
150
- 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.27.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-08-25 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: []