statsig 1.28.0 → 1.29.0
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/client_initialize_helpers.rb +1 -1
- data/lib/config_result.rb +41 -2
- 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/layer.rb +17 -3
- data/lib/network.rb +44 -7
- data/lib/spec_store.rb +14 -3
- data/lib/statsig.rb +43 -18
- data/lib/statsig_driver.rb +37 -43
- data/lib/statsig_options.rb +19 -9
- data/lib/statsig_user.rb +10 -4
- data/lib/uri_helper.rb +1 -1
- data/lib/user_persistent_storage_utils.rb +106 -0
- metadata +4 -2
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: '093cc05e0b8f3bb7e2f0f019cf36ad33e5abc4c4349d2fb1faa056c1ff5aa341'
|
4
|
+
data.tar.gz: d66640f8b0c317ca018ff4ccc77d1d370af89b1c84416508631deb610236d519
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: f634575320cd1ababfa25816ee8ac5fe27e34c761895b4ba6b20957363e487c295767d65e123ab7d306aa1ce2f535f55cc58570f8767790948d2647ab321150b
|
7
|
+
data.tar.gz: d563727b175a23afa50cb68cbaf91eb49435bae46b1a48b72d9bf0ff38febeefa86b886c34cc2632f37e357c44f96a1e2586050b41e9ac156697f4105c4f9f0b
|
@@ -42,7 +42,7 @@ module ClientInitializeHelpers
|
|
42
42
|
target_app_id = @evaluator.spec_store.get_app_id_for_sdk_key(@client_sdk_key)
|
43
43
|
config_target_apps = config_spec['targetAppIDs']
|
44
44
|
|
45
|
-
unless target_app_id.nil? || config_target_apps.nil?
|
45
|
+
unless target_app_id.nil? || (!config_target_apps.nil? && config_target_apps.include?(target_app_id))
|
46
46
|
return nil
|
47
47
|
end
|
48
48
|
|
data/lib/config_result.rb
CHANGED
@@ -1,6 +1,11 @@
|
|
1
1
|
# typed: true
|
2
|
+
|
3
|
+
require 'sorbet-runtime'
|
4
|
+
|
2
5
|
module Statsig
|
3
6
|
class ConfigResult
|
7
|
+
extend T::Sig
|
8
|
+
|
4
9
|
attr_accessor :name
|
5
10
|
attr_accessor :gate_value
|
6
11
|
attr_accessor :json_value
|
@@ -20,7 +25,7 @@ module Statsig
|
|
20
25
|
json_value = {},
|
21
26
|
rule_id = '',
|
22
27
|
secondary_exposures = [],
|
23
|
-
config_delegate =
|
28
|
+
config_delegate = nil,
|
24
29
|
explicit_parameters = [],
|
25
30
|
is_experiment_group: false,
|
26
31
|
evaluation_details: nil,
|
@@ -39,5 +44,39 @@ module Statsig
|
|
39
44
|
@group_name = group_name
|
40
45
|
@id_type = id_type
|
41
46
|
end
|
47
|
+
|
48
|
+
sig { params(config_name: String, user_persisted_values: UserPersistedValues).returns(T.nilable(ConfigResult)) }
|
49
|
+
def self.from_user_persisted_values(config_name, user_persisted_values)
|
50
|
+
sticky_values = user_persisted_values[config_name]
|
51
|
+
return nil if sticky_values.nil?
|
52
|
+
|
53
|
+
from_hash(config_name, sticky_values)
|
54
|
+
end
|
55
|
+
|
56
|
+
sig { params(config_name: String, hash: Hash).returns(ConfigResult) }
|
57
|
+
def self.from_hash(config_name, hash)
|
58
|
+
new(
|
59
|
+
config_name,
|
60
|
+
hash['gate_value'],
|
61
|
+
hash['json_value'],
|
62
|
+
hash['rule_id'],
|
63
|
+
hash['secondary_exposures'],
|
64
|
+
evaluation_details: EvaluationDetails.persisted(hash['config_sync_time'], hash['init_time']),
|
65
|
+
group_name: hash['group_name']
|
66
|
+
)
|
67
|
+
end
|
68
|
+
|
69
|
+
sig { returns(Hash) }
|
70
|
+
def to_hash
|
71
|
+
{
|
72
|
+
json_value: @json_value,
|
73
|
+
gate_value: @gate_value,
|
74
|
+
rule_id: @rule_id,
|
75
|
+
secondary_exposures: @secondary_exposures,
|
76
|
+
config_sync_time: @evaluation_details.config_sync_time,
|
77
|
+
init_time: @init_time,
|
78
|
+
group_name: @group_name
|
79
|
+
}
|
80
|
+
end
|
42
81
|
end
|
43
|
-
end
|
82
|
+
end
|
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/layer.rb
CHANGED
@@ -17,11 +17,25 @@ class Layer
|
|
17
17
|
sig { returns(String) }
|
18
18
|
attr_accessor :rule_id
|
19
19
|
|
20
|
-
sig {
|
21
|
-
|
20
|
+
sig { returns(String) }
|
21
|
+
attr_accessor :group_name
|
22
|
+
|
23
|
+
sig do
|
24
|
+
params(
|
25
|
+
name: String,
|
26
|
+
value: T::Hash[String, T.untyped],
|
27
|
+
rule_id: String,
|
28
|
+
group_name: T.nilable(String),
|
29
|
+
allocated_experiment: T.nilable(String),
|
30
|
+
exposure_log_func: T.any(Method, Proc, NilClass)
|
31
|
+
).void
|
32
|
+
end
|
33
|
+
def initialize(name, value = {}, rule_id = '', group_name = nil, allocated_experiment = nil, exposure_log_func = nil)
|
22
34
|
@name = name
|
23
35
|
@value = value
|
24
36
|
@rule_id = rule_id
|
37
|
+
@group_name = group_name
|
38
|
+
@allocated_experiment = allocated_experiment
|
25
39
|
@exposure_log_func = exposure_log_func
|
26
40
|
end
|
27
41
|
|
@@ -58,4 +72,4 @@ class Layer
|
|
58
72
|
|
59
73
|
@value[index]
|
60
74
|
end
|
61
|
-
end
|
75
|
+
end
|
data/lib/network.rb
CHANGED
@@ -53,11 +53,42 @@ module Statsig
|
|
53
53
|
end
|
54
54
|
end
|
55
55
|
|
56
|
+
sig do
|
57
|
+
params(since_time: Integer)
|
58
|
+
.returns([T.any(HTTP::Response, NilClass), T.any(StandardError, NilClass)])
|
59
|
+
end
|
60
|
+
def download_config_specs(since_time)
|
61
|
+
get("download_config_specs/#{@server_secret}.json?sinceTime=#{since_time}")
|
62
|
+
end
|
63
|
+
|
64
|
+
class HttpMethod < T::Enum
|
65
|
+
enums do
|
66
|
+
GET = new
|
67
|
+
POST = new
|
68
|
+
end
|
69
|
+
end
|
70
|
+
|
71
|
+
sig do
|
72
|
+
params(endpoint: String, retries: Integer, backoff: Integer)
|
73
|
+
.returns([T.any(HTTP::Response, NilClass), T.any(StandardError, NilClass)])
|
74
|
+
end
|
75
|
+
def get(endpoint, retries = 0, backoff = 1)
|
76
|
+
request(HttpMethod::GET, endpoint, nil, retries, backoff)
|
77
|
+
end
|
78
|
+
|
56
79
|
sig do
|
57
80
|
params(endpoint: String, body: String, retries: Integer, backoff: Integer)
|
58
81
|
.returns([T.any(HTTP::Response, NilClass), T.any(StandardError, NilClass)])
|
59
82
|
end
|
60
|
-
def
|
83
|
+
def post(endpoint, body, retries = 0, backoff = 1)
|
84
|
+
request(HttpMethod::POST, endpoint, body, retries, backoff)
|
85
|
+
end
|
86
|
+
|
87
|
+
sig do
|
88
|
+
params(method: HttpMethod, endpoint: String, body: T.nilable(String), retries: Integer, backoff: Integer)
|
89
|
+
.returns([T.any(HTTP::Response, NilClass), T.any(StandardError, NilClass)])
|
90
|
+
end
|
91
|
+
def request(method, endpoint, body, retries = 0, backoff = 1)
|
61
92
|
if @local_mode
|
62
93
|
return nil, nil
|
63
94
|
end
|
@@ -73,14 +104,20 @@ module Statsig
|
|
73
104
|
url = URIHelper.build_url(endpoint)
|
74
105
|
begin
|
75
106
|
res = @connection_pool.with do |conn|
|
76
|
-
conn.headers('STATSIG-CLIENT-TIME' => (Time.now.to_f * 1000).to_i.to_s)
|
107
|
+
request = conn.headers('STATSIG-CLIENT-TIME' => (Time.now.to_f * 1000).to_i.to_s)
|
108
|
+
case method
|
109
|
+
when HttpMethod::GET
|
110
|
+
request.get(url)
|
111
|
+
when HttpMethod::POST
|
112
|
+
request.post(url, body: body)
|
113
|
+
end
|
77
114
|
end
|
78
115
|
rescue StandardError => e
|
79
116
|
## network error retry
|
80
117
|
return nil, e unless retries.positive?
|
81
118
|
|
82
119
|
sleep backoff_adjusted
|
83
|
-
return
|
120
|
+
return request(method, endpoint, body, retries - 1, backoff * @backoff_multiplier)
|
84
121
|
end
|
85
122
|
return res, nil if res.status.success?
|
86
123
|
|
@@ -91,12 +128,12 @@ module Statsig
|
|
91
128
|
|
92
129
|
## status code retry
|
93
130
|
sleep backoff_adjusted
|
94
|
-
|
131
|
+
request(method, endpoint, body, retries - 1, backoff * @backoff_multiplier)
|
95
132
|
end
|
96
133
|
|
97
134
|
def check_gate(user, gate_name)
|
98
135
|
request_body = JSON.generate({ 'user' => user&.serialize(false), 'gateName' => gate_name })
|
99
|
-
response, =
|
136
|
+
response, = post('check_gate', request_body)
|
100
137
|
return JSON.parse(response.body) unless response.nil?
|
101
138
|
|
102
139
|
false
|
@@ -106,7 +143,7 @@ module Statsig
|
|
106
143
|
|
107
144
|
def get_config(user, dynamic_config_name)
|
108
145
|
request_body = JSON.generate({ 'user' => user&.serialize(false), 'configName' => dynamic_config_name })
|
109
|
-
response, =
|
146
|
+
response, = post('get_config', request_body)
|
110
147
|
return JSON.parse(response.body) unless response.nil?
|
111
148
|
|
112
149
|
nil
|
@@ -116,7 +153,7 @@ module Statsig
|
|
116
153
|
|
117
154
|
def post_logs(events)
|
118
155
|
json_body = JSON.generate({ 'events' => events, 'statsigMetadata' => Statsig.get_statsig_metadata })
|
119
|
-
|
156
|
+
post('log_event', json_body, @post_logs_retry_limit)
|
120
157
|
rescue StandardError
|
121
158
|
|
122
159
|
end
|
data/lib/spec_store.rb
CHANGED
@@ -4,6 +4,7 @@ require 'uri'
|
|
4
4
|
require 'evaluation_details'
|
5
5
|
require 'id_list'
|
6
6
|
require 'concurrent-ruby'
|
7
|
+
require 'hash_utils'
|
7
8
|
|
8
9
|
module Statsig
|
9
10
|
class SpecStore
|
@@ -28,7 +29,8 @@ module Statsig
|
|
28
29
|
:layers => {},
|
29
30
|
:id_lists => {},
|
30
31
|
:experiment_to_layer => {},
|
31
|
-
:sdk_keys_to_app_ids => {}
|
32
|
+
:sdk_keys_to_app_ids => {},
|
33
|
+
:hashed_sdk_keys_to_app_ids => {}
|
32
34
|
}
|
33
35
|
@diagnostics = diagnostics
|
34
36
|
@error_boundary = error_boundary
|
@@ -127,10 +129,18 @@ module Statsig
|
|
127
129
|
@specs[:sdk_keys_to_app_ids].key?(sdk_key)
|
128
130
|
end
|
129
131
|
|
132
|
+
def has_hashed_sdk_key?(hashed_sdk_key)
|
133
|
+
@specs[:hashed_sdk_keys_to_app_ids].key?(hashed_sdk_key)
|
134
|
+
end
|
135
|
+
|
130
136
|
def get_app_id_for_sdk_key(sdk_key)
|
131
137
|
if sdk_key.nil?
|
132
138
|
return nil
|
133
139
|
end
|
140
|
+
hashed_sdk_key = Statsig::HashUtils.djb2(sdk_key)
|
141
|
+
if has_hashed_sdk_key?(hashed_sdk_key)
|
142
|
+
return @specs[:hashed_sdk_keys_to_app_ids][hashed_sdk_key]
|
143
|
+
end
|
134
144
|
return nil unless has_sdk_key?(sdk_key)
|
135
145
|
@specs[:sdk_keys_to_app_ids][sdk_key]
|
136
146
|
end
|
@@ -228,7 +238,7 @@ module Statsig
|
|
228
238
|
|
229
239
|
error = nil
|
230
240
|
begin
|
231
|
-
response, e = @network.
|
241
|
+
response, e = @network.download_config_specs(@last_config_sync_time)
|
232
242
|
code = response&.status.to_i
|
233
243
|
if e.is_a? NetworkError
|
234
244
|
code = e.http_code
|
@@ -293,6 +303,7 @@ module Statsig
|
|
293
303
|
@specs[:layers] = new_layers
|
294
304
|
@specs[:experiment_to_layer] = new_exp_to_layer
|
295
305
|
@specs[:sdk_keys_to_app_ids] = specs_json['sdk_keys_to_app_ids'] || {}
|
306
|
+
@specs[:hashed_sdk_keys_to_app_ids] = specs_json['hashed_sdk_keys_to_app_ids'] || {}
|
296
307
|
|
297
308
|
unless from_adapter
|
298
309
|
save_config_specs_to_storage_adapter(specs_string)
|
@@ -323,7 +334,7 @@ module Statsig
|
|
323
334
|
|
324
335
|
def get_id_lists_from_network
|
325
336
|
tracker = @diagnostics.track('get_id_list_sources', 'network_request')
|
326
|
-
response, e = @network.
|
337
|
+
response, e = @network.post('get_id_lists', JSON.generate({ 'statsigMetadata' => Statsig.get_statsig_metadata }))
|
327
338
|
code = response&.status.to_i
|
328
339
|
if e.is_a? NetworkError
|
329
340
|
code = e.http_code
|
data/lib/statsig.rb
CHANGED
@@ -26,15 +26,20 @@ module Statsig
|
|
26
26
|
@shared_instance = StatsigDriver.new(secret_key, options, error_callback)
|
27
27
|
end
|
28
28
|
|
29
|
-
|
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',
|
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")
|
@@ -142,27 +130,26 @@ class StatsigDriver
|
|
142
130
|
end
|
143
131
|
|
144
132
|
if res == $fetch_from_server
|
145
|
-
if res.config_delegate.
|
133
|
+
if res.config_delegate.nil?
|
146
134
|
return Layer.new(layer_name)
|
147
135
|
end
|
148
136
|
res = get_config_fallback(user, res.config_delegate)
|
149
137
|
# exposure logged by the server
|
150
138
|
end
|
151
139
|
|
152
|
-
exposure_log_func = options.
|
140
|
+
exposure_log_func = !options.disable_log_exposure ? lambda { |layer, parameter_name|
|
153
141
|
@logger.log_layer_exposure(user, layer, parameter_name, res)
|
154
142
|
} : nil
|
155
|
-
Layer.new(res.name, res.json_value, res.rule_id, exposure_log_func)
|
143
|
+
Layer.new(res.name, res.json_value, res.rule_id, res.group_name, res.config_delegate, exposure_log_func)
|
156
144
|
}, caller: __method__.to_s)
|
157
145
|
}, recover: lambda { Layer.new(layer_name) }, caller: __method__.to_s)
|
158
146
|
end
|
159
147
|
|
160
148
|
sig { params(user: StatsigUser, layer_name: String, parameter_name: String).void }
|
161
|
-
|
162
149
|
def manually_log_layer_parameter_exposure(user, layer_name, parameter_name)
|
163
150
|
@err_boundary.capture(task: lambda {
|
164
151
|
res = @evaluator.get_layer(user, layer_name)
|
165
|
-
layer = Layer.new(layer_name, res.json_value, res.rule_id)
|
152
|
+
layer = Layer.new(layer_name, res.json_value, res.rule_id, res.group_name, res.config_delegate)
|
166
153
|
context = { 'is_manual_exposure' => true }
|
167
154
|
@logger.log_layer_exposure(user, layer, parameter_name, res, context)
|
168
155
|
}, caller: __method__.to_s)
|
@@ -260,7 +247,6 @@ class StatsigDriver
|
|
260
247
|
end
|
261
248
|
|
262
249
|
sig { params(user: StatsigUser, config_name: String, variable_name: String).returns(StatsigUser) }
|
263
|
-
|
264
250
|
def verify_inputs(user, config_name, variable_name)
|
265
251
|
validate_user(user)
|
266
252
|
if !config_name.is_a?(String) || config_name.empty?
|
@@ -272,8 +258,16 @@ class StatsigDriver
|
|
272
258
|
normalize_user(user)
|
273
259
|
end
|
274
260
|
|
275
|
-
|
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.
|
@@ -21,7 +22,7 @@ class StatsigOptions
|
|
21
22
|
|
22
23
|
# The base url used specifically to call download_config_specs.
|
23
24
|
# Takes precedence over api_url_base
|
24
|
-
sig { returns(
|
25
|
+
sig { returns(String) }
|
25
26
|
attr_accessor :api_url_download_config_specs
|
26
27
|
|
27
28
|
sig { returns(T.any(Float, Integer)) }
|
@@ -106,10 +107,15 @@ class StatsigOptions
|
|
106
107
|
# which overrides the default backoff time between retries
|
107
108
|
attr_accessor :post_logs_retry_backoff
|
108
109
|
|
110
|
+
sig { returns(T.any(Statsig::Interfaces::IUserPersistentStorage, NilClass)) }
|
111
|
+
# A storage adapter for persisted values. Can be used for sticky bucketing users in experiments.
|
112
|
+
# Implements Statsig::Interfaces::IUserPersistentStorage.
|
113
|
+
attr_accessor :user_persistent_storage
|
114
|
+
|
109
115
|
sig do
|
110
116
|
params(
|
111
117
|
environment: T.any(T::Hash[String, String], NilClass),
|
112
|
-
api_url_base: String,
|
118
|
+
api_url_base: T.nilable(String),
|
113
119
|
api_url_download_config_specs: T.any(String, NilClass),
|
114
120
|
rulesets_sync_interval: T.any(Float, Integer),
|
115
121
|
idlists_sync_interval: T.any(Float, Integer),
|
@@ -127,13 +133,13 @@ class StatsigOptions
|
|
127
133
|
disable_sorbet_logging_handlers: T::Boolean,
|
128
134
|
network_timeout: T.any(Integer, NilClass),
|
129
135
|
post_logs_retry_limit: Integer,
|
130
|
-
post_logs_retry_backoff: T.any(Method, Proc, Integer, NilClass)
|
136
|
+
post_logs_retry_backoff: T.any(Method, Proc, Integer, NilClass),
|
137
|
+
user_persistent_storage: T.any(Statsig::Interfaces::IUserPersistentStorage, NilClass)
|
131
138
|
).void
|
132
139
|
end
|
133
|
-
|
134
140
|
def initialize(
|
135
141
|
environment = nil,
|
136
|
-
api_url_base =
|
142
|
+
api_url_base = nil,
|
137
143
|
api_url_download_config_specs: nil,
|
138
144
|
rulesets_sync_interval: 10,
|
139
145
|
idlists_sync_interval: 60,
|
@@ -151,10 +157,12 @@ class StatsigOptions
|
|
151
157
|
disable_sorbet_logging_handlers: false,
|
152
158
|
network_timeout: nil,
|
153
159
|
post_logs_retry_limit: 3,
|
154
|
-
post_logs_retry_backoff: nil
|
160
|
+
post_logs_retry_backoff: nil,
|
161
|
+
user_persistent_storage: nil
|
162
|
+
)
|
155
163
|
@environment = environment.is_a?(Hash) ? environment : nil
|
156
|
-
@api_url_base = api_url_base
|
157
|
-
@api_url_download_config_specs = api_url_download_config_specs
|
164
|
+
@api_url_base = api_url_base || 'https://statsigapi.net/v1'
|
165
|
+
@api_url_download_config_specs = api_url_download_config_specs || api_url_base || 'https://api.statsigcdn.com/v1'
|
158
166
|
@rulesets_sync_interval = rulesets_sync_interval
|
159
167
|
@idlists_sync_interval = idlists_sync_interval
|
160
168
|
@disable_rulesets_sync = disable_rulesets_sync
|
@@ -172,5 +180,7 @@ class StatsigOptions
|
|
172
180
|
@network_timeout = network_timeout
|
173
181
|
@post_logs_retry_limit = post_logs_retry_limit
|
174
182
|
@post_logs_retry_backoff = post_logs_retry_backoff
|
183
|
+
@user_persistent_storage = user_persistent_storage
|
184
|
+
|
175
185
|
end
|
176
|
-
end
|
186
|
+
end
|
data/lib/statsig_user.rb
CHANGED
@@ -49,7 +49,6 @@ class StatsigUser
|
|
49
49
|
attr_accessor :private_attributes
|
50
50
|
|
51
51
|
sig { returns(T.any(T::Hash[String, T.untyped], NilClass)) }
|
52
|
-
|
53
52
|
def custom
|
54
53
|
@custom
|
55
54
|
end
|
@@ -61,7 +60,6 @@ class StatsigUser
|
|
61
60
|
end
|
62
61
|
|
63
62
|
sig { params(user_hash: T.any(T::Hash[T.any(String, Symbol), T.untyped], NilClass)).void }
|
64
|
-
|
65
63
|
def initialize(user_hash)
|
66
64
|
the_hash = user_hash
|
67
65
|
begin
|
@@ -166,6 +164,15 @@ class StatsigUser
|
|
166
164
|
}
|
167
165
|
end
|
168
166
|
|
167
|
+
def get_unit_id(id_type)
|
168
|
+
if id_type.is_a?(String) && id_type.downcase != 'userid'
|
169
|
+
return nil unless @custom_ids.is_a? Hash
|
170
|
+
|
171
|
+
return @custom_ids[id_type] || @custom_ids[id_type.downcase]
|
172
|
+
end
|
173
|
+
@user_id
|
174
|
+
end
|
175
|
+
|
169
176
|
private
|
170
177
|
|
171
178
|
sig {
|
@@ -189,5 +196,4 @@ class StatsigUser
|
|
189
196
|
|
190
197
|
nil
|
191
198
|
end
|
192
|
-
|
193
|
-
end
|
199
|
+
end
|
data/lib/uri_helper.rb
CHANGED
@@ -17,7 +17,7 @@ class URIHelper
|
|
17
17
|
sig { params(endpoint: String).returns(String) }
|
18
18
|
def build_url(endpoint)
|
19
19
|
api = @options.api_url_base
|
20
|
-
if endpoint
|
20
|
+
if endpoint.include?('download_config_specs')
|
21
21
|
api = T.must(@options.api_url_download_config_specs)
|
22
22
|
end
|
23
23
|
unless api.end_with?('/')
|
@@ -0,0 +1,106 @@
|
|
1
|
+
# typed: false
|
2
|
+
|
3
|
+
require 'sorbet-runtime'
|
4
|
+
require 'statsig_options'
|
5
|
+
|
6
|
+
module Statsig
|
7
|
+
UserPersistedValues = T.type_alias { T::Hash[String, Hash] }
|
8
|
+
|
9
|
+
class UserPersistentStorageUtils
|
10
|
+
extend T::Sig
|
11
|
+
|
12
|
+
sig { returns(T::Hash[String, UserPersistedValues]) }
|
13
|
+
attr_accessor :cache
|
14
|
+
|
15
|
+
sig { returns(T.nilable(Interfaces::IUserPersistentStorage)) }
|
16
|
+
attr_accessor :storage
|
17
|
+
|
18
|
+
sig { params(options: StatsigOptions).void }
|
19
|
+
def initialize(options)
|
20
|
+
@storage = options.user_persistent_storage
|
21
|
+
@cache = {}
|
22
|
+
end
|
23
|
+
|
24
|
+
sig { params(user: StatsigUser, id_type: String).returns(T.nilable(UserPersistedValues)) }
|
25
|
+
def get_user_persisted_values(user, id_type)
|
26
|
+
key = self.class.get_storage_key(user, id_type)
|
27
|
+
return @cache[key] unless @cache[key].nil?
|
28
|
+
|
29
|
+
return load_from_storage(key)
|
30
|
+
end
|
31
|
+
|
32
|
+
sig { params(key: String).returns(T.nilable(UserPersistedValues)) }
|
33
|
+
def load_from_storage(key)
|
34
|
+
return if @storage.nil?
|
35
|
+
|
36
|
+
begin
|
37
|
+
storage_values = @storage.load(key)
|
38
|
+
rescue StandardError => e
|
39
|
+
puts "Failed to load key (#{key}) from user_persisted_storage (#{e.message})"
|
40
|
+
return nil
|
41
|
+
end
|
42
|
+
|
43
|
+
unless storage_values.nil?
|
44
|
+
parsed_values = self.class.parse(storage_values)
|
45
|
+
unless parsed_values.nil?
|
46
|
+
@cache[key] = parsed_values
|
47
|
+
return @cache[key]
|
48
|
+
end
|
49
|
+
end
|
50
|
+
return nil
|
51
|
+
end
|
52
|
+
|
53
|
+
sig { params(user: StatsigUser, id_type: String, user_persisted_values: UserPersistedValues).void }
|
54
|
+
def save_to_storage(user, id_type, user_persisted_values)
|
55
|
+
return if @storage.nil?
|
56
|
+
|
57
|
+
key = self.class.get_storage_key(user, id_type)
|
58
|
+
stringified = self.class.stringify(user_persisted_values)
|
59
|
+
return if stringified.nil?
|
60
|
+
|
61
|
+
begin
|
62
|
+
@storage.save(key, stringified)
|
63
|
+
rescue StandardError => e
|
64
|
+
puts "Failed to save key (#{key}) to user_persisted_storage (#{e.message})"
|
65
|
+
end
|
66
|
+
end
|
67
|
+
|
68
|
+
sig { params(user: StatsigUser, id_type: String, config_name: String).void }
|
69
|
+
def remove_experiment_from_storage(user, id_type, config_name)
|
70
|
+
persisted_values = get_user_persisted_values(user, id_type)
|
71
|
+
unless persisted_values.nil?
|
72
|
+
persisted_values.delete(config_name)
|
73
|
+
save_to_storage(user, id_type, persisted_values)
|
74
|
+
end
|
75
|
+
end
|
76
|
+
|
77
|
+
sig { params(user_persisted_values: T.nilable(UserPersistedValues), config_name: String, evaluation: ConfigResult).void }
|
78
|
+
def add_evaluation_to_user_persisted_values(user_persisted_values, config_name, evaluation)
|
79
|
+
if user_persisted_values.nil?
|
80
|
+
user_persisted_values = {}
|
81
|
+
end
|
82
|
+
user_persisted_values[config_name] = evaluation.to_hash
|
83
|
+
end
|
84
|
+
|
85
|
+
private
|
86
|
+
|
87
|
+
sig { params(values_string: String).returns(T.nilable(UserPersistedValues)) }
|
88
|
+
def self.parse(values_string)
|
89
|
+
return JSON.parse(values_string)
|
90
|
+
rescue JSON::ParserError
|
91
|
+
return nil
|
92
|
+
end
|
93
|
+
|
94
|
+
sig { params(values_object: UserPersistedValues).returns(T.nilable(String)) }
|
95
|
+
def self.stringify(values_object)
|
96
|
+
return JSON.generate(values_object)
|
97
|
+
rescue StandardError
|
98
|
+
return nil
|
99
|
+
end
|
100
|
+
|
101
|
+
sig { params(user: StatsigUser, id_type: String).returns(String) }
|
102
|
+
def self.get_storage_key(user, id_type)
|
103
|
+
"#{user.get_unit_id(id_type)}:#{id_type}"
|
104
|
+
end
|
105
|
+
end
|
106
|
+
end
|
metadata
CHANGED
@@ -1,14 +1,14 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: statsig
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 1.
|
4
|
+
version: 1.29.0
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- Statsig, Inc
|
8
8
|
autorequire:
|
9
9
|
bindir: bin
|
10
10
|
cert_chain: []
|
11
|
-
date: 2023-
|
11
|
+
date: 2023-12-13 00:00:00.000000000 Z
|
12
12
|
dependencies:
|
13
13
|
- !ruby/object:Gem::Dependency
|
14
14
|
name: bundler
|
@@ -333,6 +333,7 @@ files:
|
|
333
333
|
- lib/hash_utils.rb
|
334
334
|
- lib/id_list.rb
|
335
335
|
- lib/interfaces/data_store.rb
|
336
|
+
- lib/interfaces/user_persistent_storage.rb
|
336
337
|
- lib/layer.rb
|
337
338
|
- lib/network.rb
|
338
339
|
- lib/spec_store.rb
|
@@ -345,6 +346,7 @@ files:
|
|
345
346
|
- lib/statsig_user.rb
|
346
347
|
- lib/ua_parser.rb
|
347
348
|
- lib/uri_helper.rb
|
349
|
+
- lib/user_persistent_storage_utils.rb
|
348
350
|
homepage: https://rubygems.org/gems/statsig
|
349
351
|
licenses:
|
350
352
|
- ISC
|