launchdarkly-server-sdk 6.1.1 → 6.2.3
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.
- checksums.yaml +4 -4
- data/.circleci/config.yml +1 -1
- data/CHANGELOG.md +16 -0
- data/lib/ldclient-rb/evaluation_detail.rb +38 -7
- data/lib/ldclient-rb/impl/diagnostic_events.rb +1 -1
- 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/ldclient.rb +6 -0
- data/lib/ldclient-rb/requestor.rb +1 -1
- data/lib/ldclient-rb/version.rb +1 -1
- data/spec/diagnostic_events_spec.rb +9 -7
- 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
- data/spec/ldclient_spec.rb +3 -11
- data/spec/requestor_spec.rb +13 -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: 3c4646e511419b3e7883147b5040b136c84e333286518eca3171105c93304453
|
4
|
+
data.tar.gz: b1d3f4fe3ee44591ec9f642abc8787ae5234bb72f794fce6f8acb46a53cd62b5
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 3657e9b125c86998638479696fb97b1729ff62e0aa3538b9d9a74f446cc3816bd797a43279c3c2e7a2fa13afb28092de80720384f125e194ee6388a0d10c5c7f
|
7
|
+
data.tar.gz: 001ce752eb3c8cc559d21ea5ca968c257a689345341324825fb6e76860062980406d8c5ee8476e2782c67bff29401d0b599ad40a896dc61ff79c719321c93365
|
data/.circleci/config.yml
CHANGED
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
|
-
|
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)
|
@@ -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
|
-
|
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/ldclient.rb
CHANGED
@@ -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
|
data/lib/ldclient-rb/version.rb
CHANGED
@@ -79,13 +79,15 @@ module LaunchDarkly
|
|
79
79
|
end
|
80
80
|
end
|
81
81
|
|
82
|
-
|
83
|
-
|
84
|
-
|
85
|
-
|
86
|
-
|
87
|
-
|
88
|
-
|
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
|
-
|
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
|
data/spec/ldclient_spec.rb
CHANGED
@@ -171,20 +171,12 @@ describe LaunchDarkly::LDClient do
|
|
171
171
|
client.variation("key", user_anonymous, "default")
|
172
172
|
end
|
173
173
|
|
174
|
-
it "
|
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).
|
179
|
-
|
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
|
|
data/spec/requestor_spec.rb
CHANGED
@@ -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.
|
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-
|
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
|