ldclient-rb 3.0.3 → 4.0.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +4 -4
- data/.circleci/config.yml +90 -0
- data/CHANGELOG.md +10 -0
- data/README.md +0 -1
- data/ldclient-rb.gemspec +8 -2
- data/lib/ldclient-rb.rb +5 -1
- data/lib/ldclient-rb/config.rb +41 -1
- data/lib/ldclient-rb/evaluation.rb +33 -17
- data/lib/ldclient-rb/event_summarizer.rb +52 -0
- data/lib/ldclient-rb/events.rb +383 -51
- data/lib/ldclient-rb/expiring_cache.rb +76 -0
- data/lib/ldclient-rb/ldclient.rb +44 -23
- data/lib/ldclient-rb/non_blocking_thread_pool.rb +46 -0
- data/lib/ldclient-rb/redis_store.rb +13 -17
- data/lib/ldclient-rb/simple_lru_cache.rb +24 -0
- data/lib/ldclient-rb/{event_serializer.rb → user_filter.rb} +17 -23
- data/lib/ldclient-rb/version.rb +1 -1
- data/spec/evaluation_spec.rb +44 -9
- data/spec/event_summarizer_spec.rb +63 -0
- data/spec/events_spec.rb +506 -0
- data/spec/expiring_cache_spec.rb +76 -0
- data/spec/fixtures/feature.json +1 -0
- data/spec/ldclient_spec.rb +94 -17
- data/spec/simple_lru_cache_spec.rb +24 -0
- data/spec/{event_serializer_spec.rb → user_filter_spec.rb} +23 -44
- metadata +49 -23
- data/circle.yml +0 -35
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA1:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 65f975d234beca67eed5238b9e693fdfb39de00e
|
4
|
+
data.tar.gz: 93e6c8e3de9f9394f1cf9acb64218fda1b873ff6
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 1aacb9669181a5cee75fcfdad3170a195bd43bb572203251daa0fd892e94ecf95bd8627ee124fa961884a44d38209271e9f9fbb094a7773b123328d33011e6ab
|
7
|
+
data.tar.gz: 12b7baa6f4176b357b11307faf9588a918e34059d1a5bc2fc97e5c6076f25ad1b5d5f13b328b4295d4c1bd4a90dfb3fa02daf75549886948e5311c2b91753bed
|
@@ -0,0 +1,90 @@
|
|
1
|
+
version: 2
|
2
|
+
|
3
|
+
workflows:
|
4
|
+
version: 2
|
5
|
+
test:
|
6
|
+
jobs:
|
7
|
+
- test-misc-rubies
|
8
|
+
- test-2.2
|
9
|
+
- test-2.3
|
10
|
+
- test-2.4
|
11
|
+
- test-jruby-9.1
|
12
|
+
|
13
|
+
ruby-docker-template: &ruby-docker-template
|
14
|
+
steps:
|
15
|
+
- checkout
|
16
|
+
- run: |
|
17
|
+
if [[ $CIRCLE_JOB == test-jruby* ]]; then
|
18
|
+
gem install jruby-openssl; # required by bundler, no effect on Ruby MRI
|
19
|
+
fi
|
20
|
+
- run: gem install bundler
|
21
|
+
- run: bundle install
|
22
|
+
- run: mkdir ./rspec
|
23
|
+
- run: bundle exec rspec --format progress --format RspecJunitFormatter -o ./rspec/rspec.xml spec
|
24
|
+
- store_test_results:
|
25
|
+
path: ./rspec
|
26
|
+
- store_artifacts:
|
27
|
+
path: ./rspec
|
28
|
+
|
29
|
+
jobs:
|
30
|
+
test-2.2:
|
31
|
+
<<: *ruby-docker-template
|
32
|
+
docker:
|
33
|
+
- image: circleci/ruby:2.2.9-jessie
|
34
|
+
- image: redis
|
35
|
+
test-2.3:
|
36
|
+
<<: *ruby-docker-template
|
37
|
+
docker:
|
38
|
+
- image: circleci/ruby:2.3.6-jessie
|
39
|
+
- image: redis
|
40
|
+
test-2.4:
|
41
|
+
<<: *ruby-docker-template
|
42
|
+
docker:
|
43
|
+
- image: circleci/ruby:2.4.3-jessie
|
44
|
+
- image: redis
|
45
|
+
test-jruby-9.1:
|
46
|
+
<<: *ruby-docker-template
|
47
|
+
docker:
|
48
|
+
- image: circleci/jruby:9-jdk
|
49
|
+
- image: redis
|
50
|
+
|
51
|
+
# The following very slow job uses an Ubuntu container to run the Ruby versions that
|
52
|
+
# CircleCI doesn't provide Docker images for.
|
53
|
+
test-misc-rubies:
|
54
|
+
machine:
|
55
|
+
image: circleci/classic:latest
|
56
|
+
environment:
|
57
|
+
- RUBIES: "ruby-2.1.9 ruby-2.0.0 ruby-1.9.3 jruby-9.0.5.0"
|
58
|
+
steps:
|
59
|
+
- run: sudo apt-get -q update
|
60
|
+
- run: sudo apt-get -qy install redis-server
|
61
|
+
- checkout
|
62
|
+
- run:
|
63
|
+
name: install all Ruby versions
|
64
|
+
command: "parallel rvm install ::: $RUBIES"
|
65
|
+
- run:
|
66
|
+
name: bundle install for all versions
|
67
|
+
shell: /bin/bash -leo pipefail # need -l in order for "rvm use" to work
|
68
|
+
command: |
|
69
|
+
set -e;
|
70
|
+
for i in $RUBIES;
|
71
|
+
do
|
72
|
+
rvm use $i;
|
73
|
+
if [[ $i == jruby* ]]; then
|
74
|
+
gem install jruby-openssl; # required by bundler, no effect on Ruby MRI
|
75
|
+
fi
|
76
|
+
gem install bundler;
|
77
|
+
bundle install;
|
78
|
+
mv Gemfile.lock "Gemfile.lock.$i"
|
79
|
+
done
|
80
|
+
- run:
|
81
|
+
name: run tests for all versions
|
82
|
+
shell: /bin/bash -leo pipefail
|
83
|
+
command: |
|
84
|
+
set -e;
|
85
|
+
for i in $RUBIES;
|
86
|
+
do
|
87
|
+
rvm use $i;
|
88
|
+
cp "Gemfile.lock.$i" Gemfile.lock;
|
89
|
+
bundle exec rspec spec;
|
90
|
+
done
|
data/CHANGELOG.md
CHANGED
@@ -2,6 +2,16 @@
|
|
2
2
|
|
3
3
|
All notable changes to the LaunchDarkly Ruby SDK will be documented in this file. This project adheres to [Semantic Versioning](http://semver.org).
|
4
4
|
|
5
|
+
## [4.0.0] - 2018-05-10
|
6
|
+
|
7
|
+
### Changed:
|
8
|
+
- To reduce the network bandwidth used for analytics events, feature request events are now sent as counters rather than individual events, and user details are now sent only at intervals rather than in each event. These behaviors can be modified through the LaunchDarkly UI and with the new configuration option `inline_users_in_events`. For more details, see [Analytics Data Stream Reference](https://docs.launchdarkly.com/v2.0/docs/analytics-data-stream-reference).
|
9
|
+
|
10
|
+
### Removed:
|
11
|
+
- JRuby 1.7 is no longer supported.
|
12
|
+
- Greatly reduced the number of indirect gem dependencies by removing `moneta`, which was previously a requirement for the Redis feature store.
|
13
|
+
|
14
|
+
|
5
15
|
## [3.0.3] - 2018-03-23
|
6
16
|
## Fixed
|
7
17
|
- In the Redis feature store, fixed a synchronization problem that could cause a feature flag update to be missed if several of them happened in rapid succession.
|
data/README.md
CHANGED
@@ -4,7 +4,6 @@ LaunchDarkly SDK for Ruby
|
|
4
4
|
[![Gem Version](https://badge.fury.io/rb/ldclient-rb.svg)](http://badge.fury.io/rb/ldclient-rb)
|
5
5
|
|
6
6
|
[![Circle CI](https://circleci.com/gh/launchdarkly/ruby-client/tree/master.svg?style=svg)](https://circleci.com/gh/launchdarkly/ruby-client/tree/master)
|
7
|
-
[![Code Climate](https://codeclimate.com/github/launchdarkly/ruby-client/badges/gpa.svg)](https://codeclimate.com/github/launchdarkly/ruby-client)
|
8
7
|
[![Test Coverage](https://codeclimate.com/github/launchdarkly/ruby-client/badges/coverage.svg)](https://codeclimate.com/github/launchdarkly/ruby-client/coverage)
|
9
8
|
[![security](https://hakiri.io/github/launchdarkly/ruby-client/master.svg)](https://hakiri.io/github/launchdarkly/ruby-client/master)
|
10
9
|
|
data/ldclient-rb.gemspec
CHANGED
@@ -22,12 +22,18 @@ Gem::Specification.new do |spec|
|
|
22
22
|
spec.extensions = 'ext/mkrf_conf.rb'
|
23
23
|
|
24
24
|
spec.add_development_dependency "bundler", "~> 1.7"
|
25
|
-
spec.add_development_dependency "rake", "~> 10.0"
|
26
25
|
spec.add_development_dependency "rspec", "~> 3.2"
|
27
26
|
spec.add_development_dependency "codeclimate-test-reporter", "~> 0"
|
28
27
|
spec.add_development_dependency "redis", "~> 3.3.5"
|
29
28
|
spec.add_development_dependency "connection_pool", ">= 2.1.2"
|
30
|
-
|
29
|
+
if RUBY_VERSION >= "2.0.0"
|
30
|
+
spec.add_development_dependency "rake", "~> 10.0"
|
31
|
+
spec.add_development_dependency "rspec_junit_formatter", "~> 0.3.0"
|
32
|
+
else
|
33
|
+
spec.add_development_dependency "rake", "12.1.0"
|
34
|
+
# higher versions of rake fail to install in JRuby 1.7
|
35
|
+
end
|
36
|
+
spec.add_development_dependency "timecop", "~> 0.9.1"
|
31
37
|
|
32
38
|
spec.add_runtime_dependency "json", [">= 1.8", "< 3"]
|
33
39
|
if RUBY_VERSION >= "2.1.0"
|
data/lib/ldclient-rb.rb
CHANGED
@@ -2,13 +2,17 @@ require "ldclient-rb/version"
|
|
2
2
|
require "ldclient-rb/evaluation"
|
3
3
|
require "ldclient-rb/ldclient"
|
4
4
|
require "ldclient-rb/cache_store"
|
5
|
+
require "ldclient-rb/expiring_cache"
|
5
6
|
require "ldclient-rb/memoized_value"
|
6
7
|
require "ldclient-rb/in_memory_store"
|
7
8
|
require "ldclient-rb/config"
|
8
9
|
require "ldclient-rb/newrelic"
|
9
10
|
require "ldclient-rb/stream"
|
10
11
|
require "ldclient-rb/polling"
|
11
|
-
require "ldclient-rb/
|
12
|
+
require "ldclient-rb/user_filter"
|
13
|
+
require "ldclient-rb/simple_lru_cache"
|
14
|
+
require "ldclient-rb/non_blocking_thread_pool"
|
15
|
+
require "ldclient-rb/event_summarizer"
|
12
16
|
require "ldclient-rb/events"
|
13
17
|
require "ldclient-rb/redis_store"
|
14
18
|
require "ldclient-rb/requestor"
|
data/lib/ldclient-rb/config.rb
CHANGED
@@ -54,7 +54,15 @@ module LaunchDarkly
|
|
54
54
|
# @option opts [Boolean] :send_events (true) Whether or not to send events back to LaunchDarkly.
|
55
55
|
# This differs from `offline` in that it affects only the sending of client-side events, not
|
56
56
|
# streaming or polling for events from the server.
|
57
|
-
#
|
57
|
+
# @option opts [Integer] :user_keys_capacity (1000) The number of user keys that the event processor
|
58
|
+
# can remember at any one time, so that duplicate user details will not be sent in analytics events.
|
59
|
+
# @option opts [Float] :user_keys_flush_interval (300) The interval in seconds at which the event
|
60
|
+
# processor will reset its set of known user keys.
|
61
|
+
# @option opts [Boolean] :inline_users_in_events (false) Whether to include full user details in every
|
62
|
+
# analytics event. By default, events will only include the user key, except for one "index" event
|
63
|
+
# that provides the full details for the user.
|
64
|
+
# @option opts [Object] :update_processor An object that will receive feature flag data from LaunchDarkly.
|
65
|
+
# Defaults to either the streaming or the polling processor, can be customized for tests.
|
58
66
|
# @return [type] [description]
|
59
67
|
# rubocop:disable Metrics/AbcSize, Metrics/PerceivedComplexity
|
60
68
|
def initialize(opts = {})
|
@@ -76,6 +84,10 @@ module LaunchDarkly
|
|
76
84
|
@all_attributes_private = opts[:all_attributes_private] || false
|
77
85
|
@private_attribute_names = opts[:private_attribute_names] || []
|
78
86
|
@send_events = opts.has_key?(:send_events) ? opts[:send_events] : Config.default_send_events
|
87
|
+
@user_keys_capacity = opts[:user_keys_capacity] || Config.default_user_keys_capacity
|
88
|
+
@user_keys_flush_interval = opts[:user_keys_flush_interval] || Config.default_user_keys_flush_interval
|
89
|
+
@inline_users_in_events = opts[:inline_users_in_events] || false
|
90
|
+
@update_processor = opts[:update_processor]
|
79
91
|
end
|
80
92
|
|
81
93
|
#
|
@@ -186,6 +198,26 @@ module LaunchDarkly
|
|
186
198
|
#
|
187
199
|
attr_reader :send_events
|
188
200
|
|
201
|
+
#
|
202
|
+
# The number of user keys that the event processor can remember at any one time, so that
|
203
|
+
# duplicate user details will not be sent in analytics events.
|
204
|
+
#
|
205
|
+
attr_reader :user_keys_capacity
|
206
|
+
|
207
|
+
#
|
208
|
+
# The interval in seconds at which the event processor will reset its set of known user keys.
|
209
|
+
#
|
210
|
+
attr_reader :user_keys_flush_interval
|
211
|
+
|
212
|
+
#
|
213
|
+
# Whether to include full user details in every
|
214
|
+
# analytics event. By default, events will only include the user key, except for one "index" event
|
215
|
+
# that provides the full details for the user.
|
216
|
+
#
|
217
|
+
attr_reader :inline_users_in_events
|
218
|
+
|
219
|
+
attr_reader :update_processor
|
220
|
+
|
189
221
|
#
|
190
222
|
# The default LaunchDarkly client configuration. This configuration sets
|
191
223
|
# reasonable defaults for most users.
|
@@ -264,5 +296,13 @@ module LaunchDarkly
|
|
264
296
|
def self.default_send_events
|
265
297
|
true
|
266
298
|
end
|
299
|
+
|
300
|
+
def self.default_user_keys_capacity
|
301
|
+
1000
|
302
|
+
end
|
303
|
+
|
304
|
+
def self.default_user_keys_flush_interval
|
305
|
+
300
|
306
|
+
end
|
267
307
|
end
|
268
308
|
end
|
@@ -114,7 +114,7 @@ module LaunchDarkly
|
|
114
114
|
# generated during prerequisite evaluation. Raises EvaluationError if the flag is not well-formed
|
115
115
|
# Will return nil, but not raise an exception, indicating that the rules (including fallthrough) did not match
|
116
116
|
# In that case, the caller should return the default value.
|
117
|
-
def evaluate(flag, user, store)
|
117
|
+
def evaluate(flag, user, store, logger)
|
118
118
|
if flag.nil?
|
119
119
|
raise EvaluationError, "Flag does not exist"
|
120
120
|
end
|
@@ -126,20 +126,23 @@ module LaunchDarkly
|
|
126
126
|
events = []
|
127
127
|
|
128
128
|
if flag[:on]
|
129
|
-
res = eval_internal(flag, user, store, events)
|
130
|
-
|
131
|
-
|
129
|
+
res = eval_internal(flag, user, store, events, logger)
|
130
|
+
if !res.nil?
|
131
|
+
res[:events] = events
|
132
|
+
return res
|
133
|
+
end
|
132
134
|
end
|
133
135
|
|
134
|
-
|
135
|
-
|
136
|
-
|
136
|
+
offVariation = flag[:offVariation]
|
137
|
+
if !offVariation.nil? && offVariation < flag[:variations].length
|
138
|
+
value = flag[:variations][offVariation]
|
139
|
+
return { variation: offVariation, value: value, events: events }
|
137
140
|
end
|
138
141
|
|
139
|
-
{ value: nil, events: events }
|
142
|
+
{ variation: nil, value: nil, events: events }
|
140
143
|
end
|
141
144
|
|
142
|
-
def eval_internal(flag, user, store, events)
|
145
|
+
def eval_internal(flag, user, store, events, logger)
|
143
146
|
failed_prereq = false
|
144
147
|
# Evaluate prerequisites, if any
|
145
148
|
(flag[:prerequisites] || []).each do |prerequisite|
|
@@ -149,14 +152,23 @@ module LaunchDarkly
|
|
149
152
|
failed_prereq = true
|
150
153
|
else
|
151
154
|
begin
|
152
|
-
prereq_res = eval_internal(prereq_flag, user, store, events)
|
153
|
-
|
154
|
-
|
155
|
-
|
155
|
+
prereq_res = eval_internal(prereq_flag, user, store, events, logger)
|
156
|
+
event = {
|
157
|
+
kind: "feature",
|
158
|
+
key: prereq_flag[:key],
|
159
|
+
variation: prereq_res.nil? ? nil : prereq_res[:variation],
|
160
|
+
value: prereq_res.nil? ? nil : prereq_res[:value],
|
161
|
+
version: prereq_flag[:version],
|
162
|
+
prereqOf: flag[:key],
|
163
|
+
trackEvents: prereq_flag[:trackEvents],
|
164
|
+
debugEventsUntilDate: prereq_flag[:debugEventsUntilDate]
|
165
|
+
}
|
166
|
+
events.push(event)
|
167
|
+
if prereq_res.nil? || prereq_res[:variation] != prerequisite[:variation]
|
156
168
|
failed_prereq = true
|
157
169
|
end
|
158
170
|
rescue => exn
|
159
|
-
|
171
|
+
logger.error { "[LDClient] Error evaluating prerequisite: #{exn.inspect}" }
|
160
172
|
failed_prereq = true
|
161
173
|
end
|
162
174
|
end
|
@@ -175,7 +187,9 @@ module LaunchDarkly
|
|
175
187
|
# Check user target matches
|
176
188
|
(flag[:targets] || []).each do |target|
|
177
189
|
(target[:values] || []).each do |value|
|
178
|
-
|
190
|
+
if value == user[:key]
|
191
|
+
return { variation: target[:variation], value: get_variation(flag, target[:variation]) }
|
192
|
+
end
|
179
193
|
end
|
180
194
|
end
|
181
195
|
|
@@ -245,7 +259,7 @@ module LaunchDarkly
|
|
245
259
|
|
246
260
|
def variation_for_user(rule, user, flag)
|
247
261
|
if !rule[:variation].nil? # fixed variation
|
248
|
-
return get_variation(flag, rule[:variation])
|
262
|
+
return { variation: rule[:variation], value: get_variation(flag, rule[:variation]) }
|
249
263
|
elsif !rule[:rollout].nil? # percentage rollout
|
250
264
|
rollout = rule[:rollout]
|
251
265
|
bucket_by = rollout[:bucketBy].nil? ? "key" : rollout[:bucketBy]
|
@@ -253,7 +267,9 @@ module LaunchDarkly
|
|
253
267
|
sum = 0;
|
254
268
|
rollout[:variations].each do |variate|
|
255
269
|
sum += variate[:weight].to_f / 100000.0
|
256
|
-
|
270
|
+
if bucket < sum
|
271
|
+
return { variation: variate[:variation], value: get_variation(flag, variate[:variation]) }
|
272
|
+
end
|
257
273
|
end
|
258
274
|
nil
|
259
275
|
else # the rule isn't well-formed
|
@@ -0,0 +1,52 @@
|
|
1
|
+
|
2
|
+
module LaunchDarkly
|
3
|
+
EventSummary = Struct.new(:start_date, :end_date, :counters)
|
4
|
+
|
5
|
+
# Manages the state of summarizable information for the EventProcessor, including the
|
6
|
+
# event counters and user deduplication. Note that the methods of this class are
|
7
|
+
# deliberately not thread-safe; the EventProcessor is responsible for enforcing
|
8
|
+
# synchronization across both the summarizer and the event queue.
|
9
|
+
class EventSummarizer
|
10
|
+
def initialize
|
11
|
+
clear
|
12
|
+
end
|
13
|
+
|
14
|
+
# Adds this event to our counters, if it is a type of event we need to count.
|
15
|
+
def summarize_event(event)
|
16
|
+
if event[:kind] == "feature"
|
17
|
+
counter_key = {
|
18
|
+
key: event[:key],
|
19
|
+
version: event[:version],
|
20
|
+
variation: event[:variation]
|
21
|
+
}
|
22
|
+
c = @counters[counter_key]
|
23
|
+
if c.nil?
|
24
|
+
@counters[counter_key] = {
|
25
|
+
value: event[:value],
|
26
|
+
default: event[:default],
|
27
|
+
count: 1
|
28
|
+
}
|
29
|
+
else
|
30
|
+
c[:count] = c[:count] + 1
|
31
|
+
end
|
32
|
+
time = event[:creationDate]
|
33
|
+
if !time.nil?
|
34
|
+
@start_date = time if @start_date == 0 || time < @start_date
|
35
|
+
@end_date = time if time > @end_date
|
36
|
+
end
|
37
|
+
end
|
38
|
+
end
|
39
|
+
|
40
|
+
# Returns a snapshot of the current summarized event data, and resets this state.
|
41
|
+
def snapshot
|
42
|
+
ret = EventSummary.new(@start_date, @end_date, @counters)
|
43
|
+
ret
|
44
|
+
end
|
45
|
+
|
46
|
+
def clear
|
47
|
+
@start_date = 0
|
48
|
+
@end_date = 0
|
49
|
+
@counters = {}
|
50
|
+
end
|
51
|
+
end
|
52
|
+
end
|
data/lib/ldclient-rb/events.rb
CHANGED
@@ -1,94 +1,426 @@
|
|
1
|
+
require "concurrent"
|
1
2
|
require "concurrent/atomics"
|
3
|
+
require "concurrent/executors"
|
2
4
|
require "thread"
|
5
|
+
require "time"
|
3
6
|
require "faraday"
|
4
7
|
|
5
8
|
module LaunchDarkly
|
9
|
+
MAX_FLUSH_WORKERS = 5
|
10
|
+
CURRENT_SCHEMA_VERSION = 3
|
11
|
+
|
12
|
+
class NullEventProcessor
|
13
|
+
def add_event(event)
|
14
|
+
end
|
15
|
+
|
16
|
+
def flush
|
17
|
+
end
|
18
|
+
|
19
|
+
def stop
|
20
|
+
end
|
21
|
+
end
|
22
|
+
|
23
|
+
class EventMessage
|
24
|
+
def initialize(event)
|
25
|
+
@event = event
|
26
|
+
end
|
27
|
+
attr_reader :event
|
28
|
+
end
|
29
|
+
|
30
|
+
class FlushMessage
|
31
|
+
end
|
32
|
+
|
33
|
+
class FlushUsersMessage
|
34
|
+
end
|
35
|
+
|
36
|
+
class SynchronousMessage
|
37
|
+
def initialize
|
38
|
+
@reply = Concurrent::Semaphore.new(0)
|
39
|
+
end
|
40
|
+
|
41
|
+
def completed
|
42
|
+
@reply.release
|
43
|
+
end
|
44
|
+
|
45
|
+
def wait_for_completion
|
46
|
+
@reply.acquire
|
47
|
+
end
|
48
|
+
end
|
49
|
+
|
50
|
+
class TestSyncMessage < SynchronousMessage
|
51
|
+
end
|
52
|
+
|
53
|
+
class StopMessage < SynchronousMessage
|
54
|
+
end
|
55
|
+
|
6
56
|
class EventProcessor
|
7
|
-
def initialize(sdk_key, config)
|
57
|
+
def initialize(sdk_key, config, client = nil)
|
8
58
|
@queue = Queue.new
|
9
|
-
@
|
10
|
-
|
11
|
-
|
12
|
-
@
|
59
|
+
@flush_task = Concurrent::TimerTask.new(execution_interval: config.flush_interval) do
|
60
|
+
@queue << FlushMessage.new
|
61
|
+
end
|
62
|
+
@flush_task.execute
|
63
|
+
@users_flush_task = Concurrent::TimerTask.new(execution_interval: config.user_keys_flush_interval) do
|
64
|
+
@queue << FlushUsersMessage.new
|
65
|
+
end
|
66
|
+
@users_flush_task.execute
|
13
67
|
@stopped = Concurrent::AtomicBoolean.new(false)
|
14
|
-
|
68
|
+
|
69
|
+
EventDispatcher.new(@queue, sdk_key, config, client)
|
70
|
+
end
|
71
|
+
|
72
|
+
def add_event(event)
|
73
|
+
event[:creationDate] = (Time.now.to_f * 1000).to_i
|
74
|
+
@queue << EventMessage.new(event)
|
15
75
|
end
|
16
76
|
|
17
|
-
def
|
18
|
-
|
77
|
+
def flush
|
78
|
+
# flush is done asynchronously
|
79
|
+
@queue << FlushMessage.new
|
19
80
|
end
|
20
81
|
|
21
82
|
def stop
|
83
|
+
# final shutdown, which includes a final flush, is done synchronously
|
22
84
|
if @stopped.make_true
|
23
|
-
|
24
|
-
|
25
|
-
|
26
|
-
|
85
|
+
@flush_task.shutdown
|
86
|
+
@users_flush_task.shutdown
|
87
|
+
@queue << FlushMessage.new
|
88
|
+
stop_msg = StopMessage.new
|
89
|
+
@queue << stop_msg
|
90
|
+
stop_msg.wait_for_completion
|
27
91
|
end
|
28
92
|
end
|
29
93
|
|
30
|
-
|
31
|
-
|
32
|
-
|
33
|
-
|
34
|
-
|
35
|
-
|
36
|
-
|
37
|
-
|
94
|
+
# exposed only for testing
|
95
|
+
def wait_until_inactive
|
96
|
+
sync_msg = TestSyncMessage.new
|
97
|
+
@queue << sync_msg
|
98
|
+
sync_msg.wait_for_completion
|
99
|
+
end
|
100
|
+
end
|
101
|
+
|
102
|
+
class EventDispatcher
|
103
|
+
def initialize(queue, sdk_key, config, client)
|
104
|
+
@sdk_key = sdk_key
|
105
|
+
@config = config
|
106
|
+
@client = client ? client : Faraday.new
|
107
|
+
@user_keys = SimpleLRUCacheSet.new(config.user_keys_capacity)
|
108
|
+
@formatter = EventOutputFormatter.new(config)
|
109
|
+
@disabled = Concurrent::AtomicBoolean.new(false)
|
110
|
+
@last_known_past_time = Concurrent::AtomicFixnum.new(0)
|
111
|
+
|
112
|
+
buffer = EventBuffer.new(config.capacity, config.logger)
|
113
|
+
flush_workers = NonBlockingThreadPool.new(MAX_FLUSH_WORKERS)
|
114
|
+
|
115
|
+
Thread.new { main_loop(queue, buffer, flush_workers) }
|
116
|
+
end
|
117
|
+
|
118
|
+
private
|
119
|
+
|
120
|
+
def now_millis()
|
121
|
+
(Time.now.to_f * 1000).to_i
|
122
|
+
end
|
123
|
+
|
124
|
+
def main_loop(queue, buffer, flush_workers)
|
125
|
+
running = true
|
126
|
+
while running do
|
127
|
+
begin
|
128
|
+
message = queue.pop
|
129
|
+
case message
|
130
|
+
when EventMessage
|
131
|
+
dispatch_event(message.event, buffer)
|
132
|
+
when FlushMessage
|
133
|
+
trigger_flush(buffer, flush_workers)
|
134
|
+
when FlushUsersMessage
|
135
|
+
@user_keys.clear
|
136
|
+
when TestSyncMessage
|
137
|
+
synchronize_for_testing(flush_workers)
|
138
|
+
message.completed
|
139
|
+
when StopMessage
|
140
|
+
do_shutdown(flush_workers)
|
141
|
+
running = false
|
142
|
+
message.completed
|
38
143
|
end
|
144
|
+
rescue => e
|
145
|
+
@config.logger.warn { "[LDClient] Unexpected error in event processor: #{e.inspect}. \nTrace: #{e.backtrace}" }
|
39
146
|
end
|
40
147
|
end
|
41
148
|
end
|
42
149
|
|
43
|
-
def
|
44
|
-
|
45
|
-
|
46
|
-
|
47
|
-
|
48
|
-
|
49
|
-
|
50
|
-
|
150
|
+
def do_shutdown(flush_workers)
|
151
|
+
flush_workers.shutdown
|
152
|
+
flush_workers.wait_for_termination
|
153
|
+
# There seems to be no such thing as "close" in Faraday: https://github.com/lostisland/faraday/issues/241
|
154
|
+
end
|
155
|
+
|
156
|
+
def synchronize_for_testing(flush_workers)
|
157
|
+
# Used only by unit tests. Wait until all active flush workers have finished.
|
158
|
+
flush_workers.wait_all
|
159
|
+
end
|
160
|
+
|
161
|
+
def dispatch_event(event, buffer)
|
162
|
+
return if @disabled.value
|
163
|
+
|
164
|
+
# Always record the event in the summary.
|
165
|
+
buffer.add_to_summary(event)
|
166
|
+
|
167
|
+
# Decide whether to add the event to the payload. Feature events may be added twice, once for
|
168
|
+
# the event (if tracked) and once for debugging.
|
169
|
+
will_add_full_event = false
|
170
|
+
debug_event = nil
|
171
|
+
if event[:kind] == "feature"
|
172
|
+
will_add_full_event = event[:trackEvents]
|
173
|
+
if should_debug_event(event)
|
174
|
+
debug_event = event.clone
|
175
|
+
debug_event[:debug] = true
|
176
|
+
end
|
177
|
+
else
|
178
|
+
will_add_full_event = true
|
51
179
|
end
|
52
|
-
|
53
|
-
|
54
|
-
|
55
|
-
|
56
|
-
|
180
|
+
|
181
|
+
# For each user we haven't seen before, we add an index event - unless this is already
|
182
|
+
# an identify event for that user.
|
183
|
+
if !(will_add_full_event && @config.inline_users_in_events)
|
184
|
+
if event.has_key?(:user) && !notice_user(event[:user]) && event[:kind] != "identify"
|
185
|
+
buffer.add_event({
|
186
|
+
kind: "index",
|
187
|
+
creationDate: event[:creationDate],
|
188
|
+
user: event[:user]
|
189
|
+
})
|
57
190
|
end
|
58
191
|
end
|
192
|
+
|
193
|
+
buffer.add_event(event) if will_add_full_event
|
194
|
+
buffer.add_event(debug_event) if !debug_event.nil?
|
59
195
|
end
|
60
196
|
|
61
|
-
|
62
|
-
|
63
|
-
|
64
|
-
|
65
|
-
|
66
|
-
|
197
|
+
# Add to the set of users we've noticed, and return true if the user was already known to us.
|
198
|
+
def notice_user(user)
|
199
|
+
if user.nil? || !user.has_key?(:key)
|
200
|
+
true
|
201
|
+
else
|
202
|
+
@user_keys.add(user[:key])
|
203
|
+
end
|
204
|
+
end
|
205
|
+
|
206
|
+
def should_debug_event(event)
|
207
|
+
debug_until = event[:debugEventsUntilDate]
|
208
|
+
if !debug_until.nil?
|
209
|
+
last_past = @last_known_past_time.value
|
210
|
+
debug_until > last_past && debug_until > now_millis
|
211
|
+
else
|
212
|
+
false
|
213
|
+
end
|
214
|
+
end
|
215
|
+
|
216
|
+
def trigger_flush(buffer, flush_workers)
|
217
|
+
if @disabled.value
|
218
|
+
return
|
219
|
+
end
|
220
|
+
|
221
|
+
payload = buffer.get_payload
|
222
|
+
if !payload.events.empty? || !payload.summary.counters.empty?
|
223
|
+
# If all available worker threads are busy, success will be false and no job will be queued.
|
224
|
+
success = flush_workers.post do
|
225
|
+
resp = EventPayloadSendTask.new.run(@sdk_key, @config, @client, payload, @formatter)
|
226
|
+
handle_response(resp) if !resp.nil?
|
67
227
|
end
|
68
|
-
|
228
|
+
buffer.clear if success # Reset our internal state, these events now belong to the flush worker
|
69
229
|
end
|
230
|
+
end
|
70
231
|
|
71
|
-
|
72
|
-
|
232
|
+
def handle_response(res)
|
233
|
+
if res.status == 401
|
234
|
+
@config.logger.error { "[LDClient] Received 401 error, no further events will be posted since SDK key is invalid" }
|
235
|
+
@disabled.value = true
|
236
|
+
else
|
237
|
+
if !res.headers.nil? && res.headers.has_key?("Date")
|
238
|
+
begin
|
239
|
+
res_time = (Time.httpdate(res.headers["Date"]).to_f * 1000).to_i
|
240
|
+
@last_known_past_time.value = res_time
|
241
|
+
rescue ArgumentError
|
242
|
+
end
|
243
|
+
end
|
73
244
|
end
|
74
245
|
end
|
246
|
+
end
|
247
|
+
|
248
|
+
FlushPayload = Struct.new(:events, :summary)
|
249
|
+
|
250
|
+
class EventBuffer
|
251
|
+
def initialize(capacity, logger)
|
252
|
+
@capacity = capacity
|
253
|
+
@logger = logger
|
254
|
+
@capacity_exceeded = false
|
255
|
+
@events = []
|
256
|
+
@summarizer = EventSummarizer.new
|
257
|
+
end
|
75
258
|
|
76
259
|
def add_event(event)
|
77
|
-
|
260
|
+
if @events.length < @capacity
|
261
|
+
@logger.debug { "[LDClient] Enqueueing event: #{event.to_json}" }
|
262
|
+
@events.push(event)
|
263
|
+
@capacity_exceeded = false
|
264
|
+
else
|
265
|
+
if !@capacity_exceeded
|
266
|
+
@capacity_exceeded = true
|
267
|
+
@logger.warn { "[LDClient] Exceeded event queue capacity. Increase capacity to avoid dropping events." }
|
268
|
+
end
|
269
|
+
end
|
270
|
+
end
|
271
|
+
|
272
|
+
def add_to_summary(event)
|
273
|
+
@summarizer.summarize_event(event)
|
274
|
+
end
|
78
275
|
|
79
|
-
|
80
|
-
|
81
|
-
|
82
|
-
|
276
|
+
def get_payload
|
277
|
+
return FlushPayload.new(@events, @summarizer.snapshot)
|
278
|
+
end
|
279
|
+
|
280
|
+
def clear
|
281
|
+
@events = []
|
282
|
+
@summarizer.clear
|
283
|
+
end
|
284
|
+
end
|
83
285
|
|
84
|
-
|
85
|
-
|
286
|
+
class EventPayloadSendTask
|
287
|
+
def run(sdk_key, config, client, payload, formatter)
|
288
|
+
events_out = formatter.make_output_events(payload.events, payload.summary)
|
289
|
+
res = nil
|
290
|
+
body = events_out.to_json
|
291
|
+
(0..1).each do |attempt|
|
292
|
+
if attempt > 0
|
293
|
+
config.logger.warn { "[LDClient] Will retry posting events after 1 second" }
|
294
|
+
sleep(1)
|
86
295
|
end
|
296
|
+
begin
|
297
|
+
config.logger.debug { "[LDClient] sending #{events_out.length} events: #{body}" }
|
298
|
+
res = client.post (config.events_uri + "/bulk") do |req|
|
299
|
+
req.headers["Authorization"] = sdk_key
|
300
|
+
req.headers["User-Agent"] = "RubyClient/" + LaunchDarkly::VERSION
|
301
|
+
req.headers["Content-Type"] = "application/json"
|
302
|
+
req.headers["X-LaunchDarkly-Event-Schema"] = CURRENT_SCHEMA_VERSION.to_s
|
303
|
+
req.body = body
|
304
|
+
req.options.timeout = config.read_timeout
|
305
|
+
req.options.open_timeout = config.connect_timeout
|
306
|
+
end
|
307
|
+
rescue StandardError => exn
|
308
|
+
config.logger.warn { "[LDClient] Error flushing events: #{exn.inspect}." }
|
309
|
+
next
|
310
|
+
end
|
311
|
+
if res.status < 200 || res.status >= 300
|
312
|
+
config.logger.error { "[LDClient] Unexpected status code while processing events: #{res.status}" }
|
313
|
+
if res.status >= 500
|
314
|
+
next
|
315
|
+
end
|
316
|
+
end
|
317
|
+
break
|
318
|
+
end
|
319
|
+
# used up our retries, return the last response if any
|
320
|
+
res
|
321
|
+
end
|
322
|
+
end
|
323
|
+
|
324
|
+
class EventOutputFormatter
|
325
|
+
def initialize(config)
|
326
|
+
@inline_users = config.inline_users_in_events
|
327
|
+
@user_filter = UserFilter.new(config)
|
328
|
+
end
|
329
|
+
|
330
|
+
# Transforms events into the format used for event sending.
|
331
|
+
def make_output_events(events, summary)
|
332
|
+
events_out = events.map { |e| make_output_event(e) }
|
333
|
+
if !summary.counters.empty?
|
334
|
+
events_out.push(make_summary_event(summary))
|
335
|
+
end
|
336
|
+
events_out
|
337
|
+
end
|
338
|
+
|
339
|
+
private
|
340
|
+
|
341
|
+
def make_output_event(event)
|
342
|
+
case event[:kind]
|
343
|
+
when "feature"
|
344
|
+
is_debug = event[:debug]
|
345
|
+
out = {
|
346
|
+
kind: is_debug ? "debug" : "feature",
|
347
|
+
creationDate: event[:creationDate],
|
348
|
+
key: event[:key],
|
349
|
+
value: event[:value]
|
350
|
+
}
|
351
|
+
out[:default] = event[:default] if event.has_key?(:default)
|
352
|
+
out[:variation] = event[:variation] if event.has_key?(:variation)
|
353
|
+
out[:version] = event[:version] if event.has_key?(:version)
|
354
|
+
out[:prereqOf] = event[:prereqOf] if event.has_key?(:prereqOf)
|
355
|
+
if @inline_users || is_debug
|
356
|
+
out[:user] = @user_filter.transform_user_props(event[:user])
|
357
|
+
else
|
358
|
+
out[:userKey] = event[:user].nil? ? nil : event[:user][:key]
|
359
|
+
end
|
360
|
+
out
|
361
|
+
when "identify"
|
362
|
+
{
|
363
|
+
kind: "identify",
|
364
|
+
creationDate: event[:creationDate],
|
365
|
+
key: event[:user].nil? ? nil : event[:user][:key],
|
366
|
+
user: @user_filter.transform_user_props(event[:user])
|
367
|
+
}
|
368
|
+
when "custom"
|
369
|
+
out = {
|
370
|
+
kind: "custom",
|
371
|
+
creationDate: event[:creationDate],
|
372
|
+
key: event[:key]
|
373
|
+
}
|
374
|
+
out[:data] = event[:data] if event.has_key?(:data)
|
375
|
+
if @inline_users
|
376
|
+
out[:user] = @user_filter.transform_user_props(event[:user])
|
377
|
+
else
|
378
|
+
out[:userKey] = event[:user].nil? ? nil : event[:user][:key]
|
379
|
+
end
|
380
|
+
out
|
381
|
+
when "index"
|
382
|
+
{
|
383
|
+
kind: "index",
|
384
|
+
creationDate: event[:creationDate],
|
385
|
+
user: @user_filter.transform_user_props(event[:user])
|
386
|
+
}
|
87
387
|
else
|
88
|
-
|
388
|
+
event
|
89
389
|
end
|
90
390
|
end
|
91
391
|
|
92
|
-
|
392
|
+
# Transforms the summary data into the format used for event sending.
|
393
|
+
def make_summary_event(summary)
|
394
|
+
flags = {}
|
395
|
+
summary[:counters].each { |ckey, cval|
|
396
|
+
flag = flags[ckey[:key]]
|
397
|
+
if flag.nil?
|
398
|
+
flag = {
|
399
|
+
default: cval[:default],
|
400
|
+
counters: []
|
401
|
+
}
|
402
|
+
flags[ckey[:key]] = flag
|
403
|
+
end
|
404
|
+
c = {
|
405
|
+
value: cval[:value],
|
406
|
+
count: cval[:count]
|
407
|
+
}
|
408
|
+
if !ckey[:variation].nil?
|
409
|
+
c[:variation] = ckey[:variation]
|
410
|
+
end
|
411
|
+
if ckey[:version].nil?
|
412
|
+
c[:unknown] = true
|
413
|
+
else
|
414
|
+
c[:version] = ckey[:version]
|
415
|
+
end
|
416
|
+
flag[:counters].push(c)
|
417
|
+
}
|
418
|
+
{
|
419
|
+
kind: "summary",
|
420
|
+
startDate: summary[:start_date],
|
421
|
+
endDate: summary[:end_date],
|
422
|
+
features: flags
|
423
|
+
}
|
424
|
+
end
|
93
425
|
end
|
94
426
|
end
|