statsig 1.28.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 +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: []
|