splitclient-rb 0.1.3

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.
Files changed (35) hide show
  1. checksums.yaml +7 -0
  2. data/.gitignore +38 -0
  3. data/Gemfile +4 -0
  4. data/LICENSE +202 -0
  5. data/README.md +152 -0
  6. data/Rakefile +4 -0
  7. data/lib/splitclient-cache/local_store.rb +45 -0
  8. data/lib/splitclient-engine/evaluator/splitter.rb +110 -0
  9. data/lib/splitclient-engine/impressions/impressions.rb +79 -0
  10. data/lib/splitclient-engine/matchers/all_keys_matcher.rb +46 -0
  11. data/lib/splitclient-engine/matchers/combiners.rb +13 -0
  12. data/lib/splitclient-engine/matchers/combining_matcher.rb +94 -0
  13. data/lib/splitclient-engine/matchers/negation_matcher.rb +54 -0
  14. data/lib/splitclient-engine/matchers/user_defined_segment_matcher.rb +58 -0
  15. data/lib/splitclient-engine/matchers/whitelist_matcher.rb +55 -0
  16. data/lib/splitclient-engine/metrics/binary_search_latency_tracker.rb +122 -0
  17. data/lib/splitclient-engine/metrics/metrics.rb +158 -0
  18. data/lib/splitclient-engine/parser/condition.rb +90 -0
  19. data/lib/splitclient-engine/parser/partition.rb +37 -0
  20. data/lib/splitclient-engine/parser/segment.rb +84 -0
  21. data/lib/splitclient-engine/parser/segment_parser.rb +46 -0
  22. data/lib/splitclient-engine/parser/split.rb +68 -0
  23. data/lib/splitclient-engine/parser/split_adapter.rb +433 -0
  24. data/lib/splitclient-engine/parser/split_parser.rb +129 -0
  25. data/lib/splitclient-engine/partitions/treatments.rb +40 -0
  26. data/lib/splitclient-rb.rb +22 -0
  27. data/lib/splitclient-rb/split_client.rb +170 -0
  28. data/lib/splitclient-rb/split_config.rb +193 -0
  29. data/lib/splitclient-rb/version.rb +3 -0
  30. data/splitclient-rb.gemspec +44 -0
  31. data/tasks/benchmark_is_treatment.rake +37 -0
  32. data/tasks/concurrent_benchmark_is_treatment.rake +43 -0
  33. data/tasks/console.rake +4 -0
  34. data/tasks/rspec.rake +3 -0
  35. metadata +260 -0
@@ -0,0 +1,84 @@
1
+ module SplitIoClient
2
+
3
+ #
4
+ # acts as dto for a segment structure
5
+ #
6
+ class Segment < NoMethodError
7
+ #
8
+ # definition of the segment
9
+ #
10
+ # @returns [object] segment values
11
+ attr_accessor :data
12
+
13
+ #
14
+ # users for the segment
15
+ #
16
+ # @returns [object] array of user keys
17
+ attr_accessor :users
18
+
19
+ #
20
+ # added users for the segment in a given time
21
+ #
22
+ # @returns [object] array of user keys that were added after the last segment fetch
23
+ attr_accessor :added
24
+
25
+ #
26
+ # removed users for the segment in a given time
27
+ #
28
+ # @returns [object] array of user keys that were removed after the last segment fetch
29
+ attr_accessor :removed
30
+
31
+ def initialize(segment)
32
+ @data = segment
33
+ @added = @data[:added]
34
+ @removed = @data[:removed]
35
+ end
36
+
37
+ #
38
+ # @returns [string] name of the segment
39
+ def name
40
+ @data[:name]
41
+ end
42
+
43
+ #
44
+ # @returns [int] since value fo the segment
45
+ def since
46
+ @data[:since]
47
+ end
48
+
49
+ #
50
+ # @returns [int] till value fo the segment
51
+ def till
52
+ @data[:till]
53
+ end
54
+
55
+ #
56
+ # @return [boolean] true if the condition is empty false otherwise
57
+ def is_empty?
58
+ @data.empty? ? true : false
59
+ end
60
+
61
+ #
62
+ # updates the array of user keys valid for the segment, it's used after each segment fetch
63
+ #
64
+ # @param added [object] array of added user keys
65
+ # @param removed [object] array of removed user keys
66
+ #
67
+ # @return [void]
68
+ def refresh_users(added, removed)
69
+ if @users.nil?
70
+ @users = self.added
71
+ else
72
+ @added = added unless added.empty?
73
+ @removed = removed unless removed.empty?
74
+ self.removed.each do |r|
75
+ @users.delete_if { |u| u == r }
76
+ end
77
+ self.added.each do |a|
78
+ @users << a unless @users.include?(a)
79
+ end
80
+ end
81
+ end
82
+ end
83
+
84
+ end
@@ -0,0 +1,46 @@
1
+ module SplitIoClient
2
+ #
3
+ # helper class to parse fetched segments
4
+ #
5
+ class SegmentParser < NoMethodError
6
+ #
7
+ # segments data
8
+ attr_accessor :segments
9
+
10
+ #
11
+ # since value for segments
12
+ attr_accessor :since
13
+
14
+ def initialize(logger)
15
+ @segments = []
16
+ @since = -1
17
+ @logger = logger
18
+ end
19
+
20
+ #
21
+ # method to get a segment by name
22
+ #
23
+ # @param name [string] segment name
24
+ #
25
+ # @return [object] segment object
26
+ def get_segment(name)
27
+ @segments.find { |s| s.name == name }
28
+ end
29
+
30
+ #
31
+ # method to get all segment names within the structure
32
+ #
33
+ # @return [object] array of segment names
34
+ def get_segment_names
35
+ @segments.map { |seg| seg.name }
36
+ end
37
+
38
+ #
39
+ # @return [boolean] true if the segment parser data is empty false otherwise
40
+ def is_empty?
41
+ @segments.empty? ? true : false
42
+ end
43
+
44
+ end
45
+
46
+ end
@@ -0,0 +1,68 @@
1
+ module SplitIoClient
2
+ #
3
+ # acts as dto for a split structure
4
+ #
5
+ class Split < NoMethodError
6
+ #
7
+ # definition of the split
8
+ #
9
+ # @returns [object] split values
10
+ attr_accessor :data
11
+
12
+ def initialize(split)
13
+ @data = split
14
+ @conditions = set_conditions
15
+ end
16
+
17
+ #
18
+ # @returns [string] name of the split
19
+ def name
20
+ @data[:name]
21
+ end
22
+
23
+ #
24
+ # @returns [int] seed value of the split
25
+ def seed
26
+ @data[:seed]
27
+ end
28
+
29
+ #
30
+ # @returns [string] status value of the split
31
+ def status
32
+ @data[:status]
33
+ end
34
+
35
+ #
36
+ # @returns [string] killed value of the split
37
+ def killed?
38
+ @data[:killed]
39
+ end
40
+
41
+ #
42
+ # @returns [object] array of condition objects for this split
43
+ def conditions
44
+ @conditions
45
+ end
46
+
47
+ #
48
+ # @return [boolean] true if the condition is empty false otherwise
49
+ def is_empty?
50
+ @data.empty? ? true : false
51
+ end
52
+
53
+ #
54
+ # converts the conditions data into an array of condition objects for this split
55
+ #
56
+ # @return [object] array of condition objects
57
+ def set_conditions
58
+ conditions_list = []
59
+ @data[:conditions].each do |c|
60
+ condition = SplitIoClient::Condition.new(c)
61
+ conditions_list << condition
62
+ end
63
+ conditions_list
64
+ end
65
+
66
+ end
67
+
68
+ end
@@ -0,0 +1,433 @@
1
+ require 'json'
2
+ require 'thread'
3
+ require 'faraday/http_cache'
4
+ require 'bundler/vendor/net/http/persistent'
5
+ require 'faraday_middleware'
6
+
7
+
8
+ module SplitIoClient
9
+
10
+ #
11
+ # acts as an api adapater to connect to split endpoints
12
+ # uses a configuration object that can be modified when creating the client instance
13
+ # also, uses safe threads to execute fetches and post give the time execution values from the config
14
+ #
15
+ class SplitAdapter < NoMethodError
16
+ #
17
+ # handler for impressions
18
+ attr_reader :impressions
19
+
20
+ #
21
+ # handler for metrics
22
+ attr_reader :metrics
23
+
24
+ #
25
+ # handler for parsed splits
26
+ attr_reader :parsed_splits
27
+
28
+ #
29
+ # handeler for parsed segments
30
+ attr_reader :parsed_segments
31
+
32
+ attr_reader :impressions_producer
33
+
34
+ #
35
+ # Creates a new split api adapter instance that consumes split api endpoints
36
+ #
37
+ # @param api_key [String] the API key for your split account
38
+ #
39
+ # @return [SplitIoClient] split.io client instance
40
+ def initialize(api_key, config)
41
+
42
+ @api_key = api_key
43
+ @config = config
44
+ @parsed_splits = SplitParser.new(@config.logger)
45
+ @parsed_segments = SegmentParser.new(@config.logger)
46
+ @impressions = Impressions.new(100)
47
+ @metrics = Metrics.new(100)
48
+
49
+ @api_client = Faraday.new do |builder|
50
+ builder.use Faraday::HttpCache, store: @config.local_store
51
+ builder.use FaradayMiddleware::Gzip
52
+ builder.adapter :net_http_persistent
53
+ end
54
+
55
+ @splits_consumer = create_splits_api_consumer
56
+ @segments_consumer = create_segments_api_consumer
57
+ @metrics_producer = create_metrics_api_producer
58
+ @impressions_producer = create_impressions_api_producer
59
+ end
60
+
61
+ #
62
+ # creates a safe thread that will be executing api calls
63
+ # for fetching splits and segments give the execution time
64
+ # provided within the configuration
65
+ #
66
+ # @return [void]
67
+ def create_splits_api_consumer
68
+ Thread.new do
69
+ loop do
70
+ begin
71
+ #splits fetcher
72
+ splits_arr = []
73
+ data = get_splits(@parsed_splits.since)
74
+ data[:splits].each do |split|
75
+ splits_arr << SplitIoClient::Split.new(split)
76
+ end
77
+
78
+ if @parsed_splits.is_empty?
79
+ @parsed_splits.splits = splits_arr
80
+ else
81
+ refresh_splits(splits_arr)
82
+ end
83
+ @parsed_splits.since = data[:till]
84
+
85
+ random_interval = randomize_interval @config.features_refresh_rate
86
+ sleep(random_interval)
87
+ rescue StandardError => error
88
+ @config.log_found_exception(__method__.to_s, error)
89
+ end
90
+ end
91
+ end
92
+ end
93
+
94
+ def create_segments_api_consumer
95
+ Thread.new do
96
+ loop do
97
+ begin
98
+ #segments fetcher
99
+ segments_arr = []
100
+ segment_data = get_segments(@parsed_splits.get_used_segments)
101
+ segment_data.each do |segment|
102
+ segments_arr << SplitIoClient::Segment.new(segment)
103
+ end
104
+ if @parsed_segments.is_empty?
105
+ @parsed_segments.segments = segments_arr
106
+ @parsed_segments.segments.map { |s| s.refresh_users(s.added, s.removed) }
107
+ else
108
+ refresh_segments(segments_arr)
109
+ end
110
+
111
+ random_interval = randomize_interval @config.segments_refresh_rate
112
+ sleep(random_interval)
113
+ rescue StandardError => error
114
+ @config.log_found_exception(__method__.to_s, error)
115
+ end
116
+ end
117
+ end
118
+ end
119
+
120
+ #
121
+ # helper method to execute a get request to the provided endpoint
122
+ #
123
+ # @param path [string] api endpoint path
124
+ # @param params [object] hash of params that will be added to the request
125
+ #
126
+ # @return [object] response to the request
127
+ def call_api(path, params = {})
128
+ @api_client.get @config.base_uri + path, params do |req|
129
+ req.headers['Authorization'] = 'Bearer ' + @api_key
130
+ req.headers['SplitSDKVersion'] = SplitIoClient::SplitClient.sdk_version
131
+ req.headers['SplitSDKMachineName'] = @config.machine_name
132
+ req.headers['SplitSDKMachineIP'] = @config.machine_ip
133
+ req.headers['Accept-Encoding'] = 'gzip'
134
+ req.options.open_timeout = @config.connection_timeout
135
+ req.options.timeout = @config.read_timeout
136
+ @config.logger.debug("GET #{@config.base_uri + path}") if @config.debug_enabled
137
+ end
138
+ end
139
+
140
+ #
141
+ # helper method to execute a post request to the provided endpoint
142
+ #
143
+ # @param path [string] api endpoint path
144
+ # @param params [object] hash of params that will be added to the request
145
+ #
146
+ # @return [object] response to the request
147
+ def post_api(path, param)
148
+ @api_client.post (@config.base_uri + path) do |req|
149
+ req.headers['Authorization'] = 'Bearer ' + @api_key
150
+ req.headers['Content-Type'] = 'application/json'
151
+ req.headers['SplitSDKVersion'] = SplitIoClient::SplitClient.sdk_version
152
+ req.headers['SplitSDKMachineName'] = @config.machine_name
153
+ req.headers['SplitSDKMachineIP'] = @config.machine_ip
154
+ req.body = param.to_json
155
+ req.options.timeout = @config.read_timeout
156
+ req.options.open_timeout = @config.connection_timeout
157
+ @config.logger.debug("POST #{@config.base_uri + path} #{req.body}") if @config.debug_enabled
158
+ end
159
+ end
160
+
161
+ #
162
+ # helper method to fetch splits by using the appropriate api endpoint
163
+ #
164
+ # @param since [int] since value for the last fetch
165
+ #
166
+ # @return splits [object] splits structure in json format
167
+ def get_splits(since)
168
+ result = nil
169
+ start = Time.now
170
+ prefix = 'splitChangeFetcher'
171
+
172
+ splits = call_api('/splitChanges', {:since => since})
173
+
174
+ if splits.status / 100 == 2
175
+ result = JSON.parse(splits.body, symbolize_names: true)
176
+ @metrics.count(prefix + '.status.' + splits.status.to_s, 1)
177
+ @config.logger.info("#{result[:splits].length} splits retrieved.")
178
+ @config.logger.debug("#{result}") if @config.debug_enabled
179
+ else
180
+ @config.logger.error('Unexpected result from API call')
181
+ @metrics.count(prefix + '.status.' + splits.status.to_s, 1)
182
+ end
183
+
184
+ latency = (Time.now - start) * 1000.0
185
+ @metrics.time(prefix + '.time', latency)
186
+
187
+ result
188
+ end
189
+
190
+ #
191
+ # helper method to refresh splits values after a new fetch with changes
192
+ #
193
+ # @param splits_arr [object] array of splits to refresh
194
+ #
195
+ # @return [void]
196
+ def refresh_splits(splits_arr)
197
+ feature_names = splits_arr.map { |s| s.name }
198
+ @parsed_splits.splits.delete_if { |sp| feature_names.include?(sp.name) }
199
+ @parsed_splits.splits += splits_arr
200
+ end
201
+
202
+ #
203
+ # helper method to fetch segments by using the appropriate api endpoint
204
+ #
205
+ # @param names [object] array of segment names that must be fetched
206
+ #
207
+ # @return segments [object] segments structure in json format
208
+ def get_segments(names)
209
+ segments = []
210
+ start = Time.now
211
+ prefix = 'segmentChangeFetcher'
212
+
213
+ names.each do |name|
214
+ curr_segment = @parsed_segments.get_segment(name)
215
+ since = curr_segment.nil? ? -1 : curr_segment.till
216
+
217
+ while true
218
+ segment = call_api('/segmentChanges/' + name, {:since => since})
219
+
220
+ if segment.status / 100 == 2
221
+ segment_content = JSON.parse(segment.body, symbolize_names: true)
222
+ @parsed_segments.since = segment_content[:till]
223
+ @metrics.count(prefix + '.status.' + segment.status.to_s, 1)
224
+ @config.logger.info("\'#{segment_content[:name]}\' segment retrieved.")
225
+ @config.logger.debug("#{segment_content}") if @config.debug_enabled
226
+ segments << segment_content
227
+ else
228
+ @config.logger.error('Unexpected result from API call')
229
+ @metrics.count(prefix + '.status.' + segment.status.to_s, 1)
230
+ end
231
+ break if (since.to_i >= @parsed_segments.since.to_i)
232
+ since = @parsed_segments.since
233
+ end
234
+ end
235
+
236
+ latency = (Time.now - start) * 1000.0
237
+ @metrics.time(prefix + '.time', latency)
238
+
239
+ segments
240
+ end
241
+
242
+ #
243
+ # helper method to refresh segments values after a new fetch with changes
244
+ #
245
+ # @param segments_arr [object] array of segments to refresh
246
+ #
247
+ # @return [void]
248
+ def refresh_segments(segments_arr)
249
+ segment_names = @parsed_segments.segments.map { |s| s.name }
250
+ segments_arr.each do |s|
251
+ if segment_names.include?(s.name)
252
+ segment_to_update = @parsed_segments.get_segment(s.name)
253
+ segment_to_update.refresh_users(s.added, s.removed)
254
+ else
255
+ @parsed_segments.segments << s
256
+ end
257
+ end
258
+ end
259
+
260
+ #
261
+ # @return parsed_splits [object] parsed splits for this adapter
262
+ def parsed_splits
263
+ @parsed_splits
264
+ end
265
+
266
+ #
267
+ # @return parsed_segments [object] parsed segments for this adapter
268
+ def parsed_segments
269
+ @parsed_segments
270
+ end
271
+
272
+ #
273
+ # creates two safe threads that will be executing api calls
274
+ # for posting impressions and metrics given the execution time
275
+ # provided within the configuration
276
+ #
277
+
278
+ def create_metrics_api_producer
279
+ Thread.new do
280
+ loop do
281
+ begin
282
+ #post captured metrics
283
+ post_metrics
284
+
285
+ random_interval = randomize_interval @config.metrics_refresh_rate
286
+ sleep(random_interval)
287
+ rescue StandardError => error
288
+ @config.log_found_exception(__method__.to_s, error)
289
+ end
290
+ end
291
+ end
292
+ end
293
+
294
+ def create_impressions_api_producer
295
+ Thread.new do
296
+ loop do
297
+ begin
298
+ #post captured impressions
299
+ post_impressions
300
+
301
+ random_interval = randomize_interval @config.impressions_refresh_rate
302
+ sleep(random_interval)
303
+ rescue StandardError => error
304
+ @config.log_found_exception(__method__.to_s, error)
305
+ end
306
+ end
307
+ end
308
+ end
309
+
310
+ #
311
+ # creates the appropriate json data for the cached impressions values
312
+ # and then sends them to the appropriate api endpoint with a valid body format
313
+ #
314
+ # @return [void]
315
+ def post_impressions
316
+ if @impressions.queue.empty?
317
+ @config.logger.info('No impressions to report.')
318
+ else
319
+ popped_impressions = @impressions.clear
320
+ test_impression_array = []
321
+ popped_impressions.each do |i|
322
+ filtered = []
323
+ keys_seen = []
324
+
325
+ impressions = i[:impressions]
326
+ impressions.each do |imp|
327
+ if keys_seen.include?(imp.key)
328
+ next
329
+ end
330
+ keys_seen << imp.key
331
+ filtered << imp
332
+ end
333
+
334
+ if filtered.empty?
335
+ @config.logger.info('No impressions to report post filtering.')
336
+ else
337
+ test_impression = {}
338
+ key_impressions = []
339
+
340
+ filtered.each do |f|
341
+ key_impressions << {keyName: f.key, treatment: f.treatment, time: f.time.to_i}
342
+ end
343
+
344
+ test_impression = {testName: i[:feature], keyImpressions: key_impressions}
345
+ test_impression_array << test_impression
346
+ end
347
+ end
348
+
349
+ res = post_api('/testImpressions/bulk', test_impression_array)
350
+ if res.status / 100 != 2
351
+ @config.logger.error("Unexpected status code while posting impressions: #{res.status}")
352
+ else
353
+ @config.logger.info("Impressions reported.")
354
+ @config.logger.debug("#{test_impression_array}")if @config.debug_enabled
355
+ end
356
+ end
357
+ end
358
+
359
+ #
360
+ # creates the appropriate json data for the cached metrics values
361
+ # include latencies, counts and gauges
362
+ # and then sends them to the appropriate api endpoint with a valida body format
363
+ #
364
+ # @return [void]
365
+ def post_metrics
366
+ clear = true
367
+ if @metrics.latencies.empty?
368
+ @config.logger.info('No latencies to report.')
369
+ else
370
+ @metrics.latencies.each do |l|
371
+ metrics_time = {}
372
+ metrics_time = {name: l[:operation], latencies: l[:latencies]}
373
+ res = post_api('/metrics/time', metrics_time)
374
+ if res.status / 100 != 2
375
+ @config.logger.error("Unexpected status code while posting time metrics: #{res.status}")
376
+ clear = false
377
+ else
378
+ @config.logger.info("Metric time reported.")
379
+ @config.logger.debug("#{metrics_time}") if @config.debug_enabled
380
+ end
381
+ end
382
+ end
383
+ @metrics.latencies.clear if clear
384
+
385
+ clear = true
386
+ if @metrics.counts.empty?
387
+ @config.logger.info('No counts to report.')
388
+ else
389
+ @metrics.counts.each do |c|
390
+ metrics_count = {}
391
+ metrics_count = {name: c[:name], delta: c[:delta].sum}
392
+ res = post_api('/metrics/counter', metrics_count)
393
+ if res.status / 100 != 2
394
+ @config.logger.error("Unexpected status code while posting count metrics: #{res.status}")
395
+ clear = false
396
+ else
397
+ @config.logger.info("Metric counts reported.")
398
+ @config.logger.debug("#{metrics_count}") if @config.debug_enabled
399
+ end
400
+ end
401
+ end
402
+ @metrics.counts.clear if clear
403
+
404
+ clear = true
405
+ if @metrics.gauges.empty?
406
+ @config.logger.info('No gauges to report.')
407
+ else
408
+ @metrics.gauges.each do |g|
409
+ metrics_gauge = {}
410
+ metrics_gauge = {name: g[:name], value: g[:gauge].value}
411
+ res = post_api('/metrics/gauge', metrics_gauge)
412
+ if res.status / 100 != 2
413
+ @config.logger.error("Unexpected status code while posting gauge metrics: #{res.status}")
414
+ clear = false
415
+ else
416
+ @config.logger.info("Metric gauge reported.")
417
+ @config.logger.debug("#{metrics_gauge}") if @config.debug_enabled
418
+ end
419
+ end
420
+ end
421
+ @metrics.gauges.clear if clear
422
+
423
+ end
424
+
425
+ private
426
+
427
+ def randomize_interval(interval)
428
+ @random_generator ||= Random.new
429
+ random_factor = @random_generator.rand(50..100)/100.0
430
+ interval * random_factor
431
+ end
432
+ end
433
+ end