Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: local evaluation v2 #67

Merged
merged 8 commits into from
Jul 29, 2024
Merged
Show file tree
Hide file tree
Changes from 6 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
3 changes: 3 additions & 0 deletions .rubocop.yml
Original file line number Diff line number Diff line change
Expand Up @@ -42,3 +42,6 @@ Metrics/ClassLength:

Metrics/ModuleLength:
Enabled: false

Style/Documentation:
Enabled: false
3 changes: 3 additions & 0 deletions lib/amplitude-experiment.rb
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,9 @@
require 'experiment/local/assignment/assignment_config'
require 'experiment/util/lru_cache'
require 'experiment/util/hash'
require 'experiment/util/topological_sort'
require 'experiment/util/user'
require 'experiment/util/variant'
require 'experiment/error'

# Amplitude Experiment Module
Expand Down
14 changes: 14 additions & 0 deletions lib/experiment/error.rb
Original file line number Diff line number Diff line change
Expand Up @@ -8,4 +8,18 @@ def initialize(status_code, message)
@status_code = status_code
end
end

class CycleError < StandardError
# Raised when topological sorting encounters a cycle between flag dependencies.
attr_reader :path

def initialize(path)
super
@path = path
end

def to_s
"Detected a cycle between flags #{@path}"
end
end
end
4 changes: 3 additions & 1 deletion lib/experiment/local/assignment/assignment.rb
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,9 @@ def initialize(user, results)
def canonicalize
sb = "#{@user&.user_id&.strip} #{@user&.device_id&.strip} "
results.sort.to_h.each do |key, value|
sb += "#{key.strip} #{value['variant']&.fetch('key', '')&.strip} "
next unless value.key

sb += "#{key.strip} #{value.key&.strip} "
end
sb
end
Expand Down
29 changes: 15 additions & 14 deletions lib/experiment/local/assignment/assignment_service.rb
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,10 @@ def initialize(amplitude, assignment_filter)
end

def track(assignment)
@amplitude.track(to_event(assignment)) if @assignment_filter.should_track(assignment)
@amplitude.track(AssignmentService.to_event(assignment)) if @assignment_filter.should_track(assignment)
end

def to_event(assignment)
def self.to_event(assignment)
event = AmplitudeAnalytics::BaseEvent.new(
'[Experiment] Assignment',
user_id: assignment.user.user_id,
Expand All @@ -20,28 +20,29 @@ def to_event(assignment)
user_properties: {}
)

assignment.results.each do |results_key, result|
event.event_properties["#{results_key}.variant"] = result['variant']['key']
end

set = {}
unset = {}

assignment.results.each do |results_key, result|
next if result['type'] == FLAG_TYPE_MUTUAL_EXCLUSION_GROUP
assignment.results.sort.to_h.each do |flag_key, variant|
next unless variant.key

version = variant.metadata['flagVersion'] if variant.metadata
segment_name = variant.metadata['segmentName'] if variant.metadata
flag_type = variant.metadata['flagType'] if variant.metadata
default = variant.metadata ? variant.metadata.fetch('default', false) : false
event.event_properties["#{flag_key}.variant"] = variant.key
event.event_properties["#{flag_key}.details"] = "v#{version} rule:#{segment_name}" if version && segment_name
next if flag_type == FLAG_TYPE_MUTUAL_EXCLUSION_GROUP

if result['isDefaultVariant']
unset["[Experiment] #{results_key}"] = '-'
if default
unset["[Experiment] #{flag_key}"] = '-'
else
set["[Experiment] #{results_key}"] = result['variant']['key']
set["[Experiment] #{flag_key}"] = variant.key
end
end

event.user_properties['$set'] = set
event.user_properties['$unset'] = unset

event.insert_id = "#{event.user_id} #{event.device_id} #{AmplitudeExperiment.hash_code(assignment.canonicalize)} #{assignment.timestamp / DAY_MILLIS}"

event
end
end
Expand Down
52 changes: 28 additions & 24 deletions lib/experiment/local/client.rb
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,7 @@
require_relative '../../amplitude'

module AmplitudeExperiment
FLAG_TYPE_MUTUAL_EXCLUSION_GROUP = 'mutual_exclusion_group'.freeze
FLAG_TYPE_HOLDOUT_GROUP = 'holdout-group'.freeze
FLAG_TYPE_MUTUAL_EXCLUSION_GROUP = 'mutual-exclusion-group'.freeze
# Main client for fetching variant data.
class LocalEvaluationClient
# Creates a new Experiment Client instance.
Expand Down Expand Up @@ -38,17 +37,37 @@ def initialize(api_key, config = nil)
#
# @return [Hash[String, Variant]] The evaluated variants
def evaluate(user, flag_keys = [])
warn 'evaluate is deprecated, please use evaluate_v2 instead.'
tyiuhc marked this conversation as resolved.
Show resolved Hide resolved
variants = evaluate_v2(user, flag_keys)
AmplitudeExperiment.filter_default_variants(variants)
end

# Locally evaluates flag variants for a user.
# This function will only evaluate flags for the keys specified in the flag_keys argument. If flag_keys is
# missing or None, all flags are evaluated. This function differs from evaluate as it will return a default
# variant object if the flag was evaluated but the user was not assigned (i.e. off).
#
# @param [User] user The user to evaluate
# @param [String[]] flag_keys The flags to evaluate with the user, if empty all flags are evaluated
# @return [Hash[String, Variant]] The evaluated variants
def evaluate_v2(user, flag_keys = [])
flags = @flags_mutex.synchronize do
@flags
end
return {} if flags.nil?

user_str = user.to_json
sorted_flags = AmplitudeExperiment.topological_sort(flags, flag_keys.to_set)
flags_json = sorted_flags.to_json

enriched_user = AmplitudeExperiment.user_to_evaluation_context(user)
user_str = enriched_user.to_json

@logger.debug("[Experiment] Evaluate: User: #{user_str} - Rules: #{flags}") if @config.debug
result = evaluation(flags, user_str)
result = evaluation(flags_json, user_str)
@logger.debug("[Experiment] evaluate - result: #{result}") if @config.debug
parse_results(result, flag_keys, user)
variants = AmplitudeExperiment.evaluation_variants_json_to_variants(result)
@assignment_service&.track(Assignment.new(user, variants))
variants
end

# Fetch initial flag configurations and start polling for updates.
Expand All @@ -69,29 +88,14 @@ def stop

private

def parse_results(result, flag_keys, user)
variants = {}
assignments = {}
result.each do |key, value|
included = flag_keys.empty? || flag_keys.include?(key)
if !value['isDefaultVariant'] && included
variant_key = value['variant']['key']
variant_payload = value['variant']['payload']
variants.store(key, Variant.new(variant_key, variant_payload))
end

assignments[key] = value if included || value['type'] == FLAG_TYPE_MUTUAL_EXCLUSION_GROUP || value['type'] == FLAG_TYPE_HOLDOUT_GROUP
end
@assignment_service&.track(Assignment.new(user, assignments))
variants
end

def run
@is_running = true
begin
flags = @fetcher.fetch_v1
flags = @fetcher.fetch_v2
flags_obj = JSON.parse(flags)
flags_map = flags_obj.each_with_object({}) { |flag, hash| hash[flag['key']] = flag }
@flags_mutex.synchronize do
@flags = flags
@flags = flags_map
end
rescue StandardError => e
@logger.error("[Experiment] Flag poller - error: #{e.message}")
Expand Down
4 changes: 2 additions & 2 deletions lib/experiment/local/evaluation/evaluation.rb
Original file line number Diff line number Diff line change
Expand Up @@ -57,11 +57,11 @@ class Libevaluation_interop_ExportedSymbols < FFI::Struct
attach_function :libevaluation_interop_symbols, [], Libevaluation_interop_ExportedSymbols.by_ref
end

def evaluation(rule_json, user_json)
def evaluation(rule_json, context_json)
lib = EvaluationInterop.libevaluation_interop_symbols()
evaluate = lib[:kotlin][:root][:evaluate]
dispose = lib[:DisposeString]
result_raw = evaluate.call(rule_json, user_json)
result_raw = evaluate.call(rule_json, context_json)
result_json = result_raw.read_string
result = JSON.parse(result_json)
dispose.call(result_raw)
Expand Down
Binary file not shown.
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,7 @@ typedef struct {
/* User functions. */
struct {
struct {
const char* (*evaluate)(const char* rules, const char* user);
const char* (*evaluate)(const char* flags, const char* context);
} root;
} kotlin;
} libevaluation_interop_ExportedSymbols;
Expand Down
Binary file not shown.
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,7 @@ typedef struct {
/* User functions. */
struct {
struct {
const char* (*evaluate)(const char* rules, const char* user);
const char* (*evaluate)(const char* flags, const char* context);
} root;
} kotlin;
} libevaluation_interop_ExportedSymbols;
Expand Down
Binary file not shown.
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,7 @@ typedef struct {
/* User functions. */
struct {
struct {
const char* (*evaluate)(const char* rules, const char* user);
const char* (*evaluate)(const char* flags, const char* context);
} root;
} kotlin;
} libevaluation_interop_ExportedSymbols;
Expand Down
Binary file not shown.
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,7 @@ typedef struct {
/* User functions. */
struct {
struct {
const char* (*evaluate)(const char* rules, const char* user);
const char* (*evaluate)(const char* flags, const char* context);
} root;
} kotlin;
} libevaluation_interop_ExportedSymbols;
Expand Down
15 changes: 15 additions & 0 deletions lib/experiment/local/fetcher.rb
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,21 @@ def fetch_v1
response.body
end

def fetch_v2
# fetch flag_configs
headers = {
'Authorization' => "Api-Key #{@api_key}",
'Content-Type' => 'application/json;charset=utf-8',
'X-Amp-Exp-Library' => "experiment-ruby-server/#{VERSION}"
}
request = Net::HTTP::Get.new("#{@server_url}/sdk/v2/flags?v=0", headers)
response = @http.request(request)
raise "flagConfigs - received error response: #{response.code}: #{response.body}" unless response.is_a?(Net::HTTPOK)

@logger.debug("[Experiment] Fetch flag configs: #{response.body}")
response.body
end

# Fetch local evaluation mode flag configs from the Experiment API server.
# These flag configs can be used to perform local evaluation.
#
Expand Down
36 changes: 2 additions & 34 deletions lib/experiment/remote/client.rb
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ def initialize(api_key, config = nil)
# @param [User] user
# @return [Hash] Variants Hash
def fetch(user)
filter_default_variants(fetch_internal(user))
AmplitudeExperiment.filter_default_variants(fetch_internal(user))
rescue StandardError => e
@logger.error("[Experiment] Failed to fetch variants: #{e.message}")
{}
Expand Down Expand Up @@ -144,30 +144,11 @@ def do_fetch(user, timeout_millis)
raise FetchError.new(response.code.to_i, "Fetch error response: status=#{response.code} #{response.message}") if response.code != '200'

json = JSON.parse(response.body)
variants = parse_json_variants(json)
variants = AmplitudeExperiment.evaluation_variants_json_to_variants(json)
@logger.debug("[Experiment] Fetched variants: #{variants}")
variants
end

# Parse JSON response hash
#
# @param [Hash] json
# @return [Hash] Hash with String => Variant
def parse_json_variants(json)
variants = {}
json.each do |key, value|
variant_value = ''
if value.key?('value')
variant_value = value.fetch('value')
elsif value.key?('key')
# value was previously under the "key" field
variant_value = value.fetch('key')
end
variants.store(key, Variant.new(variant_value, value.fetch('payload', nil), value.fetch('key', nil), value.fetch('metadata', nil)))
end
variants
end

# @param [User] user
# @return [User, Hash] user with library context
def add_context(user)
Expand All @@ -181,18 +162,5 @@ def should_retry_fetch?(err)

true
end

def filter_default_variants(variants)
variants.each do |key, value|
default = value&.metadata&.fetch('default', nil)
deployed = value&.metadata&.fetch('deployed', nil)
default = false if default.nil?
deployed = true if deployed.nil?
variants.delete(key) if default || !deployed
end
variants
end

private :filter_default_variants
end
end
20 changes: 17 additions & 3 deletions lib/experiment/user.rb
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,14 @@ class User
# @return [Hash, nil] the value of user properties
attr_accessor :user_properties

# Predefined field, must be manually provided
# @return [Hash, nil] the value of groups
attr_accessor :groups

# Predefined field, must be manually provided
# @return [Hash, nil] the value of group properties
attr_accessor :group_properties

# @param [String, nil] device_id Device ID for associating with an identity in Amplitude
# @param [String, nil] user_id User ID for associating with an identity in Amplitude
# @param [String, nil] country Predefined field, must be manually provided
Expand All @@ -89,9 +97,11 @@ class User
# @param [String, nil] carrier Predefined field, must be manually provided
# @param [String, nil] library Predefined field, auto populated, can be manually overridden
# @param [Hash, nil] user_properties Custom user properties
# @param [Hash, nil] groups List of groups the user belongs to
# @param [Hash, nil] group_properties Custom properties for groups
def initialize(device_id: nil, user_id: nil, country: nil, city: nil, region: nil, dma: nil, ip_address: nil, language: nil,
platform: nil, version: nil, os: nil, device_manufacturer: nil, device_brand: nil,
device_model: nil, carrier: nil, library: nil, user_properties: nil)
device_model: nil, carrier: nil, library: nil, user_properties: nil, groups: nil, group_properties: nil)
@device_id = device_id
@user_id = user_id
@country = country
Expand All @@ -109,6 +119,8 @@ def initialize(device_id: nil, user_id: nil, country: nil, city: nil, region: ni
@carrier = carrier
@library = library
@user_properties = user_properties
@groups = groups
@group_properties = group_properties
end

# Return User as Hash.
Expand All @@ -131,8 +143,10 @@ def as_json(_options = {})
device_model: @device_model,
carrier: @carrier,
library: @library,
user_properties: @user_properties
}
user_properties: @user_properties,
groups: @groups,
group_properties: @group_properties
}.compact
end

# Return user information as JSON string.
Expand Down
Loading
Loading