statsig 1.17.0 → 1.19.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 5b81f929cdedefaee6f57cb2b0f7aec05bbfd28cf6b4f273fc4fb3bbcf46858f
4
- data.tar.gz: 5caa719d8a301cbcd95d68f9bc5802a89f0691600c7b5883bfdbfcc8fa21bb8f
3
+ metadata.gz: 354a77a658a3232a8695d82136d3e84e5bf0ce2199d06af9fee216eaae7d5dcd
4
+ data.tar.gz: 3be67ca270e61eedd92fb3aaa96b4f28ff1b79feee3ec4b1a33bdc2d000f8c2e
5
5
  SHA512:
6
- metadata.gz: 9372cd1b25a0b91054d6315df027833cd2377e51e1f76385dd260fe9a17665da33330c357cf63756f6bc6701e51f76a7d5b81aa99d57a96bfeee807c575c3b19
7
- data.tar.gz: 5dd1c6ad849e20c0ed15b451371a78e971f6da4ed83f7bf7f79eb5db65c88ba27f1b8e9da991587d8a46cbd2086d0b30692bbb81e48283e758b84d679a63eb3a
6
+ metadata.gz: 6f2461d11144a2e822178d69dad1ff50ebb2a4cd14f7b5dc46704b9c285a56d494f512b8058361f0a3ba3c71d899a6b9afc2fe51618abbe73784c6a937c39125
7
+ data.tar.gz: 45ac2d3a06f60c1cb0a81b109df9d06e1f479d384023e2009343b6d6f74b5d806fdf86e85b5d09ad8f1e55b07ca8c1b010cb23f7c8de2e9594b1092b323c8708
@@ -0,0 +1,44 @@
1
+ # typed: true
2
+
3
+ require 'sorbet-runtime'
4
+
5
+ module Statsig
6
+ class Diagnostics
7
+ extend T::Sig
8
+
9
+ sig { returns(String) }
10
+ attr_reader :context
11
+
12
+ sig { returns(T::Array[T::Hash[Symbol, T.untyped]]) }
13
+ attr_reader :markers
14
+
15
+ sig { params(context: String).void }
16
+
17
+ def initialize(context)
18
+ @context = context
19
+ @markers = []
20
+ end
21
+
22
+ sig { params(key: String, action: String, step: T.any(String, NilClass), value: T.any(String, Integer, T::Boolean, NilClass)).void }
23
+
24
+ def mark(key, action, step = nil, value = nil)
25
+ @markers.push({
26
+ key: key,
27
+ step: step,
28
+ action: action,
29
+ value: value,
30
+ timestamp: (Time.now.to_f * 1000).to_i
31
+ })
32
+ end
33
+
34
+ sig { returns(T::Hash[Symbol, T.untyped]) }
35
+
36
+ def serialize
37
+ {
38
+ context: @context,
39
+ markers: @markers
40
+ }
41
+ end
42
+ end
43
+
44
+ end
@@ -0,0 +1,57 @@
1
+ require "statsig_errors"
2
+
3
+ $endpoint = 'https://statsigapi.net/v1/sdk_exception'
4
+
5
+ module Statsig
6
+ class ErrorBoundary
7
+ def initialize(sdk_key)
8
+ @sdk_key = sdk_key
9
+ @seen = Set.new
10
+ end
11
+
12
+ def capture(task, recover = -> {})
13
+ begin
14
+ return task.call
15
+ rescue StandardError => e
16
+ if e.is_a?(Statsig::UninitializedError) or e.is_a?(Statsig::ValueError)
17
+ raise e
18
+ end
19
+ puts "[Statsig]: An unexpected exception occurred."
20
+ log_exception(e)
21
+ return recover.call
22
+ end
23
+ end
24
+
25
+ private
26
+
27
+ def log_exception(exception)
28
+ begin
29
+ name = exception.class.name
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
53
+ return
54
+ end
55
+ end
56
+ end
57
+ 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, init_diagnostics = nil)
21
+ @spec_store = Statsig::SpecStore.new(network, options, error_callback, init_diagnostics)
22
22
  @ua_parser = UserAgentParser::Parser.new
23
23
  CountryLookup.initialize
24
24
 
data/lib/network.rb CHANGED
@@ -8,6 +8,15 @@ require 'sorbet-runtime'
8
8
  $retry_codes = [408, 500, 502, 503, 504, 522, 524, 599]
9
9
 
10
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
+
11
20
  class Network
12
21
  extend T::Sig
13
22
 
@@ -25,19 +34,23 @@ module Statsig
25
34
  @session_id = SecureRandom.uuid
26
35
  end
27
36
 
28
-
29
37
  sig { params(endpoint: String, body: String, retries: Integer, backoff: Integer)
30
- .returns([T.any(HTTP::Response, NilClass), T.any(StandardError, NilClass)]) }
38
+ .returns([T.any(HTTP::Response, NilClass), T.any(StandardError, NilClass)]) }
31
39
 
32
40
  def post_helper(endpoint, body, retries = 0, backoff = 1)
33
41
  if @local_mode
34
42
  return nil, nil
35
43
  end
44
+
45
+ meta = Statsig.get_statsig_metadata
36
46
  http = HTTP.headers(
37
- {"STATSIG-API-KEY" => @server_secret,
38
- "STATSIG-CLIENT-TIME" => (Time.now.to_f * 1000).to_i.to_s,
39
- "STATSIG-SERVER-SESSION-ID" => @session_id,
40
- "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'],
41
54
  }).accept(:json)
42
55
  begin
43
56
  res = http.post(@api + endpoint, body: body)
@@ -47,8 +60,8 @@ module Statsig
47
60
  sleep backoff
48
61
  return post_helper(endpoint, body, retries - 1, backoff * @backoff_multiplier)
49
62
  end
50
- return res, nil unless !res.status.success?
51
- 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)
52
65
  ## status code retry
53
66
  sleep backoff
54
67
  post_helper(endpoint, body, retries - 1, backoff * @backoff_multiplier)
@@ -56,7 +69,7 @@ module Statsig
56
69
 
57
70
  def check_gate(user, gate_name)
58
71
  begin
59
- request_body = JSON.generate({'user' => user&.serialize(false), 'gateName' => gate_name})
72
+ request_body = JSON.generate({ 'user' => user&.serialize(false), 'gateName' => gate_name })
60
73
  response, _ = post_helper('check_gate', request_body)
61
74
  return JSON.parse(response.body) unless response.nil?
62
75
  false
@@ -67,7 +80,7 @@ module Statsig
67
80
 
68
81
  def get_config(user, dynamic_config_name)
69
82
  begin
70
- 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 })
71
84
  response, _ = post_helper('get_config', request_body)
72
85
  return JSON.parse(response.body) unless response.nil?
73
86
  nil
@@ -78,7 +91,7 @@ module Statsig
78
91
 
79
92
  def post_logs(events)
80
93
  begin
81
- json_body = JSON.generate({'events' => events, 'statsigMetadata' => Statsig.get_statsig_metadata})
94
+ json_body = JSON.generate({ 'events' => events, 'statsigMetadata' => Statsig.get_statsig_metadata })
82
95
  post_helper('log_event', json_body, 5)
83
96
  rescue
84
97
  end
data/lib/spec_store.rb CHANGED
@@ -14,7 +14,7 @@ module Statsig
14
14
  attr_accessor :initial_config_sync_time
15
15
  attr_accessor :init_reason
16
16
 
17
- def initialize(network, options, error_callback)
17
+ def initialize(network, options, error_callback, init_diagnostics = nil)
18
18
  @init_reason = EvaluationReason::UNINITIALIZED
19
19
  @network = network
20
20
  @options = options
@@ -42,8 +42,12 @@ module Statsig
42
42
  begin
43
43
  if !@options.data_store.nil?
44
44
  puts 'data_store gets priority over bootstrap_values. bootstrap_values will be ignored'
45
- elsif process(options.bootstrap_values)
46
- @init_reason = EvaluationReason::BOOTSTRAP
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)
47
51
  end
48
52
  rescue
49
53
  puts 'the provided bootstrapValues is not a valid JSON string'
@@ -51,13 +55,18 @@ module Statsig
51
55
  end
52
56
 
53
57
  unless @options.data_store.nil?
58
+ init_diagnostics&.mark("data_store", "start", "load")
54
59
  @options.data_store.init
55
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)
56
66
  end
57
67
 
58
- download_config_specs
59
68
  @initial_config_sync_time = @last_config_sync_time == 0 ? -1 : @last_config_sync_time
60
- get_id_lists
69
+ get_id_lists(init_diagnostics)
61
70
 
62
71
  @config_sync_thread = sync_config_specs
63
72
  @id_lists_sync_thread = sync_id_lists
@@ -157,26 +166,39 @@ module Statsig
157
166
  end
158
167
  end
159
168
 
160
- def download_config_specs
161
- e = get_config_specs_from_network
162
- @error_callback.call(e) unless e.nil? or @error_callback.nil?
163
- end
169
+ def download_config_specs(init_diagnostics = nil)
170
+ init_diagnostics&.mark("download_config_specs", "start", "network_request")
164
171
 
165
- def get_config_specs_from_network
172
+ error = nil
166
173
  begin
167
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
+
168
181
  if e.nil?
169
- if !response.nil? and process(response.body)
170
- @init_reason = EvaluationReason::NETWORK
171
- @rules_updated_callback.call(response.body.to_s, @last_config_sync_time) unless response.body.nil? or @rules_updated_callback.nil?
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)
172
191
  end
192
+
173
193
  nil
174
194
  else
175
- e
195
+ error = e
176
196
  end
177
197
  rescue StandardError => e
178
- e
198
+ error = e
179
199
  end
200
+
201
+ @error_callback.call(error) unless error.nil? or @error_callback.nil?
180
202
  end
181
203
 
182
204
  def process(specs_string, from_adapter = false)
@@ -219,11 +241,13 @@ module Statsig
219
241
  true
220
242
  end
221
243
 
222
- def get_id_lists
244
+ def get_id_lists(init_diagnostics = nil)
245
+ init_diagnostics&.mark("get_id_lists", "start", "network_request")
223
246
  response, e = @network.post_helper('get_id_lists', JSON.generate({ 'statsigMetadata' => Statsig.get_statsig_metadata }))
224
247
  if !e.nil? || response.nil?
225
248
  return
226
249
  end
250
+ init_diagnostics&.mark("get_id_lists", "end", "network_request", response.status.to_i)
227
251
 
228
252
  begin
229
253
  server_id_lists = JSON.parse(response)
@@ -233,6 +257,12 @@ module Statsig
233
257
  end
234
258
  tasks = []
235
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)
265
+
236
266
  server_id_lists.each do |list_name, list|
237
267
  server_list = IDList.new(list)
238
268
  local_list = get_id_list(list_name)
@@ -267,6 +297,7 @@ module Statsig
267
297
 
268
298
  result = Concurrent::Promise.all?(*tasks).execute.wait(@id_lists_sync_interval)
269
299
  if result.state != :fulfilled
300
+ init_diagnostics&.mark("get_id_lists", "end", "process", false)
270
301
  return # timed out
271
302
  end
272
303
 
@@ -279,6 +310,7 @@ module Statsig
279
310
  delete_lists.each do |list_name|
280
311
  local_id_lists.delete list_name
281
312
  end
313
+ init_diagnostics&.mark("get_id_lists", "end", "process", true)
282
314
  rescue
283
315
  # Ignored, will try again
284
316
  end
data/lib/statsig.rb CHANGED
@@ -2,11 +2,11 @@
2
2
 
3
3
  require 'statsig_driver'
4
4
  require 'sorbet-runtime'
5
+ require 'statsig_errors'
5
6
 
6
7
  module Statsig
7
8
  extend T::Sig
8
9
 
9
-
10
10
  sig { params(secret_key: String, options: T.any(StatsigOptions, NilClass), error_callback: T.any(Method, Proc, NilClass)).void }
11
11
  ##
12
12
  # Initializes the Statsig SDK.
@@ -35,6 +35,28 @@ module Statsig
35
35
  @shared_instance&.check_gate(user, gate_name)
36
36
  end
37
37
 
38
+ sig { params(user: StatsigUser, gate_name: String).returns(T::Boolean) }
39
+ ##
40
+ # Gets the boolean result of a gate, evaluated against the given user.
41
+ #
42
+ # @param user A StatsigUser object used for the evaluation
43
+ # @param gate_name The name of the gate being checked
44
+ def self.check_gate_with_exposure_logging_disabled(user, gate_name)
45
+ ensure_initialized
46
+ @shared_instance&.check_gate(user, gate_name, StatsigDriver::CheckGateOptions.new(log_exposure: false))
47
+ end
48
+
49
+ sig { params(user: StatsigUser, gate_name: String).void }
50
+ ##
51
+ # Logs an exposure event for the gate
52
+ #
53
+ # @param user A StatsigUser object used for the evaluation
54
+ # @param gate_name The name of the gate being checked
55
+ def self.manually_log_gate_exposure(user, gate_name)
56
+ ensure_initialized
57
+ @shared_instance&.manually_log_gate_exposure(user, gate_name)
58
+ end
59
+
38
60
  sig { params(user: StatsigUser, dynamic_config_name: String).returns(DynamicConfig) }
39
61
  ##
40
62
  # Get the values of a dynamic config, evaluated against the given user. An exposure event will automatically be logged for the dynamic config.
@@ -46,6 +68,28 @@ module Statsig
46
68
  @shared_instance&.get_config(user, dynamic_config_name)
47
69
  end
48
70
 
71
+ sig { params(user: StatsigUser, dynamic_config_name: String).returns(DynamicConfig) }
72
+ ##
73
+ # Get the values of a dynamic config, evaluated against the given user.
74
+ #
75
+ # @param user A StatsigUser object used for the evaluation
76
+ # @param dynamic_config_name The name of the dynamic config
77
+ def self.get_config_with_exposure_logging_disabled(user, dynamic_config_name)
78
+ ensure_initialized
79
+ @shared_instance&.get_config(user, dynamic_config_name, StatsigDriver::GetConfigOptions.new(log_exposure: false))
80
+ end
81
+
82
+ sig { params(user: StatsigUser, dynamic_config: String).void }
83
+ ##
84
+ # Logs an exposure event for the dynamic config
85
+ #
86
+ # @param user A StatsigUser object used for the evaluation
87
+ # @param dynamic_config_name The name of the dynamic config
88
+ def self.manually_log_config_exposure(user, dynamic_config)
89
+ ensure_initialized
90
+ @shared_instance&.manually_log_config_exposure(user, dynamic_config)
91
+ end
92
+
49
93
  sig { params(user: StatsigUser, experiment_name: String).returns(DynamicConfig) }
50
94
  ##
51
95
  # Get the values of an experiment, evaluated against the given user. An exposure event will automatically be logged for the experiment.
@@ -57,6 +101,28 @@ module Statsig
57
101
  @shared_instance&.get_experiment(user, experiment_name)
58
102
  end
59
103
 
104
+ sig { params(user: StatsigUser, experiment_name: String).returns(DynamicConfig) }
105
+ ##
106
+ # Get the values of an experiment, evaluated against the given user.
107
+ #
108
+ # @param user A StatsigUser object used for the evaluation
109
+ # @param experiment_name The name of the experiment
110
+ def self.get_experiment_with_exposure_logging_disabled(user, experiment_name)
111
+ ensure_initialized
112
+ @shared_instance&.get_experiment(user, experiment_name, StatsigDriver::GetExperimentOptions.new(log_exposure: false))
113
+ end
114
+
115
+ sig { params(user: StatsigUser, experiment_name: String).void }
116
+ ##
117
+ # Logs an exposure event for the experiment
118
+ #
119
+ # @param user A StatsigUser object used for the evaluation
120
+ # @param experiment_name The name of the experiment
121
+ def self.manually_log_experiment_exposure(user, experiment_name)
122
+ ensure_initialized
123
+ @shared_instance&.manually_log_config_exposure(user, experiment_name)
124
+ end
125
+
60
126
  sig { params(user: StatsigUser, layer_name: String).returns(Layer) }
61
127
  ##
62
128
  # Get the values of a layer, evaluated against the given user.
@@ -69,6 +135,29 @@ module Statsig
69
135
  @shared_instance&.get_layer(user, layer_name)
70
136
  end
71
137
 
138
+ sig { params(user: StatsigUser, layer_name: String).returns(Layer) }
139
+ ##
140
+ # Get the values of a layer, evaluated against the given user.
141
+ #
142
+ # @param user A StatsigUser object used for the evaluation
143
+ # @param layer_name The name of the layer
144
+ def self.get_layer_with_exposure_logging_disabled(user, layer_name)
145
+ ensure_initialized
146
+ @shared_instance&.get_layer(user, layer_name, StatsigDriver::GetLayerOptions.new(log_exposure: false))
147
+ end
148
+
149
+ sig { params(user: StatsigUser, layer_name: String, parameter_name: String).returns(Layer) }
150
+ ##
151
+ # Logs an exposure event for the parameter in the given layer
152
+ #
153
+ # @param user A StatsigUser object used for the evaluation
154
+ # @param layer_name The name of the layer
155
+ # @param parameter_name The name of the parameter in the layer
156
+ def self.manually_log_layer_parameter_exposure(user, layer_name, parameter_name)
157
+ ensure_initialized
158
+ @shared_instance&.manually_log_layer_parameter_exposure(user, layer_name, parameter_name)
159
+ end
160
+
72
161
  sig { params(user: StatsigUser,
73
162
  event_name: String,
74
163
  value: T.any(String, Integer, Float, NilClass),
@@ -89,7 +178,7 @@ module Statsig
89
178
  ##
90
179
  # Stops all Statsig activity and flushes any pending events.
91
180
  def self.shutdown
92
- unless @shared_instance.nil?
181
+ if defined? @shared_instance and !@shared_instance.nil?
93
182
  @shared_instance.shutdown
94
183
  end
95
184
  @shared_instance = nil
@@ -136,32 +225,32 @@ module Statsig
136
225
  def self.get_statsig_metadata
137
226
  {
138
227
  'sdkType' => 'ruby-server',
139
- 'sdkVersion' => '1.17.0',
228
+ 'sdkVersion' => '1.19.0',
140
229
  }
141
230
  end
142
231
 
143
232
  private
144
233
 
145
234
  def self.ensure_initialized
146
- if @shared_instance.nil?
147
- raise 'Must call initialize first.'
235
+ if not defined? @shared_instance or @shared_instance.nil?
236
+ raise Statsig::UninitializedError.new
148
237
  end
149
238
  end
150
239
 
151
240
  T::Configuration.call_validation_error_handler = lambda do |signature, opts|
152
- puts opts[:pretty_message]
241
+ puts "[Type Error] " + opts[:pretty_message]
153
242
  end
154
243
 
155
244
  T::Configuration.inline_type_error_handler = lambda do |error, opts|
156
- puts error.message
245
+ puts "[Type Error] " + error.message
157
246
  end
158
247
 
159
248
  T::Configuration.sig_builder_error_handler = lambda do |error, location|
160
- puts error.message
249
+ puts "[Type Error] " + error.message
161
250
  end
162
251
 
163
252
  T::Configuration.sig_validation_error_handler = lambda do |error, opts|
164
- puts error.message
253
+ puts "[Type Error] " + error.message
165
254
  end
166
255
 
167
256
  end
@@ -3,14 +3,17 @@
3
3
  require 'config_result'
4
4
  require 'evaluator'
5
5
  require 'network'
6
+ require 'statsig_errors'
6
7
  require 'statsig_event'
7
8
  require 'statsig_logger'
8
9
  require 'statsig_options'
9
10
  require 'statsig_user'
10
11
  require 'spec_store'
11
12
  require 'dynamic_config'
13
+ require 'error_boundary'
12
14
  require 'layer'
13
15
  require 'sorbet-runtime'
16
+ require 'diagnostics'
14
17
 
15
18
  class StatsigDriver
16
19
  extend T::Sig
@@ -19,132 +22,215 @@ class StatsigDriver
19
22
 
20
23
  def initialize(secret_key, options = nil, error_callback = nil)
21
24
  unless secret_key.start_with?('secret-')
22
- raise 'Invalid secret key provided. Provide your project secret key from the Statsig console'
25
+ raise Statsig::ValueError.new('Invalid secret key provided. Provide your project secret key from the Statsig console')
23
26
  end
27
+
24
28
  if !options.nil? && !options.instance_of?(StatsigOptions)
25
- raise 'Invalid options provided. Either provide a valid StatsigOptions object or nil'
29
+ raise Statsig::ValueError.new('Invalid options provided. Either provide a valid StatsigOptions object or nil')
26
30
  end
27
31
 
28
- @options = options || StatsigOptions.new
29
- @shutdown = false
30
- @secret_key = secret_key
31
- @net = Statsig::Network.new(secret_key, @options.api_url_base, @options.local_mode)
32
- @logger = Statsig::StatsigLogger.new(@net, @options)
33
- @evaluator = Statsig::Evaluator.new(@net, @options, error_callback)
32
+ @err_boundary = Statsig::ErrorBoundary.new(secret_key)
33
+ @err_boundary.capture(-> {
34
+ @init_diagnostics = Statsig::Diagnostics.new("initialize")
35
+ @init_diagnostics.mark("overall", "start")
36
+ @options = options || StatsigOptions.new
37
+ @shutdown = false
38
+ @secret_key = secret_key
39
+ @net = Statsig::Network.new(secret_key, @options.api_url_base, @options.local_mode)
40
+ @logger = Statsig::StatsigLogger.new(@net, @options)
41
+ @evaluator = Statsig::Evaluator.new(@net, @options, error_callback, @init_diagnostics)
42
+ @init_diagnostics.mark("overall", "end")
43
+
44
+ log_init_diagnostics
45
+ })
46
+ end
47
+
48
+ class CheckGateOptions < T::Struct
49
+ prop :log_exposure, T::Boolean, default: true
34
50
  end
35
51
 
36
- sig { params(user: StatsigUser, gate_name: String).returns(T::Boolean) }
52
+ sig { params(user: StatsigUser, gate_name: String, options: CheckGateOptions).returns(T::Boolean) }
53
+
54
+ def check_gate(user, gate_name, options = CheckGateOptions.new)
55
+ @err_boundary.capture(-> {
56
+ user = verify_inputs(user, gate_name, "gate_name")
57
+
58
+ res = @evaluator.check_gate(user, gate_name)
59
+ if res.nil?
60
+ res = Statsig::ConfigResult.new(gate_name)
61
+ end
62
+
63
+ if res == $fetch_from_server
64
+ res = check_gate_fallback(user, gate_name)
65
+ # exposure logged by the server
66
+ else
67
+ if options.log_exposure
68
+ @logger.log_gate_exposure(user, res.name, res.gate_value, res.rule_id, res.secondary_exposures, res.evaluation_details)
69
+ end
70
+ end
71
+
72
+ res.gate_value
73
+ }, -> { false })
37
74
 
38
- def check_gate(user, gate_name)
39
- user = verify_inputs(user, gate_name, "gate_name")
75
+ end
40
76
 
77
+ sig { params(user: StatsigUser, gate_name: String).void }
78
+
79
+ def manually_log_gate_exposure(user, gate_name)
41
80
  res = @evaluator.check_gate(user, gate_name)
42
- if res.nil?
43
- res = Statsig::ConfigResult.new(gate_name)
44
- end
81
+ context = {'is_manual_exposure' => true}
82
+ @logger.log_gate_exposure(user, gate_name, res.gate_value, res.rule_id, res.secondary_exposures, res.evaluation_details, context)
83
+ end
45
84
 
46
- if res == $fetch_from_server
47
- res = check_gate_fallback(user, gate_name)
48
- # exposure logged by the server
49
- else
50
- @logger.log_gate_exposure(user, res.name, res.gate_value, res.rule_id, res.secondary_exposures, res.evaluation_details)
51
- end
85
+ class GetConfigOptions < T::Struct
86
+ prop :log_exposure, T::Boolean, default: true
87
+ end
88
+
89
+ sig { params(user: StatsigUser, dynamic_config_name: String, options: GetConfigOptions).returns(DynamicConfig) }
52
90
 
53
- res.gate_value
91
+ def get_config(user, dynamic_config_name, options = GetConfigOptions.new)
92
+ @err_boundary.capture(-> {
93
+ user = verify_inputs(user, dynamic_config_name, "dynamic_config_name")
94
+ get_config_impl(user, dynamic_config_name, options)
95
+ }, -> { DynamicConfig.new(dynamic_config_name) })
54
96
  end
55
97
 
56
- sig { params(user: StatsigUser, dynamic_config_name: String).returns(DynamicConfig) }
98
+ class GetExperimentOptions < T::Struct
99
+ prop :log_exposure, T::Boolean, default: true
100
+ end
101
+
102
+ sig { params(user: StatsigUser, experiment_name: String, options: GetExperimentOptions).returns(DynamicConfig) }
57
103
 
58
- def get_config(user, dynamic_config_name)
59
- user = verify_inputs(user, dynamic_config_name, "dynamic_config_name")
60
- get_config_impl(user, dynamic_config_name)
104
+ def get_experiment(user, experiment_name, options = GetExperimentOptions.new)
105
+ @err_boundary.capture(-> {
106
+ user = verify_inputs(user, experiment_name, "experiment_name")
107
+ get_config_impl(user, experiment_name, options)
108
+ }, -> { DynamicConfig.new(experiment_name) })
61
109
  end
62
110
 
63
- sig { params(user: StatsigUser, experiment_name: String).returns(DynamicConfig) }
111
+ sig { params(user: StatsigUser, config_name: String).void }
64
112
 
65
- def get_experiment(user, experiment_name)
66
- user = verify_inputs(user, experiment_name, "experiment_name")
67
- get_config_impl(user, experiment_name)
113
+ def manually_log_config_exposure(user, config_name)
114
+ res = @evaluator.get_config(user, config_name)
115
+ context = {'is_manual_exposure' => true}
116
+ @logger.log_config_exposure(user, res.name, res.rule_id, res.secondary_exposures, res.evaluation_details, context)
68
117
  end
69
118
 
70
- sig { params(user: StatsigUser, layer_name: String).returns(Layer) }
119
+ class GetLayerOptions < T::Struct
120
+ prop :log_exposure, T::Boolean, default: true
121
+ end
71
122
 
72
- def get_layer(user, layer_name)
73
- user = verify_inputs(user, layer_name, "layer_name")
123
+ sig { params(user: StatsigUser, layer_name: String, options: GetLayerOptions).returns(Layer) }
74
124
 
75
- res = @evaluator.get_layer(user, layer_name)
76
- if res.nil?
77
- res = Statsig::ConfigResult.new(layer_name)
78
- end
125
+ def get_layer(user, layer_name, options = GetLayerOptions.new)
126
+ @err_boundary.capture(-> {
127
+ user = verify_inputs(user, layer_name, "layer_name")
79
128
 
80
- if res == $fetch_from_server
81
- if res.config_delegate.empty?
82
- return Layer.new(layer_name)
129
+ res = @evaluator.get_layer(user, layer_name)
130
+ if res.nil?
131
+ res = Statsig::ConfigResult.new(layer_name)
83
132
  end
84
- res = get_config_fallback(user, res.config_delegate)
85
- # exposure logged by the server
86
- end
87
133
 
88
- Layer.new(res.name, res.json_value, res.rule_id, lambda { |layer, parameter_name|
89
- @logger.log_layer_exposure(user, layer, parameter_name, res)
134
+ if res == $fetch_from_server
135
+ if res.config_delegate.empty?
136
+ return Layer.new(layer_name)
137
+ end
138
+ res = get_config_fallback(user, res.config_delegate)
139
+ # exposure logged by the server
140
+ end
141
+
142
+ exposure_log_func = options.log_exposure ? lambda { |layer, parameter_name|
143
+ @logger.log_layer_exposure(user, layer, parameter_name, res)
144
+ } : nil
145
+ Layer.new(res.name, res.json_value, res.rule_id, exposure_log_func)
146
+ }, -> {
147
+ Layer.new(layer_name)
90
148
  })
91
149
  end
92
150
 
151
+ sig { params(user: StatsigUser, layer_name: String, parameter_name: String).void }
152
+
153
+ def manually_log_layer_parameter_exposure(user, layer_name, parameter_name)
154
+ res = @evaluator.get_layer(user, layer_name)
155
+ layer = Layer.new(layer_name, res.json_value, res.rule_id)
156
+ context = {'is_manual_exposure' => true}
157
+ @logger.log_layer_exposure(user, layer, parameter_name, res, context)
158
+ end
159
+
93
160
  def log_event(user, event_name, value = nil, metadata = nil)
94
- if !user.nil? && !user.instance_of?(StatsigUser)
95
- raise 'Must provide a valid StatsigUser or nil'
96
- end
97
- check_shutdown
161
+ @err_boundary.capture(-> {
162
+ if !user.nil? && !user.instance_of?(StatsigUser)
163
+ raise Statsig::ValueError.new('Must provide a valid StatsigUser or nil')
164
+ end
165
+ check_shutdown
98
166
 
99
- user = normalize_user(user)
167
+ user = normalize_user(user)
100
168
 
101
- event = StatsigEvent.new(event_name)
102
- event.user = user
103
- event.value = value
104
- event.metadata = metadata
105
- event.statsig_metadata = Statsig.get_statsig_metadata
106
- @logger.log_event(event)
169
+ event = StatsigEvent.new(event_name)
170
+ event.user = user
171
+ event.value = value
172
+ event.metadata = metadata
173
+ event.statsig_metadata = Statsig.get_statsig_metadata
174
+ @logger.log_event(event)
175
+ })
107
176
  end
108
177
 
109
178
  def shutdown
110
- @shutdown = true
111
- @logger.shutdown
112
- @evaluator.shutdown
179
+ @err_boundary.capture(-> {
180
+ @shutdown = true
181
+ @logger.shutdown
182
+ @evaluator.shutdown
183
+ })
113
184
  end
114
185
 
115
186
  def override_gate(gate_name, gate_value)
116
- @evaluator.override_gate(gate_name, gate_value)
187
+ @err_boundary.capture(-> {
188
+ @evaluator.override_gate(gate_name, gate_value)
189
+ })
117
190
  end
118
191
 
119
192
  def override_config(config_name, config_value)
120
- @evaluator.override_config(config_name, config_value)
193
+ @err_boundary.capture(-> {
194
+ @evaluator.override_config(config_name, config_value)
195
+ })
121
196
  end
122
197
 
123
198
  # @param [StatsigUser] user
124
199
  # @return [Hash]
125
200
  def get_client_initialize_response(user)
126
- normalize_user(user)
127
- @evaluator.get_client_initialize_response(user)
201
+ @err_boundary.capture(-> {
202
+ normalize_user(user)
203
+ @evaluator.get_client_initialize_response(user)
204
+ }, -> { nil })
128
205
  end
129
206
 
130
207
  def maybe_restart_background_threads
131
- @evaluator.maybe_restart_background_threads
132
- @logger.maybe_restart_background_threads
208
+ if @options.local_mode
209
+ return
210
+ end
211
+
212
+ @err_boundary.capture(-> {
213
+ @evaluator.maybe_restart_background_threads
214
+ @logger.maybe_restart_background_threads
215
+ })
133
216
  end
134
217
 
135
218
  private
136
219
 
220
+ sig { params(user: StatsigUser, config_name: String, variable_name: String).returns(StatsigUser) }
221
+
137
222
  def verify_inputs(user, config_name, variable_name)
138
223
  validate_user(user)
139
224
  if !config_name.is_a?(String) || config_name.empty?
140
- raise "Invalid #{variable_name} provided"
225
+ raise Statsig::ValueError.new("Invalid #{variable_name} provided")
141
226
  end
142
227
 
143
228
  check_shutdown
229
+ maybe_restart_background_threads
144
230
  normalize_user(user)
145
231
  end
146
232
 
147
- def get_config_impl(user, config_name)
233
+ def get_config_impl(user, config_name, options)
148
234
  res = @evaluator.get_config(user, config_name)
149
235
  if res.nil?
150
236
  res = Statsig::ConfigResult.new(config_name)
@@ -154,7 +240,9 @@ class StatsigDriver
154
240
  res = get_config_fallback(user, config_name)
155
241
  # exposure logged by the server
156
242
  else
157
- @logger.log_config_exposure(user, res.name, res.rule_id, res.secondary_exposures, res.evaluation_details)
243
+ if options.log_exposure
244
+ @logger.log_config_exposure(user, res.name, res.rule_id, res.secondary_exposures, res.evaluation_details)
245
+ end
158
246
  end
159
247
 
160
248
  DynamicConfig.new(res.name, res.json_value, res.rule_id)
@@ -168,7 +256,7 @@ class StatsigDriver
168
256
  !user.user_id.is_a?(String) &&
169
257
  (!user.custom_ids.is_a?(Hash) || user.custom_ids.size == 0)
170
258
  )
171
- raise 'Must provide a valid StatsigUser with a user_id or at least a custom ID. See https://docs.statsig.com/messages/serverRequiredUserID/ for more details.'
259
+ raise Statsig::ValueError.new('Must provide a valid StatsigUser with a user_id or at least a custom ID. See https://docs.statsig.com/messages/serverRequiredUserID/ for more details.')
172
260
  end
173
261
  end
174
262
 
@@ -214,4 +302,12 @@ class StatsigDriver
214
302
  network_result['rule_id'],
215
303
  )
216
304
  end
217
- end
305
+
306
+ def log_init_diagnostics
307
+ if @options.disable_diagnostics_logging
308
+ return
309
+ end
310
+
311
+ @logger.log_diagnostics_event(@init_diagnostics)
312
+ end
313
+ end
@@ -0,0 +1,11 @@
1
+ module Statsig
2
+ class UninitializedError < StandardError
3
+ def initialize(msg="Must call initialize first.")
4
+ super
5
+ end
6
+ end
7
+
8
+ class ValueError < StandardError
9
+
10
+ end
11
+ end
@@ -5,6 +5,7 @@ require 'concurrent-ruby'
5
5
  $gate_exposure_event = 'statsig::gate_exposure'
6
6
  $config_exposure_event = 'statsig::config_exposure'
7
7
  $layer_exposure_event = 'statsig::layer_exposure'
8
+ $diagnostics_event = 'statsig::diagnostics'
8
9
 
9
10
  module Statsig
10
11
  class StatsigLogger
@@ -31,7 +32,7 @@ module Statsig
31
32
  end
32
33
  end
33
34
 
34
- def log_gate_exposure(user, gate_name, value, rule_id, secondary_exposures, eval_details)
35
+ def log_gate_exposure(user, gate_name, value, rule_id, secondary_exposures, eval_details, context = nil)
35
36
  event = StatsigEvent.new($gate_exposure_event)
36
37
  event.user = user
37
38
  event.metadata = {
@@ -43,10 +44,11 @@ module Statsig
43
44
  event.secondary_exposures = secondary_exposures.is_a?(Array) ? secondary_exposures : []
44
45
 
45
46
  safe_add_eval_details(eval_details, event)
47
+ safe_add_exposure_context(context, event)
46
48
  log_event(event)
47
49
  end
48
50
 
49
- def log_config_exposure(user, config_name, rule_id, secondary_exposures, eval_details)
51
+ def log_config_exposure(user, config_name, rule_id, secondary_exposures, eval_details, context = nil)
50
52
  event = StatsigEvent.new($config_exposure_event)
51
53
  event.user = user
52
54
  event.metadata = {
@@ -57,10 +59,11 @@ module Statsig
57
59
  event.secondary_exposures = secondary_exposures.is_a?(Array) ? secondary_exposures : []
58
60
 
59
61
  safe_add_eval_details(eval_details, event)
62
+ safe_add_exposure_context(context, event)
60
63
  log_event(event)
61
64
  end
62
65
 
63
- def log_layer_exposure(user, layer, parameter_name, config_evaluation)
66
+ def log_layer_exposure(user, layer, parameter_name, config_evaluation, context = nil)
64
67
  exposures = config_evaluation.undelegated_sec_exps
65
68
  allocated_experiment = ''
66
69
  is_explicit = (config_evaluation.explicit_parameters&.include? parameter_name) || false
@@ -82,6 +85,14 @@ module Statsig
82
85
  event.secondary_exposures = exposures.is_a?(Array) ? exposures : []
83
86
 
84
87
  safe_add_eval_details(config_evaluation.evaluation_details, event)
88
+ safe_add_exposure_context(context, event)
89
+ log_event(event)
90
+ end
91
+
92
+ def log_diagnostics_event(diagnostics, user = nil)
93
+ event = StatsigEvent.new($diagnostics_event)
94
+ event.user = user
95
+ event.metadata = diagnostics.serialize
85
96
  log_event(event)
86
97
  end
87
98
 
@@ -136,5 +147,15 @@ module Statsig
136
147
  event.metadata['initTime'] = eval_details.init_time
137
148
  event.metadata['serverTime'] = eval_details.server_time
138
149
  end
150
+
151
+ def safe_add_exposure_context(context, event)
152
+ if context.nil?
153
+ return
154
+ end
155
+
156
+ if context['is_manual_exposure']
157
+ event.metadata['isManualExposure'] = 'true'
158
+ end
159
+ end
139
160
  end
140
161
  end
@@ -64,6 +64,11 @@ class StatsigOptions
64
64
  # default: 3
65
65
  attr_accessor :idlist_threadpool_size
66
66
 
67
+ sig { returns(T::Boolean) }
68
+ # Should diagnostics be logged. These include performance metrics for initialize.
69
+ # default: false
70
+ attr_accessor :disable_diagnostics_logging
71
+
67
72
  sig do
68
73
  params(
69
74
  environment: T.any(T::Hash[String, String], NilClass),
@@ -76,7 +81,8 @@ class StatsigOptions
76
81
  bootstrap_values: T.any(String, NilClass),
77
82
  rules_updated_callback: T.any(Method, Proc, NilClass),
78
83
  data_store: T.any(Statsig::Interfaces::IDataStore, NilClass),
79
- idlist_threadpool_size: Integer
84
+ idlist_threadpool_size: Integer,
85
+ disable_diagnostics_logging: T::Boolean
80
86
  ).void
81
87
  end
82
88
 
@@ -91,7 +97,8 @@ class StatsigOptions
91
97
  bootstrap_values: nil,
92
98
  rules_updated_callback: nil,
93
99
  data_store: nil,
94
- idlist_threadpool_size: 3)
100
+ idlist_threadpool_size: 3,
101
+ disable_diagnostics_logging: false)
95
102
  @environment = environment.is_a?(Hash) ? environment : nil
96
103
  @api_url_base = api_url_base
97
104
  @rulesets_sync_interval = rulesets_sync_interval
@@ -103,5 +110,6 @@ class StatsigOptions
103
110
  @rules_updated_callback = rules_updated_callback
104
111
  @data_store = data_store
105
112
  @idlist_threadpool_size = idlist_threadpool_size
113
+ @disable_diagnostics_logging = disable_diagnostics_logging
106
114
  end
107
115
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: statsig
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.17.0
4
+ version: 1.19.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Statsig, Inc
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2022-11-01 00:00:00.000000000 Z
11
+ date: 2022-12-01 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: bundler
@@ -66,6 +66,34 @@ dependencies:
66
66
  - - "~>"
67
67
  - !ruby/object:Gem::Version
68
68
  version: '1.0'
69
+ - !ruby/object:Gem::Dependency
70
+ name: sorbet
71
+ requirement: !ruby/object:Gem::Requirement
72
+ requirements:
73
+ - - '='
74
+ - !ruby/object:Gem::Version
75
+ version: 0.5.10461
76
+ type: :development
77
+ prerelease: false
78
+ version_requirements: !ruby/object:Gem::Requirement
79
+ requirements:
80
+ - - '='
81
+ - !ruby/object:Gem::Version
82
+ version: 0.5.10461
83
+ - !ruby/object:Gem::Dependency
84
+ name: tapioca
85
+ requirement: !ruby/object:Gem::Requirement
86
+ requirements:
87
+ - - '='
88
+ - !ruby/object:Gem::Version
89
+ version: 0.4.27
90
+ type: :development
91
+ prerelease: false
92
+ version_requirements: !ruby/object:Gem::Requirement
93
+ requirements:
94
+ - - '='
95
+ - !ruby/object:Gem::Version
96
+ version: 0.4.27
69
97
  - !ruby/object:Gem::Dependency
70
98
  name: user_agent_parser
71
99
  requirement: !ruby/object:Gem::Requirement
@@ -118,16 +146,16 @@ dependencies:
118
146
  name: sorbet-runtime
119
147
  requirement: !ruby/object:Gem::Requirement
120
148
  requirements:
121
- - - ">="
149
+ - - '='
122
150
  - !ruby/object:Gem::Version
123
- version: '0'
151
+ version: 0.5.10461
124
152
  type: :runtime
125
153
  prerelease: false
126
154
  version_requirements: !ruby/object:Gem::Requirement
127
155
  requirements:
128
- - - ">="
156
+ - - '='
129
157
  - !ruby/object:Gem::Version
130
- version: '0'
158
+ version: 0.5.10461
131
159
  - !ruby/object:Gem::Dependency
132
160
  name: concurrent-ruby
133
161
  requirement: !ruby/object:Gem::Requirement
@@ -150,7 +178,9 @@ extra_rdoc_files: []
150
178
  files:
151
179
  - lib/client_initialize_helpers.rb
152
180
  - lib/config_result.rb
181
+ - lib/diagnostics.rb
153
182
  - lib/dynamic_config.rb
183
+ - lib/error_boundary.rb
154
184
  - lib/evaluation_details.rb
155
185
  - lib/evaluation_helpers.rb
156
186
  - lib/evaluator.rb
@@ -161,6 +191,7 @@ files:
161
191
  - lib/spec_store.rb
162
192
  - lib/statsig.rb
163
193
  - lib/statsig_driver.rb
194
+ - lib/statsig_errors.rb
164
195
  - lib/statsig_event.rb
165
196
  - lib/statsig_logger.rb
166
197
  - lib/statsig_options.rb
@@ -184,7 +215,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
184
215
  - !ruby/object:Gem::Version
185
216
  version: '0'
186
217
  requirements: []
187
- rubygems_version: 3.3.22
218
+ rubygems_version: 3.3.7
188
219
  signing_key:
189
220
  specification_version: 4
190
221
  summary: Statsig server SDK for Ruby