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 +4 -4
- data/lib/config_result.rb +40 -1
- data/lib/dynamic_config.rb +15 -2
- data/lib/evaluation_details.rb +12 -7
- data/lib/evaluator.rb +54 -16
- data/lib/id_list.rb +1 -1
- data/lib/interfaces/user_persistent_storage.rb +12 -0
- data/lib/spec_store.rb +12 -1
- data/lib/statsig.rb +43 -18
- data/lib/statsig_driver.rb +34 -40
- data/lib/statsig_options.rb +14 -3
- data/lib/statsig_user.rb +10 -4
- data/lib/user_persistent_storage_utils.rb +106 -0
- metadata +10 -8
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: e279228e9eb701b7bcf06ac68ada05a4403f1d046c4ddc3343c87cf86d6be049
|
4
|
+
data.tar.gz: 3b110f44f460c9924aa909c1e9723094189a0fe4a20c6f7572c997cf40968e29
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
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
|
data/lib/dynamic_config.rb
CHANGED
@@ -26,13 +26,26 @@ class DynamicConfig
|
|
26
26
|
sig { returns(String) }
|
27
27
|
attr_accessor :id_type
|
28
28
|
|
29
|
-
sig {
|
30
|
-
|
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) }
|
data/lib/evaluation_details.rb
CHANGED
@@ -2,12 +2,13 @@
|
|
2
2
|
module Statsig
|
3
3
|
|
4
4
|
module EvaluationReason
|
5
|
-
NETWORK =
|
6
|
-
LOCAL_OVERRIDE =
|
7
|
-
UNRECOGNIZED =
|
8
|
-
UNINITIALIZED =
|
9
|
-
BOOTSTRAP =
|
10
|
-
DATA_ADAPTER =
|
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
|
-
|
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
|
-
|
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
|
-
|
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
|
-
|
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(
|
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(
|
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(
|
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
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
|
-
|
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
|
-
|
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,
|
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
|
-
|
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
|
-
|
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,
|
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
|
-
|
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
|
-
|
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,
|
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,
|
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,
|
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.
|
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
|
data/lib/statsig_driver.rb
CHANGED
@@ -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
|
-
@
|
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
|
-
|
49
|
-
|
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.
|
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
|
-
|
89
|
-
|
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
|
-
|
104
|
-
|
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
|
-
|
129
|
-
|
130
|
-
|
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
|
-
|
117
|
+
persisted_values
|
118
|
+
}, caller: __method__.to_s)
|
119
|
+
end
|
133
120
|
|
134
|
-
|
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.
|
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
|
-
|
276
|
-
|
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
|
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)
|
data/lib/statsig_options.rb
CHANGED
@@ -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.
|
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-
|
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:
|
367
|
+
version: 1.3.1
|
366
368
|
requirements: []
|
367
|
-
rubygems_version: 3.
|
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: []
|