Skip to content

Commit 53e8408

Browse files
authored
Merge pull request #78 from launchdarkly/eb/ch19976/explanations
implement evaluation with explanations
2 parents 50b3aa5 + 02b5712 commit 53e8408

File tree

6 files changed

+568
-241
lines changed

6 files changed

+568
-241
lines changed

lib/ldclient-rb/evaluation.rb

Lines changed: 122 additions & 74 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,37 @@
22
require "semantic"
33

44
module LaunchDarkly
5+
# An object returned by `LDClient.variation_detail`, combining the result of a flag evaluation with
6+
# an explanation of how it was calculated.
7+
class EvaluationDetail
8+
def initialize(value, variation_index, reason)
9+
@value = value
10+
@variation_index = variation_index
11+
@reason = reason
12+
end
13+
14+
# @return [Object] The result of the flag evaluation. This will be either one of the flag's
15+
# variations or the default value that was passed to the `variation` method.
16+
attr_reader :value
17+
18+
# @return [int|nil] The index of the returned value within the flag's list of variations, e.g.
19+
# 0 for the first variation - or `nil` if the default value was returned.
20+
attr_reader :variation_index
21+
22+
# @return [Hash] An object describing the main factor that influenced the flag evaluation value.
23+
attr_reader :reason
24+
25+
# @return [boolean] True if the flag evaluated to the default value rather than to one of its
26+
# variations.
27+
def default_value?
28+
variation_index.nil?
29+
end
30+
31+
def ==(other)
32+
@value == other.value && @variation_index == other.variation_index && @reason == other.reason
33+
end
34+
end
35+
536
module Evaluation
637
BUILTINS = [:key, :ip, :country, :email, :firstName, :lastName, :avatar, :name, :anonymous]
738

@@ -107,113 +138,105 @@ def self.comparator(converter)
107138
end
108139
}
109140

110-
class EvaluationError < StandardError
141+
# Used internally to hold an evaluation result and the events that were generated from prerequisites.
142+
EvalResult = Struct.new(:detail, :events)
143+
144+
def error_result(errorKind, value = nil)
145+
EvaluationDetail.new(value, nil, { kind: 'ERROR', errorKind: errorKind })
111146
end
112147

113-
# Evaluates a feature flag, returning a hash containing the evaluation result and any events
114-
# generated during prerequisite evaluation. Raises EvaluationError if the flag is not well-formed
115-
# Will return nil, but not raise an exception, indicating that the rules (including fallthrough) did not match
116-
# In that case, the caller should return the default value.
148+
# Evaluates a feature flag and returns an EvalResult. The result.value will be nil if the flag returns
149+
# the default value. Error conditions produce a result with an error reason, not an exception.
117150
def evaluate(flag, user, store, logger)
118-
if flag.nil?
119-
raise EvaluationError, "Flag does not exist"
120-
end
121-
122151
if user.nil? || user[:key].nil?
123-
raise EvaluationError, "Invalid user"
152+
return EvalResult.new(error_result('USER_NOT_SPECIFIED'), [])
124153
end
125154

126155
events = []
127156

128157
if flag[:on]
129-
res = eval_internal(flag, user, store, events, logger)
130-
if !res.nil?
131-
res[:events] = events
132-
return res
158+
detail = eval_internal(flag, user, store, events, logger)
159+
return EvalResult.new(detail, events)
160+
end
161+
162+
return EvalResult.new(get_off_value(flag, { kind: 'OFF' }, logger), events)
163+
end
164+
165+
166+
def eval_internal(flag, user, store, events, logger)
167+
prereq_failure_reason = check_prerequisites(flag, user, store, events, logger)
168+
if !prereq_failure_reason.nil?
169+
return get_off_value(flag, prereq_failure_reason, logger)
170+
end
171+
172+
# Check user target matches
173+
(flag[:targets] || []).each do |target|
174+
(target[:values] || []).each do |value|
175+
if value == user[:key]
176+
return get_variation(flag, target[:variation], { kind: 'TARGET_MATCH' }, logger)
177+
end
178+
end
179+
end
180+
181+
# Check custom rules
182+
rules = flag[:rules] || []
183+
rules.each_index do |i|
184+
rule = rules[i]
185+
if rule_match_user(rule, user, store)
186+
return get_value_for_variation_or_rollout(flag, rule, user,
187+
{ kind: 'RULE_MATCH', ruleIndex: i, ruleId: rule[:id] }, logger)
133188
end
134189
end
135190

136-
offVariation = flag[:offVariation]
137-
if !offVariation.nil? && offVariation < flag[:variations].length
138-
value = flag[:variations][offVariation]
139-
return { variation: offVariation, value: value, events: events }
191+
# Check the fallthrough rule
192+
if !flag[:fallthrough].nil?
193+
return get_value_for_variation_or_rollout(flag, flag[:fallthrough], user,
194+
{ kind: 'FALLTHROUGH' }, logger)
140195
end
141196

142-
{ variation: nil, value: nil, events: events }
197+
return EvaluationDetail.new(nil, nil, { kind: 'FALLTHROUGH' })
143198
end
144199

145-
def eval_internal(flag, user, store, events, logger)
146-
failed_prereq = false
147-
# Evaluate prerequisites, if any
200+
def check_prerequisites(flag, user, store, events, logger)
148201
(flag[:prerequisites] || []).each do |prerequisite|
149-
prereq_flag = store.get(FEATURES, prerequisite[:key])
202+
prereq_ok = true
203+
prereq_key = prerequisite[:key]
204+
prereq_flag = store.get(FEATURES, prereq_key)
150205

151206
if prereq_flag.nil? || !prereq_flag[:on]
152-
failed_prereq = true
207+
logger.error { "[LDClient] Could not retrieve prerequisite flag \"#{prereq_key}\" when evaluating \"#{flag[:key]}\"" }
208+
prereq_ok = false
209+
elsif !prereq_flag[:on]
210+
prereq_ok = false
153211
else
154212
begin
155213
prereq_res = eval_internal(prereq_flag, user, store, events, logger)
156214
event = {
157215
kind: "feature",
158-
key: prereq_flag[:key],
159-
variation: prereq_res.nil? ? nil : prereq_res[:variation],
160-
value: prereq_res.nil? ? nil : prereq_res[:value],
216+
key: prereq_key,
217+
variation: prereq_res.variation_index,
218+
value: prereq_res.value,
161219
version: prereq_flag[:version],
162220
prereqOf: flag[:key],
163221
trackEvents: prereq_flag[:trackEvents],
164222
debugEventsUntilDate: prereq_flag[:debugEventsUntilDate]
165223
}
166224
events.push(event)
167-
if prereq_res.nil? || prereq_res[:variation] != prerequisite[:variation]
168-
failed_prereq = true
225+
if prereq_res.variation_index != prerequisite[:variation]
226+
prereq_ok = false
169227
end
170228
rescue => exn
171-
logger.error { "[LDClient] Error evaluating prerequisite: #{exn.inspect}" }
172-
failed_prereq = true
229+
Util.log_exception(logger, "Error evaluating prerequisite flag \"#{prereq_key}\" for flag \"{flag[:key]}\"", exn)
230+
prereq_ok = false
173231
end
174232
end
175-
end
176-
177-
if failed_prereq
178-
return nil
179-
end
180-
# The prerequisites were satisfied.
181-
# Now walk through the evaluation steps and get the correct
182-
# variation index
183-
eval_rules(flag, user, store)
184-
end
185-
186-
def eval_rules(flag, user, store)
187-
# Check user target matches
188-
(flag[:targets] || []).each do |target|
189-
(target[:values] || []).each do |value|
190-
if value == user[:key]
191-
return { variation: target[:variation], value: get_variation(flag, target[:variation]) }
192-
end
233+
if !prereq_ok
234+
return { kind: 'PREREQUISITE_FAILED', prerequisiteKey: prereq_key }
193235
end
194236
end
195-
196-
# Check custom rules
197-
(flag[:rules] || []).each do |rule|
198-
return variation_for_user(rule, user, flag) if rule_match_user(rule, user, store)
199-
end
200-
201-
# Check the fallthrough rule
202-
if !flag[:fallthrough].nil?
203-
return variation_for_user(flag[:fallthrough], user, flag)
204-
end
205-
206-
# Not even the fallthrough matched-- return the off variation or default
207237
nil
208238
end
209239

210-
def get_variation(flag, index)
211-
if index >= flag[:variations].length
212-
raise EvaluationError, "Invalid variation index"
213-
end
214-
flag[:variations][index]
215-
end
216-
217240
def rule_match_user(rule, user, store)
218241
return false if !rule[:clauses]
219242

@@ -242,9 +265,8 @@ def clause_match_user_no_segments(clause, user)
242265
return false if val.nil?
243266

244267
op = OPERATORS[clause[:op].to_sym]
245-
246268
if op.nil?
247-
raise EvaluationError, "Unsupported operator #{clause[:op]} in evaluation"
269+
return false
248270
end
249271

250272
if val.is_a? Enumerable
@@ -257,9 +279,9 @@ def clause_match_user_no_segments(clause, user)
257279
maybe_negate(clause, match_any(op, val, clause[:values]))
258280
end
259281

260-
def variation_for_user(rule, user, flag)
282+
def variation_index_for_user(flag, rule, user)
261283
if !rule[:variation].nil? # fixed variation
262-
return { variation: rule[:variation], value: get_variation(flag, rule[:variation]) }
284+
return rule[:variation]
263285
elsif !rule[:rollout].nil? # percentage rollout
264286
rollout = rule[:rollout]
265287
bucket_by = rollout[:bucketBy].nil? ? "key" : rollout[:bucketBy]
@@ -268,12 +290,12 @@ def variation_for_user(rule, user, flag)
268290
rollout[:variations].each do |variate|
269291
sum += variate[:weight].to_f / 100000.0
270292
if bucket < sum
271-
return { variation: variate[:variation], value: get_variation(flag, variate[:variation]) }
293+
return variate[:variation]
272294
end
273295
end
274296
nil
275297
else # the rule isn't well-formed
276-
raise EvaluationError, "Rule does not define a variation or rollout"
298+
nil
277299
end
278300
end
279301

@@ -350,5 +372,31 @@ def match_any(op, value, values)
350372
end
351373
return false
352374
end
375+
376+
private
377+
378+
def get_variation(flag, index, reason, logger)
379+
if index < 0 || index >= flag[:variations].length
380+
logger.error("[LDClient] Data inconsistency in feature flag \"#{flag[:key]}\": invalid variation index")
381+
return error_result('MALFORMED_FLAG')
382+
end
383+
EvaluationDetail.new(flag[:variations][index], index, reason)
384+
end
385+
386+
def get_off_value(flag, reason, logger)
387+
if flag[:offVariation].nil? # off variation unspecified - return default value
388+
return EvaluationDetail.new(nil, nil, reason)
389+
end
390+
get_variation(flag, flag[:offVariation], reason, logger)
391+
end
392+
393+
def get_value_for_variation_or_rollout(flag, vr, user, reason, logger)
394+
index = variation_index_for_user(flag, vr, user)
395+
if index.nil?
396+
logger.error("[LDClient] Data inconsistency in feature flag \"#{flag[:key]}\": variation/rollout object with no variation or rollout")
397+
return error_result('MALFORMED_FLAG')
398+
end
399+
return get_variation(flag, index, reason, logger)
400+
end
353401
end
354402
end

lib/ldclient-rb/events.rb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -363,6 +363,7 @@ def make_output_event(event)
363363
else
364364
out[:userKey] = event[:user].nil? ? nil : event[:user][:key]
365365
end
366+
out[:reason] = event[:reason] if !event[:reason].nil?
366367
out
367368
when "identify"
368369
{

lib/ldclient-rb/flags_state.rb

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,12 +15,13 @@ def initialize(valid)
1515
end
1616

1717
# Used internally to build the state map.
18-
def add_flag(flag, value, variation)
18+
def add_flag(flag, value, variation, reason = nil)
1919
key = flag[:key]
2020
@flag_values[key] = value
2121
meta = { version: flag[:version], trackEvents: flag[:trackEvents] }
2222
meta[:variation] = variation if !variation.nil?
2323
meta[:debugEventsUntilDate] = flag[:debugEventsUntilDate] if flag[:debugEventsUntilDate]
24+
meta[:reason] = reason if !reason.nil?
2425
@flag_metadata[key] = meta
2526
end
2627

0 commit comments

Comments
 (0)