statsig 1.25.1 → 1.26.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +4 -4
- data/lib/client_initialize_helpers.rb +29 -3
- data/lib/diagnostics.rb +82 -15
- data/lib/error_boundary.rb +41 -34
- data/lib/evaluator.rb +6 -5
- data/lib/hash_utils.rb +17 -0
- data/lib/network.rb +57 -46
- data/lib/spec_store.rb +109 -64
- data/lib/statsig.rb +6 -4
- data/lib/statsig_driver.rb +106 -84
- data/lib/statsig_errors.rb +1 -0
- data/lib/statsig_logger.rb +25 -15
- data/lib/statsig_options.rb +8 -0
- data/lib/ua_parser.rb +1 -0
- data/lib/uri_helper.rb +37 -0
- metadata +52 -2
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 42f5328314de8727a2baaf89da54999627a5505ad95cd5ab816877741e02dd55
|
4
|
+
data.tar.gz: f6af1d20f540ceae4e24c70bdc702fbd9db3acc38776b71ecc50ded6ef40f67d
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 8c4375a325bf7dd1afaa584951b31eca2fd19422301b72397729fdca89e1ef8ae9b7a979aff562889cf49fc98fcd7db3edfc022ca66eb063b2680645c90df2db
|
7
|
+
data.tar.gz: ab23702707825b1ed5ada0df1982d765b97b75e8d813aeeaa9f24ae31974bb0cafa58249e82cc041eaa845bea9ee7639189c8778f8e21e0908b7ec3174d41661
|
@@ -1,4 +1,7 @@
|
|
1
1
|
# typed: true
|
2
|
+
|
3
|
+
require_relative 'hash_utils'
|
4
|
+
|
2
5
|
$empty_eval_result = {
|
3
6
|
:gate_value => false,
|
4
7
|
:json_value => {},
|
@@ -9,10 +12,12 @@ $empty_eval_result = {
|
|
9
12
|
|
10
13
|
module ClientInitializeHelpers
|
11
14
|
class ResponseFormatter
|
12
|
-
def initialize(evaluator, user)
|
15
|
+
def initialize(evaluator, user, hash, client_sdk_key)
|
13
16
|
@evaluator = evaluator
|
14
17
|
@user = user
|
15
18
|
@specs = evaluator.spec_store.get_raw_specs
|
19
|
+
@hash = hash
|
20
|
+
@client_sdk_key = client_sdk_key
|
16
21
|
end
|
17
22
|
|
18
23
|
def get_responses(key)
|
@@ -24,6 +29,13 @@ module ClientInitializeHelpers
|
|
24
29
|
private
|
25
30
|
|
26
31
|
def to_response(config_name, config_spec)
|
32
|
+
target_app_id = @evaluator.spec_store.get_app_id_for_sdk_key(@client_sdk_key)
|
33
|
+
config_target_apps = config_spec['targetAppIDs']
|
34
|
+
|
35
|
+
unless target_app_id.nil? || config_target_apps.nil? || config_target_apps.include?(target_app_id)
|
36
|
+
return nil
|
37
|
+
end
|
38
|
+
|
27
39
|
eval_result = @evaluator.eval_spec(@user, config_spec)
|
28
40
|
if eval_result.nil?
|
29
41
|
return nil
|
@@ -33,6 +45,8 @@ module ClientInitializeHelpers
|
|
33
45
|
:gate_value => eval_result.gate_value,
|
34
46
|
:json_value => eval_result.json_value,
|
35
47
|
:rule_id => eval_result.rule_id,
|
48
|
+
:group_name => eval_result.group_name,
|
49
|
+
:id_type => eval_result.id_type,
|
36
50
|
:config_delegate => eval_result.config_delegate,
|
37
51
|
:is_experiment_group => eval_result.is_experiment_group,
|
38
52
|
:secondary_exposures => eval_result.secondary_exposures,
|
@@ -52,10 +66,14 @@ module ClientInitializeHelpers
|
|
52
66
|
end
|
53
67
|
|
54
68
|
result['value'] = safe_eval_result[:gate_value]
|
69
|
+
result["group_name"] = safe_eval_result[:group_name]
|
70
|
+
result["id_type"] = safe_eval_result[:id_type]
|
55
71
|
when 'dynamic_config'
|
56
72
|
id_type = config_spec['idType']
|
57
73
|
result['value'] = safe_eval_result[:json_value]
|
58
74
|
result["group"] = safe_eval_result[:rule_id]
|
75
|
+
result["group_name"] = safe_eval_result[:group_name]
|
76
|
+
result["id_type"] = safe_eval_result[:id_type]
|
59
77
|
result["is_device_based"] = id_type.is_a?(String) && id_type.downcase == 'stableid'
|
60
78
|
else
|
61
79
|
return nil
|
@@ -67,6 +85,7 @@ module ClientInitializeHelpers
|
|
67
85
|
|
68
86
|
if entity_type == 'layer'
|
69
87
|
populate_layer_fields(config_spec, safe_eval_result, result)
|
88
|
+
result.delete('id_type') # not exposed for layer configs in /initialize
|
70
89
|
end
|
71
90
|
|
72
91
|
hashed_name = hash_name(config_name)
|
@@ -126,7 +145,14 @@ module ClientInitializeHelpers
|
|
126
145
|
end
|
127
146
|
|
128
147
|
def hash_name(name)
|
129
|
-
|
148
|
+
case @hash
|
149
|
+
when 'none'
|
150
|
+
return name
|
151
|
+
when 'sha256'
|
152
|
+
return Statsig::HashUtils.sha256(name)
|
153
|
+
when 'djb2'
|
154
|
+
return Statsig::HashUtils.djb2(name)
|
155
|
+
end
|
130
156
|
end
|
131
157
|
end
|
132
|
-
end
|
158
|
+
end
|
data/lib/diagnostics.rb
CHANGED
@@ -12,33 +12,100 @@ module Statsig
|
|
12
12
|
sig { returns(T::Array[T::Hash[Symbol, T.untyped]]) }
|
13
13
|
attr_reader :markers
|
14
14
|
|
15
|
-
sig { params(context: String).void }
|
16
|
-
|
17
15
|
def initialize(context)
|
18
16
|
@context = context
|
19
17
|
@markers = []
|
20
18
|
end
|
21
19
|
|
22
|
-
sig
|
20
|
+
sig do
|
21
|
+
params(
|
22
|
+
key: String,
|
23
|
+
action: String,
|
24
|
+
step: T.any(String, NilClass),
|
25
|
+
tags: T::Hash[Symbol, T.untyped]
|
26
|
+
).void
|
27
|
+
end
|
28
|
+
|
29
|
+
def mark(key, action, step, tags)
|
30
|
+
marker = {
|
31
|
+
key: key,
|
32
|
+
action: action,
|
33
|
+
timestamp: (Time.now.to_f * 1000).to_i
|
34
|
+
}
|
35
|
+
if !step.nil?
|
36
|
+
marker[:step] = step
|
37
|
+
end
|
38
|
+
tags.each do |key, val|
|
39
|
+
unless val.nil?
|
40
|
+
marker[key] = val
|
41
|
+
end
|
42
|
+
end
|
43
|
+
@markers.push(marker)
|
44
|
+
end
|
23
45
|
|
24
|
-
|
25
|
-
|
26
|
-
|
27
|
-
|
28
|
-
|
29
|
-
|
30
|
-
|
31
|
-
|
46
|
+
sig do
|
47
|
+
params(
|
48
|
+
key: String,
|
49
|
+
step: T.any(String, NilClass),
|
50
|
+
tags: T::Hash[Symbol, T.untyped]
|
51
|
+
).returns(Tracker)
|
52
|
+
end
|
53
|
+
def track(key, step = nil, tags = {})
|
54
|
+
tracker = Tracker.new(self, key, step, tags)
|
55
|
+
tracker.start(**tags)
|
56
|
+
tracker
|
32
57
|
end
|
33
58
|
|
34
59
|
sig { returns(T::Hash[Symbol, T.untyped]) }
|
35
60
|
|
36
61
|
def serialize
|
37
62
|
{
|
38
|
-
context: @context,
|
39
|
-
markers: @markers
|
63
|
+
context: @context.clone,
|
64
|
+
markers: @markers.clone
|
40
65
|
}
|
41
66
|
end
|
42
|
-
end
|
43
67
|
|
44
|
-
|
68
|
+
def clear_markers
|
69
|
+
@markers.clear
|
70
|
+
end
|
71
|
+
|
72
|
+
def self.sample(rate)
|
73
|
+
rand(rate).zero?
|
74
|
+
end
|
75
|
+
|
76
|
+
class Context
|
77
|
+
INITIALIZE = 'initialize'.freeze
|
78
|
+
CONFIG_SYNC = 'config_sync'.freeze
|
79
|
+
API_CALL = 'api_call'.freeze
|
80
|
+
end
|
81
|
+
|
82
|
+
API_CALL_KEYS = %w[check_gate get_config get_experiment get_layer].freeze
|
83
|
+
|
84
|
+
class Tracker
|
85
|
+
extend T::Sig
|
86
|
+
|
87
|
+
sig do
|
88
|
+
params(
|
89
|
+
diagnostics: Diagnostics,
|
90
|
+
key: String,
|
91
|
+
step: T.any(String, NilClass),
|
92
|
+
tags: T::Hash[Symbol, T.untyped]
|
93
|
+
).void
|
94
|
+
end
|
95
|
+
def initialize(diagnostics, key, step, tags = {})
|
96
|
+
@diagnostics = diagnostics
|
97
|
+
@key = key
|
98
|
+
@step = step
|
99
|
+
@tags = tags
|
100
|
+
end
|
101
|
+
|
102
|
+
def start(**tags)
|
103
|
+
@diagnostics.mark(@key, 'start', @step, tags.nil? ? {} : tags.merge(@tags))
|
104
|
+
end
|
105
|
+
|
106
|
+
def end(**tags)
|
107
|
+
@diagnostics.mark(@key, 'end', @step, tags.nil? ? {} : tags.merge(@tags))
|
108
|
+
end
|
109
|
+
end
|
110
|
+
end
|
111
|
+
end
|
data/lib/error_boundary.rb
CHANGED
@@ -1,57 +1,64 @@
|
|
1
|
-
|
1
|
+
# typed: true
|
2
|
+
|
3
|
+
require 'statsig_errors'
|
4
|
+
require 'sorbet-runtime'
|
2
5
|
|
3
6
|
$endpoint = 'https://statsigapi.net/v1/sdk_exception'
|
4
7
|
|
5
8
|
module Statsig
|
6
9
|
class ErrorBoundary
|
10
|
+
extend T::Sig
|
11
|
+
|
12
|
+
sig { params(sdk_key: String).void }
|
7
13
|
def initialize(sdk_key)
|
8
14
|
@sdk_key = sdk_key
|
9
15
|
@seen = Set.new
|
10
16
|
end
|
11
17
|
|
12
|
-
def capture(task
|
18
|
+
def capture(task:, recover: -> {}, caller: nil)
|
13
19
|
begin
|
14
|
-
|
20
|
+
res = task.call
|
15
21
|
rescue StandardError => e
|
16
|
-
if e.is_a?(Statsig::UninitializedError)
|
22
|
+
if e.is_a?(Statsig::UninitializedError) || e.is_a?(Statsig::ValueError)
|
17
23
|
raise e
|
18
24
|
end
|
19
|
-
|
20
|
-
|
21
|
-
|
25
|
+
|
26
|
+
puts '[Statsig]: An unexpected exception occurred.'
|
27
|
+
log_exception(e, tag: caller)
|
28
|
+
res = recover.call
|
22
29
|
end
|
30
|
+
return res
|
23
31
|
end
|
24
32
|
|
25
33
|
private
|
26
34
|
|
27
|
-
def log_exception(exception)
|
28
|
-
|
29
|
-
|
30
|
-
if @seen.include?(name)
|
31
|
-
return
|
32
|
-
end
|
33
|
-
|
34
|
-
@seen << name
|
35
|
-
meta = Statsig.get_statsig_metadata
|
36
|
-
http = HTTP.headers(
|
37
|
-
{
|
38
|
-
"STATSIG-API-KEY" => @sdk_key,
|
39
|
-
"STATSIG-SDK-TYPE" => meta['sdkType'],
|
40
|
-
"STATSIG-SDK-VERSION" => meta['sdkVersion'],
|
41
|
-
"Content-Type" => "application/json; charset=UTF-8"
|
42
|
-
}).accept(:json)
|
43
|
-
body = {
|
44
|
-
"exception" => name,
|
45
|
-
"info" => {
|
46
|
-
"trace" => exception.backtrace.to_s,
|
47
|
-
"message" => exception.message
|
48
|
-
}.to_s,
|
49
|
-
"statsigMetadata" => meta
|
50
|
-
}
|
51
|
-
http.post($endpoint, body: JSON.generate(body))
|
52
|
-
rescue
|
35
|
+
def log_exception(exception, tag: nil)
|
36
|
+
name = exception.class.name
|
37
|
+
if @seen.include?(name)
|
53
38
|
return
|
54
39
|
end
|
40
|
+
|
41
|
+
@seen << name
|
42
|
+
meta = Statsig.get_statsig_metadata
|
43
|
+
http = HTTP.headers(
|
44
|
+
{
|
45
|
+
'STATSIG-API-KEY' => @sdk_key,
|
46
|
+
'STATSIG-SDK-TYPE' => meta['sdkType'],
|
47
|
+
'STATSIG-SDK-VERSION' => meta['sdkVersion'],
|
48
|
+
'Content-Type' => 'application/json; charset=UTF-8'
|
49
|
+
}).accept(:json)
|
50
|
+
body = {
|
51
|
+
'exception' => name,
|
52
|
+
'info' => {
|
53
|
+
'trace' => exception.backtrace.to_s,
|
54
|
+
'message' => exception.message
|
55
|
+
}.to_s,
|
56
|
+
'statsigMetadata' => meta,
|
57
|
+
'tag' => tag
|
58
|
+
}
|
59
|
+
http.post($endpoint, body: JSON.generate(body))
|
60
|
+
rescue StandardError
|
61
|
+
return
|
55
62
|
end
|
56
63
|
end
|
57
|
-
end
|
64
|
+
end
|
data/lib/evaluator.rb
CHANGED
@@ -17,8 +17,8 @@ module Statsig
|
|
17
17
|
class Evaluator
|
18
18
|
attr_accessor :spec_store
|
19
19
|
|
20
|
-
def initialize(network, options, error_callback,
|
21
|
-
@spec_store = Statsig::SpecStore.new(network, options, error_callback,
|
20
|
+
def initialize(network, options, error_callback, diagnostics, error_boundary, logger)
|
21
|
+
@spec_store = Statsig::SpecStore.new(network, options, error_callback, diagnostics, error_boundary, logger)
|
22
22
|
UAParser.initialize_async
|
23
23
|
CountryLookup.initialize_async
|
24
24
|
|
@@ -98,12 +98,12 @@ module Statsig
|
|
98
98
|
eval_spec(user, @spec_store.get_layer(layer_name))
|
99
99
|
end
|
100
100
|
|
101
|
-
def get_client_initialize_response(user)
|
101
|
+
def get_client_initialize_response(user, hash, client_sdk_key)
|
102
102
|
if @spec_store.is_ready_for_checks == false
|
103
103
|
return nil
|
104
104
|
end
|
105
105
|
|
106
|
-
formatter = ClientInitializeHelpers::ResponseFormatter.new(self, user)
|
106
|
+
formatter = ClientInitializeHelpers::ResponseFormatter.new(self, user, hash, client_sdk_key)
|
107
107
|
|
108
108
|
evaluated_keys = {}
|
109
109
|
if user.user_id.nil? == false
|
@@ -123,6 +123,7 @@ module Statsig
|
|
123
123
|
"generator" => "statsig-ruby-sdk",
|
124
124
|
"evaluated_keys" => evaluated_keys,
|
125
125
|
"time" => 0,
|
126
|
+
"hash_used" => hash
|
126
127
|
}
|
127
128
|
end
|
128
129
|
|
@@ -199,7 +200,7 @@ module Statsig
|
|
199
200
|
@spec_store.initial_config_sync_time,
|
200
201
|
@spec_store.init_reason
|
201
202
|
),
|
202
|
-
group_name:
|
203
|
+
group_name: 'default',
|
203
204
|
id_type: config['idType']
|
204
205
|
)
|
205
206
|
end
|
data/lib/hash_utils.rb
ADDED
@@ -0,0 +1,17 @@
|
|
1
|
+
module Statsig
|
2
|
+
class HashUtils
|
3
|
+
def self.djb2(input_str)
|
4
|
+
hash = 0
|
5
|
+
input_str.each_char.each do |c|
|
6
|
+
hash = (hash << 5) - hash + c.ord
|
7
|
+
hash &= hash
|
8
|
+
end
|
9
|
+
hash &= 0xFFFFFFFF # Convert to unsigned 32-bit integer
|
10
|
+
return hash.to_s
|
11
|
+
end
|
12
|
+
|
13
|
+
def self.sha256(input_str)
|
14
|
+
return Digest::SHA256.base64digest(input_str)
|
15
|
+
end
|
16
|
+
end
|
17
|
+
end
|
data/lib/network.rb
CHANGED
@@ -4,8 +4,10 @@ require 'http'
|
|
4
4
|
require 'json'
|
5
5
|
require 'securerandom'
|
6
6
|
require 'sorbet-runtime'
|
7
|
+
require 'uri_helper'
|
8
|
+
require 'connection_pool'
|
7
9
|
|
8
|
-
|
10
|
+
RETRY_CODES = [408, 500, 502, 503, 504, 522, 524, 599].freeze
|
9
11
|
|
10
12
|
module Statsig
|
11
13
|
class NetworkError < StandardError
|
@@ -24,41 +26,44 @@ module Statsig
|
|
24
26
|
|
25
27
|
def initialize(server_secret, options, backoff_mult = 10)
|
26
28
|
super()
|
27
|
-
|
28
|
-
unless api.end_with?('/')
|
29
|
-
api += '/'
|
30
|
-
end
|
29
|
+
URIHelper.initialize(options)
|
31
30
|
@server_secret = server_secret
|
32
|
-
@api = api
|
33
31
|
@local_mode = options.local_mode
|
34
32
|
@timeout = options.network_timeout
|
35
33
|
@backoff_multiplier = backoff_mult
|
36
34
|
@post_logs_retry_backoff = options.post_logs_retry_backoff
|
37
35
|
@post_logs_retry_limit = options.post_logs_retry_limit
|
38
36
|
@session_id = SecureRandom.uuid
|
37
|
+
@connection_pool = ConnectionPool.new(size: 3) do
|
38
|
+
meta = Statsig.get_statsig_metadata
|
39
|
+
client = HTTP.headers(
|
40
|
+
{
|
41
|
+
'STATSIG-API-KEY' => @server_secret,
|
42
|
+
'STATSIG-CLIENT-TIME' => (Time.now.to_f * 1000).to_i.to_s,
|
43
|
+
'STATSIG-SERVER-SESSION-ID' => @session_id,
|
44
|
+
'Content-Type' => 'application/json; charset=UTF-8',
|
45
|
+
'STATSIG-SDK-TYPE' => meta['sdkType'],
|
46
|
+
'STATSIG-SDK-VERSION' => meta['sdkVersion']
|
47
|
+
}
|
48
|
+
).accept(:json)
|
49
|
+
if @timeout
|
50
|
+
client = client.timeout(@timeout)
|
51
|
+
end
|
52
|
+
|
53
|
+
client
|
54
|
+
end
|
39
55
|
end
|
40
56
|
|
41
|
-
sig
|
42
|
-
|
57
|
+
sig do
|
58
|
+
params(endpoint: String, body: String, retries: Integer, backoff: Integer)
|
59
|
+
.returns([T.any(HTTP::Response, NilClass), T.any(StandardError, NilClass)])
|
60
|
+
end
|
43
61
|
|
44
62
|
def post_helper(endpoint, body, retries = 0, backoff = 1)
|
45
63
|
if @local_mode
|
46
64
|
return nil, nil
|
47
65
|
end
|
48
66
|
|
49
|
-
meta = Statsig.get_statsig_metadata
|
50
|
-
http = HTTP.headers(
|
51
|
-
{
|
52
|
-
"STATSIG-API-KEY" => @server_secret,
|
53
|
-
"STATSIG-CLIENT-TIME" => (Time.now.to_f * 1000).to_i.to_s,
|
54
|
-
"STATSIG-SERVER-SESSION-ID" => @session_id,
|
55
|
-
"Content-Type" => "application/json; charset=UTF-8",
|
56
|
-
"STATSIG-SDK-TYPE" => meta['sdkType'],
|
57
|
-
"STATSIG-SDK-VERSION" => meta['sdkVersion'],
|
58
|
-
}).accept(:json)
|
59
|
-
if @timeout
|
60
|
-
http = http.timeout(@timeout)
|
61
|
-
end
|
62
67
|
backoff_adjusted = backoff > 10 ? backoff += Random.rand(10) : backoff # to deter overlap
|
63
68
|
if @post_logs_retry_backoff
|
64
69
|
if @post_logs_retry_backoff.is_a? Integer
|
@@ -67,49 +72,55 @@ module Statsig
|
|
67
72
|
backoff_adjusted = @post_logs_retry_backoff.call(retries)
|
68
73
|
end
|
69
74
|
end
|
75
|
+
url = URIHelper.build_url(endpoint)
|
70
76
|
begin
|
71
|
-
res =
|
77
|
+
res = @connection_pool.with do |conn|
|
78
|
+
conn.headers('STATSIG-CLIENT-TIME' => (Time.now.to_f * 1000).to_i.to_s).post(url, body: body)
|
79
|
+
end
|
72
80
|
rescue StandardError => e
|
73
81
|
## network error retry
|
74
|
-
return nil, e unless retries
|
82
|
+
return nil, e unless retries.positive?
|
83
|
+
|
75
84
|
sleep backoff_adjusted
|
76
85
|
return post_helper(endpoint, body, retries - 1, backoff * @backoff_multiplier)
|
77
86
|
end
|
78
87
|
return res, nil if res.status.success?
|
79
|
-
|
88
|
+
|
89
|
+
unless retries.positive? && RETRY_CODES.include?(res.code)
|
90
|
+
return res, NetworkError.new("Got an exception when making request to #{url}: #{res.to_s}",
|
91
|
+
res.status.to_i)
|
92
|
+
end
|
93
|
+
|
80
94
|
## status code retry
|
81
95
|
sleep backoff_adjusted
|
82
96
|
post_helper(endpoint, body, retries - 1, backoff * @backoff_multiplier)
|
83
97
|
end
|
84
98
|
|
85
99
|
def check_gate(user, gate_name)
|
86
|
-
|
87
|
-
|
88
|
-
|
89
|
-
|
90
|
-
|
91
|
-
|
92
|
-
|
93
|
-
end
|
100
|
+
request_body = JSON.generate({ 'user' => user&.serialize(false), 'gateName' => gate_name })
|
101
|
+
response, = post_helper('check_gate', request_body)
|
102
|
+
return JSON.parse(response.body) unless response.nil?
|
103
|
+
|
104
|
+
false
|
105
|
+
rescue StandardError
|
106
|
+
false
|
94
107
|
end
|
95
108
|
|
96
109
|
def get_config(user, dynamic_config_name)
|
97
|
-
|
98
|
-
|
99
|
-
|
100
|
-
|
101
|
-
|
102
|
-
|
103
|
-
|
104
|
-
end
|
110
|
+
request_body = JSON.generate({ 'user' => user&.serialize(false), 'configName' => dynamic_config_name })
|
111
|
+
response, = post_helper('get_config', request_body)
|
112
|
+
return JSON.parse(response.body) unless response.nil?
|
113
|
+
|
114
|
+
nil
|
115
|
+
rescue StandardError
|
116
|
+
nil
|
105
117
|
end
|
106
118
|
|
107
119
|
def post_logs(events)
|
108
|
-
|
109
|
-
|
110
|
-
|
111
|
-
|
112
|
-
end
|
120
|
+
json_body = JSON.generate({ 'events' => events, 'statsigMetadata' => Statsig.get_statsig_metadata })
|
121
|
+
post_helper('log_event', json_body, @post_logs_retry_limit)
|
122
|
+
rescue StandardError
|
123
|
+
|
113
124
|
end
|
114
125
|
end
|
115
|
-
end
|
126
|
+
end
|