Skip to content

Commit

Permalink
Merge pull request #78 from launchdarkly/eb/ch19976/explanations
Browse files Browse the repository at this point in the history
implement evaluation with explanations
  • Loading branch information
eli-darkly authored Aug 29, 2018
2 parents 50b3aa5 + 02b5712 commit 53e8408
Show file tree
Hide file tree
Showing 6 changed files with 568 additions and 241 deletions.
196 changes: 122 additions & 74 deletions lib/ldclient-rb/evaluation.rb
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,37 @@
require "semantic"

module LaunchDarkly
# An object returned by `LDClient.variation_detail`, combining the result of a flag evaluation with
# an explanation of how it was calculated.
class EvaluationDetail
def initialize(value, variation_index, reason)
@value = value
@variation_index = variation_index
@reason = reason
end

# @return [Object] The result of the flag evaluation. This will be either one of the flag's
# variations or the default value that was passed to the `variation` method.
attr_reader :value

# @return [int|nil] The index of the returned value within the flag's list of variations, e.g.
# 0 for the first variation - or `nil` if the default value was returned.
attr_reader :variation_index

# @return [Hash] An object describing the main factor that influenced the flag evaluation value.
attr_reader :reason

# @return [boolean] True if the flag evaluated to the default value rather than to one of its
# variations.
def default_value?
variation_index.nil?
end

def ==(other)
@value == other.value && @variation_index == other.variation_index && @reason == other.reason
end
end

module Evaluation
BUILTINS = [:key, :ip, :country, :email, :firstName, :lastName, :avatar, :name, :anonymous]

Expand Down Expand Up @@ -107,113 +138,105 @@ def self.comparator(converter)
end
}

class EvaluationError < StandardError
# Used internally to hold an evaluation result and the events that were generated from prerequisites.
EvalResult = Struct.new(:detail, :events)

def error_result(errorKind, value = nil)
EvaluationDetail.new(value, nil, { kind: 'ERROR', errorKind: errorKind })
end

# Evaluates a feature flag, returning a hash containing the evaluation result and any events
# generated during prerequisite evaluation. Raises EvaluationError if the flag is not well-formed
# Will return nil, but not raise an exception, indicating that the rules (including fallthrough) did not match
# In that case, the caller should return the default value.
# Evaluates a feature flag and returns an EvalResult. The result.value will be nil if the flag returns
# the default value. Error conditions produce a result with an error reason, not an exception.
def evaluate(flag, user, store, logger)
if flag.nil?
raise EvaluationError, "Flag does not exist"
end

if user.nil? || user[:key].nil?
raise EvaluationError, "Invalid user"
return EvalResult.new(error_result('USER_NOT_SPECIFIED'), [])
end

events = []

if flag[:on]
res = eval_internal(flag, user, store, events, logger)
if !res.nil?
res[:events] = events
return res
detail = eval_internal(flag, user, store, events, logger)
return EvalResult.new(detail, events)
end

return EvalResult.new(get_off_value(flag, { kind: 'OFF' }, logger), events)
end


def eval_internal(flag, user, store, events, logger)
prereq_failure_reason = check_prerequisites(flag, user, store, events, logger)
if !prereq_failure_reason.nil?
return get_off_value(flag, prereq_failure_reason, logger)
end

# Check user target matches
(flag[:targets] || []).each do |target|
(target[:values] || []).each do |value|
if value == user[:key]
return get_variation(flag, target[:variation], { kind: 'TARGET_MATCH' }, logger)
end
end
end

# Check custom rules
rules = flag[:rules] || []
rules.each_index do |i|
rule = rules[i]
if rule_match_user(rule, user, store)
return get_value_for_variation_or_rollout(flag, rule, user,
{ kind: 'RULE_MATCH', ruleIndex: i, ruleId: rule[:id] }, logger)
end
end

offVariation = flag[:offVariation]
if !offVariation.nil? && offVariation < flag[:variations].length
value = flag[:variations][offVariation]
return { variation: offVariation, value: value, events: events }
# Check the fallthrough rule
if !flag[:fallthrough].nil?
return get_value_for_variation_or_rollout(flag, flag[:fallthrough], user,
{ kind: 'FALLTHROUGH' }, logger)
end

{ variation: nil, value: nil, events: events }
return EvaluationDetail.new(nil, nil, { kind: 'FALLTHROUGH' })
end

def eval_internal(flag, user, store, events, logger)
failed_prereq = false
# Evaluate prerequisites, if any
def check_prerequisites(flag, user, store, events, logger)
(flag[:prerequisites] || []).each do |prerequisite|
prereq_flag = store.get(FEATURES, prerequisite[:key])
prereq_ok = true
prereq_key = prerequisite[:key]
prereq_flag = store.get(FEATURES, prereq_key)

if prereq_flag.nil? || !prereq_flag[:on]
failed_prereq = true
logger.error { "[LDClient] Could not retrieve prerequisite flag \"#{prereq_key}\" when evaluating \"#{flag[:key]}\"" }
prereq_ok = false
elsif !prereq_flag[:on]
prereq_ok = false
else
begin
prereq_res = eval_internal(prereq_flag, user, store, events, logger)
event = {
kind: "feature",
key: prereq_flag[:key],
variation: prereq_res.nil? ? nil : prereq_res[:variation],
value: prereq_res.nil? ? nil : prereq_res[:value],
key: prereq_key,
variation: prereq_res.variation_index,
value: prereq_res.value,
version: prereq_flag[:version],
prereqOf: flag[:key],
trackEvents: prereq_flag[:trackEvents],
debugEventsUntilDate: prereq_flag[:debugEventsUntilDate]
}
events.push(event)
if prereq_res.nil? || prereq_res[:variation] != prerequisite[:variation]
failed_prereq = true
if prereq_res.variation_index != prerequisite[:variation]
prereq_ok = false
end
rescue => exn
logger.error { "[LDClient] Error evaluating prerequisite: #{exn.inspect}" }
failed_prereq = true
Util.log_exception(logger, "Error evaluating prerequisite flag \"#{prereq_key}\" for flag \"{flag[:key]}\"", exn)
prereq_ok = false
end
end
end

if failed_prereq
return nil
end
# The prerequisites were satisfied.
# Now walk through the evaluation steps and get the correct
# variation index
eval_rules(flag, user, store)
end

def eval_rules(flag, user, store)
# Check user target matches
(flag[:targets] || []).each do |target|
(target[:values] || []).each do |value|
if value == user[:key]
return { variation: target[:variation], value: get_variation(flag, target[:variation]) }
end
if !prereq_ok
return { kind: 'PREREQUISITE_FAILED', prerequisiteKey: prereq_key }
end
end

# Check custom rules
(flag[:rules] || []).each do |rule|
return variation_for_user(rule, user, flag) if rule_match_user(rule, user, store)
end

# Check the fallthrough rule
if !flag[:fallthrough].nil?
return variation_for_user(flag[:fallthrough], user, flag)
end

# Not even the fallthrough matched-- return the off variation or default
nil
end

def get_variation(flag, index)
if index >= flag[:variations].length
raise EvaluationError, "Invalid variation index"
end
flag[:variations][index]
end

def rule_match_user(rule, user, store)
return false if !rule[:clauses]

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

op = OPERATORS[clause[:op].to_sym]

if op.nil?
raise EvaluationError, "Unsupported operator #{clause[:op]} in evaluation"
return false
end

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

def variation_for_user(rule, user, flag)
def variation_index_for_user(flag, rule, user)
if !rule[:variation].nil? # fixed variation
return { variation: rule[:variation], value: get_variation(flag, rule[:variation]) }
return rule[:variation]
elsif !rule[:rollout].nil? # percentage rollout
rollout = rule[:rollout]
bucket_by = rollout[:bucketBy].nil? ? "key" : rollout[:bucketBy]
Expand All @@ -268,12 +290,12 @@ def variation_for_user(rule, user, flag)
rollout[:variations].each do |variate|
sum += variate[:weight].to_f / 100000.0
if bucket < sum
return { variation: variate[:variation], value: get_variation(flag, variate[:variation]) }
return variate[:variation]
end
end
nil
else # the rule isn't well-formed
raise EvaluationError, "Rule does not define a variation or rollout"
nil
end
end

Expand Down Expand Up @@ -350,5 +372,31 @@ def match_any(op, value, values)
end
return false
end

private

def get_variation(flag, index, reason, logger)
if index < 0 || index >= flag[:variations].length
logger.error("[LDClient] Data inconsistency in feature flag \"#{flag[:key]}\": invalid variation index")
return error_result('MALFORMED_FLAG')
end
EvaluationDetail.new(flag[:variations][index], index, reason)
end

def get_off_value(flag, reason, logger)
if flag[:offVariation].nil? # off variation unspecified - return default value
return EvaluationDetail.new(nil, nil, reason)
end
get_variation(flag, flag[:offVariation], reason, logger)
end

def get_value_for_variation_or_rollout(flag, vr, user, reason, logger)
index = variation_index_for_user(flag, vr, user)
if index.nil?
logger.error("[LDClient] Data inconsistency in feature flag \"#{flag[:key]}\": variation/rollout object with no variation or rollout")
return error_result('MALFORMED_FLAG')
end
return get_variation(flag, index, reason, logger)
end
end
end
1 change: 1 addition & 0 deletions lib/ldclient-rb/events.rb
Original file line number Diff line number Diff line change
Expand Up @@ -363,6 +363,7 @@ def make_output_event(event)
else
out[:userKey] = event[:user].nil? ? nil : event[:user][:key]
end
out[:reason] = event[:reason] if !event[:reason].nil?
out
when "identify"
{
Expand Down
3 changes: 2 additions & 1 deletion lib/ldclient-rb/flags_state.rb
Original file line number Diff line number Diff line change
Expand Up @@ -15,12 +15,13 @@ def initialize(valid)
end

# Used internally to build the state map.
def add_flag(flag, value, variation)
def add_flag(flag, value, variation, reason = nil)
key = flag[:key]
@flag_values[key] = value
meta = { version: flag[:version], trackEvents: flag[:trackEvents] }
meta[:variation] = variation if !variation.nil?
meta[:debugEventsUntilDate] = flag[:debugEventsUntilDate] if flag[:debugEventsUntilDate]
meta[:reason] = reason if !reason.nil?
@flag_metadata[key] = meta
end

Expand Down
Loading

0 comments on commit 53e8408

Please sign in to comment.