Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 27 additions & 19 deletions lib/flagsmith.rb
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,6 @@ def initialize(config)
api_client
analytics_processor
environment_data_polling_manager
engine
load_offline_handler
end

Expand Down Expand Up @@ -99,10 +98,6 @@ def realtime_client
@realtime_client ||= Flagsmith::RealtimeClient.new(@config)
end

def engine
@engine ||= Flagsmith::Engine::Engine.new
end

def analytics_processor
return nil unless @config.enable_analytics?

Expand Down Expand Up @@ -211,21 +206,33 @@ def get_value_for_identity(feature_name, user_id = nil, default: nil)
end

def get_identity_segments(identifier, traits = {})
unless environment
raise Flagsmith::ClientError,
'Local evaluation or offline handler is required to obtain identity segments.'
end
raise Flagsmith::ClientError, 'Local evaluation or offline handler is required to obtain identity segments.' unless environment

identity_model = get_identity_model(identifier, traits)
segment_models = engine.get_identity_segments(environment, identity_model)
segment_models.map { |sm| Flagsmith::Segments::Segment.new(id: sm.id, name: sm.name) }.compact
context = Flagsmith::Engine::Mappers.get_evaluation_context(environment, identity_model)
raise Flagsmith::ClientError, 'Local evaluation required to obtain identity segments' unless context

evaluation_result = Flagsmith::Engine.get_evaluation_result(context)
evaluation_result[:segments].filter_map do |segment_result|
id = segment_result.dig(:metadata, :id)
Flagsmith::Segments::Segment.new(id: id, name: segment_result[:name]) if id
end
end

private

def environment_flags_from_document
Flagsmith::Flags::Collection.from_feature_state_models(
engine.get_environment_feature_states(environment),
def environment_flags_from_document # rubocop:disable Metrics/MethodLength
context = Flagsmith::Engine::Mappers.get_evaluation_context(environment)

unless context
raise Flagsmith::ClientError,
'Unable to get flags. No environment present.'
end

evaluation_result = Flagsmith::Engine.get_evaluation_result(context)

Flagsmith::Flags::Collection.from_evaluation_result(
evaluation_result,
analytics_processor: analytics_processor,
default_flag_handler: default_flag_handler,
offline_handler: offline_handler
Expand All @@ -234,12 +241,13 @@ def environment_flags_from_document

def get_identity_flags_from_document(identifier, traits = {})
identity_model = get_identity_model(identifier, traits)
context = Flagsmith::Engine::Mappers.get_evaluation_context(environment, identity_model)
raise Flagsmith::ClientError, 'Unable to get flags. No environment present.' unless context

Flagsmith::Flags::Collection.from_feature_state_models(
engine.get_identity_feature_states(environment, identity_model),
identity_id: identity_model.composite_key,
analytics_processor: analytics_processor,
default_flag_handler: default_flag_handler,
evaluation_result = Flagsmith::Engine.get_evaluation_result(context)
Flagsmith::Flags::Collection.from_evaluation_result(
evaluation_result,
analytics_processor: analytics_processor, default_flag_handler: default_flag_handler,
offline_handler: offline_handler
)
end
Expand Down
155 changes: 106 additions & 49 deletions lib/flagsmith/engine/core.rb
Original file line number Diff line number Diff line change
Expand Up @@ -5,86 +5,143 @@

require_relative 'environments/models'
require_relative 'features/models'
require_relative 'features/constants'
require_relative 'identities/models'
require_relative 'organisations/models'
require_relative 'projects/models'
require_relative 'segments/evaluator'
require_relative 'segments/models'
require_relative 'utils/hash_func'
require_relative 'mappers'
require_relative 'evaluation/core'

module Flagsmith
# Core evaluation logic for feature flags
module Engine
# Flags engine methods
class Engine
include Flagsmith::Engine::Segments::Evaluator

def get_identity_feature_state(environment, identity, feature_name, override_traits = nil)
feature_states = get_identity_feature_states_dict(environment, identity, override_traits).values
extend self
include Flagsmith::Engine::Utils::HashFunc
include Flagsmith::Engine::Features::TargetingReasons
include Flagsmith::Engine::Segments::Evaluator

# Get evaluation result from evaluation context
#
# @param evaluation_context [Hash] The evaluation context
# @return [Hash] Evaluation result with flags and segments
def get_evaluation_result(evaluation_context)
evaluation_context = get_enriched_context(evaluation_context)
segments, segment_overrides = evaluate_segments(evaluation_context)
flags = evaluate_features(evaluation_context, segment_overrides)
{
flags: flags,
segments: segments
}
end

feature_state = feature_states.find { |f| f.feature.name == feature_name }
# Returns { segments: EvaluationResultSegments; segmentOverrides: Record<string, SegmentOverride>; }
def evaluate_segments(evaluation_context)
return [], {} if evaluation_context[:segments].nil?

raise Flagsmith::FeatureStateNotFound, 'Feature State Not Found' if feature_state.nil?
identity_segments = get_segments_from_context(evaluation_context)

feature_state
segments = identity_segments.map do |segment|
{ name: segment[:name], metadata: segment[:metadata] }.compact
end

def get_identity_feature_states(environment, identity, override_traits = nil)
feature_states = get_identity_feature_states_dict(environment, identity, override_traits).values
segment_overrides = process_segment_overrides(identity_segments)

[segments, segment_overrides]
end

return feature_states.select(&:enabled?) if environment.project.hide_disabled_flags
# Returns Record<string: override.name, SegmentOverride>
def process_segment_overrides(identity_segments) # rubocop:disable Metrics/MethodLength
segment_overrides = {}

feature_states
identity_segments.each do |segment|
Array(segment[:overrides]).each do |override|
next unless should_apply_override(override, segment_overrides)

segment_overrides[override[:name]] = {
feature: override,
segment_name: segment[:name]
}
end
end

def get_environment_feature_state(environment, feature_name)
features_state = environment.feature_states.find { |f| f.feature.name == feature_name }
segment_overrides
end

raise Flagsmith::FeatureStateNotFound, 'Feature State Not Found' if features_state.nil?
def evaluate_features(evaluation_context, segment_overrides)
identity_key = get_identity_key(evaluation_context)

features_state
(evaluation_context[:features] || {}).each_with_object({}) do |(_, feature), flags|
segment_override = segment_overrides[feature[:name]]
final_feature = segment_override ? segment_override[:feature] : feature

flag_result = build_flag_result(final_feature, identity_key, segment_override)
flags[final_feature[:name].to_sym] = flag_result
end
end

def get_environment_feature_states(environment)
return environment.feature_states.select(&:enabled?) if environment.project.hide_disabled_flags
# Returns {value: any; reason?: string}
def evaluate_feature_value(feature, identity_key = nil)
return get_multivariate_feature_value(feature, identity_key) if feature[:variants]&.any? && identity_key

environment.feature_states
end
{ value: feature[:value], reason: nil }
end

private
# Returns {value: any; reason?: string}
def get_multivariate_feature_value(feature, identity_key)
percentage_value = hashed_percentage_for_object_ids([feature[:key], identity_key])
sorted_variants = (feature[:variants] || []).sort_by { |v| v[:priority] || WEAKEST_PRIORITY }

def get_identity_feature_states_dict(environment, identity, override_traits = nil)
# Get feature states from the environment
feature_states = {}
override = ->(fs) { feature_states[fs.feature.id] = fs }
environment.feature_states.each(&override)
variant = find_matching_variant(sorted_variants, percentage_value)
variant || { value: feature[:value], reason: nil }
end

override_by_matching_segments(environment, identity, override_traits) do |fs|
override.call(fs) unless higher_segment_priority?(feature_states, fs)
end
def find_matching_variant(sorted_variants, percentage_value)
start_percentage = 0
sorted_variants.each do |variant|
limit = start_percentage + variant[:weight]
return { value: variant[:value], reason: "#{TARGETING_REASON_SPLIT}; weight=#{variant[:weight]}" } if start_percentage <= percentage_value && percentage_value < limit

# Override with any feature states defined directly the identity
identity.identity_features.each(&override)
feature_states
start_percentage = limit
end
nil
end

# Override with any feature states defined by matching segments
def override_by_matching_segments(environment, identity, override_traits)
identity_segments = get_identity_segments(environment, identity, override_traits)
identity_segments.each do |matching_segment|
matching_segment.feature_states.each do |feature_state|
yield feature_state if block_given?
end
end
end
# returns boolean
def should_apply_override(override, existing_overrides)
current_override = existing_overrides[override[:name]]
!current_override || stronger_priority?(override[:priority], current_override[:feature][:priority])
end

def higher_segment_priority?(collection, feature_state)
collection.key?(feature_state.feature.id) &&
collection[feature_state.feature.id].higher_segment_priority?(
feature_state
)
end
private

def build_flag_result(feature, identity_key, segment_override)
evaluated = evaluate_feature_value(feature, identity_key)

flag_result = {
name: feature[:name],
enabled: feature[:enabled],
value: evaluated[:value],
reason: evaluated[:reason] || (segment_override ? "#{TARGETING_REASON_TARGETING_MATCH}; segment=#{segment_override[:segment_name]}" : TARGETING_REASON_DEFAULT)
}

flag_result[:metadata] = feature[:metadata] if feature[:metadata]
flag_result
end

# Extract identity key from evaluation context
#
# @param evaluation_context [Hash] The evaluation context
# @return [String, nil] The identity key or nil if no identity
def get_identity_key(evaluation_context)
return nil unless evaluation_context[:identity]

evaluation_context[:identity][:key]
end

def stronger_priority?(priority_a, priority_b)
(priority_a || WEAKEST_PRIORITY) < (priority_b || WEAKEST_PRIORITY)
end
end
end
2 changes: 1 addition & 1 deletion lib/flagsmith/engine/features/models.rb
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ def multivariate_value(identity_id)
# but `self` does.
# 2. `other` have a feature segment with high priority
def higher_segment_priority?(other)
feature_segment.priority.to_i < (other&.feature_segment&.priority || Float::INFINITY)
feature_segment.priority.to_i < (other&.feature_segment&.priority || WEAKEST_PRIORITY)
rescue TypeError, NoMethodError
false
end
Expand Down
2 changes: 1 addition & 1 deletion lib/flagsmith/engine/mappers/environment.rb
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ def self.build_feature_hash(feature_state)
name: feature_state.feature.name,
enabled: feature_state.enabled,
value: feature_state.get_value,
metadata: { flagsmith_id: feature_state.feature.id }
metadata: { id: feature_state.feature.id }
}
add_variants_to_feature(feature_hash, feature_state)
add_priority_to_feature(feature_hash, feature_state)
Expand Down
2 changes: 1 addition & 1 deletion lib/flagsmith/engine/mappers/identity.rb
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ def self.build_overrides_key(identity_features)
enabled: feature_state.enabled,
value: feature_state.get_value,
priority: Mappers::STRONGEST_PRIORITY,
metadata: { flagsmith_id: feature_state.feature.id }
metadata: { id: feature_state.feature.id }
}
end
end
Expand Down
4 changes: 2 additions & 2 deletions lib/flagsmith/engine/mappers/segments.rb
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ def self.build_segment_hash(segment)
overrides: build_overrides(segment.feature_states),
metadata: {
source: 'API',
flagsmith_id: segment.id
id: segment.id
}
}
end
Expand All @@ -33,7 +33,7 @@ def self.build_overrides(feature_states) # rubocop:disable Metrics/MethodLength
name: feature_state.feature.name,
enabled: feature_state.enabled,
value: feature_state.get_value,
metadata: { flagsmith_id: feature_state.feature.id }
metadata: { id: feature_state.feature.id }
}
add_priority_to_override(override_hash, feature_state)
override_hash
Expand Down
47 changes: 16 additions & 31 deletions lib/flagsmith/engine/segments/evaluator.rb
Original file line number Diff line number Diff line change
Expand Up @@ -14,29 +14,6 @@ module Evaluator # rubocop:disable Metrics/ModuleLength
include Flagsmith::Engine::Segments::Constants
include Flagsmith::Engine::Utils::HashFunc

# Model-based segment evaluation
def get_identity_segments(environment, identity, override_traits = nil)
environment.project.segments.select do |s|
evaluate_identity_in_segment(identity, s, override_traits)
end
end

def traits_match_segment_condition(identity_traits, condition, segment_id, identity_id)
if condition.operator == PERCENTAGE_SPLIT
return hashed_percentage_for_object_ids([segment_id,
identity_id]) <= condition.value.to_f
end

trait = identity_traits.find { |t| t.key.to_s == condition.property }

return handle_trait_existence_conditions(trait, condition.operator) if [IS_SET,
IS_NOT_SET].include?(condition.operator)

return condition.match_trait_value?(trait.trait_value) if trait

false
end

module_function

def get_enriched_context(context)
Expand Down Expand Up @@ -67,6 +44,22 @@ def get_segments_from_context(context)
end
end

def traits_match_segment_condition(identity_traits, condition, segment_id, identity_id)
if condition.operator == PERCENTAGE_SPLIT
return hashed_percentage_for_object_ids([segment_id,
identity_id]) <= condition.value.to_f
end

trait = identity_traits.find { |t| t.key.to_s == condition.property }

return handle_trait_existence_conditions(trait, condition.operator) if [IS_SET,
IS_NOT_SET].include?(condition.operator)

return condition.match_trait_value?(trait.trait_value) if trait

false
end

# Evaluates whether a given identity is in the provided segment.
#
# :param identity: identity model object to evaluate
Expand Down Expand Up @@ -248,14 +241,6 @@ def primitive?(value)

!(value.is_a?(Hash) || value.is_a?(Array))
end

private

def handle_trait_existence_conditions(matching_trait, operator)
return operator == IS_NOT_SET if matching_trait.nil?

operator == IS_SET
end
end
end
end
Expand Down
Loading