statsig 0.1.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/lib/config_result.rb +13 -0
- data/lib/dynamic_config.rb +16 -0
- data/lib/evaluation_helpers.rb +25 -0
- data/lib/evaluator.rb +212 -0
- data/lib/network.rb +72 -0
- data/lib/spec_store.rb +51 -0
- data/lib/statsig.rb +42 -0
- data/lib/statsig_driver.rb +146 -0
- data/lib/statsig_event.rb +21 -0
- data/lib/statsig_logger.rb +52 -0
- data/lib/statsig_user.rb +49 -0
- metadata +173 -0
checksums.yaml
ADDED
@@ -0,0 +1,7 @@
|
|
1
|
+
---
|
2
|
+
SHA256:
|
3
|
+
metadata.gz: 60035280630edb2172623545efa323cc6b3ec0374b8ebc99ba7f28312a227b21
|
4
|
+
data.tar.gz: af799a2649633d8cc364d6e69fed72f3a30ff96673212d348f20b5fa0ef83f30
|
5
|
+
SHA512:
|
6
|
+
metadata.gz: f69cf4534af68927136ec9d9bf1900ea206e3c86c7bdcdd294e03c0109071449adc44db0d5026f98cae2ff281985022e19ddb6e9a8cc9a275c0f53513c292e0a
|
7
|
+
data.tar.gz: 39269a606a0291a83c5f486535b7a075ccde3a82a50436598aed93732cd95b27c765220c56de6b50e1e515f7346ec107081dc71ea23be2ee83e33a3e88b745eb
|
@@ -0,0 +1,13 @@
|
|
1
|
+
class ConfigResult
|
2
|
+
attr_accessor :name
|
3
|
+
attr_accessor :gate_value
|
4
|
+
attr_accessor :json_value
|
5
|
+
attr_accessor :rule_id
|
6
|
+
|
7
|
+
def initialize(name, gate_value = false, json_value = {}, rule_id = '')
|
8
|
+
@name = name
|
9
|
+
@gate_value = gate_value
|
10
|
+
@json_value = json_value
|
11
|
+
@rule_id = rule_id
|
12
|
+
end
|
13
|
+
end
|
@@ -0,0 +1,16 @@
|
|
1
|
+
class DynamicConfig
|
2
|
+
attr_accessor :name
|
3
|
+
attr_accessor :value
|
4
|
+
attr_accessor :rule_id
|
5
|
+
|
6
|
+
def initialize(name, value = {}, rule_id = '')
|
7
|
+
@name = name
|
8
|
+
@value = value
|
9
|
+
@rule_id = rule_id
|
10
|
+
end
|
11
|
+
|
12
|
+
def get(index)
|
13
|
+
return nil if @value.nil?
|
14
|
+
value[index]
|
15
|
+
end
|
16
|
+
end
|
@@ -0,0 +1,25 @@
|
|
1
|
+
module EvaluationHelpers
|
2
|
+
def self.compare_numbers(a, b, func)
|
3
|
+
return false unless self.is_numeric(a) && self.is_numeric(b)
|
4
|
+
func.call(a.to_f, b.to_f) rescue false
|
5
|
+
end
|
6
|
+
|
7
|
+
# returns true if array contains value, ignoring case when comparing strings
|
8
|
+
def self.array_contains(array, value)
|
9
|
+
return false unless array.is_a?(Array) && !value.nil?
|
10
|
+
return array.include?(value) unless value.is_a?(String)
|
11
|
+
array.any?{ |s| s.is_a?(String) && s.casecmp(value) == 0 } rescue false
|
12
|
+
end
|
13
|
+
|
14
|
+
# returns true if array has any element that evaluates to true with value using func lambda, ignoring case
|
15
|
+
def self.match_string_in_array(array, value, func)
|
16
|
+
return false unless array.is_a?(Array) && value.is_a?(String)
|
17
|
+
array.any?{ |s| s.is_a?(String) && func.call(value.downcase, s.downcase) } rescue false
|
18
|
+
end
|
19
|
+
|
20
|
+
private
|
21
|
+
|
22
|
+
def self.is_numeric(v)
|
23
|
+
!(v.to_s =~ /\A[-+]?\d*\.?\d+\z/).nil?
|
24
|
+
end
|
25
|
+
end
|
data/lib/evaluator.rb
ADDED
@@ -0,0 +1,212 @@
|
|
1
|
+
require 'browser'
|
2
|
+
require 'config_result'
|
3
|
+
require 'digest'
|
4
|
+
require 'evaluation_helpers'
|
5
|
+
require 'spec_store'
|
6
|
+
|
7
|
+
$fetch_from_server = :fetch_from_server
|
8
|
+
$type_dynamic_config = 'dynamic_config'
|
9
|
+
|
10
|
+
class Evaluator
|
11
|
+
include EvaluationHelpers
|
12
|
+
|
13
|
+
def initialize(store)
|
14
|
+
@spec_store = store
|
15
|
+
@initialized = true
|
16
|
+
end
|
17
|
+
|
18
|
+
def check_gate(user, gate_name)
|
19
|
+
return nil unless @initialized && @spec_store.has_gate?(gate_name)
|
20
|
+
self.eval_spec(user, @spec_store.get_gate(gate_name))
|
21
|
+
end
|
22
|
+
|
23
|
+
def get_config(user, config_name)
|
24
|
+
return nil unless @initialized && @spec_store.has_config?(config_name)
|
25
|
+
self.eval_spec(user, @spec_store.get_config(config_name))
|
26
|
+
end
|
27
|
+
|
28
|
+
private
|
29
|
+
|
30
|
+
def eval_spec(user, config)
|
31
|
+
if config['enabled']
|
32
|
+
i = 0
|
33
|
+
until i >= config['rules'].length do
|
34
|
+
rule = config['rules'][i]
|
35
|
+
result = self.eval_rule(user, rule)
|
36
|
+
return $fetch_from_server if result == $fetch_from_server
|
37
|
+
if result
|
38
|
+
pass = self.eval_pass_percent(user, rule, config['salt'])
|
39
|
+
return ConfigResult.new(
|
40
|
+
config['name'],
|
41
|
+
pass,
|
42
|
+
pass ? rule['returnValue'] : config['defaultValue'],
|
43
|
+
rule['id'],
|
44
|
+
)
|
45
|
+
end
|
46
|
+
|
47
|
+
i += 1
|
48
|
+
end
|
49
|
+
end
|
50
|
+
|
51
|
+
ConfigResult.new(config['name'], false, config['defaultValue'], 'default')
|
52
|
+
end
|
53
|
+
|
54
|
+
def eval_rule(user, rule)
|
55
|
+
i = 0
|
56
|
+
until i >= rule['conditions'].length do
|
57
|
+
result = self.eval_condition(user, rule['conditions'][i])
|
58
|
+
return result unless result == true
|
59
|
+
i += 1
|
60
|
+
end
|
61
|
+
true
|
62
|
+
end
|
63
|
+
|
64
|
+
def eval_condition(user, condition)
|
65
|
+
value = nil
|
66
|
+
field = condition['field']
|
67
|
+
target = condition['targetValue']
|
68
|
+
type = condition['type']
|
69
|
+
operator = condition['operator']
|
70
|
+
|
71
|
+
return $fetch_from_server unless type.is_a?(String)
|
72
|
+
type = type.downcase
|
73
|
+
|
74
|
+
case type
|
75
|
+
when 'public'
|
76
|
+
return true
|
77
|
+
when 'fail_gate'
|
78
|
+
when 'pass_gate'
|
79
|
+
other_gate_result = self.check_gate(user, target)
|
80
|
+
return $fetch_from_server if other_gate_result == $fetch_from_server
|
81
|
+
return type == 'pass_gate' ? other_gate_result[:gate_value] : !other_gate_result[:gate_value]
|
82
|
+
when 'ip_based'
|
83
|
+
value = get_value_from_user(user, field) || get_value_from_ip(user['ip'], field)
|
84
|
+
return $fetch_from_server if value == $fetch_from_server
|
85
|
+
when 'ua_based'
|
86
|
+
value = get_value_from_user(user, field) || get_value_from_ua(user['userAgent'], field)
|
87
|
+
return $fetch_from_server if value == $fetch_from_server
|
88
|
+
when 'user_field'
|
89
|
+
value = get_value_from_user(user, field)
|
90
|
+
when 'current_time'
|
91
|
+
value = Time.now.to_f # epoch time in seconds
|
92
|
+
else
|
93
|
+
return $fetch_from_server
|
94
|
+
end
|
95
|
+
|
96
|
+
return $fetch_from_server if value == $fetch_from_server
|
97
|
+
return false if value.nil?
|
98
|
+
|
99
|
+
return $fetch_from_server unless operator.is_a?(String)
|
100
|
+
operator = operator.downcase
|
101
|
+
|
102
|
+
case operator
|
103
|
+
# numerical comparison
|
104
|
+
when 'gt'
|
105
|
+
return compare_numbers(value, target, ->(a, b) { a > b })
|
106
|
+
when 'gte'
|
107
|
+
return compare_numbers(value, target, ->(a, b) { a >= b })
|
108
|
+
when 'lt'
|
109
|
+
return compare_numbers(value, target, ->(a, b) { a < b })
|
110
|
+
when 'lte'
|
111
|
+
return compare_numbers(value, target, ->(a, b) { a <= b })
|
112
|
+
|
113
|
+
# version comparison
|
114
|
+
when 'version_gt'
|
115
|
+
return (Gem::Version.new(value) > Gem::Version.new(target) rescue false)
|
116
|
+
when 'version_gte'
|
117
|
+
return (Gem::Version.new(value) >= Gem::Version.new(target) rescue false)
|
118
|
+
when 'version_lt'
|
119
|
+
return (Gem::Version.new(value) < Gem::Version.new(target) rescue false)
|
120
|
+
when 'version_lte'
|
121
|
+
return (Gem::Version.new(value) <= Gem::Version.new(target) rescue false)
|
122
|
+
when 'version_eq'
|
123
|
+
return (Gem::Version.new(value) == Gem::Version.new(target) rescue false)
|
124
|
+
when 'version_neq'
|
125
|
+
return (Gem::Version.new(value) != Gem::Version.new(target) rescue false)
|
126
|
+
|
127
|
+
# array operations
|
128
|
+
when 'any'
|
129
|
+
return array_contains(target, value)
|
130
|
+
when 'none'
|
131
|
+
return !array_contains(target, value)
|
132
|
+
|
133
|
+
#string
|
134
|
+
when 'str_starts_with_any'
|
135
|
+
return match_string_in_array(target, value, ->(a, b) { a.start_with?(b) })
|
136
|
+
when 'str_ends_with_any'
|
137
|
+
return match_string_in_array(target, value, ->(a, b) { a.end_with?(b) })
|
138
|
+
when 'str_contains_any'
|
139
|
+
return match_string_in_array(target, value, ->(a, b) { a.include?(b) })
|
140
|
+
when 'str_matches'
|
141
|
+
return (value.is_a?(String) && !(value =~ Regexp.new(target)).nil? rescue false)
|
142
|
+
when 'eq'
|
143
|
+
return value == target
|
144
|
+
when 'neq'
|
145
|
+
return value != target
|
146
|
+
|
147
|
+
# dates
|
148
|
+
when 'before'
|
149
|
+
# TODO - planned future conditions
|
150
|
+
when 'after'
|
151
|
+
# TODO - planned future conditions
|
152
|
+
when 'on'
|
153
|
+
# TODO - planned future conditions
|
154
|
+
else
|
155
|
+
return $fetch_from_server
|
156
|
+
end
|
157
|
+
end
|
158
|
+
|
159
|
+
def get_value_from_user(user, field)
|
160
|
+
return nil unless user.instance_of?(StatsigUser) && field.is_a?(String)
|
161
|
+
|
162
|
+
user_lookup_table = user&.value_lookup
|
163
|
+
return nil unless user_lookup_table.is_a?(Hash)
|
164
|
+
return user_lookup_table[field.downcase] if user_lookup_table.has_key?(field.downcase)
|
165
|
+
|
166
|
+
user_custom = user_lookup_table['custom']
|
167
|
+
return nil unless user_custom.is_a?(Hash)
|
168
|
+
user_custom.each do |key, value|
|
169
|
+
return value if key.downcase.casecmp(field.downcase)
|
170
|
+
end
|
171
|
+
end
|
172
|
+
|
173
|
+
def get_value_from_ip(ip, field)
|
174
|
+
return nil unless ip.is_a?(String) && field.is_a?(String)
|
175
|
+
# TODO
|
176
|
+
$fetch_from_server
|
177
|
+
end
|
178
|
+
|
179
|
+
def get_value_from_ua(ua, field)
|
180
|
+
return nil unless ua.is_a?(String) && field.is_a?(String)
|
181
|
+
b = Browser.new(ua)
|
182
|
+
case field.downcase
|
183
|
+
when 'os_name'
|
184
|
+
os_name = b.platform.name
|
185
|
+
# special case for iOS because value is 'iOS (iPhone)'
|
186
|
+
if os_name.include?('iOS') || os_name.include?('ios')
|
187
|
+
return 'iOS'
|
188
|
+
else
|
189
|
+
return os_name
|
190
|
+
end
|
191
|
+
when 'os_version'
|
192
|
+
return b.platform.version
|
193
|
+
when 'browser_name'
|
194
|
+
return b.name
|
195
|
+
when 'browser_version'
|
196
|
+
return b.full_version
|
197
|
+
else
|
198
|
+
nil
|
199
|
+
end
|
200
|
+
end
|
201
|
+
|
202
|
+
def eval_pass_percent(user, rule, salt)
|
203
|
+
return false unless salt.is_a?(String) && !rule['passPercentage'].nil?
|
204
|
+
begin
|
205
|
+
user_id = user.user_id || ''
|
206
|
+
hash = Digest::SHA256.digest("#{salt}.#{rule['name']}.#{user_id}").unpack('Q>')[0]
|
207
|
+
return hash % 10000 < rule['passPercentage'].to_f * 100
|
208
|
+
rescue
|
209
|
+
return false
|
210
|
+
end
|
211
|
+
end
|
212
|
+
end
|
data/lib/network.rb
ADDED
@@ -0,0 +1,72 @@
|
|
1
|
+
require 'concurrent'
|
2
|
+
require 'http'
|
3
|
+
require 'json'
|
4
|
+
require 'dynamic_config'
|
5
|
+
|
6
|
+
class Network
|
7
|
+
include Concurrent::Async
|
8
|
+
|
9
|
+
def initialize(server_secret, api)
|
10
|
+
super()
|
11
|
+
unless api.end_with?('/')
|
12
|
+
api += '/'
|
13
|
+
end
|
14
|
+
@http = HTTP
|
15
|
+
.headers({"STATSIG-API-KEY" => server_secret, "Content-Type" => "application/json; charset=UTF-8"})
|
16
|
+
.accept(:json)
|
17
|
+
@api = api
|
18
|
+
@last_sync_time = 0
|
19
|
+
end
|
20
|
+
|
21
|
+
def check_gate(user, gate_name)
|
22
|
+
begin
|
23
|
+
request_body = JSON.generate({'user' => user&.serialize(), 'gateName' => gate_name})
|
24
|
+
response = @http.post(@api + 'check_gate', body: request_body)
|
25
|
+
return JSON.parse(response.body)
|
26
|
+
rescue
|
27
|
+
return false
|
28
|
+
end
|
29
|
+
end
|
30
|
+
|
31
|
+
def get_config(user, dynamic_config_name)
|
32
|
+
begin
|
33
|
+
request_body = JSON.generate({'user' => user&.serialize(), 'configName' => dynamic_config_name})
|
34
|
+
response = @http.post(@api + 'get_config', body: request_body)
|
35
|
+
return JSON.parse(response.body)
|
36
|
+
rescue
|
37
|
+
return nil
|
38
|
+
end
|
39
|
+
end
|
40
|
+
|
41
|
+
def download_config_specs
|
42
|
+
begin
|
43
|
+
response = @http.post(@api + 'download_config_specs', body: JSON.generate({'sinceTime' => @last_sync_time}))
|
44
|
+
json_body = JSON.parse(response.body)
|
45
|
+
@last_sync_time = json_body['time']
|
46
|
+
return json_body
|
47
|
+
rescue
|
48
|
+
return nil
|
49
|
+
end
|
50
|
+
end
|
51
|
+
|
52
|
+
def poll_for_changes(callback)
|
53
|
+
Thread.new do
|
54
|
+
loop do
|
55
|
+
sleep 10
|
56
|
+
specs = download_config_specs
|
57
|
+
unless specs.nil?
|
58
|
+
callback.call(specs)
|
59
|
+
end
|
60
|
+
end
|
61
|
+
end
|
62
|
+
end
|
63
|
+
|
64
|
+
def post_logs(events, statsig_metadata)
|
65
|
+
begin
|
66
|
+
json_body = JSON.generate({'events' => events, 'statsigMetadata' => statsig_metadata})
|
67
|
+
@http.post(@api + 'log_event', body: json_body)
|
68
|
+
rescue
|
69
|
+
# TODO: retries
|
70
|
+
end
|
71
|
+
end
|
72
|
+
end
|
data/lib/spec_store.rb
ADDED
@@ -0,0 +1,51 @@
|
|
1
|
+
require 'net/http'
|
2
|
+
require 'uri'
|
3
|
+
|
4
|
+
class SpecStore
|
5
|
+
def initialize(specs_json)
|
6
|
+
@last_sync_time = 0
|
7
|
+
@store = {
|
8
|
+
:gates => {},
|
9
|
+
:configs => {},
|
10
|
+
}
|
11
|
+
process(specs_json)
|
12
|
+
end
|
13
|
+
|
14
|
+
def process(specs_json)
|
15
|
+
if specs_json.nil?
|
16
|
+
return
|
17
|
+
end
|
18
|
+
|
19
|
+
@last_sync_time = specs_json['time'] || @last_sync_time
|
20
|
+
return unless specs_json['has_updates'] == true &&
|
21
|
+
!specs_json['feature_gates'].nil? &&
|
22
|
+
!specs_json['dynamic_configs'].nil?
|
23
|
+
|
24
|
+
@store = {
|
25
|
+
:gates => {},
|
26
|
+
:configs => {},
|
27
|
+
}
|
28
|
+
|
29
|
+
specs_json['feature_gates'].map{|gate| @store[:gates][gate['name']] = gate }
|
30
|
+
specs_json['dynamic_configs'].map{|config| @store[:configs][config['name']] = config }
|
31
|
+
end
|
32
|
+
|
33
|
+
def has_gate?(gate_name)
|
34
|
+
return @store[:gates].key?(gate_name)
|
35
|
+
end
|
36
|
+
|
37
|
+
def has_config?(config_name)
|
38
|
+
return @store[:configs].key?(config_name)
|
39
|
+
end
|
40
|
+
|
41
|
+
def get_gate(gate_name)
|
42
|
+
return nil unless has_gate?(gate_name)
|
43
|
+
@store[:gates][gate_name]
|
44
|
+
end
|
45
|
+
|
46
|
+
def get_config(config_name)
|
47
|
+
return nil unless has_config?(config_name)
|
48
|
+
@store[:configs][config_name]
|
49
|
+
end
|
50
|
+
|
51
|
+
end
|
data/lib/statsig.rb
ADDED
@@ -0,0 +1,42 @@
|
|
1
|
+
require 'statsig_driver'
|
2
|
+
|
3
|
+
module Statsig
|
4
|
+
def self.initialize(secret_key)
|
5
|
+
unless @shared_instance.nil?
|
6
|
+
puts 'Statsig already initialized.'
|
7
|
+
return @shared_instance
|
8
|
+
end
|
9
|
+
|
10
|
+
@shared_instance = StatsigDriver.new(secret_key)
|
11
|
+
end
|
12
|
+
|
13
|
+
def self.check_gate(user, gate_name)
|
14
|
+
self.ensure_initialized
|
15
|
+
@shared_instance.check_gate(user, gate_name)
|
16
|
+
end
|
17
|
+
|
18
|
+
def self.get_config(user, dynamic_config_name)
|
19
|
+
self.ensure_initialized
|
20
|
+
@shared_instance.get_config(user, dynamic_config_name)
|
21
|
+
end
|
22
|
+
|
23
|
+
def self.log_event(user, event_name, value, metadata)
|
24
|
+
self.ensure_initialized
|
25
|
+
@shared_instance.log_event(user, event_name, value, metadata)
|
26
|
+
end
|
27
|
+
|
28
|
+
def self.shutdown
|
29
|
+
unless @shared_instance.nil?
|
30
|
+
@shared_instance.shutdown
|
31
|
+
end
|
32
|
+
@shared_instance = nil
|
33
|
+
end
|
34
|
+
|
35
|
+
private
|
36
|
+
|
37
|
+
def self.ensure_initialized
|
38
|
+
if @shared_instance.nil?
|
39
|
+
raise 'Must call initialize first.'
|
40
|
+
end
|
41
|
+
end
|
42
|
+
end
|
@@ -0,0 +1,146 @@
|
|
1
|
+
require 'concurrent'
|
2
|
+
require 'config_result'
|
3
|
+
require 'evaluator'
|
4
|
+
require 'network'
|
5
|
+
require 'statsig_event'
|
6
|
+
require 'statsig_logger'
|
7
|
+
require 'statsig_user'
|
8
|
+
require 'spec_store'
|
9
|
+
|
10
|
+
class StatsigDriver
|
11
|
+
include Concurrent::Async
|
12
|
+
|
13
|
+
def initialize(secret_key)
|
14
|
+
super()
|
15
|
+
if !secret_key.is_a?(String) || !secret_key.start_with?('secret-')
|
16
|
+
raise 'Invalid secret key provided. Provide your project secret key from the Statsig console'
|
17
|
+
end
|
18
|
+
@shutdown = false
|
19
|
+
@secret_key = secret_key
|
20
|
+
@net = Network.new(secret_key, 'https://api.statsig.com/v1/')
|
21
|
+
@statsig_metadata = {
|
22
|
+
'sdkType' => 'ruby-server',
|
23
|
+
'sdkVersion' => Gem::Specification::load('statsig.gemspec')&.version,
|
24
|
+
}
|
25
|
+
@logger = StatsigLogger.new(@net, @statsig_metadata)
|
26
|
+
|
27
|
+
downloaded_specs = @net.download_config_specs
|
28
|
+
unless downloaded_specs.nil?
|
29
|
+
@initialized = true
|
30
|
+
end
|
31
|
+
|
32
|
+
@store = SpecStore.new(downloaded_specs)
|
33
|
+
@evaluator = Evaluator.new(@store)
|
34
|
+
|
35
|
+
@polling_thread = @net.poll_for_changes(-> (config_specs) { @store.process(config_specs) })
|
36
|
+
end
|
37
|
+
|
38
|
+
def check_gate(user, gate_name)
|
39
|
+
if !user.nil? && !user.instance_of?(StatsigUser)
|
40
|
+
raise 'Must provide a valid StatsigUser'
|
41
|
+
end
|
42
|
+
if !gate_name.is_a?(String) || gate_name.empty?
|
43
|
+
raise 'Invalid gate_name provided'
|
44
|
+
end
|
45
|
+
check_shutdown
|
46
|
+
unless @initialized
|
47
|
+
return false
|
48
|
+
end
|
49
|
+
|
50
|
+
res = @evaluator.check_gate(user, gate_name)
|
51
|
+
if res.nil?
|
52
|
+
res = ConfigResult.new(gate_name)
|
53
|
+
end
|
54
|
+
|
55
|
+
if res == $fetch_from_server
|
56
|
+
res = check_gate_fallback(user, gate_name)
|
57
|
+
end
|
58
|
+
|
59
|
+
@logger.logGateExposure(user, res.name, res.gate_value, res.rule_id)
|
60
|
+
res.gate_value
|
61
|
+
end
|
62
|
+
|
63
|
+
def get_config(user, dynamic_config_name)
|
64
|
+
if !user.nil? && !user.instance_of?(StatsigUser)
|
65
|
+
raise 'Must provide a valid StatsigUser or nil'
|
66
|
+
end
|
67
|
+
if !dynamic_config_name.is_a?(String) || dynamic_config_name.empty?
|
68
|
+
raise "Invalid dynamic_config_name provided"
|
69
|
+
end
|
70
|
+
check_shutdown
|
71
|
+
unless @initialized
|
72
|
+
return DynamicConfig.new(dynamic_config_name)
|
73
|
+
end
|
74
|
+
|
75
|
+
res = @evaluator.get_config(user, dynamic_config_name)
|
76
|
+
if res.nil?
|
77
|
+
res = ConfigResult.new(dynamic_config_name)
|
78
|
+
end
|
79
|
+
|
80
|
+
if res == $fetch_from_server
|
81
|
+
res = get_config_fallback(user, dynamic_config_name)
|
82
|
+
end
|
83
|
+
|
84
|
+
result_config = DynamicConfig.new(res.name, res.json_value, res.rule_id)
|
85
|
+
@logger.logConfigExposure(user, result_config.name, result_config.rule_id)
|
86
|
+
result_config
|
87
|
+
end
|
88
|
+
|
89
|
+
def log_event(user, event_name, value = nil, metadata = nil)
|
90
|
+
if !user.nil? && !user.instance_of?(StatsigUser)
|
91
|
+
raise 'Must provide a valid StatsigUser or nil'
|
92
|
+
end
|
93
|
+
check_shutdown
|
94
|
+
|
95
|
+
event = StatsigEvent.new(event_name)
|
96
|
+
event.user = user&.serialize
|
97
|
+
event.value = value
|
98
|
+
event.metadata = metadata
|
99
|
+
event.statsig_metadata = @statsig_metadata
|
100
|
+
@logger.log_event(event)
|
101
|
+
end
|
102
|
+
|
103
|
+
def shutdown
|
104
|
+
@shutdown = true
|
105
|
+
@logger.flush
|
106
|
+
@polling_thread&.exit
|
107
|
+
end
|
108
|
+
|
109
|
+
private
|
110
|
+
|
111
|
+
def check_shutdown
|
112
|
+
if @shutdown
|
113
|
+
puts 'SDK has been shutdown. Updates in the Statsig Console will no longer reflect.'
|
114
|
+
end
|
115
|
+
end
|
116
|
+
|
117
|
+
def check_gate_fallback(user, gate_name)
|
118
|
+
network_result = @net.check_gate(user, gate_name)
|
119
|
+
if network_result.nil?
|
120
|
+
config_result = ConfigResult.new(gate_name)
|
121
|
+
return config_result
|
122
|
+
end
|
123
|
+
|
124
|
+
ConfigResult.new(
|
125
|
+
network_result['name'],
|
126
|
+
network_result['value'],
|
127
|
+
{},
|
128
|
+
network_result['rule_id'],
|
129
|
+
)
|
130
|
+
end
|
131
|
+
|
132
|
+
def get_config_fallback(user, dynamic_config_name)
|
133
|
+
network_result = @net.get_config(user, dynamic_config_name)
|
134
|
+
if network_result.nil?
|
135
|
+
config_result = ConfigResult.new(dynamic_config_name)
|
136
|
+
return config_result
|
137
|
+
end
|
138
|
+
|
139
|
+
ConfigResult.new(
|
140
|
+
network_result['name'],
|
141
|
+
false,
|
142
|
+
network_result['value'],
|
143
|
+
network_result['rule_id'],
|
144
|
+
)
|
145
|
+
end
|
146
|
+
end
|
@@ -0,0 +1,21 @@
|
|
1
|
+
class StatsigEvent
|
2
|
+
attr_accessor :value
|
3
|
+
attr_accessor :user
|
4
|
+
attr_accessor :metadata
|
5
|
+
attr_accessor :statsig_metadata
|
6
|
+
def initialize(event_name)
|
7
|
+
@event_name = event_name
|
8
|
+
@time = Time.now.to_i * 1000
|
9
|
+
end
|
10
|
+
|
11
|
+
def serialize
|
12
|
+
return {
|
13
|
+
'eventName' => @event_name,
|
14
|
+
'metadata' => @metadata,
|
15
|
+
'value' => @value,
|
16
|
+
'user' => @user,
|
17
|
+
'time' => @time,
|
18
|
+
'statsigMetadata' => @statsig_metadata,
|
19
|
+
}
|
20
|
+
end
|
21
|
+
end
|
@@ -0,0 +1,52 @@
|
|
1
|
+
require 'statsig_event'
|
2
|
+
|
3
|
+
$gate_exposure_event = 'statsig::gate_exposure'
|
4
|
+
$config_exposure_event = 'statsig::config_exposure'
|
5
|
+
|
6
|
+
class StatsigLogger
|
7
|
+
def initialize(network, statsig_metadata)
|
8
|
+
@network = network
|
9
|
+
@statsig_metadata = statsig_metadata
|
10
|
+
@events = []
|
11
|
+
end
|
12
|
+
|
13
|
+
def log_event(event)
|
14
|
+
@events.push(event)
|
15
|
+
if @events.length >= 500
|
16
|
+
flush()
|
17
|
+
end
|
18
|
+
end
|
19
|
+
|
20
|
+
def logGateExposure(user, gate_name, value, rule_id)
|
21
|
+
event = StatsigEvent.new($gate_exposure_event)
|
22
|
+
event.user = user
|
23
|
+
event.metadata = {
|
24
|
+
'gate' => gate_name,
|
25
|
+
'gateValue' => value.to_s,
|
26
|
+
'ruleID' => rule_id
|
27
|
+
}
|
28
|
+
event.statsig_metadata = @statsig_metadata
|
29
|
+
log_event(event)
|
30
|
+
end
|
31
|
+
|
32
|
+
def logConfigExposure(user, config_name, rule_id)
|
33
|
+
event = StatsigEvent.new($config_exposure_event)
|
34
|
+
event.user = user
|
35
|
+
event.metadata = {
|
36
|
+
'config' => config_name,
|
37
|
+
'ruleID' => rule_id
|
38
|
+
}
|
39
|
+
event.statsig_metadata = @statsig_metadata
|
40
|
+
log_event(event)
|
41
|
+
end
|
42
|
+
|
43
|
+
def flush
|
44
|
+
if @events.length == 0
|
45
|
+
return
|
46
|
+
end
|
47
|
+
flush_events = @events.map { |e| e.serialize() }
|
48
|
+
@events = []
|
49
|
+
|
50
|
+
@network.post_logs(flush_events, @statsig_metadata)
|
51
|
+
end
|
52
|
+
end
|
data/lib/statsig_user.rb
ADDED
@@ -0,0 +1,49 @@
|
|
1
|
+
class StatsigUser
|
2
|
+
attr_accessor :user_id
|
3
|
+
attr_accessor :email
|
4
|
+
attr_accessor :ip
|
5
|
+
attr_accessor :user_agent
|
6
|
+
attr_accessor :country
|
7
|
+
attr_accessor :locale
|
8
|
+
attr_accessor :client_version
|
9
|
+
|
10
|
+
def custom
|
11
|
+
@custom
|
12
|
+
end
|
13
|
+
|
14
|
+
def custom=(value)
|
15
|
+
@custom = value.is_a?(Hash) ? value : Hash.new
|
16
|
+
end
|
17
|
+
|
18
|
+
def serialize
|
19
|
+
{
|
20
|
+
'userID' => @user_id,
|
21
|
+
'email' => @email,
|
22
|
+
'ip' => @ip,
|
23
|
+
'userAgent' => @user_agent,
|
24
|
+
'country' => @country,
|
25
|
+
'locale' => @locale,
|
26
|
+
'clientVersion' => @client_version,
|
27
|
+
'custom' => @custom,
|
28
|
+
}
|
29
|
+
end
|
30
|
+
|
31
|
+
def value_lookup
|
32
|
+
{
|
33
|
+
'userID' => @user_id,
|
34
|
+
'userid' => @user_id,
|
35
|
+
'user_id' => @user_id,
|
36
|
+
'email' => @email,
|
37
|
+
'ip' => @ip,
|
38
|
+
'userAgent' => @user_agent,
|
39
|
+
'useragent' => @user_agent,
|
40
|
+
'user_agent' => @user_agent,
|
41
|
+
'country' => @country,
|
42
|
+
'locale' => @locale,
|
43
|
+
'clientVersion' => @client_version,
|
44
|
+
'clientversion' => @client_version,
|
45
|
+
'client_version' => @client_version,
|
46
|
+
'custom' => @custom,
|
47
|
+
}
|
48
|
+
end
|
49
|
+
end
|
metadata
ADDED
@@ -0,0 +1,173 @@
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
2
|
+
name: statsig
|
3
|
+
version: !ruby/object:Gem::Version
|
4
|
+
version: 0.1.0
|
5
|
+
platform: ruby
|
6
|
+
authors:
|
7
|
+
- Statsig, Inc
|
8
|
+
autorequire:
|
9
|
+
bindir: bin
|
10
|
+
cert_chain: []
|
11
|
+
date: 2021-06-01 00:00:00.000000000 Z
|
12
|
+
dependencies:
|
13
|
+
- !ruby/object:Gem::Dependency
|
14
|
+
name: concurrent-ruby
|
15
|
+
requirement: !ruby/object:Gem::Requirement
|
16
|
+
requirements:
|
17
|
+
- - "~>"
|
18
|
+
- !ruby/object:Gem::Version
|
19
|
+
version: '1.1'
|
20
|
+
- - ">="
|
21
|
+
- !ruby/object:Gem::Version
|
22
|
+
version: 1.1.0
|
23
|
+
type: :development
|
24
|
+
prerelease: false
|
25
|
+
version_requirements: !ruby/object:Gem::Requirement
|
26
|
+
requirements:
|
27
|
+
- - "~>"
|
28
|
+
- !ruby/object:Gem::Version
|
29
|
+
version: '1.1'
|
30
|
+
- - ">="
|
31
|
+
- !ruby/object:Gem::Version
|
32
|
+
version: 1.1.0
|
33
|
+
- !ruby/object:Gem::Dependency
|
34
|
+
name: http
|
35
|
+
requirement: !ruby/object:Gem::Requirement
|
36
|
+
requirements:
|
37
|
+
- - "~>"
|
38
|
+
- !ruby/object:Gem::Version
|
39
|
+
version: '4.4'
|
40
|
+
- - ">="
|
41
|
+
- !ruby/object:Gem::Version
|
42
|
+
version: 4.4.1
|
43
|
+
type: :development
|
44
|
+
prerelease: false
|
45
|
+
version_requirements: !ruby/object:Gem::Requirement
|
46
|
+
requirements:
|
47
|
+
- - "~>"
|
48
|
+
- !ruby/object:Gem::Version
|
49
|
+
version: '4.4'
|
50
|
+
- - ">="
|
51
|
+
- !ruby/object:Gem::Version
|
52
|
+
version: 4.4.1
|
53
|
+
- !ruby/object:Gem::Dependency
|
54
|
+
name: browser
|
55
|
+
requirement: !ruby/object:Gem::Requirement
|
56
|
+
requirements:
|
57
|
+
- - "~>"
|
58
|
+
- !ruby/object:Gem::Version
|
59
|
+
version: '5.3'
|
60
|
+
- - ">="
|
61
|
+
- !ruby/object:Gem::Version
|
62
|
+
version: 5.3.1
|
63
|
+
type: :development
|
64
|
+
prerelease: false
|
65
|
+
version_requirements: !ruby/object:Gem::Requirement
|
66
|
+
requirements:
|
67
|
+
- - "~>"
|
68
|
+
- !ruby/object:Gem::Version
|
69
|
+
version: '5.3'
|
70
|
+
- - ">="
|
71
|
+
- !ruby/object:Gem::Version
|
72
|
+
version: 5.3.1
|
73
|
+
- !ruby/object:Gem::Dependency
|
74
|
+
name: browser
|
75
|
+
requirement: !ruby/object:Gem::Requirement
|
76
|
+
requirements:
|
77
|
+
- - "~>"
|
78
|
+
- !ruby/object:Gem::Version
|
79
|
+
version: '5.3'
|
80
|
+
- - ">="
|
81
|
+
- !ruby/object:Gem::Version
|
82
|
+
version: 5.3.1
|
83
|
+
type: :runtime
|
84
|
+
prerelease: false
|
85
|
+
version_requirements: !ruby/object:Gem::Requirement
|
86
|
+
requirements:
|
87
|
+
- - "~>"
|
88
|
+
- !ruby/object:Gem::Version
|
89
|
+
version: '5.3'
|
90
|
+
- - ">="
|
91
|
+
- !ruby/object:Gem::Version
|
92
|
+
version: 5.3.1
|
93
|
+
- !ruby/object:Gem::Dependency
|
94
|
+
name: concurrent-ruby
|
95
|
+
requirement: !ruby/object:Gem::Requirement
|
96
|
+
requirements:
|
97
|
+
- - "~>"
|
98
|
+
- !ruby/object:Gem::Version
|
99
|
+
version: '1.1'
|
100
|
+
- - ">="
|
101
|
+
- !ruby/object:Gem::Version
|
102
|
+
version: 1.1.0
|
103
|
+
type: :runtime
|
104
|
+
prerelease: false
|
105
|
+
version_requirements: !ruby/object:Gem::Requirement
|
106
|
+
requirements:
|
107
|
+
- - "~>"
|
108
|
+
- !ruby/object:Gem::Version
|
109
|
+
version: '1.1'
|
110
|
+
- - ">="
|
111
|
+
- !ruby/object:Gem::Version
|
112
|
+
version: 1.1.0
|
113
|
+
- !ruby/object:Gem::Dependency
|
114
|
+
name: http
|
115
|
+
requirement: !ruby/object:Gem::Requirement
|
116
|
+
requirements:
|
117
|
+
- - "~>"
|
118
|
+
- !ruby/object:Gem::Version
|
119
|
+
version: '4.4'
|
120
|
+
- - ">="
|
121
|
+
- !ruby/object:Gem::Version
|
122
|
+
version: 4.4.1
|
123
|
+
type: :runtime
|
124
|
+
prerelease: false
|
125
|
+
version_requirements: !ruby/object:Gem::Requirement
|
126
|
+
requirements:
|
127
|
+
- - "~>"
|
128
|
+
- !ruby/object:Gem::Version
|
129
|
+
version: '4.4'
|
130
|
+
- - ">="
|
131
|
+
- !ruby/object:Gem::Version
|
132
|
+
version: 4.4.1
|
133
|
+
description: Statsig server SDK for feature gates and experimentation in Ruby
|
134
|
+
email: support@statsig.com
|
135
|
+
executables: []
|
136
|
+
extensions: []
|
137
|
+
extra_rdoc_files: []
|
138
|
+
files:
|
139
|
+
- lib/config_result.rb
|
140
|
+
- lib/dynamic_config.rb
|
141
|
+
- lib/evaluation_helpers.rb
|
142
|
+
- lib/evaluator.rb
|
143
|
+
- lib/network.rb
|
144
|
+
- lib/spec_store.rb
|
145
|
+
- lib/statsig.rb
|
146
|
+
- lib/statsig_driver.rb
|
147
|
+
- lib/statsig_event.rb
|
148
|
+
- lib/statsig_logger.rb
|
149
|
+
- lib/statsig_user.rb
|
150
|
+
homepage: https://rubygems.org/gems/statsig
|
151
|
+
licenses:
|
152
|
+
- ISC
|
153
|
+
metadata: {}
|
154
|
+
post_install_message:
|
155
|
+
rdoc_options: []
|
156
|
+
require_paths:
|
157
|
+
- lib
|
158
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
159
|
+
requirements:
|
160
|
+
- - ">="
|
161
|
+
- !ruby/object:Gem::Version
|
162
|
+
version: '0'
|
163
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
164
|
+
requirements:
|
165
|
+
- - ">="
|
166
|
+
- !ruby/object:Gem::Version
|
167
|
+
version: '0'
|
168
|
+
requirements: []
|
169
|
+
rubygems_version: 3.2.3
|
170
|
+
signing_key:
|
171
|
+
specification_version: 4
|
172
|
+
summary: Statsig server SDK for Ruby
|
173
|
+
test_files: []
|