ldclient-rb 2.2.7 → 2.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  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