ldclient-rb 0.4.0 → 0.5.0

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