statsig 1.30.0 → 1.32.0

Sign up to get free protection for your applications and to get access to all the features.
data/lib/spec_store.rb CHANGED
@@ -1,17 +1,24 @@
1
- # typed: false
2
1
  require 'net/http'
3
2
  require 'uri'
4
3
  require 'evaluation_details'
5
4
  require 'id_list'
6
5
  require 'concurrent-ruby'
7
6
  require 'hash_utils'
7
+ require 'api_config'
8
8
 
9
9
  module Statsig
10
10
  class SpecStore
11
-
12
11
  attr_accessor :last_config_sync_time
13
12
  attr_accessor :initial_config_sync_time
14
13
  attr_accessor :init_reason
14
+ attr_accessor :gates
15
+ attr_accessor :configs
16
+ attr_accessor :layers
17
+ attr_accessor :id_lists
18
+ attr_accessor :experiment_to_layer
19
+ attr_accessor :sdk_keys_to_app_ids
20
+ attr_accessor :hashed_sdk_keys_to_app_ids
21
+ attr_accessor :unsupported_configs
15
22
 
16
23
  def initialize(network, options, error_callback, diagnostics, error_boundary, logger, secret_key)
17
24
  @init_reason = EvaluationReason::UNINITIALIZED
@@ -23,37 +30,36 @@ module Statsig
23
30
  @rulesets_sync_interval = options.rulesets_sync_interval
24
31
  @id_lists_sync_interval = options.idlists_sync_interval
25
32
  @rules_updated_callback = options.rules_updated_callback
26
- @specs = {
27
- :gates => {},
28
- :configs => {},
29
- :layers => {},
30
- :id_lists => {},
31
- :experiment_to_layer => {},
32
- :sdk_keys_to_app_ids => {},
33
- :hashed_sdk_keys_to_app_ids => {}
34
- }
33
+ @gates = {}
34
+ @configs = {}
35
+ @layers = {}
36
+ @id_lists = {}
37
+ @experiment_to_layer = {}
38
+ @sdk_keys_to_app_ids = {}
39
+ @hashed_sdk_keys_to_app_ids = {}
35
40
  @diagnostics = diagnostics
36
41
  @error_boundary = error_boundary
37
42
  @logger = logger
38
43
  @secret_key = secret_key
44
+ @unsupported_configs = Set.new
39
45
 
40
46
  @id_list_thread_pool = Concurrent::FixedThreadPool.new(
41
47
  options.idlist_threadpool_size,
42
48
  name: 'statsig-idlist',
43
49
  max_queue: 100,
44
- fallback_policy: :discard,
50
+ fallback_policy: :discard
45
51
  )
46
52
 
47
53
  unless @options.bootstrap_values.nil?
48
54
  if !@options.data_store.nil?
49
55
  puts 'data_store gets priority over bootstrap_values. bootstrap_values will be ignored'
50
56
  else
51
- tracker = @diagnostics.track('bootstrap', 'process')
57
+ tracker = @diagnostics.track('initialize','bootstrap', 'process')
52
58
  begin
53
59
  if process_specs(options.bootstrap_values)
54
60
  @init_reason = EvaluationReason::BOOTSTRAP
55
61
  end
56
- rescue
62
+ rescue StandardError
57
63
  puts 'the provided bootstrapValues is not a valid JSON string'
58
64
  ensure
59
65
  tracker.end(success: @init_reason == EvaluationReason::BOOTSTRAP)
@@ -63,18 +69,18 @@ module Statsig
63
69
 
64
70
  unless @options.data_store.nil?
65
71
  @options.data_store.init
66
- load_config_specs_from_storage_adapter
72
+ load_config_specs_from_storage_adapter('initialize')
67
73
  end
68
74
 
69
75
  if @init_reason == EvaluationReason::UNINITIALIZED
70
- download_config_specs
76
+ download_config_specs('initialize')
71
77
  end
72
78
 
73
79
  @initial_config_sync_time = @last_config_sync_time == 0 ? -1 : @last_config_sync_time
74
80
  if !@options.data_store.nil?
75
- get_id_lists_from_adapter
81
+ get_id_lists_from_adapter('initialize')
76
82
  else
77
- get_id_lists_from_network
83
+ get_id_lists_from_network('initialize')
78
84
  end
79
85
 
80
86
  @config_sync_thread = spawn_sync_config_specs_thread
@@ -96,70 +102,61 @@ module Statsig
96
102
  end
97
103
 
98
104
  def has_gate?(gate_name)
99
- @specs[:gates].key?(gate_name)
105
+ @gates.key?(gate_name)
100
106
  end
101
107
 
102
108
  def has_config?(config_name)
103
- @specs[:configs].key?(config_name)
109
+ @configs.key?(config_name)
104
110
  end
105
111
 
106
112
  def has_layer?(layer_name)
107
- @specs[:layers].key?(layer_name)
113
+ @layers.key?(layer_name)
108
114
  end
109
115
 
110
116
  def get_gate(gate_name)
111
117
  return nil unless has_gate?(gate_name)
112
- @specs[:gates][gate_name]
118
+
119
+ @gates[gate_name]
113
120
  end
114
121
 
115
122
  def get_config(config_name)
116
123
  return nil unless has_config?(config_name)
117
- @specs[:configs][config_name]
124
+
125
+ @configs[config_name]
118
126
  end
119
127
 
120
128
  def get_layer(layer_name)
121
129
  return nil unless has_layer?(layer_name)
122
- @specs[:layers][layer_name]
123
- end
124
130
 
125
- def gates
126
- @specs[:gates]
127
- end
128
-
129
- def configs
130
- @specs[:configs]
131
- end
132
-
133
- def layers
134
- @specs[:layers]
131
+ @layers[layer_name]
135
132
  end
136
133
 
137
134
  def get_id_list(list_name)
138
- @specs[:id_lists][list_name]
135
+ @id_lists[list_name]
139
136
  end
140
137
 
141
138
  def has_sdk_key?(sdk_key)
142
- @specs[:sdk_keys_to_app_ids].key?(sdk_key)
139
+ @sdk_keys_to_app_ids.key?(sdk_key)
143
140
  end
144
141
 
145
142
  def has_hashed_sdk_key?(hashed_sdk_key)
146
- @specs[:hashed_sdk_keys_to_app_ids].key?(hashed_sdk_key)
143
+ @hashed_sdk_keys_to_app_ids.key?(hashed_sdk_key)
147
144
  end
148
145
 
149
146
  def get_app_id_for_sdk_key(sdk_key)
150
147
  if sdk_key.nil?
151
148
  return nil
152
149
  end
153
- hashed_sdk_key = Statsig::HashUtils.djb2(sdk_key)
150
+
151
+ hashed_sdk_key = Statsig::HashUtils.djb2(sdk_key).to_sym
154
152
  if has_hashed_sdk_key?(hashed_sdk_key)
155
- return @specs[:hashed_sdk_keys_to_app_ids][hashed_sdk_key]
153
+ return @hashed_sdk_keys_to_app_ids[hashed_sdk_key]
156
154
  end
157
- return nil unless has_sdk_key?(sdk_key)
158
- @specs[:sdk_keys_to_app_ids][sdk_key]
159
- end
160
155
 
161
- def get_raw_specs
162
- @specs
156
+ key = sdk_key.to_sym
157
+ return nil unless has_sdk_key?(key)
158
+
159
+ @sdk_keys_to_app_ids[key]
163
160
  end
164
161
 
165
162
  def maybe_restart_background_threads
@@ -172,47 +169,46 @@ module Statsig
172
169
  end
173
170
 
174
171
  def sync_config_specs
175
- @diagnostics.context = 'config_sync'
176
172
  if @options.data_store&.should_be_used_for_querying_updates(Interfaces::IDataStore::CONFIG_SPECS_KEY)
177
- load_config_specs_from_storage_adapter
173
+ load_config_specs_from_storage_adapter('config_sync')
178
174
  else
179
- download_config_specs
175
+ download_config_specs('config_sync')
180
176
  end
181
- @logger.log_diagnostics_event(@diagnostics)
177
+ @logger.log_diagnostics_event(@diagnostics, 'config_sync')
182
178
  end
183
179
 
184
180
  def sync_id_lists
185
- @diagnostics.context = 'config_sync'
186
181
  if @options.data_store&.should_be_used_for_querying_updates(Interfaces::IDataStore::ID_LISTS_KEY)
187
- get_id_lists_from_adapter
182
+ get_id_lists_from_adapter('config_sync')
188
183
  else
189
- get_id_lists_from_network
184
+ get_id_lists_from_network('config_sync')
190
185
  end
191
- @logger.log_diagnostics_event(@diagnostics)
186
+ @logger.log_diagnostics_event(@diagnostics, 'config_sync')
192
187
  end
193
188
 
194
189
  private
195
190
 
196
- def load_config_specs_from_storage_adapter
197
- tracker = @diagnostics.track('data_store_config_specs', 'fetch')
191
+ def load_config_specs_from_storage_adapter(context)
192
+ tracker = @diagnostics.track(context, 'data_store_config_specs', 'fetch')
198
193
  cached_values = @options.data_store.get(Interfaces::IDataStore::CONFIG_SPECS_KEY)
199
194
  tracker.end(success: true)
200
195
  return if cached_values.nil?
201
196
 
202
- tracker = @diagnostics.track('data_store_config_specs', 'process')
197
+ tracker = @diagnostics.track(context, 'data_store_config_specs', 'process')
203
198
  process_specs(cached_values, from_adapter: true)
204
199
  @init_reason = EvaluationReason::DATA_ADAPTER
205
200
  tracker.end(success: true)
206
201
  rescue StandardError
207
202
  # Fallback to network
208
203
  tracker.end(success: false)
209
- download_config_specs
204
+ download_config_specs(context)
210
205
  end
211
206
 
212
207
  def save_config_specs_to_storage_adapter(specs_string)
213
208
  if @options.data_store.nil?
214
209
  return
215
210
  end
211
+
216
212
  @options.data_store.set(Interfaces::IDataStore::CONFIG_SPECS_KEY, specs_string)
217
213
  end
218
214
 
@@ -246,8 +242,8 @@ module Statsig
246
242
  end
247
243
  end
248
244
 
249
- def download_config_specs
250
- tracker = @diagnostics.track('download_config_specs', 'network_request')
245
+ def download_config_specs(context)
246
+ tracker = @diagnostics.track(context, 'download_config_specs', 'network_request')
251
247
 
252
248
  error = nil
253
249
  begin
@@ -260,14 +256,16 @@ module Statsig
260
256
 
261
257
  if e.nil?
262
258
  unless response.nil?
263
- tracker = @diagnostics.track('download_config_specs', 'process')
264
-
259
+ tracker = @diagnostics.track(context, 'download_config_specs', 'process')
265
260
  if process_specs(response.body.to_s)
266
261
  @init_reason = EvaluationReason::NETWORK
267
262
  end
268
263
  tracker.end(success: @init_reason == EvaluationReason::NETWORK)
269
264
 
270
- @rules_updated_callback.call(response.body.to_s, @last_config_sync_time) unless response.body.nil? or @rules_updated_callback.nil?
265
+ unless response.body.nil? or @rules_updated_callback.nil?
266
+ @rules_updated_callback.call(response.body.to_s,
267
+ @last_config_sync_time)
268
+ end
271
269
  end
272
270
 
273
271
  nil
@@ -286,43 +284,41 @@ module Statsig
286
284
  return false
287
285
  end
288
286
 
289
- specs_json = JSON.parse(specs_string)
287
+ specs_json = JSON.parse(specs_string, { symbolize_names: true })
290
288
  return false unless specs_json.is_a? Hash
291
289
 
292
- hashed_sdk_key_used = specs_json['hashed_sdk_key_used']
290
+ hashed_sdk_key_used = specs_json[:hashed_sdk_key_used]
293
291
  unless hashed_sdk_key_used.nil? or hashed_sdk_key_used == Statsig::HashUtils.djb2(@secret_key)
294
292
  err_boundary.log_exception(Statsig::InvalidSDKKeyResponse.new)
295
293
  return false
296
294
  end
297
295
 
298
- @last_config_sync_time = specs_json['time'] || @last_config_sync_time
299
- return false unless specs_json['has_updates'] == true &&
300
- !specs_json['feature_gates'].nil? &&
301
- !specs_json['dynamic_configs'].nil? &&
302
- !specs_json['layer_configs'].nil?
296
+ @last_config_sync_time = specs_json[:time] || @last_config_sync_time
297
+ return false unless specs_json[:has_updates] == true &&
298
+ !specs_json[:feature_gates].nil? &&
299
+ !specs_json[:dynamic_configs].nil? &&
300
+ !specs_json[:layer_configs].nil?
303
301
 
304
- new_gates = {}
305
- new_configs = {}
306
- new_layers = {}
307
- new_exp_to_layer = {}
302
+ @unsupported_configs.clear()
303
+ new_gates = process_configs(specs_json[:feature_gates])
304
+ new_configs = process_configs(specs_json[:dynamic_configs])
305
+ new_layers = process_configs(specs_json[:layer_configs])
308
306
 
309
- specs_json['feature_gates'].each { |gate| new_gates[gate['name']] = gate }
310
- specs_json['dynamic_configs'].each { |config| new_configs[config['name']] = config }
311
- specs_json['layer_configs'].each { |layer| new_layers[layer['name']] = layer }
312
- specs_json['diagnostics']&.each { |key, value| @diagnostics.sample_rates[key] = value }
307
+ new_exp_to_layer = {}
308
+ specs_json[:diagnostics]&.each { |key, value| @diagnostics.sample_rates[key.to_s] = value }
313
309
 
314
- if specs_json['layers'].is_a?(Hash)
315
- specs_json['layers'].each { |layer_name, experiments|
310
+ if specs_json[:layers].is_a?(Hash)
311
+ specs_json[:layers].each do |layer_name, experiments|
316
312
  experiments.each { |experiment_name| new_exp_to_layer[experiment_name] = layer_name }
317
- }
313
+ end
318
314
  end
319
315
 
320
- @specs[:gates] = new_gates
321
- @specs[:configs] = new_configs
322
- @specs[:layers] = new_layers
323
- @specs[:experiment_to_layer] = new_exp_to_layer
324
- @specs[:sdk_keys_to_app_ids] = specs_json['sdk_keys_to_app_ids'] || {}
325
- @specs[:hashed_sdk_keys_to_app_ids] = specs_json['hashed_sdk_keys_to_app_ids'] || {}
316
+ @gates = new_gates
317
+ @configs = new_configs
318
+ @layers = new_layers
319
+ @experiment_to_layer = new_exp_to_layer
320
+ @sdk_keys_to_app_ids = specs_json[:sdk_keys_to_app_ids] || {}
321
+ @hashed_sdk_keys_to_app_ids = specs_json[:hashed_sdk_keys_to_app_ids] || {}
326
322
 
327
323
  unless from_adapter
328
324
  save_config_specs_to_storage_adapter(specs_string)
@@ -330,29 +326,41 @@ module Statsig
330
326
  true
331
327
  end
332
328
 
333
- def get_id_lists_from_adapter
334
- tracker = @diagnostics.track('data_store_id_lists', 'fetch')
329
+ def process_configs(configs)
330
+ configs.each_with_object({}) do |config, new_configs|
331
+ begin
332
+ new_configs[config[:name]] = APIConfig.from_json(config)
333
+ rescue UnsupportedConfigException => e
334
+ @unsupported_configs.add(config[:name])
335
+ nil
336
+ end
337
+ end
338
+ end
339
+
340
+ def get_id_lists_from_adapter(context)
341
+ tracker = @diagnostics.track(context, 'data_store_id_lists', 'fetch')
335
342
  cached_values = @options.data_store.get(Interfaces::IDataStore::ID_LISTS_KEY)
336
343
  return if cached_values.nil?
337
344
 
338
345
  tracker.end(success: true)
339
346
  id_lists = JSON.parse(cached_values)
340
- process_id_lists(id_lists, from_adapter: true)
347
+ process_id_lists(id_lists, context, from_adapter: true)
341
348
  rescue StandardError
342
349
  # Fallback to network
343
350
  tracker.end(success: false)
344
- get_id_lists_from_network
351
+ get_id_lists_from_network(context)
345
352
  end
346
353
 
347
354
  def save_id_lists_to_adapter(id_lists_raw_json)
348
355
  if @options.data_store.nil?
349
356
  return
350
357
  end
358
+
351
359
  @options.data_store.set(Interfaces::IDataStore::ID_LISTS_KEY, id_lists_raw_json)
352
360
  end
353
361
 
354
- def get_id_lists_from_network
355
- tracker = @diagnostics.track('get_id_list_sources', 'network_request')
362
+ def get_id_lists_from_network(context)
363
+ tracker = @diagnostics.track(context, 'get_id_list_sources', 'network_request')
356
364
  response, e = @network.post('get_id_lists', JSON.generate({ 'statsigMetadata' => Statsig.get_statsig_metadata }))
357
365
  code = response&.status.to_i
358
366
  if e.is_a? NetworkError
@@ -360,27 +368,28 @@ module Statsig
360
368
  end
361
369
  success = e.nil? && !response.nil?
362
370
  tracker.end(statusCode: code, success: success, sdkRegion: response&.headers&.[]('X-Statsig-Region'))
363
- if !success
371
+ unless success
364
372
  return
365
373
  end
366
374
 
367
375
  begin
368
376
  server_id_lists = JSON.parse(response)
369
- process_id_lists(server_id_lists)
377
+ process_id_lists(server_id_lists, context)
370
378
  save_id_lists_to_adapter(response.body.to_s)
371
- rescue
379
+ rescue StandardError
372
380
  # Ignored, will try again
373
381
  end
374
382
  end
375
383
 
376
- def process_id_lists(new_id_lists, from_adapter: false)
377
- local_id_lists = @specs[:id_lists]
384
+ def process_id_lists(new_id_lists, context, from_adapter: false)
385
+ local_id_lists = @id_lists
378
386
  if !new_id_lists.is_a?(Hash) || !local_id_lists.is_a?(Hash)
379
387
  return
380
388
  end
389
+
381
390
  tasks = []
382
391
 
383
- tracker = @diagnostics.track(
392
+ tracker = @diagnostics.track(context,
384
393
  from_adapter ? 'data_store_id_lists' : 'get_id_list_sources',
385
394
  'process',
386
395
  { idListCount: new_id_lists.length }
@@ -392,7 +401,7 @@ module Statsig
392
401
  end
393
402
 
394
403
  delete_lists = []
395
- local_id_lists.each do |list_name, list|
404
+ local_id_lists.each do |list_name, _list|
396
405
  unless new_id_lists.key? list_name
397
406
  delete_lists.push list_name
398
407
  end
@@ -428,11 +437,11 @@ module Statsig
428
437
  next
429
438
  end
430
439
 
431
- tasks << Concurrent::Promise.execute(:executor => @id_list_thread_pool) do
440
+ tasks << Concurrent::Promise.execute(executor: @id_list_thread_pool) do
432
441
  if from_adapter
433
- get_single_id_list_from_adapter(local_list)
442
+ get_single_id_list_from_adapter(local_list, context)
434
443
  else
435
- download_single_id_list(local_list)
444
+ download_single_id_list(local_list, context)
436
445
  end
437
446
  end
438
447
  end
@@ -441,12 +450,12 @@ module Statsig
441
450
  tracker.end(success: result.state == :fulfilled)
442
451
  end
443
452
 
444
- def get_single_id_list_from_adapter(list)
445
- tracker = @diagnostics.track('data_store_id_list', 'fetch', { url: list.url })
453
+ def get_single_id_list_from_adapter(list, context)
454
+ tracker = @diagnostics.track(context, 'data_store_id_list', 'fetch', { url: list.url })
446
455
  cached_values = @options.data_store.get("#{Interfaces::IDataStore::ID_LISTS_KEY}::#{list.name}")
447
456
  tracker.end(success: true)
448
457
  content = cached_values.to_s
449
- process_single_id_list(list, content, from_adapter: true)
458
+ process_single_id_list(list, context, content, from_adapter: true)
450
459
  rescue StandardError
451
460
  tracker.end(success: false)
452
461
  nil
@@ -458,10 +467,10 @@ module Statsig
458
467
  @options.data_store.set("#{Interfaces::IDataStore::ID_LISTS_KEY}::#{name}", content)
459
468
  end
460
469
 
461
- def download_single_id_list(list)
470
+ def download_single_id_list(list, context)
462
471
  nil unless list.is_a? IDList
463
472
  http = HTTP.headers({ 'Range' => "bytes=#{list&.size || 0}-" }).accept(:json)
464
- tracker = @diagnostics.track('get_id_list', 'network_request', { url: list.url })
473
+ tracker = @diagnostics.track(context, 'get_id_list', 'network_request', { url: list.url })
465
474
  begin
466
475
  res = http.get(list.url)
467
476
  tracker.end(statusCode: res.status.code, success: res.status.success?)
@@ -469,20 +478,20 @@ module Statsig
469
478
  content_length = Integer(res['content-length'])
470
479
  nil if content_length.nil? || content_length <= 0
471
480
  content = res.body.to_s
472
- success = process_single_id_list(list, content, content_length)
481
+ success = process_single_id_list(list, context, content, content_length)
473
482
  save_single_id_list_to_adapter(list.name, content) unless success.nil? || !success
474
- rescue
483
+ rescue StandardError
475
484
  tracker.end(success: false)
476
485
  nil
477
486
  end
478
487
  end
479
488
 
480
- def process_single_id_list(list, content, content_length = nil, from_adapter: false)
489
+ def process_single_id_list(list, context, content, content_length = nil, from_adapter: false)
481
490
  false unless list.is_a? IDList
482
491
  begin
483
- tracker = @diagnostics.track(from_adapter ? 'data_store_id_list' : 'get_id_list', 'process', { url: list.url })
492
+ tracker = @diagnostics.track(context, from_adapter ? 'data_store_id_list' : 'get_id_list', 'process', { url: list.url })
484
493
  unless content.is_a?(String) && (content[0] == '-' || content[0] == '+')
485
- @specs[:id_lists].delete(list.name)
494
+ @id_lists.delete(list.name)
486
495
  tracker.end(success: false)
487
496
  return false
488
497
  end
@@ -491,6 +500,7 @@ module Statsig
491
500
  lines.each do |li|
492
501
  line = li.strip
493
502
  next if line.length <= 1
503
+
494
504
  op = line[0]
495
505
  id = line[1..line.length]
496
506
  if op == '+'
@@ -507,11 +517,10 @@ module Statsig
507
517
  end
508
518
  tracker.end(success: true)
509
519
  return true
510
- rescue
520
+ rescue StandardError
511
521
  tracker.end(success: false)
512
522
  return false
513
523
  end
514
524
  end
515
-
516
525
  end
517
- end
526
+ end