statsig 1.10.0 → 1.20.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -1,50 +1,69 @@
1
+ # typed: true
1
2
  require 'statsig_event'
3
+ require 'concurrent-ruby'
2
4
 
3
5
  $gate_exposure_event = 'statsig::gate_exposure'
4
6
  $config_exposure_event = 'statsig::config_exposure'
5
7
  $layer_exposure_event = 'statsig::layer_exposure'
8
+ $diagnostics_event = 'statsig::diagnostics'
6
9
 
7
10
  module Statsig
8
11
  class StatsigLogger
9
- def initialize(network)
12
+ def initialize(network, options)
10
13
  @network = network
11
14
  @events = []
15
+ @options = options
16
+
17
+ @logging_pool = Concurrent::ThreadPoolExecutor.new(
18
+ min_threads: [2, Concurrent.processor_count].min,
19
+ max_threads: [2, Concurrent.processor_count].max,
20
+ # max jobs pending before we start dropping
21
+ max_queue: [2, Concurrent.processor_count].max * 5,
22
+ fallback_policy: :discard,
23
+ )
24
+
12
25
  @background_flush = periodic_flush
13
26
  end
14
27
 
15
28
  def log_event(event)
16
29
  @events.push(event)
17
- if @events.length >= 1000
18
- flush
30
+ if @events.length >= @options.logging_max_buffer_size
31
+ flush_async
19
32
  end
20
33
  end
21
34
 
22
- def log_gate_exposure(user, gate_name, value, rule_id, secondary_exposures)
35
+ def log_gate_exposure(user, gate_name, value, rule_id, secondary_exposures, eval_details, context = nil)
23
36
  event = StatsigEvent.new($gate_exposure_event)
24
37
  event.user = user
25
38
  event.metadata = {
26
39
  'gate' => gate_name,
27
40
  'gateValue' => value.to_s,
28
- 'ruleID' => rule_id
41
+ 'ruleID' => rule_id,
29
42
  }
30
43
  event.statsig_metadata = Statsig.get_statsig_metadata
31
44
  event.secondary_exposures = secondary_exposures.is_a?(Array) ? secondary_exposures : []
45
+
46
+ safe_add_eval_details(eval_details, event)
47
+ safe_add_exposure_context(context, event)
32
48
  log_event(event)
33
49
  end
34
50
 
35
- def log_config_exposure(user, config_name, rule_id, secondary_exposures)
51
+ def log_config_exposure(user, config_name, rule_id, secondary_exposures, eval_details, context = nil)
36
52
  event = StatsigEvent.new($config_exposure_event)
37
53
  event.user = user
38
54
  event.metadata = {
39
55
  'config' => config_name,
40
- 'ruleID' => rule_id
56
+ 'ruleID' => rule_id,
41
57
  }
42
58
  event.statsig_metadata = Statsig.get_statsig_metadata
43
59
  event.secondary_exposures = secondary_exposures.is_a?(Array) ? secondary_exposures : []
60
+
61
+ safe_add_eval_details(eval_details, event)
62
+ safe_add_exposure_context(context, event)
44
63
  log_event(event)
45
64
  end
46
65
 
47
- def log_layer_exposure(user, layer, parameter_name, config_evaluation)
66
+ def log_layer_exposure(user, layer, parameter_name, config_evaluation, context = nil)
48
67
  exposures = config_evaluation.undelegated_sec_exps
49
68
  allocated_experiment = ''
50
69
  is_explicit = (config_evaluation.explicit_parameters&.include? parameter_name) || false
@@ -60,26 +79,46 @@ module Statsig
60
79
  'ruleID' => layer.rule_id,
61
80
  'allocatedExperiment' => allocated_experiment,
62
81
  'parameterName' => parameter_name,
63
- 'isExplicitParameter' => String(is_explicit)
82
+ 'isExplicitParameter' => String(is_explicit),
64
83
  }
65
84
  event.statsig_metadata = Statsig.get_statsig_metadata
66
85
  event.secondary_exposures = exposures.is_a?(Array) ? exposures : []
86
+
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
67
96
  log_event(event)
68
97
  end
69
98
 
70
99
  def periodic_flush
71
100
  Thread.new do
72
101
  loop do
73
- sleep 60
102
+ sleep @options.logging_interval_seconds
74
103
  flush
75
104
  end
76
105
  end
77
106
  end
78
107
 
79
- def flush(closing = false)
80
- if closing
81
- @background_flush&.exit
108
+ def shutdown
109
+ @background_flush&.exit
110
+ @logging_pool.shutdown
111
+ @logging_pool.wait_for_termination(timeout = 3)
112
+ flush
113
+ end
114
+
115
+ def flush_async
116
+ @logging_pool.post do
117
+ flush
82
118
  end
119
+ end
120
+
121
+ def flush
83
122
  if @events.length == 0
84
123
  return
85
124
  end
@@ -89,5 +128,34 @@ module Statsig
89
128
 
90
129
  @network.post_logs(flush_events)
91
130
  end
131
+
132
+ def maybe_restart_background_threads
133
+ if @background_flush.nil? or !@background_flush.alive?
134
+ @background_flush = periodic_flush
135
+ end
136
+ end
137
+
138
+ private
139
+
140
+ def safe_add_eval_details(eval_details, event)
141
+ if eval_details.nil?
142
+ return
143
+ end
144
+
145
+ event.metadata['reason'] = eval_details.reason
146
+ event.metadata['configSyncTime'] = eval_details.config_sync_time
147
+ event.metadata['initTime'] = eval_details.init_time
148
+ event.metadata['serverTime'] = eval_details.server_time
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
92
160
  end
93
161
  end
@@ -1,17 +1,124 @@
1
+ # typed: true
2
+
3
+ require 'sorbet-runtime'
4
+ require_relative 'interfaces/data_store'
5
+
6
+ ##
7
+ # Configuration options for the Statsig SDK.
1
8
  class StatsigOptions
2
- attr_reader :environment
3
- attr_reader :api_url_base
4
- attr_reader :rulesets_sync_interval
5
- attr_reader :idlists_sync_interval
9
+ extend T::Sig
10
+
11
+ sig { returns(T.any(T::Hash[String, String], NilClass)) }
12
+ # Hash you can use to set environment variables that apply to all of your users in
13
+ # the same session and will be used for targeting purposes.
14
+ # eg. { "tier" => "development" }
15
+ attr_accessor :environment
16
+
17
+ sig { returns(String) }
18
+ # The base url used to make network calls to Statsig.
19
+ # default: https://statsigapi.net/v1
20
+ attr_accessor :api_url_base
21
+
22
+ sig { returns(T.any(Float, Integer)) }
23
+ # The interval (in seconds) to poll for changes to your Statsig configuration
24
+ # default: 10s
25
+ attr_accessor :rulesets_sync_interval
26
+
27
+ sig { returns(T.any(Float, Integer)) }
28
+ # The interval (in seconds) to poll for changes to your id lists
29
+ # default: 60s
30
+ attr_accessor :idlists_sync_interval
31
+
32
+ sig { returns(T.any(Float, Integer)) }
33
+ # How often to flush logs to Statsig
34
+ # default: 60s
35
+ attr_accessor :logging_interval_seconds
36
+
37
+ sig { returns(Integer) }
38
+ # The maximum number of events to batch before flushing logs to the server
39
+ # default: 1000
40
+ attr_accessor :logging_max_buffer_size
41
+
42
+ sig { returns(T::Boolean) }
43
+ # Restricts the SDK to not issue any network requests and only respond with default values (or local overrides)
44
+ # default: false
45
+ attr_accessor :local_mode
46
+
47
+ sig { returns(T.any(String, NilClass)) }
48
+ # A string that represents all rules for all feature gates, dynamic configs and experiments.
49
+ # It can be provided to bootstrap the Statsig server SDK at initialization in case your server runs
50
+ # into network issue or Statsig is down temporarily.
51
+ attr_accessor :bootstrap_values
52
+
53
+ sig { returns(T.any(Method, Proc, NilClass)) }
54
+ # A callback function that will be called anytime the rulesets are updated.
55
+ attr_accessor :rules_updated_callback
56
+
57
+ sig { returns(T.any(Statsig::Interfaces::IDataStore, NilClass)) }
58
+ # A class that extends IDataStore. Can be used to provide values from a
59
+ # common data store (like Redis) to initialize the Statsig SDK.
60
+ attr_accessor :data_store
61
+
62
+ sig { returns(Integer) }
63
+ # The number of threads allocated to syncing IDLists.
64
+ # default: 3
65
+ attr_accessor :idlist_threadpool_size
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
+
72
+ sig { returns(T::Boolean) }
73
+ # Statsig utilizes Sorbet (https://sorbet.org) to ensure type safety of the SDK. This includes logging
74
+ # to console when errors are detected. You can disable this logging by setting this flag to true.
75
+ # default: false
76
+ attr_accessor :disable_sorbet_logging_handlers
77
+
78
+ sig do
79
+ params(
80
+ environment: T.any(T::Hash[String, String], NilClass),
81
+ api_url_base: String,
82
+ rulesets_sync_interval: T.any(Float, Integer),
83
+ idlists_sync_interval: T.any(Float, Integer),
84
+ logging_interval_seconds: T.any(Float, Integer),
85
+ logging_max_buffer_size: Integer,
86
+ local_mode: T::Boolean,
87
+ bootstrap_values: T.any(String, NilClass),
88
+ rules_updated_callback: T.any(Method, Proc, NilClass),
89
+ data_store: T.any(Statsig::Interfaces::IDataStore, NilClass),
90
+ idlist_threadpool_size: Integer,
91
+ disable_diagnostics_logging: T::Boolean,
92
+ disable_sorbet_logging_handlers: T::Boolean
93
+ ).void
94
+ end
6
95
 
7
96
  def initialize(
8
- environment=nil,
9
- api_url_base='https://statsigapi.net/v1',
97
+ environment = nil,
98
+ api_url_base = 'https://statsigapi.net/v1',
10
99
  rulesets_sync_interval: 10,
11
- idlists_sync_interval: 60)
100
+ idlists_sync_interval: 60,
101
+ logging_interval_seconds: 60,
102
+ logging_max_buffer_size: 1000,
103
+ local_mode: false,
104
+ bootstrap_values: nil,
105
+ rules_updated_callback: nil,
106
+ data_store: nil,
107
+ idlist_threadpool_size: 3,
108
+ disable_diagnostics_logging: false,
109
+ disable_sorbet_logging_handlers: false)
12
110
  @environment = environment.is_a?(Hash) ? environment : nil
13
111
  @api_url_base = api_url_base
14
112
  @rulesets_sync_interval = rulesets_sync_interval
15
113
  @idlists_sync_interval = idlists_sync_interval
114
+ @logging_interval_seconds = logging_interval_seconds
115
+ @logging_max_buffer_size = [logging_max_buffer_size, 1000].min
116
+ @local_mode = local_mode
117
+ @bootstrap_values = bootstrap_values
118
+ @rules_updated_callback = rules_updated_callback
119
+ @data_store = data_store
120
+ @idlist_threadpool_size = idlist_threadpool_size
121
+ @disable_diagnostics_logging = disable_diagnostics_logging
122
+ @disable_sorbet_logging_handlers = disable_sorbet_logging_handlers
16
123
  end
17
124
  end
data/lib/statsig_user.rb CHANGED
@@ -1,40 +1,78 @@
1
+ # typed: true
2
+
3
+ require 'sorbet-runtime'
4
+
5
+ ##
6
+ # The user object to be evaluated against your Statsig configurations (gates/experiments/dynamic configs).
1
7
  class StatsigUser
8
+ extend T::Sig
9
+
10
+ sig { returns(T.any(String, NilClass)) }
11
+ # An identifier for this user. Evaluated against the User ID criteria. (https://docs.statsig.com/feature-gates/conditions#userid)
2
12
  attr_accessor :user_id
13
+
14
+ sig { returns(T.any(String, NilClass)) }
15
+ # An identifier for this user. Evaluated against the Email criteria. (https://docs.statsig.com/feature-gates/conditions#email)
3
16
  attr_accessor :email
17
+
18
+ sig { returns(T.any(String, NilClass)) }
19
+ # An IP address associated with this user. Evaluated against the IP Address criteria. (https://docs.statsig.com/feature-gates/conditions#ip)
4
20
  attr_accessor :ip
21
+
22
+ sig { returns(T.any(String, NilClass)) }
23
+ # A user agent string associated with this user. Evaluated against Browser Version and Name (https://docs.statsig.com/feature-gates/conditions#browser-version)
5
24
  attr_accessor :user_agent
25
+
26
+ sig { returns(T.any(String, NilClass)) }
27
+ # The country code associated with this user (e.g New Zealand => NZ). Evaluated against the Country criteria. (https://docs.statsig.com/feature-gates/conditions#country)
6
28
  attr_accessor :country
29
+
30
+ sig { returns(T.any(String, NilClass)) }
31
+ # An locale for this user.
7
32
  attr_accessor :locale
33
+
34
+ sig { returns(T.any(String, NilClass)) }
35
+ # The current app version the user is interacting with. Evaluated against the App Version criteria. (https://docs.statsig.com/feature-gates/conditions#app-version)
8
36
  attr_accessor :app_version
37
+
38
+ sig { returns(T.any(T::Hash[String, String], NilClass)) }
39
+ # A Hash you can use to set environment variables that apply to this user. e.g. { "tier" => "development" }
9
40
  attr_accessor :statsig_environment
41
+
42
+ sig { returns(T.any(T::Hash[String, String], NilClass)) }
43
+ # Any Custom IDs to associated with the user. (See https://docs.statsig.com/guides/experiment-on-custom-id-types)
10
44
  attr_accessor :custom_ids
45
+
46
+ sig { returns(T.any(T::Hash[String, String], NilClass)) }
47
+ # Any value you wish to use in evaluation, but do not want logged with events, can be stored in this field.
11
48
  attr_accessor :private_attributes
12
49
 
50
+ sig { returns(T.any(T::Hash[String, T.untyped], NilClass)) }
51
+
13
52
  def custom
14
53
  @custom
15
54
  end
16
55
 
56
+ sig { params(value: T.any(T::Hash[String, T.untyped], NilClass)).void }
57
+ # Any custom fields for this user. Evaluated against the Custom criteria. (https://docs.statsig.com/feature-gates/conditions#custom)
17
58
  def custom=(value)
18
59
  @custom = value.is_a?(Hash) ? value : Hash.new
19
60
  end
20
61
 
62
+ sig { params(user_hash: T.any(T::Hash[T.any(String, Symbol), T.untyped], NilClass)).void }
63
+
21
64
  def initialize(user_hash)
22
- @statsig_environment = Hash.new
23
- if user_hash.is_a?(Hash)
24
- @user_id = user_hash['userID'] || user_hash['user_id']
25
- @user_id = @user_id.to_s unless @user_id.nil?
26
- @email = user_hash['email']
27
- @ip = user_hash['ip']
28
- @user_agent = user_hash['userAgent'] || user_hash['user_agent']
29
- @country = user_hash['country']
30
- @locale = user_hash['locale']
31
- @app_version = user_hash['appVersion'] || user_hash['app_version']
32
- @custom = user_hash['custom'] if user_hash['custom'].is_a? Hash
33
- @statsig_environment = user_hash['statsigEnvironment']
34
- @private_attributes = user_hash['privateAttributes'] if user_hash['privateAttributes'].is_a? Hash
35
- custom_ids = user_hash['customIDs'] || user_hash['custom_ids']
36
- @custom_ids = custom_ids if custom_ids.is_a? Hash
37
- end
65
+ @user_id = from_hash(user_hash, [:user_id, :userID], String)
66
+ @email = from_hash(user_hash, [:email], String)
67
+ @ip = from_hash(user_hash, [:ip], String)
68
+ @user_agent = from_hash(user_hash, [:user_agent, :userAgent], String)
69
+ @country = from_hash(user_hash, [:country], String)
70
+ @locale = from_hash(user_hash, [:locale], String)
71
+ @app_version = from_hash(user_hash, [:app_version, :appVersion], String)
72
+ @custom = from_hash(user_hash, [:custom], Hash)
73
+ @private_attributes = from_hash(user_hash, [:private_attributes, :privateAttributes], Hash)
74
+ @custom_ids = from_hash(user_hash, [:custom_ids, :customIDs], Hash)
75
+ @statsig_environment = from_hash(user_hash, [:statsig_environment, :statsigEnvironment], Hash)
38
76
  end
39
77
 
40
78
  def serialize(for_logging)
@@ -76,4 +114,29 @@ class StatsigUser
76
114
  'privateAttributes' => @private_attributes,
77
115
  }
78
116
  end
117
+
118
+ private
119
+
120
+ sig {
121
+ params(user_hash: T.any(T::Hash[T.any(String, Symbol), T.untyped], NilClass),
122
+ keys: T::Array[Symbol],
123
+ type: T.untyped)
124
+ .returns(T.untyped)
125
+ }
126
+ # Pulls fields from the user hash via Symbols and Strings
127
+ def from_hash(user_hash, keys, type)
128
+ if user_hash.nil?
129
+ return nil
130
+ end
131
+
132
+ keys.each do |key|
133
+ val = user_hash[key] || user_hash[key.to_s]
134
+ if not val.nil? and val.is_a? type
135
+ return val
136
+ end
137
+ end
138
+
139
+ nil
140
+ end
141
+
79
142
  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.10.0
4
+ version: 1.20.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-07-07 00:00:00.000000000 Z
11
+ date: 2023-01-18 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: bundler
@@ -16,14 +16,14 @@ dependencies:
16
16
  requirements:
17
17
  - - "~>"
18
18
  - !ruby/object:Gem::Version
19
- version: '2.1'
19
+ version: '2.0'
20
20
  type: :development
21
21
  prerelease: false
22
22
  version_requirements: !ruby/object:Gem::Requirement
23
23
  requirements:
24
24
  - - "~>"
25
25
  - !ruby/object:Gem::Version
26
- version: '2.1'
26
+ version: '2.0'
27
27
  - !ruby/object:Gem::Dependency
28
28
  name: webmock
29
29
  requirement: !ruby/object:Gem::Requirement
@@ -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
@@ -102,34 +130,68 @@ dependencies:
102
130
  version: '6.0'
103
131
  - !ruby/object:Gem::Dependency
104
132
  name: ip3country
133
+ requirement: !ruby/object:Gem::Requirement
134
+ requirements:
135
+ - - '='
136
+ - !ruby/object:Gem::Version
137
+ version: 0.1.1
138
+ type: :runtime
139
+ prerelease: false
140
+ version_requirements: !ruby/object:Gem::Requirement
141
+ requirements:
142
+ - - '='
143
+ - !ruby/object:Gem::Version
144
+ version: 0.1.1
145
+ - !ruby/object:Gem::Dependency
146
+ name: sorbet-runtime
147
+ requirement: !ruby/object:Gem::Requirement
148
+ requirements:
149
+ - - "~>"
150
+ - !ruby/object:Gem::Version
151
+ version: 0.5.10461
152
+ type: :runtime
153
+ prerelease: false
154
+ version_requirements: !ruby/object:Gem::Requirement
155
+ requirements:
156
+ - - "~>"
157
+ - !ruby/object:Gem::Version
158
+ version: 0.5.10461
159
+ - !ruby/object:Gem::Dependency
160
+ name: concurrent-ruby
105
161
  requirement: !ruby/object:Gem::Requirement
106
162
  requirements:
107
163
  - - "~>"
108
164
  - !ruby/object:Gem::Version
109
- version: '0.1'
165
+ version: '1.1'
110
166
  type: :runtime
111
167
  prerelease: false
112
168
  version_requirements: !ruby/object:Gem::Requirement
113
169
  requirements:
114
170
  - - "~>"
115
171
  - !ruby/object:Gem::Version
116
- version: '0.1'
172
+ version: '1.1'
117
173
  description: Statsig server SDK for feature gates and experimentation in Ruby
118
174
  email: support@statsig.com
119
175
  executables: []
120
176
  extensions: []
121
177
  extra_rdoc_files: []
122
178
  files:
179
+ - lib/client_initialize_helpers.rb
123
180
  - lib/config_result.rb
181
+ - lib/diagnostics.rb
124
182
  - lib/dynamic_config.rb
183
+ - lib/error_boundary.rb
184
+ - lib/evaluation_details.rb
125
185
  - lib/evaluation_helpers.rb
126
186
  - lib/evaluator.rb
127
187
  - lib/id_list.rb
188
+ - lib/interfaces/data_store.rb
128
189
  - lib/layer.rb
129
190
  - lib/network.rb
130
191
  - lib/spec_store.rb
131
192
  - lib/statsig.rb
132
193
  - lib/statsig_driver.rb
194
+ - lib/statsig_errors.rb
133
195
  - lib/statsig_event.rb
134
196
  - lib/statsig_logger.rb
135
197
  - lib/statsig_options.rb
@@ -146,14 +208,14 @@ required_ruby_version: !ruby/object:Gem::Requirement
146
208
  requirements:
147
209
  - - ">="
148
210
  - !ruby/object:Gem::Version
149
- version: '0'
211
+ version: 2.5.0
150
212
  required_rubygems_version: !ruby/object:Gem::Requirement
151
213
  requirements:
152
214
  - - ">="
153
215
  - !ruby/object:Gem::Version
154
216
  version: '0'
155
217
  requirements: []
156
- rubygems_version: 3.2.3
218
+ rubygems_version: 3.3.26
157
219
  signing_key:
158
220
  specification_version: 4
159
221
  summary: Statsig server SDK for Ruby