statsig 1.2.0 → 1.6.1

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: 90f13c55a79aa919b94e633f69d39b3cb997a92cceec3e27bdb3e00e00e8e3ef
4
- data.tar.gz: cd40c863516da1b3ff0730959ccbc3fa664afe0e613775f0369a1377ccc06123
3
+ metadata.gz: 8dfdb320cf540cd81046ba96aae19d9d3f947ec557ad7fb7b405a35065eb67fa
4
+ data.tar.gz: 0e8d15d9c3f9aa0245c2e834e55394479775d2a9de23208e54dfb41be89e0326
5
5
  SHA512:
6
- metadata.gz: a4c73b125853c8128d5aa57a60acfe1e80a0aa8ffc9f1fdcd89ea74e9a6ac033c93262103850d0c13ec84052ca9b9a464627836a1af945e6100c036c0fbc1359
7
- data.tar.gz: 86bf2fe76cdc5cdc29126bd188a33e746f2a143e56fce1cd2e7d5a242622acb4b3783694f01e11a6e8ce10c707bc38a57a055694e15c965ee584bb951c6acb0d
6
+ metadata.gz: e7a479f8ed9b2cbf49f4b567bdf16000b5ba23a88015593cec6e9806477419011a13b5ed64fd578208cec5f6633101c881e3fa0b98b0474a2faebc91aaf8be1e
7
+ data.tar.gz: d731197be16b7ef43d6c9060f00254b3db3a205bef736243a326a382e9fca1aa72b1335102612ab13c044a03f4dc247e0b3443badfad147dffdd8be8391a038d
@@ -9,8 +9,8 @@ class DynamicConfig
9
9
  @rule_id = rule_id
10
10
  end
11
11
 
12
- def get(index)
13
- return nil if @value.nil?
14
- value[index]
12
+ def get(index, default_value)
13
+ return default_value if @value.nil? || !@value.key?(index)
14
+ @value[index]
15
15
  end
16
16
  end
@@ -6,19 +6,11 @@ module EvaluationHelpers
6
6
  func.call(a.to_f, b.to_f) rescue false
7
7
  end
8
8
 
9
- # returns true if array contains value, ignoring case when comparing strings
10
- def self.array_contains(array, value, ignore_case)
11
- return false unless array.is_a?(Array) && !value.nil?
12
- if value.is_a?(String) && match_string_in_array(array, value, ignore_case, ->(a, b) { a == b })
13
- return true
14
- end
15
- return array.include?(value)
16
- end
17
-
18
9
  # returns true if array has any element that evaluates to true with value using func lambda, ignoring case
19
10
  def self.match_string_in_array(array, value, ignore_case, func)
20
- return false unless array.is_a?(Array) && value.is_a?(String)
21
- array.any?{ |s| s.is_a?(String) && ((ignore_case && func.call(value.downcase, s.downcase)) || func.call(value, s)) } rescue false
11
+ return false unless array.is_a?(Array) && !value.nil?
12
+ str_value = value.to_s
13
+ array.any?{ |s| !s.nil? && ((ignore_case && func.call(str_value.downcase, s.to_s.downcase)) || func.call(str_value, s.to_s)) } rescue false
22
14
  end
23
15
 
24
16
  def self.compare_times(a, b, func)
data/lib/evaluator.rb CHANGED
@@ -79,16 +79,15 @@ class Evaluator
79
79
  case type
80
80
  when 'public'
81
81
  return true
82
- when 'fail_gate'
83
- when 'pass_gate'
82
+ when 'fail_gate', 'pass_gate'
84
83
  other_gate_result = self.check_gate(user, target)
85
84
  return $fetch_from_server if other_gate_result == $fetch_from_server
86
85
  return type == 'pass_gate' ? other_gate_result.gate_value : !other_gate_result.gate_value
87
86
  when 'ip_based'
88
- value = get_value_from_user(user, field) || get_value_from_ip(user&.value_lookup['ip'], field)
87
+ value = get_value_from_user(user, field) || get_value_from_ip(user, field)
89
88
  return $fetch_from_server if value == $fetch_from_server
90
89
  when 'ua_based'
91
- value = get_value_from_user(user, field) || get_value_from_ua(user&.value_lookup['userAgent'], field)
90
+ value = get_value_from_user(user, field) || get_value_from_ua(user, field)
92
91
  return $fetch_from_server if value == $fetch_from_server
93
92
  when 'user_field'
94
93
  value = get_value_from_user(user, field)
@@ -109,10 +108,7 @@ class Evaluator
109
108
  return $fetch_from_server
110
109
  end
111
110
 
112
- return $fetch_from_server if value == $fetch_from_server
113
- return false if value.nil?
114
-
115
- return $fetch_from_server unless operator.is_a?(String)
111
+ return $fetch_from_server if value == $fetch_from_server || !operator.is_a?(String)
116
112
  operator = operator.downcase
117
113
 
118
114
  case operator
@@ -142,13 +138,13 @@ class Evaluator
142
138
 
143
139
  # array operations
144
140
  when 'any'
145
- return EvaluationHelpers::array_contains(target, value, true)
141
+ return EvaluationHelpers::match_string_in_array(target, value, true, ->(a, b) { a == b })
146
142
  when 'none'
147
- return !EvaluationHelpers::array_contains(target, value, true)
143
+ return !EvaluationHelpers::match_string_in_array(target, value, true, ->(a, b) { a == b })
148
144
  when 'any_case_sensitive'
149
- return EvaluationHelpers::array_contains(target, value, false)
145
+ return EvaluationHelpers::match_string_in_array(target, value, false, ->(a, b) { a == b })
150
146
  when 'none_case_sensitive'
151
- return !EvaluationHelpers::array_contains(target, value, false)
147
+ return !EvaluationHelpers::match_string_in_array(target, value, false, ->(a, b) { a == b })
152
148
 
153
149
  #string
154
150
  when 'str_starts_with_any'
@@ -157,6 +153,8 @@ class Evaluator
157
153
  return EvaluationHelpers::match_string_in_array(target, value, true, ->(a, b) { a.end_with?(b) })
158
154
  when 'str_contains_any'
159
155
  return EvaluationHelpers::match_string_in_array(target, value, true, ->(a, b) { a.include?(b) })
156
+ when 'str_contains_none'
157
+ return !EvaluationHelpers::match_string_in_array(target, value, true, ->(a, b) { a.include?(b) })
160
158
  when 'str_matches'
161
159
  return (value.is_a?(String) && !(value =~ Regexp.new(target)).nil? rescue false)
162
160
  when 'eq'
@@ -181,13 +179,22 @@ class Evaluator
181
179
 
182
180
  user_lookup_table = user&.value_lookup
183
181
  return nil unless user_lookup_table.is_a?(Hash)
184
- return user_lookup_table[field.downcase] if user_lookup_table.has_key?(field.downcase)
182
+ return user_lookup_table[field.downcase] if user_lookup_table.has_key?(field.downcase) && !user_lookup_table[field.downcase].nil?
185
183
 
186
184
  user_custom = user_lookup_table['custom']
187
- return nil unless user_custom.is_a?(Hash)
188
- user_custom.each do |key, value|
189
- return value if key.downcase.casecmp?(field.downcase)
185
+ if user_custom.is_a?(Hash)
186
+ user_custom.each do |key, value|
187
+ return value if key.downcase.casecmp?(field.downcase) && !value.nil?
188
+ end
189
+ end
190
+
191
+ private_attributes = user_lookup_table['privateAttributes']
192
+ if private_attributes.is_a?(Hash)
193
+ private_attributes.each do |key, value|
194
+ return value if key.downcase.casecmp?(field.downcase) && !value.nil?
195
+ end
190
196
  end
197
+
191
198
  nil
192
199
  end
193
200
 
@@ -201,17 +208,19 @@ class Evaluator
201
208
  nil
202
209
  end
203
210
 
204
- def get_value_from_ip(ip, field)
205
- return nil unless ip.is_a?(String) && field.is_a?(String)
211
+ def get_value_from_ip(user, field)
212
+ return nil unless user.is_a?(StatsigUser) && field.is_a?(String) && field.downcase == 'country'
213
+ ip = get_value_from_user(user, 'ip')
214
+ return nil unless ip.is_a?(String)
206
215
 
207
- if field.downcase != 'country'
208
- return $fetch_from_server
209
- end
210
216
  CountryLookup.lookup_ip_string(ip)
211
217
  end
212
218
 
213
- def get_value_from_ua(ua, field)
214
- return nil unless ua.is_a?(String) && field.is_a?(String)
219
+ def get_value_from_ua(user, field)
220
+ return nil unless user.is_a?(StatsigUser) && field.is_a?(String)
221
+ ua = get_value_from_user(user, 'userAgent')
222
+ return nil unless ua.is_a?(String)
223
+
215
224
  parsed = @ua_parser.parse ua
216
225
  os = parsed.os
217
226
  case field.downcase
data/lib/network.rb CHANGED
@@ -2,8 +2,10 @@ require 'http'
2
2
  require 'json'
3
3
  require 'dynamic_config'
4
4
 
5
+ $retry_codes = [408, 500, 502, 503, 504, 522, 524, 599]
6
+
5
7
  class Network
6
- def initialize(server_secret, api)
8
+ def initialize(server_secret, api, backoff_mult = 10)
7
9
  super()
8
10
  unless api.end_with?('/')
9
11
  api += '/'
@@ -11,22 +13,36 @@ class Network
11
13
  @server_secret = server_secret
12
14
  @api = api
13
15
  @last_sync_time = 0
16
+ @backoff_multiplier = backoff_mult
14
17
  end
15
18
 
16
- def post_helper(endpoint, body)
19
+ def post_helper(endpoint, body, retries = 0, backoff = 1)
17
20
  http = HTTP.headers(
18
21
  {"STATSIG-API-KEY" => @server_secret,
19
22
  "STATSIG-CLIENT-TIME" => (Time.now.to_f * 1000).to_s,
20
23
  "Content-Type" => "application/json; charset=UTF-8"
21
24
  }).accept(:json)
22
- http.post(@api + endpoint, body: body)
25
+ begin
26
+ res = http.post(@api + endpoint, body: body)
27
+ rescue
28
+ ## network error retry
29
+ return nil unless retries > 0
30
+ sleep backoff
31
+ return post_helper(endpoint, body, retries - 1, backoff * @backoff_multiplier)
32
+ end
33
+ return res unless !res.status.success?
34
+ return nil 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)
23
38
  end
24
39
 
25
40
  def check_gate(user, gate_name)
26
41
  begin
27
- request_body = JSON.generate({'user' => user&.serialize, 'gateName' => gate_name})
42
+ request_body = JSON.generate({'user' => user&.serialize(false), 'gateName' => gate_name})
28
43
  response = post_helper('check_gate', request_body)
29
- return JSON.parse(response.body)
44
+ return JSON.parse(response.body) unless response.nil?
45
+ false
30
46
  rescue
31
47
  return false
32
48
  end
@@ -34,9 +50,10 @@ class Network
34
50
 
35
51
  def get_config(user, dynamic_config_name)
36
52
  begin
37
- request_body = JSON.generate({'user' => user&.serialize, 'configName' => dynamic_config_name})
53
+ request_body = JSON.generate({'user' => user&.serialize(false), 'configName' => dynamic_config_name})
38
54
  response = post_helper('get_config', request_body)
39
- return JSON.parse(response.body)
55
+ return JSON.parse(response.body) unless response.nil?
56
+ nil
40
57
  rescue
41
58
  return nil
42
59
  end
@@ -45,6 +62,7 @@ class Network
45
62
  def download_config_specs
46
63
  begin
47
64
  response = post_helper('download_config_specs', JSON.generate({'sinceTime' => @last_sync_time}))
65
+ return nil unless !response.nil?
48
66
  json_body = JSON.parse(response.body)
49
67
  @last_sync_time = json_body['time']
50
68
  return json_body
@@ -68,9 +86,8 @@ class Network
68
86
  def post_logs(events, statsig_metadata)
69
87
  begin
70
88
  json_body = JSON.generate({'events' => events, 'statsigMetadata' => statsig_metadata})
71
- post_helper('log_event', body: json_body)
89
+ post_helper('log_event', body: json_body, retries: 5)
72
90
  rescue
73
- # TODO: retries
74
91
  end
75
92
  end
76
93
  end
@@ -56,9 +56,11 @@ class StatsigDriver
56
56
 
57
57
  if res == $fetch_from_server
58
58
  res = check_gate_fallback(user, gate_name)
59
+ # exposure logged by the server
60
+ else
61
+ @logger.log_gate_exposure(user, res.name, res.gate_value, res.rule_id)
59
62
  end
60
63
 
61
- @logger.log_gate_exposure(user, res.name, res.gate_value, res.rule_id)
62
64
  res.gate_value
63
65
  end
64
66
 
@@ -80,11 +82,12 @@ class StatsigDriver
80
82
 
81
83
  if res == $fetch_from_server
82
84
  res = get_config_fallback(user, dynamic_config_name)
85
+ # exposure logged by the server
86
+ else
87
+ @logger.log_config_exposure(user, res.name, res.rule_id)
83
88
  end
84
89
 
85
- result_config = DynamicConfig.new(res.name, res.json_value, res.rule_id)
86
- @logger.log_config_exposure(user, result_config.name, result_config.rule_id)
87
- result_config
90
+ DynamicConfig.new(res.name, res.json_value, res.rule_id)
88
91
  end
89
92
 
90
93
  def log_event(user, event_name, value = nil, metadata = nil)
@@ -96,7 +99,7 @@ class StatsigDriver
96
99
  user = normalize_user(user)
97
100
 
98
101
  event = StatsigEvent.new(event_name)
99
- event.user = user&.serialize
102
+ event.user = user
100
103
  event.value = value
101
104
  event.metadata = metadata
102
105
  event.statsig_metadata = @statsig_metadata
@@ -105,7 +108,7 @@ class StatsigDriver
105
108
 
106
109
  def shutdown
107
110
  @shutdown = true
108
- @logger.flush
111
+ @logger.flush(true)
109
112
  @polling_thread&.exit
110
113
  end
111
114
 
data/lib/statsig_event.rb CHANGED
@@ -1,15 +1,22 @@
1
1
  class StatsigEvent
2
2
  attr_accessor :value
3
- attr_accessor :user
4
3
  attr_accessor :metadata
5
4
  attr_accessor :statsig_metadata
5
+ attr_reader :user
6
+
6
7
  def initialize(event_name)
7
8
  @event_name = event_name
8
9
  @time = Time.now.to_f * 1000
9
10
  end
10
11
 
12
+ def user=(value)
13
+ if value.is_a?(StatsigUser)
14
+ @user = value.serialize(true)
15
+ end
16
+ end
17
+
11
18
  def serialize
12
- return {
19
+ {
13
20
  'eventName' => @event_name,
14
21
  'metadata' => @metadata,
15
22
  'value' => @value,
@@ -8,6 +8,10 @@ class StatsigLogger
8
8
  @network = network
9
9
  @statsig_metadata = statsig_metadata
10
10
  @events = []
11
+ @background_flush = Thread.new do
12
+ sleep 60
13
+ flush
14
+ end
11
15
  end
12
16
 
13
17
  def log_event(event)
@@ -40,13 +44,18 @@ class StatsigLogger
40
44
  log_event(event)
41
45
  end
42
46
 
43
- def flush
47
+ def flush(closing = false)
48
+ if closing
49
+ @background_flush.exit
50
+ end
44
51
  if @events.length == 0
45
52
  return
46
53
  end
47
54
  flush_events = @events.map { |e| e.serialize() }
48
55
  @events = []
49
56
 
50
- @network.post_logs(flush_events, @statsig_metadata)
57
+ Thread.new do
58
+ @network.post_logs(flush_events, @statsig_metadata)
59
+ end
51
60
  end
52
61
  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 :private_attributes
10
11
 
11
12
  def custom
12
13
  @custom
@@ -29,11 +30,12 @@ class StatsigUser
29
30
  @app_version = user_hash['appVersion'] || user_hash['app_version']
30
31
  @custom = user_hash['custom']
31
32
  @statsig_environment = user_hash['statsigEnvironment']
33
+ @private_attributes = user_hash['privateAttributes']
32
34
  end
33
35
  end
34
36
 
35
- def serialize
36
- {
37
+ def serialize(for_logging)
38
+ hash = {
37
39
  'userID' => @user_id,
38
40
  'email' => @email,
39
41
  'ip' => @ip,
@@ -43,7 +45,12 @@ class StatsigUser
43
45
  'appVersion' => @app_version,
44
46
  'custom' => @custom,
45
47
  'statsigEnvironment' => @statsig_environment,
48
+ 'privateAttributes' => @private_attributes,
46
49
  }
50
+ if for_logging
51
+ hash.delete('privateAttributes')
52
+ end
53
+ hash
47
54
  end
48
55
 
49
56
  def value_lookup
@@ -62,6 +69,7 @@ class StatsigUser
62
69
  'appversion' => @app_version,
63
70
  'app_version' => @app_version,
64
71
  'custom' => @custom,
72
+ 'privateAttributes' => @private_attributes,
65
73
  }
66
74
  end
67
75
  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.2.0
4
+ version: 1.6.1
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-07-31 00:00:00.000000000 Z
11
+ date: 2021-09-11 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: bundler
@@ -24,6 +24,48 @@ dependencies:
24
24
  - - "~>"
25
25
  - !ruby/object:Gem::Version
26
26
  version: '2.1'
27
+ - !ruby/object:Gem::Dependency
28
+ name: webmock
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - "~>"
32
+ - !ruby/object:Gem::Version
33
+ version: '3.13'
34
+ type: :development
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - "~>"
39
+ - !ruby/object:Gem::Version
40
+ version: '3.13'
41
+ - !ruby/object:Gem::Dependency
42
+ name: minitest
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - "~>"
46
+ - !ruby/object:Gem::Version
47
+ version: '5.14'
48
+ type: :development
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - "~>"
53
+ - !ruby/object:Gem::Version
54
+ version: '5.14'
55
+ - !ruby/object:Gem::Dependency
56
+ name: spy
57
+ requirement: !ruby/object:Gem::Requirement
58
+ requirements:
59
+ - - "~>"
60
+ - !ruby/object:Gem::Version
61
+ version: '1.0'
62
+ type: :development
63
+ prerelease: false
64
+ version_requirements: !ruby/object:Gem::Requirement
65
+ requirements:
66
+ - - "~>"
67
+ - !ruby/object:Gem::Version
68
+ version: '1.0'
27
69
  - !ruby/object:Gem::Dependency
28
70
  name: user_agent_parser
29
71
  requirement: !ruby/object:Gem::Requirement