launchdarkly-server-sdk 6.1.1 → 6.2.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +4 -4
- data/.circleci/config.yml +1 -1
- data/CHANGELOG.md +4 -0
- data/lib/ldclient-rb/evaluation_detail.rb +38 -7
- data/lib/ldclient-rb/impl/evaluator.rb +8 -2
- data/lib/ldclient-rb/impl/evaluator_bucketing.rb +22 -9
- data/lib/ldclient-rb/impl/event_factory.rb +6 -0
- data/lib/ldclient-rb/version.rb +1 -1
- data/spec/impl/evaluator_bucketing_spec.rb +131 -26
- data/spec/impl/evaluator_rule_spec.rb +32 -0
- data/spec/impl/evaluator_spec.rb +44 -0
- data/spec/impl/event_factory_spec.rb +108 -0
- metadata +4 -2
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 1ee671939e04661c649d31502496cc33cd8844002c9af06864db9f322a81ec56
|
4
|
+
data.tar.gz: f5f77f10a6c487e1846f8d86201e9456b04bfb03950c751a328a7d3b9c3b8597
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 6dc025a98ed3ab25bfe14baa878ff36beb34875caafee9c1979c724d58af9d08ca2596615cb31819dbb150fdeffd17b597f76e19cf2f09962dc1095ffe686ea7
|
7
|
+
data.tar.gz: 5d22b72feb1b46e7537f00b3959c72368d94c6606db640df1a7a9b17e1b01b952f1d34ea260e8d20b661fdee558975a9e034087e0d785aacd297d20ab15e9944
|
data/.circleci/config.yml
CHANGED
data/CHANGELOG.md
CHANGED
@@ -2,6 +2,10 @@
|
|
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.1.1] - 2021-05-27
|
6
|
+
### Fixed:
|
7
|
+
- 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.
|
8
|
+
|
5
9
|
## [6.1.0] - 2021-02-04
|
6
10
|
### Added:
|
7
11
|
- 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
|
-
|
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
|
-
|
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
|
-
|
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
|
-
|
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)
|
@@ -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
|
-
|
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]
|
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
|
-
|
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
|
data/lib/ldclient-rb/version.rb
CHANGED
@@ -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
|
-
|
62
|
-
|
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
|
-
|
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:
|
79
|
-
{ variation:
|
80
|
-
{ variation:
|
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,
|
87
|
-
expect(result_variation).to be
|
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
|
data/spec/impl/evaluator_spec.rb
CHANGED
@@ -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
|
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.
|
4
|
+
version: 6.2.0
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- LaunchDarkly
|
8
8
|
autorequire:
|
9
9
|
bindir: bin
|
10
10
|
cert_chain: []
|
11
|
-
date: 2021-
|
11
|
+
date: 2021-06-17 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
|