statsig 1.12.0 → 1.13.0
Sign up to get free protection for your applications and to get access to all the features.
- 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 +102 -23
- data/lib/network.rb +1 -1
- data/lib/spec_store.rb +78 -26
- data/lib/statsig.rb +7 -1
- data/lib/statsig_driver.rb +15 -3
- data/lib/statsig_event.rb +1 -1
- data/lib/statsig_logger.rb +30 -5
- data/lib/statsig_options.rb +7 -1
- 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,43 +14,105 @@ $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
|
-
@initialized = true
|
20
23
|
|
21
24
|
@gate_overrides = {}
|
22
25
|
@config_overrides = {}
|
23
26
|
end
|
24
27
|
|
28
|
+
def maybe_restart_background_threads
|
29
|
+
@spec_store.maybe_restart_background_threads
|
30
|
+
end
|
31
|
+
|
25
32
|
def check_gate(user, gate_name)
|
26
|
-
|
27
|
-
|
28
|
-
|
29
|
-
|
30
|
-
|
31
|
-
|
32
|
-
|
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
|
+
|
33
51
|
eval_spec(user, @spec_store.get_gate(gate_name))
|
34
52
|
end
|
35
53
|
|
36
54
|
def get_config(user, config_name)
|
37
|
-
|
38
|
-
|
39
|
-
|
40
|
-
|
41
|
-
|
42
|
-
|
43
|
-
|
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
|
+
|
44
73
|
eval_spec(user, @spec_store.get_config(config_name))
|
45
74
|
end
|
46
75
|
|
47
76
|
def get_layer(user, layer_name)
|
48
|
-
|
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
|
+
|
49
85
|
eval_spec(user, @spec_store.get_layer(layer_name))
|
50
86
|
end
|
51
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
|
+
|
52
116
|
def shutdown
|
53
117
|
@spec_store.shutdown
|
54
118
|
end
|
@@ -61,8 +125,6 @@ module Statsig
|
|
61
125
|
@config_overrides[config] = value
|
62
126
|
end
|
63
127
|
|
64
|
-
private
|
65
|
-
|
66
128
|
def eval_spec(user, config)
|
67
129
|
default_rule_id = 'default'
|
68
130
|
exposures = []
|
@@ -85,7 +147,9 @@ module Statsig
|
|
85
147
|
pass,
|
86
148
|
pass ? result.json_value : config['defaultValue'],
|
87
149
|
result.rule_id,
|
88
|
-
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,
|
89
153
|
)
|
90
154
|
end
|
91
155
|
|
@@ -95,9 +159,17 @@ module Statsig
|
|
95
159
|
default_rule_id = 'disabled'
|
96
160
|
end
|
97
161
|
|
98
|
-
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))
|
99
169
|
end
|
100
170
|
|
171
|
+
private
|
172
|
+
|
101
173
|
def eval_rule(user, rule)
|
102
174
|
exposures = []
|
103
175
|
pass = true
|
@@ -117,7 +189,14 @@ module Statsig
|
|
117
189
|
i += 1
|
118
190
|
end
|
119
191
|
|
120
|
-
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)
|
121
200
|
end
|
122
201
|
|
123
202
|
def eval_delegate(name, user, rule, exposures)
|
@@ -177,7 +256,7 @@ module Statsig
|
|
177
256
|
when 'environment_field'
|
178
257
|
value = get_value_from_environment(user, field)
|
179
258
|
when 'current_time'
|
180
|
-
value = Time.now.
|
259
|
+
value = Time.now.to_i # epoch time in seconds
|
181
260
|
when 'user_bucket'
|
182
261
|
begin
|
183
262
|
salt = additional_values['salt']
|
data/lib/network.rb
CHANGED
@@ -22,7 +22,7 @@ module Statsig
|
|
22
22
|
return nil unless !@local_mode
|
23
23
|
http = HTTP.headers(
|
24
24
|
{"STATSIG-API-KEY" => @server_secret,
|
25
|
-
"STATSIG-CLIENT-TIME" => (Time.now.to_f * 1000).to_s,
|
25
|
+
"STATSIG-CLIENT-TIME" => (Time.now.to_f * 1000).to_i.to_s,
|
26
26
|
"STATSIG-SERVER-SESSION-ID" => @session_id,
|
27
27
|
"Content-Type" => "application/json; charset=UTF-8"
|
28
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
|
}
|
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
|
+
|
19
39
|
@error_callback = error_callback
|
20
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
|
@@ -87,9 +125,12 @@ module Statsig
|
|
87
125
|
|
88
126
|
def get_config_specs_from_network
|
89
127
|
begin
|
90
|
-
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 }))
|
91
129
|
if e.nil?
|
92
|
-
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
|
93
134
|
nil
|
94
135
|
else
|
95
136
|
e
|
@@ -101,36 +142,47 @@ module Statsig
|
|
101
142
|
|
102
143
|
def process(specs_json)
|
103
144
|
if specs_json.nil?
|
104
|
-
return
|
145
|
+
return false
|
105
146
|
end
|
106
147
|
|
107
|
-
@
|
108
|
-
|
148
|
+
@last_config_sync_time = specs_json['time'] || @last_config_sync_time
|
149
|
+
|
150
|
+
return false unless specs_json['has_updates'] == true &&
|
109
151
|
!specs_json['feature_gates'].nil? &&
|
110
152
|
!specs_json['dynamic_configs'].nil? &&
|
111
|
-
!specs_json['layer_configs'].nil?
|
153
|
+
!specs_json['layer_configs'].nil?
|
112
154
|
|
113
155
|
new_gates = {}
|
114
156
|
new_configs = {}
|
115
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
|
116
169
|
|
117
|
-
|
118
|
-
|
119
|
-
|
120
|
-
@
|
121
|
-
|
122
|
-
@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
|
123
175
|
end
|
124
176
|
|
125
177
|
def get_id_lists
|
126
|
-
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 }))
|
127
179
|
if !e.nil? || response.nil?
|
128
180
|
return
|
129
181
|
end
|
130
182
|
|
131
183
|
begin
|
132
184
|
server_id_lists = JSON.parse(response)
|
133
|
-
local_id_lists = @
|
185
|
+
local_id_lists = @specs[:id_lists]
|
134
186
|
if !server_id_lists.is_a?(Hash) || !local_id_lists.is_a?(Hash)
|
135
187
|
return
|
136
188
|
end
|
@@ -184,7 +236,7 @@ module Statsig
|
|
184
236
|
|
185
237
|
def download_single_id_list(list)
|
186
238
|
nil unless list.is_a? IDList
|
187
|
-
http = HTTP.headers({'Range' => "bytes=#{list&.size || 0}-"}).accept(:json)
|
239
|
+
http = HTTP.headers({ 'Range' => "bytes=#{list&.size || 0}-" }).accept(:json)
|
188
240
|
begin
|
189
241
|
res = http.get(list.url)
|
190
242
|
nil unless res.status.success?
|
@@ -192,7 +244,7 @@ module Statsig
|
|
192
244
|
nil if content_length.nil? || content_length <= 0
|
193
245
|
content = res.body.to_s
|
194
246
|
unless content.is_a?(String) && (content[0] == '-' || content[0] == '+')
|
195
|
-
@
|
247
|
+
@specs[:id_lists].delete(list.name)
|
196
248
|
return
|
197
249
|
end
|
198
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
|
|
@@ -52,10 +53,15 @@ module Statsig
|
|
52
53
|
@shared_instance&.override_config(config_name, config_value)
|
53
54
|
end
|
54
55
|
|
56
|
+
def self.get_client_initialize_response(user)
|
57
|
+
ensure_initialized
|
58
|
+
@shared_instance&.get_client_initialize_response(user)
|
59
|
+
end
|
60
|
+
|
55
61
|
def self.get_statsig_metadata
|
56
62
|
{
|
57
63
|
'sdkType' => 'ruby-server',
|
58
|
-
'sdkVersion' => '1.
|
64
|
+
'sdkVersion' => '1.13.0',
|
59
65
|
}
|
60
66
|
end
|
61
67
|
|
data/lib/statsig_driver.rb
CHANGED
@@ -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
|
@@ -106,6 +106,18 @@ class StatsigDriver
|
|
106
106
|
@evaluator.override_config(config_name, config_value)
|
107
107
|
end
|
108
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
|
+
|
109
121
|
private
|
110
122
|
|
111
123
|
def verify_inputs(user, config_name, variable_name)
|
@@ -128,7 +140,7 @@ class StatsigDriver
|
|
128
140
|
res = get_config_fallback(user, config_name)
|
129
141
|
# exposure logged by the server
|
130
142
|
else
|
131
|
-
@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)
|
132
144
|
end
|
133
145
|
|
134
146
|
DynamicConfig.new(res.name, res.json_value, res.rule_id)
|
@@ -140,7 +152,7 @@ class StatsigDriver
|
|
140
152
|
(
|
141
153
|
# user_id is nil and custom_ids is not a hash with entries
|
142
154
|
!user.user_id.is_a?(String) &&
|
143
|
-
|
155
|
+
(!user.custom_ids.is_a?(Hash) || user.custom_ids.size == 0)
|
144
156
|
)
|
145
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.'
|
146
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
@@ -6,6 +6,8 @@ class StatsigOptions
|
|
6
6
|
attr_accessor :logging_interval_seconds
|
7
7
|
attr_accessor :logging_max_buffer_size
|
8
8
|
attr_accessor :local_mode
|
9
|
+
attr_accessor :bootstrap_values
|
10
|
+
attr_accessor :rules_updated_callback
|
9
11
|
|
10
12
|
def initialize(
|
11
13
|
environment=nil,
|
@@ -14,7 +16,9 @@ class StatsigOptions
|
|
14
16
|
idlists_sync_interval: 60,
|
15
17
|
logging_interval_seconds: 60,
|
16
18
|
logging_max_buffer_size: 1000,
|
17
|
-
local_mode: false
|
19
|
+
local_mode: false,
|
20
|
+
bootstrap_values: nil,
|
21
|
+
rules_updated_callback: nil)
|
18
22
|
@environment = environment.is_a?(Hash) ? environment : nil
|
19
23
|
@api_url_base = api_url_base
|
20
24
|
@rulesets_sync_interval = rulesets_sync_interval
|
@@ -22,5 +26,7 @@ class StatsigOptions
|
|
22
26
|
@logging_interval_seconds = logging_interval_seconds
|
23
27
|
@logging_max_buffer_size = [logging_max_buffer_size, 1000].min
|
24
28
|
@local_mode = local_mode
|
29
|
+
@bootstrap_values = bootstrap_values
|
30
|
+
@rules_updated_callback = rules_updated_callback
|
25
31
|
end
|
26
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.2.
|
158
|
+
rubygems_version: 3.2.3
|
157
159
|
signing_key:
|
158
160
|
specification_version: 4
|
159
161
|
summary: Statsig server SDK for Ruby
|