statsig 1.10.0 → 1.20.0

Sign up to get free protection for your applications and to get access to all the features.
data/lib/layer.rb CHANGED
@@ -1,7 +1,22 @@
1
+ # typed: false
2
+
3
+ ##
4
+ # Contains the current values from Statsig.
5
+ # Will contain layer default values for all shared parameters in that layer.
6
+ # If a parameter is in an active experiment, and the current user is allocated to that experiment,
7
+ # those parameters will be updated to reflect the experiment values not the layer defaults.
8
+ #
9
+ # Layers Documentation: https://docs.statsig.com/layers
1
10
  class Layer
11
+ extend T::Sig
12
+
13
+ sig { returns(String) }
2
14
  attr_accessor :name
15
+
16
+ sig { returns(String) }
3
17
  attr_accessor :rule_id
4
18
 
19
+ sig { params(name: String, value: T::Hash[String, T.untyped], rule_id: String, exposure_log_func: T.any(Method, Proc, NilClass)).void }
5
20
  def initialize(name, value = {}, rule_id = '', exposure_log_func = nil)
6
21
  @name = name
7
22
  @value = value
@@ -9,6 +24,12 @@ class Layer
9
24
  @exposure_log_func = exposure_log_func
10
25
  end
11
26
 
27
+ sig { params(index: String, default_value: T.untyped).returns(T.untyped) }
28
+ ##
29
+ # Get the value for the given key (index), falling back to the default_value if it cannot be found.
30
+ #
31
+ # @param index The name of parameter being fetched
32
+ # @param default_value The fallback value if the name cannot be found
12
33
  def get(index, default_value)
13
34
  return default_value if @value.nil? || !@value.key?(index)
14
35
 
@@ -18,4 +39,22 @@ class Layer
18
39
 
19
40
  @value[index]
20
41
  end
42
+
43
+ sig { params(index: String, default_value: T.untyped).returns(T.untyped) }
44
+ ##
45
+ # Get the value for the given key (index), falling back to the default_value if it cannot be found
46
+ # or is found to have a different type from the default_value.
47
+ #
48
+ # @param index The name of parameter being fetched
49
+ # @param default_value The fallback value if the name cannot be found
50
+ def get_typed(index, default_value)
51
+ return default_value if @value.nil? || !@value.key?(index)
52
+ return default_value if @value[index].class != default_value.class and default_value.class != TrueClass and default_value.class != FalseClass
53
+
54
+ if @exposure_log_func.is_a? Proc
55
+ @exposure_log_func.call(self, index)
56
+ end
57
+
58
+ @value[index]
59
+ end
21
60
  end
data/lib/network.rb CHANGED
@@ -1,28 +1,56 @@
1
+ # typed: true
2
+
1
3
  require 'http'
2
4
  require 'json'
3
5
  require 'securerandom'
6
+ require 'sorbet-runtime'
4
7
 
5
8
  $retry_codes = [408, 500, 502, 503, 504, 522, 524, 599]
6
9
 
7
10
  module Statsig
11
+ class NetworkError < StandardError
12
+ attr_reader :http_code
13
+
14
+ def initialize(msg = nil, http_code = nil)
15
+ super(msg)
16
+ @http_code = http_code
17
+ end
18
+ end
19
+
8
20
  class Network
9
- def initialize(server_secret, api, backoff_mult = 10)
21
+ extend T::Sig
22
+
23
+ sig { params(server_secret: String, api: String, local_mode: T::Boolean, backoff_mult: Integer).void }
24
+
25
+ def initialize(server_secret, api, local_mode, backoff_mult = 10)
10
26
  super()
11
27
  unless api.end_with?('/')
12
28
  api += '/'
13
29
  end
14
30
  @server_secret = server_secret
15
31
  @api = api
32
+ @local_mode = local_mode
16
33
  @backoff_multiplier = backoff_mult
17
34
  @session_id = SecureRandom.uuid
18
35
  end
19
36
 
37
+ sig { params(endpoint: String, body: String, retries: Integer, backoff: Integer)
38
+ .returns([T.any(HTTP::Response, NilClass), T.any(StandardError, NilClass)]) }
39
+
20
40
  def post_helper(endpoint, body, retries = 0, backoff = 1)
41
+ if @local_mode
42
+ return nil, nil
43
+ end
44
+
45
+ meta = Statsig.get_statsig_metadata
21
46
  http = HTTP.headers(
22
- {"STATSIG-API-KEY" => @server_secret,
23
- "STATSIG-CLIENT-TIME" => (Time.now.to_f * 1000).to_s,
24
- "STATSIG-SERVER-SESSION-ID" => @session_id,
25
- "Content-Type" => "application/json; charset=UTF-8"
47
+ {
48
+ "STATSIG-API-KEY" => @server_secret,
49
+ "STATSIG-CLIENT-TIME" => (Time.now.to_f * 1000).to_i.to_s,
50
+ "STATSIG-SERVER-SESSION-ID" => @session_id,
51
+ "Content-Type" => "application/json; charset=UTF-8",
52
+ "STATSIG-SDK-TYPE" => meta['sdkType'],
53
+ "STATSIG-SDK-VERSION" => meta['sdkVersion'],
26
54
  }).accept(:json)
27
55
  begin
28
56
  res = http.post(@api + endpoint, body: body)
@@ -32,8 +60,8 @@ module Statsig
32
60
  sleep backoff
33
61
  return post_helper(endpoint, body, retries - 1, backoff * @backoff_multiplier)
34
62
  end
35
- return res, nil unless !res.status.success?
36
- return nil, StandardError.new("Got an exception when making request to #{@api + endpoint}: #{res.to_s}") unless retries > 0 && $retry_codes.include?(res.code)
63
+ return res, nil if res.status.success?
64
+ return nil, NetworkError.new("Got an exception when making request to #{@api + endpoint}: #{res.to_s}", res.status.to_i) unless retries > 0 && $retry_codes.include?(res.code)
37
65
  ## status code retry
38
66
  sleep backoff
39
67
  post_helper(endpoint, body, retries - 1, backoff * @backoff_multiplier)
@@ -41,7 +69,7 @@ module Statsig
41
69
 
42
70
  def check_gate(user, gate_name)
43
71
  begin
44
- request_body = JSON.generate({'user' => user&.serialize(false), 'gateName' => gate_name})
72
+ request_body = JSON.generate({ 'user' => user&.serialize(false), 'gateName' => gate_name })
45
73
  response, _ = post_helper('check_gate', request_body)
46
74
  return JSON.parse(response.body) unless response.nil?
47
75
  false
@@ -52,7 +80,7 @@ module Statsig
52
80
 
53
81
  def get_config(user, dynamic_config_name)
54
82
  begin
55
- request_body = JSON.generate({'user' => user&.serialize(false), 'configName' => dynamic_config_name})
83
+ request_body = JSON.generate({ 'user' => user&.serialize(false), 'configName' => dynamic_config_name })
56
84
  response, _ = post_helper('get_config', request_body)
57
85
  return JSON.parse(response.body) unless response.nil?
58
86
  nil
@@ -63,8 +91,8 @@ module Statsig
63
91
 
64
92
  def post_logs(events)
65
93
  begin
66
- json_body = JSON.generate({'events' => events, 'statsigMetadata' => Statsig.get_statsig_metadata})
67
- post_helper('log_event', json_body, retries: 5)
94
+ json_body = JSON.generate({ 'events' => events, 'statsigMetadata' => Statsig.get_statsig_metadata })
95
+ post_helper('log_event', json_body, 5)
68
96
  rescue
69
97
  end
70
98
  end
data/lib/spec_store.rb CHANGED
@@ -1,71 +1,157 @@
1
+ # typed: false
1
2
  require 'net/http'
2
3
  require 'uri'
3
-
4
+ require 'evaluation_details'
4
5
  require 'id_list'
6
+ require 'concurrent-ruby'
5
7
 
6
8
  module Statsig
7
9
  class SpecStore
8
- def initialize(network, error_callback = nil, rulesets_sync_interval = 10, id_lists_sync_interval = 60)
10
+
11
+ CONFIG_SPECS_KEY = "statsig.cache"
12
+
13
+ attr_accessor :last_config_sync_time
14
+ attr_accessor :initial_config_sync_time
15
+ attr_accessor :init_reason
16
+
17
+ def initialize(network, options, error_callback, init_diagnostics = nil)
18
+ @init_reason = EvaluationReason::UNINITIALIZED
9
19
  @network = network
10
- @last_sync_time = 0
11
- @rulesets_sync_interval = rulesets_sync_interval
12
- @id_lists_sync_interval = id_lists_sync_interval
13
- @store = {
20
+ @options = options
21
+ @error_callback = error_callback
22
+ @last_config_sync_time = 0
23
+ @initial_config_sync_time = 0
24
+ @rulesets_sync_interval = options.rulesets_sync_interval
25
+ @id_lists_sync_interval = options.idlists_sync_interval
26
+ @rules_updated_callback = options.rules_updated_callback
27
+ @specs = {
14
28
  :gates => {},
15
29
  :configs => {},
16
30
  :layers => {},
17
31
  :id_lists => {},
32
+ :experiment_to_layer => {}
18
33
  }
19
- e = download_config_specs
20
- error_callback.call(e) unless error_callback.nil?
21
- get_id_lists
34
+
35
+ @id_list_thread_pool = Concurrent::FixedThreadPool.new(
36
+ options.idlist_threadpool_size,
37
+ max_queue: 100,
38
+ fallback_policy: :discard,
39
+ )
40
+
41
+ unless @options.bootstrap_values.nil?
42
+ begin
43
+ if !@options.data_store.nil?
44
+ puts 'data_store gets priority over bootstrap_values. bootstrap_values will be ignored'
45
+ else
46
+ init_diagnostics&.mark("bootstrap", "start", "load")
47
+ if process(options.bootstrap_values)
48
+ @init_reason = EvaluationReason::BOOTSTRAP
49
+ end
50
+ init_diagnostics&.mark("bootstrap", "end", "load", @init_reason == EvaluationReason::BOOTSTRAP)
51
+ end
52
+ rescue
53
+ puts 'the provided bootstrapValues is not a valid JSON string'
54
+ end
55
+ end
56
+
57
+ unless @options.data_store.nil?
58
+ init_diagnostics&.mark("data_store", "start", "load")
59
+ @options.data_store.init
60
+ load_from_storage_adapter
61
+ init_diagnostics&.mark("data_store", "end", "load", @init_reason == EvaluationReason::DATA_ADAPTER)
62
+ end
63
+
64
+ if @init_reason == EvaluationReason::UNINITIALIZED
65
+ download_config_specs(init_diagnostics)
66
+ end
67
+
68
+ @initial_config_sync_time = @last_config_sync_time == 0 ? -1 : @last_config_sync_time
69
+ get_id_lists(init_diagnostics)
22
70
 
23
71
  @config_sync_thread = sync_config_specs
24
72
  @id_lists_sync_thread = sync_id_lists
25
73
  end
26
74
 
75
+ def is_ready_for_checks
76
+ @last_config_sync_time != 0
77
+ end
78
+
27
79
  def shutdown
28
80
  @config_sync_thread&.exit
29
81
  @id_lists_sync_thread&.exit
82
+ @id_list_thread_pool.shutdown
83
+ @id_list_thread_pool.wait_for_termination(timeout = 3)
84
+ unless @options.data_store.nil?
85
+ @options.data_store.shutdown
86
+ end
30
87
  end
31
88
 
32
89
  def has_gate?(gate_name)
33
- @store[:gates].key?(gate_name)
90
+ @specs[:gates].key?(gate_name)
34
91
  end
35
92
 
36
93
  def has_config?(config_name)
37
- @store[:configs].key?(config_name)
94
+ @specs[:configs].key?(config_name)
38
95
  end
39
96
 
40
97
  def has_layer?(layer_name)
41
- @store[:layers].key?(layer_name)
98
+ @specs[:layers].key?(layer_name)
42
99
  end
43
100
 
44
101
  def get_gate(gate_name)
45
102
  return nil unless has_gate?(gate_name)
46
- @store[:gates][gate_name]
103
+ @specs[:gates][gate_name]
47
104
  end
48
105
 
49
106
  def get_config(config_name)
50
107
  return nil unless has_config?(config_name)
51
- @store[:configs][config_name]
108
+ @specs[:configs][config_name]
52
109
  end
53
110
 
54
111
  def get_layer(layer_name)
55
112
  return nil unless has_layer?(layer_name)
56
- @store[:layers][layer_name]
113
+ @specs[:layers][layer_name]
57
114
  end
58
115
 
59
116
  def get_id_list(list_name)
60
- @store[:id_lists][list_name]
117
+ @specs[:id_lists][list_name]
118
+ end
119
+
120
+ def get_raw_specs
121
+ @specs
122
+ end
123
+
124
+ def maybe_restart_background_threads
125
+ if @config_sync_thread.nil? or !@config_sync_thread.alive?
126
+ @config_sync_thread = sync_config_specs
127
+ end
128
+ if @id_lists_sync_thread.nil? or !@id_lists_sync_thread.alive?
129
+ @id_lists_sync_thread = sync_id_lists
130
+ end
61
131
  end
62
132
 
63
133
  private
64
134
 
135
+ def load_from_storage_adapter
136
+ cached_values = @options.data_store.get(CONFIG_SPECS_KEY)
137
+ if cached_values.nil?
138
+ return
139
+ end
140
+ process(cached_values, true)
141
+ @init_reason = EvaluationReason::DATA_ADAPTER
142
+ end
143
+
144
+ def save_to_storage_adapter(specs_string)
145
+ if @options.data_store.nil?
146
+ return
147
+ end
148
+ @options.data_store.set(CONFIG_SPECS_KEY, specs_string)
149
+ end
150
+
65
151
  def sync_config_specs
66
152
  Thread.new do
67
153
  loop do
68
- sleep @rulesets_sync_interval
154
+ sleep @options.rulesets_sync_interval
69
155
  download_config_specs
70
156
  end
71
157
  end
@@ -80,55 +166,102 @@ module Statsig
80
166
  end
81
167
  end
82
168
 
83
- def download_config_specs
169
+ def download_config_specs(init_diagnostics = nil)
170
+ init_diagnostics&.mark("download_config_specs", "start", "network_request")
171
+
172
+ error = nil
84
173
  begin
85
- response, e = @network.post_helper('download_config_specs', JSON.generate({'sinceTime' => @last_sync_time}))
174
+ response, e = @network.post_helper('download_config_specs', JSON.generate({ 'sinceTime' => @last_config_sync_time }))
175
+ code = response&.status.to_i
176
+ if e.is_a? NetworkError
177
+ code = e.http_code
178
+ end
179
+ init_diagnostics&.mark("download_config_specs", "end", "network_request", code)
180
+
86
181
  if e.nil?
87
- process(JSON.parse(response.body))
182
+ unless response.nil?
183
+ init_diagnostics&.mark("download_config_specs", "start", "process")
184
+
185
+ if process(response.body)
186
+ @init_reason = EvaluationReason::NETWORK
187
+ @rules_updated_callback.call(response.body.to_s, @last_config_sync_time) unless response.body.nil? or @rules_updated_callback.nil?
188
+ end
189
+
190
+ init_diagnostics&.mark("download_config_specs", "end", "process", @init_reason == EvaluationReason::NETWORK)
191
+ end
192
+
193
+ nil
88
194
  else
89
- e
195
+ error = e
90
196
  end
91
197
  rescue StandardError => e
92
- e
198
+ error = e
93
199
  end
200
+
201
+ @error_callback.call(error) unless error.nil? or @error_callback.nil?
94
202
  end
95
203
 
96
- def process(specs_json)
97
- if specs_json.nil?
98
- return
204
+ def process(specs_string, from_adapter = false)
205
+ if specs_string.nil?
206
+ return false
99
207
  end
100
208
 
101
- @last_sync_time = specs_json['time'] || @last_sync_time
102
- return unless specs_json['has_updates'] == true &&
209
+ specs_json = JSON.parse(specs_string)
210
+ return false unless specs_json.is_a? Hash
211
+
212
+ @last_config_sync_time = specs_json['time'] || @last_config_sync_time
213
+ return false unless specs_json['has_updates'] == true &&
103
214
  !specs_json['feature_gates'].nil? &&
104
215
  !specs_json['dynamic_configs'].nil? &&
105
- !specs_json['layer_configs'].nil?
216
+ !specs_json['layer_configs'].nil?
106
217
 
107
218
  new_gates = {}
108
219
  new_configs = {}
109
220
  new_layers = {}
221
+ new_exp_to_layer = {}
222
+
223
+ specs_json['feature_gates'].each { |gate| new_gates[gate['name']] = gate }
224
+ specs_json['dynamic_configs'].each { |config| new_configs[config['name']] = config }
225
+ specs_json['layer_configs'].each { |layer| new_layers[layer['name']] = layer }
226
+
227
+ if specs_json['layers'].is_a?(Hash)
228
+ specs_json['layers'].each { |layer_name, experiments|
229
+ experiments.each { |experiment_name| new_exp_to_layer[experiment_name] = layer_name }
230
+ }
231
+ end
110
232
 
111
- specs_json['feature_gates'].map{|gate| new_gates[gate['name']] = gate }
112
- specs_json['dynamic_configs'].map{|config| new_configs[config['name']] = config }
113
- specs_json['layer_configs'].map{|layer| new_layers[layer['name']] = layer }
114
- @store[:gates] = new_gates
115
- @store[:configs] = new_configs
116
- @store[:layers] = new_layers
233
+ @specs[:gates] = new_gates
234
+ @specs[:configs] = new_configs
235
+ @specs[:layers] = new_layers
236
+ @specs[:experiment_to_layer] = new_exp_to_layer
237
+
238
+ unless from_adapter
239
+ save_to_storage_adapter(specs_string)
240
+ end
241
+ true
117
242
  end
118
243
 
119
- def get_id_lists
120
- response, e = @network.post_helper('get_id_lists', JSON.generate({'statsigMetadata' => Statsig.get_statsig_metadata}))
244
+ def get_id_lists(init_diagnostics = nil)
245
+ init_diagnostics&.mark("get_id_lists", "start", "network_request")
246
+ response, e = @network.post_helper('get_id_lists', JSON.generate({ 'statsigMetadata' => Statsig.get_statsig_metadata }))
121
247
  if !e.nil? || response.nil?
122
248
  return
123
249
  end
250
+ init_diagnostics&.mark("get_id_lists", "end", "network_request", response.status.to_i)
124
251
 
125
252
  begin
126
253
  server_id_lists = JSON.parse(response)
127
- local_id_lists = @store[:id_lists]
254
+ local_id_lists = @specs[:id_lists]
128
255
  if !server_id_lists.is_a?(Hash) || !local_id_lists.is_a?(Hash)
129
256
  return
130
257
  end
131
- threads = []
258
+ tasks = []
259
+
260
+ if server_id_lists.length == 0
261
+ return
262
+ end
263
+
264
+ init_diagnostics&.mark("get_id_lists", "start", "process", server_id_lists.length)
132
265
 
133
266
  server_id_lists.each do |list_name, list|
134
267
  server_list = IDList.new(list)
@@ -157,11 +290,17 @@ module Statsig
157
290
  next
158
291
  end
159
292
 
160
- threads << Thread.new do
293
+ tasks << Concurrent::Promise.execute(:executor => @id_list_thread_pool) do
161
294
  download_single_id_list(local_list)
162
295
  end
163
296
  end
164
- threads.each(&:join)
297
+
298
+ result = Concurrent::Promise.all?(*tasks).execute.wait(@id_lists_sync_interval)
299
+ if result.state != :fulfilled
300
+ init_diagnostics&.mark("get_id_lists", "end", "process", false)
301
+ return # timed out
302
+ end
303
+
165
304
  delete_lists = []
166
305
  local_id_lists.each do |list_name, list|
167
306
  unless server_id_lists.key? list_name
@@ -171,6 +310,7 @@ module Statsig
171
310
  delete_lists.each do |list_name|
172
311
  local_id_lists.delete list_name
173
312
  end
313
+ init_diagnostics&.mark("get_id_lists", "end", "process", true)
174
314
  rescue
175
315
  # Ignored, will try again
176
316
  end
@@ -178,7 +318,7 @@ module Statsig
178
318
 
179
319
  def download_single_id_list(list)
180
320
  nil unless list.is_a? IDList
181
- http = HTTP.headers({'Range' => "bytes=#{list&.size || 0}-"}).accept(:json)
321
+ http = HTTP.headers({ 'Range' => "bytes=#{list&.size || 0}-" }).accept(:json)
182
322
  begin
183
323
  res = http.get(list.url)
184
324
  nil unless res.status.success?
@@ -186,7 +326,7 @@ module Statsig
186
326
  nil if content_length.nil? || content_length <= 0
187
327
  content = res.body.to_s
188
328
  unless content.is_a?(String) && (content[0] == '-' || content[0] == '+')
189
- @store[:id_lists].delete(list.name)
329
+ @specs[:id_lists].delete(list.name)
190
330
  return
191
331
  end
192
332
  ids_clone = list.ids # clone the list, operate on the new list, and swap out the old list, so the operation is thread-safe
@@ -195,7 +335,7 @@ module Statsig
195
335
  line = li.strip
196
336
  next if line.length <= 1
197
337
  op = line[0]
198
- id = line[1..]
338
+ id = line[1..line.length]
199
339
  if op == '+'
200
340
  ids_clone.add(id)
201
341
  elsif op == '-'