diff --git a/.rubocop.yml b/.rubocop.yml index 06af4cd..75ca911 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -42,3 +42,6 @@ Metrics/ClassLength: Metrics/ModuleLength: Enabled: false + +Style/Documentation: + Enabled: false diff --git a/lib/amplitude-experiment.rb b/lib/amplitude-experiment.rb index e551e43..97dceb1 100644 --- a/lib/amplitude-experiment.rb +++ b/lib/amplitude-experiment.rb @@ -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 diff --git a/lib/experiment/error.rb b/lib/experiment/error.rb index 928887f..47119c4 100644 --- a/lib/experiment/error.rb +++ b/lib/experiment/error.rb @@ -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 diff --git a/lib/experiment/local/assignment/assignment.rb b/lib/experiment/local/assignment/assignment.rb index 2d25e4c..f2d5fd4 100644 --- a/lib/experiment/local/assignment/assignment.rb +++ b/lib/experiment/local/assignment/assignment.rb @@ -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 diff --git a/lib/experiment/local/assignment/assignment_service.rb b/lib/experiment/local/assignment/assignment_service.rb index d69e98a..8d40da1 100644 --- a/lib/experiment/local/assignment/assignment_service.rb +++ b/lib/experiment/local/assignment/assignment_service.rb @@ -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, @@ -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 diff --git a/lib/experiment/local/client.rb b/lib/experiment/local/client.rb index c7ab6a1..7a11ccb 100644 --- a/lib/experiment/local/client.rb +++ b/lib/experiment/local/client.rb @@ -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. @@ -37,18 +36,38 @@ def initialize(api_key, config = nil) # @param [String[]] flag_keys The flags to evaluate with the user. If empty, all flags from the flag cache are evaluated # # @return [Hash[String, Variant]] The evaluated variants + # @deprecated Please use {evaluate_v2} instead def evaluate(user, flag_keys = []) + 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. @@ -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}") diff --git a/lib/experiment/local/evaluation/evaluation.rb b/lib/experiment/local/evaluation/evaluation.rb index 58920c6..5a4c201 100644 --- a/lib/experiment/local/evaluation/evaluation.rb +++ b/lib/experiment/local/evaluation/evaluation.rb @@ -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) diff --git a/lib/experiment/local/evaluation/lib/linuxArm64/libevaluation_interop.so b/lib/experiment/local/evaluation/lib/linuxArm64/libevaluation_interop.so index e1a78a0..525c434 100755 Binary files a/lib/experiment/local/evaluation/lib/linuxArm64/libevaluation_interop.so and b/lib/experiment/local/evaluation/lib/linuxArm64/libevaluation_interop.so differ diff --git a/lib/experiment/local/evaluation/lib/linuxArm64/libevaluation_interop_api.h b/lib/experiment/local/evaluation/lib/linuxArm64/libevaluation_interop_api.h index ea18cd4..70485d1 100644 --- a/lib/experiment/local/evaluation/lib/linuxArm64/libevaluation_interop_api.h +++ b/lib/experiment/local/evaluation/lib/linuxArm64/libevaluation_interop_api.h @@ -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; diff --git a/lib/experiment/local/evaluation/lib/linuxX64/libevaluation_interop.so b/lib/experiment/local/evaluation/lib/linuxX64/libevaluation_interop.so index 9220a00..14a8f55 100755 Binary files a/lib/experiment/local/evaluation/lib/linuxX64/libevaluation_interop.so and b/lib/experiment/local/evaluation/lib/linuxX64/libevaluation_interop.so differ diff --git a/lib/experiment/local/evaluation/lib/linuxX64/libevaluation_interop_api.h b/lib/experiment/local/evaluation/lib/linuxX64/libevaluation_interop_api.h index ea18cd4..70485d1 100644 --- a/lib/experiment/local/evaluation/lib/linuxX64/libevaluation_interop_api.h +++ b/lib/experiment/local/evaluation/lib/linuxX64/libevaluation_interop_api.h @@ -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; diff --git a/lib/experiment/local/evaluation/lib/macosArm64/libevaluation_interop.dylib b/lib/experiment/local/evaluation/lib/macosArm64/libevaluation_interop.dylib index 2bca7f1..35b8fe1 100755 Binary files a/lib/experiment/local/evaluation/lib/macosArm64/libevaluation_interop.dylib and b/lib/experiment/local/evaluation/lib/macosArm64/libevaluation_interop.dylib differ diff --git a/lib/experiment/local/evaluation/lib/macosArm64/libevaluation_interop_api.h b/lib/experiment/local/evaluation/lib/macosArm64/libevaluation_interop_api.h index ea18cd4..70485d1 100644 --- a/lib/experiment/local/evaluation/lib/macosArm64/libevaluation_interop_api.h +++ b/lib/experiment/local/evaluation/lib/macosArm64/libevaluation_interop_api.h @@ -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; diff --git a/lib/experiment/local/evaluation/lib/macosX64/libevaluation_interop.dylib b/lib/experiment/local/evaluation/lib/macosX64/libevaluation_interop.dylib index 5dd4eac..23d73f9 100755 Binary files a/lib/experiment/local/evaluation/lib/macosX64/libevaluation_interop.dylib and b/lib/experiment/local/evaluation/lib/macosX64/libevaluation_interop.dylib differ diff --git a/lib/experiment/local/evaluation/lib/macosX64/libevaluation_interop_api.h b/lib/experiment/local/evaluation/lib/macosX64/libevaluation_interop_api.h index ea18cd4..70485d1 100644 --- a/lib/experiment/local/evaluation/lib/macosX64/libevaluation_interop_api.h +++ b/lib/experiment/local/evaluation/lib/macosX64/libevaluation_interop_api.h @@ -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; diff --git a/lib/experiment/local/fetcher.rb b/lib/experiment/local/fetcher.rb index d863906..46eb57a 100644 --- a/lib/experiment/local/fetcher.rb +++ b/lib/experiment/local/fetcher.rb @@ -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. # diff --git a/lib/experiment/remote/client.rb b/lib/experiment/remote/client.rb index 35916dd..89efaeb 100644 --- a/lib/experiment/remote/client.rb +++ b/lib/experiment/remote/client.rb @@ -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}") {} @@ -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) @@ -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 diff --git a/lib/experiment/user.rb b/lib/experiment/user.rb index 0c7b888..402bcb5 100644 --- a/lib/experiment/user.rb +++ b/lib/experiment/user.rb @@ -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 @@ -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 @@ -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. @@ -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. diff --git a/lib/experiment/util/topological_sort.rb b/lib/experiment/util/topological_sort.rb new file mode 100644 index 0000000..b7ed4a6 --- /dev/null +++ b/lib/experiment/util/topological_sort.rb @@ -0,0 +1,39 @@ +module AmplitudeExperiment + def self.topological_sort(flags, keys = nil, ordered: false) + available = flags.dup + result = [] + starting_keys = keys.nil? || keys.empty? ? flags.keys : keys + # Used for testing to ensure consistency. + starting_keys.sort! if ordered && (keys.nil? || keys.empty?) + + starting_keys.each do |flag_key| + traversal = parent_traversal(flag_key, available, Set.new) + result.concat(traversal) unless traversal.nil? + end + result + end + + def self.parent_traversal(flag_key, available, path) + flag = available[flag_key] + return nil if flag.nil? + + dependencies = flag['dependencies'] + if dependencies.nil? || dependencies.empty? + available.delete(flag_key) + return [flag] + end + + path.add(flag_key) + result = [] + dependencies.each do |parent_key| + raise CycleError, path if path.include?(parent_key) + + traversal = parent_traversal(parent_key, available, path) + result.concat(traversal) unless traversal.nil? + end + result << flag + path.delete(flag_key) + available.delete(flag_key) + result + end +end diff --git a/lib/experiment/util/user.rb b/lib/experiment/util/user.rb new file mode 100644 index 0000000..3965b28 --- /dev/null +++ b/lib/experiment/util/user.rb @@ -0,0 +1,33 @@ +module AmplitudeExperiment + def self.user_to_evaluation_context(user) + user_groups = user.groups + user_group_properties = user.group_properties + user_hash = user.as_json.compact + user_hash.delete(:groups) + user_hash.delete(:group_properties) + + context = user_hash.empty? ? {} : { user: user_hash } + + return context if user_groups.nil? + + groups = {} + user_groups.each do |group_type, group_name| + group_name = group_name[0] if group_name.is_a?(Array) && !group_name.empty? + + groups[group_type.to_sym] = { group_name: group_name } + + next if user_group_properties.nil? + + group_properties_type = user_group_properties[group_type.to_sym] + next if group_properties_type.nil? || !group_properties_type.is_a?(Hash) + + group_properties_name = group_properties_type[group_name.to_sym] + next if group_properties_name.nil? || !group_properties_name.is_a?(Hash) + + groups[group_type.to_sym][:group_properties] = group_properties_name + end + + context[:groups] = groups unless groups.empty? + context + end +end diff --git a/lib/experiment/util/variant.rb b/lib/experiment/util/variant.rb new file mode 100644 index 0000000..d953f04 --- /dev/null +++ b/lib/experiment/util/variant.rb @@ -0,0 +1,32 @@ +require 'json' +module AmplitudeExperiment + def self.evaluation_variants_json_to_variants(variants_json) + variants = {} + variants_json.each do |key, value| + variants[key] = AmplitudeExperiment.evaluation_variant_json_to_variant(value) + end + variants + end + + def self.evaluation_variant_json_to_variant(variant_json) + value = variant_json['value'] + value = value.to_json if value && !value.is_a?(String) + Variant.new( + value: value, + key: variant_json['key'], + payload: variant_json['payload'], + metadata: variant_json['metadata'] + ) + end + + def self.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 +end diff --git a/lib/experiment/variant.rb b/lib/experiment/variant.rb index 96eef7a..0033c51 100644 --- a/lib/experiment/variant.rb +++ b/lib/experiment/variant.rb @@ -17,7 +17,9 @@ class Variant # @param [String] value The value of the variant determined by the flag configuration. # @param [Object, nil] payload The attached payload, if any. - def initialize(value, payload = nil, key = nil, metadata = nil) + # @param [String] key The key of the variant determined by the flag configuration. + # @param [Object, nil] metadata The attached metadata, if any. + def initialize(value: nil, payload: nil, key: nil, metadata: nil) @key = key @value = value @payload = payload diff --git a/spec/experiment/local/assignment/assignment_spec.rb b/spec/experiment/local/assignment/assignment_spec.rb new file mode 100644 index 0000000..3c9befe --- /dev/null +++ b/spec/experiment/local/assignment/assignment_spec.rb @@ -0,0 +1,164 @@ +module AmplitudeExperiment + describe AssignmentService do + let(:instance) { double('Amplitude') } + let(:user) { User.new(user_id: 'user', device_id: 'device') } + + def build_variant(key, value, metadata = {}) + Variant.new( + key: key, + value: value, + metadata: metadata + ) + end + + it 'assignment to event as expected' do + variants = { + 'basic' => build_variant('control', 'control', 'segmentName' => 'All Other Users', 'flagType' => 'experiment', 'flagVersion' => 10, 'default' => false), + 'different_value' => build_variant('on', 'control', 'segmentName' => 'All Other Users', 'flagType' => 'experiment', 'flagVersion' => 10, 'default' => false), + 'default' => build_variant('off', nil, 'segmentName' => 'All Other Users', 'flagType' => 'experiment', 'flagVersion' => 10, 'default' => true), + 'mutex' => build_variant('slot-1', 'slot-1', 'segmentName' => 'All Other Users', 'flagType' => 'mutual-exclusion-group', 'flagVersion' => 10, 'default' => false), + 'holdout' => build_variant('holdout', 'holdout', 'segmentName' => 'All Other Users', 'flagType' => 'holdout-group', 'flagVersion' => 10, 'default' => false), + 'partial_metadata' => build_variant('on', 'on', 'segmentName' => 'All Other Users', 'flagType' => 'release'), + 'empty_metadata' => build_variant('on', 'on'), + 'empty_variant' => build_variant(nil, nil) + } + assignment = Assignment.new(user, variants) + event = AssignmentService.to_event(assignment) + + expect(event.user_id).to eq(user.user_id) + expect(event.device_id).to eq(user.device_id) + expect(event.event_type).to eq('[Experiment] Assignment') + + event_properties = event.event_properties + expect(event_properties['basic.variant']).to eq('control') + expect(event_properties['basic.details']).to eq('v10 rule:All Other Users') + expect(event_properties['different_value.variant']).to eq('on') + expect(event_properties['different_value.details']).to eq('v10 rule:All Other Users') + expect(event_properties['default.variant']).to eq('off') + expect(event_properties['default.details']).to eq('v10 rule:All Other Users') + expect(event_properties['mutex.variant']).to eq('slot-1') + expect(event_properties['mutex.details']).to eq('v10 rule:All Other Users') + expect(event_properties['holdout.variant']).to eq('holdout') + expect(event_properties['holdout.details']).to eq('v10 rule:All Other Users') + expect(event_properties['partial_metadata.variant']).to eq('on') + expect(event_properties['empty_metadata.variant']).to eq('on') + + user_properties = event.user_properties + set_properties = user_properties['$set'] + expect(set_properties['[Experiment] basic']).to eq('control') + expect(set_properties['[Experiment] different_value']).to eq('on') + expect(set_properties['[Experiment] holdout']).to eq('holdout') + expect(set_properties['[Experiment] partial_metadata']).to eq('on') + expect(set_properties['[Experiment] empty_metadata']).to eq('on') + unset_properties = user_properties['$unset'] + expect(unset_properties['[Experiment] default']).to eq('-') + + canonicalization = 'user device basic control default off different_value on empty_metadata on holdout holdout mutex slot-1 partial_metadata on ' + expected = "user device #{AmplitudeExperiment.hash_code(canonicalization)} #{assignment.timestamp / DAY_MILLIS}" + expect(event.insert_id).to eq(expected) + end + + it 'calls track on the Amplitude instance' do + service = AssignmentService.new(instance, AssignmentFilter.new(2)) + user = User.new(user_id: 'user', device_id: 'device') + results = { 'flag-key-1' => Variant.new(key: 'on') } + allow(instance).to receive(:track) + service.track(Assignment.new(user, results)) + expect(instance).to have_received(:track) + end + end + + describe AssignmentFilter do + let(:filter) { AssignmentFilter.new(100) } + let(:user) { User.new(user_id: 'user', device_id: 'device') } + let(:variant_on) { Variant.new(key: 'on', value: 'on') } + let(:variant_control) { Variant.new(key: 'control', value: 'control') } + let(:results) { { 'flag-key-1' => variant_on, 'flag-key-2' => variant_control } } + + it 'filters single assignment' do + assignment = Assignment.new(user, results) + expect(filter.should_track(assignment)).to eq(true) + end + + it 'filters duplicate assignment' do + assignment1 = Assignment.new(user, results) + assignment2 = Assignment.new(user, results) + filter.should_track(assignment1) + expect(filter.should_track(assignment2)).to eq(false) + end + + it 'filters same user different results' do + results1 = results + results2 = { + 'flag-key-1' => variant_control, + 'flag-key-2' => variant_on + } + assignment1 = Assignment.new(user, results1) + assignment2 = Assignment.new(user, results2) + expect(filter.should_track(assignment1)).to eq(true) + expect(filter.should_track(assignment2)).to eq(true) + end + + it 'filters same result different user' do + user1 = User.new(user_id: 'user1') + user2 = User.new(user_id: 'different-user') + assignment1 = Assignment.new(user1, results) + assignment2 = Assignment.new(user2, results) + expect(filter.should_track(assignment1)).to eq(true) + expect(filter.should_track(assignment2)).to eq(true) + end + + it 'filters empty result' do + user1 = User.new(user_id: 'user') + user2 = User.new(user_id: 'different-user') + assignment1 = Assignment.new(user1, {}) + assignment2 = Assignment.new(user1, {}) + assignment3 = Assignment.new(user2, {}) + expect(filter.should_track(assignment1)).to eq(false) + expect(filter.should_track(assignment2)).to eq(false) + expect(filter.should_track(assignment3)).to eq(false) + end + + it 'filters duplicate assignments with different result ordering' do + results1 = results + results2 = { + 'flag-key-2' => variant_control, + 'flag-key-1' => variant_on + } + assignment1 = Assignment.new(user, results1) + assignment2 = Assignment.new(user, results2) + expect(filter.should_track(assignment1)).to eq(true) + expect(filter.should_track(assignment2)).to eq(false) + end + + it 'handles LRU replacement' do + filter = AssignmentFilter.new(2) + user1 = User.new(user_id: 'user1') + user2 = User.new(user_id: 'user2') + user3 = User.new(user_id: 'user3') + assignment1 = Assignment.new(user1, results) + assignment2 = Assignment.new(user2, results) + assignment3 = Assignment.new(user3, results) + expect(filter.should_track(assignment1)).to eq(true) + expect(filter.should_track(assignment2)).to eq(true) + expect(filter.should_track(assignment3)).to eq(true) + expect(filter.should_track(assignment1)).to eq(true) + end + + it 'handles LRU TTL-based expiration' do + filter = AssignmentFilter.new(2, 1000) + user1 = User.new(user_id: 'user1') + user2 = User.new(user_id: 'user2') + assignment1 = Assignment.new(user1, results) + assignment2 = Assignment.new(user2, results) + expect(filter.should_track(assignment1)).to eq(true) + expect(filter.should_track(assignment1)).to eq(false) + sleep 1.05 + expect(filter.should_track(assignment1)).to eq(true) + expect(filter.should_track(assignment2)).to eq(true) + expect(filter.should_track(assignment2)).to eq(false) + sleep 0.95 + expect(filter.should_track(assignment2)).to eq(false) + end + end +end diff --git a/spec/experiment/local/assignment_spec.rb b/spec/experiment/local/assignment_spec.rb deleted file mode 100644 index edade3b..0000000 --- a/spec/experiment/local/assignment_spec.rb +++ /dev/null @@ -1,249 +0,0 @@ -module AmplitudeExperiment - describe AssignmentService do - it 'assignment to event as expected' do - filter = AssignmentFilter.new(100) - service = AssignmentService.new('', filter) - user = User.new(user_id: 'user', device_id: 'device') - results = { - 'flag-key-1' => { - 'variant' => { 'key' => 'on' }, - 'description' => 'description-1', - 'isDefaultVariant' => false - }, - 'flag-key-2' => { - 'variant' => { 'key' => 'control' }, - 'description' => 'description-2', - 'isDefaultVariant' => true - } - } - assignment = Assignment.new(user, results) - - event = service.to_event(assignment) - - expect(event.user_id).to eq(user.user_id) - expect(event.device_id).to eq(user.device_id) - expect(event.event_type).to eq('[Experiment] Assignment') - - event_properties = event.event_properties - expect(event_properties.keys.length).to eq(2) - expect(event_properties['flag-key-1.variant']).to eq('on') - expect(event_properties['flag-key-2.variant']).to eq('control') - - user_properties = event.user_properties - expect(user_properties.keys.length).to eq(2) - expect(user_properties['$set'].keys.length).to eq(1) - expect(user_properties['$unset'].keys.length).to eq(1) - - canonicalization = 'user device flag-key-1 on flag-key-2 control ' - expected = "user device #{AmplitudeExperiment.hash_code(canonicalization)} #{assignment.timestamp / DAY_MILLIS}" - expect(assignment.canonicalize).to eq(canonicalization) - expect(event.insert_id).to eq(expected) - end - - describe AssignmentFilter do - it 'filter - single assignment' do - filter = AssignmentFilter.new(100) - user = User.new(user_id: 'user', device_id: 'device') - results = { - 'flag-key-1' => { - 'variant' => { 'key' => 'on' }, - 'description' => 'description-1', - 'isDefaultVariant' => false - }, - 'flag-key-2' => { - 'variant' => { 'key' => 'control' }, - 'description' => 'description-2', - 'isDefaultVariant' => true - } - } - assignment = Assignment.new(user, results) - - expect(filter.should_track(assignment)).to eq(true) - end - - it 'filter - duplicate assignment' do - filter = AssignmentFilter.new(100) - user = User.new(user_id: 'user', device_id: 'device') - results = { - 'flag-key-1' => { - 'variant' => { 'key' => 'on' }, - 'description' => 'description-1', - 'isDefaultVariant' => false - }, - 'flag-key-2' => { - 'variant' => { 'key' => 'control' }, - 'description' => 'description-2', - 'isDefaultVariant' => true - } - } - assignment1 = Assignment.new(user, results) - assignment2 = Assignment.new(user, results) - - filter.should_track(assignment1) - expect(filter.should_track(assignment2)).to eq(false) - end - - it 'filter - same user different results' do - filter = AssignmentFilter.new(100) - user = User.new(user_id: 'user', device_id: 'device') - results1 = { - 'flag-key-1' => { - 'variant' => { 'key' => 'on' }, - 'description' => 'description-1', - 'isDefaultVariant' => false - }, - 'flag-key-2' => { - 'variant' => { 'key' => 'control' }, - 'description' => 'description-2', - 'isDefaultVariant' => true - } - } - results2 = { - 'flag-key-1' => { - 'variant' => { 'key' => 'control' }, - 'description' => 'description-1', - 'isDefaultVariant' => false - }, - 'flag-key-2' => { - 'variant' => { 'key' => 'on' }, - 'description' => 'description-2', - 'isDefaultVariant' => true - } - } - assignment1 = Assignment.new(user, results1) - assignment2 = Assignment.new(user, results2) - - expect(filter.should_track(assignment1)).to eq(true) - expect(filter.should_track(assignment2)).to eq(true) - end - - it 'filter - same result different user' do - filter = AssignmentFilter.new(100) - user1 = User.new(user_id: 'user') - user2 = User.new(user_id: 'different-user') - results = { - 'flag-key-1' => { - 'variant' => { 'key' => 'on' }, - 'description' => 'description-1', - 'isDefaultVariant' => false - }, - 'flag-key-2' => { - 'variant' => { 'key' => 'control' }, - 'description' => 'description-2', - 'isDefaultVariant' => true - } - } - assignment1 = Assignment.new(user1, results) - assignment2 = Assignment.new(user2, results) - - expect(filter.should_track(assignment1)).to eq(true) - expect(filter.should_track(assignment2)).to eq(true) - end - - it 'filter - empty result' do - filter = AssignmentFilter.new(100) - user1 = User.new(user_id: 'user') - user2 = User.new(user_id: 'different-user') - - assignment1 = Assignment.new(user1, {}) - assignment2 = Assignment.new(user1, {}) - assignment3 = Assignment.new(user2, {}) - - expect(filter.should_track(assignment1)).to eq(false) - expect(filter.should_track(assignment2)).to eq(false) - expect(filter.should_track(assignment3)).to eq(false) - end - - it 'filter - duplicate assignments with different result ordering' do - filter = AssignmentFilter.new(100) - user = User.new(user_id: 'user') - results1 = { - 'flag-key-1' => { - 'variant' => { 'key' => 'on' }, - 'description' => 'description-1', - 'isDefaultVariant' => false - }, - 'flag-key-2' => { - 'variant' => { 'key' => 'control' }, - 'description' => 'description-2', - 'isDefaultVariant' => true - } - } - results2 = { - 'flag-key-2' => { - 'variant' => { 'key' => 'control' }, - 'description' => 'description-2', - 'isDefaultVariant' => true - }, - 'flag-key-1' => { - 'variant' => { 'key' => 'on' }, - 'description' => 'description-1', - 'isDefaultVariant' => false - } - } - - assignment1 = Assignment.new(user, results1) - assignment2 = Assignment.new(user, results2) - - expect(filter.should_track(assignment1)).to eq(true) - expect(filter.should_track(assignment2)).to eq(false) - end - - it 'filter - lru replacement' do - filter = AssignmentFilter.new(2) - user1 = User.new(user_id: 'user1') - user2 = User.new(user_id: 'user2') - user3 = User.new(user_id: 'user3') - results = { - 'flag-key-1' => { - 'variant' => { 'key' => 'on' }, - 'description' => 'description-1', - 'isDefaultVariant' => false - }, - 'flag-key-2' => { - 'variant' => { 'key' => 'control' }, - 'description' => 'description-2', - 'isDefaultVariant' => true - } - } - assignment1 = Assignment.new(user1, results) - assignment2 = Assignment.new(user2, results) - assignment3 = Assignment.new(user3, results) - - expect(filter.should_track(assignment1)).to eq(true) - expect(filter.should_track(assignment2)).to eq(true) - expect(filter.should_track(assignment3)).to eq(true) - expect(filter.should_track(assignment1)).to eq(true) - end - - it 'filter - lru ttl-based expiration' do - filter = AssignmentFilter.new(2, 1000) - user1 = User.new(user_id: 'user1') - user2 = User.new(user_id: 'user2') - results = { - 'flag-key-1' => { - 'variant' => { 'key' => 'on' }, - 'description' => 'description-1', - 'isDefaultVariant' => false - }, - 'flag-key-2' => { - 'variant' => { 'key' => 'control' }, - 'description' => 'description-2', - 'isDefaultVariant' => true - } - } - assignment1 = Assignment.new(user1, results) - assignment2 = Assignment.new(user2, results) - - expect(filter.should_track(assignment1)).to eq(true) - expect(filter.should_track(assignment1)).to eq(false) - sleep 1.05 - expect(filter.should_track(assignment1)).to eq(true) - expect(filter.should_track(assignment2)).to eq(true) - expect(filter.should_track(assignment2)).to eq(false) - sleep 0.95 - expect(filter.should_track(assignment2)).to eq(false) - end - end - end -end diff --git a/spec/experiment/local/benchmark_spec.rb b/spec/experiment/local/benchmark_spec.rb index 38ee3c5..a46af67 100644 --- a/spec/experiment/local/benchmark_spec.rb +++ b/spec/experiment/local/benchmark_spec.rb @@ -1,9 +1,8 @@ require 'benchmark' module AmplitudeExperiment - SERVER_API_KEY = 'server-qz35UwzJ5akieoAdIgzM4m9MIiOLXLoz'.freeze - describe LocalEvaluationClient do + let(:api_key) { 'client-DvWljIjiiuqLbyjqdvBaLFfEBrAvGuA3' } local_evaluation_client = nil def random_benchmark_flag n = rand(4) @@ -34,15 +33,15 @@ def random_experiment_user end before(:each) do - local_evaluation_client = LocalEvaluationClient.new(SERVER_API_KEY) - response = '[{"allUsersTargetingConfig":{"allocations":[{"percentage":0,"weights":{"array-payload":0,"control":0,"object-payload":0}}],"bucketingKey":"device_id","conditions":[],"name":"default-segment"},"bucketingKey":"device_id","bucketingSalt":"6jLqNjj5","customSegmentTargetingConfigs":[{"allocations":[{"percentage":9900,"weights":{"array-payload":0,"boolean-payload":0,"control":1,"null-payload":0,"number-payload":0,"object-payload":0,"string-payload":0,"treatment":0}}],"bucketingKey":"user_id","conditions":[{"op":"IS","prop":"gp:bucket","values":["user_id"]}],"name":"Bucket by User ID"},{"allocations":[{"percentage":9900,"weights":{"array-payload":0,"boolean-payload":0,"control":0,"null-payload":0,"number-payload":0,"object-payload":0,"string-payload":0,"treatment":1}}],"bucketingKey":"device_id","conditions":[{"op":"IS","prop":"gp:bucket","values":["device_id"]}],"name":"Bucket by Device ID"},{"allocations":[{"percentage":10000,"weights":{"array-payload":0,"boolean-payload":0,"control":0,"null-payload":0,"number-payload":0,"object-payload":0,"string-payload":1,"treatment":0}}],"bucketingKey":"device_id","conditions":[{"op":"IS","prop":"gp:test is","values":["string","true","1312.1"]},{"op":"IS_NOT","prop":"gp:test is not","values":["string","true","1312.1"]}],"name":"Test IS & IS NOT"},{"allocations":[{"percentage":10000,"weights":{"array-payload":0,"boolean-payload":1,"control":0,"null-payload":0,"number-payload":0,"object-payload":0,"string-payload":0,"treatment":0}}],"bucketingKey":"device_id","conditions":[{"op":"CONTAINS","prop":"gp:test contains","values":["@amplitude.com"]},{"op":"DOES_NOT_CONTAIN","prop":"gp:test does not contain","values":["asdf"]}],"name":"Test CONTAINS & DOES_NOT_CONTAIN"},{"allocations":[{"percentage":10000,"weights":{"array-payload":0,"boolean-payload":0,"control":0,"null-payload":0,"number-payload":0,"object-payload":1,"string-payload":0,"treatment":0}}],"bucketingKey":"device_id","conditions":[{"op":"GREATER_THAN","prop":"gp:test greater","values":["1.2.3"]},{"op":"GREATER_THAN_EQUALS","prop":"gp:test greater or equal","values":["1.2.3"]},{"op":"LESS_THAN","prop":"gp:test less","values":["1.2.3"]},{"op":"LESS_THAN_EQUALS","prop":"gp:test less or equal","values":["1.2.3"]}],"name":"Test GREATER & GREATER OR EQUAL & LESS & LESS OR EQUAL"},{"allocations":[{"percentage":10000,"weights":{"array-payload":0,"boolean-payload":0,"control":0,"null-payload":1,"number-payload":0,"object-payload":0,"string-payload":0,"treatment":0}}],"bucketingKey":"device_id","conditions":[{"op":"SET_CONTAINS","prop":"gp:test set contains","values":["asdf"]}],"name":"Test SET_CONTAINS (not supported)"}],"defaultValue":"off","enabled":true,"evalMode":"LOCAL","flagKey":"sdk-local-evaluation-unit-test","flagName":"sdk-local-evaluation-unit-test","flagVersion":33,"globalHoldbackBucketingKey":"amplitude_id","globalHoldbackPct":0,"globalHoldbackSalt":null,"mutualExclusionConfig":null,"type":"RELEASE","useStickyBucketing":false,"userProperty":"[Experiment] sdk-local-evaluation-unit-test","variants":[{"key":"control","payload":null},{"key":"treatment","payload":null},{"key":"string-payload","payload":"string"},{"key":"number-payload","payload":1312.1},{"key":"boolean-payload","payload":true},{"key":"object-payload","payload":{"array":[1,2,3],"boolean":true,"number":2,"object":{"k":"v"},"string":"value"}},{"key":"array-payload","payload":[1,2,3,"4",true,{"k":"v"},[1,2,3]]},{"key":"null-payload","payload":null}],"variantsExclusions":null,"variantsInclusions":{"array-payload":["array-payload"],"boolean-payload":["boolean-payload"],"control":["control"],"null-payload":["null-payload"],"number-payload":["number-payload"],"object-payload":["object-payload"],"string-payload":["string-payload"],"treatment":["treatment"]}}]' + local_evaluation_client = LocalEvaluationClient.new(api_key) + response = '[{"key":"holdout-sdk-ci-local-dependencies-test-holdout","metadata":{"deployed":false,"evaluationMode":"local","flagType":"holdout-group","flagVersion":1},"segments":[{"bucket":{"allocations":[{"distributions":[{"range":[0,429497],"variant":"holdout"},{"range":[429496,42949673],"variant":"on"}],"range":[0,100]}],"salt":"nI33zio8","selector":["context","user","device_id"]},"metadata":{"segmentName":"All Other Users"},"variant":"off"}],"variants":{"holdout":{"key":"holdout","payload":{"flagIds":[]},"value":"holdout"},"off":{"key":"off","metadata":{"default":true}},"on":{"key":"on","payload":{"flagIds":["42953"]},"value":"on"}}},{"key":"mutex-sdk-ci-local-dependencies-test-mutex","metadata":{"deployed":false,"evaluationMode":"local","flagType":"mutual-exclusion-group","flagVersion":1},"segments":[{"metadata":{"segmentName":"All Other Users"},"variant":"slot-1"}],"variants":{"off":{"key":"off","metadata":{"default":true}},"slot-1":{"key":"slot-1","payload":{"flagIds":["42953"]},"value":"slot-1"},"unallocated":{"key":"unallocated","payload":{"flagIds":[]},"value":"unallocated"}}},{"dependencies":["holdout-sdk-ci-local-dependencies-test-holdout","mutex-sdk-ci-local-dependencies-test-mutex"],"key":"sdk-ci-local-dependencies-test","metadata":{"deployed":true,"evaluationMode":"local","experimentKey":"exp-1","flagType":"experiment","flagVersion":9},"segments":[{"conditions":[[{"op":"is not","selector":["result","holdout-sdk-ci-local-dependencies-test-holdout","key"],"values":["on"]}],[{"op":"is not","selector":["result","mutex-sdk-ci-local-dependencies-test-mutex","key"],"values":["slot-1"]}]],"metadata":{"segmentName":"flag-dependencies"},"variant":"off"},{"metadata":{"segmentName":"All Other Users"},"variant":"control"}],"variants":{"control":{"key":"control","value":"control"},"off":{"key":"off","metadata":{"default":true}},"treatment":{"key":"treatment","value":"treatment"}}},{"key":"sdk-local-evaluation-ci-test","metadata":{"deployed":true,"evaluationMode":"local","flagType":"release","flagVersion":7},"segments":[{"metadata":{"segmentName":"All Other Users"},"variant":"on"}],"variants":{"off":{"key":"off","metadata":{"default":true}},"on":{"key":"on","payload":"payload","value":"on"}}},{"key":"holdout-sdk-ci-dependencies-test-force-holdout","metadata":{"deployed":false,"evaluationMode":"local","flagType":"holdout-group","flagVersion":2},"segments":[{"bucket":{"allocations":[{"distributions":[{"range":[0,42520177],"variant":"holdout"},{"range":[42520175,42949673],"variant":"on"}],"range":[0,100]}],"salt":"ubvfZywq","selector":["context","user","device_id"]},"metadata":{"segmentName":"All Other Users"},"variant":"off"}],"variants":{"holdout":{"key":"holdout","payload":{"flagIds":[]},"value":"holdout"},"off":{"key":"off","metadata":{"default":true}},"on":{"key":"on","payload":{"flagIds":["44564"]},"value":"on"}}},{"dependencies":["holdout-sdk-ci-dependencies-test-force-holdout"],"key":"sdk-ci-local-dependencies-test-holdout","metadata":{"deployed":true,"evaluationMode":"local","experimentKey":"exp-1","flagType":"experiment","flagVersion":5},"segments":[{"conditions":[[{"op":"is not","selector":["result","holdout-sdk-ci-dependencies-test-force-holdout","key"],"values":["on"]}]],"metadata":{"segmentName":"flag-dependencies"},"variant":"off"},{"metadata":{"segmentName":"All Other Users"},"variant":"control"}],"variants":{"control":{"key":"control","value":"control"},"off":{"key":"off","metadata":{"default":true}},"treatment":{"key":"treatment","value":"treatment"}}}]' - stub_request(:get, 'https://api.lab.amplitude.com/sdk/v1/flags') + stub_request(:get, 'https://api.lab.amplitude.com/sdk/v2/flags?v=0') .with( headers: { 'Accept' => '*/*', 'Accept-Encoding' => 'gzip;q=1.0,deflate;q=0.6,identity;q=0.3', - 'Authorization' => "Api-Key #{SERVER_API_KEY}", + 'Authorization' => "Api-Key #{api_key}", 'Content-Type' => 'application/json;charset=utf-8', 'X-Amp-Exp-Library' => "experiment-ruby-server/#{VERSION}", 'User-Agent' => 'Ruby' diff --git a/spec/experiment/local/client_spec.rb b/spec/experiment/local/client_spec.rb index bb90f1c..ed02f24 100644 --- a/spec/experiment/local/client_spec.rb +++ b/spec/experiment/local/client_spec.rb @@ -2,19 +2,19 @@ require_relative '../../../lib/amplitude' module AmplitudeExperiment - LOCAL_SERVER_URL = 'https://api.lab.amplitude.com/sdk/v2/vardata?v=0'.freeze - TEST_USER = User.new(user_id: 'test_user') - TEST_USER_2 = User.new(user_id: 'user_id', device_id: 'device_id') - describe LocalEvaluationClient do + let(:api_key) { 'client-DvWljIjiiuqLbyjqdvBaLFfEBrAvGuA3' } + let(:test_user) { User.new(user_id: 'test_user') } + let(:test_user2) { User.new(user_id: 'user_id', device_id: 'device_id') } + def setup_stub - response = '[{"allUsersTargetingConfig":{"allocations":[{"percentage":10000,"weights":{"holdout":1,"on":99}}],"bucketingGroupType":null,"bucketingKey":"device_id","conditions":[],"name":"All Other Users"},"bucketingGroupType":null,"bucketingSalt":"nI33zio8","customSegmentTargetingConfigs":[],"defaultValue":"off","deployed":false,"enabled":true,"experimentKey":null,"flagKey":"holdout-sdk-ci-local-dependencies-test-holdout","flagVersion":1,"parentDependencies":null,"type":"holdout-group","variants":[{"key":"holdout","payload":{"flagIds":[]}},{"key":"on","payload":{"flagIds":["42953"]}}],"variantsInclusions":{}},{"allUsersTargetingConfig":{"allocations":[{"percentage":10000,"weights":{"slot-1":100,"unallocated":0}}],"bucketingGroupType":null,"bucketingKey":"device_id","conditions":[],"name":"All Other Users"},"bucketingGroupType":null,"bucketingSalt":"sVlTAPmD","customSegmentTargetingConfigs":[],"defaultValue":"off","deployed":false,"enabled":true,"experimentKey":null,"flagKey":"mutex-sdk-ci-local-dependencies-test-mutex","flagVersion":1,"parentDependencies":null,"type":"mutual-exclusion-group","variants":[{"key":"unallocated","payload":{"flagIds":[]}},{"key":"slot-1","payload":{"flagIds":["42953"]}}],"variantsInclusions":{}},{"allUsersTargetingConfig":{"allocations":[{"percentage":10000,"weights":{"control":1,"treatment":0}}],"bucketingGroupType":null,"bucketingKey":"device_id","conditions":[],"name":"All Other Users"},"bucketingGroupType":null,"bucketingSalt":"ne4upNtg","customSegmentTargetingConfigs":[],"defaultValue":"off","deployed":true,"enabled":true,"experimentKey":"exp-1","flagKey":"sdk-ci-local-dependencies-test","flagVersion":9,"parentDependencies":{"flags":{"holdout-sdk-ci-local-dependencies-test-holdout":["on"],"mutex-sdk-ci-local-dependencies-test-mutex":["slot-1"]},"operator":"ALL"},"type":"experiment","variants":[{"key":"control","payload":null},{"key":"treatment","payload":null}],"variantsInclusions":{}},{"allUsersTargetingConfig":{"allocations":[{"percentage":0,"weights":{"on":1}}],"bucketingGroupType":null,"bucketingKey":"user_id","conditions":[],"name":"All Other Users"},"bucketingGroupType":null,"bucketingSalt":"e4BrRQzR","customSegmentTargetingConfigs":[{"allocations":[{"percentage":10000,"weights":{"on":1}}],"bucketingGroupType":null,"bucketingKey":"user_id","conditions":[{"op":"IS","prop":"userdata_cohort","values":["ursx46e","mg7og2z"]}],"name":"Segment 1"}],"defaultValue":"off","deployed":true,"enabled":true,"experimentKey":null,"flagKey":"sdk-cohort-ci-test","flagVersion":29,"parentDependencies":null,"type":"release","variants":[{"key":"on","payload":null}],"variantsInclusions":{}},{"allUsersTargetingConfig":{"allocations":[{"percentage":10000,"weights":{"on":1}}],"bucketingGroupType":null,"bucketingKey":"user_id","conditions":[],"name":"All Other Users"},"bucketingGroupType":null,"bucketingSalt":"LM8tqPRS","customSegmentTargetingConfigs":[],"defaultValue":"off","deployed":true,"enabled":true,"experimentKey":null,"flagKey":"sdk-local-evaluation-ci-test","flagVersion":7,"parentDependencies":null,"type":"release","variants":[{"key":"on","payload":"payload"}],"variantsInclusions":{}},{"allUsersTargetingConfig":{"allocations":[{"percentage":10000,"weights":{"holdout":99,"on":1}}],"bucketingGroupType":null,"bucketingKey":"device_id","conditions":[],"name":"All Other Users"},"bucketingGroupType":null,"bucketingSalt":"ubvfZywq","customSegmentTargetingConfigs":[],"defaultValue":"off","deployed":false,"enabled":true,"experimentKey":null,"flagKey":"holdout-sdk-ci-dependencies-test-force-holdout","flagVersion":2,"parentDependencies":null,"type":"holdout-group","variants":[{"key":"holdout","payload":{"flagIds":[]}},{"key":"on","payload":{"flagIds":["44564"]}}],"variantsInclusions":{}},{"allUsersTargetingConfig":{"allocations":[{"percentage":10000,"weights":{"control":1,"treatment":0}}],"bucketingGroupType":null,"bucketingKey":"device_id","conditions":[],"name":"All Other Users"},"bucketingGroupType":null,"bucketingSalt":"OI9rGc1K","customSegmentTargetingConfigs":[],"defaultValue":"off","deployed":true,"enabled":true,"experimentKey":"exp-1","flagKey":"sdk-ci-local-dependencies-test-holdout","flagVersion":5,"parentDependencies":{"flags":{"holdout-sdk-ci-dependencies-test-force-holdout":["on"]},"operator":"ALL"},"type":"experiment","variants":[{"key":"control","payload":null},{"key":"treatment","payload":null}],"variantsInclusions":{}}]' - stub_request(:get, 'https://api.lab.amplitude.com/sdk/v1/flags') + response = '[{"key":"holdout-sdk-ci-local-dependencies-test-holdout","metadata":{"deployed":false,"evaluationMode":"local","flagType":"holdout-group","flagVersion":1},"segments":[{"bucket":{"allocations":[{"distributions":[{"range":[0,429497],"variant":"holdout"},{"range":[429496,42949673],"variant":"on"}],"range":[0,100]}],"salt":"nI33zio8","selector":["context","user","device_id"]},"metadata":{"segmentName":"All Other Users"},"variant":"off"}],"variants":{"holdout":{"key":"holdout","payload":{"flagIds":[]},"value":"holdout"},"off":{"key":"off","metadata":{"default":true}},"on":{"key":"on","payload":{"flagIds":["42953"]},"value":"on"}}},{"key":"mutex-sdk-ci-local-dependencies-test-mutex","metadata":{"deployed":false,"evaluationMode":"local","flagType":"mutual-exclusion-group","flagVersion":1},"segments":[{"metadata":{"segmentName":"All Other Users"},"variant":"slot-1"}],"variants":{"off":{"key":"off","metadata":{"default":true}},"slot-1":{"key":"slot-1","payload":{"flagIds":["42953"]},"value":"slot-1"},"unallocated":{"key":"unallocated","payload":{"flagIds":[]},"value":"unallocated"}}},{"dependencies":["holdout-sdk-ci-local-dependencies-test-holdout","mutex-sdk-ci-local-dependencies-test-mutex"],"key":"sdk-ci-local-dependencies-test","metadata":{"deployed":true,"evaluationMode":"local","experimentKey":"exp-1","flagType":"experiment","flagVersion":9},"segments":[{"conditions":[[{"op":"is not","selector":["result","holdout-sdk-ci-local-dependencies-test-holdout","key"],"values":["on"]}],[{"op":"is not","selector":["result","mutex-sdk-ci-local-dependencies-test-mutex","key"],"values":["slot-1"]}]],"metadata":{"segmentName":"flag-dependencies"},"variant":"off"},{"metadata":{"segmentName":"All Other Users"},"variant":"control"}],"variants":{"control":{"key":"control","value":"control"},"off":{"key":"off","metadata":{"default":true}},"treatment":{"key":"treatment","value":"treatment"}}},{"key":"sdk-local-evaluation-ci-test","metadata":{"deployed":true,"evaluationMode":"local","flagType":"release","flagVersion":7},"segments":[{"metadata":{"segmentName":"All Other Users"},"variant":"on"}],"variants":{"off":{"key":"off","metadata":{"default":true}},"on":{"key":"on","payload":"payload","value":"on"}}},{"key":"holdout-sdk-ci-dependencies-test-force-holdout","metadata":{"deployed":false,"evaluationMode":"local","flagType":"holdout-group","flagVersion":2},"segments":[{"bucket":{"allocations":[{"distributions":[{"range":[0,42520177],"variant":"holdout"},{"range":[42520175,42949673],"variant":"on"}],"range":[0,100]}],"salt":"ubvfZywq","selector":["context","user","device_id"]},"metadata":{"segmentName":"All Other Users"},"variant":"off"}],"variants":{"holdout":{"key":"holdout","payload":{"flagIds":[]},"value":"holdout"},"off":{"key":"off","metadata":{"default":true}},"on":{"key":"on","payload":{"flagIds":["44564"]},"value":"on"}}},{"dependencies":["holdout-sdk-ci-dependencies-test-force-holdout"],"key":"sdk-ci-local-dependencies-test-holdout","metadata":{"deployed":true,"evaluationMode":"local","experimentKey":"exp-1","flagType":"experiment","flagVersion":5},"segments":[{"conditions":[[{"op":"is not","selector":["result","holdout-sdk-ci-dependencies-test-force-holdout","key"],"values":["on"]}]],"metadata":{"segmentName":"flag-dependencies"},"variant":"off"},{"metadata":{"segmentName":"All Other Users"},"variant":"control"}],"variants":{"control":{"key":"control","value":"control"},"off":{"key":"off","metadata":{"default":true}},"treatment":{"key":"treatment","value":"treatment"}}}]' + stub_request(:get, 'https://api.lab.amplitude.com/sdk/v2/flags?v=0') .with( headers: { 'Accept' => '*/*', 'Accept-Encoding' => 'gzip;q=1.0,deflate;q=0.6,identity;q=0.3', - 'Authorization' => "Api-Key #{SERVER_API_KEY}", + 'Authorization' => "Api-Key #{api_key}", 'Content-Type' => 'application/json;charset=utf-8', 'X-Amp-Exp-Library' => "experiment-ruby-server/#{VERSION}", 'User-Agent' => 'Ruby' @@ -36,56 +36,56 @@ def setup_stub it 'evaluation should return specific variants' do setup_stub - local_evaluation_client = LocalEvaluationClient.new(SERVER_API_KEY) + local_evaluation_client = LocalEvaluationClient.new(api_key) local_evaluation_client.start - result = local_evaluation_client.evaluate(TEST_USER, ['sdk-local-evaluation-ci-test']) - expect(result['sdk-local-evaluation-ci-test']).to eq(Variant.new('on', 'payload')) + result = local_evaluation_client.evaluate(test_user, ['sdk-local-evaluation-ci-test']) + expect(result['sdk-local-evaluation-ci-test']).to eq(Variant.new(key: 'on', value: 'on', payload: 'payload')) end it 'evaluation should return all variants' do setup_stub - local_evaluation_client = LocalEvaluationClient.new(SERVER_API_KEY) + local_evaluation_client = LocalEvaluationClient.new(api_key) local_evaluation_client.start - result = local_evaluation_client.evaluate(TEST_USER) - expect(result['sdk-local-evaluation-ci-test']).to eq(Variant.new('on', 'payload')) + result = local_evaluation_client.evaluate(test_user) + expect(result['sdk-local-evaluation-ci-test']).to eq(Variant.new(key: 'on', value: 'on', payload: 'payload')) end it 'evaluation with dependencies should return variant' do setup_stub - local_evaluation_client = LocalEvaluationClient.new(SERVER_API_KEY) + local_evaluation_client = LocalEvaluationClient.new(api_key) local_evaluation_client.start - result = local_evaluation_client.evaluate(TEST_USER_2) - expect(result['sdk-ci-local-dependencies-test']).to eq(Variant.new('control', nil)) + result = local_evaluation_client.evaluate(test_user2) + expect(result['sdk-ci-local-dependencies-test']).to eq(Variant.new(key: 'control', value: 'control')) end it 'evaluation with dependencies and flag keys should return variant' do setup_stub - local_evaluation_client = LocalEvaluationClient.new(SERVER_API_KEY) + local_evaluation_client = LocalEvaluationClient.new(api_key) local_evaluation_client.start - result = local_evaluation_client.evaluate(TEST_USER_2, ['sdk-ci-local-dependencies-test']) - expect(result['sdk-ci-local-dependencies-test']).to eq(Variant.new('control', nil)) + result = local_evaluation_client.evaluate(test_user2, ['sdk-ci-local-dependencies-test']) + expect(result['sdk-ci-local-dependencies-test']).to eq(Variant.new(key: 'control', value: 'control')) end it 'evaluation with dependencies and flag keys not existing should not return variant' do setup_stub - local_evaluation_client = LocalEvaluationClient.new(SERVER_API_KEY) + local_evaluation_client = LocalEvaluationClient.new(api_key) local_evaluation_client.start - result = local_evaluation_client.evaluate(TEST_USER_2, ['does-not-exist']) + result = local_evaluation_client.evaluate(test_user2, ['does-not-exist']) expect(result['sdk-ci-local-dependencies-test']).to eq(nil) end it 'evaluation with dependencies holdout excludes variant from experiment' do setup_stub - local_evaluation_client = LocalEvaluationClient.new(SERVER_API_KEY) + local_evaluation_client = LocalEvaluationClient.new(api_key) local_evaluation_client.start - result = local_evaluation_client.evaluate(TEST_USER_2) + result = local_evaluation_client.evaluate(test_user2) expect(result['sdk-ci-local-dependencies-test-holdout']).to eq(nil) end end diff --git a/spec/experiment/local/evaluation_spec.rb b/spec/experiment/local/evaluation_spec.rb deleted file mode 100644 index 2b8faf6..0000000 --- a/spec/experiment/local/evaluation_spec.rb +++ /dev/null @@ -1,25 +0,0 @@ -require 'spec_helper' -require 'experiment/local/evaluation/evaluation' - -module AmplitudeExperiment - describe EvaluationInterop do - rules_json = '[{"allUsersTargetingConfig":{"allocations":[{"percentage":0,"weights":{"array-payload":0,"control":0,"object-payload":0}}],"bucketingKey":"device_id","conditions":[],"name":"default-segment"},"bucketingKey":"device_id","bucketingSalt":"6jLqNjj5","customSegmentTargetingConfigs":[{"allocations":[{"percentage":9900,"weights":{"array-payload":0,"boolean-payload":0,"control":1,"null-payload":0,"number-payload":0,"object-payload":0,"string-payload":0,"treatment":0}}],"bucketingKey":"user_id","conditions":[{"op":"IS","prop":"gp:bucket","values":["user_id"]}],"name":"Bucket by User ID"},{"allocations":[{"percentage":9900,"weights":{"array-payload":0,"boolean-payload":0,"control":0,"null-payload":0,"number-payload":0,"object-payload":0,"string-payload":0,"treatment":1}}],"bucketingKey":"device_id","conditions":[{"op":"IS","prop":"gp:bucket","values":["device_id"]}],"name":"Bucket by Device ID"},{"allocations":[{"percentage":10000,"weights":{"array-payload":0,"boolean-payload":0,"control":0,"null-payload":0,"number-payload":0,"object-payload":0,"string-payload":1,"treatment":0}}],"bucketingKey":"device_id","conditions":[{"op":"IS","prop":"gp:test is","values":["string","true","1312.1"]},{"op":"IS_NOT","prop":"gp:test is not","values":["string","true","1312.1"]}],"name":"Test IS & IS NOT"},{"allocations":[{"percentage":10000,"weights":{"array-payload":0,"boolean-payload":1,"control":0,"null-payload":0,"number-payload":0,"object-payload":0,"string-payload":0,"treatment":0}}],"bucketingKey":"device_id","conditions":[{"op":"CONTAINS","prop":"gp:test contains","values":["@amplitude.com"]},{"op":"DOES_NOT_CONTAIN","prop":"gp:test does not contain","values":["asdf"]}],"name":"Test CONTAINS & DOES_NOT_CONTAIN"},{"allocations":[{"percentage":10000,"weights":{"array-payload":0,"boolean-payload":0,"control":0,"null-payload":0,"number-payload":0,"object-payload":1,"string-payload":0,"treatment":0}}],"bucketingKey":"device_id","conditions":[{"op":"GREATER_THAN","prop":"gp:test greater","values":["1.2.3"]},{"op":"GREATER_THAN_EQUALS","prop":"gp:test greater or equal","values":["1.2.3"]},{"op":"LESS_THAN","prop":"gp:test less","values":["1.2.3"]},{"op":"LESS_THAN_EQUALS","prop":"gp:test less or equal","values":["1.2.3"]}],"name":"Test GREATER & GREATER OR EQUAL & LESS & LESS OR EQUAL"},{"allocations":[{"percentage":10000,"weights":{"array-payload":0,"boolean-payload":0,"control":0,"null-payload":1,"number-payload":0,"object-payload":0,"string-payload":0,"treatment":0}}],"bucketingKey":"device_id","conditions":[{"op":"SET_CONTAINS","prop":"gp:test set contains","values":["asdf"]}],"name":"Test SET_CONTAINS (not supported)"}],"defaultValue":"off","enabled":true,"evalMode":"LOCAL","flagKey":"sdk-local-evaluation-unit-test","flagName":"sdk-local-evaluation-unit-test","flagVersion":33,"globalHoldbackBucketingKey":"amplitude_id","globalHoldbackPct":0,"globalHoldbackSalt":null,"mutualExclusionConfig":null,"type":"RELEASE","useStickyBucketing":false,"userProperty":"[Experiment] sdk-local-evaluation-unit-test","variants":[{"key":"control","payload":null},{"key":"treatment","payload":null},{"key":"string-payload","payload":"string"},{"key":"number-payload","payload":1312.1},{"key":"boolean-payload","payload":true},{"key":"object-payload","payload":{"array":[1,2,3],"boolean":true,"number":2,"object":{"k":"v"},"string":"value"}},{"key":"array-payload","payload":[1,2,3,"4",true,{"k":"v"},[1,2,3]]},{"key":"null-payload","payload":null}],"variantsExclusions":null,"variantsInclusions":{"array-payload":["array-payload"],"boolean-payload":["boolean-payload"],"control":["control"],"null-payload":["null-payload"],"number-payload":["number-payload"],"object-payload":["object-payload"],"string-payload":["string-payload"],"treatment":["treatment"]}}]' - user_json = '{"user_id":"control","device_id":"control"}' - expected_result = { - 'sdk-local-evaluation-unit-test' => { - 'variant' => { 'key' => 'control' }, - 'description' => 'inclusion-list', - 'isDefaultVariant' => false, - 'deployed' => true, - 'type' => 'RELEASE' - } - } - - describe '#evaluation' do - it 'get the variants for the given user and rule' do - result = evaluation(rules_json, user_json) - expect(result).to eq(expected_result) - end - end - end -end diff --git a/spec/experiment/local/util/topological_sort_spec.rb b/spec/experiment/local/util/topological_sort_spec.rb new file mode 100644 index 0000000..d254b47 --- /dev/null +++ b/spec/experiment/local/util/topological_sort_spec.rb @@ -0,0 +1,235 @@ +module AmplitudeExperiment + RSpec.describe 'TopologicalSort' do + def sort(flags, flag_keys = nil) + flag_keys_strings = flag_keys ? flag_keys.map(&:to_s) : [] + flags_dict = flags.each_with_object({}) do |flag, hash| + hash[flag['key']] = flag + end + AmplitudeExperiment.topological_sort(flags_dict, flag_keys_strings, ordered: true) + end + + def flag(key, dependencies) + { 'key' => key.to_s, 'dependencies' => dependencies.map(&:to_s) } + end + + it 'handles empty flags' do + flags = [] + # no flag keys + result = sort(flags) + expect(result).to eq([]) + # with flag keys + result = sort(flags, [1]) + expect(result).to eq([]) + end + + it 'handles single flag with no dependencies' do + flags = [flag(1, [])] + # no flag keys + result = sort(flags) + expect(result).to eq(flags) + # with flag keys + result = sort(flags, [1]) + expect(result).to eq(flags) + # with flag keys, no match + result = sort(flags, [999]) + expect(result).to eq([]) + end + + it 'handles single flag with dependencies' do + flags = [flag(1, [2])] + # no flag keys + result = sort(flags) + expect(result).to eq(flags) + # with flag keys + result = sort(flags, [1]) + expect(result).to eq(flags) + # with flag keys, no match + result = sort(flags, [999]) + expect(result).to eq([]) + end + + it 'handles multiple flags with no dependencies' do + flags = [flag(1, []), flag(2, [])] + # no flag keys + result = sort(flags) + expect(result).to eq(flags) + # with flag keys + result = sort(flags, [1, 2]) + expect(result).to eq(flags) + # with flag keys, no match + result = sort(flags, [99, 999]) + expect(result).to eq([]) + end + + it 'handles multiple flags with dependencies' do + flags = [flag(1, [2]), flag(2, [3]), flag(3, [])] + # no flag keys + result = sort(flags) + expect(result).to eq([flag(3, []), flag(2, [3]), flag(1, [2])]) + # with flag keys + result = sort(flags, [1, 2]) + expect(result).to eq([flag(3, []), flag(2, [3]), flag(1, [2])]) + # with flag keys, no match + result = sort(flags, [99, 999]) + expect(result).to eq([]) + end + + it 'handles single flag cycle' do + flags = [flag(1, [1])] + # no flag keys + expect do + sort(flags) + end.to raise_error(CycleError) { |e| expect(e.path).to eq(['1'].to_set) } + # with flag keys + expect do + sort(flags, [1]) + end.to raise_error(CycleError) { |e| expect(e.path).to eq(['1'].to_set) } + # with flag keys, no match + expect do + result = sort(flags, [999]) + expect(result).to eq([]) + end.not_to raise_error + end + + it 'handles two flag cycle' do + flags = [flag(1, [2]), flag(2, [1])] + # no flag keys + expect do + sort(flags) + end.to raise_error(CycleError) { |e| expect(e.path).to eq(%w[1 2].to_set) } + # with flag keys + expect do + sort(flags, [1, 2]) + end.to raise_error(CycleError) { |e| expect(e.path).to eq(%w[1 2].to_set) } + # with flag keys, no match + expect do + result = sort(flags, [999]) + expect(result).to eq([]) + end.not_to raise_error + end + + it 'handles multiple flags with complex cycle' do + flags = [ + flag(3, [1, 2]), + flag(1, []), + flag(4, [21, 3]), + flag(2, []), + flag(5, [3]), + flag(6, []), + flag(7, []), + flag(8, [9]), + flag(9, []), + flag(20, [4]), + flag(21, [20]) + ] + expect do + sort(flags, [3, 1, 4, 2, 5, 6, 7, 8, 9, 20, 21]) + end.to raise_error(CycleError) { |e| expect(e.path).to eq(%w[4 21 20].to_set) } + end + + it 'handles multiple flags with complex dependencies without cycle starting at leaf' do + flags = [ + flag(1, [6, 3]), + flag(2, [8, 5, 3, 1]), + flag(3, [6, 5]), + flag(4, [8, 7]), + flag(5, [10, 7]), + flag(7, [8]), + flag(6, [7, 4]), + flag(8, []), + flag(9, [10, 7, 5]), + flag(10, [7]), + flag(20, []), + flag(21, [20]), + flag(30, []) + ] + result = sort(flags, [1, 2, 3, 4, 5, 7, 6, 8, 9, 10, 20, 21, 30]) + expected = [ + flag(8, []), + flag(7, [8]), + flag(4, [8, 7]), + flag(6, [7, 4]), + flag(10, [7]), + flag(5, [10, 7]), + flag(3, [6, 5]), + flag(1, [6, 3]), + flag(2, [8, 5, 3, 1]), + flag(9, [10, 7, 5]), + flag(20, []), + flag(21, [20]), + flag(30, []) + ] + expect(result).to eq(expected) + end + + it 'handles multiple flags with complex dependencies without cycle starting at middle' do + flags = [ + flag(6, [7, 4]), + flag(1, [6, 3]), + flag(2, [8, 5, 3, 1]), + flag(3, [6, 5]), + flag(4, [8, 7]), + flag(5, [10, 7]), + flag(7, [8]), + flag(8, []), + flag(9, [10, 7, 5]), + flag(10, [7]), + flag(20, []), + flag(21, [20]), + flag(30, []) + ] + result = sort(flags, [6, 1, 2, 3, 4, 5, 7, 8, 9, 10, 20, 21, 30]) + expected = [ + flag(8, []), + flag(7, [8]), + flag(4, [8, 7]), + flag(6, [7, 4]), + flag(10, [7]), + flag(5, [10, 7]), + flag(3, [6, 5]), + flag(1, [6, 3]), + flag(2, [8, 5, 3, 1]), + flag(9, [10, 7, 5]), + flag(20, []), + flag(21, [20]), + flag(30, []) + ] + expect(result).to eq(expected) + end + + it 'handles multiple flags with complex dependencies without cycle starting at root' do + flags = [ + flag(8, []), + flag(1, [6, 3]), + flag(2, [8, 5, 3, 1]), + flag(3, [6, 5]), + flag(4, [8, 7]), + flag(5, [10, 7]), + flag(6, [7, 4]), + flag(7, [8]), + flag(9, [10, 7, 5]), + flag(10, [7]), + flag(20, []), + flag(21, [20]), + flag(30, []) + ] + result = sort(flags, [8, 1, 2, 3, 4, 5, 6, 7, 9, 10, 20, 21, 30]) + expected = [ + flag(8, []), + flag(7, [8]), + flag(4, [8, 7]), + flag(6, [7, 4]), + flag(10, [7]), + flag(5, [10, 7]), + flag(3, [6, 5]), + flag(1, [6, 3]), + flag(2, [8, 5, 3, 1]), + flag(9, [10, 7, 5]), + flag(20, []), + flag(21, [20]), + flag(30, []) + ] + expect(result).to eq(expected) + end + end +end diff --git a/spec/experiment/local/util/user_spec.rb b/spec/experiment/local/util/user_spec.rb new file mode 100644 index 0000000..68a2aef --- /dev/null +++ b/spec/experiment/local/util/user_spec.rb @@ -0,0 +1,97 @@ +module AmplitudeExperiment + RSpec.describe 'User to Evaluation Context' do + it 'returns the correct context with user and groups' do + user = User.new( + device_id: 'device_id', + user_id: 'user_id', + country: 'country', + city: 'city', + language: 'language', + platform: 'platform', + version: 'version', + user_properties: { k: 'v' }, + groups: { type: 'name' }, + group_properties: { type: { name: { gk: 'gv' } } } + ) + context = AmplitudeExperiment.user_to_evaluation_context(user) + expected_context = { + user: { + device_id: 'device_id', + user_id: 'user_id', + country: 'country', + city: 'city', + language: 'language', + platform: 'platform', + version: 'version', + user_properties: { k: 'v' } + }, + groups: { + type: { + group_name: 'name', + group_properties: { gk: 'gv' } + } + } + } + expect(context).to eq(expected_context) + end + + it 'returns the correct context with only user' do + user = User.new( + device_id: 'device_id', + user_id: 'user_id', + country: 'country', + city: 'city', + language: 'language', + platform: 'platform', + version: 'version', + user_properties: { k: 'v' } + ) + context = AmplitudeExperiment.user_to_evaluation_context(user) + expected_context = { + user: { + device_id: 'device_id', + user_id: 'user_id', + country: 'country', + city: 'city', + language: 'language', + platform: 'platform', + version: 'version', + user_properties: { k: 'v' } + } + } + expect(context).to eq(expected_context) + end + + it 'returns the correct context with only groups' do + user = User.new( + groups: { type: 'name' }, + group_properties: { type: { name: { gk: 'gv' } } } + ) + context = AmplitudeExperiment.user_to_evaluation_context(user) + expected_context = { + groups: { + type: { + group_name: 'name', + group_properties: { gk: 'gv' } + } + } + } + expect(context).to eq(expected_context) + end + + it 'returns the correct context with only groups but no group properties' do + user = User.new( + groups: { type: 'name' } + ) + context = AmplitudeExperiment.user_to_evaluation_context(user) + expected_context = { + groups: { + type: { + group_name: 'name' + } + } + } + expect(context).to eq(expected_context) + end + end +end diff --git a/spec/experiment/local/util/variant_spec.rb b/spec/experiment/local/util/variant_spec.rb new file mode 100644 index 0000000..53c78e9 --- /dev/null +++ b/spec/experiment/local/util/variant_spec.rb @@ -0,0 +1,81 @@ +module AmplitudeExperiment + # RSpec tests + RSpec.describe 'EvaluationVariant' do + describe '#AmplitudeExperiment.evaluation_variant_json_to_variant' do + it 'handles string value' do + evaluation_variant = { 'key' => 'on', 'value' => 'test' } + variant = AmplitudeExperiment.evaluation_variant_json_to_variant(evaluation_variant) + expect(variant).to eq(Variant.new(key: 'on', value: 'test')) + end + + it 'handles boolean value' do + evaluation_variant = { 'key' => 'on', 'value' => true } + variant = AmplitudeExperiment.evaluation_variant_json_to_variant(evaluation_variant) + expect(variant).to eq(Variant.new(key: 'on', value: 'true')) + end + + it 'handles int value' do + evaluation_variant = { 'key' => 'on', 'value' => 10 } + variant = AmplitudeExperiment.evaluation_variant_json_to_variant(evaluation_variant) + expect(variant).to eq(Variant.new(key: 'on', value: '10')) + end + + it 'handles float value' do + evaluation_variant = { 'key' => 'on', 'value' => 10.2 } + variant = AmplitudeExperiment.evaluation_variant_json_to_variant(evaluation_variant) + expect(variant).to eq(Variant.new(key: 'on', value: '10.2')) + end + + it 'handles array value' do + evaluation_variant = { 'key' => 'on', 'value' => [1, 2, 3] } + variant = AmplitudeExperiment.evaluation_variant_json_to_variant(evaluation_variant) + expect(variant).to eq(Variant.new(key: 'on', value: '[1,2,3]')) + end + + it 'handles object value' do + evaluation_variant = { 'key' => 'on', 'value' => { 'k' => 'v' } } + variant = AmplitudeExperiment.evaluation_variant_json_to_variant(evaluation_variant) + expect(variant).to eq(Variant.new(key: 'on', value: '{"k":"v"}')) + end + + it 'handles null value' do + evaluation_variant = { 'key' => 'on', 'value' => nil } + variant = AmplitudeExperiment.evaluation_variant_json_to_variant(evaluation_variant) + expect(variant).to eq(Variant.new(key: 'on', value: nil)) + end + + it 'handles undefined value' do + evaluation_variant = { 'key' => 'on' } + variant = AmplitudeExperiment.evaluation_variant_json_to_variant(evaluation_variant) + expect(variant).to eq(Variant.new(key: 'on', value: nil)) + end + end + + describe '#AmplitudeExperiment.evaluation_variants_json_to_variants' do + it 'handles multiple variants' do + evaluation_variants = { + 'string' => { 'key' => 'on', 'value' => 'test' }, + 'boolean' => { 'key' => 'on', 'value' => true }, + 'int' => { 'key' => 'on', 'value' => 10 }, + 'float' => { 'key' => 'on', 'value' => 10.2 }, + 'array' => { 'key' => 'on', 'value' => [1, 2, 3] }, + 'object' => { 'key' => 'on', 'value' => { 'k' => 'v' } }, + 'null' => { 'key' => 'on', 'value' => nil }, + 'undefined' => { 'key' => 'on' } + } + variants = AmplitudeExperiment.evaluation_variants_json_to_variants(evaluation_variants) + expected_variants = { + 'string' => Variant.new(key: 'on', value: 'test'), + 'boolean' => Variant.new(key: 'on', value: 'true'), + 'int' => Variant.new(key: 'on', value: '10'), + 'float' => Variant.new(key: 'on', value: '10.2'), + 'array' => Variant.new(key: 'on', value: '[1,2,3]'), + 'object' => Variant.new(key: 'on', value: '{"k":"v"}'), + 'null' => Variant.new(key: 'on', value: nil), + 'undefined' => Variant.new(key: 'on', value: nil) + } + expect(variants).to eq(expected_variants) + end + end + end +end diff --git a/spec/experiment/remote/client_spec.rb b/spec/experiment/remote/client_spec.rb index 5f5f8ba..793744f 100644 --- a/spec/experiment/remote/client_spec.rb +++ b/spec/experiment/remote/client_spec.rb @@ -1,16 +1,14 @@ -require 'spec_helper' - module AmplitudeExperiment - API_KEY = 'client-DvWljIjiiuqLbyjqdvBaLFfEBrAvGuA3'.freeze - SERVER_URL = 'https://api.lab.amplitude.com/sdk/v2/vardata?v=0'.freeze - describe RemoteEvaluationClient do + let(:api_key) { 'client-DvWljIjiiuqLbyjqdvBaLFfEBrAvGuA3' } + let(:server_url) { 'https://api.lab.amplitude.com/sdk/v2/vardata?v=0' } + describe '#initialize' do - it 'error if api_key is nil' do + it 'raises an error if api_key is nil' do expect { RemoteEvaluationClient.new(nil) }.to raise_error(ArgumentError) end - it 'error if api_key is empty' do + it 'raises an error if api_key is empty' do expect { RemoteEvaluationClient.new('') }.to raise_error(ArgumentError) end end @@ -32,33 +30,43 @@ module AmplitudeExperiment 'test_user_property' => 'test value' * 1000 }) - describe '#fetch' do - def self.test_fetch(response, test_user, variant_name, debug, expected_state, expected_payload, expected_key = nil) - it "fetch sync success with response #{response}, user #{test_user.user_id}, debug #{debug}" do - stub_request(:post, SERVER_URL) - .to_return(status: 200, body: response) - client = RemoteEvaluationClient.new(API_KEY, RemoteEvaluationConfig.new(debug: debug)) - expected_variant = Variant.new(expected_state, expected_payload, expected_key) - variants = client.fetch(test_user) - expect(variants.key?(variant_name)).to be_truthy - expect(variants.fetch(variant_name)).to eq(expected_variant) - end + def self.test_fetch_shared(response, test_user, variant_name, debug, expected_variant) + it "fetch sync success with response #{response}, user #{test_user.user_id}, debug #{debug}" do + stub_request(:post, server_url) + .to_return(status: 200, body: response) + client = RemoteEvaluationClient.new(api_key, RemoteEvaluationConfig.new(debug: debug)) + variants = client.fetch(test_user) + expect(variants.key?(variant_name)).to be_truthy + expect(variants.fetch(variant_name)).to eq(expected_variant) end + end - test_fetch response_with_key, test_user, variant_name, false, 'on', 'payload', 'on' - test_fetch response_with_value, test_user_with_properties, variant_name, false, 'on', 'payload' - test_fetch response_with_int_payload, test_user, variant_name, true, 'off', 123, 'off' - test_fetch response_with_boolean_payload, test_user_with_properties, variant_name, false, 'on', false, 'on' - test_fetch response_with_list_payload, test_user, variant_name, false, 'on', %w[payload1 payload2], 'on' - test_fetch response_with_hash_payload, test_user_with_properties, variant_name, false, 'off', { 'nested' => 'nested payload' }, 'off' - test_fetch response_without_payload, test_user, variant_name, false, 'on', nil, 'on' - test_fetch response_with_value_without_payload, test_user, variant_name, false, 'on', nil + def self.test_fetch_v2_shared(response, test_user, variant_name, debug, expected_variant) + it "fetch v2 sync success with response #{response}, user #{test_user.user_id}, debug #{debug}" do + stub_request(:post, server_url) + .to_return(status: 200, body: response) + client = RemoteEvaluationClient.new(api_key, RemoteEvaluationConfig.new(debug: debug)) + variants = client.fetch_v2(test_user) + expect(variants.key?(variant_name)).to be_truthy + expect(variants.fetch(variant_name)).to eq(expected_variant) + end + end + + describe '#fetch' do + test_fetch_shared response_with_key, test_user, variant_name, false, Variant.new(payload: 'payload', key: 'on') + test_fetch_shared response_with_value, test_user_with_properties, variant_name, false, Variant.new(value: 'on', payload: 'payload') + test_fetch_shared response_with_int_payload, test_user, variant_name, true, Variant.new(payload: 123, key: 'off') + test_fetch_shared response_with_boolean_payload, test_user_with_properties, variant_name, false, Variant.new(payload: false, key: 'on') + test_fetch_shared response_with_list_payload, test_user, variant_name, false, Variant.new(payload: %w[payload1 payload2], key: 'on') + test_fetch_shared response_with_hash_payload, test_user_with_properties, variant_name, false, Variant.new(payload: { 'nested' => 'nested payload' }, key: 'off') + test_fetch_shared response_without_payload, test_user, variant_name, false, Variant.new(key: 'on') + test_fetch_shared response_with_value_without_payload, test_user, variant_name, false, Variant.new(value: 'on') it 'fetch timeout failure' do - stub_request(:post, SERVER_URL) + stub_request(:post, server_url) .to_timeout test_user = User.new(user_id: 'test_user') - client = RemoteEvaluationClient.new(API_KEY, RemoteEvaluationConfig.new(fetch_timeout_millis: 1, fetch_retries: 1, debug: true)) + client = RemoteEvaluationClient.new(api_key, RemoteEvaluationConfig.new(fetch_timeout_millis: 1, fetch_retries: 1, debug: true)) variants = nil expect { variants = client.fetch(test_user) }.to output(/Retrying fetch/).to_stdout_from_any_process expect(variants).to eq({}) @@ -70,12 +78,11 @@ def self.test_fetch(response, test_user, variant_name, debug, expected_state, ex allow(Thread).to receive(:new).and_yield end - def self.test_fetch_async(response, test_user, variant_name, debug, expected_state, expected_payload, expected_key = nil) + def self.test_fetch_async_shared(response, test_user, variant_name, debug, expected_variant) it "fetch async success with response #{response}, user #{test_user.user_id}, debug #{debug}" do - stub_request(:post, SERVER_URL) + stub_request(:post, server_url) .to_return(status: 200, body: response) - client = RemoteEvaluationClient.new(API_KEY, RemoteEvaluationConfig.new(debug: debug)) - expected_variant = Variant.new(expected_state, expected_payload, expected_key) + client = RemoteEvaluationClient.new(api_key, RemoteEvaluationConfig.new(debug: debug)) variants = client.fetch_async(test_user) do |user, block_variants| expect(user).to equal(test_user) expect(block_variants.fetch(variant_name)).to eq(expected_variant) @@ -85,20 +92,20 @@ def self.test_fetch_async(response, test_user, variant_name, debug, expected_sta end end - test_fetch_async response_with_key, test_user, variant_name, false, 'on', 'payload', 'on' - test_fetch_async response_with_value, test_user_with_properties, variant_name, false, 'on', 'payload' - test_fetch_async response_with_int_payload, test_user, variant_name, true, 'off', 123, 'off' - test_fetch_async response_with_boolean_payload, test_user_with_properties, variant_name, false, 'on', false, 'on' - test_fetch_async response_with_list_payload, test_user, variant_name, false, 'on', %w[payload1 payload2], 'on' - test_fetch_async response_with_hash_payload, test_user_with_properties, variant_name, false, 'off', { 'nested' => 'nested payload' }, 'off' - test_fetch_async response_without_payload, test_user, variant_name, false, 'on', nil, 'on' - test_fetch_async response_with_value_without_payload, test_user, variant_name, false, 'on', nil + test_fetch_async_shared response_with_key, test_user, variant_name, false, Variant.new(payload: 'payload', key: 'on') + test_fetch_async_shared response_with_value, test_user_with_properties, variant_name, false, Variant.new(value: 'on', payload: 'payload') + test_fetch_async_shared response_with_int_payload, test_user, variant_name, true, Variant.new(payload: 123, key: 'off') + test_fetch_async_shared response_with_boolean_payload, test_user_with_properties, variant_name, false, Variant.new(payload: false, key: 'on') + test_fetch_async_shared response_with_list_payload, test_user, variant_name, false, Variant.new(payload: %w[payload1 payload2], key: 'on') + test_fetch_async_shared response_with_hash_payload, test_user_with_properties, variant_name, false, Variant.new(payload: { 'nested' => 'nested payload' }, key: 'off') + test_fetch_async_shared response_without_payload, test_user, variant_name, false, Variant.new(key: 'on') + test_fetch_async_shared response_with_value_without_payload, test_user, variant_name, false, Variant.new(value: 'on') it 'fetch async timeout failure' do - stub_request(:post, SERVER_URL) + stub_request(:post, server_url) .to_timeout test_user = User.new(user_id: 'test_user') - client = RemoteEvaluationClient.new(API_KEY, RemoteEvaluationConfig.new(fetch_timeout_millis: 1, fetch_retries: 1, debug: true)) + client = RemoteEvaluationClient.new(api_key, RemoteEvaluationConfig.new(fetch_timeout_millis: 1, fetch_retries: 1, debug: true)) variants = client.fetch_async(test_user) do |user, block_variants| expect(user).to equal(test_user) expect(block_variants).to eq({}) @@ -115,7 +122,7 @@ def self.test_fetch_async(response, test_user, variant_name, debug, expected_sta [0, 'Other Exception', true] ].each do |response_code, error_message, should_call_retry| it "handles response code #{response_code}" do - client = RemoteEvaluationClient.new(API_KEY, RemoteEvaluationConfig.new(fetch_retries: 1)) + client = RemoteEvaluationClient.new(api_key, RemoteEvaluationConfig.new(fetch_retries: 1)) allow(client).to receive(:retry_fetch) allow(client).to receive(:do_fetch) do response_code == 0 ? raise(StandardError, error_message) : raise(FetchError.new(response_code, error_message)) @@ -130,32 +137,20 @@ def self.test_fetch_async(response, test_user, variant_name, debug, expected_sta end describe '#fetch_v2' do - def self.test_fetch_v2(response, test_user, variant_name, debug, expected_state, expected_payload, expected_key = nil) - it "fetch v2 sync success with response #{response}, user #{test_user.user_id}, debug #{debug}" do - stub_request(:post, SERVER_URL) - .to_return(status: 200, body: response) - client = RemoteEvaluationClient.new(API_KEY, RemoteEvaluationConfig.new(debug: debug)) - expected_variant = Variant.new(expected_state, expected_payload, expected_key) - variants = client.fetch_v2(test_user) - expect(variants.key?(variant_name)).to be_truthy - expect(variants.fetch(variant_name)).to eq(expected_variant) - end - end - - test_fetch_v2 response_with_key, test_user, variant_name, false, 'on', 'payload', 'on' - test_fetch_v2 response_with_value, test_user_with_properties, variant_name, false, 'on', 'payload' - test_fetch_v2 response_with_int_payload, test_user, variant_name, true, 'off', 123, 'off' - test_fetch_v2 response_with_boolean_payload, test_user_with_properties, variant_name, false, 'on', false, 'on' - test_fetch_v2 response_with_list_payload, test_user, variant_name, false, 'on', %w[payload1 payload2], 'on' - test_fetch_v2 response_with_hash_payload, test_user_with_properties, variant_name, false, 'off', { 'nested' => 'nested payload' }, 'off' - test_fetch_v2 response_without_payload, test_user, variant_name, false, 'on', nil, 'on' - test_fetch_v2 response_with_value_without_payload, test_user, variant_name, false, 'on', nil + test_fetch_v2_shared response_with_key, test_user, variant_name, false, Variant.new(key: 'on', payload: 'payload') + test_fetch_v2_shared response_with_value, test_user_with_properties, variant_name, false, Variant.new(payload: 'payload', value: 'on') + test_fetch_v2_shared response_with_int_payload, test_user, variant_name, true, Variant.new(key: 'off', payload: 123) + test_fetch_v2_shared response_with_boolean_payload, test_user_with_properties, variant_name, false, Variant.new(key: 'on', payload: false) + test_fetch_v2_shared response_with_list_payload, test_user, variant_name, false, Variant.new(key: 'on', payload: %w[payload1 payload2]) + test_fetch_v2_shared response_with_hash_payload, test_user_with_properties, variant_name, false, Variant.new(key: 'off', payload: { 'nested' => 'nested payload' }) + test_fetch_v2_shared response_without_payload, test_user, variant_name, false, Variant.new(key: 'on') + test_fetch_v2_shared response_with_value_without_payload, test_user, variant_name, false, Variant.new(value: 'on') it 'fetch v2 timeout failure' do - stub_request(:post, SERVER_URL) + stub_request(:post, server_url) .to_timeout test_user = User.new(user_id: 'test_user') - client = RemoteEvaluationClient.new(API_KEY, RemoteEvaluationConfig.new(fetch_timeout_millis: 1, fetch_retries: 1, debug: true)) + client = RemoteEvaluationClient.new(api_key, RemoteEvaluationConfig.new(fetch_timeout_millis: 1, fetch_retries: 1, debug: true)) variants = nil expect { variants = client.fetch_v2(test_user) }.to output(/Retrying fetch/).to_stdout_from_any_process expect(variants).to eq({})