statsig 1.7.0.beta.2 → 1.8.2

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: 889dec377c0c563738b53a97153d54cde36d34518bfa67d1e56a1f2ac6b161b3
4
- data.tar.gz: 9da0ba2bf4b241042e5e2a2ae0822700f2906f0454d5ddfda625b99b69c1fd2f
3
+ metadata.gz: 572aeb29b8bbc76e436127efd30c2fc01d0faa66f95aca1c15fdc094e43000b4
4
+ data.tar.gz: c9cfa2cfb4ae0d974d261119ab056600793febe578f2d8bf0f34c828b3e93ec5
5
5
  SHA512:
6
- metadata.gz: fd0c026a5d93e99c8862fa36e215c185aef5704173c1f56fde1ff01765d276633880233e0d60b65f079b00f63d6618c447dfd10f1f037311b30804b5362db916
7
- data.tar.gz: 4cf98a1164ce38977d05d682d94b761eda719e7cb1188a363c0b565e1ea9ad402f8719454363edf83dfefba4603666e2e1bd64573b04cbb899d008815e20b665
6
+ metadata.gz: 4e90695bad164518cb6f61a62b9b44c8ec2384a30d1e44d17bf4434230ddc85c56c8365b71e4d8768efda2026302e11118c60a69b0cdcbf6898d1bb6fe7d469b
7
+ data.tar.gz: 2a9f90c772b22f920d2f3821c872a5d28b87d2db9da80b0d2ce66cf7ea7d3003dea337c1f2cee44ce2c14c3fd7bc8428190708a7b4da1335d6fdf8075349e94c
data/lib/config_result.rb CHANGED
@@ -1,15 +1,18 @@
1
- class ConfigResult
2
- attr_accessor :name
3
- attr_accessor :gate_value
4
- attr_accessor :json_value
5
- attr_accessor :rule_id
6
- attr_accessor :secondary_exposures
7
1
 
8
- def initialize(name, gate_value = false, json_value = {}, rule_id = '', secondary_exposures = [])
9
- @name = name
10
- @gate_value = gate_value
11
- @json_value = json_value
12
- @rule_id = rule_id
13
- @secondary_exposures = secondary_exposures.is_a?(Array) ? secondary_exposures : []
2
+ module Statsig
3
+ class ConfigResult
4
+ attr_accessor :name
5
+ attr_accessor :gate_value
6
+ attr_accessor :json_value
7
+ attr_accessor :rule_id
8
+ attr_accessor :secondary_exposures
9
+
10
+ def initialize(name, gate_value = false, json_value = {}, rule_id = '', secondary_exposures = [])
11
+ @name = name
12
+ @gate_value = gate_value
13
+ @json_value = json_value
14
+ @rule_id = rule_id
15
+ @secondary_exposures = secondary_exposures.is_a?(Array) ? secondary_exposures : []
16
+ end
14
17
  end
15
18
  end
@@ -2,7 +2,7 @@ require 'time'
2
2
 
3
3
  module EvaluationHelpers
4
4
  def self.compare_numbers(a, b, func)
5
- return false unless self.is_numeric(a) && self.is_numeric(b)
5
+ return false unless is_numeric(a) && is_numeric(b)
6
6
  func.call(a.to_f, b.to_f) rescue false
7
7
  end
8
8
 
@@ -15,20 +15,22 @@ module EvaluationHelpers
15
15
 
16
16
  def self.compare_times(a, b, func)
17
17
  begin
18
- time_1 = self.get_epoch_time(a)
19
- time_2 = self.get_epoch_time(b)
18
+ time_1 = get_epoch_time(a)
19
+ time_2 = get_epoch_time(b)
20
20
  func.call(time_1, time_2)
21
21
  rescue
22
22
  false
23
23
  end
24
24
  end
25
25
 
26
+ private
27
+
26
28
  def self.is_numeric(v)
27
29
  !(v.to_s =~ /\A[-+]?\d*\.?\d+\z/).nil?
28
30
  end
29
31
 
30
32
  def self.get_epoch_time(v)
31
- time = self.is_numeric(v) ? Time.at(v.to_f) : Time.parse(v)
33
+ time = is_numeric(v) ? Time.at(v.to_f) : Time.parse(v)
32
34
  if time.year > Time.now.year + 100
33
35
  # divide by 1000 when the epoch time is in milliseconds instead of seconds
34
36
  return time.to_i / 1000
data/lib/evaluator.rb CHANGED
@@ -10,271 +10,302 @@ require 'user_agent_parser/operating_system'
10
10
  $fetch_from_server = :fetch_from_server
11
11
  $type_dynamic_config = 'dynamic_config'
12
12
 
13
- class Evaluator
14
- def initialize(store)
15
- @spec_store = store
16
- @initialized = true
17
- @ua_parser = UserAgentParser::Parser.new
18
- CountryLookup.initialize
19
- end
13
+ module Statsig
14
+ class Evaluator
15
+ def initialize(network, error_callback)
16
+ @spec_store = Statsig::SpecStore.new(network, error_callback)
17
+ @ua_parser = UserAgentParser::Parser.new
18
+ CountryLookup.initialize
19
+ @initialized = true
20
+ end
20
21
 
21
- def check_gate(user, gate_name)
22
- return nil unless @initialized && @spec_store.has_gate?(gate_name)
23
- self.eval_spec(user, @spec_store.get_gate(gate_name))
24
- end
22
+ def check_gate(user, gate_name)
23
+ return nil unless @initialized && @spec_store.has_gate?(gate_name)
24
+ eval_spec(user, @spec_store.get_gate(gate_name))
25
+ end
25
26
 
26
- def get_config(user, config_name)
27
- return nil unless @initialized && @spec_store.has_config?(config_name)
28
- self.eval_spec(user, @spec_store.get_config(config_name))
29
- end
27
+ def get_config(user, config_name)
28
+ return nil unless @initialized && @spec_store.has_config?(config_name)
29
+ eval_spec(user, @spec_store.get_config(config_name))
30
+ end
31
+
32
+ def shutdown
33
+ @spec_store.shutdown
34
+ end
35
+
36
+ private
30
37
 
31
- def eval_spec(user, config)
32
- default_rule_id = 'default'
33
- exposures = []
34
- if config['enabled']
38
+ def eval_spec(user, config)
39
+ default_rule_id = 'default'
40
+ exposures = []
41
+ if config['enabled']
42
+ i = 0
43
+ until i >= config['rules'].length do
44
+ rule = config['rules'][i]
45
+ result = eval_rule(user, rule)
46
+ return $fetch_from_server if result == $fetch_from_server
47
+ exposures = exposures + result['exposures'] if result['exposures'].is_a? Array
48
+ if result['value']
49
+ pass = eval_pass_percent(user, rule, config['salt'])
50
+ return Statsig::ConfigResult.new(
51
+ config['name'],
52
+ pass,
53
+ pass ? rule['returnValue'] : config['defaultValue'],
54
+ rule['id'],
55
+ exposures
56
+ )
57
+ end
58
+
59
+ i += 1
60
+ end
61
+ else
62
+ default_rule_id = 'disabled'
63
+ end
64
+
65
+ Statsig::ConfigResult.new(config['name'], false, config['defaultValue'], default_rule_id, exposures)
66
+ end
67
+
68
+ def eval_rule(user, rule)
69
+ exposures = []
70
+ pass = true
35
71
  i = 0
36
- until i >= config['rules'].length do
37
- rule = config['rules'][i]
38
- result = self.eval_rule(user, rule)
39
- return $fetch_from_server if result == $fetch_from_server
40
- exposures = exposures + result['exposures'] if result['exposures'].is_a? Array
41
- if result['value']
42
- pass = self.eval_pass_percent(user, rule, config['salt'])
43
- return ConfigResult.new(
44
- config['name'],
45
- pass,
46
- pass ? rule['returnValue'] : config['defaultValue'],
47
- rule['id'],
48
- exposures
49
- )
72
+ until i >= rule['conditions'].length do
73
+ result = eval_condition(user, rule['conditions'][i])
74
+ if result == $fetch_from_server
75
+ return $fetch_from_server
50
76
  end
51
77
 
78
+ if result.is_a?(Hash)
79
+ exposures = exposures + result['exposures'] if result['exposures'].is_a? Array
80
+ pass = false if result['value'] == false
81
+ elsif result == false
82
+ pass = false
83
+ end
52
84
  i += 1
53
85
  end
54
- elsif (default_rule_id = 'disabled')
86
+ { 'value' => pass, 'exposures' => exposures }
55
87
  end
56
88
 
57
- ConfigResult.new(config['name'], false, config['defaultValue'], default_rule_id, exposures)
58
- end
89
+ def eval_condition(user, condition)
90
+ value = nil
91
+ field = condition['field']
92
+ target = condition['targetValue']
93
+ type = condition['type']
94
+ operator = condition['operator']
95
+ additional_values = condition['additionalValues']
96
+ additional_values = Hash.new unless additional_values.is_a? Hash
97
+ idType = condition['idType']
98
+
99
+ return $fetch_from_server unless type.is_a? String
100
+ type = type.downcase
59
101
 
60
- def eval_rule(user, rule)
61
- exposures = []
62
- pass = true
63
- i = 0
64
- until i >= rule['conditions'].length do
65
- result = self.eval_condition(user, rule['conditions'][i])
66
- if result == $fetch_from_server
102
+ case type
103
+ when 'public'
104
+ return true
105
+ when 'fail_gate', 'pass_gate'
106
+ other_gate_result = check_gate(user, target)
107
+ return $fetch_from_server if other_gate_result == $fetch_from_server
108
+
109
+ gate_value = other_gate_result&.gate_value == true
110
+ new_exposure = {
111
+ 'gate' => target,
112
+ 'gateValue' => gate_value ? 'true' : 'false',
113
+ 'ruleID' => other_gate_result&.rule_id
114
+ }
115
+ exposures = other_gate_result&.secondary_exposures&.append(new_exposure)
116
+ return {
117
+ 'value' => type == 'pass_gate' ? gate_value : !gate_value,
118
+ 'exposures' => exposures
119
+ }
120
+ when 'ip_based'
121
+ value = get_value_from_user(user, field) || get_value_from_ip(user, field)
122
+ return $fetch_from_server if value == $fetch_from_server
123
+ when 'ua_based'
124
+ value = get_value_from_user(user, field) || get_value_from_ua(user, field)
125
+ return $fetch_from_server if value == $fetch_from_server
126
+ when 'user_field'
127
+ value = get_value_from_user(user, field)
128
+ when 'environment_field'
129
+ value = get_value_from_environment(user, field)
130
+ when 'current_time'
131
+ value = Time.now.to_f # epoch time in seconds
132
+ when 'user_bucket'
133
+ begin
134
+ salt = additional_values['salt']
135
+ unit_id = get_unit_id(user, idType) || ''
136
+ # there are only 1000 user buckets as opposed to 10k for gate pass %
137
+ value = compute_user_hash("#{salt}.#{unit_id}") % 1000
138
+ rescue
139
+ return false
140
+ end
141
+ when 'unit_id'
142
+ value = get_unit_id(user, idType)
143
+ else
67
144
  return $fetch_from_server
68
145
  end
69
146
 
70
- if result.is_a?(Hash)
71
- exposures = exposures + result['exposures'] if result['exposures'].is_a? Array
72
- pass = false if result['value'] == false
73
- elsif result == false
74
- pass = false
75
- end
76
- i += 1
77
- end
78
- { 'value' => pass, 'exposures' => exposures }
79
- end
147
+ return $fetch_from_server if value == $fetch_from_server || !operator.is_a?(String)
148
+ operator = operator.downcase
80
149
 
81
- def eval_condition(user, condition)
82
- value = nil
83
- field = condition['field']
84
- target = condition['targetValue']
85
- type = condition['type']
86
- operator = condition['operator']
87
- additional_values = condition['additionalValues']
88
- additional_values = Hash.new unless additional_values.is_a? Hash
89
-
90
- return $fetch_from_server unless type.is_a? String
91
- type = type.downcase
92
-
93
- case type
94
- when 'public'
95
- return true
96
- when 'fail_gate', 'pass_gate'
97
- other_gate_result = self.check_gate(user, target)
98
- return $fetch_from_server if other_gate_result == $fetch_from_server
99
-
100
- gate_value = other_gate_result&.gate_value == true
101
- new_exposure = {
102
- 'gate' => target,
103
- 'gateValue' => gate_value ? 'true' : 'false',
104
- 'ruleID' => other_gate_result&.rule_id
105
- }
106
- exposures = other_gate_result&.secondary_exposures&.append(new_exposure)
107
- return {
108
- 'value' => type == 'pass_gate' ? gate_value : !gate_value,
109
- 'exposures' => exposures
110
- }
111
- when 'ip_based'
112
- value = get_value_from_user(user, field) || get_value_from_ip(user, field)
113
- return $fetch_from_server if value == $fetch_from_server
114
- when 'ua_based'
115
- value = get_value_from_user(user, field) || get_value_from_ua(user, field)
116
- return $fetch_from_server if value == $fetch_from_server
117
- when 'user_field'
118
- value = get_value_from_user(user, field)
119
- when 'environment_field'
120
- value = get_value_from_environment(user, field)
121
- when 'current_time'
122
- value = Time.now.to_f # epoch time in seconds
123
- when 'user_bucket'
124
- begin
125
- salt = additional_values['salt']
126
- user_id = user.user_id || ''
127
- # there are only 1000 user buckets as opposed to 10k for gate pass %
128
- value = compute_user_hash("#{salt}.#{user_id}") % 1000
129
- rescue
130
- return false
150
+ case operator
151
+ # numerical comparison
152
+ when 'gt'
153
+ return EvaluationHelpers::compare_numbers(value, target, ->(a, b) { a > b })
154
+ when 'gte'
155
+ return EvaluationHelpers::compare_numbers(value, target, ->(a, b) { a >= b })
156
+ when 'lt'
157
+ return EvaluationHelpers::compare_numbers(value, target, ->(a, b) { a < b })
158
+ when 'lte'
159
+ return EvaluationHelpers::compare_numbers(value, target, ->(a, b) { a <= b })
160
+
161
+ # version comparison
162
+ when 'version_gt'
163
+ return (Gem::Version.new(value) > Gem::Version.new(target) rescue false)
164
+ when 'version_gte'
165
+ return (Gem::Version.new(value) >= Gem::Version.new(target) rescue false)
166
+ when 'version_lt'
167
+ return (Gem::Version.new(value) < Gem::Version.new(target) rescue false)
168
+ when 'version_lte'
169
+ return (Gem::Version.new(value) <= Gem::Version.new(target) rescue false)
170
+ when 'version_eq'
171
+ return (Gem::Version.new(value) == Gem::Version.new(target) rescue false)
172
+ when 'version_neq'
173
+ return (Gem::Version.new(value) != Gem::Version.new(target) rescue false)
174
+
175
+ # array operations
176
+ when 'any'
177
+ return EvaluationHelpers::match_string_in_array(target, value, true, ->(a, b) { a == b })
178
+ when 'none'
179
+ return !EvaluationHelpers::match_string_in_array(target, value, true, ->(a, b) { a == b })
180
+ when 'any_case_sensitive'
181
+ return EvaluationHelpers::match_string_in_array(target, value, false, ->(a, b) { a == b })
182
+ when 'none_case_sensitive'
183
+ return !EvaluationHelpers::match_string_in_array(target, value, false, ->(a, b) { a == b })
184
+
185
+ #string
186
+ when 'str_starts_with_any'
187
+ return EvaluationHelpers::match_string_in_array(target, value, true, ->(a, b) { a.start_with?(b) })
188
+ when 'str_ends_with_any'
189
+ return EvaluationHelpers::match_string_in_array(target, value, true, ->(a, b) { a.end_with?(b) })
190
+ when 'str_contains_any'
191
+ return EvaluationHelpers::match_string_in_array(target, value, true, ->(a, b) { a.include?(b) })
192
+ when 'str_contains_none'
193
+ return !EvaluationHelpers::match_string_in_array(target, value, true, ->(a, b) { a.include?(b) })
194
+ when 'str_matches'
195
+ return (value.is_a?(String) && !(value =~ Regexp.new(target)).nil? rescue false)
196
+ when 'eq'
197
+ return value == target
198
+ when 'neq'
199
+ return value != target
200
+
201
+ # dates
202
+ when 'before'
203
+ return EvaluationHelpers::compare_times(value, target, ->(a, b) { a < b })
204
+ when 'after'
205
+ return EvaluationHelpers::compare_times(value, target, ->(a, b) { a > b })
206
+ when 'on'
207
+ return EvaluationHelpers::compare_times(value, target, ->(a, b) { a.year == b.year && a.month == b.month && a.day == b.day })
208
+ when 'in_segment_list', 'not_in_segment_list'
209
+ begin
210
+ id_list = (@spec_store.get_id_list(target) || {:ids => {}})[:ids]
211
+ hashed_id = Digest::SHA256.base64digest(value.to_s)[0, 8]
212
+ is_in_list = id_list.is_a?(Hash) && id_list[hashed_id] == true
213
+
214
+ return is_in_list if operator == 'in_segment_list'
215
+ return !is_in_list
216
+ rescue StandardError => e
217
+ return false
218
+ end
219
+ else
220
+ return $fetch_from_server
131
221
  end
132
- else
133
- return $fetch_from_server
134
222
  end
135
223
 
136
- return $fetch_from_server if value == $fetch_from_server || !operator.is_a?(String)
137
- operator = operator.downcase
138
-
139
- case operator
140
- # numerical comparison
141
- when 'gt'
142
- return EvaluationHelpers::compare_numbers(value, target, ->(a, b) { a > b })
143
- when 'gte'
144
- return EvaluationHelpers::compare_numbers(value, target, ->(a, b) { a >= b })
145
- when 'lt'
146
- return EvaluationHelpers::compare_numbers(value, target, ->(a, b) { a < b })
147
- when 'lte'
148
- return EvaluationHelpers::compare_numbers(value, target, ->(a, b) { a <= b })
149
-
150
- # version comparison
151
- when 'version_gt'
152
- return (Gem::Version.new(value) > Gem::Version.new(target) rescue false)
153
- when 'version_gte'
154
- return (Gem::Version.new(value) >= Gem::Version.new(target) rescue false)
155
- when 'version_lt'
156
- return (Gem::Version.new(value) < Gem::Version.new(target) rescue false)
157
- when 'version_lte'
158
- return (Gem::Version.new(value) <= Gem::Version.new(target) rescue false)
159
- when 'version_eq'
160
- return (Gem::Version.new(value) == Gem::Version.new(target) rescue false)
161
- when 'version_neq'
162
- return (Gem::Version.new(value) != Gem::Version.new(target) rescue false)
163
-
164
- # array operations
165
- when 'any'
166
- return EvaluationHelpers::match_string_in_array(target, value, true, ->(a, b) { a == b })
167
- when 'none'
168
- return !EvaluationHelpers::match_string_in_array(target, value, true, ->(a, b) { a == b })
169
- when 'any_case_sensitive'
170
- return EvaluationHelpers::match_string_in_array(target, value, false, ->(a, b) { a == b })
171
- when 'none_case_sensitive'
172
- return !EvaluationHelpers::match_string_in_array(target, value, false, ->(a, b) { a == b })
173
-
174
- #string
175
- when 'str_starts_with_any'
176
- return EvaluationHelpers::match_string_in_array(target, value, true, ->(a, b) { a.start_with?(b) })
177
- when 'str_ends_with_any'
178
- return EvaluationHelpers::match_string_in_array(target, value, true, ->(a, b) { a.end_with?(b) })
179
- when 'str_contains_any'
180
- return EvaluationHelpers::match_string_in_array(target, value, true, ->(a, b) { a.include?(b) })
181
- when 'str_contains_none'
182
- return !EvaluationHelpers::match_string_in_array(target, value, true, ->(a, b) { a.include?(b) })
183
- when 'str_matches'
184
- return (value.is_a?(String) && !(value =~ Regexp.new(target)).nil? rescue false)
185
- when 'eq'
186
- return value == target
187
- when 'neq'
188
- return value != target
189
-
190
- # dates
191
- when 'before'
192
- return EvaluationHelpers::compare_times(value, target, ->(a, b) { a < b })
193
- when 'after'
194
- return EvaluationHelpers::compare_times(value, target, ->(a, b) { a > b })
195
- when 'on'
196
- return EvaluationHelpers::compare_times(value, target, ->(a, b) { a.year == b.year && a.month == b.month && a.day == b.day })
197
- else
198
- return $fetch_from_server
199
- end
200
- end
224
+ def get_value_from_user(user, field)
225
+ return nil unless user.instance_of?(StatsigUser) && field.is_a?(String)
201
226
 
202
- def get_value_from_user(user, field)
203
- return nil unless user.instance_of?(StatsigUser) && field.is_a?(String)
227
+ user_lookup_table = user&.value_lookup
228
+ return nil unless user_lookup_table.is_a?(Hash)
229
+ return user_lookup_table[field.downcase] if user_lookup_table.has_key?(field.downcase) && !user_lookup_table[field.downcase].nil?
204
230
 
205
- user_lookup_table = user&.value_lookup
206
- return nil unless user_lookup_table.is_a?(Hash)
207
- return user_lookup_table[field.downcase] if user_lookup_table.has_key?(field.downcase) && !user_lookup_table[field.downcase].nil?
231
+ user_custom = user_lookup_table['custom']
232
+ if user_custom.is_a?(Hash)
233
+ user_custom.each do |key, value|
234
+ return value if key.downcase.casecmp?(field.downcase) && !value.nil?
235
+ end
236
+ end
208
237
 
209
- user_custom = user_lookup_table['custom']
210
- if user_custom.is_a?(Hash)
211
- user_custom.each do |key, value|
212
- return value if key.downcase.casecmp?(field.downcase) && !value.nil?
238
+ private_attributes = user_lookup_table['privateAttributes']
239
+ if private_attributes.is_a?(Hash)
240
+ private_attributes.each do |key, value|
241
+ return value if key.downcase.casecmp?(field.downcase) && !value.nil?
242
+ end
213
243
  end
244
+
245
+ nil
214
246
  end
215
247
 
216
- private_attributes = user_lookup_table['privateAttributes']
217
- if private_attributes.is_a?(Hash)
218
- private_attributes.each do |key, value|
219
- return value if key.downcase.casecmp?(field.downcase) && !value.nil?
248
+ def get_value_from_environment(user, field)
249
+ return nil unless user.instance_of?(StatsigUser) && field.is_a?(String)
250
+ field = field.downcase
251
+ return nil unless user.statsig_environment.is_a? Hash
252
+ user.statsig_environment.each do |key, value|
253
+ return value if key.downcase == (field)
220
254
  end
255
+ nil
221
256
  end
222
257
 
223
- nil
224
- end
258
+ def get_value_from_ip(user, field)
259
+ return nil unless user.is_a?(StatsigUser) && field.is_a?(String) && field.downcase == 'country'
260
+ ip = get_value_from_user(user, 'ip')
261
+ return nil unless ip.is_a?(String)
225
262
 
226
- def get_value_from_environment(user, field)
227
- return nil unless user.instance_of?(StatsigUser) && field.is_a?(String)
228
- field = field.downcase
229
- return nil unless user.statsig_environment.is_a? Hash
230
- user.statsig_environment.each do |key, value|
231
- return value if key.downcase == (field)
263
+ CountryLookup.lookup_ip_string(ip)
232
264
  end
233
- nil
234
- end
235
265
 
236
- def get_value_from_ip(user, field)
237
- return nil unless user.is_a?(StatsigUser) && field.is_a?(String) && field.downcase == 'country'
238
- ip = get_value_from_user(user, 'ip')
239
- return nil unless ip.is_a?(String)
266
+ def get_value_from_ua(user, field)
267
+ return nil unless user.is_a?(StatsigUser) && field.is_a?(String)
268
+ ua = get_value_from_user(user, 'userAgent')
269
+ return nil unless ua.is_a?(String)
240
270
 
241
- CountryLookup.lookup_ip_string(ip)
242
- end
271
+ parsed = @ua_parser.parse ua
272
+ os = parsed.os
273
+ case field.downcase
274
+ when 'os_name', 'osname'
275
+ return os&.family
276
+ when 'os_version', 'osversion'
277
+ return os&.version unless os&.version.nil?
278
+ when 'browser_name', 'browsername'
279
+ return parsed.family
280
+ when 'browser_version', 'browserversion'
281
+ return parsed.version.to_s
282
+ else
283
+ nil
284
+ end
285
+ end
243
286
 
244
- def get_value_from_ua(user, field)
245
- return nil unless user.is_a?(StatsigUser) && field.is_a?(String)
246
- ua = get_value_from_user(user, 'userAgent')
247
- return nil unless ua.is_a?(String)
248
-
249
- parsed = @ua_parser.parse ua
250
- os = parsed.os
251
- case field.downcase
252
- when 'os_name', 'osname'
253
- return os&.family
254
- when 'os_version', 'osversion'
255
- return os&.version unless os&.version.nil?
256
- when 'browser_name', 'browsername'
257
- return parsed.family
258
- when 'browser_version', 'browserversion'
259
- return parsed.version.to_s
260
- else
261
- nil
287
+ def eval_pass_percent(user, rule, config_salt)
288
+ return false unless config_salt.is_a?(String) && !rule['passPercentage'].nil?
289
+ begin
290
+ unit_id = get_unit_id(user, rule['id_type']) || ''
291
+ rule_salt = rule['salt'] || rule['id'] || ''
292
+ hash = compute_user_hash("#{config_salt}.#{rule_salt}.#{unit_id}")
293
+ return (hash % 10000) < (rule['passPercentage'].to_f * 100)
294
+ rescue
295
+ return false
296
+ end
262
297
  end
263
- end
264
298
 
265
- def eval_pass_percent(user, rule, config_salt)
266
- return false unless config_salt.is_a?(String) && !rule['passPercentage'].nil?
267
- begin
268
- user_id = user.user_id || ''
269
- rule_salt = rule['salt'] || rule['id'] || ''
270
- hash = compute_user_hash("#{config_salt}.#{rule_salt}.#{user_id}")
271
- return (hash % 10000) < (rule['passPercentage'].to_f * 100)
272
- rescue
273
- return false
299
+ def get_unit_id(user, id_type)
300
+ if id_type.is_a?(String) && id_type.downcase != 'userid'
301
+ return nil unless user&.custom_ids.is_a? Hash
302
+ return user.custom_ids[id_type] || user.custom_ids[id_type.downcase]
303
+ end
304
+ user.user_id
274
305
  end
275
- end
276
306
 
277
- def compute_user_hash(user_hash)
278
- Digest::SHA256.digest(user_hash).unpack('Q>')[0]
307
+ def compute_user_hash(user_hash)
308
+ Digest::SHA256.digest(user_hash).unpack('Q>')[0]
309
+ end
279
310
  end
280
311
  end
data/lib/network.rb CHANGED
@@ -4,90 +4,67 @@ require 'dynamic_config'
4
4
 
5
5
  $retry_codes = [408, 500, 502, 503, 504, 522, 524, 599]
6
6
 
7
- class Network
8
- def initialize(server_secret, api, backoff_mult = 10)
9
- super()
10
- unless api.end_with?('/')
11
- api += '/'
7
+ module Statsig
8
+ class Network
9
+ def initialize(server_secret, api, backoff_mult = 10)
10
+ super()
11
+ unless api.end_with?('/')
12
+ api += '/'
13
+ end
14
+ @server_secret = server_secret
15
+ @api = api
16
+ @backoff_multiplier = backoff_mult
12
17
  end
13
- @server_secret = server_secret
14
- @api = api
15
- @last_sync_time = 0
16
- @backoff_multiplier = backoff_mult
17
- end
18
18
 
19
- def post_helper(endpoint, body, retries = 0, backoff = 1)
20
- http = HTTP.headers(
21
- {"STATSIG-API-KEY" => @server_secret,
22
- "STATSIG-CLIENT-TIME" => (Time.now.to_f * 1000).to_s,
23
- "Content-Type" => "application/json; charset=UTF-8"
24
- }).accept(:json)
25
- begin
26
- res = http.post(@api + endpoint, body: body)
27
- rescue StandardError => e
28
- ## network error retry
29
- return nil, e unless retries > 0
19
+ def post_helper(endpoint, body, retries = 0, backoff = 1)
20
+ http = HTTP.headers(
21
+ {"STATSIG-API-KEY" => @server_secret,
22
+ "STATSIG-CLIENT-TIME" => (Time.now.to_f * 1000).to_s,
23
+ "Content-Type" => "application/json; charset=UTF-8"
24
+ }).accept(:json)
25
+ begin
26
+ res = http.post(@api + endpoint, body: body)
27
+ rescue StandardError => e
28
+ ## network error retry
29
+ return nil, e unless retries > 0
30
+ sleep backoff
31
+ return post_helper(endpoint, body, retries - 1, backoff * @backoff_multiplier)
32
+ end
33
+ return res, nil unless !res.status.success?
34
+ return nil, StandardError.new("Got an exception when making request to #{@api + endpoint}: #{res.to_s}") unless retries > 0 && $retry_codes.include?(res.code)
35
+ ## status code retry
30
36
  sleep backoff
31
- return post_helper(endpoint, body, retries - 1, backoff * @backoff_multiplier)
32
- end
33
- return res, nil unless !res.status.success?
34
- return nil, StandardError.new("Got an exception when making request to #{@api + endpoint}: #{res.to_s}") unless retries > 0 && $retry_codes.include?(res.code)
35
- ## status code retry
36
- sleep backoff
37
- post_helper(endpoint, body, retries - 1, backoff * @backoff_multiplier)
38
- end
39
-
40
- def check_gate(user, gate_name)
41
- begin
42
- request_body = JSON.generate({'user' => user&.serialize(false), 'gateName' => gate_name})
43
- response, _ = post_helper('check_gate', request_body)
44
- return JSON.parse(response.body) unless response.nil?
45
- false
46
- rescue
47
- return false
48
- end
49
- end
50
-
51
- def get_config(user, dynamic_config_name)
52
- begin
53
- request_body = JSON.generate({'user' => user&.serialize(false), 'configName' => dynamic_config_name})
54
- response, _ = post_helper('get_config', request_body)
55
- return JSON.parse(response.body) unless response.nil?
56
- nil
57
- rescue
58
- return nil
37
+ post_helper(endpoint, body, retries - 1, backoff * @backoff_multiplier)
59
38
  end
60
- end
61
39
 
62
- def download_config_specs
63
- begin
64
- response, e = post_helper('download_config_specs', JSON.generate({'sinceTime' => @last_sync_time}))
65
- return nil, e if response.nil?
66
- json_body = JSON.parse(response.body)
67
- @last_sync_time = json_body['time']
68
- return json_body, nil
69
- rescue StandardError => e
70
- return nil, e
40
+ def check_gate(user, gate_name)
41
+ begin
42
+ request_body = JSON.generate({'user' => user&.serialize(false), 'gateName' => gate_name})
43
+ response, _ = post_helper('check_gate', request_body)
44
+ return JSON.parse(response.body) unless response.nil?
45
+ false
46
+ rescue
47
+ return false
48
+ end
71
49
  end
72
- end
73
50
 
74
- def poll_for_changes(callback)
75
- Thread.new do
76
- loop do
77
- sleep 10
78
- specs, _ = download_config_specs
79
- unless specs.nil?
80
- callback.call(specs)
81
- end
51
+ def get_config(user, dynamic_config_name)
52
+ begin
53
+ request_body = JSON.generate({'user' => user&.serialize(false), 'configName' => dynamic_config_name})
54
+ response, _ = post_helper('get_config', request_body)
55
+ return JSON.parse(response.body) unless response.nil?
56
+ nil
57
+ rescue
58
+ return nil
82
59
  end
83
60
  end
84
- end
85
61
 
86
- def post_logs(events, statsig_metadata)
87
- begin
88
- json_body = JSON.generate({'events' => events, 'statsigMetadata' => statsig_metadata})
89
- post_helper('log_event', json_body, retries: 5)
90
- rescue
62
+ def post_logs(events)
63
+ begin
64
+ json_body = JSON.generate({'events' => events, 'statsigMetadata' => Statsig.get_statsig_metadata})
65
+ post_helper('log_event', json_body, retries: 5)
66
+ rescue
67
+ end
91
68
  end
92
69
  end
93
70
  end
data/lib/spec_store.rb CHANGED
@@ -1,51 +1,151 @@
1
1
  require 'net/http'
2
2
  require 'uri'
3
3
 
4
- class SpecStore
5
- def initialize(specs_json)
6
- @last_sync_time = 0
7
- @store = {
8
- :gates => {},
9
- :configs => {},
10
- }
11
- process(specs_json)
12
- end
4
+ module Statsig
5
+ class SpecStore
6
+ def initialize(network, error_callback = nil, config_sync_interval = 10, id_lists_sync_interval = 60)
7
+ @network = network
8
+ @last_sync_time = 0
9
+ @config_sync_interval = config_sync_interval
10
+ @id_lists_sync_interval = id_lists_sync_interval
11
+ @store = {
12
+ :gates => {},
13
+ :configs => {},
14
+ :id_lists => {},
15
+ }
16
+ e = download_config_specs
17
+ error_callback.call(e) unless error_callback.nil?
18
+ download_id_lists
13
19
 
14
- def process(specs_json)
15
- if specs_json.nil?
16
- return
20
+ @config_sync_thread = sync_config_specs
21
+ @id_lists_sync_thread = sync_id_lists
17
22
  end
18
23
 
19
- @last_sync_time = specs_json['time'] || @last_sync_time
20
- return unless specs_json['has_updates'] == true &&
21
- !specs_json['feature_gates'].nil? &&
22
- !specs_json['dynamic_configs'].nil?
24
+ def shutdown
25
+ @config_sync_thread&.exit
26
+ @id_lists_sync_thread&.exit
27
+ end
23
28
 
24
- @store = {
25
- :gates => {},
26
- :configs => {},
27
- }
29
+ def has_gate?(gate_name)
30
+ @store[:gates].key?(gate_name)
31
+ end
28
32
 
29
- specs_json['feature_gates'].map{|gate| @store[:gates][gate['name']] = gate }
30
- specs_json['dynamic_configs'].map{|config| @store[:configs][config['name']] = config }
31
- end
33
+ def has_config?(config_name)
34
+ @store[:configs].key?(config_name)
35
+ end
32
36
 
33
- def has_gate?(gate_name)
34
- return @store[:gates].key?(gate_name)
35
- end
37
+ def get_gate(gate_name)
38
+ return nil unless has_gate?(gate_name)
39
+ @store[:gates][gate_name]
40
+ end
36
41
 
37
- def has_config?(config_name)
38
- return @store[:configs].key?(config_name)
39
- end
42
+ def get_config(config_name)
43
+ return nil unless has_config?(config_name)
44
+ @store[:configs][config_name]
45
+ end
40
46
 
41
- def get_gate(gate_name)
42
- return nil unless has_gate?(gate_name)
43
- @store[:gates][gate_name]
44
- end
47
+ def get_id_list(list_name)
48
+ @store[:id_lists][list_name]
49
+ end
45
50
 
46
- def get_config(config_name)
47
- return nil unless has_config?(config_name)
48
- @store[:configs][config_name]
49
- end
51
+ private
52
+
53
+ def sync_config_specs
54
+ Thread.new do
55
+ loop do
56
+ sleep @config_sync_interval
57
+ download_config_specs
58
+ end
59
+ end
60
+ end
50
61
 
51
- end
62
+ def sync_id_lists
63
+ Thread.new do
64
+ loop do
65
+ sleep @id_lists_sync_interval
66
+ download_id_lists
67
+ end
68
+ end
69
+ end
70
+
71
+ def download_config_specs
72
+ begin
73
+ response, e = @network.post_helper('download_config_specs', JSON.generate({'sinceTime' => @last_sync_time}))
74
+ if e.nil?
75
+ process(JSON.parse(response.body))
76
+ else
77
+ e
78
+ end
79
+ rescue StandardError => e
80
+ e
81
+ end
82
+ end
83
+
84
+ def process(specs_json)
85
+ if specs_json.nil?
86
+ return
87
+ end
88
+
89
+ @last_sync_time = specs_json['time'] || @last_sync_time
90
+ return unless specs_json['has_updates'] == true &&
91
+ !specs_json['feature_gates'].nil? &&
92
+ !specs_json['dynamic_configs'].nil?
93
+
94
+ new_gates = {}
95
+ new_configs = {}
96
+
97
+ specs_json['feature_gates'].map{|gate| new_gates[gate['name']] = gate }
98
+ specs_json['dynamic_configs'].map{|config| new_configs[config['name']] = config }
99
+ @store[:gates] = new_gates
100
+ @store[:configs] = new_configs
101
+
102
+ new_id_lists = specs_json['id_lists']
103
+ if new_id_lists.is_a? Hash
104
+ new_id_lists.each do |list_name, _|
105
+ unless @store[:id_lists].key?(list_name)
106
+ @store[:id_lists][list_name] = { :ids => {}, :time => 0 }
107
+ end
108
+ end
109
+
110
+ @store[:id_lists].each do |list_name, _|
111
+ unless new_id_lists.key?(list_name)
112
+ @store[:id_lists].delete(list_name)
113
+ end
114
+ end
115
+ end
116
+ end
117
+
118
+ def download_id_lists
119
+ if @store[:id_lists].is_a? Hash
120
+ threads = []
121
+ id_lists = @store[:id_lists]
122
+ id_lists.each do |list_name, list|
123
+ threads << Thread.new do
124
+ response, e = @network.post_helper('download_id_list', JSON.generate({'listName' => list_name, 'statsigMetadata' => Statsig.get_statsig_metadata, 'sinceTime' => list['time'] || 0 }))
125
+ if e.nil? && !response.nil?
126
+ begin
127
+ data = JSON.parse(response)
128
+ if data['add_ids'].is_a? Array
129
+ data['add_ids'].each do |id|
130
+ list[:ids][id] = true
131
+ end
132
+ end
133
+ if data['remove_ids'].is_a? Array
134
+ data['remove_ids'].each do |id|
135
+ list[:ids]&.delete(id)
136
+ end
137
+ end
138
+ if data['time'].is_a? Numeric
139
+ list[:time] = data['time']
140
+ end
141
+ rescue
142
+ # Ignored
143
+ end
144
+ end
145
+ end
146
+ end
147
+ threads.each(&:join)
148
+ end
149
+ end
150
+ end
151
+ end
data/lib/statsig.rb CHANGED
@@ -11,22 +11,22 @@ module Statsig
11
11
  end
12
12
 
13
13
  def self.check_gate(user, gate_name)
14
- self.ensure_initialized
14
+ ensure_initialized
15
15
  @shared_instance&.check_gate(user, gate_name)
16
16
  end
17
17
 
18
18
  def self.get_config(user, dynamic_config_name)
19
- self.ensure_initialized
19
+ ensure_initialized
20
20
  @shared_instance&.get_config(user, dynamic_config_name)
21
21
  end
22
22
 
23
23
  def self.get_experiment(user, experiment_name)
24
- self.ensure_initialized
24
+ ensure_initialized
25
25
  @shared_instance&.get_config(user, experiment_name)
26
26
  end
27
27
 
28
28
  def self.log_event(user, event_name, value, metadata)
29
- self.ensure_initialized
29
+ ensure_initialized
30
30
  @shared_instance&.log_event(user, event_name, value, metadata)
31
31
  end
32
32
 
@@ -37,6 +37,15 @@ module Statsig
37
37
  @shared_instance = nil
38
38
  end
39
39
 
40
+ def self.get_statsig_metadata
41
+ {
42
+ 'sdkType' => 'ruby-server',
43
+ 'sdkVersion' => '1.8.2',
44
+ }
45
+ end
46
+
47
+ private
48
+
40
49
  def self.ensure_initialized
41
50
  if @shared_instance.nil?
42
51
  raise 'Must call initialize first.'
@@ -20,24 +20,9 @@ class StatsigDriver
20
20
  @options = options || StatsigOptions.new
21
21
  @shutdown = false
22
22
  @secret_key = secret_key
23
- @net = Network.new(secret_key, @options.api_url_base)
24
- @statsig_metadata = {
25
- 'sdkType' => 'ruby-server',
26
- 'sdkVersion' => Gem::Specification::load('statsig.gemspec')&.version,
27
- }
28
- @logger = StatsigLogger.new(@net, @statsig_metadata)
29
-
30
- downloaded_specs, e = @net.download_config_specs
31
- unless downloaded_specs.nil?
32
- @initialized = true
33
- end
34
-
35
- @store = SpecStore.new(downloaded_specs)
36
- @evaluator = Evaluator.new(@store)
37
-
38
- @polling_thread = @net.poll_for_changes(-> (config_specs) { @store.process(config_specs) })
39
-
40
- error_callback.call(e) unless error_callback.nil?
23
+ @net = Statsig::Network.new(secret_key, @options.api_url_base)
24
+ @logger = Statsig::StatsigLogger.new(@net)
25
+ @evaluator = Statsig::Evaluator.new(@net, error_callback)
41
26
  end
42
27
 
43
28
  def check_gate(user, gate_name)
@@ -47,13 +32,10 @@ class StatsigDriver
47
32
  raise 'Invalid gate_name provided'
48
33
  end
49
34
  check_shutdown
50
- unless @initialized
51
- return false
52
- end
53
35
 
54
36
  res = @evaluator.check_gate(user, gate_name)
55
37
  if res.nil?
56
- res = ConfigResult.new(gate_name)
38
+ res = Statsig::ConfigResult.new(gate_name)
57
39
  end
58
40
 
59
41
  if res == $fetch_from_server
@@ -73,13 +55,10 @@ class StatsigDriver
73
55
  raise "Invalid dynamic_config_name provided"
74
56
  end
75
57
  check_shutdown
76
- unless @initialized
77
- return DynamicConfig.new(dynamic_config_name)
78
- end
79
58
 
80
59
  res = @evaluator.get_config(user, dynamic_config_name)
81
60
  if res.nil?
82
- res = ConfigResult.new(dynamic_config_name)
61
+ res = Statsig::ConfigResult.new(dynamic_config_name)
83
62
  end
84
63
 
85
64
  if res == $fetch_from_server
@@ -111,16 +90,18 @@ class StatsigDriver
111
90
  event.user = user
112
91
  event.value = value
113
92
  event.metadata = metadata
114
- event.statsig_metadata = @statsig_metadata
93
+ event.statsig_metadata = Statsig.get_statsig_metadata
115
94
  @logger.log_event(event)
116
95
  end
117
96
 
118
97
  def shutdown
119
98
  @shutdown = true
120
99
  @logger.flush(true)
121
- @polling_thread&.exit
100
+ @evaluator.shutdown
122
101
  end
123
102
 
103
+ private
104
+
124
105
  def validate_user(user)
125
106
  if user.nil? || !user.instance_of?(StatsigUser) || !user.user_id.is_a?(String)
126
107
  raise 'Must provide a valid StatsigUser with a user_id to use the server SDK. See https://docs.statsig.com/messages/serverRequiredUserID/ for more details.'
@@ -143,11 +124,11 @@ class StatsigDriver
143
124
  def check_gate_fallback(user, gate_name)
144
125
  network_result = @net.check_gate(user, gate_name)
145
126
  if network_result.nil?
146
- config_result = ConfigResult.new(gate_name)
127
+ config_result = Statsig::ConfigResult.new(gate_name)
147
128
  return config_result
148
129
  end
149
130
 
150
- ConfigResult.new(
131
+ Statsig::ConfigResult.new(
151
132
  network_result['name'],
152
133
  network_result['value'],
153
134
  {},
@@ -158,11 +139,11 @@ class StatsigDriver
158
139
  def get_config_fallback(user, dynamic_config_name)
159
140
  network_result = @net.get_config(user, dynamic_config_name)
160
141
  if network_result.nil?
161
- config_result = ConfigResult.new(dynamic_config_name)
142
+ config_result = Statsig::ConfigResult.new(dynamic_config_name)
162
143
  return config_result
163
144
  end
164
145
 
165
- ConfigResult.new(
146
+ Statsig::ConfigResult.new(
166
147
  network_result['name'],
167
148
  false,
168
149
  network_result['value'],
@@ -3,59 +3,60 @@ require 'statsig_event'
3
3
  $gate_exposure_event = 'statsig::gate_exposure'
4
4
  $config_exposure_event = 'statsig::config_exposure'
5
5
 
6
- class StatsigLogger
7
- def initialize(network, statsig_metadata)
8
- @network = network
9
- @statsig_metadata = statsig_metadata
10
- @events = []
11
- @background_flush = Thread.new do
12
- sleep 60
13
- flush
6
+ module Statsig
7
+ class StatsigLogger
8
+ def initialize(network)
9
+ @network = network
10
+ @events = []
11
+ @background_flush = Thread.new do
12
+ sleep 60
13
+ flush
14
+ end
14
15
  end
15
- end
16
16
 
17
- def log_event(event)
18
- @events.push(event)
19
- if @events.length >= 500
20
- flush
17
+ def log_event(event)
18
+ @events.push(event)
19
+ if @events.length >= 500
20
+ flush
21
+ end
21
22
  end
22
- end
23
23
 
24
- def log_gate_exposure(user, gate_name, value, rule_id, secondary_exposures)
25
- event = StatsigEvent.new($gate_exposure_event)
26
- event.user = user
27
- event.metadata = {
28
- 'gate' => gate_name,
29
- 'gateValue' => value.to_s,
30
- 'ruleID' => rule_id
31
- }
32
- event.statsig_metadata = @statsig_metadata
33
- event.secondary_exposures = secondary_exposures.is_a?(Array) ? secondary_exposures : []
34
- log_event(event)
35
- end
36
-
37
- def log_config_exposure(user, config_name, rule_id, secondary_exposures)
38
- event = StatsigEvent.new($config_exposure_event)
39
- event.user = user
40
- event.metadata = {
41
- 'config' => config_name,
42
- 'ruleID' => rule_id
43
- }
44
- event.statsig_metadata = @statsig_metadata
45
- event.secondary_exposures = secondary_exposures.is_a?(Array) ? secondary_exposures : []
46
- log_event(event)
47
- end
48
-
49
- def flush(closing = false)
50
- if closing
51
- @background_flush.exit
24
+ def log_gate_exposure(user, gate_name, value, rule_id, secondary_exposures)
25
+ event = StatsigEvent.new($gate_exposure_event)
26
+ event.user = user
27
+ event.metadata = {
28
+ 'gate' => gate_name,
29
+ 'gateValue' => value.to_s,
30
+ 'ruleID' => rule_id
31
+ }
32
+ event.statsig_metadata = Statsig.get_statsig_metadata
33
+ event.secondary_exposures = secondary_exposures.is_a?(Array) ? secondary_exposures : []
34
+ log_event(event)
52
35
  end
53
- if @events.length == 0
54
- return
36
+
37
+ def log_config_exposure(user, config_name, rule_id, secondary_exposures)
38
+ event = StatsigEvent.new($config_exposure_event)
39
+ event.user = user
40
+ event.metadata = {
41
+ 'config' => config_name,
42
+ 'ruleID' => rule_id
43
+ }
44
+ event.statsig_metadata = Statsig.get_statsig_metadata
45
+ event.secondary_exposures = secondary_exposures.is_a?(Array) ? secondary_exposures : []
46
+ log_event(event)
55
47
  end
56
- flush_events = @events.map { |e| e.serialize }
57
- @events = []
58
48
 
59
- @network.post_logs(flush_events, @statsig_metadata)
49
+ def flush(closing = false)
50
+ if closing
51
+ @background_flush.exit
52
+ end
53
+ if @events.length == 0
54
+ return
55
+ end
56
+ flush_events = @events.map { |e| e.serialize }
57
+ @events = []
58
+
59
+ @network.post_logs(flush_events)
60
+ end
60
61
  end
61
62
  end
data/lib/statsig_user.rb CHANGED
@@ -7,6 +7,7 @@ class StatsigUser
7
7
  attr_accessor :locale
8
8
  attr_accessor :app_version
9
9
  attr_accessor :statsig_environment
10
+ attr_accessor :custom_ids
10
11
  attr_accessor :private_attributes
11
12
 
12
13
  def custom
@@ -28,9 +29,11 @@ class StatsigUser
28
29
  @country = user_hash['country']
29
30
  @locale = user_hash['locale']
30
31
  @app_version = user_hash['appVersion'] || user_hash['app_version']
31
- @custom = user_hash['custom']
32
+ @custom = user_hash['custom'] if user_hash['custom'].is_a? Hash
32
33
  @statsig_environment = user_hash['statsigEnvironment']
33
- @private_attributes = user_hash['privateAttributes']
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
34
37
  end
35
38
  end
36
39
 
@@ -46,6 +49,7 @@ class StatsigUser
46
49
  'custom' => @custom,
47
50
  'statsigEnvironment' => @statsig_environment,
48
51
  'privateAttributes' => @private_attributes,
52
+ 'customIDs' => @custom_ids,
49
53
  }
50
54
  if for_logging
51
55
  hash.delete('privateAttributes')
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.7.0.beta.2
4
+ version: 1.8.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Statsig, Inc
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2021-10-28 00:00:00.000000000 Z
11
+ date: 2022-01-12 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: bundler
@@ -141,9 +141,9 @@ required_ruby_version: !ruby/object:Gem::Requirement
141
141
  version: '0'
142
142
  required_rubygems_version: !ruby/object:Gem::Requirement
143
143
  requirements:
144
- - - ">"
144
+ - - ">="
145
145
  - !ruby/object:Gem::Version
146
- version: 1.3.1
146
+ version: '0'
147
147
  requirements: []
148
148
  rubygems_version: 3.2.3
149
149
  signing_key: