statsig 1.11.0 → 1.13.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 +131 -0
- data/lib/config_result.rb +15 -2
- data/lib/evaluation_details.rb +40 -0
- data/lib/evaluator.rb +112 -10
- data/lib/network.rb +4 -2
- data/lib/spec_store.rb +86 -28
- data/lib/statsig.rb +17 -1
- data/lib/statsig_driver.rb +24 -4
- data/lib/statsig_event.rb +1 -1
- data/lib/statsig_logger.rb +30 -5
- data/lib/statsig_options.rb +16 -7
- metadata +5 -3
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 898237740392ece6cde365fd111e6d49daacac47d39317f06435fbfe83df8821
|
4
|
+
data.tar.gz: 6075c3333c37aeb94fc24511cea4bb6f1a7689c23d05100c91f9a9afc8525393
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: ee1eb3fec70c73522532f44255ef065b2d286382645ad7988cad9d297295548a97f18a50b5f73a046e6987fadbff3184f53a91cbb9f969b805c1cb1681552454
|
7
|
+
data.tar.gz: d3818d3492d50cd9da8cdf2f8067140383c25b1d124d205a47303023fb64a10e1f5424fcf08e8b8270c3c7e96a88ee088fa2abc1c8a91918c550dcc280fc7778
|
@@ -0,0 +1,131 @@
|
|
1
|
+
$empty_eval_result = {
|
2
|
+
:gate_value => false,
|
3
|
+
:json_value => {},
|
4
|
+
:rule_id => "",
|
5
|
+
:is_experiment_group => false,
|
6
|
+
:secondary_exposures => []
|
7
|
+
}
|
8
|
+
|
9
|
+
module ClientInitializeHelpers
|
10
|
+
class ResponseFormatter
|
11
|
+
def initialize(evaluator, user)
|
12
|
+
@evaluator = evaluator
|
13
|
+
@user = user
|
14
|
+
@specs = evaluator.spec_store.get_raw_specs
|
15
|
+
end
|
16
|
+
|
17
|
+
def get_responses(key)
|
18
|
+
@specs[key]
|
19
|
+
.map { |name, spec| to_response(name, spec) }
|
20
|
+
.delete_if { |v| v.nil? }.to_h
|
21
|
+
end
|
22
|
+
|
23
|
+
private
|
24
|
+
|
25
|
+
def to_response(config_name, config_spec)
|
26
|
+
eval_result = @evaluator.eval_spec(@user, config_spec)
|
27
|
+
if eval_result.nil?
|
28
|
+
return nil
|
29
|
+
end
|
30
|
+
|
31
|
+
safe_eval_result = eval_result == $fetch_from_server ? $empty_eval_result : {
|
32
|
+
:gate_value => eval_result.gate_value,
|
33
|
+
:json_value => eval_result.json_value,
|
34
|
+
:rule_id => eval_result.rule_id,
|
35
|
+
:config_delegate => eval_result.config_delegate,
|
36
|
+
:is_experiment_group => eval_result.is_experiment_group,
|
37
|
+
:secondary_exposures => eval_result.secondary_exposures,
|
38
|
+
:undelegated_sec_exps => eval_result.undelegated_sec_exps
|
39
|
+
}
|
40
|
+
|
41
|
+
category = config_spec['type']
|
42
|
+
entity_type = config_spec['entity']
|
43
|
+
|
44
|
+
result = {}
|
45
|
+
|
46
|
+
case category
|
47
|
+
|
48
|
+
when 'feature_gate'
|
49
|
+
if entity_type == 'segment' || entity_type == 'holdout'
|
50
|
+
return nil
|
51
|
+
end
|
52
|
+
|
53
|
+
result['value'] = safe_eval_result[:gate_value]
|
54
|
+
when 'dynamic_config'
|
55
|
+
id_type = config_spec['idType']
|
56
|
+
result['value'] = safe_eval_result[:json_value]
|
57
|
+
result["group"] = safe_eval_result[:rule_id]
|
58
|
+
result["is_device_based"] = id_type.is_a?(String) && id_type.downcase == 'stableid'
|
59
|
+
else
|
60
|
+
return nil
|
61
|
+
end
|
62
|
+
|
63
|
+
if entity_type == 'experiment'
|
64
|
+
populate_experiment_fields(config_name, config_spec, safe_eval_result, result)
|
65
|
+
end
|
66
|
+
|
67
|
+
if entity_type == 'layer'
|
68
|
+
populate_layer_fields(config_spec, safe_eval_result, result)
|
69
|
+
end
|
70
|
+
|
71
|
+
hashed_name = hash_name(config_name)
|
72
|
+
[hashed_name, result.merge(
|
73
|
+
{
|
74
|
+
"name" => hashed_name,
|
75
|
+
"rule_id" => safe_eval_result[:rule_id],
|
76
|
+
"secondary_exposures" => clean_exposures(safe_eval_result[:secondary_exposures])
|
77
|
+
})]
|
78
|
+
end
|
79
|
+
|
80
|
+
def clean_exposures(exposures)
|
81
|
+
seen = {}
|
82
|
+
exposures.reject do |exposure|
|
83
|
+
key = "#{exposure["gate"]}|#{exposure["gateValue"]}|#{exposure["ruleID"]}}"
|
84
|
+
should_reject = seen[key]
|
85
|
+
seen[key] = true
|
86
|
+
should_reject == true
|
87
|
+
end
|
88
|
+
end
|
89
|
+
|
90
|
+
def populate_experiment_fields(config_name, config_spec, eval_result, result)
|
91
|
+
result["is_user_in_experiment"] = eval_result[:is_experiment_group]
|
92
|
+
result["is_experiment_active"] = config_spec['isActive'] == true
|
93
|
+
|
94
|
+
if config_spec['hasSharedParams'] != true
|
95
|
+
return
|
96
|
+
end
|
97
|
+
|
98
|
+
result["is_in_layer"] = true
|
99
|
+
result["explicit_parameters"] = config_spec["explicitParameters"] || []
|
100
|
+
|
101
|
+
layer_name = @specs[:experiment_to_layer][config_name]
|
102
|
+
if layer_name.nil? || @specs[:layers][layer_name].nil?
|
103
|
+
return
|
104
|
+
end
|
105
|
+
|
106
|
+
layer = @specs[:layers][layer_name]
|
107
|
+
result["value"] = layer["defaultValue"].merge(result["value"])
|
108
|
+
end
|
109
|
+
|
110
|
+
def populate_layer_fields(config_spec, eval_result, result)
|
111
|
+
delegate = eval_result[:config_delegate]
|
112
|
+
result["explicit_parameters"] = config_spec["explicitParameters"] || []
|
113
|
+
|
114
|
+
if delegate.nil? == false && delegate.empty? == false
|
115
|
+
delegate_spec = @specs[:configs][delegate]
|
116
|
+
delegate_result = @evaluator.eval_spec(@user, delegate_spec)
|
117
|
+
|
118
|
+
result["allocated_experiment_name"] = hash_name(delegate)
|
119
|
+
result["is_user_in_experiment"] = delegate_result.is_experiment_group
|
120
|
+
result["is_experiment_active"] = delegate_spec['isActive'] == true
|
121
|
+
result["explicit_parameters"] = delegate_spec["explicitParameters"] || []
|
122
|
+
end
|
123
|
+
|
124
|
+
result["undelegated_secondary_exposures"] = clean_exposures(eval_result[:undelegated_sec_exps] || [])
|
125
|
+
end
|
126
|
+
|
127
|
+
def hash_name(name)
|
128
|
+
Digest::SHA256.base64digest(name)
|
129
|
+
end
|
130
|
+
end
|
131
|
+
end
|
data/lib/config_result.rb
CHANGED
@@ -1,4 +1,3 @@
|
|
1
|
-
|
2
1
|
module Statsig
|
3
2
|
class ConfigResult
|
4
3
|
attr_accessor :name
|
@@ -9,8 +8,20 @@ module Statsig
|
|
9
8
|
attr_accessor :undelegated_sec_exps
|
10
9
|
attr_accessor :config_delegate
|
11
10
|
attr_accessor :explicit_parameters
|
11
|
+
attr_accessor :is_experiment_group
|
12
|
+
attr_accessor :evaluation_details
|
12
13
|
|
13
|
-
def initialize(
|
14
|
+
def initialize(
|
15
|
+
name,
|
16
|
+
gate_value = false,
|
17
|
+
json_value = {},
|
18
|
+
rule_id = '',
|
19
|
+
secondary_exposures = [],
|
20
|
+
undelegated_sec_exps = [],
|
21
|
+
config_delegate = '',
|
22
|
+
explicit_parameters = [],
|
23
|
+
is_experiment_group: false,
|
24
|
+
evaluation_details: nil)
|
14
25
|
@name = name
|
15
26
|
@gate_value = gate_value
|
16
27
|
@json_value = json_value
|
@@ -19,6 +30,8 @@ module Statsig
|
|
19
30
|
@undelegated_sec_exps = undelegated_sec_exps.is_a?(Array) ? undelegated_sec_exps : []
|
20
31
|
@config_delegate = config_delegate
|
21
32
|
@explicit_parameters = explicit_parameters
|
33
|
+
@is_experiment_group = is_experiment_group
|
34
|
+
@evaluation_details = evaluation_details
|
22
35
|
end
|
23
36
|
end
|
24
37
|
end
|
@@ -0,0 +1,40 @@
|
|
1
|
+
module Statsig
|
2
|
+
|
3
|
+
module EvaluationReason
|
4
|
+
NETWORK = "Network"
|
5
|
+
LOCAL_OVERRIDE = "LocalOverride"
|
6
|
+
UNRECOGNIZED = "Unrecognized"
|
7
|
+
UNINITIALIZED = "Uninitialized"
|
8
|
+
BOOTSTRAP = "Bootstrap"
|
9
|
+
end
|
10
|
+
|
11
|
+
class EvaluationDetails
|
12
|
+
attr_accessor :config_sync_time
|
13
|
+
attr_accessor :init_time
|
14
|
+
attr_accessor :reason
|
15
|
+
attr_accessor :server_time
|
16
|
+
|
17
|
+
def initialize(config_sync_time, init_time, reason)
|
18
|
+
@config_sync_time = config_sync_time
|
19
|
+
@init_time = init_time
|
20
|
+
@reason = reason
|
21
|
+
@server_time = (Time.now.to_i * 1000).to_s
|
22
|
+
end
|
23
|
+
|
24
|
+
def self.unrecognized(config_sync_time, init_time)
|
25
|
+
EvaluationDetails.new(config_sync_time, init_time, EvaluationReason::UNRECOGNIZED)
|
26
|
+
end
|
27
|
+
|
28
|
+
def self.uninitialized
|
29
|
+
EvaluationDetails.new(0, 0, EvaluationReason::UNINITIALIZED)
|
30
|
+
end
|
31
|
+
|
32
|
+
def self.network(config_sync_time, init_time)
|
33
|
+
EvaluationDetails.new(config_sync_time, init_time, EvaluationReason::NETWORK)
|
34
|
+
end
|
35
|
+
|
36
|
+
def self.local_override(config_sync_time, init_time)
|
37
|
+
EvaluationDetails.new(config_sync_time, init_time, EvaluationReason::LOCAL_OVERRIDE)
|
38
|
+
end
|
39
|
+
end
|
40
|
+
end
|
data/lib/evaluator.rb
CHANGED
@@ -2,9 +2,11 @@ require 'config_result'
|
|
2
2
|
require 'country_lookup'
|
3
3
|
require 'digest'
|
4
4
|
require 'evaluation_helpers'
|
5
|
+
require 'client_initialize_helpers'
|
5
6
|
require 'spec_store'
|
6
7
|
require 'time'
|
7
8
|
require 'user_agent_parser'
|
9
|
+
require 'evaluation_details'
|
8
10
|
require 'user_agent_parser/operating_system'
|
9
11
|
|
10
12
|
$fetch_from_server = 'fetch_from_server'
|
@@ -12,33 +14,116 @@ $type_dynamic_config = 'dynamic_config'
|
|
12
14
|
|
13
15
|
module Statsig
|
14
16
|
class Evaluator
|
17
|
+
attr_accessor :spec_store
|
18
|
+
|
15
19
|
def initialize(network, options, error_callback)
|
16
|
-
@spec_store = Statsig::SpecStore.new(network, error_callback, options.rulesets_sync_interval, options.idlists_sync_interval)
|
20
|
+
@spec_store = Statsig::SpecStore.new(network, error_callback, options.rulesets_sync_interval, options.idlists_sync_interval, options.bootstrap_values, options.rules_updated_callback)
|
17
21
|
@ua_parser = UserAgentParser::Parser.new
|
18
22
|
CountryLookup.initialize
|
19
|
-
|
23
|
+
|
24
|
+
@gate_overrides = {}
|
25
|
+
@config_overrides = {}
|
26
|
+
end
|
27
|
+
|
28
|
+
def maybe_restart_background_threads
|
29
|
+
@spec_store.maybe_restart_background_threads
|
20
30
|
end
|
21
31
|
|
22
32
|
def check_gate(user, gate_name)
|
23
|
-
|
33
|
+
if @gate_overrides.has_key?(gate_name)
|
34
|
+
return Statsig::ConfigResult.new(
|
35
|
+
gate_name,
|
36
|
+
@gate_overrides[gate_name],
|
37
|
+
@gate_overrides[gate_name],
|
38
|
+
'override',
|
39
|
+
[],
|
40
|
+
evaluation_details: EvaluationDetails.local_override(@spec_store.last_config_sync_time, @spec_store.initial_config_sync_time))
|
41
|
+
end
|
42
|
+
|
43
|
+
if @spec_store.init_reason == EvaluationReason::UNINITIALIZED
|
44
|
+
return Statsig::ConfigResult.new(gate_name, evaluation_details: EvaluationDetails.uninitialized)
|
45
|
+
end
|
46
|
+
|
47
|
+
unless @spec_store.has_gate?(gate_name)
|
48
|
+
return Statsig::ConfigResult.new(gate_name, evaluation_details: EvaluationDetails.unrecognized(@spec_store.last_config_sync_time, @spec_store.initial_config_sync_time))
|
49
|
+
end
|
50
|
+
|
24
51
|
eval_spec(user, @spec_store.get_gate(gate_name))
|
25
52
|
end
|
26
53
|
|
27
54
|
def get_config(user, config_name)
|
28
|
-
|
55
|
+
if @config_overrides.has_key?(config_name)
|
56
|
+
return Statsig::ConfigResult.new(
|
57
|
+
config_name,
|
58
|
+
false,
|
59
|
+
@config_overrides[config_name],
|
60
|
+
'override',
|
61
|
+
[],
|
62
|
+
evaluation_details: EvaluationDetails.local_override(@spec_store.last_config_sync_time, @spec_store.initial_config_sync_time))
|
63
|
+
end
|
64
|
+
|
65
|
+
if @spec_store.init_reason == EvaluationReason::UNINITIALIZED
|
66
|
+
return Statsig::ConfigResult.new(config_name, evaluation_details: EvaluationDetails.uninitialized)
|
67
|
+
end
|
68
|
+
|
69
|
+
unless @spec_store.has_config?(config_name)
|
70
|
+
return Statsig::ConfigResult.new(config_name, evaluation_details: EvaluationDetails.unrecognized(@spec_store.last_config_sync_time, @spec_store.initial_config_sync_time))
|
71
|
+
end
|
72
|
+
|
29
73
|
eval_spec(user, @spec_store.get_config(config_name))
|
30
74
|
end
|
31
75
|
|
32
76
|
def get_layer(user, layer_name)
|
33
|
-
|
77
|
+
if @spec_store.init_reason == EvaluationReason::UNINITIALIZED
|
78
|
+
return Statsig::ConfigResult.new(layer_name, evaluation_details: EvaluationDetails.uninitialized)
|
79
|
+
end
|
80
|
+
|
81
|
+
unless @spec_store.has_layer?(layer_name)
|
82
|
+
return Statsig::ConfigResult.new(layer_name, evaluation_details: EvaluationDetails.unrecognized(@spec_store.last_config_sync_time, @spec_store.initial_config_sync_time))
|
83
|
+
end
|
84
|
+
|
34
85
|
eval_spec(user, @spec_store.get_layer(layer_name))
|
35
86
|
end
|
36
87
|
|
88
|
+
def get_client_initialize_response(user)
|
89
|
+
if @spec_store.is_ready_for_checks == false
|
90
|
+
return nil
|
91
|
+
end
|
92
|
+
|
93
|
+
formatter = ClientInitializeHelpers::ResponseFormatter.new(self, user)
|
94
|
+
|
95
|
+
{
|
96
|
+
"feature_gates" => formatter.get_responses(:gates),
|
97
|
+
"dynamic_configs" => formatter.get_responses(:configs),
|
98
|
+
"layer_configs" => formatter.get_responses(:layers),
|
99
|
+
"sdkParams" => {},
|
100
|
+
"has_updates" => true,
|
101
|
+
"generator" => "statsig-ruby-sdk",
|
102
|
+
"time" => 0,
|
103
|
+
}
|
104
|
+
end
|
105
|
+
|
106
|
+
def clean_exposures(exposures)
|
107
|
+
seen = {}
|
108
|
+
exposures.reject do |exposure|
|
109
|
+
key = "#{exposure["gate"]}|#{exposure["gateValue"]}|#{exposure["ruleID"]}}"
|
110
|
+
should_reject = seen[key]
|
111
|
+
seen[key] = true
|
112
|
+
should_reject == true
|
113
|
+
end
|
114
|
+
end
|
115
|
+
|
37
116
|
def shutdown
|
38
117
|
@spec_store.shutdown
|
39
118
|
end
|
40
119
|
|
41
|
-
|
120
|
+
def override_gate(gate, value)
|
121
|
+
@gate_overrides[gate] = value
|
122
|
+
end
|
123
|
+
|
124
|
+
def override_config(config, value)
|
125
|
+
@config_overrides[config] = value
|
126
|
+
end
|
42
127
|
|
43
128
|
def eval_spec(user, config)
|
44
129
|
default_rule_id = 'default'
|
@@ -62,7 +147,9 @@ module Statsig
|
|
62
147
|
pass,
|
63
148
|
pass ? result.json_value : config['defaultValue'],
|
64
149
|
result.rule_id,
|
65
|
-
exposures
|
150
|
+
exposures,
|
151
|
+
evaluation_details: EvaluationDetails.new(@spec_store.last_config_sync_time, @spec_store.initial_config_sync_time, @spec_store.init_reason),
|
152
|
+
is_experiment_group: result.is_experiment_group,
|
66
153
|
)
|
67
154
|
end
|
68
155
|
|
@@ -72,9 +159,17 @@ module Statsig
|
|
72
159
|
default_rule_id = 'disabled'
|
73
160
|
end
|
74
161
|
|
75
|
-
Statsig::ConfigResult.new(
|
162
|
+
Statsig::ConfigResult.new(
|
163
|
+
config['name'],
|
164
|
+
false,
|
165
|
+
config['defaultValue'],
|
166
|
+
default_rule_id,
|
167
|
+
exposures,
|
168
|
+
evaluation_details: EvaluationDetails.new(@spec_store.last_config_sync_time, @spec_store.initial_config_sync_time, @spec_store.init_reason))
|
76
169
|
end
|
77
170
|
|
171
|
+
private
|
172
|
+
|
78
173
|
def eval_rule(user, rule)
|
79
174
|
exposures = []
|
80
175
|
pass = true
|
@@ -94,7 +189,14 @@ module Statsig
|
|
94
189
|
i += 1
|
95
190
|
end
|
96
191
|
|
97
|
-
Statsig::ConfigResult.new(
|
192
|
+
Statsig::ConfigResult.new(
|
193
|
+
'',
|
194
|
+
pass,
|
195
|
+
rule['returnValue'],
|
196
|
+
rule['id'],
|
197
|
+
exposures,
|
198
|
+
evaluation_details: EvaluationDetails.new(@spec_store.last_config_sync_time, @spec_store.initial_config_sync_time, @spec_store.init_reason),
|
199
|
+
is_experiment_group: rule["isExperimentGroup"] == true)
|
98
200
|
end
|
99
201
|
|
100
202
|
def eval_delegate(name, user, rule, exposures)
|
@@ -154,7 +256,7 @@ module Statsig
|
|
154
256
|
when 'environment_field'
|
155
257
|
value = get_value_from_environment(user, field)
|
156
258
|
when 'current_time'
|
157
|
-
value = Time.now.
|
259
|
+
value = Time.now.to_i # epoch time in seconds
|
158
260
|
when 'user_bucket'
|
159
261
|
begin
|
160
262
|
salt = additional_values['salt']
|
data/lib/network.rb
CHANGED
@@ -6,21 +6,23 @@ $retry_codes = [408, 500, 502, 503, 504, 522, 524, 599]
|
|
6
6
|
|
7
7
|
module Statsig
|
8
8
|
class Network
|
9
|
-
def initialize(server_secret, api, backoff_mult = 10)
|
9
|
+
def initialize(server_secret, api, local_mode, backoff_mult = 10)
|
10
10
|
super()
|
11
11
|
unless api.end_with?('/')
|
12
12
|
api += '/'
|
13
13
|
end
|
14
14
|
@server_secret = server_secret
|
15
15
|
@api = api
|
16
|
+
@local_mode = local_mode
|
16
17
|
@backoff_multiplier = backoff_mult
|
17
18
|
@session_id = SecureRandom.uuid
|
18
19
|
end
|
19
20
|
|
20
21
|
def post_helper(endpoint, body, retries = 0, backoff = 1)
|
22
|
+
return nil unless !@local_mode
|
21
23
|
http = HTTP.headers(
|
22
24
|
{"STATSIG-API-KEY" => @server_secret,
|
23
|
-
"STATSIG-CLIENT-TIME" => (Time.now.to_f * 1000).to_s,
|
25
|
+
"STATSIG-CLIENT-TIME" => (Time.now.to_f * 1000).to_i.to_s,
|
24
26
|
"STATSIG-SERVER-SESSION-ID" => @session_id,
|
25
27
|
"Content-Type" => "application/json; charset=UTF-8"
|
26
28
|
}).accept(:json)
|
data/lib/spec_store.rb
CHANGED
@@ -1,63 +1,101 @@
|
|
1
1
|
require 'net/http'
|
2
2
|
require 'uri'
|
3
3
|
|
4
|
+
require 'evaluation_details'
|
4
5
|
require 'id_list'
|
5
6
|
|
6
7
|
module Statsig
|
7
8
|
class SpecStore
|
8
|
-
|
9
|
+
attr_accessor :last_config_sync_time
|
10
|
+
attr_accessor :initial_config_sync_time
|
11
|
+
attr_accessor :init_reason
|
12
|
+
|
13
|
+
def initialize(network, error_callback = nil, rulesets_sync_interval = 10, id_lists_sync_interval = 60, bootstrap_values = nil, rules_updated_callback = nil)
|
14
|
+
@init_reason = EvaluationReason::UNINITIALIZED
|
9
15
|
@network = network
|
10
|
-
@
|
16
|
+
@last_config_sync_time = 0
|
17
|
+
@initial_config_sync_time = 0
|
11
18
|
@rulesets_sync_interval = rulesets_sync_interval
|
12
19
|
@id_lists_sync_interval = id_lists_sync_interval
|
13
|
-
@
|
20
|
+
@rules_updated_callback = rules_updated_callback
|
21
|
+
@specs = {
|
14
22
|
:gates => {},
|
15
23
|
:configs => {},
|
16
24
|
:layers => {},
|
17
25
|
:id_lists => {},
|
26
|
+
:experiment_to_layer => {}
|
18
27
|
}
|
19
|
-
|
20
|
-
|
28
|
+
|
29
|
+
unless bootstrap_values.nil?
|
30
|
+
begin
|
31
|
+
if process(JSON.parse(bootstrap_values))
|
32
|
+
@init_reason = EvaluationReason::BOOTSTRAP
|
33
|
+
end
|
34
|
+
rescue
|
35
|
+
puts 'the provided bootstrapValues is not a valid JSON string'
|
36
|
+
end
|
37
|
+
end
|
38
|
+
|
39
|
+
@error_callback = error_callback
|
40
|
+
download_config_specs
|
41
|
+
@initial_config_sync_time = @last_config_sync_time == 0 ? -1 : @last_config_sync_time
|
21
42
|
get_id_lists
|
22
43
|
|
23
44
|
@config_sync_thread = sync_config_specs
|
24
45
|
@id_lists_sync_thread = sync_id_lists
|
25
46
|
end
|
26
47
|
|
48
|
+
def is_ready_for_checks
|
49
|
+
@last_config_sync_time != 0
|
50
|
+
end
|
51
|
+
|
27
52
|
def shutdown
|
28
53
|
@config_sync_thread&.exit
|
29
54
|
@id_lists_sync_thread&.exit
|
30
55
|
end
|
31
56
|
|
32
57
|
def has_gate?(gate_name)
|
33
|
-
@
|
58
|
+
@specs[:gates].key?(gate_name)
|
34
59
|
end
|
35
60
|
|
36
61
|
def has_config?(config_name)
|
37
|
-
@
|
62
|
+
@specs[:configs].key?(config_name)
|
38
63
|
end
|
39
64
|
|
40
65
|
def has_layer?(layer_name)
|
41
|
-
@
|
66
|
+
@specs[:layers].key?(layer_name)
|
42
67
|
end
|
43
68
|
|
44
69
|
def get_gate(gate_name)
|
45
70
|
return nil unless has_gate?(gate_name)
|
46
|
-
@
|
71
|
+
@specs[:gates][gate_name]
|
47
72
|
end
|
48
73
|
|
49
74
|
def get_config(config_name)
|
50
75
|
return nil unless has_config?(config_name)
|
51
|
-
@
|
76
|
+
@specs[:configs][config_name]
|
52
77
|
end
|
53
78
|
|
54
79
|
def get_layer(layer_name)
|
55
80
|
return nil unless has_layer?(layer_name)
|
56
|
-
@
|
81
|
+
@specs[:layers][layer_name]
|
57
82
|
end
|
58
83
|
|
59
84
|
def get_id_list(list_name)
|
60
|
-
@
|
85
|
+
@specs[:id_lists][list_name]
|
86
|
+
end
|
87
|
+
|
88
|
+
def get_raw_specs
|
89
|
+
@specs
|
90
|
+
end
|
91
|
+
|
92
|
+
def maybe_restart_background_threads
|
93
|
+
if @config_sync_thread.nil? or !@config_sync_thread.alive?
|
94
|
+
@config_sync_thread = sync_config_specs
|
95
|
+
end
|
96
|
+
if @id_lists_sync_thread.nil? or !@id_lists_sync_thread.alive?
|
97
|
+
@id_lists_sync_thread = sync_id_lists
|
98
|
+
end
|
61
99
|
end
|
62
100
|
|
63
101
|
private
|
@@ -81,10 +119,19 @@ module Statsig
|
|
81
119
|
end
|
82
120
|
|
83
121
|
def download_config_specs
|
122
|
+
e = get_config_specs_from_network
|
123
|
+
@error_callback.call(e) unless e.nil? or @error_callback.nil?
|
124
|
+
end
|
125
|
+
|
126
|
+
def get_config_specs_from_network
|
84
127
|
begin
|
85
|
-
response, e = @network.post_helper('download_config_specs', JSON.generate({'sinceTime' => @
|
128
|
+
response, e = @network.post_helper('download_config_specs', JSON.generate({ 'sinceTime' => @last_config_sync_time }))
|
86
129
|
if e.nil?
|
87
|
-
process(JSON.parse(response.body))
|
130
|
+
if process(JSON.parse(response.body))
|
131
|
+
@init_reason = EvaluationReason::NETWORK
|
132
|
+
@rules_updated_callback.call(response.body.to_s, @last_config_sync_time) unless response.body.nil? or @rules_updated_callback.nil?
|
133
|
+
end
|
134
|
+
nil
|
88
135
|
else
|
89
136
|
e
|
90
137
|
end
|
@@ -95,36 +142,47 @@ module Statsig
|
|
95
142
|
|
96
143
|
def process(specs_json)
|
97
144
|
if specs_json.nil?
|
98
|
-
return
|
145
|
+
return false
|
99
146
|
end
|
100
147
|
|
101
|
-
@
|
102
|
-
|
148
|
+
@last_config_sync_time = specs_json['time'] || @last_config_sync_time
|
149
|
+
|
150
|
+
return false unless specs_json['has_updates'] == true &&
|
103
151
|
!specs_json['feature_gates'].nil? &&
|
104
152
|
!specs_json['dynamic_configs'].nil? &&
|
105
|
-
!specs_json['layer_configs'].nil?
|
153
|
+
!specs_json['layer_configs'].nil?
|
106
154
|
|
107
155
|
new_gates = {}
|
108
156
|
new_configs = {}
|
109
157
|
new_layers = {}
|
158
|
+
new_exp_to_layer = {}
|
159
|
+
|
160
|
+
specs_json['feature_gates'].each { |gate| new_gates[gate['name']] = gate }
|
161
|
+
specs_json['dynamic_configs'].each { |config| new_configs[config['name']] = config }
|
162
|
+
specs_json['layer_configs'].each { |layer| new_layers[layer['name']] = layer }
|
163
|
+
|
164
|
+
if specs_json['layers'].is_a?(Hash)
|
165
|
+
specs_json['layers'].each { |layer_name, experiments|
|
166
|
+
experiments.each { |experiment_name| new_exp_to_layer[experiment_name] = layer_name }
|
167
|
+
}
|
168
|
+
end
|
110
169
|
|
111
|
-
|
112
|
-
|
113
|
-
|
114
|
-
@
|
115
|
-
|
116
|
-
@store[:layers] = new_layers
|
170
|
+
@specs[:gates] = new_gates
|
171
|
+
@specs[:configs] = new_configs
|
172
|
+
@specs[:layers] = new_layers
|
173
|
+
@specs[:experiment_to_layer] = new_exp_to_layer
|
174
|
+
true
|
117
175
|
end
|
118
176
|
|
119
177
|
def get_id_lists
|
120
|
-
response, e = @network.post_helper('get_id_lists', JSON.generate({'statsigMetadata' => Statsig.get_statsig_metadata}))
|
178
|
+
response, e = @network.post_helper('get_id_lists', JSON.generate({ 'statsigMetadata' => Statsig.get_statsig_metadata }))
|
121
179
|
if !e.nil? || response.nil?
|
122
180
|
return
|
123
181
|
end
|
124
182
|
|
125
183
|
begin
|
126
184
|
server_id_lists = JSON.parse(response)
|
127
|
-
local_id_lists = @
|
185
|
+
local_id_lists = @specs[:id_lists]
|
128
186
|
if !server_id_lists.is_a?(Hash) || !local_id_lists.is_a?(Hash)
|
129
187
|
return
|
130
188
|
end
|
@@ -178,7 +236,7 @@ module Statsig
|
|
178
236
|
|
179
237
|
def download_single_id_list(list)
|
180
238
|
nil unless list.is_a? IDList
|
181
|
-
http = HTTP.headers({'Range' => "bytes=#{list&.size || 0}-"}).accept(:json)
|
239
|
+
http = HTTP.headers({ 'Range' => "bytes=#{list&.size || 0}-" }).accept(:json)
|
182
240
|
begin
|
183
241
|
res = http.get(list.url)
|
184
242
|
nil unless res.status.success?
|
@@ -186,7 +244,7 @@ module Statsig
|
|
186
244
|
nil if content_length.nil? || content_length <= 0
|
187
245
|
content = res.body.to_s
|
188
246
|
unless content.is_a?(String) && (content[0] == '-' || content[0] == '+')
|
189
|
-
@
|
247
|
+
@specs[:id_lists].delete(list.name)
|
190
248
|
return
|
191
249
|
end
|
192
250
|
ids_clone = list.ids # clone the list, operate on the new list, and swap out the old list, so the operation is thread-safe
|
data/lib/statsig.rb
CHANGED
@@ -4,6 +4,7 @@ module Statsig
|
|
4
4
|
def self.initialize(secret_key, options = nil, error_callback = nil)
|
5
5
|
unless @shared_instance.nil?
|
6
6
|
puts 'Statsig already initialized.'
|
7
|
+
@shared_instance.maybe_restart_background_threads
|
7
8
|
return @shared_instance
|
8
9
|
end
|
9
10
|
|
@@ -42,10 +43,25 @@ module Statsig
|
|
42
43
|
@shared_instance = nil
|
43
44
|
end
|
44
45
|
|
46
|
+
def self.override_gate(gate_name, gate_value)
|
47
|
+
ensure_initialized
|
48
|
+
@shared_instance&.override_gate(gate_name, gate_value)
|
49
|
+
end
|
50
|
+
|
51
|
+
def self.override_config(config_name, config_value)
|
52
|
+
ensure_initialized
|
53
|
+
@shared_instance&.override_config(config_name, config_value)
|
54
|
+
end
|
55
|
+
|
56
|
+
def self.get_client_initialize_response(user)
|
57
|
+
ensure_initialized
|
58
|
+
@shared_instance&.get_client_initialize_response(user)
|
59
|
+
end
|
60
|
+
|
45
61
|
def self.get_statsig_metadata
|
46
62
|
{
|
47
63
|
'sdkType' => 'ruby-server',
|
48
|
-
'sdkVersion' => '1.
|
64
|
+
'sdkVersion' => '1.13.0',
|
49
65
|
}
|
50
66
|
end
|
51
67
|
|
data/lib/statsig_driver.rb
CHANGED
@@ -22,7 +22,7 @@ class StatsigDriver
|
|
22
22
|
@options = options || StatsigOptions.new
|
23
23
|
@shutdown = false
|
24
24
|
@secret_key = secret_key
|
25
|
-
@net = Statsig::Network.new(secret_key, @options.api_url_base)
|
25
|
+
@net = Statsig::Network.new(secret_key, @options.api_url_base, @options.local_mode)
|
26
26
|
@logger = Statsig::StatsigLogger.new(@net, @options)
|
27
27
|
@evaluator = Statsig::Evaluator.new(@net, @options, error_callback)
|
28
28
|
end
|
@@ -39,7 +39,7 @@ class StatsigDriver
|
|
39
39
|
res = check_gate_fallback(user, gate_name)
|
40
40
|
# exposure logged by the server
|
41
41
|
else
|
42
|
-
@logger.log_gate_exposure(user, res.name, res.gate_value, res.rule_id, res.secondary_exposures)
|
42
|
+
@logger.log_gate_exposure(user, res.name, res.gate_value, res.rule_id, res.secondary_exposures, res.evaluation_details)
|
43
43
|
end
|
44
44
|
|
45
45
|
res.gate_value
|
@@ -98,6 +98,26 @@ class StatsigDriver
|
|
98
98
|
@evaluator.shutdown
|
99
99
|
end
|
100
100
|
|
101
|
+
def override_gate(gate_name, gate_value)
|
102
|
+
@evaluator.override_gate(gate_name, gate_value)
|
103
|
+
end
|
104
|
+
|
105
|
+
def override_config(config_name, config_value)
|
106
|
+
@evaluator.override_config(config_name, config_value)
|
107
|
+
end
|
108
|
+
|
109
|
+
# @param [StatsigUser] user
|
110
|
+
# @return [Hash]
|
111
|
+
def get_client_initialize_response(user)
|
112
|
+
normalize_user(user)
|
113
|
+
@evaluator.get_client_initialize_response(user)
|
114
|
+
end
|
115
|
+
|
116
|
+
def maybe_restart_background_threads
|
117
|
+
@evaluator.maybe_restart_background_threads
|
118
|
+
@logger.maybe_restart_background_threads
|
119
|
+
end
|
120
|
+
|
101
121
|
private
|
102
122
|
|
103
123
|
def verify_inputs(user, config_name, variable_name)
|
@@ -120,7 +140,7 @@ class StatsigDriver
|
|
120
140
|
res = get_config_fallback(user, config_name)
|
121
141
|
# exposure logged by the server
|
122
142
|
else
|
123
|
-
@logger.log_config_exposure(user, res.name, res.rule_id, res.secondary_exposures)
|
143
|
+
@logger.log_config_exposure(user, res.name, res.rule_id, res.secondary_exposures, res.evaluation_details)
|
124
144
|
end
|
125
145
|
|
126
146
|
DynamicConfig.new(res.name, res.json_value, res.rule_id)
|
@@ -132,7 +152,7 @@ class StatsigDriver
|
|
132
152
|
(
|
133
153
|
# user_id is nil and custom_ids is not a hash with entries
|
134
154
|
!user.user_id.is_a?(String) &&
|
135
|
-
|
155
|
+
(!user.custom_ids.is_a?(Hash) || user.custom_ids.size == 0)
|
136
156
|
)
|
137
157
|
raise 'Must provide a valid StatsigUser with a user_id or at least a custom ID. See https://docs.statsig.com/messages/serverRequiredUserID/ for more details.'
|
138
158
|
end
|
data/lib/statsig_event.rb
CHANGED
data/lib/statsig_logger.rb
CHANGED
@@ -20,28 +20,32 @@ module Statsig
|
|
20
20
|
end
|
21
21
|
end
|
22
22
|
|
23
|
-
def log_gate_exposure(user, gate_name, value, rule_id, secondary_exposures)
|
23
|
+
def log_gate_exposure(user, gate_name, value, rule_id, secondary_exposures, eval_details)
|
24
24
|
event = StatsigEvent.new($gate_exposure_event)
|
25
25
|
event.user = user
|
26
26
|
event.metadata = {
|
27
27
|
'gate' => gate_name,
|
28
28
|
'gateValue' => value.to_s,
|
29
|
-
'ruleID' => rule_id
|
29
|
+
'ruleID' => rule_id,
|
30
30
|
}
|
31
31
|
event.statsig_metadata = Statsig.get_statsig_metadata
|
32
32
|
event.secondary_exposures = secondary_exposures.is_a?(Array) ? secondary_exposures : []
|
33
|
+
|
34
|
+
safe_add_eval_details(eval_details, event)
|
33
35
|
log_event(event)
|
34
36
|
end
|
35
37
|
|
36
|
-
def log_config_exposure(user, config_name, rule_id, secondary_exposures)
|
38
|
+
def log_config_exposure(user, config_name, rule_id, secondary_exposures, eval_details)
|
37
39
|
event = StatsigEvent.new($config_exposure_event)
|
38
40
|
event.user = user
|
39
41
|
event.metadata = {
|
40
42
|
'config' => config_name,
|
41
|
-
'ruleID' => rule_id
|
43
|
+
'ruleID' => rule_id,
|
42
44
|
}
|
43
45
|
event.statsig_metadata = Statsig.get_statsig_metadata
|
44
46
|
event.secondary_exposures = secondary_exposures.is_a?(Array) ? secondary_exposures : []
|
47
|
+
|
48
|
+
safe_add_eval_details(eval_details, event)
|
45
49
|
log_event(event)
|
46
50
|
end
|
47
51
|
|
@@ -61,10 +65,12 @@ module Statsig
|
|
61
65
|
'ruleID' => layer.rule_id,
|
62
66
|
'allocatedExperiment' => allocated_experiment,
|
63
67
|
'parameterName' => parameter_name,
|
64
|
-
'isExplicitParameter' => String(is_explicit)
|
68
|
+
'isExplicitParameter' => String(is_explicit),
|
65
69
|
}
|
66
70
|
event.statsig_metadata = Statsig.get_statsig_metadata
|
67
71
|
event.secondary_exposures = exposures.is_a?(Array) ? exposures : []
|
72
|
+
|
73
|
+
safe_add_eval_details(config_evaluation.evaluation_details, event)
|
68
74
|
log_event(event)
|
69
75
|
end
|
70
76
|
|
@@ -90,5 +96,24 @@ module Statsig
|
|
90
96
|
|
91
97
|
@network.post_logs(flush_events)
|
92
98
|
end
|
99
|
+
|
100
|
+
def maybe_restart_background_threads
|
101
|
+
if @background_flush.nil? or !@background_flush.alive?
|
102
|
+
@background_flush = periodic_flush
|
103
|
+
end
|
104
|
+
end
|
105
|
+
|
106
|
+
private
|
107
|
+
|
108
|
+
def safe_add_eval_details(eval_details, event)
|
109
|
+
if eval_details.nil?
|
110
|
+
return
|
111
|
+
end
|
112
|
+
|
113
|
+
event.metadata['reason'] = eval_details.reason
|
114
|
+
event.metadata['configSyncTime'] = eval_details.config_sync_time
|
115
|
+
event.metadata['initTime'] = eval_details.init_time
|
116
|
+
event.metadata['serverTime'] = eval_details.server_time
|
117
|
+
end
|
93
118
|
end
|
94
119
|
end
|
data/lib/statsig_options.rb
CHANGED
@@ -1,10 +1,13 @@
|
|
1
1
|
class StatsigOptions
|
2
|
-
|
3
|
-
|
4
|
-
|
5
|
-
|
6
|
-
|
7
|
-
|
2
|
+
attr_accessor :environment
|
3
|
+
attr_accessor :api_url_base
|
4
|
+
attr_accessor :rulesets_sync_interval
|
5
|
+
attr_accessor :idlists_sync_interval
|
6
|
+
attr_accessor :logging_interval_seconds
|
7
|
+
attr_accessor :logging_max_buffer_size
|
8
|
+
attr_accessor :local_mode
|
9
|
+
attr_accessor :bootstrap_values
|
10
|
+
attr_accessor :rules_updated_callback
|
8
11
|
|
9
12
|
def initialize(
|
10
13
|
environment=nil,
|
@@ -12,12 +15,18 @@ class StatsigOptions
|
|
12
15
|
rulesets_sync_interval: 10,
|
13
16
|
idlists_sync_interval: 60,
|
14
17
|
logging_interval_seconds: 60,
|
15
|
-
logging_max_buffer_size: 1000
|
18
|
+
logging_max_buffer_size: 1000,
|
19
|
+
local_mode: false,
|
20
|
+
bootstrap_values: nil,
|
21
|
+
rules_updated_callback: nil)
|
16
22
|
@environment = environment.is_a?(Hash) ? environment : nil
|
17
23
|
@api_url_base = api_url_base
|
18
24
|
@rulesets_sync_interval = rulesets_sync_interval
|
19
25
|
@idlists_sync_interval = idlists_sync_interval
|
20
26
|
@logging_interval_seconds = logging_interval_seconds
|
21
27
|
@logging_max_buffer_size = [logging_max_buffer_size, 1000].min
|
28
|
+
@local_mode = local_mode
|
29
|
+
@bootstrap_values = bootstrap_values
|
30
|
+
@rules_updated_callback = rules_updated_callback
|
22
31
|
end
|
23
32
|
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.13.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: 2022-
|
11
|
+
date: 2022-09-20 00:00:00.000000000 Z
|
12
12
|
dependencies:
|
13
13
|
- !ruby/object:Gem::Dependency
|
14
14
|
name: bundler
|
@@ -120,8 +120,10 @@ executables: []
|
|
120
120
|
extensions: []
|
121
121
|
extra_rdoc_files: []
|
122
122
|
files:
|
123
|
+
- lib/client_initialize_helpers.rb
|
123
124
|
- lib/config_result.rb
|
124
125
|
- lib/dynamic_config.rb
|
126
|
+
- lib/evaluation_details.rb
|
125
127
|
- lib/evaluation_helpers.rb
|
126
128
|
- lib/evaluator.rb
|
127
129
|
- lib/id_list.rb
|
@@ -153,7 +155,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
153
155
|
- !ruby/object:Gem::Version
|
154
156
|
version: '0'
|
155
157
|
requirements: []
|
156
|
-
rubygems_version: 3.3
|
158
|
+
rubygems_version: 3.2.3
|
157
159
|
signing_key:
|
158
160
|
specification_version: 4
|
159
161
|
summary: Statsig server SDK for Ruby
|