ldclient-rb 0.8.0 → 2.0.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.
@@ -0,0 +1,75 @@
1
+ require "thread"
2
+ require "faraday"
3
+
4
+ module LaunchDarkly
5
+
6
+ class EventProcessor
7
+ def initialize(sdk_key, config)
8
+ @queue = Queue.new
9
+ @sdk_key = sdk_key
10
+ @config = config
11
+ @client = Faraday.new
12
+ @worker = create_worker
13
+ end
14
+
15
+ def create_worker
16
+ Thread.new do
17
+ loop do
18
+ begin
19
+ flush
20
+ sleep(@config.flush_interval)
21
+ rescue StandardError => exn
22
+ log_exception(__method__.to_s, exn)
23
+ end
24
+ end
25
+ end
26
+ end
27
+
28
+ def post_flushed_events(events)
29
+ res = @client.post (@config.events_uri + "/bulk") do |req|
30
+ req.headers["Authorization"] = @sdk_key
31
+ req.headers["User-Agent"] = "RubyClient/" + LaunchDarkly::VERSION
32
+ req.headers["Content-Type"] = "application/json"
33
+ req.body = events.to_json
34
+ req.options.timeout = @config.read_timeout
35
+ req.options.open_timeout = @config.connect_timeout
36
+ end
37
+ if res.status / 100 != 2
38
+ @config.logger.error("[LDClient] Unexpected status code while processing events: #{res.status}")
39
+ end
40
+ end
41
+
42
+ def flush
43
+ events = []
44
+ begin
45
+ loop do
46
+ events << @queue.pop(true)
47
+ end
48
+ rescue ThreadError
49
+ end
50
+
51
+ if !events.empty?
52
+ post_flushed_events(events)
53
+ end
54
+ end
55
+
56
+ def add_event(event)
57
+ return if @offline
58
+
59
+ if @queue.length < @config.capacity
60
+ event[:creationDate] = (Time.now.to_f * 1000).to_i
61
+ @config.logger.debug("[LDClient] Enqueueing event: #{event.to_json}")
62
+ @queue.push(event)
63
+
64
+ if !@worker.alive?
65
+ @worker = create_worker
66
+ end
67
+ else
68
+ @config.logger.warn("[LDClient] Exceeded event queue capacity. Increase capacity to avoid dropping events.")
69
+ end
70
+ end
71
+
72
+ private :create_worker, :post_flushed_events
73
+
74
+ end
75
+ end
@@ -0,0 +1,60 @@
1
+ require "concurrent/atomics"
2
+
3
+ module LaunchDarkly
4
+
5
+ class InMemoryFeatureStore
6
+ def initialize
7
+ @features = Hash.new
8
+ @lock = Concurrent::ReadWriteLock.new
9
+ @initialized = Concurrent::AtomicBoolean.new(false)
10
+ end
11
+
12
+ def get(key)
13
+ @lock.with_read_lock do
14
+ f = @features[key.to_sym]
15
+ (f.nil? || f[:deleted]) ? nil : f
16
+ end
17
+ end
18
+
19
+ def all
20
+ @lock.with_read_lock do
21
+ @features.select { |_k, f| not f[:deleted] }
22
+ end
23
+ end
24
+
25
+ def delete(key, version)
26
+ @lock.with_write_lock do
27
+ old = @features[key.to_sym]
28
+
29
+ if !old.nil? && old[:version] < version
30
+ old[:deleted] = true
31
+ old[:version] = version
32
+ @features[key.to_sym] = old
33
+ elsif old.nil?
34
+ @features[key.to_sym] = { deleted: true, version: version }
35
+ end
36
+ end
37
+ end
38
+
39
+ def init(fs)
40
+ @lock.with_write_lock do
41
+ @features.replace(fs)
42
+ @initialized.make_true
43
+ end
44
+ end
45
+
46
+ def upsert(key, feature)
47
+ @lock.with_write_lock do
48
+ old = @features[key.to_sym]
49
+
50
+ if old.nil? || old[:version] < feature[:version]
51
+ @features[key.to_sym] = feature
52
+ end
53
+ end
54
+ end
55
+
56
+ def initialized?
57
+ @initialized.value
58
+ end
59
+ end
60
+ end
@@ -1,100 +1,77 @@
1
- require "faraday/http_cache"
2
- require "json"
3
1
  require "digest/sha1"
4
- require "thread"
5
2
  require "logger"
6
- require "net/http/persistent"
7
3
  require "benchmark"
8
- require "hashdiff"
4
+ require "waitutil"
5
+ require "json"
6
+ require "openssl"
9
7
 
10
8
  module LaunchDarkly
11
- BUILTINS = [:key, :ip, :country, :email, :firstName, :lastName, :avatar, :name, :anonymous]
12
-
13
9
  #
14
- # A client for the LaunchDarkly API. Client instances are thread-safe. Users
10
+ # A client for LaunchDarkly. Client instances are thread-safe. Users
15
11
  # should create a single client instance for the lifetime of the application.
16
12
  #
17
13
  #
18
14
  class LDClient
19
- include Settings
15
+ include Evaluation
20
16
  #
21
17
  # Creates a new client instance that connects to LaunchDarkly. A custom
22
18
  # configuration parameter can also supplied to specify advanced options,
23
19
  # but for most use cases, the default configuration is appropriate.
24
20
  #
25
21
  #
26
- # @param api_key [String] the API key for your LaunchDarkly account
22
+ # @param sdk_key [String] the SDK key for your LaunchDarkly account
27
23
  # @param config [Config] an optional client configuration object
28
24
  #
29
25
  # @return [LDClient] The LaunchDarkly client instance
30
- def initialize(api_key, config = Config.default)
31
- @queue = Queue.new
32
- @api_key = api_key
26
+ def initialize(sdk_key, config = Config.default, wait_for_sec = 5)
27
+ @sdk_key = sdk_key
33
28
  @config = config
34
- @client = Faraday.new do |builder|
35
- builder.use :http_cache, store: @config.store
36
-
37
- builder.adapter :net_http_persistent
38
- end
39
- @offline = false
40
-
41
- if @config.stream?
42
- @stream_processor = StreamProcessor.new(api_key, config)
29
+ @store = config.feature_store
30
+ requestor = Requestor.new(sdk_key, config)
31
+
32
+ if !@config.offline?
33
+ if @config.stream?
34
+ @update_processor = StreamProcessor.new(sdk_key, config, requestor)
35
+ else
36
+ @update_processor = PollingProcessor.new(config, requestor)
37
+ end
38
+ @update_processor.start
43
39
  end
44
40
 
45
- @worker = create_worker
46
- end
41
+ @event_processor = EventProcessor.new(sdk_key, config)
47
42
 
48
- def flush
49
- events = []
50
- begin
51
- loop do
52
- events << @queue.pop(true)
43
+ if !@config.offline? && wait_for_sec > 0
44
+ begin
45
+ WaitUtil.wait_for_condition("LaunchDarkly client initialization", :timeout_sec => wait_for_sec, :delay_sec => 0.1) do
46
+ @update_processor.initialized?
47
+ end
48
+ rescue WaitUtil::TimeoutError
49
+ @config.logger.error("[LDClient] Timeout encountered waiting for LaunchDarkly client initialization")
53
50
  end
54
- rescue ThreadError
55
- end
56
-
57
- if !events.empty?
58
- post_flushed_events(events)
59
51
  end
60
52
  end
61
53
 
62
- def post_flushed_events(events)
63
- res = log_timings("Flush events") do
64
- next @client.post (@config.events_uri + "/bulk") do |req|
65
- req.headers["Authorization"] = "api_key " + @api_key
66
- req.headers["User-Agent"] = "RubyClient/" + LaunchDarkly::VERSION
67
- req.headers["Content-Type"] = "application/json"
68
- req.body = events.to_json
69
- req.options.timeout = @config.read_timeout
70
- req.options.open_timeout = @config.connect_timeout
71
- end
72
- end
73
- if res.status / 100 != 2
74
- @config.logger.error("[LDClient] Unexpected status code while processing events: #{res.status}")
75
- end
54
+ def flush
55
+ @event_processor.flush
76
56
  end
77
57
 
78
- def create_worker
79
- Thread.new do
80
- loop do
81
- begin
82
- flush
58
+ def toggle?(key, user, default = False)
59
+ @config.logger.warn("[LDClient] toggle? is deprecated. Use variation instead")
60
+ variation(key, user, default)
61
+ end
83
62
 
84
- sleep(@config.flush_interval)
85
- rescue StandardError => exn
86
- log_exception(__method__.to_s, exn)
87
- end
88
- end
89
- end
63
+ def secure_mode_hash(user)
64
+ OpenSSL::HMAC.hexdigest('sha256', @sdk_key, user[:key].to_s)
90
65
  end
91
66
 
92
- def get_flag?(key, user, default = false)
93
- toggle?(key, user, default)
67
+ # Returns whether the client has been initialized and is ready to serve feature flag requests
68
+ # @return [Boolean] true if the client has been initialized
69
+ def initialized?
70
+ @update_processor.initialized?
94
71
  end
95
72
 
96
73
  #
97
- # Calculates the value of a feature flag for a given user. At a minimum,
74
+ # Determines the variation of a feature flag to present to a user. At a minimum,
98
75
  # the user hash should contain a +:key+ .
99
76
  #
100
77
  # @example Basic user hash
@@ -110,8 +87,6 @@ module LaunchDarkly
110
87
  # @example More complete user hash
111
88
  # {key: "user@example.com", ip: "127.0.0.1", country: "US"}
112
89
  #
113
- # Countries should be sent as ISO 3166-1 alpha-2 codes.
114
- #
115
90
  # The user hash can contain arbitrary custom attributes stored in a +:custom+ sub-hash:
116
91
  #
117
92
  # @example A user hash with custom attributes
@@ -123,51 +98,53 @@ module LaunchDarkly
123
98
  # @param key [String] the unique feature key for the feature flag, as shown
124
99
  # on the LaunchDarkly dashboard
125
100
  # @param user [Hash] a hash containing parameters for the end user requesting the flag
126
- # @param default=false [Boolean] the default value of the flag
101
+ # @param default=false the default value of the flag
127
102
  #
128
- # @return [Boolean] whether or not the flag should be enabled, or the
129
- # default value if the flag is disabled on the LaunchDarkly control panel
130
- def toggle?(key, user, default = false)
131
- return default if @offline
103
+ # @return the variation to show the user, or the
104
+ # default value if there's an an error
105
+ def variation(key, user, default)
106
+ return default if @config.offline?
132
107
 
133
108
  unless user
134
109
  @config.logger.error("[LDClient] Must specify user")
110
+ @event_processor.add_event(kind: "feature", key: key, value: default, default: default, user: user)
135
111
  return default
136
112
  end
137
- sanitize_user(user)
138
-
139
- if @config.stream? && !@stream_processor.started?
140
- @stream_processor.start
141
- end
142
113
 
143
- if @config.stream? && @stream_processor.initialized?
144
- feature = get_streamed_flag(key)
145
- else
146
- feature = get_flag_int(key)
114
+ if !@update_processor.initialized?
115
+ @config.logger.error("[LDClient] Client has not finished initializing. Returning default value")
116
+ @event_processor.add_event(kind: "feature", key: key, value: default, default: default, user: user)
117
+ return default
147
118
  end
148
- value = evaluate(feature, user)
149
- value = value.nil? ? default : value
150
119
 
151
- add_event(kind: "feature", key: key, user: user, value: value, default: default)
152
- LDNewRelic.annotate_transaction(key, value)
153
- return value
154
- rescue StandardError => error
155
- log_exception(__method__.to_s, error)
156
- default
157
- end
158
-
159
- def add_event(event)
160
- return if @offline
120
+ sanitize_user(user)
121
+ feature = @store.get(key)
161
122
 
162
- if @queue.length < @config.capacity
163
- event[:creationDate] = (Time.now.to_f * 1000).to_i
164
- @queue.push(event)
123
+ if feature.nil?
124
+ @config.logger.error("[LDClient] Unknown feature flag #{key}. Returning default value")
125
+ @event_processor.add_event(kind: "feature", key: key, value: default, default: default, user: user)
126
+ return default
127
+ end
165
128
 
166
- if !@worker.alive?
167
- @worker = create_worker
129
+ begin
130
+ res = evaluate(feature, user, @store)
131
+ if !res[:events].nil?
132
+ res[:events].each do |event|
133
+ @event_processor.add_event(event)
134
+ end
168
135
  end
169
- else
170
- @config.logger.warn("[LDClient] Exceeded event queue capacity. Increase capacity to avoid dropping events.")
136
+ if !res[:value].nil?
137
+ @event_processor.add_event(kind: "feature", key: key, user: user, value: res[:value], default: default, version: feature[:version])
138
+ return res[:value]
139
+ else
140
+ @config.logger.debug("[LDClient] Result value is null in toggle")
141
+ @event_processor.add_event(kind: "feature", key: key, user: user, value: default, default: default, version: feature[:version])
142
+ return default
143
+ end
144
+ rescue => exn
145
+ @config.logger.warn("[LDClient] Error evaluating feature flag: #{exn.inspect}. \nTrace: #{exn.backtrace}")
146
+ @event_processor.add_event(kind: "feature", key: key, user: user, value: default, default: default, version: feature[:version])
147
+ return default
171
148
  end
172
149
  end
173
150
 
@@ -176,21 +153,10 @@ module LaunchDarkly
176
153
  #
177
154
  # @param [Hash] The user to register
178
155
  #
156
+ # @return [void]
179
157
  def identify(user)
180
158
  sanitize_user(user)
181
- add_event(kind: "identify", key: user[:key], user: user)
182
- end
183
-
184
- def set_offline
185
- @offline = true
186
- end
187
-
188
- def set_online
189
- @offline = false
190
- end
191
-
192
- def is_offline?
193
- @offline
159
+ @event_processor.add_event(kind: "identify", key: user[:key], user: user)
194
160
  end
195
161
 
196
162
  #
@@ -203,204 +169,30 @@ module LaunchDarkly
203
169
  # @return [void]
204
170
  def track(event_name, user, data)
205
171
  sanitize_user(user)
206
- add_event(kind: "custom", key: event_name, user: user, data: data)
207
- end
208
-
209
- #
210
- # Returns the key of every feature flag
211
- #
212
- def all_keys
213
- all_flags.keys
172
+ @event_processor.add_event(kind: "custom", key: event_name, user: user, data: data)
214
173
  end
215
174
 
216
175
  #
217
- # Returns all feature flags
176
+ # Returns all feature flag values for the given user
218
177
  #
219
- def all_flags
220
- return Hash.new if @offline
221
-
222
- if @config.stream? && !@stream_processor.started?
223
- @stream_processor.start
224
- end
225
-
226
- if @config.stream? && @stream_processor.initialized?
227
- @stream_processor.get_all_features
228
- else
229
- res = make_request "/api/eval/features"
230
-
231
- if res.status / 100 == 2
232
- JSON.parse(res.body, symbolize_names: true)
233
- else
234
- @config.logger.error("[LDClient] Unexpected status code #{res.status}")
235
- Hash.new
236
- end
237
- end
238
- end
239
-
240
- def get_user_settings(user)
241
- Hash[all_flags.map { |key, feature| [key, evaluate(feature, user)]}]
242
- end
243
-
244
- def get_streamed_flag(key)
245
- feature = get_flag_stream(key)
246
- if @config.debug_stream?
247
- polled = get_flag_int(key)
248
- diff = HashDiff.diff(feature, polled)
249
- if not diff.empty?
250
- @config.logger.error("Streamed flag differs from polled flag " + diff.to_s)
251
- end
252
- end
253
- feature
254
- end
255
-
256
- def get_flag_stream(key)
257
- @stream_processor.get_feature(key)
258
- end
259
-
260
- def get_flag_int(key)
261
- res = log_timings("Feature request") do
262
- next make_request "/api/eval/features/" + key
263
- end
264
-
265
- if res.status == 401
266
- @config.logger.error("[LDClient] Invalid API key")
267
- return nil
268
- end
269
-
270
- if res.status == 404
271
- @config.logger.error("[LDClient] Unknown feature key: #{key}")
272
- return nil
273
- end
274
-
275
- if res.status / 100 != 2
276
- @config.logger.error("[LDClient] Unexpected status code #{res.status}")
277
- return nil
278
- end
279
-
280
- JSON.parse(res.body, symbolize_names: true)
281
- end
282
-
283
- def make_request(path)
284
- @client.get (@config.base_uri + path) do |req|
285
- req.headers["Authorization"] = "api_key " + @api_key
286
- req.headers["User-Agent"] = "RubyClient/" + LaunchDarkly::VERSION
287
- req.options.timeout = @config.read_timeout
288
- req.options.open_timeout = @config.connect_timeout
289
- end
290
- end
291
-
292
- def param_for_user(feature, user)
293
- return nil unless user[:key]
294
-
295
- id_hash = user[:key]
296
- if user[:secondary]
297
- id_hash += "." + user[:secondary]
298
- end
299
-
300
- hash_key = "%s.%s.%s" % [feature[:key], feature[:salt], id_hash]
301
-
302
- hash_val = (Digest::SHA1.hexdigest(hash_key))[0..14]
303
- hash_val.to_i(16) / Float(0xFFFFFFFFFFFFFFF)
304
- end
305
-
306
- def match_target?(target, user)
307
- attrib = target[:attribute].to_sym
308
-
309
- if BUILTINS.include?(attrib)
310
- return false unless user[attrib]
311
-
312
- u_value = user[attrib]
313
- return target[:values].include? u_value
314
- else # custom attribute
315
- return false unless user[:custom]
316
- return false unless user[:custom].include? attrib
317
-
318
- u_value = user[:custom][attrib]
319
- if u_value.is_a? Array
320
- return ! ((target[:values] & u_value).empty?)
321
- else
322
- return target[:values].include? u_value
323
- end
324
-
325
- return false
326
- end
327
- end
328
-
329
- def match_user?(variation, user)
330
- if variation[:userTarget]
331
- return match_target?(variation[:userTarget], user)
332
- end
333
- false
334
- end
335
-
336
- def find_user_match(feature, user)
337
- feature[:variations].each do |variation|
338
- return variation[:value] if match_user?(variation, user)
339
- end
340
- nil
341
- end
342
-
343
- def match_variation?(variation, user)
344
- variation[:targets].each do |target|
345
- if !!variation[:userTarget] && target[:attribute].to_sym == :key
346
- next
347
- end
348
-
349
- if match_target?(target, user)
350
- return true
351
- end
352
- end
353
- false
354
- end
355
-
356
- def find_target_match(feature, user)
357
- feature[:variations].each do |variation|
358
- return variation[:value] if match_variation?(variation, user)
359
- end
360
- nil
361
- end
362
-
363
- def find_weight_match(feature, param)
364
- total = 0.0
365
- feature[:variations].each do |variation|
366
- total += variation[:weight].to_f / 100.0
178
+ def all_flags(user)
179
+ sanitize_user(user)
180
+ return Hash.new if @config.offline?
367
181
 
368
- return variation[:value] if param < total
182
+ unless user
183
+ @config.logger.error("[LDClient] Must specify user in all_flags")
184
+ return Hash.new
369
185
  end
370
186
 
371
- nil
372
- end
373
-
374
- def evaluate(feature, user)
375
- return nil if feature.nil?
376
- return nil unless feature[:on]
377
-
378
- param = param_for_user(feature, user)
379
- return nil if param.nil?
380
-
381
- value = find_user_match(feature, user)
382
- return value if !value.nil?
383
-
384
- value = find_target_match(feature, user)
385
- return value if !value.nil?
386
-
387
- find_weight_match(feature, param)
388
- end
187
+ begin
188
+ features = @store.all
389
189
 
390
- def log_timings(label, &block)
391
- return block.call unless @config.log_timings? && @config.logger.debug?
392
- res = nil
393
- exn = nil
394
- bench = Benchmark.measure do
395
- begin
396
- res = block.call
397
- rescue StandardError => e
398
- exn = e
399
- end
190
+ # TODO rescue if necessary
191
+ Hash[features.map{|k,f| [k, evaluate(f, user, @store)[:value]] }]
192
+ rescue => exn
193
+ @config.logger.warn("[LDClient] Error evaluating all flags: #{exn.inspect}. \nTrace: #{exn.backtrace}")
194
+ return Hash.new
400
195
  end
401
- @config.logger.debug { "[LDClient] #{label} timing: #{bench}".chomp }
402
- raise exn if exn
403
- res
404
196
  end
405
197
 
406
198
  def log_exception(caller, exn)
@@ -415,9 +207,6 @@ module LaunchDarkly
415
207
  end
416
208
  end
417
209
 
418
- private :post_flushed_events, :add_event, :get_streamed_flag,
419
- :get_flag_stream, :get_flag_int, :make_request, :param_for_user,
420
- :match_target?, :match_user?, :match_variation?, :evaluate,
421
- :create_worker, :log_timings, :log_exception, :sanitize_user
210
+ private :evaluate, :log_exception, :sanitize_user
422
211
  end
423
212
  end