statsig 1.8.0 → 1.8.4.beta.pre.1
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/config_result.rb +15 -12
- data/lib/evaluator.rb +261 -259
- data/lib/network.rb +52 -50
- data/lib/spec_store.rb +111 -110
- data/lib/statsig.rb +1 -1
- data/lib/statsig_driver.rb +9 -9
- data/lib/statsig_logger.rb +58 -45
- metadata +4 -4
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 9e0d1d1530c323ccc58ea83da7a0c305692b51defa32071ee3f842d1519bb16b
|
4
|
+
data.tar.gz: e0d5018bbfd6f8c3dbe4fc099039d729d270d1ea5df8d5f59dbb72e56772da30
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 9aa5bcf5d281709982ab3c325043cfcea99118e36bf2d82d11fd75995ad4a1e1f8f3e3e1a087652432ae5b6d0f15ee3a1a5deebff6f8d557ede16167edf72250
|
7
|
+
data.tar.gz: b1d95b28521991e9a247f181b16ce7652158bf713e230aca0a2c4ebc1752c6a2e317223e50e69caaa2b1b9751d85b7e6db3de5d70e5dc1750ca41009e52ec0a8
|
data/lib/config_result.rb
CHANGED
@@ -1,15 +1,18 @@
|
|
1
|
-
class ConfigResult
|
2
|
-
attr_accessor :name
|
3
|
-
attr_accessor :gate_value
|
4
|
-
attr_accessor :json_value
|
5
|
-
attr_accessor :rule_id
|
6
|
-
attr_accessor :secondary_exposures
|
7
1
|
|
8
|
-
|
9
|
-
|
10
|
-
|
11
|
-
|
12
|
-
|
13
|
-
|
2
|
+
module Statsig
|
3
|
+
class ConfigResult
|
4
|
+
attr_accessor :name
|
5
|
+
attr_accessor :gate_value
|
6
|
+
attr_accessor :json_value
|
7
|
+
attr_accessor :rule_id
|
8
|
+
attr_accessor :secondary_exposures
|
9
|
+
|
10
|
+
def initialize(name, gate_value = false, json_value = {}, rule_id = '', secondary_exposures = [])
|
11
|
+
@name = name
|
12
|
+
@gate_value = gate_value
|
13
|
+
@json_value = json_value
|
14
|
+
@rule_id = rule_id
|
15
|
+
@secondary_exposures = secondary_exposures.is_a?(Array) ? secondary_exposures : []
|
16
|
+
end
|
14
17
|
end
|
15
18
|
end
|
data/lib/evaluator.rb
CHANGED
@@ -10,300 +10,302 @@ require 'user_agent_parser/operating_system'
|
|
10
10
|
$fetch_from_server = :fetch_from_server
|
11
11
|
$type_dynamic_config = 'dynamic_config'
|
12
12
|
|
13
|
-
|
14
|
-
|
15
|
-
|
16
|
-
|
17
|
-
|
18
|
-
|
19
|
-
|
13
|
+
module Statsig
|
14
|
+
class Evaluator
|
15
|
+
def initialize(network, error_callback)
|
16
|
+
@spec_store = Statsig::SpecStore.new(network, error_callback)
|
17
|
+
@ua_parser = UserAgentParser::Parser.new
|
18
|
+
CountryLookup.initialize
|
19
|
+
@initialized = true
|
20
|
+
end
|
20
21
|
|
21
|
-
|
22
|
-
|
23
|
-
|
24
|
-
|
22
|
+
def check_gate(user, gate_name)
|
23
|
+
return nil unless @initialized && @spec_store.has_gate?(gate_name)
|
24
|
+
eval_spec(user, @spec_store.get_gate(gate_name))
|
25
|
+
end
|
25
26
|
|
26
|
-
|
27
|
-
|
28
|
-
|
29
|
-
|
27
|
+
def get_config(user, config_name)
|
28
|
+
return nil unless @initialized && @spec_store.has_config?(config_name)
|
29
|
+
eval_spec(user, @spec_store.get_config(config_name))
|
30
|
+
end
|
30
31
|
|
31
|
-
|
32
|
-
|
33
|
-
|
32
|
+
def shutdown
|
33
|
+
@spec_store.shutdown
|
34
|
+
end
|
35
|
+
|
36
|
+
private
|
37
|
+
|
38
|
+
def eval_spec(user, config)
|
39
|
+
default_rule_id = 'default'
|
40
|
+
exposures = []
|
41
|
+
if config['enabled']
|
42
|
+
i = 0
|
43
|
+
until i >= config['rules'].length do
|
44
|
+
rule = config['rules'][i]
|
45
|
+
result = eval_rule(user, rule)
|
46
|
+
return $fetch_from_server if result == $fetch_from_server
|
47
|
+
exposures = exposures + result['exposures'] if result['exposures'].is_a? Array
|
48
|
+
if result['value']
|
49
|
+
pass = eval_pass_percent(user, rule, config['salt'])
|
50
|
+
return Statsig::ConfigResult.new(
|
51
|
+
config['name'],
|
52
|
+
pass,
|
53
|
+
pass ? rule['returnValue'] : config['defaultValue'],
|
54
|
+
rule['id'],
|
55
|
+
exposures
|
56
|
+
)
|
57
|
+
end
|
58
|
+
|
59
|
+
i += 1
|
60
|
+
end
|
61
|
+
else
|
62
|
+
default_rule_id = 'disabled'
|
63
|
+
end
|
34
64
|
|
35
|
-
|
65
|
+
Statsig::ConfigResult.new(config['name'], false, config['defaultValue'], default_rule_id, exposures)
|
66
|
+
end
|
36
67
|
|
37
|
-
|
38
|
-
|
39
|
-
|
40
|
-
if config['enabled']
|
68
|
+
def eval_rule(user, rule)
|
69
|
+
exposures = []
|
70
|
+
pass = true
|
41
71
|
i = 0
|
42
|
-
until i >=
|
43
|
-
|
44
|
-
result
|
45
|
-
|
46
|
-
exposures = exposures + result['exposures'] if result['exposures'].is_a? Array
|
47
|
-
if result['value']
|
48
|
-
pass = eval_pass_percent(user, rule, config['salt'])
|
49
|
-
return ConfigResult.new(
|
50
|
-
config['name'],
|
51
|
-
pass,
|
52
|
-
pass ? rule['returnValue'] : config['defaultValue'],
|
53
|
-
rule['id'],
|
54
|
-
exposures
|
55
|
-
)
|
72
|
+
until i >= rule['conditions'].length do
|
73
|
+
result = eval_condition(user, rule['conditions'][i])
|
74
|
+
if result == $fetch_from_server
|
75
|
+
return $fetch_from_server
|
56
76
|
end
|
57
77
|
|
78
|
+
if result.is_a?(Hash)
|
79
|
+
exposures = exposures + result['exposures'] if result['exposures'].is_a? Array
|
80
|
+
pass = false if result['value'] == false
|
81
|
+
elsif result == false
|
82
|
+
pass = false
|
83
|
+
end
|
58
84
|
i += 1
|
59
85
|
end
|
60
|
-
|
61
|
-
default_rule_id = 'disabled'
|
86
|
+
{ 'value' => pass, 'exposures' => exposures }
|
62
87
|
end
|
63
88
|
|
64
|
-
|
65
|
-
|
66
|
-
|
67
|
-
|
68
|
-
|
69
|
-
|
70
|
-
|
71
|
-
|
72
|
-
|
73
|
-
|
89
|
+
def eval_condition(user, condition)
|
90
|
+
value = nil
|
91
|
+
field = condition['field']
|
92
|
+
target = condition['targetValue']
|
93
|
+
type = condition['type']
|
94
|
+
operator = condition['operator']
|
95
|
+
additional_values = condition['additionalValues']
|
96
|
+
additional_values = Hash.new unless additional_values.is_a? Hash
|
97
|
+
idType = condition['idType']
|
98
|
+
|
99
|
+
return $fetch_from_server unless type.is_a? String
|
100
|
+
type = type.downcase
|
101
|
+
|
102
|
+
case type
|
103
|
+
when 'public'
|
104
|
+
return true
|
105
|
+
when 'fail_gate', 'pass_gate'
|
106
|
+
other_gate_result = check_gate(user, target)
|
107
|
+
return $fetch_from_server if other_gate_result == $fetch_from_server
|
108
|
+
|
109
|
+
gate_value = other_gate_result&.gate_value == true
|
110
|
+
new_exposure = {
|
111
|
+
'gate' => target,
|
112
|
+
'gateValue' => gate_value ? 'true' : 'false',
|
113
|
+
'ruleID' => other_gate_result&.rule_id
|
114
|
+
}
|
115
|
+
exposures = other_gate_result&.secondary_exposures&.append(new_exposure)
|
116
|
+
return {
|
117
|
+
'value' => type == 'pass_gate' ? gate_value : !gate_value,
|
118
|
+
'exposures' => exposures
|
119
|
+
}
|
120
|
+
when 'ip_based'
|
121
|
+
value = get_value_from_user(user, field) || get_value_from_ip(user, field)
|
122
|
+
return $fetch_from_server if value == $fetch_from_server
|
123
|
+
when 'ua_based'
|
124
|
+
value = get_value_from_user(user, field) || get_value_from_ua(user, field)
|
125
|
+
return $fetch_from_server if value == $fetch_from_server
|
126
|
+
when 'user_field'
|
127
|
+
value = get_value_from_user(user, field)
|
128
|
+
when 'environment_field'
|
129
|
+
value = get_value_from_environment(user, field)
|
130
|
+
when 'current_time'
|
131
|
+
value = Time.now.to_f # epoch time in seconds
|
132
|
+
when 'user_bucket'
|
133
|
+
begin
|
134
|
+
salt = additional_values['salt']
|
135
|
+
unit_id = get_unit_id(user, idType) || ''
|
136
|
+
# there are only 1000 user buckets as opposed to 10k for gate pass %
|
137
|
+
value = compute_user_hash("#{salt}.#{unit_id}") % 1000
|
138
|
+
rescue
|
139
|
+
return false
|
140
|
+
end
|
141
|
+
when 'unit_id'
|
142
|
+
value = get_unit_id(user, idType)
|
143
|
+
else
|
74
144
|
return $fetch_from_server
|
75
145
|
end
|
76
146
|
|
77
|
-
if
|
78
|
-
|
79
|
-
|
80
|
-
|
81
|
-
|
82
|
-
|
83
|
-
|
84
|
-
|
85
|
-
|
86
|
-
|
87
|
-
|
88
|
-
|
89
|
-
|
90
|
-
|
91
|
-
|
92
|
-
|
93
|
-
|
94
|
-
|
95
|
-
|
96
|
-
|
97
|
-
|
98
|
-
|
99
|
-
|
100
|
-
|
101
|
-
|
102
|
-
|
103
|
-
|
104
|
-
|
105
|
-
|
106
|
-
|
107
|
-
|
108
|
-
|
109
|
-
|
110
|
-
|
111
|
-
|
112
|
-
|
113
|
-
|
114
|
-
|
115
|
-
|
116
|
-
|
117
|
-
|
118
|
-
|
119
|
-
|
120
|
-
|
121
|
-
|
122
|
-
|
123
|
-
|
124
|
-
|
125
|
-
|
126
|
-
|
127
|
-
|
128
|
-
|
129
|
-
|
130
|
-
|
131
|
-
|
132
|
-
|
133
|
-
|
134
|
-
|
135
|
-
|
136
|
-
|
137
|
-
|
138
|
-
|
147
|
+
return $fetch_from_server if value == $fetch_from_server || !operator.is_a?(String)
|
148
|
+
operator = operator.downcase
|
149
|
+
|
150
|
+
case operator
|
151
|
+
# numerical comparison
|
152
|
+
when 'gt'
|
153
|
+
return EvaluationHelpers::compare_numbers(value, target, ->(a, b) { a > b })
|
154
|
+
when 'gte'
|
155
|
+
return EvaluationHelpers::compare_numbers(value, target, ->(a, b) { a >= b })
|
156
|
+
when 'lt'
|
157
|
+
return EvaluationHelpers::compare_numbers(value, target, ->(a, b) { a < b })
|
158
|
+
when 'lte'
|
159
|
+
return EvaluationHelpers::compare_numbers(value, target, ->(a, b) { a <= b })
|
160
|
+
|
161
|
+
# version comparison
|
162
|
+
when 'version_gt'
|
163
|
+
return (Gem::Version.new(value) > Gem::Version.new(target) rescue false)
|
164
|
+
when 'version_gte'
|
165
|
+
return (Gem::Version.new(value) >= Gem::Version.new(target) rescue false)
|
166
|
+
when 'version_lt'
|
167
|
+
return (Gem::Version.new(value) < Gem::Version.new(target) rescue false)
|
168
|
+
when 'version_lte'
|
169
|
+
return (Gem::Version.new(value) <= Gem::Version.new(target) rescue false)
|
170
|
+
when 'version_eq'
|
171
|
+
return (Gem::Version.new(value) == Gem::Version.new(target) rescue false)
|
172
|
+
when 'version_neq'
|
173
|
+
return (Gem::Version.new(value) != Gem::Version.new(target) rescue false)
|
174
|
+
|
175
|
+
# array operations
|
176
|
+
when 'any'
|
177
|
+
return EvaluationHelpers::match_string_in_array(target, value, true, ->(a, b) { a == b })
|
178
|
+
when 'none'
|
179
|
+
return !EvaluationHelpers::match_string_in_array(target, value, true, ->(a, b) { a == b })
|
180
|
+
when 'any_case_sensitive'
|
181
|
+
return EvaluationHelpers::match_string_in_array(target, value, false, ->(a, b) { a == b })
|
182
|
+
when 'none_case_sensitive'
|
183
|
+
return !EvaluationHelpers::match_string_in_array(target, value, false, ->(a, b) { a == b })
|
184
|
+
|
185
|
+
#string
|
186
|
+
when 'str_starts_with_any'
|
187
|
+
return EvaluationHelpers::match_string_in_array(target, value, true, ->(a, b) { a.start_with?(b) })
|
188
|
+
when 'str_ends_with_any'
|
189
|
+
return EvaluationHelpers::match_string_in_array(target, value, true, ->(a, b) { a.end_with?(b) })
|
190
|
+
when 'str_contains_any'
|
191
|
+
return EvaluationHelpers::match_string_in_array(target, value, true, ->(a, b) { a.include?(b) })
|
192
|
+
when 'str_contains_none'
|
193
|
+
return !EvaluationHelpers::match_string_in_array(target, value, true, ->(a, b) { a.include?(b) })
|
194
|
+
when 'str_matches'
|
195
|
+
return (value.is_a?(String) && !(value =~ Regexp.new(target)).nil? rescue false)
|
196
|
+
when 'eq'
|
197
|
+
return value == target
|
198
|
+
when 'neq'
|
199
|
+
return value != target
|
200
|
+
|
201
|
+
# dates
|
202
|
+
when 'before'
|
203
|
+
return EvaluationHelpers::compare_times(value, target, ->(a, b) { a < b })
|
204
|
+
when 'after'
|
205
|
+
return EvaluationHelpers::compare_times(value, target, ->(a, b) { a > b })
|
206
|
+
when 'on'
|
207
|
+
return EvaluationHelpers::compare_times(value, target, ->(a, b) { a.year == b.year && a.month == b.month && a.day == b.day })
|
208
|
+
when 'in_segment_list', 'not_in_segment_list'
|
209
|
+
begin
|
210
|
+
id_list = (@spec_store.get_id_list(target) || {:ids => {}})[:ids]
|
211
|
+
hashed_id = Digest::SHA256.base64digest(value.to_s)[0, 8]
|
212
|
+
is_in_list = id_list.is_a?(Hash) && id_list[hashed_id] == true
|
213
|
+
|
214
|
+
return is_in_list if operator == 'in_segment_list'
|
215
|
+
return !is_in_list
|
216
|
+
rescue StandardError => e
|
217
|
+
return false
|
218
|
+
end
|
219
|
+
else
|
220
|
+
return $fetch_from_server
|
139
221
|
end
|
140
|
-
when 'unit_id'
|
141
|
-
value = get_unit_id(user, idType)
|
142
|
-
else
|
143
|
-
return $fetch_from_server
|
144
222
|
end
|
145
223
|
|
146
|
-
|
147
|
-
|
148
|
-
|
149
|
-
case operator
|
150
|
-
# numerical comparison
|
151
|
-
when 'gt'
|
152
|
-
return EvaluationHelpers::compare_numbers(value, target, ->(a, b) { a > b })
|
153
|
-
when 'gte'
|
154
|
-
return EvaluationHelpers::compare_numbers(value, target, ->(a, b) { a >= b })
|
155
|
-
when 'lt'
|
156
|
-
return EvaluationHelpers::compare_numbers(value, target, ->(a, b) { a < b })
|
157
|
-
when 'lte'
|
158
|
-
return EvaluationHelpers::compare_numbers(value, target, ->(a, b) { a <= b })
|
159
|
-
|
160
|
-
# version comparison
|
161
|
-
when 'version_gt'
|
162
|
-
return (Gem::Version.new(value) > Gem::Version.new(target) rescue false)
|
163
|
-
when 'version_gte'
|
164
|
-
return (Gem::Version.new(value) >= Gem::Version.new(target) rescue false)
|
165
|
-
when 'version_lt'
|
166
|
-
return (Gem::Version.new(value) < Gem::Version.new(target) rescue false)
|
167
|
-
when 'version_lte'
|
168
|
-
return (Gem::Version.new(value) <= Gem::Version.new(target) rescue false)
|
169
|
-
when 'version_eq'
|
170
|
-
return (Gem::Version.new(value) == Gem::Version.new(target) rescue false)
|
171
|
-
when 'version_neq'
|
172
|
-
return (Gem::Version.new(value) != Gem::Version.new(target) rescue false)
|
173
|
-
|
174
|
-
# array operations
|
175
|
-
when 'any'
|
176
|
-
return EvaluationHelpers::match_string_in_array(target, value, true, ->(a, b) { a == b })
|
177
|
-
when 'none'
|
178
|
-
return !EvaluationHelpers::match_string_in_array(target, value, true, ->(a, b) { a == b })
|
179
|
-
when 'any_case_sensitive'
|
180
|
-
return EvaluationHelpers::match_string_in_array(target, value, false, ->(a, b) { a == b })
|
181
|
-
when 'none_case_sensitive'
|
182
|
-
return !EvaluationHelpers::match_string_in_array(target, value, false, ->(a, b) { a == b })
|
183
|
-
|
184
|
-
#string
|
185
|
-
when 'str_starts_with_any'
|
186
|
-
return EvaluationHelpers::match_string_in_array(target, value, true, ->(a, b) { a.start_with?(b) })
|
187
|
-
when 'str_ends_with_any'
|
188
|
-
return EvaluationHelpers::match_string_in_array(target, value, true, ->(a, b) { a.end_with?(b) })
|
189
|
-
when 'str_contains_any'
|
190
|
-
return EvaluationHelpers::match_string_in_array(target, value, true, ->(a, b) { a.include?(b) })
|
191
|
-
when 'str_contains_none'
|
192
|
-
return !EvaluationHelpers::match_string_in_array(target, value, true, ->(a, b) { a.include?(b) })
|
193
|
-
when 'str_matches'
|
194
|
-
return (value.is_a?(String) && !(value =~ Regexp.new(target)).nil? rescue false)
|
195
|
-
when 'eq'
|
196
|
-
return value == target
|
197
|
-
when 'neq'
|
198
|
-
return value != target
|
199
|
-
|
200
|
-
# dates
|
201
|
-
when 'before'
|
202
|
-
return EvaluationHelpers::compare_times(value, target, ->(a, b) { a < b })
|
203
|
-
when 'after'
|
204
|
-
return EvaluationHelpers::compare_times(value, target, ->(a, b) { a > b })
|
205
|
-
when 'on'
|
206
|
-
return EvaluationHelpers::compare_times(value, target, ->(a, b) { a.year == b.year && a.month == b.month && a.day == b.day })
|
207
|
-
when 'in_segment_list', 'not_in_segment_list'
|
208
|
-
begin
|
209
|
-
id_list = (@spec_store.get_id_list(target) || {:ids => {}})[:ids]
|
210
|
-
hashed_id = Digest::SHA256.base64digest(value.to_s)[0, 8]
|
211
|
-
is_in_list = id_list.is_a?(Hash) && id_list[hashed_id] == true
|
212
|
-
|
213
|
-
return is_in_list if operator == 'in_segment_list'
|
214
|
-
return !is_in_list
|
215
|
-
rescue StandardError => e
|
216
|
-
return false
|
217
|
-
end
|
218
|
-
else
|
219
|
-
return $fetch_from_server
|
220
|
-
end
|
221
|
-
end
|
224
|
+
def get_value_from_user(user, field)
|
225
|
+
return nil unless user.instance_of?(StatsigUser) && field.is_a?(String)
|
222
226
|
|
223
|
-
|
224
|
-
|
227
|
+
user_lookup_table = user&.value_lookup
|
228
|
+
return nil unless user_lookup_table.is_a?(Hash)
|
229
|
+
return user_lookup_table[field.downcase] if user_lookup_table.has_key?(field.downcase) && !user_lookup_table[field.downcase].nil?
|
225
230
|
|
226
|
-
|
227
|
-
|
228
|
-
|
231
|
+
user_custom = user_lookup_table['custom']
|
232
|
+
if user_custom.is_a?(Hash)
|
233
|
+
user_custom.each do |key, value|
|
234
|
+
return value if key.downcase.casecmp?(field.downcase) && !value.nil?
|
235
|
+
end
|
236
|
+
end
|
229
237
|
|
230
|
-
|
231
|
-
|
232
|
-
|
233
|
-
|
238
|
+
private_attributes = user_lookup_table['privateAttributes']
|
239
|
+
if private_attributes.is_a?(Hash)
|
240
|
+
private_attributes.each do |key, value|
|
241
|
+
return value if key.downcase.casecmp?(field.downcase) && !value.nil?
|
242
|
+
end
|
234
243
|
end
|
244
|
+
|
245
|
+
nil
|
235
246
|
end
|
236
247
|
|
237
|
-
|
238
|
-
|
239
|
-
|
240
|
-
|
248
|
+
def get_value_from_environment(user, field)
|
249
|
+
return nil unless user.instance_of?(StatsigUser) && field.is_a?(String)
|
250
|
+
field = field.downcase
|
251
|
+
return nil unless user.statsig_environment.is_a? Hash
|
252
|
+
user.statsig_environment.each do |key, value|
|
253
|
+
return value if key.downcase == (field)
|
241
254
|
end
|
255
|
+
nil
|
242
256
|
end
|
243
257
|
|
244
|
-
|
245
|
-
|
258
|
+
def get_value_from_ip(user, field)
|
259
|
+
return nil unless user.is_a?(StatsigUser) && field.is_a?(String) && field.downcase == 'country'
|
260
|
+
ip = get_value_from_user(user, 'ip')
|
261
|
+
return nil unless ip.is_a?(String)
|
246
262
|
|
247
|
-
|
248
|
-
return nil unless user.instance_of?(StatsigUser) && field.is_a?(String)
|
249
|
-
field = field.downcase
|
250
|
-
return nil unless user.statsig_environment.is_a? Hash
|
251
|
-
user.statsig_environment.each do |key, value|
|
252
|
-
return value if key.downcase == (field)
|
263
|
+
CountryLookup.lookup_ip_string(ip)
|
253
264
|
end
|
254
|
-
nil
|
255
|
-
end
|
256
|
-
|
257
|
-
def get_value_from_ip(user, field)
|
258
|
-
return nil unless user.is_a?(StatsigUser) && field.is_a?(String) && field.downcase == 'country'
|
259
|
-
ip = get_value_from_user(user, 'ip')
|
260
|
-
return nil unless ip.is_a?(String)
|
261
265
|
|
262
|
-
|
263
|
-
|
264
|
-
|
265
|
-
|
266
|
-
|
267
|
-
|
268
|
-
|
269
|
-
|
270
|
-
|
271
|
-
|
272
|
-
|
273
|
-
|
274
|
-
|
275
|
-
|
276
|
-
|
277
|
-
|
278
|
-
|
279
|
-
|
280
|
-
|
281
|
-
else
|
282
|
-
nil
|
266
|
+
def get_value_from_ua(user, field)
|
267
|
+
return nil unless user.is_a?(StatsigUser) && field.is_a?(String)
|
268
|
+
ua = get_value_from_user(user, 'userAgent')
|
269
|
+
return nil unless ua.is_a?(String)
|
270
|
+
|
271
|
+
parsed = @ua_parser.parse ua
|
272
|
+
os = parsed.os
|
273
|
+
case field.downcase
|
274
|
+
when 'os_name', 'osname'
|
275
|
+
return os&.family
|
276
|
+
when 'os_version', 'osversion'
|
277
|
+
return os&.version unless os&.version.nil?
|
278
|
+
when 'browser_name', 'browsername'
|
279
|
+
return parsed.family
|
280
|
+
when 'browser_version', 'browserversion'
|
281
|
+
return parsed.version.to_s
|
282
|
+
else
|
283
|
+
nil
|
284
|
+
end
|
283
285
|
end
|
284
|
-
end
|
285
286
|
|
286
|
-
|
287
|
-
|
288
|
-
|
289
|
-
|
290
|
-
|
291
|
-
|
292
|
-
|
293
|
-
|
294
|
-
|
287
|
+
def eval_pass_percent(user, rule, config_salt)
|
288
|
+
return false unless config_salt.is_a?(String) && !rule['passPercentage'].nil?
|
289
|
+
begin
|
290
|
+
unit_id = get_unit_id(user, rule['id_type']) || ''
|
291
|
+
rule_salt = rule['salt'] || rule['id'] || ''
|
292
|
+
hash = compute_user_hash("#{config_salt}.#{rule_salt}.#{unit_id}")
|
293
|
+
return (hash % 10000) < (rule['passPercentage'].to_f * 100)
|
294
|
+
rescue
|
295
|
+
return false
|
296
|
+
end
|
295
297
|
end
|
296
|
-
end
|
297
298
|
|
298
|
-
|
299
|
-
|
300
|
-
|
301
|
-
|
299
|
+
def get_unit_id(user, id_type)
|
300
|
+
if id_type.is_a?(String) && id_type.downcase != 'userid'
|
301
|
+
return nil unless user&.custom_ids.is_a? Hash
|
302
|
+
return user.custom_ids[id_type] || user.custom_ids[id_type.downcase]
|
303
|
+
end
|
304
|
+
user.user_id
|
302
305
|
end
|
303
|
-
user.user_id
|
304
|
-
end
|
305
306
|
|
306
|
-
|
307
|
-
|
307
|
+
def compute_user_hash(user_hash)
|
308
|
+
Digest::SHA256.digest(user_hash).unpack('Q>')[0]
|
309
|
+
end
|
308
310
|
end
|
309
311
|
end
|
data/lib/network.rb
CHANGED
@@ -4,65 +4,67 @@ require 'dynamic_config'
|
|
4
4
|
|
5
5
|
$retry_codes = [408, 500, 502, 503, 504, 522, 524, 599]
|
6
6
|
|
7
|
-
|
8
|
-
|
9
|
-
|
10
|
-
|
11
|
-
api
|
7
|
+
module Statsig
|
8
|
+
class Network
|
9
|
+
def initialize(server_secret, api, backoff_mult = 10)
|
10
|
+
super()
|
11
|
+
unless api.end_with?('/')
|
12
|
+
api += '/'
|
13
|
+
end
|
14
|
+
@server_secret = server_secret
|
15
|
+
@api = api
|
16
|
+
@backoff_multiplier = backoff_mult
|
12
17
|
end
|
13
|
-
@server_secret = server_secret
|
14
|
-
@api = api
|
15
|
-
@backoff_multiplier = backoff_mult
|
16
|
-
end
|
17
18
|
|
18
|
-
|
19
|
-
|
20
|
-
|
21
|
-
|
22
|
-
|
23
|
-
|
24
|
-
|
25
|
-
|
26
|
-
|
27
|
-
|
28
|
-
|
19
|
+
def post_helper(endpoint, body, retries = 0, backoff = 1)
|
20
|
+
http = HTTP.headers(
|
21
|
+
{"STATSIG-API-KEY" => @server_secret,
|
22
|
+
"STATSIG-CLIENT-TIME" => (Time.now.to_f * 1000).to_s,
|
23
|
+
"Content-Type" => "application/json; charset=UTF-8"
|
24
|
+
}).accept(:json)
|
25
|
+
begin
|
26
|
+
res = http.post(@api + endpoint, body: body)
|
27
|
+
rescue StandardError => e
|
28
|
+
## network error retry
|
29
|
+
return nil, e unless retries > 0
|
30
|
+
sleep backoff
|
31
|
+
return post_helper(endpoint, body, retries - 1, backoff * @backoff_multiplier)
|
32
|
+
end
|
33
|
+
return res, nil unless !res.status.success?
|
34
|
+
return nil, StandardError.new("Got an exception when making request to #{@api + endpoint}: #{res.to_s}") unless retries > 0 && $retry_codes.include?(res.code)
|
35
|
+
## status code retry
|
29
36
|
sleep backoff
|
30
|
-
|
37
|
+
post_helper(endpoint, body, retries - 1, backoff * @backoff_multiplier)
|
31
38
|
end
|
32
|
-
return res, nil unless !res.status.success?
|
33
|
-
return nil, StandardError.new("Got an exception when making request to #{@api + endpoint}: #{res.to_s}") unless retries > 0 && $retry_codes.include?(res.code)
|
34
|
-
## status code retry
|
35
|
-
sleep backoff
|
36
|
-
post_helper(endpoint, body, retries - 1, backoff * @backoff_multiplier)
|
37
|
-
end
|
38
39
|
|
39
|
-
|
40
|
-
|
41
|
-
|
42
|
-
|
43
|
-
|
44
|
-
|
45
|
-
|
46
|
-
|
40
|
+
def check_gate(user, gate_name)
|
41
|
+
begin
|
42
|
+
request_body = JSON.generate({'user' => user&.serialize(false), 'gateName' => gate_name})
|
43
|
+
response, _ = post_helper('check_gate', request_body)
|
44
|
+
return JSON.parse(response.body) unless response.nil?
|
45
|
+
false
|
46
|
+
rescue
|
47
|
+
return false
|
48
|
+
end
|
47
49
|
end
|
48
|
-
end
|
49
50
|
|
50
|
-
|
51
|
-
|
52
|
-
|
53
|
-
|
54
|
-
|
55
|
-
|
56
|
-
|
57
|
-
|
51
|
+
def get_config(user, dynamic_config_name)
|
52
|
+
begin
|
53
|
+
request_body = JSON.generate({'user' => user&.serialize(false), 'configName' => dynamic_config_name})
|
54
|
+
response, _ = post_helper('get_config', request_body)
|
55
|
+
return JSON.parse(response.body) unless response.nil?
|
56
|
+
nil
|
57
|
+
rescue
|
58
|
+
return nil
|
59
|
+
end
|
58
60
|
end
|
59
|
-
end
|
60
61
|
|
61
|
-
|
62
|
-
|
63
|
-
|
64
|
-
|
65
|
-
|
62
|
+
def post_logs(events)
|
63
|
+
begin
|
64
|
+
json_body = JSON.generate({'events' => events, 'statsigMetadata' => Statsig.get_statsig_metadata})
|
65
|
+
post_helper('log_event', json_body, retries: 5)
|
66
|
+
rescue
|
67
|
+
end
|
66
68
|
end
|
67
69
|
end
|
68
70
|
end
|
data/lib/spec_store.rb
CHANGED
@@ -1,150 +1,151 @@
|
|
1
1
|
require 'net/http'
|
2
2
|
require 'uri'
|
3
3
|
|
4
|
-
|
5
|
-
|
6
|
-
|
7
|
-
|
8
|
-
|
9
|
-
|
10
|
-
|
11
|
-
|
12
|
-
|
13
|
-
|
14
|
-
|
15
|
-
|
16
|
-
|
17
|
-
|
18
|
-
|
19
|
-
|
20
|
-
|
21
|
-
|
4
|
+
module Statsig
|
5
|
+
class SpecStore
|
6
|
+
def initialize(network, error_callback = nil, config_sync_interval = 10, id_lists_sync_interval = 60)
|
7
|
+
@network = network
|
8
|
+
@last_sync_time = 0
|
9
|
+
@config_sync_interval = config_sync_interval
|
10
|
+
@id_lists_sync_interval = id_lists_sync_interval
|
11
|
+
@store = {
|
12
|
+
:gates => {},
|
13
|
+
:configs => {},
|
14
|
+
:id_lists => {},
|
15
|
+
}
|
16
|
+
e = download_config_specs
|
17
|
+
error_callback.call(e) unless error_callback.nil?
|
18
|
+
download_id_lists
|
19
|
+
|
20
|
+
@config_sync_thread = sync_config_specs
|
21
|
+
@id_lists_sync_thread = sync_id_lists
|
22
|
+
end
|
22
23
|
|
23
|
-
|
24
|
-
|
25
|
-
|
26
|
-
|
24
|
+
def shutdown
|
25
|
+
@config_sync_thread&.exit
|
26
|
+
@id_lists_sync_thread&.exit
|
27
|
+
end
|
27
28
|
|
28
|
-
|
29
|
-
|
30
|
-
|
29
|
+
def has_gate?(gate_name)
|
30
|
+
@store[:gates].key?(gate_name)
|
31
|
+
end
|
31
32
|
|
32
|
-
|
33
|
-
|
34
|
-
|
33
|
+
def has_config?(config_name)
|
34
|
+
@store[:configs].key?(config_name)
|
35
|
+
end
|
35
36
|
|
36
|
-
|
37
|
-
|
38
|
-
|
39
|
-
|
37
|
+
def get_gate(gate_name)
|
38
|
+
return nil unless has_gate?(gate_name)
|
39
|
+
@store[:gates][gate_name]
|
40
|
+
end
|
40
41
|
|
41
|
-
|
42
|
-
|
43
|
-
|
44
|
-
|
42
|
+
def get_config(config_name)
|
43
|
+
return nil unless has_config?(config_name)
|
44
|
+
@store[:configs][config_name]
|
45
|
+
end
|
45
46
|
|
46
|
-
|
47
|
-
|
48
|
-
|
47
|
+
def get_id_list(list_name)
|
48
|
+
@store[:id_lists][list_name]
|
49
|
+
end
|
49
50
|
|
50
|
-
|
51
|
+
private
|
51
52
|
|
52
|
-
|
53
|
-
|
54
|
-
|
55
|
-
|
56
|
-
|
53
|
+
def sync_config_specs
|
54
|
+
Thread.new do
|
55
|
+
loop do
|
56
|
+
sleep @config_sync_interval
|
57
|
+
download_config_specs
|
58
|
+
end
|
57
59
|
end
|
58
60
|
end
|
59
|
-
end
|
60
61
|
|
61
|
-
|
62
|
-
|
63
|
-
|
64
|
-
|
65
|
-
|
62
|
+
def sync_id_lists
|
63
|
+
Thread.new do
|
64
|
+
loop do
|
65
|
+
sleep @id_lists_sync_interval
|
66
|
+
download_id_lists
|
67
|
+
end
|
66
68
|
end
|
67
69
|
end
|
68
|
-
end
|
69
70
|
|
70
|
-
|
71
|
-
|
72
|
-
|
73
|
-
|
74
|
-
|
75
|
-
|
71
|
+
def download_config_specs
|
72
|
+
begin
|
73
|
+
response, e = @network.post_helper('download_config_specs', JSON.generate({'sinceTime' => @last_sync_time}))
|
74
|
+
if e.nil?
|
75
|
+
process(JSON.parse(response.body))
|
76
|
+
else
|
77
|
+
e
|
78
|
+
end
|
79
|
+
rescue StandardError => e
|
76
80
|
e
|
77
81
|
end
|
78
|
-
rescue StandardError => e
|
79
|
-
e
|
80
82
|
end
|
81
|
-
end
|
82
83
|
|
83
|
-
|
84
|
-
|
85
|
-
|
86
|
-
|
84
|
+
def process(specs_json)
|
85
|
+
if specs_json.nil?
|
86
|
+
return
|
87
|
+
end
|
87
88
|
|
88
|
-
|
89
|
-
|
90
|
-
|
91
|
-
|
89
|
+
@last_sync_time = specs_json['time'] || @last_sync_time
|
90
|
+
return unless specs_json['has_updates'] == true &&
|
91
|
+
!specs_json['feature_gates'].nil? &&
|
92
|
+
!specs_json['dynamic_configs'].nil?
|
92
93
|
|
93
|
-
|
94
|
-
|
94
|
+
new_gates = {}
|
95
|
+
new_configs = {}
|
95
96
|
|
96
|
-
|
97
|
-
|
98
|
-
|
99
|
-
|
97
|
+
specs_json['feature_gates'].map{|gate| new_gates[gate['name']] = gate }
|
98
|
+
specs_json['dynamic_configs'].map{|config| new_configs[config['name']] = config }
|
99
|
+
@store[:gates] = new_gates
|
100
|
+
@store[:configs] = new_configs
|
100
101
|
|
101
|
-
|
102
|
-
|
103
|
-
|
104
|
-
|
105
|
-
|
102
|
+
new_id_lists = specs_json['id_lists']
|
103
|
+
if new_id_lists.is_a? Hash
|
104
|
+
new_id_lists.each do |list_name, _|
|
105
|
+
unless @store[:id_lists].key?(list_name)
|
106
|
+
@store[:id_lists][list_name] = { :ids => {}, :time => 0 }
|
107
|
+
end
|
106
108
|
end
|
107
|
-
end
|
108
109
|
|
109
|
-
|
110
|
-
|
111
|
-
|
110
|
+
@store[:id_lists].each do |list_name, _|
|
111
|
+
unless new_id_lists.key?(list_name)
|
112
|
+
@store[:id_lists].delete(list_name)
|
113
|
+
end
|
112
114
|
end
|
113
115
|
end
|
114
116
|
end
|
115
|
-
end
|
116
117
|
|
117
|
-
|
118
|
-
|
119
|
-
|
120
|
-
|
121
|
-
|
122
|
-
|
123
|
-
|
124
|
-
|
125
|
-
|
126
|
-
|
127
|
-
|
128
|
-
|
129
|
-
|
118
|
+
def download_id_lists
|
119
|
+
if @store[:id_lists].is_a? Hash
|
120
|
+
threads = []
|
121
|
+
id_lists = @store[:id_lists]
|
122
|
+
id_lists.each do |list_name, list|
|
123
|
+
threads << Thread.new do
|
124
|
+
response, e = @network.post_helper('download_id_list', JSON.generate({'listName' => list_name, 'statsigMetadata' => Statsig.get_statsig_metadata, 'sinceTime' => list['time'] || 0 }))
|
125
|
+
if e.nil? && !response.nil?
|
126
|
+
begin
|
127
|
+
data = JSON.parse(response)
|
128
|
+
if data['add_ids'].is_a? Array
|
129
|
+
data['add_ids'].each do |id|
|
130
|
+
list[:ids][id] = true
|
131
|
+
end
|
130
132
|
end
|
131
|
-
|
132
|
-
|
133
|
-
|
134
|
-
|
133
|
+
if data['remove_ids'].is_a? Array
|
134
|
+
data['remove_ids'].each do |id|
|
135
|
+
list[:ids]&.delete(id)
|
136
|
+
end
|
135
137
|
end
|
138
|
+
if data['time'].is_a? Numeric
|
139
|
+
list[:time] = data['time']
|
140
|
+
end
|
141
|
+
rescue
|
142
|
+
# Ignored
|
136
143
|
end
|
137
|
-
if data['time'].is_a? Numeric
|
138
|
-
list[:time] = data['time']
|
139
|
-
end
|
140
|
-
rescue
|
141
|
-
# Ignored
|
142
144
|
end
|
143
145
|
end
|
144
146
|
end
|
147
|
+
threads.each(&:join)
|
145
148
|
end
|
146
|
-
threads.each(&:join)
|
147
149
|
end
|
148
150
|
end
|
149
|
-
|
150
|
-
end
|
151
|
+
end
|
data/lib/statsig.rb
CHANGED
data/lib/statsig_driver.rb
CHANGED
@@ -20,9 +20,9 @@ class StatsigDriver
|
|
20
20
|
@options = options || StatsigOptions.new
|
21
21
|
@shutdown = false
|
22
22
|
@secret_key = secret_key
|
23
|
-
@net = Network.new(secret_key, @options.api_url_base)
|
24
|
-
@logger = StatsigLogger.new(@net)
|
25
|
-
@evaluator = Evaluator.new(@net, error_callback)
|
23
|
+
@net = Statsig::Network.new(secret_key, @options.api_url_base)
|
24
|
+
@logger = Statsig::StatsigLogger.new(@net)
|
25
|
+
@evaluator = Statsig::Evaluator.new(@net, error_callback)
|
26
26
|
end
|
27
27
|
|
28
28
|
def check_gate(user, gate_name)
|
@@ -35,7 +35,7 @@ class StatsigDriver
|
|
35
35
|
|
36
36
|
res = @evaluator.check_gate(user, gate_name)
|
37
37
|
if res.nil?
|
38
|
-
res = ConfigResult.new(gate_name)
|
38
|
+
res = Statsig::ConfigResult.new(gate_name)
|
39
39
|
end
|
40
40
|
|
41
41
|
if res == $fetch_from_server
|
@@ -58,7 +58,7 @@ class StatsigDriver
|
|
58
58
|
|
59
59
|
res = @evaluator.get_config(user, dynamic_config_name)
|
60
60
|
if res.nil?
|
61
|
-
res = ConfigResult.new(dynamic_config_name)
|
61
|
+
res = Statsig::ConfigResult.new(dynamic_config_name)
|
62
62
|
end
|
63
63
|
|
64
64
|
if res == $fetch_from_server
|
@@ -124,11 +124,11 @@ class StatsigDriver
|
|
124
124
|
def check_gate_fallback(user, gate_name)
|
125
125
|
network_result = @net.check_gate(user, gate_name)
|
126
126
|
if network_result.nil?
|
127
|
-
config_result = ConfigResult.new(gate_name)
|
127
|
+
config_result = Statsig::ConfigResult.new(gate_name)
|
128
128
|
return config_result
|
129
129
|
end
|
130
130
|
|
131
|
-
ConfigResult.new(
|
131
|
+
Statsig::ConfigResult.new(
|
132
132
|
network_result['name'],
|
133
133
|
network_result['value'],
|
134
134
|
{},
|
@@ -139,11 +139,11 @@ class StatsigDriver
|
|
139
139
|
def get_config_fallback(user, dynamic_config_name)
|
140
140
|
network_result = @net.get_config(user, dynamic_config_name)
|
141
141
|
if network_result.nil?
|
142
|
-
config_result = ConfigResult.new(dynamic_config_name)
|
142
|
+
config_result = Statsig::ConfigResult.new(dynamic_config_name)
|
143
143
|
return config_result
|
144
144
|
end
|
145
145
|
|
146
|
-
ConfigResult.new(
|
146
|
+
Statsig::ConfigResult.new(
|
147
147
|
network_result['name'],
|
148
148
|
false,
|
149
149
|
network_result['value'],
|
data/lib/statsig_logger.rb
CHANGED
@@ -3,58 +3,71 @@ require 'statsig_event'
|
|
3
3
|
$gate_exposure_event = 'statsig::gate_exposure'
|
4
4
|
$config_exposure_event = 'statsig::config_exposure'
|
5
5
|
|
6
|
-
|
7
|
-
|
8
|
-
|
9
|
-
|
10
|
-
|
11
|
-
|
12
|
-
flush
|
6
|
+
module Statsig
|
7
|
+
class StatsigLogger
|
8
|
+
def initialize(network)
|
9
|
+
@network = network
|
10
|
+
@events = []
|
11
|
+
@background_flush = periodic_flush
|
13
12
|
end
|
14
|
-
end
|
15
13
|
|
16
|
-
|
17
|
-
|
18
|
-
|
19
|
-
|
14
|
+
def log_event(event)
|
15
|
+
@events.push(event)
|
16
|
+
if @events.length >= 500
|
17
|
+
flush
|
18
|
+
end
|
20
19
|
end
|
21
|
-
end
|
22
20
|
|
23
|
-
|
24
|
-
|
25
|
-
|
26
|
-
|
27
|
-
|
28
|
-
|
29
|
-
|
30
|
-
|
31
|
-
|
32
|
-
|
33
|
-
|
34
|
-
|
35
|
-
|
36
|
-
def log_config_exposure(user, config_name, rule_id, secondary_exposures)
|
37
|
-
event = StatsigEvent.new($config_exposure_event)
|
38
|
-
event.user = user
|
39
|
-
event.metadata = {
|
40
|
-
'config' => config_name,
|
41
|
-
'ruleID' => rule_id
|
42
|
-
}
|
43
|
-
event.statsig_metadata = Statsig.get_statsig_metadata
|
44
|
-
event.secondary_exposures = secondary_exposures.is_a?(Array) ? secondary_exposures : []
|
45
|
-
log_event(event)
|
46
|
-
end
|
21
|
+
def log_gate_exposure(user, gate_name, value, rule_id, secondary_exposures)
|
22
|
+
event = StatsigEvent.new($gate_exposure_event)
|
23
|
+
event.user = user
|
24
|
+
event.metadata = {
|
25
|
+
'gate' => gate_name,
|
26
|
+
'gateValue' => value.to_s,
|
27
|
+
'ruleID' => rule_id
|
28
|
+
}
|
29
|
+
event.statsig_metadata = Statsig.get_statsig_metadata
|
30
|
+
event.secondary_exposures = secondary_exposures.is_a?(Array) ? secondary_exposures : []
|
31
|
+
log_event(event)
|
32
|
+
end
|
47
33
|
|
48
|
-
|
49
|
-
|
50
|
-
|
34
|
+
def log_config_exposure(user, config_name, rule_id, secondary_exposures)
|
35
|
+
event = StatsigEvent.new($config_exposure_event)
|
36
|
+
event.user = user
|
37
|
+
event.metadata = {
|
38
|
+
'config' => config_name,
|
39
|
+
'ruleID' => rule_id
|
40
|
+
}
|
41
|
+
event.statsig_metadata = Statsig.get_statsig_metadata
|
42
|
+
event.secondary_exposures = secondary_exposures.is_a?(Array) ? secondary_exposures : []
|
43
|
+
log_event(event)
|
51
44
|
end
|
52
|
-
|
53
|
-
|
45
|
+
|
46
|
+
def periodic_flush
|
47
|
+
puts "Statsig:: starting a thread"
|
48
|
+
Thread.new do
|
49
|
+
loop do
|
50
|
+
puts "Statsig:: beginning to sleep for 60 seconds"
|
51
|
+
sleep 60
|
52
|
+
puts "Statsig:: finished sleeping"
|
53
|
+
flush
|
54
|
+
puts "Statsig:: flushed"
|
55
|
+
end
|
56
|
+
end
|
54
57
|
end
|
55
|
-
flush_events = @events.map { |e| e.serialize }
|
56
|
-
@events = []
|
57
58
|
|
58
|
-
|
59
|
+
def flush(closing = false)
|
60
|
+
if closing
|
61
|
+
@background_flush&.exit
|
62
|
+
end
|
63
|
+
if @events.length == 0
|
64
|
+
return
|
65
|
+
end
|
66
|
+
puts "Statsig:: flushing events, count: #{@events.length.to_s}"
|
67
|
+
flush_events = @events.map { |e| e.serialize }
|
68
|
+
@events = []
|
69
|
+
|
70
|
+
@network.post_logs(flush_events)
|
71
|
+
end
|
59
72
|
end
|
60
73
|
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.8.
|
4
|
+
version: 1.8.4.beta.pre.1
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- Statsig, Inc
|
8
8
|
autorequire:
|
9
9
|
bindir: bin
|
10
10
|
cert_chain: []
|
11
|
-
date:
|
11
|
+
date: 2022-01-18 00:00:00.000000000 Z
|
12
12
|
dependencies:
|
13
13
|
- !ruby/object:Gem::Dependency
|
14
14
|
name: bundler
|
@@ -141,9 +141,9 @@ required_ruby_version: !ruby/object:Gem::Requirement
|
|
141
141
|
version: '0'
|
142
142
|
required_rubygems_version: !ruby/object:Gem::Requirement
|
143
143
|
requirements:
|
144
|
-
- - "
|
144
|
+
- - ">"
|
145
145
|
- !ruby/object:Gem::Version
|
146
|
-
version:
|
146
|
+
version: 1.3.1
|
147
147
|
requirements: []
|
148
148
|
rubygems_version: 3.2.3
|
149
149
|
signing_key:
|