ldclient-rb 0.8.0 → 2.0.1

Sign up to get free protection for your applications and to get access to all the features.
@@ -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