Skip to content

Add feature flag accessor API #56

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

Merged
merged 3 commits into from
Aug 29, 2017
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
69 changes: 59 additions & 10 deletions lib/optimizely.rb
Original file line number Diff line number Diff line change
Expand Up @@ -107,17 +107,8 @@ def activate(experiment_key, user_id, attributes = nil)
end

# Create and dispatch impression event
variation_id = @config.get_variation_id_from_key(experiment_key, variation_key)
experiment = @config.get_experiment_from_key(experiment_key)
impression_event = @event_builder.create_impression_event(experiment, variation_id, user_id, attributes)
@logger.log(Logger::INFO,
'Dispatching impression event to URL %s with params %s.' % [impression_event.url,
impression_event.params])
begin
@event_dispatcher.dispatch_event(impression_event)
rescue => e
@logger.log(Logger::ERROR, "Unable to dispatch impression event. Error: #{e}")
end
send_impression(experiment, variation_key, user_id, attributes)

variation_key
end
Expand Down Expand Up @@ -202,6 +193,50 @@ def track(event_key, user_id, attributes = nil, event_tags = nil)
end
end

def is_feature_enabled(feature_flag_key, user_id, attributes = nil)
# Determine whether a feature is enabled.
# Sends an impression event if the user is bucketed into an experiment using the feature.
#
# feature_flag_key - String unique key of the feature.
# userId - String ID of the user.
# attributes - Hash representing visitor attributes and values which need to be recorded.
#
# Returns True if the feature is enabled.
# False if the feature is disabled.
# False if the feature is not found.

unless @is_valid
logger = SimpleLogger.new
logger.log(Logger::ERROR, InvalidDatafileError.new('is_feature_enabled').message)
return nil
end

feature_flag = @config.get_feature_flag_from_key(feature_flag_key)
unless feature_flag
@logger.log(Logger::ERROR, "No feature flag was found for key '#{feature_flag_key}'.")
return false
end

decision = @decision_service.get_variation_for_feature(feature_flag, user_id, attributes)
unless decision.nil?
variation = decision['variation']
experiment = decision['experiment']
unless experiment.nil?
send_impression(experiment, variation['key'], user_id, attributes)
else
@logger.log(Logger::DEBUG,
"The user '#{user_id}' is not being experimented on in feature '#{feature_flag_key}'.")
end

@logger.log(Logger::INFO, "Feature '#{feature_flag_key}' is enabled for user '#{user_id}'.")
return true
end

@logger.log(Logger::INFO,
"Feature '#{feature_flag_key}' is not enabled for user '#{user_id}'.")
false
end

private

def get_valid_experiments_for_event(event_key, user_id, attributes)
Expand Down Expand Up @@ -278,5 +313,19 @@ def validate_instantiation_options(datafile, skip_json_validation)
raise InvalidInputError.new('error_handler') unless Helpers::Validator.error_handler_valid?(@error_handler)
raise InvalidInputError.new('event_dispatcher') unless Helpers::Validator.event_dispatcher_valid?(@event_dispatcher)
end

def send_impression(experiment, variation_key, user_id, attributes = nil)
experiment_key = experiment['key']
variation_id = @config.get_variation_id_from_key(experiment_key, variation_key)
impression_event = @event_builder.create_impression_event(experiment, variation_id, user_id, attributes)
@logger.log(Logger::INFO,
'Dispatching impression event to URL %s with params %s.' % [impression_event.url,
impression_event.params])
begin
@event_dispatcher.dispatch_event(impression_event)
rescue => e
@logger.log(Logger::ERROR, "Unable to dispatch impression event. Error: #{e}")
end
end
end
end
13 changes: 8 additions & 5 deletions lib/optimizely/decision_service.rb
Original file line number Diff line number Diff line change
Expand Up @@ -97,11 +97,11 @@ def get_variation_for_feature(feature_flag, user_id, attributes = nil)
# user_id - String ID for the user
# attributes - Hash representing user attributes
#
# Returns variation where visitor will be bucketed (nil if the user is not bucketed into any of the experiments on the feature)
# Returns hash with the experiment and variation where visitor will be bucketed (nil if the user is not bucketed into any of the experiments on the feature)

# check if the feature is being experiment on and whether the user is bucketed into the experiment
variation = get_variation_for_feature_experiment(feature_flag, user_id, attributes)
return variation
decision = get_variation_for_feature_experiment(feature_flag, user_id, attributes)
return decision

# @TODO(mng) next check if the user feature being rolled out and whether the user is part of the rollout
end
Expand All @@ -115,7 +115,7 @@ def get_variation_for_feature_experiment(feature_flag, user_id, attributes = nil
# user_id - String ID for the user
# attributes - Hash representing user attributes
#
# Returns variation where visitor will be bucketed
# Returns a hash with the experiment and variation where visitor will be bucketed
# or nil if the user is not bucketed into any of the experiments on the feature

feature_flag_key = feature_flag['key']
Expand Down Expand Up @@ -157,7 +157,10 @@ def get_variation_for_feature_experiment(feature_flag, user_id, attributes = nil
Logger::INFO,
"The user '#{user_id}' is bucketed into experiment '#{experiment_key}' of feature '#{feature_flag_key}'."
)
return variation
return {
'variation' => variation,
'experiment' => experiment
}
else
@config.logger.log(
Logger::INFO,
Expand Down
24 changes: 23 additions & 1 deletion lib/optimizely/project_config.rb
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ class ProjectConfig
attr_reader :audiences
attr_reader :events
attr_reader :experiments
attr_reader :feature_flags
attr_reader :groups
attr_reader :parsing_succeeded
attr_reader :project_id
Expand Down Expand Up @@ -97,6 +98,15 @@ def initialize(datafile, logger, error_handler)
@variation_id_map = {}
@variation_key_map = {}
@variation_id_to_variable_usage_map = {}
@variation_id_to_experiment_map = {}
@experiment_key_map.each do |key, exp|
# Excludes experiments from rollouts
variations = exp.fetch('variations')
variations.each do |variation|
variation_id = variation['id']
@variation_id_to_experiment_map[variation_id] = exp
end
end
@rollout_id_map = generate_key_map(@rollouts, 'id')
# split out the experiment id map for rollouts
@rollout_experiment_id_map = {}
Expand Down Expand Up @@ -136,7 +146,7 @@ def get_experiment_from_key(experiment_key)
#
# experiment_key - String key representing the experiment
#
# Returns Experiment
# Returns Experiment or nil if not found

experiment = @experiment_key_map[experiment_key]
return experiment if experiment
Expand Down Expand Up @@ -281,6 +291,18 @@ def variation_id_exists?(experiment_id, variation_id)
false
end

def get_feature_flag_from_key(feature_flag_key)
# Retrieves the feature flag with the given key
#
# feature_flag_key - String feature key
#
# Returns feature flag if found, otherwise nil
feature_flag = @feature_flag_key_map[feature_flag_key]
return feature_flag if feature_flag
@logger.log Logger::ERROR, "Feature flag key '#{feature_flag_key}' is not in datafile."
nil
end

private

def generate_key_map(array, key)
Expand Down
15 changes: 11 additions & 4 deletions spec/decision_service_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -316,8 +316,11 @@
it 'should return the variation' do
user_attributes = {}
feature_flag = config.feature_flag_key_map['multi_variate_feature']
expected_variation = config.variation_id_map['test_experiment_multivariate']['122231']
expect(decision_service.get_variation_for_feature(feature_flag, 'user_1', user_attributes)).to eq(expected_variation)
expected_decision = {
'experiment' => config.experiment_key_map['test_experiment_multivariate'],
'variation' => config.variation_id_map['test_experiment_multivariate']['122231']
}
expect(decision_service.get_variation_for_feature(feature_flag, 'user_1', user_attributes)).to eq(expected_decision)

expect(spy_logger).to have_received(:log).once
.with(Logger::INFO, "The user 'user_1' is bucketed into experiment 'test_experiment_multivariate' of feature 'multi_variate_feature'.")
Expand All @@ -327,12 +330,16 @@

describe 'when the feature flag is associated with a mutex experiment' do
mutex_exp = nil
expected_variation = nil
expected_decision = nil
describe 'and the user is bucketed into one of the experiments' do
before(:each) do
group_1 = config.group_key_map['101']
mutex_exp = config.experiment_key_map['group1_exp1']
expected_variation = mutex_exp['variations'][0]
expected_decision = {
'experiment' => mutex_exp,
'variation' => expected_variation
}
allow(decision_service.bucketer).to receive(:find_bucket)
.with(user_id, group_1['id'], group_1['trafficAllocation'])
.and_return(mutex_exp['id'])
Expand All @@ -343,7 +350,7 @@

it 'should return the variation the user is bucketed into' do
feature_flag = config.feature_flag_key_map['boolean_feature']
expect(decision_service.get_variation_for_feature(feature_flag, user_id, user_attributes)).to eq(expected_variation)
expect(decision_service.get_variation_for_feature(feature_flag, user_id, user_attributes)).to eq(expected_decision)

expect(spy_logger).to have_received(:log).once
.with(Logger::INFO, "The user 'user_1' is bucketed into experiment 'group1_exp1' of feature 'boolean_feature'.")
Expand Down
16 changes: 16 additions & 0 deletions spec/project_config_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@
expect(project_config.attributes).to eq(config_body['attributes'])
expect(project_config.audiences).to eq(config_body['audiences'])
expect(project_config.events).to eq(config_body['events'])
expect(project_config.feature_flags).to eq(config_body['featureFlags'])
expect(project_config.groups).to eq(config_body['groups'])
expect(project_config.project_id).to eq(config_body['projectId'])
expect(project_config.revision).to eq(config_body['revision'])
Expand Down Expand Up @@ -541,6 +542,14 @@
"Attribute key 'invalid_attr' is not in datafile.")
end
end

describe 'get_feature_flag_from_key' do
it 'should log a message when provided feature flag key is invalid' do
config.get_feature_flag_from_key('totally_invalid_feature_key')
expect(spy_logger).to have_received(:log).with(Logger::ERROR,
"Feature flag key 'totally_invalid_feature_key' is not in datafile.")
end
end
end

describe '@error_handler' do
Expand Down Expand Up @@ -613,4 +622,11 @@
expect(config.experiment_running?(experiment)).to eq(false)
end
end

describe '#get_feature_flag_from_key' do
it 'should return the feature flag associated with the given feature flag key' do
feature_flag = config.get_feature_flag_from_key('boolean_feature')
expect(feature_flag).to eq(config_body['featureFlags'][0])
end
end
end
65 changes: 65 additions & 0 deletions spec/project_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -666,4 +666,69 @@ class InvalidErrorHandler; end
invalid_project.get_variation('test_exp', 'test_user')
end
end

describe '#is_feature_enabled' do
before(:example) do
allow(Time).to receive(:now).and_return(time_now)
end

it 'should return false when the feature flag key is invalid' do
expect(project_instance.is_feature_enabled('totally_invalid_feature_key', 'test_user')).to be false
expect(spy_logger).to have_received(:log).once.with(Logger::ERROR, "Feature flag key 'totally_invalid_feature_key' is not in datafile.")
expect(spy_logger).to have_received(:log).once.with(Logger::ERROR, "No feature flag was found for key 'totally_invalid_feature_key'.")
end

it 'should return false when the user is not bucketed into any variation' do
allow(project_instance.decision_service).to receive(:get_variation_for_feature).and_return(nil)

expect(project_instance.is_feature_enabled('multi_variate_feature', 'test_user')).to be(false)
expect(spy_logger).to have_received(:log).once.with(Logger::INFO, "Feature 'multi_variate_feature' is not enabled for user 'test_user'.")
end

it 'should return true but not send an impression if the user is not bucketed into a feature experiment' do
experiment_to_return = config_body['rollouts'][0]['experiments'][0]
variation_to_return = experiment_to_return['variations'][0]
decision_to_return = {
'experiment' => nil,
'variation' => variation_to_return
}
allow(project_instance.decision_service).to receive(:get_variation_for_feature).and_return(decision_to_return)

expect(project_instance.is_feature_enabled('boolean_single_variable_feature', 'test_user')).to be true
expect(spy_logger).to have_received(:log).once.with(Logger::DEBUG, "The user 'test_user' is not being experimented on in feature 'boolean_single_variable_feature'.")
expect(spy_logger).to have_received(:log).once.with(Logger::INFO, "Feature 'boolean_single_variable_feature' is enabled for user 'test_user'.")
end

it 'should return true and send an impression if the user is bucketed into a feature experiment' do
allow(project_instance.event_dispatcher).to receive(:dispatch_event).with(instance_of(Optimizely::Event))
experiment_to_return = config_body['experiments'][3]
variation_to_return = experiment_to_return['variations'][0]
decision_to_return = {
'experiment' => experiment_to_return,
'variation' => variation_to_return
}
allow(project_instance.decision_service).to receive(:get_variation_for_feature).and_return(decision_to_return)

expected_params = {
"projectId"=>"111001",
"accountId"=>"12001",
"visitorId"=>"test_user",
"userFeatures"=>[],
"clientEngine"=>"ruby-sdk",
'clientVersion' => version,
'timestamp' => (time_now.to_f * 1000).to_i,
"isGlobalHoldback"=>false,
"layerId"=>"4",
"decision"=>{
"variationId"=>"122231",
"experimentId"=>"122230",
"isLayerHoldback"=>false
}
}

expect(project_instance.is_feature_enabled('multi_variate_feature', 'test_user')).to be true
expect(spy_logger).to have_received(:log).once.with(Logger::INFO, "Dispatching impression event to URL https://logx.optimizely.com/log/decision with params #{expected_params}.")
expect(spy_logger).to have_received(:log).once.with(Logger::INFO, "Feature 'multi_variate_feature' is enabled for user 'test_user'.")
end
end
end