statsig 1.17.0 → 1.19.0

Sign up to get free protection for your applications and to get access to all the features.
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