statsig 1.2.0 → 1.6.1

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 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