ldclient-rb 0.4.0 → 0.5.0

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