statsig 2.1.0 → 2.5.5

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.
@@ -1,15 +1,24 @@
1
1
  require 'constants'
2
2
  require 'statsig_event'
3
+ require 'ttl_set'
3
4
  require 'concurrent-ruby'
5
+ require 'hash_utils'
4
6
 
5
7
  $gate_exposure_event = 'statsig::gate_exposure'
6
8
  $config_exposure_event = 'statsig::config_exposure'
7
9
  $layer_exposure_event = 'statsig::layer_exposure'
8
10
  $diagnostics_event = 'statsig::diagnostics'
9
11
  $ignored_metadata_keys = [:serverTime, :configSyncTime, :initTime, :reason]
12
+
13
+ class EntityType
14
+ GATE = "gate"
15
+ CONFIG = "config"
16
+ LAYER = "layer"
17
+ end
18
+
10
19
  module Statsig
11
20
  class StatsigLogger
12
- def initialize(network, options, error_boundary)
21
+ def initialize(network, options, error_boundary, sdk_configs)
13
22
  @network = network
14
23
  @events = []
15
24
  @options = options
@@ -25,10 +34,12 @@ module Statsig
25
34
 
26
35
  @error_boundary = error_boundary
27
36
  @background_flush = periodic_flush
28
- @deduper = Concurrent::Set.new()
37
+ @deduper = Concurrent::Set.new
38
+ @sampling_key_set = Statsig::TTLSet.new
29
39
  @interval = 0
30
40
  @flush_mutex = Mutex.new
31
41
  @debug_info = nil
42
+ @sdk_configs = sdk_configs
32
43
  end
33
44
 
34
45
  def log_event(event)
@@ -38,43 +49,58 @@ module Statsig
38
49
  end
39
50
  end
40
51
 
41
- def log_gate_exposure(user, gate_name, value, rule_id, secondary_exposures, eval_details, context = nil)
52
+ def log_gate_exposure(user, result, context = nil)
53
+ should_log, logged_sampling_rate, shadow_logged = determine_sampling(EntityType::GATE, result.name, result, user)
54
+ return unless should_log
42
55
  event = StatsigEvent.new($gate_exposure_event)
43
56
  event.user = user
44
57
  metadata = {
45
- gate: gate_name,
46
- gateValue: value.to_s,
47
- ruleID: rule_id || Statsig::Const::EMPTY_STR,
58
+ gate: result.name,
59
+ gateValue: result.gate_value.to_s,
60
+ ruleID: result.rule_id || Statsig::Const::EMPTY_STR,
48
61
  }
62
+ if result.config_version != nil
63
+ metadata[:configVersion] = result.config_version.to_s
64
+ end
49
65
  if @debug_info != nil
50
66
  metadata[:debugInfo] = @debug_info
51
67
  end
52
68
  return false if not is_unique_exposure(user, $gate_exposure_event, metadata)
53
69
  event.metadata = metadata
70
+ event.statsig_metadata = {}
54
71
 
55
- event.secondary_exposures = secondary_exposures.is_a?(Array) ? secondary_exposures : []
72
+ event.secondary_exposures = result.secondary_exposures.is_a?(Array) ? result.secondary_exposures : []
56
73
 
57
- safe_add_eval_details(eval_details, event)
74
+ safe_add_eval_details(result.evaluation_details, event)
58
75
  safe_add_exposure_context(context, event)
76
+ safe_add_sampling_metadata(event, logged_sampling_rate, shadow_logged)
59
77
  log_event(event)
60
78
  end
61
79
 
62
- def log_config_exposure(user, config_name, rule_id, secondary_exposures, eval_details, context = nil)
80
+ def log_config_exposure(user, result, context = nil)
81
+ should_log, logged_sampling_rate, shadow_logged = determine_sampling(EntityType::CONFIG, result.name, result, user)
82
+ return unless should_log
63
83
  event = StatsigEvent.new($config_exposure_event)
64
84
  event.user = user
65
85
  metadata = {
66
- config: config_name,
67
- ruleID: rule_id || Statsig::Const::EMPTY_STR,
86
+ config: result.name,
87
+ ruleID: result.rule_id || Statsig::Const::EMPTY_STR,
88
+ rulePassed: result.gate_value.to_s,
68
89
  }
90
+ if result.config_version != nil
91
+ metadata[:configVersion] = result.config_version.to_s
92
+ end
69
93
  if @debug_info != nil
70
94
  metadata[:debugInfo] = @debug_info
71
95
  end
72
96
  return false if not is_unique_exposure(user, $config_exposure_event, metadata)
73
97
  event.metadata = metadata
74
- event.secondary_exposures = secondary_exposures.is_a?(Array) ? secondary_exposures : []
98
+ event.secondary_exposures = result.secondary_exposures.is_a?(Array) ? result.secondary_exposures : []
99
+ event.statsig_metadata = {}
75
100
 
76
- safe_add_eval_details(eval_details, event)
101
+ safe_add_eval_details(result.evaluation_details, event)
77
102
  safe_add_exposure_context(context, event)
103
+ safe_add_sampling_metadata(event, logged_sampling_rate, shadow_logged)
78
104
  log_event(event)
79
105
  end
80
106
 
@@ -87,6 +113,9 @@ module Statsig
87
113
  exposures = config_evaluation.secondary_exposures
88
114
  end
89
115
 
116
+ should_log, logged_sampling_rate, shadow_logged = determine_sampling(EntityType::LAYER, config_evaluation.name, config_evaluation, user, allocated_experiment, parameter_name)
117
+ return unless should_log
118
+
90
119
  event = StatsigEvent.new($layer_exposure_event)
91
120
  event.user = user
92
121
  metadata = {
@@ -96,15 +125,20 @@ module Statsig
96
125
  parameterName: parameter_name,
97
126
  isExplicitParameter: String(is_explicit)
98
127
  }
128
+ if config_evaluation.config_version != nil
129
+ metadata[:configVersion] = config_evaluation.config_version.to_s
130
+ end
99
131
  if @debug_info != nil
100
132
  metadata[:debugInfo] = @debug_info
101
133
  end
102
134
  return false unless is_unique_exposure(user, $layer_exposure_event, metadata)
103
135
  event.metadata = metadata
104
136
  event.secondary_exposures = exposures.is_a?(Array) ? exposures : []
137
+ event.statsig_metadata = {}
105
138
 
106
139
  safe_add_eval_details(config_evaluation.evaluation_details, event)
107
140
  safe_add_exposure_context(context, event)
141
+ safe_add_sampling_metadata(event, logged_sampling_rate, shadow_logged)
108
142
  log_event(event)
109
143
  end
110
144
 
@@ -140,6 +174,7 @@ module Statsig
140
174
 
141
175
  def shutdown
142
176
  @background_flush&.exit
177
+ @sampling_key_set.shutdown
143
178
  @logging_pool.shutdown
144
179
  @logging_pool.wait_for_termination(timeout = 3)
145
180
  flush
@@ -159,8 +194,11 @@ module Statsig
159
194
 
160
195
  events_clone = @events
161
196
  @events = []
162
- flush_events = events_clone.map { |e| e.serialize }
163
- @network.post_logs(flush_events, @error_boundary)
197
+ serialized_events = events_clone.map { |e| e.serialize }
198
+
199
+ serialized_events.each_slice(@options.logging_max_buffer_size) do |batch|
200
+ @network.post_logs(batch, @error_boundary)
201
+ end
164
202
  end
165
203
  end
166
204
 
@@ -197,6 +235,18 @@ module Statsig
197
235
  end
198
236
  end
199
237
 
238
+ def safe_add_sampling_metadata(event, logged_sampling_rate = nil, shadow_logged = nil)
239
+ unless logged_sampling_rate.nil?
240
+ event.statsig_metadata["samplingRate"] = logged_sampling_rate
241
+ end
242
+
243
+ unless shadow_logged.nil?
244
+ event.statsig_metadata["shadowLogged"] = shadow_logged
245
+ end
246
+
247
+ event.statsig_metadata["samplingMode"] = @sdk_configs.get_config_string_value("sampling_mode")
248
+ end
249
+
200
250
  def is_unique_exposure(user, event_name, metadata)
201
251
  return true if user.nil?
202
252
  @deduper.clear if @deduper.size > 10000
@@ -214,5 +264,69 @@ module Statsig
214
264
  @deduper.add(key)
215
265
  true
216
266
  end
267
+
268
+ def determine_sampling(type, name, result, user, exp_name = "", param_name = "")
269
+ begin
270
+ shadow_should_log, logged_sampling_rate = true, nil
271
+ env = @options.environment&.dig(:tier)
272
+ sampling_mode = @sdk_configs.get_config_string_value("sampling_mode")
273
+ special_case_sampling_rate = @sdk_configs.get_config_int_value("special_case_sampling_rate")
274
+ special_case_rules = ["disabled", "default", ""]
275
+
276
+ if sampling_mode.nil? || sampling_mode == "none" || env != "production"
277
+ return true, nil, nil
278
+ end
279
+
280
+ return true, nil, nil if result.forward_all_exposures
281
+ return true, nil, nil if result.rule_id.end_with?(":override", ":id_override")
282
+ return true, nil, nil if result.has_seen_analytical_gates
283
+
284
+ sampling_set_key = "#{name}_#{result.rule_id}"
285
+ unless @sampling_key_set.contains?(sampling_set_key)
286
+ @sampling_key_set.add(sampling_set_key)
287
+ return true, nil, nil
288
+ end
289
+
290
+ should_sample = result.sampling_rate || special_case_rules.include?(result.rule_id)
291
+ unless should_sample
292
+ return true, nil, nil
293
+ end
294
+
295
+ exposure_key = ""
296
+ case type
297
+ when EntityType::GATE
298
+ exposure_key = Statsig::HashUtils.compute_dedupe_key_for_gate(name, result.rule_id, result.gate_value, user.user_id, user.custom_ids)
299
+ when EntityType::CONFIG
300
+ exposure_key = Statsig::HashUtils.compute_dedupe_key_for_config(name, result.rule_id, user.user_id, user.custom_ids)
301
+ when EntityType::LAYER
302
+ exposure_key = Statsig::HashUtils.compute_dedupe_key_for_layer(name, exp_name, param_name, result.rule_id, user.user_id, user.custom_ids)
303
+ end
304
+
305
+ if result.sampling_rate
306
+ shadow_should_log = Statsig::HashUtils.is_hash_in_sampling_rate(exposure_key, result.sampling_rate)
307
+ logged_sampling_rate = result.sampling_rate
308
+ elsif special_case_rules.include?(result.rule_id) && special_case_sampling_rate
309
+ shadow_should_log = Statsig::HashUtils.is_hash_in_sampling_rate(exposure_key, special_case_sampling_rate)
310
+ logged_sampling_rate = special_case_sampling_rate
311
+ end
312
+
313
+ shadow_logged = if logged_sampling_rate.nil?
314
+ nil
315
+ else
316
+ shadow_should_log ? "logged" : "dropped"
317
+ end
318
+ if sampling_mode == "on"
319
+ return shadow_should_log, logged_sampling_rate, shadow_logged
320
+ elsif sampling_mode == "shadow"
321
+ return true, logged_sampling_rate, shadow_logged
322
+ end
323
+
324
+ return true, nil, nil
325
+ rescue => e
326
+ @error_boundary.log_exception(e, "__determine_sampling")
327
+ return true, nil, nil
328
+ end
329
+ end
330
+
217
331
  end
218
332
  end
@@ -88,6 +88,10 @@ class StatsigOptions
88
88
  # Implements Statsig::Interfaces::IUserPersistentStorage.
89
89
  attr_accessor :user_persistent_storage
90
90
 
91
+ # Disable memoization of evaluation results. When true, each evaluation will be performed fresh.
92
+ # default: false
93
+ attr_accessor :disable_evaluation_memoization
94
+
91
95
  def initialize(
92
96
  environment = nil,
93
97
  download_config_specs_url: nil,
@@ -110,7 +114,8 @@ class StatsigOptions
110
114
  network_timeout: nil,
111
115
  post_logs_retry_limit: 3,
112
116
  post_logs_retry_backoff: nil,
113
- user_persistent_storage: nil
117
+ user_persistent_storage: nil,
118
+ disable_evaluation_memoization: false
114
119
  )
115
120
  @environment = environment.is_a?(Hash) ? environment : nil
116
121
 
@@ -140,6 +145,6 @@ class StatsigOptions
140
145
  @post_logs_retry_limit = post_logs_retry_limit
141
146
  @post_logs_retry_backoff = post_logs_retry_backoff
142
147
  @user_persistent_storage = user_persistent_storage
143
-
148
+ @disable_evaluation_memoization = disable_evaluation_memoization
144
149
  end
145
150
  end
data/lib/ttl_set.rb ADDED
@@ -0,0 +1,36 @@
1
+ require 'concurrent-ruby'
2
+
3
+ RESET_INTERVAL = 60
4
+
5
+ module Statsig
6
+ class TTLSet
7
+ def initialize
8
+ @store = Concurrent::Set.new
9
+ @reset_interval = RESET_INTERVAL
10
+ @background_reset = periodic_reset
11
+ end
12
+
13
+ def add(key)
14
+ @store.add(key)
15
+ end
16
+
17
+ def contains?(key)
18
+ @store.include?(key)
19
+ end
20
+
21
+ def shutdown
22
+ @background_reset&.exit
23
+ end
24
+
25
+ def periodic_reset
26
+ Thread.new do
27
+ loop do
28
+ sleep @reset_interval
29
+ @store.clear
30
+ end
31
+ end
32
+ end
33
+ end
34
+ end
35
+
36
+
@@ -65,15 +65,27 @@ module Statsig
65
65
  if user_persisted_values.nil?
66
66
  user_persisted_values = {}
67
67
  end
68
- user_persisted_values[config_name] = evaluation.to_hash
68
+ hash = evaluation.to_hash
69
+ if hash['json_value'].is_a?(Hash)
70
+ hash['json_value'] = self.class.symbolize_keys(hash['json_value'])
71
+ end
72
+ user_persisted_values[config_name] = hash
69
73
  end
70
74
 
71
75
  private
72
76
 
73
77
  def self.parse(values_string)
74
- return JSON.parse(values_string)
75
- rescue JSON::ParserError
76
- return nil
78
+ return nil if values_string.nil?
79
+
80
+ parsed = JSON.parse(values_string)
81
+ return nil if parsed.nil?
82
+
83
+ parsed.each do |config_name, config_value|
84
+ if config_value.is_a?(Hash) && config_value.key?('json_value')
85
+ config_value['json_value'] = symbolize_keys(config_value['json_value'])
86
+ end
87
+ end
88
+ parsed
77
89
  end
78
90
 
79
91
  def self.stringify(values_object)
@@ -85,5 +97,15 @@ module Statsig
85
97
  def self.get_storage_key(user, id_type)
86
98
  "#{user.get_unit_id(id_type)}:#{id_type}"
87
99
  end
100
+
101
+ def self.symbolize_keys(hash)
102
+ return hash unless hash.is_a?(Hash)
103
+
104
+ symbolized = {}
105
+ hash.each do |key, value|
106
+ symbolized[key.to_sym] = value.is_a?(Hash) ? symbolize_keys(value) : value
107
+ end
108
+ symbolized
109
+ end
88
110
  end
89
111
  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: 2.1.0
4
+ version: 2.5.5
5
5
  platform: ruby
6
6
  authors:
7
7
  - Statsig, Inc
8
- autorequire:
8
+ autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2024-09-26 00:00:00.000000000 Z
11
+ date: 2025-08-11 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: bundler
@@ -226,14 +226,14 @@ dependencies:
226
226
  requirements:
227
227
  - - "~>"
228
228
  - !ruby/object:Gem::Version
229
- version: 2.15.0
229
+ version: 2.18.0
230
230
  type: :runtime
231
231
  prerelease: false
232
232
  version_requirements: !ruby/object:Gem::Requirement
233
233
  requirements:
234
234
  - - "~>"
235
235
  - !ruby/object:Gem::Version
236
- version: 2.15.0
236
+ version: 2.18.0
237
237
  - !ruby/object:Gem::Dependency
238
238
  name: http
239
239
  requirement: !ruby/object:Gem::Requirement
@@ -340,6 +340,7 @@ files:
340
340
  - lib/layer.rb
341
341
  - lib/memo.rb
342
342
  - lib/network.rb
343
+ - lib/sdk_configs.rb
343
344
  - lib/spec_store.rb
344
345
  - lib/statsig.rb
345
346
  - lib/statsig_driver.rb
@@ -348,13 +349,14 @@ files:
348
349
  - lib/statsig_logger.rb
349
350
  - lib/statsig_options.rb
350
351
  - lib/statsig_user.rb
352
+ - lib/ttl_set.rb
351
353
  - lib/ua_parser.rb
352
354
  - lib/user_persistent_storage_utils.rb
353
355
  homepage: https://rubygems.org/gems/statsig
354
356
  licenses:
355
357
  - ISC
356
358
  metadata: {}
357
- post_install_message:
359
+ post_install_message:
358
360
  rdoc_options: []
359
361
  require_paths:
360
362
  - lib
@@ -370,7 +372,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
370
372
  version: '0'
371
373
  requirements: []
372
374
  rubygems_version: 3.2.33
373
- signing_key:
375
+ signing_key:
374
376
  specification_version: 4
375
377
  summary: Statsig server SDK for Ruby
376
378
  test_files: []