statsig 1.10.0 → 1.20.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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 == '-'