launchdarkly-server-sdk 6.1.1 → 6.2.3

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 631066b2968b323ed2e12aed1565c76cf1f8bb6284e8372121771577b49bda18
4
- data.tar.gz: 3e6b7c3b9769c917996e844c598796912f19c20e796692b877a885886d8fa070
3
+ metadata.gz: 3c4646e511419b3e7883147b5040b136c84e333286518eca3171105c93304453
4
+ data.tar.gz: b1d3f4fe3ee44591ec9f642abc8787ae5234bb72f794fce6f8acb46a53cd62b5
5
5
  SHA512:
6
- metadata.gz: 5a885a5714a9cc157e295dd1a88a353a546847a702a771b4edf49f33c081eb19c4141ee7a0b4a8004620c1a4c20ef0111af0c0900ee3a28845f29e2385ebd13c
7
- data.tar.gz: 6e604eac4e997431d09257bf97ec115f3a130293e4bc83fa08254baaa921eb28a2230e5bc2bd080c855293e773331e5eed006741db6cd9943132a1f2a1dc243d
6
+ metadata.gz: 3657e9b125c86998638479696fb97b1729ff62e0aa3538b9d9a74f446cc3816bd797a43279c3c2e7a2fa13afb28092de80720384f125e194ee6388a0d10c5c7f
7
+ data.tar.gz: 001ce752eb3c8cc559d21ea5ca968c257a689345341324825fb6e76860062980406d8c5ee8476e2782c67bff29401d0b599ad40a896dc61ff79c719321c93365
data/.circleci/config.yml CHANGED
@@ -20,7 +20,7 @@ jobs:
20
20
  LD_RELEASE_DOCS_TITLE: ""
21
21
  LD_RELEASE_PROJECT: "ruby-server-sdk"
22
22
  LD_RELEASE_PROJECT_TEMPLATE: "ruby"
23
- LD_RELEASE_VERSION: "6.1.1"
23
+ LD_RELEASE_VERSION: "6.2.3"
24
24
  LD_SKIP_DATABASE_TESTS: "1"
25
25
  steps:
26
26
  - checkout
data/CHANGELOG.md CHANGED
@@ -2,6 +2,22 @@
2
2
 
3
3
  All notable changes to the LaunchDarkly Ruby SDK will be documented in this file. This project adheres to [Semantic Versioning](http://semver.org).
4
4
 
5
+ ## [6.2.2] - 2021-07-23
6
+ ### Fixed:
7
+ - Enabling debug logging in polling mode could cause polling to fail with a `NameError`. (Thanks, [mmurphy-notarize](https://github.com/launchdarkly/ruby-server-sdk/pull/180)!)
8
+
9
+ ## [6.2.1] - 2021-07-15
10
+ ### Changed:
11
+ - If `variation` or `variation_detail` is called with a user object that has no `key` (an invalid condition that will always result in the default value being returned), the SDK now logs a `warn`-level message to alert you to this incorrect usage. This makes the Ruby SDK's logging behavior consistent with the other server-side LaunchDarkly SDKs. ([#177](https://github.com/launchdarkly/ruby-server-sdk/issues/177))
12
+
13
+ ## [6.2.0] - 2021-06-17
14
+ ### Added:
15
+ - The SDK now supports the ability to control the proportion of traffic allocation to an experiment. This works in conjunction with a new platform feature now available to early access customers.
16
+
17
+ ## [6.1.1] - 2021-05-27
18
+ ### Fixed:
19
+ - Calling `variation` with a nil user parameter is invalid, causing the SDK to log an error and return a fallback value, but the SDK was still sending an analytics event for this. An event without a user is meaningless and can't be processed by LaunchDarkly. This is now fixed so the SDK will not send one.
20
+
5
21
  ## [6.1.0] - 2021-02-04
6
22
  ### Added:
7
23
  - Added the `alias` method. This can be used to associate two user objects for analytics purposes by generating an alias event.
@@ -120,6 +120,9 @@ module LaunchDarkly
120
120
  # or deleted. If {#kind} is not {#RULE_MATCH}, this will be `nil`.
121
121
  attr_reader :rule_id
122
122
 
123
+ # A boolean or nil value representing if the rule or fallthrough has an experiment rollout.
124
+ attr_reader :in_experiment
125
+
123
126
  # The key of the prerequisite flag that did not return the desired variation. If {#kind} is not
124
127
  # {#PREREQUISITE_FAILED}, this will be `nil`.
125
128
  attr_reader :prerequisite_key
@@ -136,8 +139,12 @@ module LaunchDarkly
136
139
 
137
140
  # Returns an instance whose {#kind} is {#FALLTHROUGH}.
138
141
  # @return [EvaluationReason]
139
- def self.fallthrough
140
- @@fallthrough
142
+ def self.fallthrough(in_experiment=false)
143
+ if in_experiment
144
+ @@fallthrough_with_experiment
145
+ else
146
+ @@fallthrough
147
+ end
141
148
  end
142
149
 
143
150
  # Returns an instance whose {#kind} is {#TARGET_MATCH}.
@@ -153,10 +160,16 @@ module LaunchDarkly
153
160
  # @param rule_id [String] unique string identifier for the matched rule
154
161
  # @return [EvaluationReason]
155
162
  # @raise [ArgumentError] if `rule_index` is not a number or `rule_id` is not a string
156
- def self.rule_match(rule_index, rule_id)
163
+ def self.rule_match(rule_index, rule_id, in_experiment=false)
157
164
  raise ArgumentError.new("rule_index must be a number") if !(rule_index.is_a? Numeric)
158
165
  raise ArgumentError.new("rule_id must be a string") if !rule_id.nil? && !(rule_id.is_a? String) # in test data, ID could be nil
159
- new(:RULE_MATCH, rule_index, rule_id, nil, nil)
166
+
167
+ if in_experiment
168
+ er = new(:RULE_MATCH, rule_index, rule_id, nil, nil, true)
169
+ else
170
+ er = new(:RULE_MATCH, rule_index, rule_id, nil, nil)
171
+ end
172
+ er
160
173
  end
161
174
 
162
175
  # Returns an instance whose {#kind} is {#PREREQUISITE_FAILED}.
@@ -204,11 +217,17 @@ module LaunchDarkly
204
217
  def inspect
205
218
  case @kind
206
219
  when :RULE_MATCH
207
- "RULE_MATCH(#{@rule_index},#{@rule_id})"
220
+ if @in_experiment
221
+ "RULE_MATCH(#{@rule_index},#{@rule_id},#{@in_experiment})"
222
+ else
223
+ "RULE_MATCH(#{@rule_index},#{@rule_id})"
224
+ end
208
225
  when :PREREQUISITE_FAILED
209
226
  "PREREQUISITE_FAILED(#{@prerequisite_key})"
210
227
  when :ERROR
211
228
  "ERROR(#{@error_kind})"
229
+ when :FALLTHROUGH
230
+ @in_experiment ? "FALLTHROUGH(#{@in_experiment})" : @kind.to_s
212
231
  else
213
232
  @kind.to_s
214
233
  end
@@ -225,11 +244,21 @@ module LaunchDarkly
225
244
  # as_json and then modify the result.
226
245
  case @kind
227
246
  when :RULE_MATCH
228
- { kind: @kind, ruleIndex: @rule_index, ruleId: @rule_id }
247
+ if @in_experiment
248
+ { kind: @kind, ruleIndex: @rule_index, ruleId: @rule_id, inExperiment: @in_experiment }
249
+ else
250
+ { kind: @kind, ruleIndex: @rule_index, ruleId: @rule_id }
251
+ end
229
252
  when :PREREQUISITE_FAILED
230
253
  { kind: @kind, prerequisiteKey: @prerequisite_key }
231
254
  when :ERROR
232
255
  { kind: @kind, errorKind: @error_kind }
256
+ when :FALLTHROUGH
257
+ if @in_experiment
258
+ { kind: @kind, inExperiment: @in_experiment }
259
+ else
260
+ { kind: @kind }
261
+ end
233
262
  else
234
263
  { kind: @kind }
235
264
  end
@@ -263,7 +292,7 @@ module LaunchDarkly
263
292
 
264
293
  private
265
294
 
266
- def initialize(kind, rule_index, rule_id, prerequisite_key, error_kind)
295
+ def initialize(kind, rule_index, rule_id, prerequisite_key, error_kind, in_experiment=nil)
267
296
  @kind = kind.to_sym
268
297
  @rule_index = rule_index
269
298
  @rule_id = rule_id
@@ -271,6 +300,7 @@ module LaunchDarkly
271
300
  @prerequisite_key = prerequisite_key
272
301
  @prerequisite_key.freeze if !prerequisite_key.nil?
273
302
  @error_kind = error_kind
303
+ @in_experiment = in_experiment
274
304
  end
275
305
 
276
306
  private_class_method :new
@@ -279,6 +309,7 @@ module LaunchDarkly
279
309
  new(:ERROR, nil, nil, nil, error_kind)
280
310
  end
281
311
 
312
+ @@fallthrough_with_experiment = new(:FALLTHROUGH, nil, nil, nil, nil, true)
282
313
  @@fallthrough = new(:FALLTHROUGH, nil, nil, nil, nil)
283
314
  @@off = new(:OFF, nil, nil, nil, nil)
284
315
  @@target_match = new(:TARGET_MATCH, nil, nil, nil, nil)
@@ -79,7 +79,7 @@ module LaunchDarkly
79
79
  streamingDisabled: !config.stream?,
80
80
  userKeysCapacity: config.user_keys_capacity,
81
81
  userKeysFlushIntervalMillis: self.seconds_to_millis(config.user_keys_flush_interval),
82
- usingProxy: ENV.has_key?('http_proxy') || ENV.has_key?('https_proxy') || ENV.has_key?('HTTP_PROXY'),
82
+ usingProxy: ENV.has_key?('http_proxy') || ENV.has_key?('https_proxy') || ENV.has_key?('HTTP_PROXY') || ENV.has_key?('HTTPS_PROXY'),
83
83
  usingRelayDaemon: config.use_ldd?,
84
84
  }
85
85
  ret
@@ -190,7 +190,7 @@ module LaunchDarkly
190
190
  return true if !rule[:weight]
191
191
 
192
192
  # All of the clauses are met. See if the user buckets in
193
- bucket = EvaluatorBucketing.bucket_user(user, segment_key, rule[:bucketBy].nil? ? "key" : rule[:bucketBy], salt)
193
+ bucket = EvaluatorBucketing.bucket_user(user, segment_key, rule[:bucketBy].nil? ? "key" : rule[:bucketBy], salt, nil)
194
194
  weight = rule[:weight].to_f / 100000.0
195
195
  return bucket < weight
196
196
  end
@@ -213,7 +213,13 @@ module LaunchDarkly
213
213
  end
214
214
 
215
215
  def get_value_for_variation_or_rollout(flag, vr, user, reason)
216
- index = EvaluatorBucketing.variation_index_for_user(flag, vr, user)
216
+ index, in_experiment = EvaluatorBucketing.variation_index_for_user(flag, vr, user)
217
+ #if in experiment is true, set reason to a different reason instance/singleton with in_experiment set
218
+ if in_experiment && reason.kind == :FALLTHROUGH
219
+ reason = EvaluationReason::fallthrough(in_experiment)
220
+ elsif in_experiment && reason.kind == :RULE_MATCH
221
+ reason = EvaluationReason::rule_match(reason.rule_index, reason.rule_id, in_experiment)
222
+ end
217
223
  if index.nil?
218
224
  @logger.error("[LDClient] Data inconsistency in feature flag \"#{flag[:key]}\": variation/rollout object with no variation or rollout")
219
225
  return Evaluator.error_result(EvaluationReason::ERROR_MALFORMED_FLAG)
@@ -10,20 +10,26 @@ module LaunchDarkly
10
10
  # @param user [Object] the user properties
11
11
  # @return [Number] the variation index, or nil if there is an error
12
12
  def self.variation_index_for_user(flag, rule, user)
13
+
13
14
  variation = rule[:variation]
14
- return variation if !variation.nil? # fixed variation
15
+ return variation, false if !variation.nil? # fixed variation
15
16
  rollout = rule[:rollout]
16
- return nil if rollout.nil?
17
+ return nil, false if rollout.nil?
17
18
  variations = rollout[:variations]
18
19
  if !variations.nil? && variations.length > 0 # percentage rollout
19
- rollout = rule[:rollout]
20
20
  bucket_by = rollout[:bucketBy].nil? ? "key" : rollout[:bucketBy]
21
- bucket = bucket_user(user, flag[:key], bucket_by, flag[:salt])
21
+
22
+ seed = rollout[:seed]
23
+ bucket = bucket_user(user, flag[:key], bucket_by, flag[:salt], seed) # may not be present
22
24
  sum = 0;
23
25
  variations.each do |variate|
26
+ if rollout[:kind] == "experiment" && !variate[:untracked]
27
+ in_experiment = true
28
+ end
29
+
24
30
  sum += variate[:weight].to_f / 100000.0
25
31
  if bucket < sum
26
- return variate[:variation]
32
+ return variate[:variation], !!in_experiment
27
33
  end
28
34
  end
29
35
  # The user's bucket value was greater than or equal to the end of the last bucket. This could happen due
@@ -31,9 +37,12 @@ module LaunchDarkly
31
37
  # data could contain buckets that don't actually add up to 100000. Rather than returning an error in
32
38
  # this case (or changing the scaling, which would potentially change the results for *all* users), we
33
39
  # will simply put the user in the last bucket.
34
- variations[-1][:variation]
40
+ last_variation = variations[-1]
41
+ in_experiment = rollout[:kind] == "experiment" && !last_variation[:untracked]
42
+
43
+ [last_variation[:variation], in_experiment]
35
44
  else # the rule isn't well-formed
36
- nil
45
+ [nil, false]
37
46
  end
38
47
  end
39
48
 
@@ -44,7 +53,7 @@ module LaunchDarkly
44
53
  # @param bucket_by [String|Symbol] the name of the user attribute to be used for bucketing
45
54
  # @param salt [String] the feature flag's or segment's salt value
46
55
  # @return [Number] the bucket value, from 0 inclusive to 1 exclusive
47
- def self.bucket_user(user, key, bucket_by, salt)
56
+ def self.bucket_user(user, key, bucket_by, salt, seed)
48
57
  return nil unless user[:key]
49
58
 
50
59
  id_hash = bucketable_string_value(EvaluatorOperators.user_value(user, bucket_by))
@@ -56,7 +65,11 @@ module LaunchDarkly
56
65
  id_hash += "." + user[:secondary].to_s
57
66
  end
58
67
 
59
- hash_key = "%s.%s.%s" % [key, salt, id_hash]
68
+ if seed
69
+ hash_key = "%d.%s" % [seed, id_hash]
70
+ else
71
+ hash_key = "%s.%s.%s" % [key, salt, id_hash]
72
+ end
60
73
 
61
74
  hash_val = (Digest::SHA1.hexdigest(hash_key))[0..14]
62
75
  hash_val.to_i(16) / Float(0xFFFFFFFFFFFFFFF)
@@ -103,6 +103,11 @@ module LaunchDarkly
103
103
 
104
104
  def is_experiment(flag, reason)
105
105
  return false if !reason
106
+
107
+ if reason.in_experiment
108
+ return true
109
+ end
110
+
106
111
  case reason[:kind]
107
112
  when 'RULE_MATCH'
108
113
  index = reason[:ruleIndex]
@@ -115,6 +120,7 @@ module LaunchDarkly
115
120
  end
116
121
  false
117
122
  end
123
+
118
124
  end
119
125
  end
120
126
  end
@@ -407,6 +407,12 @@ module LaunchDarkly
407
407
  return detail
408
408
  end
409
409
 
410
+ if user[:key].nil?
411
+ @config.logger.warn { "[LDClient] Variation called with nil user key; returning default value" }
412
+ detail = Evaluator.error_result(EvaluationReason::ERROR_USER_NOT_SPECIFIED, default)
413
+ return detail
414
+ end
415
+
410
416
  if !initialized?
411
417
  if @store.initialized?
412
418
  @config.logger.warn { "[LDClient] Client has not finished initializing; using last known values from feature store" }
@@ -60,9 +60,9 @@ module LaunchDarkly
60
60
  headers: headers
61
61
  })
62
62
  status = response.status.code
63
- @config.logger.debug { "[LDClient] Got response from uri: #{uri}\n\tstatus code: #{status}\n\theaders: #{response.headers}\n\tbody: #{res.to_s}" }
64
63
  # must fully read body for persistent connections
65
64
  body = response.to_s
65
+ @config.logger.debug { "[LDClient] Got response from uri: #{uri}\n\tstatus code: #{status}\n\theaders: #{response.headers.to_h}\n\tbody: #{body}" }
66
66
  if status == 304 && !cached.nil?
67
67
  body = cached.body
68
68
  else
@@ -1,3 +1,3 @@
1
1
  module LaunchDarkly
2
- VERSION = "6.1.1"
2
+ VERSION = "6.2.3"
3
3
  end
@@ -79,13 +79,15 @@ module LaunchDarkly
79
79
  end
80
80
  end
81
81
 
82
- it "detects proxy" do
83
- begin
84
- ENV["http_proxy"] = 'http://my-proxy'
85
- event = default_acc.create_init_event(Config.new)
86
- expect(event[:configuration][:usingProxy]).to be true
87
- ensure
88
- ENV["http_proxy"] = nil
82
+ ['http_proxy', 'https_proxy', 'HTTP_PROXY', 'HTTPS_PROXY'].each do |name|
83
+ it "detects proxy #{name}" do
84
+ begin
85
+ ENV[name] = 'http://my-proxy'
86
+ event = default_acc.create_init_event(Config.new)
87
+ expect(event[:configuration][:usingProxy]).to be true
88
+ ensure
89
+ ENV[name] = nil
90
+ end
89
91
  end
90
92
  end
91
93
 
@@ -4,17 +4,58 @@ describe LaunchDarkly::Impl::EvaluatorBucketing do
4
4
  subject { LaunchDarkly::Impl::EvaluatorBucketing }
5
5
 
6
6
  describe "bucket_user" do
7
+ describe "seed exists" do
8
+ let(:seed) { 61 }
9
+ it "returns the expected bucket values for seed" do
10
+ user = { key: "userKeyA" }
11
+ bucket = subject.bucket_user(user, "hashKey", "key", "saltyA", seed)
12
+ expect(bucket).to be_within(0.0000001).of(0.09801207);
13
+
14
+ user = { key: "userKeyB" }
15
+ bucket = subject.bucket_user(user, "hashKey", "key", "saltyA", seed)
16
+ expect(bucket).to be_within(0.0000001).of(0.14483777);
17
+
18
+ user = { key: "userKeyC" }
19
+ bucket = subject.bucket_user(user, "hashKey", "key", "saltyA", seed)
20
+ expect(bucket).to be_within(0.0000001).of(0.9242641);
21
+ end
22
+
23
+ it "returns the same bucket regardless of hashKey and salt" do
24
+ user = { key: "userKeyA" }
25
+ bucket1 = subject.bucket_user(user, "hashKey", "key", "saltyA", seed)
26
+ bucket2 = subject.bucket_user(user, "hashKey1", "key", "saltyB", seed)
27
+ bucket3 = subject.bucket_user(user, "hashKey2", "key", "saltyC", seed)
28
+ expect(bucket1).to eq(bucket2)
29
+ expect(bucket2).to eq(bucket3)
30
+ end
31
+
32
+ it "returns a different bucket if the seed is not the same" do
33
+ user = { key: "userKeyA" }
34
+ bucket1 = subject.bucket_user(user, "hashKey", "key", "saltyA", seed)
35
+ bucket2 = subject.bucket_user(user, "hashKey1", "key", "saltyB", seed+1)
36
+ expect(bucket1).to_not eq(bucket2)
37
+ end
38
+
39
+ it "returns a different bucket if the user is not the same" do
40
+ user1 = { key: "userKeyA" }
41
+ user2 = { key: "userKeyB" }
42
+ bucket1 = subject.bucket_user(user1, "hashKey", "key", "saltyA", seed)
43
+ bucket2 = subject.bucket_user(user2, "hashKey1", "key", "saltyB", seed)
44
+ expect(bucket1).to_not eq(bucket2)
45
+ end
46
+ end
47
+
7
48
  it "gets expected bucket values for specific keys" do
8
49
  user = { key: "userKeyA" }
9
- bucket = subject.bucket_user(user, "hashKey", "key", "saltyA")
50
+ bucket = subject.bucket_user(user, "hashKey", "key", "saltyA", nil)
10
51
  expect(bucket).to be_within(0.0000001).of(0.42157587);
11
52
 
12
53
  user = { key: "userKeyB" }
13
- bucket = subject.bucket_user(user, "hashKey", "key", "saltyA")
54
+ bucket = subject.bucket_user(user, "hashKey", "key", "saltyA", nil)
14
55
  expect(bucket).to be_within(0.0000001).of(0.6708485);
15
56
 
16
57
  user = { key: "userKeyC" }
17
- bucket = subject.bucket_user(user, "hashKey", "key", "saltyA")
58
+ bucket = subject.bucket_user(user, "hashKey", "key", "saltyA", nil)
18
59
  expect(bucket).to be_within(0.0000001).of(0.10343106);
19
60
  end
20
61
 
@@ -26,8 +67,8 @@ describe LaunchDarkly::Impl::EvaluatorBucketing do
26
67
  intAttr: 33333
27
68
  }
28
69
  }
29
- stringResult = subject.bucket_user(user, "hashKey", "stringAttr", "saltyA")
30
- intResult = subject.bucket_user(user, "hashKey", "intAttr", "saltyA")
70
+ stringResult = subject.bucket_user(user, "hashKey", "stringAttr", "saltyA", nil)
71
+ intResult = subject.bucket_user(user, "hashKey", "intAttr", "saltyA", nil)
31
72
 
32
73
  expect(intResult).to be_within(0.0000001).of(0.54771423)
33
74
  expect(intResult).to eq(stringResult)
@@ -40,7 +81,7 @@ describe LaunchDarkly::Impl::EvaluatorBucketing do
40
81
  floatAttr: 33.5
41
82
  }
42
83
  }
43
- result = subject.bucket_user(user, "hashKey", "floatAttr", "saltyA")
84
+ result = subject.bucket_user(user, "hashKey", "floatAttr", "saltyA", nil)
44
85
  expect(result).to eq(0.0)
45
86
  end
46
87
 
@@ -52,60 +93,124 @@ describe LaunchDarkly::Impl::EvaluatorBucketing do
52
93
  boolAttr: true
53
94
  }
54
95
  }
55
- result = subject.bucket_user(user, "hashKey", "boolAttr", "saltyA")
96
+ result = subject.bucket_user(user, "hashKey", "boolAttr", "saltyA", nil)
56
97
  expect(result).to eq(0.0)
57
98
  end
58
99
  end
59
100
 
60
101
  describe "variation_index_for_user" do
61
- it "matches bucket" do
62
- user = { key: "userkey" }
102
+ context "rollout is not an experiment" do
103
+ it "matches bucket" do
104
+ user = { key: "userkey" }
105
+ flag_key = "flagkey"
106
+ salt = "salt"
107
+
108
+ # First verify that with our test inputs, the bucket value will be greater than zero and less than 100000,
109
+ # so we can construct a rollout whose second bucket just barely contains that value
110
+ bucket_value = (subject.bucket_user(user, flag_key, "key", salt, nil) * 100000).truncate()
111
+ expect(bucket_value).to be > 0
112
+ expect(bucket_value).to be < 100000
113
+
114
+ bad_variation_a = 0
115
+ matched_variation = 1
116
+ bad_variation_b = 2
117
+ rule = {
118
+ rollout: {
119
+ variations: [
120
+ { variation: bad_variation_a, weight: bucket_value }, # end of bucket range is not inclusive, so it will *not* match the target value
121
+ { variation: matched_variation, weight: 1 }, # size of this bucket is 1, so it only matches that specific value
122
+ { variation: bad_variation_b, weight: 100000 - (bucket_value + 1) }
123
+ ]
124
+ }
125
+ }
126
+ flag = { key: flag_key, salt: salt }
127
+
128
+ result_variation, inExperiment = subject.variation_index_for_user(flag, rule, user)
129
+ expect(result_variation).to be matched_variation
130
+ expect(inExperiment).to be(false)
131
+ end
132
+
133
+ it "uses last bucket if bucket value is equal to total weight" do
134
+ user = { key: "userkey" }
135
+ flag_key = "flagkey"
136
+ salt = "salt"
137
+
138
+ bucket_value = (subject.bucket_user(user, flag_key, "key", salt, nil) * 100000).truncate()
139
+
140
+ # We'll construct a list of variations that stops right at the target bucket value
141
+ rule = {
142
+ rollout: {
143
+ variations: [
144
+ { variation: 0, weight: bucket_value }
145
+ ]
146
+ }
147
+ }
148
+ flag = { key: flag_key, salt: salt }
149
+
150
+ result_variation, inExperiment = subject.variation_index_for_user(flag, rule, user)
151
+ expect(result_variation).to be 0
152
+ expect(inExperiment).to be(false)
153
+ end
154
+ end
155
+ end
156
+
157
+ context "rollout is an experiment" do
158
+ it "returns whether user is in the experiment or not" do
159
+ user1 = { key: "userKeyA" }
160
+ user2 = { key: "userKeyB" }
161
+ user3 = { key: "userKeyC" }
63
162
  flag_key = "flagkey"
64
163
  salt = "salt"
164
+ seed = 61
65
165
 
66
- # First verify that with our test inputs, the bucket value will be greater than zero and less than 100000,
67
- # so we can construct a rollout whose second bucket just barely contains that value
68
- bucket_value = (subject.bucket_user(user, flag_key, "key", salt) * 100000).truncate()
69
- expect(bucket_value).to be > 0
70
- expect(bucket_value).to be < 100000
71
-
72
- bad_variation_a = 0
73
- matched_variation = 1
74
- bad_variation_b = 2
166
+
75
167
  rule = {
76
168
  rollout: {
169
+ seed: seed,
170
+ kind: 'experiment',
77
171
  variations: [
78
- { variation: bad_variation_a, weight: bucket_value }, # end of bucket range is not inclusive, so it will *not* match the target value
79
- { variation: matched_variation, weight: 1 }, # size of this bucket is 1, so it only matches that specific value
80
- { variation: bad_variation_b, weight: 100000 - (bucket_value + 1) }
172
+ { variation: 0, weight: 10000, untracked: false },
173
+ { variation: 2, weight: 20000, untracked: false },
174
+ { variation: 0, weight: 70000 , untracked: true }
81
175
  ]
82
176
  }
83
177
  }
84
178
  flag = { key: flag_key, salt: salt }
85
179
 
86
- result_variation = subject.variation_index_for_user(flag, rule, user)
87
- expect(result_variation).to be matched_variation
180
+ result_variation, inExperiment = subject.variation_index_for_user(flag, rule, user1)
181
+ expect(result_variation).to be(0)
182
+ expect(inExperiment).to be(true)
183
+ result_variation, inExperiment = subject.variation_index_for_user(flag, rule, user2)
184
+ expect(result_variation).to be(2)
185
+ expect(inExperiment).to be(true)
186
+ result_variation, inExperiment = subject.variation_index_for_user(flag, rule, user3)
187
+ expect(result_variation).to be(0)
188
+ expect(inExperiment).to be(false)
88
189
  end
89
190
 
90
191
  it "uses last bucket if bucket value is equal to total weight" do
91
192
  user = { key: "userkey" }
92
193
  flag_key = "flagkey"
93
194
  salt = "salt"
195
+ seed = 61
94
196
 
95
- bucket_value = (subject.bucket_user(user, flag_key, "key", salt) * 100000).truncate()
197
+ bucket_value = (subject.bucket_user(user, flag_key, "key", salt, seed) * 100000).truncate()
96
198
 
97
199
  # We'll construct a list of variations that stops right at the target bucket value
98
200
  rule = {
99
201
  rollout: {
202
+ seed: seed,
203
+ kind: 'experiment',
100
204
  variations: [
101
- { variation: 0, weight: bucket_value }
205
+ { variation: 0, weight: bucket_value, untracked: false }
102
206
  ]
103
207
  }
104
208
  }
105
209
  flag = { key: flag_key, salt: salt }
106
210
 
107
- result_variation = subject.variation_index_for_user(flag, rule, user)
211
+ result_variation, inExperiment = subject.variation_index_for_user(flag, rule, user)
108
212
  expect(result_variation).to be 0
213
+ expect(inExperiment).to be(true)
109
214
  end
110
215
  end
111
216
  end
@@ -91,6 +91,38 @@ module LaunchDarkly
91
91
  result = basic_evaluator.evaluate(flag, user, factory)
92
92
  expect(result.detail.reason).to eq(EvaluationReason::rule_match(0, 'ruleid'))
93
93
  end
94
+
95
+ describe "experiment rollout behavior" do
96
+ it "sets the in_experiment value if rollout kind is experiment " do
97
+ rule = { id: 'ruleid', clauses: [{ attribute: 'key', op: 'in', values: ['userkey'] }],
98
+ rollout: { kind: 'experiment', variations: [ { weight: 100000, variation: 1, untracked: false } ] } }
99
+ flag = boolean_flag_with_rules([rule])
100
+ user = { key: "userkey", secondary: 999 }
101
+ result = basic_evaluator.evaluate(flag, user, factory)
102
+ expect(result.detail.reason.to_json).to include('"inExperiment":true')
103
+ expect(result.detail.reason.in_experiment).to eq(true)
104
+ end
105
+
106
+ it "does not set the in_experiment value if rollout kind is not experiment " do
107
+ rule = { id: 'ruleid', clauses: [{ attribute: 'key', op: 'in', values: ['userkey'] }],
108
+ rollout: { kind: 'rollout', variations: [ { weight: 100000, variation: 1, untracked: false } ] } }
109
+ flag = boolean_flag_with_rules([rule])
110
+ user = { key: "userkey", secondary: 999 }
111
+ result = basic_evaluator.evaluate(flag, user, factory)
112
+ expect(result.detail.reason.to_json).to_not include('"inExperiment":true')
113
+ expect(result.detail.reason.in_experiment).to eq(nil)
114
+ end
115
+
116
+ it "does not set the in_experiment value if rollout kind is experiment and untracked is true" do
117
+ rule = { id: 'ruleid', clauses: [{ attribute: 'key', op: 'in', values: ['userkey'] }],
118
+ rollout: { kind: 'experiment', variations: [ { weight: 100000, variation: 1, untracked: true } ] } }
119
+ flag = boolean_flag_with_rules([rule])
120
+ user = { key: "userkey", secondary: 999 }
121
+ result = basic_evaluator.evaluate(flag, user, factory)
122
+ expect(result.detail.reason.to_json).to_not include('"inExperiment":true')
123
+ expect(result.detail.reason.in_experiment).to eq(nil)
124
+ end
125
+ end
94
126
  end
95
127
  end
96
128
  end
@@ -299,6 +299,50 @@ module LaunchDarkly
299
299
  expect(result.detail).to eq(detail)
300
300
  expect(result.events).to eq(nil)
301
301
  end
302
+
303
+ describe "experiment rollout behavior" do
304
+ it "sets the in_experiment value if rollout kind is experiment and untracked false" do
305
+ flag = {
306
+ key: 'feature',
307
+ on: true,
308
+ fallthrough: { rollout: { kind: 'experiment', variations: [ { weight: 100000, variation: 1, untracked: false } ] } },
309
+ offVariation: 1,
310
+ variations: ['a', 'b', 'c']
311
+ }
312
+ user = { key: 'userkey' }
313
+ result = basic_evaluator.evaluate(flag, user, factory)
314
+ expect(result.detail.reason.to_json).to include('"inExperiment":true')
315
+ expect(result.detail.reason.in_experiment).to eq(true)
316
+ end
317
+
318
+ it "does not set the in_experiment value if rollout kind is not experiment" do
319
+ flag = {
320
+ key: 'feature',
321
+ on: true,
322
+ fallthrough: { rollout: { kind: 'rollout', variations: [ { weight: 100000, variation: 1, untracked: false } ] } },
323
+ offVariation: 1,
324
+ variations: ['a', 'b', 'c']
325
+ }
326
+ user = { key: 'userkey' }
327
+ result = basic_evaluator.evaluate(flag, user, factory)
328
+ expect(result.detail.reason.to_json).to_not include('"inExperiment":true')
329
+ expect(result.detail.reason.in_experiment).to eq(nil)
330
+ end
331
+
332
+ it "does not set the in_experiment value if rollout kind is experiment and untracked is true" do
333
+ flag = {
334
+ key: 'feature',
335
+ on: true,
336
+ fallthrough: { rollout: { kind: 'experiment', variations: [ { weight: 100000, variation: 1, untracked: true } ] } },
337
+ offVariation: 1,
338
+ variations: ['a', 'b', 'c']
339
+ }
340
+ user = { key: 'userkey' }
341
+ result = basic_evaluator.evaluate(flag, user, factory)
342
+ expect(result.detail.reason.to_json).to_not include('"inExperiment":true')
343
+ expect(result.detail.reason.in_experiment).to eq(nil)
344
+ end
345
+ end
302
346
  end
303
347
  end
304
348
  end
@@ -0,0 +1,108 @@
1
+ require "spec_helper"
2
+
3
+ describe LaunchDarkly::Impl::EventFactory do
4
+ subject { LaunchDarkly::Impl::EventFactory }
5
+
6
+ describe "#new_eval_event" do
7
+ let(:event_factory_without_reason) { subject.new(false) }
8
+ let(:user) { { 'key': 'userA' } }
9
+ let(:rule_with_experiment_rollout) {
10
+ { id: 'ruleid',
11
+ clauses: [{ attribute: 'key', op: 'in', values: ['userkey'] }],
12
+ trackEvents: false,
13
+ rollout: { kind: 'experiment', salt: '', variations: [ { weight: 100000, variation: 0, untracked: false } ] }
14
+ }
15
+ }
16
+
17
+ let(:rule_with_rollout) {
18
+ { id: 'ruleid',
19
+ trackEvents: false,
20
+ clauses: [{ attribute: 'key', op: 'in', values: ['userkey'] }],
21
+ rollout: { salt: '', variations: [ { weight: 100000, variation: 0, untracked: false } ] }
22
+ }
23
+ }
24
+
25
+ let(:fallthrough_with_rollout) {
26
+ { rollout: { kind: 'rollout', salt: '', variations: [ { weight: 100000, variation: 0, untracked: false } ], trackEventsFallthrough: false } }
27
+ }
28
+
29
+ let(:rule_reason) { LaunchDarkly::EvaluationReason::rule_match(0, 'ruleid') }
30
+ let(:rule_reason_with_experiment) { LaunchDarkly::EvaluationReason::rule_match(0, 'ruleid', true) }
31
+ let(:fallthrough_reason) { LaunchDarkly::EvaluationReason::fallthrough }
32
+ let(:fallthrough_reason_with_experiment) { LaunchDarkly::EvaluationReason::fallthrough(true) }
33
+
34
+ context "in_experiment is true" do
35
+ it "sets the reason and trackevents: true for rules" do
36
+ flag = createFlag('rule', rule_with_experiment_rollout)
37
+ detail = LaunchDarkly::EvaluationDetail.new(true, 0, rule_reason_with_experiment)
38
+ r = subject.new(false).new_eval_event(flag, user, detail, nil, nil)
39
+ expect(r[:trackEvents]).to eql(true)
40
+ expect(r[:reason].to_s).to eql("RULE_MATCH(0,ruleid,true)")
41
+ end
42
+
43
+ it "sets the reason and trackevents: true for the fallthrough" do
44
+ fallthrough_with_rollout[:kind] = 'experiment'
45
+ flag = createFlag('fallthrough', fallthrough_with_rollout)
46
+ detail = LaunchDarkly::EvaluationDetail.new(true, 0, fallthrough_reason_with_experiment)
47
+ r = subject.new(false).new_eval_event(flag, user, detail, nil, nil)
48
+ expect(r[:trackEvents]).to eql(true)
49
+ expect(r[:reason].to_s).to eql("FALLTHROUGH(true)")
50
+ end
51
+ end
52
+
53
+ context "in_experiment is false" do
54
+ it "sets the reason & trackEvents: true if rule has trackEvents set to true" do
55
+ rule_with_rollout[:trackEvents] = true
56
+ flag = createFlag('rule', rule_with_rollout)
57
+ detail = LaunchDarkly::EvaluationDetail.new(true, 0, rule_reason)
58
+ r = subject.new(false).new_eval_event(flag, user, detail, nil, nil)
59
+ expect(r[:trackEvents]).to eql(true)
60
+ expect(r[:reason].to_s).to eql("RULE_MATCH(0,ruleid)")
61
+ end
62
+
63
+ it "sets the reason & trackEvents: true if fallthrough has trackEventsFallthrough set to true" do
64
+ flag = createFlag('fallthrough', fallthrough_with_rollout)
65
+ flag[:trackEventsFallthrough] = true
66
+ detail = LaunchDarkly::EvaluationDetail.new(true, 0, fallthrough_reason)
67
+ r = subject.new(false).new_eval_event(flag, user, detail, nil, nil)
68
+ expect(r[:trackEvents]).to eql(true)
69
+ expect(r[:reason].to_s).to eql("FALLTHROUGH")
70
+ end
71
+
72
+ it "doesn't set the reason & trackEvents if rule has trackEvents set to false" do
73
+ flag = createFlag('rule', rule_with_rollout)
74
+ detail = LaunchDarkly::EvaluationDetail.new(true, 0, rule_reason)
75
+ r = subject.new(false).new_eval_event(flag, user, detail, nil, nil)
76
+ expect(r[:trackEvents]).to be_nil
77
+ expect(r[:reason]).to be_nil
78
+ end
79
+
80
+ it "doesn't set the reason & trackEvents if fallthrough has trackEventsFallthrough set to false" do
81
+ flag = createFlag('fallthrough', fallthrough_with_rollout)
82
+ detail = LaunchDarkly::EvaluationDetail.new(true, 0, fallthrough_reason)
83
+ r = subject.new(false).new_eval_event(flag, user, detail, nil, nil)
84
+ expect(r[:trackEvents]).to be_nil
85
+ expect(r[:reason]).to be_nil
86
+ end
87
+
88
+ it "sets trackEvents true and doesn't set the reason if flag[:trackEvents] = true" do
89
+ flag = createFlag('fallthrough', fallthrough_with_rollout)
90
+ flag[:trackEvents] = true
91
+ detail = LaunchDarkly::EvaluationDetail.new(true, 0, fallthrough_reason)
92
+ r = subject.new(false).new_eval_event(flag, user, detail, nil, nil)
93
+ expect(r[:trackEvents]).to eql(true)
94
+ expect(r[:reason]).to be_nil
95
+ end
96
+ end
97
+ end
98
+
99
+ def createFlag(kind, rule)
100
+ if kind == 'rule'
101
+ { key: 'feature', on: true, rules: [rule], fallthrough: { variation: 0 }, variations: [ false, true ] }
102
+ elsif kind == 'fallthrough'
103
+ { key: 'feature', on: true, fallthrough: rule, variations: [ false, true ] }
104
+ else
105
+ { key: 'feature', on: true, fallthrough: { variation: 0 }, variations: [ false, true ] }
106
+ end
107
+ end
108
+ end
@@ -171,20 +171,12 @@ describe LaunchDarkly::LDClient do
171
171
  client.variation("key", user_anonymous, "default")
172
172
  end
173
173
 
174
- it "queues a feature event for an existing feature when user key is nil" do
174
+ it "does not queue a feature event for an existing feature when user key is nil" do
175
175
  config.feature_store.init({ LaunchDarkly::FEATURES => {} })
176
176
  config.feature_store.upsert(LaunchDarkly::FEATURES, feature_with_value)
177
177
  bad_user = { name: "Bob" }
178
- expect(event_processor).to receive(:add_event).with(hash_including(
179
- kind: "feature",
180
- key: "key",
181
- version: 100,
182
- user: bad_user,
183
- value: "default",
184
- default: "default",
185
- trackEvents: true,
186
- debugEventsUntilDate: 1000
187
- ))
178
+ expect(event_processor).not_to receive(:add_event)
179
+ expect(logger).to receive(:warn)
188
180
  client.variation("key", bad_user, "default")
189
181
  end
190
182
 
@@ -40,6 +40,19 @@ describe LaunchDarkly::Requestor do
40
40
  end
41
41
  end
42
42
 
43
+ it "logs debug output" do
44
+ logger = ::Logger.new($stdout)
45
+ logger.level = ::Logger::DEBUG
46
+ with_server do |server|
47
+ with_requestor(server.base_uri.to_s, { logger: logger }) do |requestor|
48
+ server.setup_ok_response("/", { flags: { x: { key: "y" } } }.to_json)
49
+ expect do
50
+ requestor.request_all_data()
51
+ end.to output(/\[LDClient\] Got response from uri\:/).to_stdout_from_any_process
52
+ end
53
+ end
54
+ end
55
+
43
56
  it "sends etag from previous response" do
44
57
  etag = "xyz"
45
58
  with_server do |server|
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: launchdarkly-server-sdk
3
3
  version: !ruby/object:Gem::Version
4
- version: 6.1.1
4
+ version: 6.2.3
5
5
  platform: ruby
6
6
  authors:
7
7
  - LaunchDarkly
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2021-05-27 00:00:00.000000000 Z
11
+ date: 2021-08-06 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: aws-sdk-dynamodb
@@ -336,6 +336,7 @@ files:
336
336
  - spec/impl/evaluator_segment_spec.rb
337
337
  - spec/impl/evaluator_spec.rb
338
338
  - spec/impl/evaluator_spec_base.rb
339
+ - spec/impl/event_factory_spec.rb
339
340
  - spec/impl/model/serialization_spec.rb
340
341
  - spec/in_memory_feature_store_spec.rb
341
342
  - spec/integrations/consul_feature_store_spec.rb
@@ -402,6 +403,7 @@ test_files:
402
403
  - spec/impl/evaluator_segment_spec.rb
403
404
  - spec/impl/evaluator_spec.rb
404
405
  - spec/impl/evaluator_spec_base.rb
406
+ - spec/impl/event_factory_spec.rb
405
407
  - spec/impl/model/serialization_spec.rb
406
408
  - spec/in_memory_feature_store_spec.rb
407
409
  - spec/integrations/consul_feature_store_spec.rb