Skip to content

Commit 0fa6cf5

Browse files
committed
Merge branch 'master' into HEAD
2 parents c91698c + 55e802a commit 0fa6cf5

19 files changed

+744
-15
lines changed

CODEOWNERS

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
* @ashanbrown

circle.yml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
machine:
22
environment:
33
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
46

57
dependencies:
68
cache_directories:

ldclient-rb.gemspec

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,15 +25,18 @@ Gem::Specification.new do |spec|
2525
spec.add_development_dependency "rake", "~> 10.0"
2626
spec.add_development_dependency "rspec", "~> 3.2"
2727
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+
2932
spec.add_runtime_dependency "json", "~> 1.8"
3033
spec.add_runtime_dependency "faraday", "~> 0.9"
3134
spec.add_runtime_dependency "faraday-http-cache", "~> 1.3.0"
3235
spec.add_runtime_dependency "thread_safe", "~> 0.3"
3336
spec.add_runtime_dependency "net-http-persistent", "~> 2.9"
3437
spec.add_runtime_dependency "concurrent-ruby", "~> 1.0.4"
3538
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"
3740
spec.add_runtime_dependency "celluloid", "~> 0.18.0.pre" # transitive dep; specified here for more control
3841

3942
if RUBY_VERSION >= "2.2.2"

lib/ldclient-rb.rb

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@
66
require "ldclient-rb/newrelic"
77
require "ldclient-rb/stream"
88
require "ldclient-rb/polling"
9+
require "ldclient-rb/event_serializer"
910
require "ldclient-rb/events"
1011
require "ldclient-rb/feature_store"
12+
require "ldclient-rb/redis_feature_store"
1113
require "ldclient-rb/requestor"

lib/ldclient-rb/config.rb

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,12 +34,23 @@ class Config
3434
# @option opts [Object] :cache_store A cache store for the Faraday HTTP caching
3535
# library. Defaults to the Rails cache in a Rails environment, or a
3636
# 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.
3741
# @option opts [Boolean] :offline (false) Whether the client should be initialized in
3842
# offline mode. In offline mode, default values are returned for all flags and no
3943
# remote network requests are made.
4044
# @option opts [Float] :poll_interval (1) The number of seconds between polls for flag updates
4145
# if streaming is off.
4246
# @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.
4354
#
4455
# @return [type] [description]
4556
# rubocop:disable Metrics/AbcSize, Metrics/PerceivedComplexity
@@ -55,9 +66,13 @@ def initialize(opts = {})
5566
@read_timeout = opts[:read_timeout] || Config.default_read_timeout
5667
@feature_store = opts[:feature_store] || Config.default_feature_store
5768
@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
5870
@offline = opts.has_key?(:offline) ? opts[:offline] : Config.default_offline
5971
@poll_interval = opts.has_key?(:poll_interval) && opts[:poll_interval] > 1 ? opts[:poll_interval] : Config.default_poll_interval
6072
@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
6176
end
6277

6378
#
@@ -87,6 +102,16 @@ def stream?
87102
@stream
88103
end
89104

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+
90115
# TODO docs
91116
def offline?
92117
@offline
@@ -150,6 +175,15 @@ def offline?
150175
#
151176
attr_reader :proxy
152177

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+
153187
#
154188
# The default LaunchDarkly client configuration. This configuration sets
155189
# reasonable defaults for most users.
@@ -209,6 +243,10 @@ def self.default_stream
209243
true
210244
end
211245

246+
def self.default_use_ldd
247+
false
248+
end
249+
212250
def self.default_feature_store
213251
InMemoryFeatureStore.new
214252
end
@@ -220,5 +258,9 @@ def self.default_offline
220258
def self.default_poll_interval
221259
1
222260
end
261+
262+
def self.default_send_events
263+
true
264+
end
223265
end
224266
end
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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

lib/ldclient-rb/events.rb

Lines changed: 26 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
require "concurrent/atomics"
12
require "thread"
23
require "faraday"
34

@@ -7,13 +8,28 @@ def initialize(sdk_key, config)
78
@queue = Queue.new
89
@sdk_key = sdk_key
910
@config = config
11+
@serializer = EventSerializer.new(config)
1012
@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
1228
end
1329

1430
def create_worker
1531
Thread.new do
16-
loop do
32+
while !@stopped.value do
1733
begin
1834
flush
1935
sleep(@config.flush_interval)
@@ -29,16 +45,21 @@ def post_flushed_events(events)
2945
req.headers["Authorization"] = @sdk_key
3046
req.headers["User-Agent"] = "RubyClient/" + LaunchDarkly::VERSION
3147
req.headers["Content-Type"] = "application/json"
32-
req.body = events.to_json
48+
req.body = @serializer.serialize_events(events)
3349
req.options.timeout = @config.read_timeout
3450
req.options.open_timeout = @config.connect_timeout
3551
end
3652
if res.status < 200 || res.status >= 300
3753
@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
3858
end
3959
end
4060

4161
def flush
62+
return if @offline || !@config.send_events
4263
events = []
4364
begin
4465
loop do
@@ -47,13 +68,13 @@ def flush
4768
rescue ThreadError
4869
end
4970

50-
if !events.empty?
71+
if !events.empty? && !@stopped.value
5172
post_flushed_events(events)
5273
end
5374
end
5475

5576
def add_event(event)
56-
return if @offline
77+
return if @offline || !@config.send_events || @stopped.value
5778

5879
if @queue.length < @config.capacity
5980
event[:creationDate] = (Time.now.to_f * 1000).to_i

lib/ldclient-rb/feature_store.rb

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,5 +55,9 @@ def upsert(key, feature)
5555
def initialized?
5656
@initialized.value
5757
end
58+
59+
def stop
60+
# nothing to do
61+
end
5862
end
5963
end

lib/ldclient-rb/ldclient.rb

Lines changed: 23 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,14 @@ def initialize(sdk_key, config = Config.default, wait_for_sec = 5)
2727
@sdk_key = sdk_key
2828
@config = config
2929
@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+
3038
requestor = Requestor.new(sdk_key, config)
3139

3240
if !@config.offline?
@@ -38,8 +46,6 @@ def initialize(sdk_key, config = Config.default, wait_for_sec = 5)
3846
@update_processor.start
3947
end
4048

41-
@event_processor = EventProcessor.new(sdk_key, config)
42-
4349
if !@config.offline? && wait_for_sec > 0
4450
begin
4551
WaitUtil.wait_for_condition("LaunchDarkly client initialization", timeout_sec: wait_for_sec, delay_sec: 0.1) do
@@ -67,7 +73,7 @@ def secure_mode_hash(user)
6773
# Returns whether the client has been initialized and is ready to serve feature flag requests
6874
# @return [Boolean] true if the client has been initialized
6975
def initialized?
70-
@update_processor.initialized?
76+
@config.offline? || @config.use_ldd? || @update_processor.initialized?
7177
end
7278

7379
#
@@ -111,7 +117,7 @@ def variation(key, user, default)
111117
return default
112118
end
113119

114-
if !@update_processor.initialized?
120+
if !@update_processor.nil? && !@update_processor.initialized?
115121
@config.logger.error("[LDClient] Client has not finished initializing. Returning default value")
116122
@event_processor.add_event(kind: "feature", key: key, value: default, default: default, user: user)
117123
return default
@@ -195,6 +201,19 @@ def all_flags(user)
195201
end
196202
end
197203

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+
198217
def log_exception(caller, exn)
199218
error_traceback = "#{exn.inspect} #{exn}\n\t#{exn.backtrace.join("\n\t")}"
200219
error = "[LDClient] Unexpected exception in #{caller}: #{error_traceback}"

lib/ldclient-rb/polling.rb

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ def initialize(config, requestor)
88
@requestor = requestor
99
@initialized = Concurrent::AtomicBoolean.new(false)
1010
@started = Concurrent::AtomicBoolean.new(false)
11+
@stopped = Concurrent::AtomicBoolean.new(false)
1112
end
1213

1314
def initialized?
@@ -20,6 +21,15 @@ def start
2021
create_worker
2122
end
2223

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+
2333
def poll
2434
flags = @requestor.request_all_flags
2535
if flags
@@ -31,16 +41,19 @@ def poll
3141
end
3242

3343
def create_worker
34-
Thread.new do
44+
@worker = Thread.new do
3545
@config.logger.debug("[LDClient] Starting polling worker")
36-
loop do
46+
while !@stopped.value do
3747
begin
3848
started_at = Time.now
3949
poll
4050
delta = @config.poll_interval - (Time.now - started_at)
4151
if delta > 0
4252
sleep(delta)
4353
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
4457
rescue StandardError => exn
4558
@config.logger.error("[LDClient] Exception while polling: #{exn.inspect}")
4659
# TODO: log_exception(__method__.to_s, exn)

0 commit comments

Comments
 (0)