Skip to content

Commit f718a18

Browse files
authored
feat(Decide): Add Decide API (#274)
## Summary Added new Apis to support the decide feature. Introduced a new `OptimizelyUserContext ` class through `create_user_context` class api. This creates an optimizely instance with memoized user context and exposes the following APIs 1. `set_attribute` 2. `decide` 3. `decide_all` 4. `decide_for_keys` 5. `track_event` ## Test plan 1. Manually tested thoroughly. 2. Added unit tests to cover new functionality. 3. All new and existing FSC tests pass.
1 parent 9da6a58 commit f718a18

11 files changed

+1523
-128
lines changed

lib/optimizely.rb

Lines changed: 184 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,9 @@
1919
require_relative 'optimizely/config/datafile_project_config'
2020
require_relative 'optimizely/config_manager/http_project_config_manager'
2121
require_relative 'optimizely/config_manager/static_project_config_manager'
22+
require_relative 'optimizely/decide/optimizely_decide_option'
23+
require_relative 'optimizely/decide/optimizely_decision'
24+
require_relative 'optimizely/decide/optimizely_decision_message'
2225
require_relative 'optimizely/decision_service'
2326
require_relative 'optimizely/error_handler'
2427
require_relative 'optimizely/event_builder'
@@ -34,9 +37,12 @@
3437
require_relative 'optimizely/logger'
3538
require_relative 'optimizely/notification_center'
3639
require_relative 'optimizely/optimizely_config'
40+
require_relative 'optimizely/optimizely_user_context'
3741

3842
module Optimizely
3943
class Project
44+
include Optimizely::Decide
45+
4046
attr_reader :notification_center
4147
# @api no-doc
4248
attr_reader :config_manager, :decision_service, :error_handler, :event_dispatcher,
@@ -67,12 +73,21 @@ def initialize(
6773
sdk_key = nil,
6874
config_manager = nil,
6975
notification_center = nil,
70-
event_processor = nil
76+
event_processor = nil,
77+
default_decide_options = []
7178
)
7279
@logger = logger || NoOpLogger.new
7380
@error_handler = error_handler || NoOpErrorHandler.new
7481
@event_dispatcher = event_dispatcher || EventDispatcher.new(logger: @logger, error_handler: @error_handler)
7582
@user_profile_service = user_profile_service
83+
@default_decide_options = []
84+
85+
if default_decide_options.is_a? Array
86+
@default_decide_options = default_decide_options.clone
87+
else
88+
@logger.log(Logger::DEBUG, 'Provided default decide options is not an array.')
89+
@default_decide_options = []
90+
end
7691

7792
begin
7893
validate_instantiation_options
@@ -107,6 +122,174 @@ def initialize(
107122
end
108123
end
109124

125+
# Create a context of the user for which decision APIs will be called.
126+
#
127+
# A user context will be created successfully even when the SDK is not fully configured yet.
128+
#
129+
# @param user_id - The user ID to be used for bucketing.
130+
# @param attributes - A Hash representing user attribute names and values.
131+
#
132+
# @return [OptimizelyUserContext] An OptimizelyUserContext associated with this OptimizelyClient.
133+
# @return [nil] If user attributes are not in valid format.
134+
135+
def create_user_context(user_id, attributes = nil)
136+
# We do not check for is_valid here as a user context can be created successfully
137+
# even when the SDK is not fully configured.
138+
139+
# validate user_id
140+
return nil unless Optimizely::Helpers::Validator.inputs_valid?(
141+
{
142+
user_id: user_id
143+
}, @logger, Logger::ERROR
144+
)
145+
146+
# validate attributes
147+
return nil unless user_inputs_valid?(attributes)
148+
149+
user_context = OptimizelyUserContext.new(self, user_id, attributes)
150+
user_context
151+
end
152+
153+
def decide(user_context, key, decide_options = [])
154+
# raising on user context as it is internal and not provided directly by the user.
155+
raise if user_context.class != OptimizelyUserContext
156+
157+
reasons = []
158+
159+
# check if SDK is ready
160+
unless is_valid
161+
@logger.log(Logger::ERROR, InvalidProjectConfigError.new('decide').message)
162+
reasons.push(OptimizelyDecisionMessage::SDK_NOT_READY)
163+
return OptimizelyDecision.new(flag_key: key, user_context: user_context, reasons: reasons)
164+
end
165+
166+
# validate that key is a string
167+
unless key.is_a?(String)
168+
@logger.log(Logger::ERROR, 'Provided key is invalid')
169+
reasons.push(format(OptimizelyDecisionMessage::FLAG_KEY_INVALID, key))
170+
return OptimizelyDecision.new(flag_key: key, user_context: user_context, reasons: reasons)
171+
end
172+
173+
# validate that key maps to a feature flag
174+
config = project_config
175+
feature_flag = config.get_feature_flag_from_key(key)
176+
unless feature_flag
177+
@logger.log(Logger::ERROR, "No feature flag was found for key '#{key}'.")
178+
reasons.push(format(OptimizelyDecisionMessage::FLAG_KEY_INVALID, key))
179+
return OptimizelyDecision.new(flag_key: key, user_context: user_context, reasons: reasons)
180+
end
181+
182+
# merge decide_options and default_decide_options
183+
if decide_options.is_a? Array
184+
decide_options += @default_decide_options
185+
else
186+
@logger.log(Logger::DEBUG, 'Provided decide options is not an array. Using default decide options.')
187+
decide_options = @default_decide_options
188+
end
189+
190+
# Create Optimizely Decision Result.
191+
user_id = user_context.user_id
192+
attributes = user_context.user_attributes
193+
variation_key = nil
194+
feature_enabled = false
195+
rule_key = nil
196+
flag_key = key
197+
all_variables = {}
198+
decision_event_dispatched = false
199+
experiment = nil
200+
decision_source = Optimizely::DecisionService::DECISION_SOURCES['ROLLOUT']
201+
202+
decision = @decision_service.get_variation_for_feature(config, feature_flag, user_id, attributes, decide_options, reasons)
203+
204+
# Send impression event if Decision came from a feature test and decide options doesn't include disableDecisionEvent
205+
if decision.is_a?(Optimizely::DecisionService::Decision)
206+
experiment = decision.experiment
207+
rule_key = experiment['key']
208+
variation = decision['variation']
209+
variation_key = variation['key']
210+
feature_enabled = variation['featureEnabled']
211+
decision_source = decision.source
212+
end
213+
214+
unless decide_options.include? OptimizelyDecideOption::DISABLE_DECISION_EVENT
215+
if decision_source == Optimizely::DecisionService::DECISION_SOURCES['FEATURE_TEST'] || config.send_flag_decisions
216+
send_impression(config, experiment, variation_key || '', flag_key, rule_key || '', feature_enabled, decision_source, user_id, attributes)
217+
decision_event_dispatched = true
218+
end
219+
end
220+
221+
# Generate all variables map if decide options doesn't include excludeVariables
222+
unless decide_options.include? OptimizelyDecideOption::EXCLUDE_VARIABLES
223+
feature_flag['variables'].each do |variable|
224+
variable_value = get_feature_variable_for_variation(key, feature_enabled, variation, variable, user_id)
225+
all_variables[variable['key']] = Helpers::VariableType.cast_value_to_type(variable_value, variable['type'], @logger)
226+
end
227+
end
228+
229+
should_include_reasons = decide_options.include? OptimizelyDecideOption::INCLUDE_REASONS
230+
231+
# Send notification
232+
@notification_center.send_notifications(
233+
NotificationCenter::NOTIFICATION_TYPES[:DECISION],
234+
Helpers::Constants::DECISION_NOTIFICATION_TYPES['FLAG'],
235+
user_id, (attributes || {}),
236+
flag_key: flag_key,
237+
enabled: feature_enabled,
238+
variables: all_variables,
239+
variation_key: variation_key,
240+
rule_key: rule_key,
241+
reasons: should_include_reasons ? reasons : [],
242+
decision_event_dispatched: decision_event_dispatched
243+
)
244+
245+
OptimizelyDecision.new(
246+
variation_key: variation_key,
247+
enabled: feature_enabled,
248+
variables: all_variables,
249+
rule_key: rule_key,
250+
flag_key: flag_key,
251+
user_context: user_context,
252+
reasons: should_include_reasons ? reasons : []
253+
)
254+
end
255+
256+
def decide_all(user_context, decide_options = [])
257+
# raising on user context as it is internal and not provided directly by the user.
258+
raise if user_context.class != OptimizelyUserContext
259+
260+
# check if SDK is ready
261+
unless is_valid
262+
@logger.log(Logger::ERROR, InvalidProjectConfigError.new('decide_all').message)
263+
return {}
264+
end
265+
266+
keys = []
267+
project_config.feature_flags.each do |feature_flag|
268+
keys.push(feature_flag['key'])
269+
end
270+
decide_for_keys(user_context, keys, decide_options)
271+
end
272+
273+
def decide_for_keys(user_context, keys, decide_options = [])
274+
# raising on user context as it is internal and not provided directly by the user.
275+
raise if user_context.class != OptimizelyUserContext
276+
277+
# check if SDK is ready
278+
unless is_valid
279+
@logger.log(Logger::ERROR, InvalidProjectConfigError.new('decide_for_keys').message)
280+
return {}
281+
end
282+
283+
enabled_flags_only = (!decide_options.nil? && (decide_options.include? OptimizelyDecideOption::ENABLED_FLAGS_ONLY)) || (@default_decide_options.include? OptimizelyDecideOption::ENABLED_FLAGS_ONLY)
284+
285+
decisions = {}
286+
keys.each do |key|
287+
decision = decide(user_context, key, decide_options)
288+
decisions[key] = decision unless enabled_flags_only && !decision.enabled
289+
end
290+
decisions
291+
end
292+
110293
# Buckets visitor and sends impression event to Optimizely.
111294
#
112295
# @param experiment_key - Experiment which needs to be activated.

lib/optimizely/bucketer.rb

Lines changed: 19 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ def initialize(logger)
3535
@bucket_seed = HASH_SEED
3636
end
3737

38-
def bucket(project_config, experiment, bucketing_id, user_id)
38+
def bucket(project_config, experiment, bucketing_id, user_id, decide_reasons = nil)
3939
# Determines ID of variation to be shown for a given experiment key and user ID.
4040
#
4141
# project_config - Instance of ProjectConfig
@@ -58,46 +58,45 @@ def bucket(project_config, experiment, bucketing_id, user_id)
5858
bucketed_experiment_id = find_bucket(bucketing_id, user_id, group_id, traffic_allocations)
5959
# return if the user is not bucketed into any experiment
6060
unless bucketed_experiment_id
61-
@logger.log(Logger::INFO, "User '#{user_id}' is in no experiment.")
61+
message = "User '#{user_id}' is in no experiment."
62+
@logger.log(Logger::INFO, message)
63+
decide_reasons&.push(message)
6264
return nil
6365
end
6466

6567
# return if the user is bucketed into a different experiment than the one specified
6668
if bucketed_experiment_id != experiment_id
67-
@logger.log(
68-
Logger::INFO,
69-
"User '#{user_id}' is not in experiment '#{experiment_key}' of group #{group_id}."
70-
)
69+
message = "User '#{user_id}' is not in experiment '#{experiment_key}' of group #{group_id}."
70+
@logger.log(Logger::INFO, message)
71+
decide_reasons&.push(message)
7172
return nil
7273
end
7374

7475
# continue bucketing if the user is bucketed into the experiment specified
75-
@logger.log(
76-
Logger::INFO,
77-
"User '#{user_id}' is in experiment '#{experiment_key}' of group #{group_id}."
78-
)
76+
message = "User '#{user_id}' is in experiment '#{experiment_key}' of group #{group_id}."
77+
@logger.log(Logger::INFO, message)
78+
decide_reasons&.push(message)
7979
end
8080
end
8181

8282
traffic_allocations = experiment['trafficAllocation']
83-
variation_id = find_bucket(bucketing_id, user_id, experiment_id, traffic_allocations)
83+
variation_id = find_bucket(bucketing_id, user_id, experiment_id, traffic_allocations, decide_reasons)
8484
if variation_id && variation_id != ''
8585
variation = project_config.get_variation_from_id(experiment_key, variation_id)
8686
return variation
8787
end
8888

8989
# Handle the case when the traffic range is empty due to sticky bucketing
9090
if variation_id == ''
91-
@logger.log(
92-
Logger::DEBUG,
93-
'Bucketed into an empty traffic range. Returning nil.'
94-
)
91+
message = 'Bucketed into an empty traffic range. Returning nil.'
92+
@logger.log(Logger::DEBUG, message)
93+
decide_reasons&.push(message)
9594
end
9695

9796
nil
9897
end
9998

100-
def find_bucket(bucketing_id, user_id, parent_id, traffic_allocations)
99+
def find_bucket(bucketing_id, user_id, parent_id, traffic_allocations, decide_reasons = nil)
101100
# Helper function to find the matching entity ID for a given bucketing value in a list of traffic allocations.
102101
#
103102
# bucketing_id - String A customer-assigned value user to generate bucketing key
@@ -108,8 +107,10 @@ def find_bucket(bucketing_id, user_id, parent_id, traffic_allocations)
108107
# Returns entity ID corresponding to the provided bucket value or nil if no match is found.
109108
bucketing_key = format(BUCKETING_ID_TEMPLATE, bucketing_id: bucketing_id, entity_id: parent_id)
110109
bucket_value = generate_bucket_value(bucketing_key)
111-
@logger.log(Logger::DEBUG, "Assigned bucket #{bucket_value} to user '#{user_id}' "\
112-
"with bucketing ID: '#{bucketing_id}'.")
110+
111+
message = "Assigned bucket #{bucket_value} to user '#{user_id}' with bucketing ID: '#{bucketing_id}'."
112+
@logger.log(Logger::DEBUG, message)
113+
decide_reasons&.push(message)
113114

114115
traffic_allocations.each do |traffic_allocation|
115116
current_end_of_range = traffic_allocation['endOfRange']
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
# frozen_string_literal: true
2+
3+
# Copyright 2020, Optimizely and contributors
4+
#
5+
# Licensed under the Apache License, Version 2.0 (the "License");
6+
# you may not use this file except in compliance with the License.
7+
# You may obtain a copy of the License at
8+
#
9+
# http://www.apache.org/licenses/LICENSE-2.0
10+
#
11+
# Unless required by applicable law or agreed to in writing, software
12+
# distributed under the License is distributed on an "AS IS" BASIS,
13+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14+
# See the License for the specific language governing permissions and
15+
# limitations under the License.
16+
#
17+
18+
module Optimizely
19+
module Decide
20+
module OptimizelyDecideOption
21+
DISABLE_DECISION_EVENT = 'DISABLE_DECISION_EVENT'
22+
ENABLED_FLAGS_ONLY = 'ENABLED_FLAGS_ONLY'
23+
IGNORE_USER_PROFILE_SERVICE = 'IGNORE_USER_PROFILE_SERVICE'
24+
INCLUDE_REASONS = 'INCLUDE_REASONS'
25+
EXCLUDE_VARIABLES = 'EXCLUDE_VARIABLES'
26+
end
27+
end
28+
end
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
# frozen_string_literal: true
2+
3+
# Copyright 2020, Optimizely and contributors
4+
#
5+
# Licensed under the Apache License, Version 2.0 (the "License");
6+
# you may not use this file except in compliance with the License.
7+
# You may obtain a copy of the License at
8+
#
9+
# http://www.apache.org/licenses/LICENSE-2.0
10+
#
11+
# Unless required by applicable law or agreed to in writing, software
12+
# distributed under the License is distributed on an "AS IS" BASIS,
13+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14+
# See the License for the specific language governing permissions and
15+
# limitations under the License.
16+
#
17+
18+
require 'json'
19+
20+
module Optimizely
21+
module Decide
22+
class OptimizelyDecision
23+
attr_reader :variation_key, :enabled, :variables, :rule_key, :flag_key, :user_context, :reasons
24+
25+
def initialize(
26+
variation_key: nil,
27+
enabled: nil,
28+
variables: nil,
29+
rule_key: nil,
30+
flag_key: nil,
31+
user_context: nil,
32+
reasons: nil
33+
)
34+
@variation_key = variation_key
35+
@enabled = enabled || false
36+
@variables = variables || {}
37+
@rule_key = rule_key
38+
@flag_key = flag_key
39+
@user_context = user_context
40+
@reasons = reasons || []
41+
end
42+
43+
def as_json
44+
{
45+
variation_key: @variation_key,
46+
enabled: @enabled,
47+
variables: @variables,
48+
rule_key: @rule_key,
49+
flag_key: @flag_key,
50+
user_context: @user_context.as_json,
51+
reasons: @reasons
52+
}
53+
end
54+
55+
def to_json(*args)
56+
as_json.to_json(*args)
57+
end
58+
end
59+
end
60+
end

0 commit comments

Comments
 (0)