statsig 2.0.1 → 2.8.1

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/network.rb CHANGED
@@ -1,10 +1,9 @@
1
+ require 'connection_pool'
1
2
  require 'http'
2
3
  require 'json'
3
4
  require 'securerandom'
4
5
  require 'zlib'
5
6
 
6
- require 'connection_pool'
7
-
8
7
  RETRY_CODES = [408, 500, 502, 503, 504, 522, 524, 599].freeze
9
8
 
10
9
  module Statsig
@@ -53,7 +52,11 @@ module Statsig
53
52
 
54
53
  def download_config_specs(since_time)
55
54
  url = @options.download_config_specs_url
56
- get("#{url}#{@server_secret}.json?sinceTime=#{since_time}")
55
+ dcs_url = "#{url}#{@server_secret}.json"
56
+ if since_time.positive?
57
+ dcs_url += "?sinceTime=#{since_time}"
58
+ end
59
+ get(dcs_url)
57
60
  end
58
61
 
59
62
  def post_logs(events, error_boundary)
@@ -64,6 +67,10 @@ module Statsig
64
67
  gzip << json_body
65
68
 
66
69
  response, e = post(url, gzip.close.string, @post_logs_retry_limit, 1, true, event_count)
70
+
71
+ # Consume response body to ensure connection can be closed.
72
+ response&.flush
73
+
67
74
  unless e == nil
68
75
  message = "Failed to log #{event_count} events after #{@post_logs_retry_limit} retries"
69
76
  puts "[Statsig]: #{message}"
@@ -0,0 +1,37 @@
1
+ require 'concurrent-ruby'
2
+
3
+ module Statsig
4
+ class SDKConfigs
5
+ def initialize
6
+ @configs = Concurrent::Hash.new
7
+ @flags = Concurrent::Hash.new
8
+ end
9
+
10
+ def set_flags(new_flags)
11
+ @flags = new_flags || Concurrent::Hash.new
12
+ end
13
+
14
+ def set_configs(new_configs)
15
+ @configs = new_configs || Concurrent::Hash.new
16
+ end
17
+
18
+ def on(flag)
19
+ @flags[flag.to_sym] == true
20
+ end
21
+
22
+ def get_config_num_value(config)
23
+ value = @configs[config.to_sym]
24
+ value.is_a?(Numeric) ? value.to_f : nil
25
+ end
26
+
27
+ def get_config_string_value(config)
28
+ value = @configs[config.to_sym]
29
+ value.is_a?(String) ? value : nil
30
+ end
31
+
32
+ def get_config_int_value(config)
33
+ value = @configs[config.to_sym]
34
+ value.is_a?(Integer) ? value : nil
35
+ end
36
+ end
37
+ end
data/lib/spec_store.rb CHANGED
@@ -1,10 +1,10 @@
1
+ require 'concurrent-ruby'
1
2
  require 'net/http'
2
3
  require 'uri'
3
- require 'evaluation_details'
4
- require 'id_list'
5
- require 'concurrent-ruby'
6
- require 'hash_utils'
7
- require 'api_config'
4
+ require_relative 'api_config'
5
+ require_relative 'evaluation_details'
6
+ require_relative 'hash_utils'
7
+ require_relative 'id_list'
8
8
 
9
9
  module Statsig
10
10
  class SpecStore
@@ -19,8 +19,11 @@ module Statsig
19
19
  attr_accessor :sdk_keys_to_app_ids
20
20
  attr_accessor :hashed_sdk_keys_to_app_ids
21
21
  attr_accessor :unsupported_configs
22
+ attr_accessor :cmab_configs
23
+ attr_accessor :overrides
24
+ attr_accessor :override_rules
22
25
 
23
- def initialize(network, options, error_callback, diagnostics, error_boundary, logger, secret_key)
26
+ def initialize(network, options, error_callback, diagnostics, error_boundary, logger, secret_key, sdk_config)
24
27
  @init_reason = EvaluationReason::UNINITIALIZED
25
28
  @network = network
26
29
  @options = options
@@ -33,16 +36,22 @@ module Statsig
33
36
  @gates = {}
34
37
  @configs = {}
35
38
  @layers = {}
39
+ @cmab_configs = {}
36
40
  @condition_map = {}
37
41
  @id_lists = {}
38
42
  @experiment_to_layer = {}
39
43
  @sdk_keys_to_app_ids = {}
40
44
  @hashed_sdk_keys_to_app_ids = {}
45
+ @overrides = {}
46
+ @override_rules = {}
41
47
  @diagnostics = diagnostics
42
48
  @error_boundary = error_boundary
43
49
  @logger = logger
44
50
  @secret_key = secret_key
45
51
  @unsupported_configs = Set.new
52
+ @sdk_configs = sdk_config
53
+
54
+ startTime = (Time.now.to_f * 1000).to_i
46
55
 
47
56
  @id_list_thread_pool = Concurrent::FixedThreadPool.new(
48
57
  options.idlist_threadpool_size,
@@ -57,7 +66,7 @@ module Statsig
57
66
  else
58
67
  tracker = @diagnostics.track('initialize','bootstrap', 'process')
59
68
  begin
60
- if process_specs(options.bootstrap_values)
69
+ if process_specs(options.bootstrap_values).nil?
61
70
  @init_reason = EvaluationReason::BOOTSTRAP
62
71
  end
63
72
  rescue StandardError
@@ -68,13 +77,15 @@ module Statsig
68
77
  end
69
78
  end
70
79
 
80
+ failure_details = nil
81
+
71
82
  unless @options.data_store.nil?
72
83
  @options.data_store.init
73
- load_config_specs_from_storage_adapter('initialize')
84
+ failure_details = load_config_specs_from_storage_adapter('initialize')
74
85
  end
75
86
 
76
87
  if @init_reason == EvaluationReason::UNINITIALIZED
77
- download_config_specs('initialize')
88
+ failure_details = download_config_specs('initialize')
78
89
  end
79
90
 
80
91
  @initial_config_sync_time = @last_config_sync_time == 0 ? -1 : @last_config_sync_time
@@ -86,12 +97,18 @@ module Statsig
86
97
 
87
98
  @config_sync_thread = spawn_sync_config_specs_thread
88
99
  @id_lists_sync_thread = spawn_sync_id_lists_thread
100
+ endTime = (Time.now.to_f * 1000).to_i
101
+ @initialization_details = {duration: endTime - startTime, isSDKReady: true, configSpecReady: @init_reason != EvaluationReason::UNINITIALIZED, failureDetails: failure_details}
89
102
  end
90
103
 
91
104
  def is_ready_for_checks
92
105
  @last_config_sync_time != 0
93
106
  end
94
107
 
108
+ def get_initialization_details
109
+ @initialization_details
110
+ end
111
+
95
112
  def shutdown
96
113
  @config_sync_thread&.exit
97
114
  @id_lists_sync_thread&.exit
@@ -114,6 +131,13 @@ module Statsig
114
131
  @layers.key?(layer_name.to_sym)
115
132
  end
116
133
 
134
+ def has_cmab_config?(config_name)
135
+ if @cmab_configs.nil?
136
+ return false
137
+ end
138
+ @cmab_configs.key?(config_name.to_sym)
139
+ end
140
+
117
141
  def get_gate(gate_name)
118
142
  gate_sym = gate_name.to_sym
119
143
  return nil unless has_gate?(gate_sym)
@@ -134,6 +158,12 @@ module Statsig
134
158
  @layers[layer_sym]
135
159
  end
136
160
 
161
+ def get_cmab_config(config_name)
162
+ config_sym = config_name.to_sym
163
+ return nil unless has_cmab_config?(config_sym)
164
+ @cmab_configs[config_sym]
165
+ end
166
+
137
167
  def get_condition(condition_hash)
138
168
  @condition_map[condition_hash.to_sym]
139
169
  end
@@ -202,13 +232,19 @@ module Statsig
202
232
  return if cached_values.nil?
203
233
 
204
234
  tracker = @diagnostics.track(context, 'data_store_config_specs', 'process')
205
- process_specs(cached_values, from_adapter: true)
206
- @init_reason = EvaluationReason::DATA_ADAPTER
207
- tracker.end(success: true)
235
+ failure_details = process_specs(cached_values, from_adapter: true)
236
+ if failure_details.nil?
237
+ @init_reason = EvaluationReason::DATA_ADAPTER
238
+ tracker.end(success: true)
239
+ else
240
+ tracker.end(success: false)
241
+ return download_config_specs(context)
242
+ end
243
+ return failure_details
208
244
  rescue StandardError
209
245
  # Fallback to network
210
246
  tracker.end(success: false)
211
- download_config_specs(context)
247
+ return download_config_specs(context)
212
248
  end
213
249
 
214
250
  def save_rulesets_to_storage_adapter(rulesets_string)
@@ -253,18 +289,21 @@ module Statsig
253
289
  tracker = @diagnostics.track(context, 'download_config_specs', 'network_request')
254
290
 
255
291
  error = nil
292
+ failure_details = nil
256
293
  begin
257
294
  response, e = @network.download_config_specs(@last_config_sync_time)
258
295
  code = response&.status.to_i
259
296
  if e.is_a? NetworkError
260
297
  code = e.http_code
298
+ failure_details = {statusCode: code, exception: e, reason: "CONFIG_SPECS_NETWORK_ERROR"}
261
299
  end
262
300
  tracker.end(statusCode: code, success: e.nil?, sdkRegion: response&.headers&.[]('X-Statsig-Region'))
263
301
 
264
302
  if e.nil?
265
303
  unless response.nil?
266
304
  tracker = @diagnostics.track(context, 'download_config_specs', 'process')
267
- if process_specs(response.body.to_s)
305
+ failure_details = process_specs(response.body.to_s)
306
+ if failure_details.nil?
268
307
  @init_reason = EvaluationReason::NETWORK
269
308
  end
270
309
  tracker.end(success: @init_reason == EvaluationReason::NETWORK)
@@ -274,59 +313,68 @@ module Statsig
274
313
  @last_config_sync_time)
275
314
  end
276
315
  end
277
-
278
- nil
279
316
  else
280
317
  error = e
281
318
  end
282
319
  rescue StandardError => e
320
+ failure_details = {exception: e, reason: "INTERNAL_ERROR"}
283
321
  error = e
284
322
  end
285
323
 
286
324
  @error_callback.call(error) unless error.nil? or @error_callback.nil?
325
+ return failure_details
287
326
  end
288
327
 
289
328
  def process_specs(specs_string, from_adapter: false)
290
329
  if specs_string.nil?
291
- return false
292
- end
293
-
294
- specs_json = JSON.parse(specs_string, { symbolize_names: true })
295
- return false unless specs_json.is_a? Hash
296
-
297
- hashed_sdk_key_used = specs_json[:hashed_sdk_key_used]
298
- unless hashed_sdk_key_used.nil? or hashed_sdk_key_used == Statsig::HashUtils.djb2(@secret_key)
299
- err_boundary.log_exception(Statsig::InvalidSDKKeyResponse.new)
300
- return false
301
- end
302
-
303
- new_specs_sync_time = specs_json[:time]
304
- if new_specs_sync_time.nil? \
305
- || new_specs_sync_time < @last_config_sync_time \
306
- || specs_json[:has_updates] != true \
307
- || specs_json[:feature_gates].nil? \
308
- || specs_json[:dynamic_configs].nil? \
309
- || specs_json[:layer_configs].nil?
310
- return false
330
+ return {reason: "EMPTY_SPEC"}
311
331
  end
312
332
 
313
- @last_config_sync_time = new_specs_sync_time
314
- @unsupported_configs.clear
333
+ begin
334
+ specs_json = JSON.parse(specs_string, { symbolize_names: true })
335
+ return {reason: "PARSE_RESPONSE_ERROR"} unless specs_json.is_a? Hash
315
336
 
316
- specs_json[:diagnostics]&.each { |key, value| @diagnostics.sample_rates[key.to_s] = value }
337
+ hashed_sdk_key_used = specs_json[:hashed_sdk_key_used]
338
+ unless hashed_sdk_key_used.nil? or hashed_sdk_key_used == Statsig::HashUtils.djb2(@secret_key)
339
+ err_boundary.log_exception(Statsig::InvalidSDKKeyResponse.new)
340
+ return {reason: "PARSE_RESPONSE_ERROR"}
341
+ end
317
342
 
318
- @gates = specs_json[:feature_gates]
319
- @configs = specs_json[:dynamic_configs]
320
- @layers = specs_json[:layer_configs]
321
- @condition_map = specs_json[:condition_map]
322
- @experiment_to_layer = specs_json[:experiment_to_layer]
323
- @sdk_keys_to_app_ids = specs_json[:sdk_keys_to_app_ids] || {}
324
- @hashed_sdk_keys_to_app_ids = specs_json[:hashed_sdk_keys_to_app_ids] || {}
343
+ new_specs_sync_time = specs_json[:time]
344
+ if new_specs_sync_time.nil? \
345
+ || new_specs_sync_time < @last_config_sync_time \
346
+ || specs_json[:has_updates] != true \
347
+ || specs_json[:feature_gates].nil? \
348
+ || specs_json[:dynamic_configs].nil? \
349
+ || specs_json[:layer_configs].nil?
350
+ return {reason: "PARSE_RESPONSE_ERROR"}
351
+ end
325
352
 
326
- unless from_adapter
327
- save_rulesets_to_storage_adapter(specs_string)
353
+ @last_config_sync_time = new_specs_sync_time
354
+ @unsupported_configs.clear
355
+
356
+ specs_json[:diagnostics]&.each { |key, value| @diagnostics.sample_rates[key.to_s] = value }
357
+
358
+ @gates = specs_json[:feature_gates]
359
+ @configs = specs_json[:dynamic_configs]
360
+ @layers = specs_json[:layer_configs]
361
+ @cmab_configs = specs_json[:cmab_configs]
362
+ @condition_map = specs_json[:condition_map]
363
+ @experiment_to_layer = specs_json[:experiment_to_layer]
364
+ @sdk_keys_to_app_ids = specs_json[:sdk_keys_to_app_ids] || {}
365
+ @hashed_sdk_keys_to_app_ids = specs_json[:hashed_sdk_keys_to_app_ids] || {}
366
+ @sdk_configs.set_flags(specs_json[:sdk_flags])
367
+ @sdk_configs.set_configs(specs_json[:sdk_configs])
368
+ @overrides = specs_json[:overrides] || {}
369
+ @override_rules = specs_json[:override_rules] || {}
370
+
371
+ unless from_adapter
372
+ save_rulesets_to_storage_adapter(specs_string)
373
+ end
374
+ rescue StandardError => e
375
+ return {reason: "PARSE_RESPONSE_ERROR"}
328
376
  end
329
- true
377
+ nil
330
378
  end
331
379
 
332
380
  def get_id_lists_from_adapter(context)
data/lib/statsig.rb CHANGED
@@ -1,6 +1,5 @@
1
- require 'statsig_driver'
2
-
3
- require 'statsig_errors'
1
+ require_relative 'statsig_driver'
2
+ require_relative 'statsig_errors'
4
3
 
5
4
  module Statsig
6
5
 
@@ -20,6 +19,13 @@ module Statsig
20
19
  @shared_instance = StatsigDriver.new(secret_key, options, error_callback)
21
20
  end
22
21
 
22
+ def self.get_initialization_details
23
+ if not defined? @shared_instance or @shared_instance.nil?
24
+ return {duration: 0, isSDKReady: false, configSpecReady: false, failure_details: {exception: Statsig::UninitializedError.new, reason: 'INTERNAL_ERROR'}}
25
+ end
26
+ @shared_instance.get_initialization_details
27
+ end
28
+
23
29
  class GetGateOptions
24
30
  attr_accessor :disable_log_exposure, :skip_evaluation, :disable_evaluation_details
25
31
 
@@ -65,7 +71,7 @@ module Statsig
65
71
  end
66
72
 
67
73
  ##
68
- # @deprecated - use check_gate(user, gate, options) and disable_exposure_logging in options
74
+ # @deprecated - use check_gate(user, gate, options) with CheckGateOptions.new(disable_log_exposure: true) as options
69
75
  # Gets the boolean result of a gate, evaluated against the given user.
70
76
  #
71
77
  # @param user A StatsigUser object used for the evaluation
@@ -85,6 +91,11 @@ module Statsig
85
91
  @shared_instance&.manually_log_gate_exposure(user, gate_name)
86
92
  end
87
93
 
94
+ def self.get_fields_used_for_gate(gate_name)
95
+ ensure_initialized
96
+ @shared_instance&.get_fields_used_for_gate(gate_name)
97
+ end
98
+
88
99
  class GetConfigOptions
89
100
  attr_accessor :disable_log_exposure, :disable_evaluation_details, :ignore_local_overrides
90
101
 
@@ -108,7 +119,7 @@ module Statsig
108
119
  end
109
120
 
110
121
  ##
111
- # @deprecated - use get_config(user, config, options) and disable_exposure_logging in options
122
+ # @deprecated - use get_config(user, config, options) with GetConfigOptions.new(disable_log_exposure: true) as options
112
123
  # Get the values of a dynamic config, evaluated against the given user.
113
124
  #
114
125
  # @param [StatsigUser] user A StatsigUser object used for the evaluation
@@ -129,6 +140,11 @@ module Statsig
129
140
  @shared_instance&.manually_log_config_exposure(user, dynamic_config)
130
141
  end
131
142
 
143
+ def self.get_fields_used_for_config(config_name)
144
+ ensure_initialized
145
+ @shared_instance&.get_fields_used_for_config(config_name)
146
+ end
147
+
132
148
  class GetExperimentOptions
133
149
  attr_accessor :disable_log_exposure, :user_persisted_values, :disable_evaluation_details, :ignore_local_overrides
134
150
 
@@ -152,7 +168,7 @@ module Statsig
152
168
  end
153
169
 
154
170
  ##
155
- # @deprecated - use get_experiment(user, experiment, options) and disable_exposure_logging in options
171
+ # @deprecated - use get_experiment(user, experiment, options) with GetExperimentOptions.new(disable_log_exposure: true) as options
156
172
  # Get the values of an experiment, evaluated against the given user.
157
173
  #
158
174
  # @param [StatsigUser] user A StatsigUser object used for the evaluation
@@ -177,6 +193,11 @@ module Statsig
177
193
  @shared_instance&.get_user_persisted_values(user, id_type)
178
194
  end
179
195
 
196
+ def self.get_fields_used_for_experiment(experiment_name)
197
+ ensure_initialized
198
+ @shared_instance&.get_fields_used_for_config(experiment_name)
199
+ end
200
+
180
201
  class GetLayerOptions
181
202
  attr_accessor :disable_log_exposure, :disable_evaluation_details
182
203
 
@@ -199,7 +220,7 @@ module Statsig
199
220
  end
200
221
 
201
222
  ##
202
- # @deprecated - use get_layer(user, gate, options) and disable_exposure_logging in options
223
+ # @deprecated - use get_layer(user, gate, options) with GetLayerOptions.new(disable_log_exposure: true) as options
203
224
  # Get the values of a layer, evaluated against the given user.
204
225
  #
205
226
  # @param user A StatsigUser object used for the evaluation
@@ -220,6 +241,11 @@ module Statsig
220
241
  @shared_instance&.manually_log_layer_parameter_exposure(user, layer_name, parameter_name)
221
242
  end
222
243
 
244
+ def self.get_fields_used_for_layer(layer_name)
245
+ ensure_initialized
246
+ @shared_instance&.get_fields_used_for_layer(layer_name)
247
+ end
248
+
223
249
  ##
224
250
  # Logs an event to Statsig with the provided values.
225
251
  #
@@ -321,6 +347,17 @@ module Statsig
321
347
  @shared_instance&.override_config(config_name, config_value)
322
348
  end
323
349
 
350
+
351
+ ##
352
+ # Overrides an experiment to return the value for a specific group name.
353
+ #
354
+ # @param experiment_name The name of the experiment to be overridden
355
+ # @param group_name The name of the group whose value should be returned
356
+ def self.override_experiment_by_group_name(experiment_name, group_name)
357
+ ensure_initialized
358
+ @shared_instance&.override_experiment_by_group_name(experiment_name, group_name)
359
+ end
360
+
324
361
  def self.remove_config_override(config_name)
325
362
  ensure_initialized
326
363
  @shared_instance&.remove_config_override(config_name)
@@ -331,6 +368,16 @@ module Statsig
331
368
  @shared_instance&.clear_config_overrides
332
369
  end
333
370
 
371
+ def self.clear_experiment_overrides
372
+ ensure_initialized
373
+ @shared_instance&.clear_experiment_overrides
374
+ end
375
+
376
+ def self.remove_experiment_override(experiment_name)
377
+ ensure_initialized
378
+ @shared_instance&.remove_experiment_override(experiment_name)
379
+ end
380
+
334
381
  ##
335
382
  # @param [HashTable] debug information log with exposure events
336
383
  def self.set_debug_info(debug_info)
@@ -363,11 +410,15 @@ module Statsig
363
410
  def self.get_statsig_metadata
364
411
  {
365
412
  'sdkType' => 'ruby-server',
366
- 'sdkVersion' => '2.0.1',
413
+ 'sdkVersion' => '2.8.1',
367
414
  'languageVersion' => RUBY_VERSION
368
415
  }
369
416
  end
370
417
 
418
+ def self.get_options
419
+ @driver&.instance_variable_get(:@options)
420
+ end
421
+
371
422
  private
372
423
 
373
424
  def self.ensure_initialized