Skip to content

Commit cc25602

Browse files
authored
Add feature flag accessor API (#56)
1 parent 4b35109 commit cc25602

File tree

6 files changed

+182
-20
lines changed

6 files changed

+182
-20
lines changed

lib/optimizely.rb

Lines changed: 59 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -107,17 +107,8 @@ def activate(experiment_key, user_id, attributes = nil)
107107
end
108108

109109
# Create and dispatch impression event
110-
variation_id = @config.get_variation_id_from_key(experiment_key, variation_key)
111110
experiment = @config.get_experiment_from_key(experiment_key)
112-
impression_event = @event_builder.create_impression_event(experiment, variation_id, user_id, attributes)
113-
@logger.log(Logger::INFO,
114-
'Dispatching impression event to URL %s with params %s.' % [impression_event.url,
115-
impression_event.params])
116-
begin
117-
@event_dispatcher.dispatch_event(impression_event)
118-
rescue => e
119-
@logger.log(Logger::ERROR, "Unable to dispatch impression event. Error: #{e}")
120-
end
111+
send_impression(experiment, variation_key, user_id, attributes)
121112

122113
variation_key
123114
end
@@ -202,6 +193,50 @@ def track(event_key, user_id, attributes = nil, event_tags = nil)
202193
end
203194
end
204195

196+
def is_feature_enabled(feature_flag_key, user_id, attributes = nil)
197+
# Determine whether a feature is enabled.
198+
# Sends an impression event if the user is bucketed into an experiment using the feature.
199+
#
200+
# feature_flag_key - String unique key of the feature.
201+
# userId - String ID of the user.
202+
# attributes - Hash representing visitor attributes and values which need to be recorded.
203+
#
204+
# Returns True if the feature is enabled.
205+
# False if the feature is disabled.
206+
# False if the feature is not found.
207+
208+
unless @is_valid
209+
logger = SimpleLogger.new
210+
logger.log(Logger::ERROR, InvalidDatafileError.new('is_feature_enabled').message)
211+
return nil
212+
end
213+
214+
feature_flag = @config.get_feature_flag_from_key(feature_flag_key)
215+
unless feature_flag
216+
@logger.log(Logger::ERROR, "No feature flag was found for key '#{feature_flag_key}'.")
217+
return false
218+
end
219+
220+
decision = @decision_service.get_variation_for_feature(feature_flag, user_id, attributes)
221+
unless decision.nil?
222+
variation = decision['variation']
223+
experiment = decision['experiment']
224+
unless experiment.nil?
225+
send_impression(experiment, variation['key'], user_id, attributes)
226+
else
227+
@logger.log(Logger::DEBUG,
228+
"The user '#{user_id}' is not being experimented on in feature '#{feature_flag_key}'.")
229+
end
230+
231+
@logger.log(Logger::INFO, "Feature '#{feature_flag_key}' is enabled for user '#{user_id}'.")
232+
return true
233+
end
234+
235+
@logger.log(Logger::INFO,
236+
"Feature '#{feature_flag_key}' is not enabled for user '#{user_id}'.")
237+
false
238+
end
239+
205240
private
206241

207242
def get_valid_experiments_for_event(event_key, user_id, attributes)
@@ -278,5 +313,19 @@ def validate_instantiation_options(datafile, skip_json_validation)
278313
raise InvalidInputError.new('error_handler') unless Helpers::Validator.error_handler_valid?(@error_handler)
279314
raise InvalidInputError.new('event_dispatcher') unless Helpers::Validator.event_dispatcher_valid?(@event_dispatcher)
280315
end
316+
317+
def send_impression(experiment, variation_key, user_id, attributes = nil)
318+
experiment_key = experiment['key']
319+
variation_id = @config.get_variation_id_from_key(experiment_key, variation_key)
320+
impression_event = @event_builder.create_impression_event(experiment, variation_id, user_id, attributes)
321+
@logger.log(Logger::INFO,
322+
'Dispatching impression event to URL %s with params %s.' % [impression_event.url,
323+
impression_event.params])
324+
begin
325+
@event_dispatcher.dispatch_event(impression_event)
326+
rescue => e
327+
@logger.log(Logger::ERROR, "Unable to dispatch impression event. Error: #{e}")
328+
end
329+
end
281330
end
282331
end

lib/optimizely/decision_service.rb

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -97,11 +97,11 @@ def get_variation_for_feature(feature_flag, user_id, attributes = nil)
9797
# user_id - String ID for the user
9898
# attributes - Hash representing user attributes
9999
#
100-
# Returns variation where visitor will be bucketed (nil if the user is not bucketed into any of the experiments on the feature)
100+
# 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)
101101

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

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

121121
feature_flag_key = feature_flag['key']
@@ -157,7 +157,10 @@ def get_variation_for_feature_experiment(feature_flag, user_id, attributes = nil
157157
Logger::INFO,
158158
"The user '#{user_id}' is bucketed into experiment '#{experiment_key}' of feature '#{feature_flag_key}'."
159159
)
160-
return variation
160+
return {
161+
'variation' => variation,
162+
'experiment' => experiment
163+
}
161164
else
162165
@config.logger.log(
163166
Logger::INFO,

lib/optimizely/project_config.rb

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ class ProjectConfig
3434
attr_reader :audiences
3535
attr_reader :events
3636
attr_reader :experiments
37+
attr_reader :feature_flags
3738
attr_reader :groups
3839
attr_reader :parsing_succeeded
3940
attr_reader :project_id
@@ -97,6 +98,15 @@ def initialize(datafile, logger, error_handler)
9798
@variation_id_map = {}
9899
@variation_key_map = {}
99100
@variation_id_to_variable_usage_map = {}
101+
@variation_id_to_experiment_map = {}
102+
@experiment_key_map.each do |key, exp|
103+
# Excludes experiments from rollouts
104+
variations = exp.fetch('variations')
105+
variations.each do |variation|
106+
variation_id = variation['id']
107+
@variation_id_to_experiment_map[variation_id] = exp
108+
end
109+
end
100110
@rollout_id_map = generate_key_map(@rollouts, 'id')
101111
# split out the experiment id map for rollouts
102112
@rollout_experiment_id_map = {}
@@ -136,7 +146,7 @@ def get_experiment_from_key(experiment_key)
136146
#
137147
# experiment_key - String key representing the experiment
138148
#
139-
# Returns Experiment
149+
# Returns Experiment or nil if not found
140150

141151
experiment = @experiment_key_map[experiment_key]
142152
return experiment if experiment
@@ -281,6 +291,18 @@ def variation_id_exists?(experiment_id, variation_id)
281291
false
282292
end
283293

294+
def get_feature_flag_from_key(feature_flag_key)
295+
# Retrieves the feature flag with the given key
296+
#
297+
# feature_flag_key - String feature key
298+
#
299+
# Returns feature flag if found, otherwise nil
300+
feature_flag = @feature_flag_key_map[feature_flag_key]
301+
return feature_flag if feature_flag
302+
@logger.log Logger::ERROR, "Feature flag key '#{feature_flag_key}' is not in datafile."
303+
nil
304+
end
305+
284306
private
285307

286308
def generate_key_map(array, key)

spec/decision_service_spec.rb

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -316,8 +316,11 @@
316316
it 'should return the variation' do
317317
user_attributes = {}
318318
feature_flag = config.feature_flag_key_map['multi_variate_feature']
319-
expected_variation = config.variation_id_map['test_experiment_multivariate']['122231']
320-
expect(decision_service.get_variation_for_feature(feature_flag, 'user_1', user_attributes)).to eq(expected_variation)
319+
expected_decision = {
320+
'experiment' => config.experiment_key_map['test_experiment_multivariate'],
321+
'variation' => config.variation_id_map['test_experiment_multivariate']['122231']
322+
}
323+
expect(decision_service.get_variation_for_feature(feature_flag, 'user_1', user_attributes)).to eq(expected_decision)
321324

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

328331
describe 'when the feature flag is associated with a mutex experiment' do
329332
mutex_exp = nil
330-
expected_variation = nil
333+
expected_decision = nil
331334
describe 'and the user is bucketed into one of the experiments' do
332335
before(:each) do
333336
group_1 = config.group_key_map['101']
334337
mutex_exp = config.experiment_key_map['group1_exp1']
335338
expected_variation = mutex_exp['variations'][0]
339+
expected_decision = {
340+
'experiment' => mutex_exp,
341+
'variation' => expected_variation
342+
}
336343
allow(decision_service.bucketer).to receive(:find_bucket)
337344
.with(user_id, group_1['id'], group_1['trafficAllocation'])
338345
.and_return(mutex_exp['id'])
@@ -343,7 +350,7 @@
343350

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

348355
expect(spy_logger).to have_received(:log).once
349356
.with(Logger::INFO, "The user 'user_1' is bucketed into experiment 'group1_exp1' of feature 'boolean_feature'.")

spec/project_config_spec.rb

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@
3232
expect(project_config.attributes).to eq(config_body['attributes'])
3333
expect(project_config.audiences).to eq(config_body['audiences'])
3434
expect(project_config.events).to eq(config_body['events'])
35+
expect(project_config.feature_flags).to eq(config_body['featureFlags'])
3536
expect(project_config.groups).to eq(config_body['groups'])
3637
expect(project_config.project_id).to eq(config_body['projectId'])
3738
expect(project_config.revision).to eq(config_body['revision'])
@@ -541,6 +542,14 @@
541542
"Attribute key 'invalid_attr' is not in datafile.")
542543
end
543544
end
545+
546+
describe 'get_feature_flag_from_key' do
547+
it 'should log a message when provided feature flag key is invalid' do
548+
config.get_feature_flag_from_key('totally_invalid_feature_key')
549+
expect(spy_logger).to have_received(:log).with(Logger::ERROR,
550+
"Feature flag key 'totally_invalid_feature_key' is not in datafile.")
551+
end
552+
end
544553
end
545554

546555
describe '@error_handler' do
@@ -613,4 +622,11 @@
613622
expect(config.experiment_running?(experiment)).to eq(false)
614623
end
615624
end
625+
626+
describe '#get_feature_flag_from_key' do
627+
it 'should return the feature flag associated with the given feature flag key' do
628+
feature_flag = config.get_feature_flag_from_key('boolean_feature')
629+
expect(feature_flag).to eq(config_body['featureFlags'][0])
630+
end
631+
end
616632
end

spec/project_spec.rb

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -666,4 +666,69 @@ class InvalidErrorHandler; end
666666
invalid_project.get_variation('test_exp', 'test_user')
667667
end
668668
end
669+
670+
describe '#is_feature_enabled' do
671+
before(:example) do
672+
allow(Time).to receive(:now).and_return(time_now)
673+
end
674+
675+
it 'should return false when the feature flag key is invalid' do
676+
expect(project_instance.is_feature_enabled('totally_invalid_feature_key', 'test_user')).to be false
677+
expect(spy_logger).to have_received(:log).once.with(Logger::ERROR, "Feature flag key 'totally_invalid_feature_key' is not in datafile.")
678+
expect(spy_logger).to have_received(:log).once.with(Logger::ERROR, "No feature flag was found for key 'totally_invalid_feature_key'.")
679+
end
680+
681+
it 'should return false when the user is not bucketed into any variation' do
682+
allow(project_instance.decision_service).to receive(:get_variation_for_feature).and_return(nil)
683+
684+
expect(project_instance.is_feature_enabled('multi_variate_feature', 'test_user')).to be(false)
685+
expect(spy_logger).to have_received(:log).once.with(Logger::INFO, "Feature 'multi_variate_feature' is not enabled for user 'test_user'.")
686+
end
687+
688+
it 'should return true but not send an impression if the user is not bucketed into a feature experiment' do
689+
experiment_to_return = config_body['rollouts'][0]['experiments'][0]
690+
variation_to_return = experiment_to_return['variations'][0]
691+
decision_to_return = {
692+
'experiment' => nil,
693+
'variation' => variation_to_return
694+
}
695+
allow(project_instance.decision_service).to receive(:get_variation_for_feature).and_return(decision_to_return)
696+
697+
expect(project_instance.is_feature_enabled('boolean_single_variable_feature', 'test_user')).to be true
698+
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'.")
699+
expect(spy_logger).to have_received(:log).once.with(Logger::INFO, "Feature 'boolean_single_variable_feature' is enabled for user 'test_user'.")
700+
end
701+
702+
it 'should return true and send an impression if the user is bucketed into a feature experiment' do
703+
allow(project_instance.event_dispatcher).to receive(:dispatch_event).with(instance_of(Optimizely::Event))
704+
experiment_to_return = config_body['experiments'][3]
705+
variation_to_return = experiment_to_return['variations'][0]
706+
decision_to_return = {
707+
'experiment' => experiment_to_return,
708+
'variation' => variation_to_return
709+
}
710+
allow(project_instance.decision_service).to receive(:get_variation_for_feature).and_return(decision_to_return)
711+
712+
expected_params = {
713+
"projectId"=>"111001",
714+
"accountId"=>"12001",
715+
"visitorId"=>"test_user",
716+
"userFeatures"=>[],
717+
"clientEngine"=>"ruby-sdk",
718+
'clientVersion' => version,
719+
'timestamp' => (time_now.to_f * 1000).to_i,
720+
"isGlobalHoldback"=>false,
721+
"layerId"=>"4",
722+
"decision"=>{
723+
"variationId"=>"122231",
724+
"experimentId"=>"122230",
725+
"isLayerHoldback"=>false
726+
}
727+
}
728+
729+
expect(project_instance.is_feature_enabled('multi_variate_feature', 'test_user')).to be true
730+
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}.")
731+
expect(spy_logger).to have_received(:log).once.with(Logger::INFO, "Feature 'multi_variate_feature' is enabled for user 'test_user'.")
732+
end
733+
end
669734
end

0 commit comments

Comments
 (0)