ldclient-rb 2.2.7 → 2.3.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
  SHA1:
3
- metadata.gz: 3e645183ee16963c519ab1ad307c7143c5d3a7e2
4
- data.tar.gz: 91e57821ddab1cc9df9a30ec5ca25301a6ec97b9
3
+ metadata.gz: 8bfb8d22b2b4294ec4096d0c3131e20390bf2634
4
+ data.tar.gz: 42d06dbed907402e81213b207bbcf4ccce52dd5d
5
5
  SHA512:
6
- metadata.gz: ee4f0da087bd1f0bcc900c9ff3e28239ad1d102a6e542b7d3349d72d0d5f23a6450e1040afbedfd0ad941bf6bab4f08f7bec7a8e11b7314464b2157b9a7128c0
7
- data.tar.gz: 333cc4aa6a1a2ff95bfed3c7a71641d8162f11707a693ab3e14874b83ab732618de46b87c8e3dd9583e4130e0dfa1547362b4a1674dc70fd4c59330787869122
6
+ metadata.gz: d3b59e2fdbec17b31cc7c6450e1485afa9a522009990528b998f2f1782711049b86db19f56ba6212d09c931a23a5f92b1078fb70c071fd8adbbd1116990d8601
7
+ data.tar.gz: 6d2bdc142c97b704ed5ad211bb9eee18f3b0e2bf5deaf5ac2957b3d8fe8475f591e28bdc4ad604665b5154fbf96548fe058a6dbeeed4e98d9ed51320738c7994
data/CHANGELOG.md CHANGED
@@ -2,6 +2,22 @@
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
+ ## [2.3.1] - 2017-11-16
6
+
7
+ ### Changed
8
+ - Include source code for changes described in 2.3.0
9
+
10
+
11
+ ## [2.3.0] - 2017-11-16
12
+ ## Added
13
+ - Add `close` method to Ruby client to stop processing events
14
+ - Add support for Redis feature store
15
+ - Add support for LDD mode
16
+ - Allow user to disable outgoing event stream.
17
+
18
+ ## Changed
19
+ - Stop retrying on 401 responses (due to bad sdk keys)
20
+
5
21
  ## [2.2.7] - 2017-07-26
6
22
  ## Changed
7
23
  - Update Readme to fix instructions on installing gem using command line
data/CODEOWNERS ADDED
@@ -0,0 +1 @@
1
+ * @ashanbrown
data/circle.yml CHANGED
@@ -1,6 +1,8 @@
1
1
  machine:
2
2
  environment:
3
3
  RUBIES: "ruby-2.4.1;ruby-2.2.3;ruby-2.1.7;ruby-2.0.0;ruby-1.9.3;jruby-1.7.22"
4
+ services:
5
+ - redis
4
6
 
5
7
  dependencies:
6
8
  cache_directories:
data/ldclient-rb.gemspec CHANGED
@@ -25,7 +25,10 @@ Gem::Specification.new do |spec|
25
25
  spec.add_development_dependency "rake", "~> 10.0"
26
26
  spec.add_development_dependency "rspec", "~> 3.2"
27
27
  spec.add_development_dependency "codeclimate-test-reporter", "~> 0"
28
-
28
+ spec.add_development_dependency "redis", "~> 3.3.5"
29
+ spec.add_development_dependency "connection_pool", ">= 2.1.2"
30
+ spec.add_development_dependency "moneta", "~> 1.0.0"
31
+
29
32
  spec.add_runtime_dependency "json", "~> 1.8"
30
33
  spec.add_runtime_dependency "faraday", "~> 0.9"
31
34
  spec.add_runtime_dependency "faraday-http-cache", "~> 1.3.0"
@@ -33,7 +36,7 @@ Gem::Specification.new do |spec|
33
36
  spec.add_runtime_dependency "net-http-persistent", "~> 2.9"
34
37
  spec.add_runtime_dependency "concurrent-ruby", "~> 1.0.4"
35
38
  spec.add_runtime_dependency "hashdiff", "~> 0.2"
36
- spec.add_runtime_dependency "ld-celluloid-eventsource", "~> 0.10.0"
39
+ spec.add_runtime_dependency "ld-celluloid-eventsource", "~> 0.11.0"
37
40
  spec.add_runtime_dependency "celluloid", "~> 0.18.0.pre" # transitive dep; specified here for more control
38
41
 
39
42
  if RUBY_VERSION >= "2.2.2"
data/lib/ldclient-rb.rb CHANGED
@@ -6,6 +6,8 @@ require "ldclient-rb/config"
6
6
  require "ldclient-rb/newrelic"
7
7
  require "ldclient-rb/stream"
8
8
  require "ldclient-rb/polling"
9
+ require "ldclient-rb/event_serializer"
9
10
  require "ldclient-rb/events"
10
11
  require "ldclient-rb/feature_store"
12
+ require "ldclient-rb/redis_feature_store"
11
13
  require "ldclient-rb/requestor"
@@ -34,12 +34,23 @@ module LaunchDarkly
34
34
  # @option opts [Object] :cache_store A cache store for the Faraday HTTP caching
35
35
  # library. Defaults to the Rails cache in a Rails environment, or a
36
36
  # thread-safe in-memory store otherwise.
37
+ # @option opts [Boolean] :use_ldd (false) Whether you are using the LaunchDarkly relay proxy in
38
+ # daemon mode. In this configuration, the client will not use a streaming connection to listen
39
+ # for updates, but instead will get feature state from a Redis instance. The `stream` and
40
+ # `poll_interval` options will be ignored if this option is set to true.
37
41
  # @option opts [Boolean] :offline (false) Whether the client should be initialized in
38
42
  # offline mode. In offline mode, default values are returned for all flags and no
39
43
  # remote network requests are made.
40
44
  # @option opts [Float] :poll_interval (1) The number of seconds between polls for flag updates
41
45
  # if streaming is off.
42
46
  # @option opts [Boolean] :stream (true) Whether or not the streaming API should be used to receive flag updates.
47
+ # @option opts [Boolean] all_attributes_private (false) If true, all user attributes (other than the key)
48
+ # will be private, not just the attributes specified in `private_attribute_names`.
49
+ # @option opts [Array] :private_attribute_names Marks a set of attribute names private. Any users sent to
50
+ # LaunchDarkly with this configuration active will have attributes with these names removed.
51
+ # @option opts [Boolean] :send_events (true) Whether or not to send events back to LaunchDarkly.
52
+ # This differs from `offline` in that it affects only the sending of client-side events, not
53
+ # streaming or polling for events from the server.
43
54
  #
44
55
  # @return [type] [description]
45
56
  # rubocop:disable Metrics/AbcSize, Metrics/PerceivedComplexity
@@ -55,9 +66,13 @@ module LaunchDarkly
55
66
  @read_timeout = opts[:read_timeout] || Config.default_read_timeout
56
67
  @feature_store = opts[:feature_store] || Config.default_feature_store
57
68
  @stream = opts.has_key?(:stream) ? opts[:stream] : Config.default_stream
69
+ @use_ldd = opts.has_key?(:use_ldd) ? opts[:use_ldd] : Config.default_use_ldd
58
70
  @offline = opts.has_key?(:offline) ? opts[:offline] : Config.default_offline
59
71
  @poll_interval = opts.has_key?(:poll_interval) && opts[:poll_interval] > 1 ? opts[:poll_interval] : Config.default_poll_interval
60
72
  @proxy = opts[:proxy] || Config.default_proxy
73
+ @all_attributes_private = opts[:all_attributes_private] || false
74
+ @private_attribute_names = opts[:private_attribute_names] || []
75
+ @send_events = opts.has_key?(:send_events) ? opts[:send_events] : Config.default_send_events
61
76
  end
62
77
 
63
78
  #
@@ -87,6 +102,16 @@ module LaunchDarkly
87
102
  @stream
88
103
  end
89
104
 
105
+ #
106
+ # Whether to use the LaunchDarkly relay proxy in daemon mode. In this mode, we do
107
+ # not use polling or streaming to get feature flag updates from the server, but instead
108
+ # read them from a Redis instance that is updated by the proxy.
109
+ #
110
+ # @return [Boolean] True if using the LaunchDarkly relay proxy in daemon mode
111
+ def use_ldd?
112
+ @use_ldd
113
+ end
114
+
90
115
  # TODO docs
91
116
  def offline?
92
117
  @offline
@@ -150,6 +175,15 @@ module LaunchDarkly
150
175
  #
151
176
  attr_reader :proxy
152
177
 
178
+ attr_reader :all_attributes_private
179
+
180
+ attr_reader :private_attribute_names
181
+
182
+ #
183
+ # Whether to send events back to LaunchDarkly.
184
+ #
185
+ attr_reader :send_events
186
+
153
187
  #
154
188
  # The default LaunchDarkly client configuration. This configuration sets
155
189
  # reasonable defaults for most users.
@@ -209,6 +243,10 @@ module LaunchDarkly
209
243
  true
210
244
  end
211
245
 
246
+ def self.default_use_ldd
247
+ false
248
+ end
249
+
212
250
  def self.default_feature_store
213
251
  InMemoryFeatureStore.new
214
252
  end
@@ -220,5 +258,9 @@ module LaunchDarkly
220
258
  def self.default_poll_interval
221
259
  1
222
260
  end
261
+
262
+ def self.default_send_events
263
+ true
264
+ end
223
265
  end
224
266
  end
@@ -0,0 +1,51 @@
1
+ require "json"
2
+
3
+ module LaunchDarkly
4
+ class EventSerializer
5
+ def initialize(config)
6
+ @all_attributes_private = config.all_attributes_private
7
+ @private_attribute_names = Set.new(config.private_attribute_names.map(&:to_sym))
8
+ end
9
+
10
+ def serialize_events(events)
11
+ events.map { |event|
12
+ Hash[event.map { |key, value|
13
+ [key, (key.to_sym == :user) ? transform_user_props(value) : value]
14
+ }]
15
+ }.to_json
16
+ end
17
+
18
+ private
19
+
20
+ IGNORED_TOP_LEVEL_KEYS = Set.new([:custom, :key, :privateAttributeNames])
21
+ STRIPPED_TOP_LEVEL_KEYS = Set.new([:privateAttributeNames])
22
+
23
+ def filter_values(props, user_private_attrs, ignore=[])
24
+ removed_keys = Set.new(props.keys.select { |key|
25
+ !ignore.include?(key) && private_attr?(key, user_private_attrs)
26
+ })
27
+ filtered_hash = props.select { |key, value| !removed_keys.include?(key) && !STRIPPED_TOP_LEVEL_KEYS.include?(key) }
28
+ [filtered_hash, removed_keys]
29
+ end
30
+
31
+ def private_attr?(name, user_private_attrs)
32
+ @all_attributes_private || @private_attribute_names.include?(name) || user_private_attrs.include?(name)
33
+ end
34
+
35
+ def transform_user_props(user_props)
36
+ user_private_attrs = Set.new((user_props[:privateAttributeNames] || []).map(&:to_sym))
37
+
38
+ filtered_user_props, removed = filter_values(user_props, user_private_attrs, IGNORED_TOP_LEVEL_KEYS)
39
+ if user_props.has_key?(:custom)
40
+ filtered_user_props[:custom], removed_custom = filter_values(user_props[:custom], user_private_attrs)
41
+ removed.merge(removed_custom)
42
+ end
43
+
44
+ unless removed.empty?
45
+ # note, :privateAttributeNames is what the developer sets; :privateAttrs is what we send to the server
46
+ filtered_user_props[:privateAttrs] = removed.to_a.sort
47
+ end
48
+ return filtered_user_props
49
+ end
50
+ end
51
+ end
@@ -1,3 +1,4 @@
1
+ require "concurrent/atomics"
1
2
  require "thread"
2
3
  require "faraday"
3
4
 
@@ -7,13 +8,28 @@ module LaunchDarkly
7
8
  @queue = Queue.new
8
9
  @sdk_key = sdk_key
9
10
  @config = config
11
+ @serializer = EventSerializer.new(config)
10
12
  @client = Faraday.new
11
- @worker = create_worker
13
+ @stopped = Concurrent::AtomicBoolean.new(false)
14
+ @worker = create_worker if @config.send_events
15
+ end
16
+
17
+ def alive?
18
+ !@stopped.value
19
+ end
20
+
21
+ def stop
22
+ if @stopped.make_true
23
+ # There seems to be no such thing as "close" in Faraday: https://github.com/lostisland/faraday/issues/241
24
+ if !@worker.nil? && @worker.alive?
25
+ @worker.raise "shutting down client"
26
+ end
27
+ end
12
28
  end
13
29
 
14
30
  def create_worker
15
31
  Thread.new do
16
- loop do
32
+ while !@stopped.value do
17
33
  begin
18
34
  flush
19
35
  sleep(@config.flush_interval)
@@ -29,16 +45,21 @@ module LaunchDarkly
29
45
  req.headers["Authorization"] = @sdk_key
30
46
  req.headers["User-Agent"] = "RubyClient/" + LaunchDarkly::VERSION
31
47
  req.headers["Content-Type"] = "application/json"
32
- req.body = events.to_json
48
+ req.body = @serializer.serialize_events(events)
33
49
  req.options.timeout = @config.read_timeout
34
50
  req.options.open_timeout = @config.connect_timeout
35
51
  end
36
52
  if res.status < 200 || res.status >= 300
37
53
  @config.logger.error("[LDClient] Unexpected status code while processing events: #{res.status}")
54
+ if res.status == 401
55
+ @config.logger.error("[LDClient] Received 401 error, no further events will be posted since SDK key is invalid")
56
+ stop
57
+ end
38
58
  end
39
59
  end
40
60
 
41
61
  def flush
62
+ return if @offline || !@config.send_events
42
63
  events = []
43
64
  begin
44
65
  loop do
@@ -47,13 +68,13 @@ module LaunchDarkly
47
68
  rescue ThreadError
48
69
  end
49
70
 
50
- if !events.empty?
71
+ if !events.empty? && !@stopped.value
51
72
  post_flushed_events(events)
52
73
  end
53
74
  end
54
75
 
55
76
  def add_event(event)
56
- return if @offline
77
+ return if @offline || !@config.send_events || @stopped.value
57
78
 
58
79
  if @queue.length < @config.capacity
59
80
  event[:creationDate] = (Time.now.to_f * 1000).to_i
@@ -55,5 +55,9 @@ module LaunchDarkly
55
55
  def initialized?
56
56
  @initialized.value
57
57
  end
58
+
59
+ def stop
60
+ # nothing to do
61
+ end
58
62
  end
59
63
  end
@@ -27,6 +27,14 @@ module LaunchDarkly
27
27
  @sdk_key = sdk_key
28
28
  @config = config
29
29
  @store = config.feature_store
30
+
31
+ @event_processor = EventProcessor.new(sdk_key, config)
32
+
33
+ if @config.use_ldd?
34
+ @config.logger.info("[LDClient] Started LaunchDarkly Client in LDD mode")
35
+ return # requestor and update processor are not used in this mode
36
+ end
37
+
30
38
  requestor = Requestor.new(sdk_key, config)
31
39
 
32
40
  if !@config.offline?
@@ -38,8 +46,6 @@ module LaunchDarkly
38
46
  @update_processor.start
39
47
  end
40
48
 
41
- @event_processor = EventProcessor.new(sdk_key, config)
42
-
43
49
  if !@config.offline? && wait_for_sec > 0
44
50
  begin
45
51
  WaitUtil.wait_for_condition("LaunchDarkly client initialization", timeout_sec: wait_for_sec, delay_sec: 0.1) do
@@ -67,7 +73,7 @@ module LaunchDarkly
67
73
  # Returns whether the client has been initialized and is ready to serve feature flag requests
68
74
  # @return [Boolean] true if the client has been initialized
69
75
  def initialized?
70
- @update_processor.initialized?
76
+ @config.offline? || @config.use_ldd? || @update_processor.initialized?
71
77
  end
72
78
 
73
79
  #
@@ -111,7 +117,7 @@ module LaunchDarkly
111
117
  return default
112
118
  end
113
119
 
114
- if !@update_processor.initialized?
120
+ if !@update_processor.nil? && !@update_processor.initialized?
115
121
  @config.logger.error("[LDClient] Client has not finished initializing. Returning default value")
116
122
  @event_processor.add_event(kind: "feature", key: key, value: default, default: default, user: user)
117
123
  return default
@@ -195,6 +201,19 @@ module LaunchDarkly
195
201
  end
196
202
  end
197
203
 
204
+ #
205
+ # Releases all network connections and other resources held by the client, making it no longer usable
206
+ #
207
+ # @return [void]
208
+ def close
209
+ @config.logger.info("[LDClient] Closing LaunchDarkly client...")
210
+ if not @config.offline?
211
+ @update_processor.stop
212
+ end
213
+ @event_processor.stop
214
+ @store.stop
215
+ end
216
+
198
217
  def log_exception(caller, exn)
199
218
  error_traceback = "#{exn.inspect} #{exn}\n\t#{exn.backtrace.join("\n\t")}"
200
219
  error = "[LDClient] Unexpected exception in #{caller}: #{error_traceback}"
@@ -8,6 +8,7 @@ module LaunchDarkly
8
8
  @requestor = requestor
9
9
  @initialized = Concurrent::AtomicBoolean.new(false)
10
10
  @started = Concurrent::AtomicBoolean.new(false)
11
+ @stopped = Concurrent::AtomicBoolean.new(false)
11
12
  end
12
13
 
13
14
  def initialized?
@@ -20,6 +21,15 @@ module LaunchDarkly
20
21
  create_worker
21
22
  end
22
23
 
24
+ def stop
25
+ if @stopped.make_true
26
+ if @worker && @worker.alive?
27
+ @worker.raise "shutting down client"
28
+ end
29
+ @config.logger.info("[LDClient] Polling connection stopped")
30
+ end
31
+ end
32
+
23
33
  def poll
24
34
  flags = @requestor.request_all_flags
25
35
  if flags
@@ -31,9 +41,9 @@ module LaunchDarkly
31
41
  end
32
42
 
33
43
  def create_worker
34
- Thread.new do
44
+ @worker = Thread.new do
35
45
  @config.logger.debug("[LDClient] Starting polling worker")
36
- loop do
46
+ while !@stopped.value do
37
47
  begin
38
48
  started_at = Time.now
39
49
  poll
@@ -41,6 +51,9 @@ module LaunchDarkly
41
51
  if delta > 0
42
52
  sleep(delta)
43
53
  end
54
+ rescue InvalidSDKKeyError
55
+ @config.logger.error("[LDClient] Received 401 error, no further polling requests will be made since SDK key is invalid");
56
+ stop
44
57
  rescue StandardError => exn
45
58
  @config.logger.error("[LDClient] Exception while polling: #{exn.inspect}")
46
59
  # TODO: log_exception(__method__.to_s, exn)
@@ -0,0 +1,234 @@
1
+ require "concurrent/atomics"
2
+ require "json"
3
+ require "thread_safe"
4
+
5
+ module LaunchDarkly
6
+ #
7
+ # An implementation of the LaunchDarkly client's feature store that uses a Redis
8
+ # instance. Feature data can also be further cached in memory to reduce overhead
9
+ # of calls to Redis.
10
+ #
11
+ # To use this class, you must first have the `redis`, `connection-pool`, and `moneta`
12
+ # gems installed. Then, create an instance and store it in the `feature_store`
13
+ # property of your client configuration.
14
+ #
15
+ class RedisFeatureStore
16
+ INIT_KEY = :"$initialized"
17
+
18
+ begin
19
+ require "redis"
20
+ require "connection_pool"
21
+ require "moneta"
22
+ REDIS_ENABLED = true
23
+ rescue ScriptError, StandardError
24
+ REDIS_ENABLED = false
25
+ end
26
+
27
+ #
28
+ # Constructor for a RedisFeatureStore instance.
29
+ #
30
+ # @param opts [Hash] the configuration options
31
+ # @option opts [String] :redis_url URL of the Redis instance (shortcut for omitting redis_opts)
32
+ # @option opts [Hash] :redis_opts options to pass to the Redis constructor (if you want to specify more than just redis_url)
33
+ # @option opts [String] :prefix namespace prefix to add to all hash keys used by LaunchDarkly
34
+ # @option opts [Logger] :logger a `Logger` instance; defaults to `Config.default_logger`
35
+ # @option opts [Integer] :max_connections size of the Redis connection pool
36
+ # @option opts [Integer] :expiration expiration time for the in-memory cache, in seconds; 0 for no local caching
37
+ # @option opts [Integer] :capacity maximum number of feature flags to cache locally
38
+ # @option opts [Object] :pool custom connection pool, used for testing only
39
+ #
40
+ def initialize(opts = {})
41
+ if !REDIS_ENABLED
42
+ raise RuntimeError.new("can't use RedisFeatureStore because one of these gems is missing: redis, connection_pool, moneta")
43
+ end
44
+ @redis_opts = opts[:redis_opts] || Hash.new
45
+ if opts[:redis_url]
46
+ @redis_opts[:url] = opts[:redis_url]
47
+ end
48
+ if !@redis_opts.include?(:url)
49
+ @redis_opts[:url] = RedisFeatureStore.default_redis_url
50
+ end
51
+ max_connections = opts[:max_connections] || 16
52
+ @pool = opts[:pool] || ConnectionPool.new(size: max_connections) do
53
+ Redis.new(@redis_opts)
54
+ end
55
+ @prefix = opts[:prefix] || RedisFeatureStore.default_prefix
56
+ @logger = opts[:logger] || Config.default_logger
57
+ @features_key = @prefix + ':features'
58
+
59
+ @expiration_seconds = opts[:expiration] || 15
60
+ @capacity = opts[:capacity] || 1000
61
+ # We're using Moneta only to provide expiration behavior for the in-memory cache.
62
+ # Moneta can also be used as a wrapper for Redis, but it doesn't support the Redis
63
+ # hash operations that we use.
64
+ if @expiration_seconds > 0
65
+ @cache = Moneta.new(:LRUHash, expires: true, threadsafe: true, max_count: @capacity)
66
+ else
67
+ @cache = Moneta.new(:Null) # a stub that caches nothing
68
+ end
69
+
70
+ @stopped = Concurrent::AtomicBoolean.new(false)
71
+
72
+ with_connection do |redis|
73
+ @logger.info("RedisFeatureStore: using Redis instance at #{redis.connection[:host]}:#{redis.connection[:port]} \
74
+ and prefix: #{@prefix}")
75
+ end
76
+ end
77
+
78
+ #
79
+ # Default value for the `redis_url` constructor parameter; points to an instance of Redis
80
+ # running at `localhost` with its default port.
81
+ #
82
+ def self.default_redis_url
83
+ 'redis://localhost:6379/0'
84
+ end
85
+
86
+ #
87
+ # Default value for the `prefix` constructor parameter.
88
+ #
89
+ def self.default_prefix
90
+ 'launchdarkly'
91
+ end
92
+
93
+ def get(key)
94
+ f = @cache[key.to_sym]
95
+ if f.nil?
96
+ @logger.debug("RedisFeatureStore: no cache hit for #{key}, requesting from Redis")
97
+ f = with_connection do |redis|
98
+ begin
99
+ get_redis(redis,key.to_sym)
100
+ rescue => e
101
+ @logger.error("RedisFeatureStore: could not retrieve feature #{key} from Redis, with error: #{e}")
102
+ nil
103
+ end
104
+ end
105
+ if !f.nil?
106
+ put_cache(key.to_sym, f)
107
+ end
108
+ end
109
+ if f.nil?
110
+ @logger.warn("RedisFeatureStore: feature #{key} not found")
111
+ nil
112
+ elsif f[:deleted]
113
+ @logger.warn("RedisFeatureStore: feature #{key} was deleted, returning nil")
114
+ nil
115
+ else
116
+ f
117
+ end
118
+ end
119
+
120
+ def all
121
+ fs = {}
122
+ with_connection do |redis|
123
+ begin
124
+ hashfs = redis.hgetall(@features_key)
125
+ rescue => e
126
+ @logger.error("RedisFeatureStore: could not retrieve all flags from Redis with error: #{e}; returning none")
127
+ hashfs = {}
128
+ end
129
+ hashfs.each do |k, jsonFeature|
130
+ f = JSON.parse(jsonFeature, symbolize_names: true)
131
+ if !f[:deleted]
132
+ fs[k.to_sym] = f
133
+ end
134
+ end
135
+ end
136
+ fs
137
+ end
138
+
139
+ def delete(key, version)
140
+ with_connection do |redis|
141
+ f = get_redis(redis, key)
142
+ if f.nil?
143
+ put_redis_and_cache(redis, key, { deleted: true, version: version })
144
+ else
145
+ if f[:version] < version
146
+ f1 = f.clone
147
+ f1[:deleted] = true
148
+ f1[:version] = version
149
+ put_redis_and_cache(redis, key, f1)
150
+ else
151
+ @logger.warn("RedisFeatureStore: attempted to delete flag: #{key} version: #{f[:version]} \
152
+ with a version that is the same or older: #{version}")
153
+ end
154
+ end
155
+ end
156
+ end
157
+
158
+ def init(fs)
159
+ @cache.clear
160
+ with_connection do |redis|
161
+ redis.del(@features_key)
162
+ fs.each { |k, f| put_redis_and_cache(redis, k, f) }
163
+ end
164
+ put_cache(INIT_KEY, true)
165
+ @logger.info("RedisFeatureStore: initialized with #{fs.count} feature flags")
166
+ end
167
+
168
+ def upsert(key, feature)
169
+ with_connection do |redis|
170
+ redis.watch(@features_key) do
171
+ old = get_redis(redis, key)
172
+ if old.nil? || (old[:version] < feature[:version])
173
+ put_redis_and_cache(redis, key, feature)
174
+ end
175
+ redis.unwatch
176
+ end
177
+ end
178
+ end
179
+
180
+ def initialized?
181
+ if @cache[INIT_KEY].nil?
182
+ if with_connection { |redis| redis.exists(@features_key) }
183
+ put_cache(INIT_KEY, true)
184
+ true
185
+ else
186
+ false
187
+ end
188
+ else
189
+ put_cache(INIT_KEY, true) # reset TTL
190
+ end
191
+ end
192
+
193
+ def stop
194
+ if @stopped.make_true
195
+ @pool.shutdown { |redis| redis.close }
196
+ @cache.clear
197
+ end
198
+ end
199
+
200
+ # exposed for testing
201
+ def clear_local_cache()
202
+ @cache.clear
203
+ end
204
+
205
+ private
206
+
207
+ def with_connection
208
+ @pool.with { |redis| yield(redis) }
209
+ end
210
+
211
+ def get_redis(redis, key)
212
+ begin
213
+ json_feature = redis.hget(@features_key, key)
214
+ JSON.parse(json_feature, symbolize_names: true) if json_feature
215
+ rescue => e
216
+ @logger.error("RedisFeatureStore: could not retrieve feature #{key} from Redis, error: #{e}")
217
+ nil
218
+ end
219
+ end
220
+
221
+ def put_cache(key, value)
222
+ @cache.store(key, value, expires: @expiration_seconds)
223
+ end
224
+
225
+ def put_redis_and_cache(redis, key, feature)
226
+ begin
227
+ redis.hset(@features_key, key, feature.to_json)
228
+ rescue => e
229
+ @logger.error("RedisFeatureStore: could not store #{key} in Redis, error: #{e}")
230
+ end
231
+ put_cache(key.to_sym, feature)
232
+ end
233
+ end
234
+ end
@@ -4,6 +4,9 @@ require "faraday/http_cache"
4
4
 
5
5
  module LaunchDarkly
6
6
 
7
+ class InvalidSDKKeyError < StandardError
8
+ end
9
+
7
10
  class Requestor
8
11
  def initialize(sdk_key, config)
9
12
  @sdk_key = sdk_key
@@ -39,7 +42,7 @@ module LaunchDarkly
39
42
 
40
43
  if res.status == 401
41
44
  @config.logger.error("[LDClient] Invalid SDK key")
42
- return nil
45
+ raise InvalidSDKKeyError
43
46
  end
44
47
 
45
48
  if res.status == 404
@@ -8,6 +8,7 @@ module LaunchDarkly
8
8
  DELETE = :delete
9
9
  INDIRECT_PUT = :'indirect/put'
10
10
  INDIRECT_PATCH = :'indirect/patch'
11
+ READ_TIMEOUT_SECONDS = 300 # 5 minutes; the stream should send a ping every 3 minutes
11
12
 
12
13
  class StreamProcessor
13
14
  def initialize(sdk_key, config, requestor)
@@ -17,6 +18,7 @@ module LaunchDarkly
17
18
  @requestor = requestor
18
19
  @initialized = Concurrent::AtomicBoolean.new(false)
19
20
  @started = Concurrent::AtomicBoolean.new(false)
21
+ @stopped = Concurrent::AtomicBoolean.new(false)
20
22
  end
21
23
 
22
24
  def initialized?
@@ -33,13 +35,34 @@ module LaunchDarkly
33
35
  'Authorization' => @sdk_key,
34
36
  'User-Agent' => 'RubyClient/' + LaunchDarkly::VERSION
35
37
  }
36
- opts = {:headers => headers, :with_credentials => true, :proxy => @config.proxy}
38
+ opts = {:headers => headers, :with_credentials => true, :proxy => @config.proxy, :read_timeout => READ_TIMEOUT_SECONDS}
37
39
  @es = Celluloid::EventSource.new(@config.stream_uri + "/flags", opts) do |conn|
38
40
  conn.on(PUT) { |message| process_message(message, PUT) }
39
41
  conn.on(PATCH) { |message| process_message(message, PATCH) }
40
42
  conn.on(DELETE) { |message| process_message(message, DELETE) }
41
43
  conn.on(INDIRECT_PUT) { |message| process_message(message, INDIRECT_PUT) }
42
44
  conn.on(INDIRECT_PATCH) { |message| process_message(message, INDIRECT_PATCH) }
45
+ conn.on_error { |err|
46
+ @config.logger.error("[LDClient] Unexpected status code #{err[:status_code]} from streaming connection")
47
+ if err[:status_code] == 401
48
+ @config.logger.error("[LDClient] Received 401 error, no further streaming connection will be made since SDK key is invalid")
49
+ stop
50
+ end
51
+ }
52
+ end
53
+ end
54
+
55
+ def stop
56
+ if @stopped.make_true
57
+ @es.close
58
+ @config.logger.info("[LDClient] Stream connection stopped")
59
+ end
60
+ end
61
+
62
+ def stop
63
+ if @stopped.make_true
64
+ @es.close
65
+ @config.logger.info("[LDClient] Stream connection stopped")
43
66
  end
44
67
  end
45
68
 
@@ -1,3 +1,3 @@
1
1
  module LaunchDarkly
2
- VERSION = "2.2.7"
2
+ VERSION = "2.3.1"
3
3
  end
@@ -0,0 +1,86 @@
1
+ require "spec_helper"
2
+
3
+ describe LaunchDarkly::EventSerializer do
4
+ subject { LaunchDarkly::EventSerializer }
5
+
6
+ let(:base_config) { LaunchDarkly::Config.new }
7
+ let(:config_with_all_attrs_private) { LaunchDarkly::Config.new({ all_attributes_private: true })}
8
+ let(:config_with_some_attrs_private) { LaunchDarkly::Config.new({ private_attribute_names: ['firstName', 'bizzle'] })}
9
+
10
+ # users to serialize
11
+
12
+ let(:user) {
13
+ { key: 'abc', firstName: 'Sue', custom: { bizzle: 'def', dizzle: 'ghi' }}
14
+ }
15
+
16
+ let(:user_specifying_own_private_attr) {
17
+ u = user.clone
18
+ u[:privateAttributeNames] = [ 'dizzle', 'unused' ]
19
+ u
20
+ }
21
+
22
+ # expected results from serializing user
23
+
24
+ let(:user_with_all_attrs_hidden) {
25
+ { key: 'abc', custom: { }, privateAttrs: [ 'bizzle', 'dizzle', 'firstName' ]}
26
+ }
27
+
28
+ let(:user_with_some_attrs_hidden) {
29
+ { key: 'abc', custom: { dizzle: 'ghi' }, privateAttrs: [ 'bizzle', 'firstName' ]}
30
+ }
31
+
32
+ let(:user_with_own_specified_attr_hidden) {
33
+ { key: 'abc', firstName: 'Sue', custom: { bizzle: 'def' }, privateAttrs: [ 'dizzle' ]}
34
+ }
35
+
36
+
37
+ def make_event(user)
38
+ {
39
+ creationDate: 1000000,
40
+ key: 'xyz',
41
+ kind: 'thing',
42
+ user: user
43
+ }
44
+ end
45
+
46
+ def parse_results(js)
47
+ JSON.parse(js, symbolize_names: true)
48
+ end
49
+
50
+ describe "serialize_events" do
51
+ it "includes all user attributes by default" do
52
+ es = LaunchDarkly::EventSerializer.new(base_config)
53
+ event = make_event(user)
54
+ j = es.serialize_events([event])
55
+ expect(parse_results(j)).to eq [event]
56
+ end
57
+
58
+ it "hides all except key if all_attributes_private is true" do
59
+ es = LaunchDarkly::EventSerializer.new(config_with_all_attrs_private)
60
+ event = make_event(user)
61
+ j = es.serialize_events([event])
62
+ expect(parse_results(j)).to eq [make_event(user_with_all_attrs_hidden)]
63
+ end
64
+
65
+ it "hides some attributes if private_attribute_names is set" do
66
+ es = LaunchDarkly::EventSerializer.new(config_with_some_attrs_private)
67
+ event = make_event(user)
68
+ j = es.serialize_events([event])
69
+ expect(parse_results(j)).to eq [make_event(user_with_some_attrs_hidden)]
70
+ end
71
+
72
+ it "hides attributes specified in per-user privateAttrs" do
73
+ es = LaunchDarkly::EventSerializer.new(base_config)
74
+ event = make_event(user_specifying_own_private_attr)
75
+ j = es.serialize_events([event])
76
+ expect(parse_results(j)).to eq [make_event(user_with_own_specified_attr_hidden)]
77
+ end
78
+
79
+ it "looks at both per-user privateAttrs and global config" do
80
+ es = LaunchDarkly::EventSerializer.new(config_with_some_attrs_private)
81
+ event = make_event(user_specifying_own_private_attr)
82
+ j = es.serialize_events([event])
83
+ expect(parse_results(j)).to eq [make_event(user_with_all_attrs_hidden)]
84
+ end
85
+ end
86
+ end
@@ -0,0 +1,112 @@
1
+ require "spec_helper"
2
+
3
+ RSpec.shared_examples "feature_store" do |create_store_method|
4
+
5
+ let(:feature0) {
6
+ {
7
+ key: "test-feature-flag",
8
+ version: 11,
9
+ on: true,
10
+ prerequisites: [],
11
+ salt: "718ea30a918a4eba8734b57ab1a93227",
12
+ sel: "fe1244e5378c4f99976c9634e33667c6",
13
+ targets: [
14
+ {
15
+ values: [ "alice" ],
16
+ variation: 0
17
+ },
18
+ {
19
+ values: [ "bob" ],
20
+ variation: 1
21
+ }
22
+ ],
23
+ rules: [],
24
+ fallthrough: { variation: 0 },
25
+ offVariation: 1,
26
+ variations: [ true, false ],
27
+ deleted: false
28
+ }
29
+ }
30
+ let(:key0) { feature0[:key].to_sym }
31
+
32
+ let!(:store) do
33
+ s = create_store_method.call()
34
+ s.init({ key0 => feature0 })
35
+ s
36
+ end
37
+
38
+ def new_version_plus(f, deltaVersion, attrs = {})
39
+ f1 = f.clone
40
+ f1[:version] = f[:version] + deltaVersion
41
+ f1.update(attrs)
42
+ f1
43
+ end
44
+
45
+
46
+ it "is initialized" do
47
+ expect(store.initialized?).to eq true
48
+ end
49
+
50
+ it "can get existing feature with symbol key" do
51
+ expect(store.get(key0)).to eq feature0
52
+ end
53
+
54
+ it "can get existing feature with string key" do
55
+ expect(store.get(key0.to_s)).to eq feature0
56
+ end
57
+
58
+ it "gets nil for nonexisting feature" do
59
+ expect(store.get('nope')).to be_nil
60
+ end
61
+
62
+ it "can get all features" do
63
+ feature1 = feature0.clone
64
+ feature1[:key] = "test-feature-flag1"
65
+ feature1[:version] = 5
66
+ feature1[:on] = false
67
+ store.upsert(:"test-feature-flag1", feature1)
68
+ expect(store.all).to eq ({ key0 => feature0, :"test-feature-flag1" => feature1 })
69
+ end
70
+
71
+ it "can add new feature" do
72
+ feature1 = feature0.clone
73
+ feature1[:key] = "test-feature-flag1"
74
+ feature1[:version] = 5
75
+ feature1[:on] = false
76
+ store.upsert(:"test-feature-flag1", feature1)
77
+ expect(store.get(:"test-feature-flag1")).to eq feature1
78
+ end
79
+
80
+ it "can update feature with newer version" do
81
+ f1 = new_version_plus(feature0, 1, { on: !feature0[:on] })
82
+ store.upsert(key0, f1)
83
+ expect(store.get(key0)).to eq f1
84
+ end
85
+
86
+ it "cannot update feature with same version" do
87
+ f1 = new_version_plus(feature0, 0, { on: !feature0[:on] })
88
+ store.upsert(key0, f1)
89
+ expect(store.get(key0)).to eq feature0
90
+ end
91
+
92
+ it "cannot update feature with older version" do
93
+ f1 = new_version_plus(feature0, -1, { on: !feature0[:on] })
94
+ store.upsert(key0, f1)
95
+ expect(store.get(key0)).to eq feature0
96
+ end
97
+
98
+ it "can delete feature with newer version" do
99
+ store.delete(key0, feature0[:version] + 1)
100
+ expect(store.get(key0)).to be_nil
101
+ end
102
+
103
+ it "cannot delete feature with same version" do
104
+ store.delete(key0, feature0[:version])
105
+ expect(store.get(key0)).to eq feature0
106
+ end
107
+
108
+ it "cannot delete feature with older version" do
109
+ store.delete(key0, feature0[:version] - 1)
110
+ expect(store.get(key0)).to eq feature0
111
+ end
112
+ end
@@ -0,0 +1,36 @@
1
+ {
2
+ "key":"test-feature-flag1",
3
+ "version":5,
4
+ "on":false,
5
+ "prerequisites":[
6
+
7
+ ],
8
+ "salt":"718ea30a918a4eba8734b57ab1a93227",
9
+ "sel":"fe1244e5378c4f99976c9634e33667c6",
10
+ "targets":[
11
+ {
12
+ "values":[
13
+ "alice"
14
+ ],
15
+ "variation":0
16
+ },
17
+ {
18
+ "values":[
19
+ "bob"
20
+ ],
21
+ "variation":1
22
+ }
23
+ ],
24
+ "rules":[
25
+
26
+ ],
27
+ "fallthrough":{
28
+ "variation":0
29
+ },
30
+ "offVariation":1,
31
+ "variations":[
32
+ true,
33
+ false
34
+ ],
35
+ "deleted":false
36
+ }
@@ -0,0 +1,12 @@
1
+ require "feature_store_spec_base"
2
+ require "spec_helper"
3
+
4
+ def create_in_memory_store()
5
+ LaunchDarkly::InMemoryFeatureStore.new
6
+ end
7
+
8
+ describe LaunchDarkly::InMemoryFeatureStore do
9
+ subject { LaunchDarkly::InMemoryFeatureStore }
10
+
11
+ include_examples "feature_store", method(:create_in_memory_store)
12
+ end
@@ -70,4 +70,26 @@ describe LaunchDarkly::LDClient do
70
70
  end
71
71
  end
72
72
  end
73
+
74
+ describe 'with send_events: false' do
75
+ let(:config) { LaunchDarkly::Config.new({offline: true, send_events: false}) }
76
+ let(:client) { subject.new("secret", config) }
77
+
78
+ let(:queue) { client.instance_variable_get(:@event_processor).instance_variable_get(:@queue) }
79
+
80
+ it "does not enqueue a feature event" do
81
+ client.variation(feature[:key], user, "default")
82
+ expect(queue.empty?).to be true
83
+ end
84
+
85
+ it "does not enqueue a custom event" do
86
+ client.track("custom_event_name", user, 42)
87
+ expect(queue.empty?).to be true
88
+ end
89
+
90
+ it "does not enqueue an identify event" do
91
+ client.identify(user)
92
+ expect(queue.empty?).to be true
93
+ end
94
+ end
73
95
  end
@@ -0,0 +1,43 @@
1
+ require "feature_store_spec_base"
2
+ require "json"
3
+ require "spec_helper"
4
+
5
+
6
+
7
+ $my_prefix = 'testprefix'
8
+ $null_log = ::Logger.new($stdout)
9
+ $null_log.level = ::Logger::FATAL
10
+
11
+
12
+ def create_redis_store()
13
+ LaunchDarkly::RedisFeatureStore.new(prefix: $my_prefix, logger: $null_log, expiration: 60)
14
+ end
15
+
16
+ def create_redis_store_uncached()
17
+ LaunchDarkly::RedisFeatureStore.new(prefix: $my_prefix, logger: $null_log, expiration: 0)
18
+ end
19
+
20
+
21
+ describe LaunchDarkly::RedisFeatureStore do
22
+ subject { LaunchDarkly::RedisFeatureStore }
23
+
24
+ let(:feature0_with_higher_version) do
25
+ f = feature0.clone
26
+ f[:version] = feature0[:version] + 10
27
+ f
28
+ end
29
+
30
+ # These tests will all fail if there isn't a Redis instance running on the default port.
31
+
32
+ context "real Redis with local cache" do
33
+
34
+ include_examples "feature_store", method(:create_redis_store)
35
+
36
+ end
37
+
38
+ context "real Redis without local cache" do
39
+
40
+ include_examples "feature_store", method(:create_redis_store_uncached)
41
+
42
+ end
43
+ end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: ldclient-rb
3
3
  version: !ruby/object:Gem::Version
4
- version: 2.2.7
4
+ version: 2.3.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - LaunchDarkly
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2017-07-26 00:00:00.000000000 Z
11
+ date: 2017-11-16 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: bundler
@@ -66,6 +66,48 @@ dependencies:
66
66
  - - "~>"
67
67
  - !ruby/object:Gem::Version
68
68
  version: '0'
69
+ - !ruby/object:Gem::Dependency
70
+ name: redis
71
+ requirement: !ruby/object:Gem::Requirement
72
+ requirements:
73
+ - - "~>"
74
+ - !ruby/object:Gem::Version
75
+ version: 3.3.5
76
+ type: :development
77
+ prerelease: false
78
+ version_requirements: !ruby/object:Gem::Requirement
79
+ requirements:
80
+ - - "~>"
81
+ - !ruby/object:Gem::Version
82
+ version: 3.3.5
83
+ - !ruby/object:Gem::Dependency
84
+ name: connection_pool
85
+ requirement: !ruby/object:Gem::Requirement
86
+ requirements:
87
+ - - ">="
88
+ - !ruby/object:Gem::Version
89
+ version: 2.1.2
90
+ type: :development
91
+ prerelease: false
92
+ version_requirements: !ruby/object:Gem::Requirement
93
+ requirements:
94
+ - - ">="
95
+ - !ruby/object:Gem::Version
96
+ version: 2.1.2
97
+ - !ruby/object:Gem::Dependency
98
+ name: moneta
99
+ requirement: !ruby/object:Gem::Requirement
100
+ requirements:
101
+ - - "~>"
102
+ - !ruby/object:Gem::Version
103
+ version: 1.0.0
104
+ type: :development
105
+ prerelease: false
106
+ version_requirements: !ruby/object:Gem::Requirement
107
+ requirements:
108
+ - - "~>"
109
+ - !ruby/object:Gem::Version
110
+ version: 1.0.0
69
111
  - !ruby/object:Gem::Dependency
70
112
  name: json
71
113
  requirement: !ruby/object:Gem::Requirement
@@ -170,14 +212,14 @@ dependencies:
170
212
  requirements:
171
213
  - - "~>"
172
214
  - !ruby/object:Gem::Version
173
- version: 0.10.0
215
+ version: 0.11.0
174
216
  type: :runtime
175
217
  prerelease: false
176
218
  version_requirements: !ruby/object:Gem::Requirement
177
219
  requirements:
178
220
  - - "~>"
179
221
  - !ruby/object:Gem::Version
180
- version: 0.10.0
222
+ version: 0.11.0
181
223
  - !ruby/object:Gem::Dependency
182
224
  name: celluloid
183
225
  requirement: !ruby/object:Gem::Requirement
@@ -234,6 +276,7 @@ files:
234
276
  - ".rubocop.yml"
235
277
  - ".simplecov"
236
278
  - CHANGELOG.md
279
+ - CODEOWNERS
237
280
  - CONTRIBUTING.md
238
281
  - Gemfile
239
282
  - LICENSE.txt
@@ -246,22 +289,29 @@ files:
246
289
  - lib/ldclient-rb/cache_store.rb
247
290
  - lib/ldclient-rb/config.rb
248
291
  - lib/ldclient-rb/evaluation.rb
292
+ - lib/ldclient-rb/event_serializer.rb
249
293
  - lib/ldclient-rb/events.rb
250
294
  - lib/ldclient-rb/feature_store.rb
251
295
  - lib/ldclient-rb/ldclient.rb
252
296
  - lib/ldclient-rb/newrelic.rb
253
297
  - lib/ldclient-rb/polling.rb
298
+ - lib/ldclient-rb/redis_feature_store.rb
254
299
  - lib/ldclient-rb/requestor.rb
255
300
  - lib/ldclient-rb/stream.rb
256
301
  - lib/ldclient-rb/version.rb
257
302
  - scripts/release.sh
258
303
  - spec/config_spec.rb
304
+ - spec/event_serializer_spec.rb
305
+ - spec/feature_store_spec_base.rb
259
306
  - spec/fixtures/feature.json
307
+ - spec/fixtures/feature1.json
260
308
  - spec/fixtures/numeric_key_user.json
261
309
  - spec/fixtures/sanitized_numeric_key_user.json
262
310
  - spec/fixtures/user.json
311
+ - spec/in_memory_feature_store_spec.rb
263
312
  - spec/ldclient_spec.rb
264
313
  - spec/newrelic_spec.rb
314
+ - spec/redis_feature_store_spec.rb
265
315
  - spec/requestor_spec.rb
266
316
  - spec/spec_helper.rb
267
317
  - spec/store_spec.rb
@@ -293,12 +343,17 @@ specification_version: 4
293
343
  summary: LaunchDarkly SDK for Ruby
294
344
  test_files:
295
345
  - spec/config_spec.rb
346
+ - spec/event_serializer_spec.rb
347
+ - spec/feature_store_spec_base.rb
296
348
  - spec/fixtures/feature.json
349
+ - spec/fixtures/feature1.json
297
350
  - spec/fixtures/numeric_key_user.json
298
351
  - spec/fixtures/sanitized_numeric_key_user.json
299
352
  - spec/fixtures/user.json
353
+ - spec/in_memory_feature_store_spec.rb
300
354
  - spec/ldclient_spec.rb
301
355
  - spec/newrelic_spec.rb
356
+ - spec/redis_feature_store_spec.rb
302
357
  - spec/requestor_spec.rb
303
358
  - spec/spec_helper.rb
304
359
  - spec/store_spec.rb