launchdarkly-server-sdk 5.5.7

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.
Files changed (87) hide show
  1. checksums.yaml +7 -0
  2. data/.circleci/config.yml +134 -0
  3. data/.github/ISSUE_TEMPLATE/bug_report.md +37 -0
  4. data/.github/ISSUE_TEMPLATE/feature_request.md +20 -0
  5. data/.gitignore +15 -0
  6. data/.hound.yml +2 -0
  7. data/.rspec +2 -0
  8. data/.rubocop.yml +600 -0
  9. data/.simplecov +4 -0
  10. data/.yardopts +9 -0
  11. data/CHANGELOG.md +261 -0
  12. data/CODEOWNERS +1 -0
  13. data/CONTRIBUTING.md +37 -0
  14. data/Gemfile +3 -0
  15. data/Gemfile.lock +102 -0
  16. data/LICENSE.txt +13 -0
  17. data/README.md +56 -0
  18. data/Rakefile +5 -0
  19. data/azure-pipelines.yml +51 -0
  20. data/ext/mkrf_conf.rb +11 -0
  21. data/launchdarkly-server-sdk.gemspec +40 -0
  22. data/lib/ldclient-rb.rb +29 -0
  23. data/lib/ldclient-rb/cache_store.rb +45 -0
  24. data/lib/ldclient-rb/config.rb +411 -0
  25. data/lib/ldclient-rb/evaluation.rb +455 -0
  26. data/lib/ldclient-rb/event_summarizer.rb +55 -0
  27. data/lib/ldclient-rb/events.rb +468 -0
  28. data/lib/ldclient-rb/expiring_cache.rb +77 -0
  29. data/lib/ldclient-rb/file_data_source.rb +312 -0
  30. data/lib/ldclient-rb/flags_state.rb +76 -0
  31. data/lib/ldclient-rb/impl.rb +13 -0
  32. data/lib/ldclient-rb/impl/integrations/consul_impl.rb +158 -0
  33. data/lib/ldclient-rb/impl/integrations/dynamodb_impl.rb +228 -0
  34. data/lib/ldclient-rb/impl/integrations/redis_impl.rb +155 -0
  35. data/lib/ldclient-rb/impl/store_client_wrapper.rb +47 -0
  36. data/lib/ldclient-rb/impl/store_data_set_sorter.rb +55 -0
  37. data/lib/ldclient-rb/in_memory_store.rb +100 -0
  38. data/lib/ldclient-rb/integrations.rb +55 -0
  39. data/lib/ldclient-rb/integrations/consul.rb +38 -0
  40. data/lib/ldclient-rb/integrations/dynamodb.rb +47 -0
  41. data/lib/ldclient-rb/integrations/redis.rb +55 -0
  42. data/lib/ldclient-rb/integrations/util/store_wrapper.rb +230 -0
  43. data/lib/ldclient-rb/interfaces.rb +153 -0
  44. data/lib/ldclient-rb/ldclient.rb +424 -0
  45. data/lib/ldclient-rb/memoized_value.rb +32 -0
  46. data/lib/ldclient-rb/newrelic.rb +17 -0
  47. data/lib/ldclient-rb/non_blocking_thread_pool.rb +46 -0
  48. data/lib/ldclient-rb/polling.rb +78 -0
  49. data/lib/ldclient-rb/redis_store.rb +87 -0
  50. data/lib/ldclient-rb/requestor.rb +101 -0
  51. data/lib/ldclient-rb/simple_lru_cache.rb +25 -0
  52. data/lib/ldclient-rb/stream.rb +141 -0
  53. data/lib/ldclient-rb/user_filter.rb +51 -0
  54. data/lib/ldclient-rb/util.rb +50 -0
  55. data/lib/ldclient-rb/version.rb +3 -0
  56. data/scripts/gendocs.sh +11 -0
  57. data/scripts/release.sh +27 -0
  58. data/spec/config_spec.rb +63 -0
  59. data/spec/evaluation_spec.rb +739 -0
  60. data/spec/event_summarizer_spec.rb +63 -0
  61. data/spec/events_spec.rb +642 -0
  62. data/spec/expiring_cache_spec.rb +76 -0
  63. data/spec/feature_store_spec_base.rb +213 -0
  64. data/spec/file_data_source_spec.rb +255 -0
  65. data/spec/fixtures/feature.json +37 -0
  66. data/spec/fixtures/feature1.json +36 -0
  67. data/spec/fixtures/user.json +9 -0
  68. data/spec/flags_state_spec.rb +81 -0
  69. data/spec/http_util.rb +109 -0
  70. data/spec/in_memory_feature_store_spec.rb +12 -0
  71. data/spec/integrations/consul_feature_store_spec.rb +42 -0
  72. data/spec/integrations/dynamodb_feature_store_spec.rb +105 -0
  73. data/spec/integrations/store_wrapper_spec.rb +276 -0
  74. data/spec/ldclient_spec.rb +471 -0
  75. data/spec/newrelic_spec.rb +5 -0
  76. data/spec/polling_spec.rb +120 -0
  77. data/spec/redis_feature_store_spec.rb +95 -0
  78. data/spec/requestor_spec.rb +214 -0
  79. data/spec/segment_store_spec_base.rb +95 -0
  80. data/spec/simple_lru_cache_spec.rb +24 -0
  81. data/spec/spec_helper.rb +9 -0
  82. data/spec/store_spec.rb +10 -0
  83. data/spec/stream_spec.rb +60 -0
  84. data/spec/user_filter_spec.rb +91 -0
  85. data/spec/util_spec.rb +17 -0
  86. data/spec/version_spec.rb +7 -0
  87. metadata +375 -0
@@ -0,0 +1,455 @@
1
+ require "date"
2
+ require "semantic"
3
+
4
+ module LaunchDarkly
5
+ # An object returned by {LDClient#variation_detail}, combining the result of a flag evaluation with
6
+ # an explanation of how it was calculated.
7
+ class EvaluationDetail
8
+ def initialize(value, variation_index, reason)
9
+ @value = value
10
+ @variation_index = variation_index
11
+ @reason = reason
12
+ end
13
+
14
+ #
15
+ # The result of the flag evaluation. This will be either one of the flag's variations, or the
16
+ # default value that was passed to {LDClient#variation_detail}. It is the same as the return
17
+ # value of {LDClient#variation}.
18
+ #
19
+ # @return [Object]
20
+ #
21
+ attr_reader :value
22
+
23
+ #
24
+ # The index of the returned value within the flag's list of variations. The first variation is
25
+ # 0, the second is 1, etc. This is `nil` if the default value was returned.
26
+ #
27
+ # @return [int|nil]
28
+ #
29
+ attr_reader :variation_index
30
+
31
+ #
32
+ # An object describing the main factor that influenced the flag evaluation value.
33
+ #
34
+ # This object is currently represented as a Hash, which may have the following keys:
35
+ #
36
+ # `:kind`: The general category of reason. Possible values:
37
+ #
38
+ # * `'OFF'`: the flag was off and therefore returned its configured off value
39
+ # * `'FALLTHROUGH'`: the flag was on but the user did not match any targets or rules
40
+ # * `'TARGET_MATCH'`: the user key was specifically targeted for this flag
41
+ # * `'RULE_MATCH'`: the user matched one of the flag's rules
42
+ # * `'PREREQUISITE_FAILED`': the flag was considered off because it had at least one
43
+ # prerequisite flag that either was off or did not return the desired variation
44
+ # * `'ERROR'`: the flag could not be evaluated, so the default value was returned
45
+ #
46
+ # `:ruleIndex`: If the kind was `RULE_MATCH`, this is the positional index of the
47
+ # matched rule (0 for the first rule).
48
+ #
49
+ # `:ruleId`: If the kind was `RULE_MATCH`, this is the rule's unique identifier.
50
+ #
51
+ # `:prerequisiteKey`: If the kind was `PREREQUISITE_FAILED`, this is the flag key of
52
+ # the prerequisite flag that failed.
53
+ #
54
+ # `:errorKind`: If the kind was `ERROR`, this indicates the type of error:
55
+ #
56
+ # * `'CLIENT_NOT_READY'`: the caller tried to evaluate a flag before the client had
57
+ # successfully initialized
58
+ # * `'FLAG_NOT_FOUND'`: the caller provided a flag key that did not match any known flag
59
+ # * `'MALFORMED_FLAG'`: there was an internal inconsistency in the flag data, e.g. a
60
+ # rule specified a nonexistent variation
61
+ # * `'USER_NOT_SPECIFIED'`: the user object or user key was not provied
62
+ # * `'EXCEPTION'`: an unexpected exception stopped flag evaluation
63
+ #
64
+ # @return [Hash]
65
+ #
66
+ attr_reader :reason
67
+
68
+ #
69
+ # Tests whether the flag evaluation returned a default value. This is the same as checking
70
+ # whether {#variation_index} is nil.
71
+ #
72
+ # @return [Boolean]
73
+ #
74
+ def default_value?
75
+ variation_index.nil?
76
+ end
77
+
78
+ def ==(other)
79
+ @value == other.value && @variation_index == other.variation_index && @reason == other.reason
80
+ end
81
+ end
82
+
83
+ # @private
84
+ module Evaluation
85
+ BUILTINS = [:key, :ip, :country, :email, :firstName, :lastName, :avatar, :name, :anonymous]
86
+
87
+ NUMERIC_VERSION_COMPONENTS_REGEX = Regexp.new("^[0-9.]*")
88
+
89
+ DATE_OPERAND = lambda do |v|
90
+ if v.is_a? String
91
+ begin
92
+ DateTime.rfc3339(v).strftime("%Q").to_i
93
+ rescue => e
94
+ nil
95
+ end
96
+ elsif v.is_a? Numeric
97
+ v
98
+ else
99
+ nil
100
+ end
101
+ end
102
+
103
+ SEMVER_OPERAND = lambda do |v|
104
+ semver = nil
105
+ if v.is_a? String
106
+ for _ in 0..2 do
107
+ begin
108
+ semver = Semantic::Version.new(v)
109
+ break # Some versions of jruby cannot properly handle a return here and return from the method that calls this lambda
110
+ rescue ArgumentError
111
+ v = addZeroVersionComponent(v)
112
+ end
113
+ end
114
+ end
115
+ semver
116
+ end
117
+
118
+ def self.addZeroVersionComponent(v)
119
+ NUMERIC_VERSION_COMPONENTS_REGEX.match(v) { |m|
120
+ m[0] + ".0" + v[m[0].length..-1]
121
+ }
122
+ end
123
+
124
+ def self.comparator(converter)
125
+ lambda do |a, b|
126
+ av = converter.call(a)
127
+ bv = converter.call(b)
128
+ if !av.nil? && !bv.nil?
129
+ yield av <=> bv
130
+ else
131
+ return false
132
+ end
133
+ end
134
+ end
135
+
136
+ OPERATORS = {
137
+ in:
138
+ lambda do |a, b|
139
+ a == b
140
+ end,
141
+ endsWith:
142
+ lambda do |a, b|
143
+ (a.is_a? String) && (a.end_with? b)
144
+ end,
145
+ startsWith:
146
+ lambda do |a, b|
147
+ (a.is_a? String) && (a.start_with? b)
148
+ end,
149
+ matches:
150
+ lambda do |a, b|
151
+ (b.is_a? String) && !(Regexp.new b).match(a).nil?
152
+ end,
153
+ contains:
154
+ lambda do |a, b|
155
+ (a.is_a? String) && (a.include? b)
156
+ end,
157
+ lessThan:
158
+ lambda do |a, b|
159
+ (a.is_a? Numeric) && (a < b)
160
+ end,
161
+ lessThanOrEqual:
162
+ lambda do |a, b|
163
+ (a.is_a? Numeric) && (a <= b)
164
+ end,
165
+ greaterThan:
166
+ lambda do |a, b|
167
+ (a.is_a? Numeric) && (a > b)
168
+ end,
169
+ greaterThanOrEqual:
170
+ lambda do |a, b|
171
+ (a.is_a? Numeric) && (a >= b)
172
+ end,
173
+ before:
174
+ comparator(DATE_OPERAND) { |n| n < 0 },
175
+ after:
176
+ comparator(DATE_OPERAND) { |n| n > 0 },
177
+ semVerEqual:
178
+ comparator(SEMVER_OPERAND) { |n| n == 0 },
179
+ semVerLessThan:
180
+ comparator(SEMVER_OPERAND) { |n| n < 0 },
181
+ semVerGreaterThan:
182
+ comparator(SEMVER_OPERAND) { |n| n > 0 },
183
+ segmentMatch:
184
+ lambda do |a, b|
185
+ false # we should never reach this - instead we special-case this operator in clause_match_user
186
+ end
187
+ }
188
+
189
+ # Used internally to hold an evaluation result and the events that were generated from prerequisites.
190
+ EvalResult = Struct.new(:detail, :events)
191
+
192
+ USER_ATTRS_TO_STRINGIFY_FOR_EVALUATION = [ :key, :secondary ]
193
+ # Currently we are not stringifying the rest of the built-in attributes prior to evaluation, only for events.
194
+ # This is because it could affect evaluation results for existing users (ch35206).
195
+
196
+ def error_result(errorKind, value = nil)
197
+ EvaluationDetail.new(value, nil, { kind: 'ERROR', errorKind: errorKind })
198
+ end
199
+
200
+ # Evaluates a feature flag and returns an EvalResult. The result.value will be nil if the flag returns
201
+ # the default value. Error conditions produce a result with an error reason, not an exception.
202
+ def evaluate(flag, user, store, logger)
203
+ if user.nil? || user[:key].nil?
204
+ return EvalResult.new(error_result('USER_NOT_SPECIFIED'), [])
205
+ end
206
+
207
+ sanitized_user = Util.stringify_attrs(user, USER_ATTRS_TO_STRINGIFY_FOR_EVALUATION)
208
+
209
+ events = []
210
+ detail = eval_internal(flag, sanitized_user, store, events, logger)
211
+ return EvalResult.new(detail, events)
212
+ end
213
+
214
+ def eval_internal(flag, user, store, events, logger)
215
+ if !flag[:on]
216
+ return get_off_value(flag, { kind: 'OFF' }, logger)
217
+ end
218
+
219
+ prereq_failure_reason = check_prerequisites(flag, user, store, events, logger)
220
+ if !prereq_failure_reason.nil?
221
+ return get_off_value(flag, prereq_failure_reason, logger)
222
+ end
223
+
224
+ # Check user target matches
225
+ (flag[:targets] || []).each do |target|
226
+ (target[:values] || []).each do |value|
227
+ if value == user[:key]
228
+ return get_variation(flag, target[:variation], { kind: 'TARGET_MATCH' }, logger)
229
+ end
230
+ end
231
+ end
232
+
233
+ # Check custom rules
234
+ rules = flag[:rules] || []
235
+ rules.each_index do |i|
236
+ rule = rules[i]
237
+ if rule_match_user(rule, user, store)
238
+ return get_value_for_variation_or_rollout(flag, rule, user,
239
+ { kind: 'RULE_MATCH', ruleIndex: i, ruleId: rule[:id] }, logger)
240
+ end
241
+ end
242
+
243
+ # Check the fallthrough rule
244
+ if !flag[:fallthrough].nil?
245
+ return get_value_for_variation_or_rollout(flag, flag[:fallthrough], user,
246
+ { kind: 'FALLTHROUGH' }, logger)
247
+ end
248
+
249
+ return EvaluationDetail.new(nil, nil, { kind: 'FALLTHROUGH' })
250
+ end
251
+
252
+ def check_prerequisites(flag, user, store, events, logger)
253
+ (flag[:prerequisites] || []).each do |prerequisite|
254
+ prereq_ok = true
255
+ prereq_key = prerequisite[:key]
256
+ prereq_flag = store.get(FEATURES, prereq_key)
257
+
258
+ if prereq_flag.nil?
259
+ logger.error { "[LDClient] Could not retrieve prerequisite flag \"#{prereq_key}\" when evaluating \"#{flag[:key]}\"" }
260
+ prereq_ok = false
261
+ else
262
+ begin
263
+ prereq_res = eval_internal(prereq_flag, user, store, events, logger)
264
+ # Note that if the prerequisite flag is off, we don't consider it a match no matter what its
265
+ # off variation was. But we still need to evaluate it in order to generate an event.
266
+ if !prereq_flag[:on] || prereq_res.variation_index != prerequisite[:variation]
267
+ prereq_ok = false
268
+ end
269
+ event = {
270
+ kind: "feature",
271
+ key: prereq_key,
272
+ user: user,
273
+ variation: prereq_res.variation_index,
274
+ value: prereq_res.value,
275
+ version: prereq_flag[:version],
276
+ prereqOf: flag[:key],
277
+ trackEvents: prereq_flag[:trackEvents],
278
+ debugEventsUntilDate: prereq_flag[:debugEventsUntilDate]
279
+ }
280
+ events.push(event)
281
+ rescue => exn
282
+ Util.log_exception(logger, "Error evaluating prerequisite flag \"#{prereq_key}\" for flag \"#{flag[:key]}\"", exn)
283
+ prereq_ok = false
284
+ end
285
+ end
286
+ if !prereq_ok
287
+ return { kind: 'PREREQUISITE_FAILED', prerequisiteKey: prereq_key }
288
+ end
289
+ end
290
+ nil
291
+ end
292
+
293
+ def rule_match_user(rule, user, store)
294
+ return false if !rule[:clauses]
295
+
296
+ (rule[:clauses] || []).each do |clause|
297
+ return false if !clause_match_user(clause, user, store)
298
+ end
299
+
300
+ return true
301
+ end
302
+
303
+ def clause_match_user(clause, user, store)
304
+ # In the case of a segment match operator, we check if the user is in any of the segments,
305
+ # and possibly negate
306
+ if clause[:op].to_sym == :segmentMatch
307
+ (clause[:values] || []).each do |v|
308
+ segment = store.get(SEGMENTS, v)
309
+ return maybe_negate(clause, true) if !segment.nil? && segment_match_user(segment, user)
310
+ end
311
+ return maybe_negate(clause, false)
312
+ end
313
+ clause_match_user_no_segments(clause, user)
314
+ end
315
+
316
+ def clause_match_user_no_segments(clause, user)
317
+ val = user_value(user, clause[:attribute])
318
+ return false if val.nil?
319
+
320
+ op = OPERATORS[clause[:op].to_sym]
321
+ if op.nil?
322
+ return false
323
+ end
324
+
325
+ if val.is_a? Enumerable
326
+ val.each do |v|
327
+ return maybe_negate(clause, true) if match_any(op, v, clause[:values])
328
+ end
329
+ return maybe_negate(clause, false)
330
+ end
331
+
332
+ maybe_negate(clause, match_any(op, val, clause[:values]))
333
+ end
334
+
335
+ def variation_index_for_user(flag, rule, user)
336
+ if !rule[:variation].nil? # fixed variation
337
+ return rule[:variation]
338
+ elsif !rule[:rollout].nil? # percentage rollout
339
+ rollout = rule[:rollout]
340
+ bucket_by = rollout[:bucketBy].nil? ? "key" : rollout[:bucketBy]
341
+ bucket = bucket_user(user, flag[:key], bucket_by, flag[:salt])
342
+ sum = 0;
343
+ rollout[:variations].each do |variate|
344
+ sum += variate[:weight].to_f / 100000.0
345
+ if bucket < sum
346
+ return variate[:variation]
347
+ end
348
+ end
349
+ nil
350
+ else # the rule isn't well-formed
351
+ nil
352
+ end
353
+ end
354
+
355
+ def segment_match_user(segment, user)
356
+ return false unless user[:key]
357
+
358
+ return true if segment[:included].include?(user[:key])
359
+ return false if segment[:excluded].include?(user[:key])
360
+
361
+ (segment[:rules] || []).each do |r|
362
+ return true if segment_rule_match_user(r, user, segment[:key], segment[:salt])
363
+ end
364
+
365
+ return false
366
+ end
367
+
368
+ def segment_rule_match_user(rule, user, segment_key, salt)
369
+ (rule[:clauses] || []).each do |c|
370
+ return false unless clause_match_user_no_segments(c, user)
371
+ end
372
+
373
+ # If the weight is absent, this rule matches
374
+ return true if !rule[:weight]
375
+
376
+ # All of the clauses are met. See if the user buckets in
377
+ bucket = bucket_user(user, segment_key, rule[:bucketBy].nil? ? "key" : rule[:bucketBy], salt)
378
+ weight = rule[:weight].to_f / 100000.0
379
+ return bucket < weight
380
+ end
381
+
382
+ def bucket_user(user, key, bucket_by, salt)
383
+ return nil unless user[:key]
384
+
385
+ id_hash = bucketable_string_value(user_value(user, bucket_by))
386
+ if id_hash.nil?
387
+ return 0.0
388
+ end
389
+
390
+ if user[:secondary]
391
+ id_hash += "." + user[:secondary]
392
+ end
393
+
394
+ hash_key = "%s.%s.%s" % [key, salt, id_hash]
395
+
396
+ hash_val = (Digest::SHA1.hexdigest(hash_key))[0..14]
397
+ hash_val.to_i(16) / Float(0xFFFFFFFFFFFFFFF)
398
+ end
399
+
400
+ def bucketable_string_value(value)
401
+ return value if value.is_a? String
402
+ return value.to_s if value.is_a? Integer
403
+ nil
404
+ end
405
+
406
+ def user_value(user, attribute)
407
+ attribute = attribute.to_sym
408
+
409
+ if BUILTINS.include? attribute
410
+ user[attribute]
411
+ elsif !user[:custom].nil?
412
+ user[:custom][attribute]
413
+ else
414
+ nil
415
+ end
416
+ end
417
+
418
+ def maybe_negate(clause, b)
419
+ clause[:negate] ? !b : b
420
+ end
421
+
422
+ def match_any(op, value, values)
423
+ values.each do |v|
424
+ return true if op.call(value, v)
425
+ end
426
+ return false
427
+ end
428
+
429
+ private
430
+
431
+ def get_variation(flag, index, reason, logger)
432
+ if index < 0 || index >= flag[:variations].length
433
+ logger.error("[LDClient] Data inconsistency in feature flag \"#{flag[:key]}\": invalid variation index")
434
+ return error_result('MALFORMED_FLAG')
435
+ end
436
+ EvaluationDetail.new(flag[:variations][index], index, reason)
437
+ end
438
+
439
+ def get_off_value(flag, reason, logger)
440
+ if flag[:offVariation].nil? # off variation unspecified - return default value
441
+ return EvaluationDetail.new(nil, nil, reason)
442
+ end
443
+ get_variation(flag, flag[:offVariation], reason, logger)
444
+ end
445
+
446
+ def get_value_for_variation_or_rollout(flag, vr, user, reason, logger)
447
+ index = variation_index_for_user(flag, vr, user)
448
+ if index.nil?
449
+ logger.error("[LDClient] Data inconsistency in feature flag \"#{flag[:key]}\": variation/rollout object with no variation or rollout")
450
+ return error_result('MALFORMED_FLAG')
451
+ end
452
+ return get_variation(flag, index, reason, logger)
453
+ end
454
+ end
455
+ end