statsig 1.12.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 +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
|