-
Notifications
You must be signed in to change notification settings - Fork 27
feat(Decide): Add Decide API #274
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
Changes from 44 commits
4f8765b
a7b78d8
c5f138c
c8d3a35
f770ce7
244c4f3
1bd6df7
ddd5349
98ced8c
e7366f4
03a397f
3e9e417
ab824c7
507dac6
13833de
b981f64
d102a14
7fb2ddf
16a509d
f6437b7
ac90cd0
4026855
d6be7c2
752114d
ee32ee1
57ac8a0
a300b3e
0070d3b
6727d30
d9a5549
cd1648e
8ec1619
a041cb6
7d122a4
c40237f
7f6d985
b793012
0a38bdd
c401588
cffa963
43b14ee
245637e
b76af55
5057b3f
92afe26
1ef9539
89c3d6b
315d4aa
f58d8bd
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -19,6 +19,9 @@ | |
| require_relative 'optimizely/config/datafile_project_config' | ||
| require_relative 'optimizely/config_manager/http_project_config_manager' | ||
| require_relative 'optimizely/config_manager/static_project_config_manager' | ||
| require_relative 'optimizely/decide/optimizely_decide_option' | ||
| require_relative 'optimizely/decide/optimizely_decision' | ||
| require_relative 'optimizely/decide/optimizely_decision_message' | ||
| require_relative 'optimizely/decision_service' | ||
| require_relative 'optimizely/error_handler' | ||
| require_relative 'optimizely/event_builder' | ||
|
|
@@ -34,9 +37,12 @@ | |
| require_relative 'optimizely/logger' | ||
| require_relative 'optimizely/notification_center' | ||
| require_relative 'optimizely/optimizely_config' | ||
| require_relative 'optimizely/optimizely_user_context' | ||
|
|
||
| module Optimizely | ||
| class Project | ||
| include Optimizely::Decide | ||
|
|
||
| attr_reader :notification_center | ||
| # @api no-doc | ||
| attr_reader :config_manager, :decision_service, :error_handler, :event_dispatcher, | ||
|
|
@@ -67,12 +73,21 @@ def initialize( | |
| sdk_key = nil, | ||
| config_manager = nil, | ||
| notification_center = nil, | ||
| event_processor = nil | ||
| event_processor = nil, | ||
| default_decide_options = [] | ||
| ) | ||
| @logger = logger || NoOpLogger.new | ||
| @error_handler = error_handler || NoOpErrorHandler.new | ||
| @event_dispatcher = event_dispatcher || EventDispatcher.new(logger: @logger, error_handler: @error_handler) | ||
| @user_profile_service = user_profile_service | ||
| @default_decide_options = [] | ||
|
|
||
| if default_decide_options.is_a? Array | ||
| @default_decide_options = default_decide_options.clone | ||
| else | ||
| @logger.log(Logger::DEBUG, 'Provided default decide options is not an array.') | ||
| @default_decide_options = [] | ||
| end | ||
|
|
||
| begin | ||
| validate_instantiation_options | ||
|
|
@@ -107,6 +122,163 @@ def initialize( | |
| end | ||
| end | ||
|
|
||
| def create_user_context(user_id, attributes = nil) | ||
| # We do not check for is_valid here as a user context can be created successfully | ||
| # even when the SDK is not fully configured. | ||
|
|
||
| # validate user_id | ||
| return nil unless Optimizely::Helpers::Validator.inputs_valid?( | ||
| { | ||
| user_id: user_id | ||
| }, @logger, Logger::ERROR | ||
| ) | ||
|
|
||
| # validate attributes | ||
| return nil unless user_inputs_valid?(attributes) | ||
|
|
||
| user_context = OptimizelyUserContext.new(self, user_id, attributes) | ||
| user_context | ||
| end | ||
|
|
||
| def decide(user_context, key, decide_options = []) | ||
| # raising on user context as it is internal and not provided directly by the user. | ||
| raise if user_context.class != OptimizelyUserContext | ||
|
|
||
| reasons = [] | ||
|
|
||
| # check if SDK is ready | ||
| unless is_valid | ||
| @logger.log(Logger::ERROR, InvalidProjectConfigError.new('decide').message) | ||
| reasons.push(OptimizelyDecisionMessage::SDK_NOT_READY) | ||
| return OptimizelyDecision.new(flag_key: key, user_context: user_context, reasons: reasons) | ||
| end | ||
|
|
||
| # validate that key is a string | ||
| unless key.is_a?(String) | ||
| @logger.log(Logger::ERROR, 'Provided key is invalid') | ||
| reasons.push(format(OptimizelyDecisionMessage::FLAG_KEY_INVALID, key)) | ||
| return OptimizelyDecision.new(flag_key: key, user_context: user_context, reasons: reasons) | ||
| end | ||
|
|
||
| # validate that key maps to a feature flag | ||
| config = project_config | ||
| feature_flag = config.get_feature_flag_from_key(key) | ||
| unless feature_flag | ||
| @logger.log(Logger::ERROR, "No feature flag was found for key '#{key}'.") | ||
| reasons.push(format(OptimizelyDecisionMessage::FLAG_KEY_INVALID, key)) | ||
| return OptimizelyDecision.new(flag_key: key, user_context: user_context, reasons: reasons) | ||
| end | ||
|
|
||
| # merge decide_options and default_decide_options | ||
| if decide_options.is_a? Array | ||
| decide_options += @default_decide_options | ||
| else | ||
| @logger.log(Logger::DEBUG, 'Provided decide options is not an array. Using default decide options.') | ||
| decide_options = @default_decide_options | ||
| end | ||
|
|
||
| # Create Optimizely Decision Result. | ||
| user_id = user_context.user_id | ||
| attributes = user_context.user_attributes | ||
| variation_key = nil | ||
| feature_enabled = false | ||
| rule_key = nil | ||
| flag_key = key | ||
| all_variables = {} | ||
| decision_event_dispatched = false | ||
| experiment = nil | ||
| decision_source = Optimizely::DecisionService::DECISION_SOURCES['ROLLOUT'] | ||
|
|
||
| decision = @decision_service.get_variation_for_feature(config, feature_flag, user_id, attributes, decide_options, reasons) | ||
|
|
||
| # Send impression event if Decision came from a feature test and decide options doesn't include disableDecisionEvent | ||
| if decision.is_a?(Optimizely::DecisionService::Decision) | ||
| experiment = decision.experiment | ||
| rule_key = experiment['key'] | ||
| variation = decision['variation'] | ||
| variation_key = variation['key'] | ||
| feature_enabled = variation['featureEnabled'] | ||
| decision_source = decision.source | ||
| end | ||
|
|
||
| unless decide_options.include? OptimizelyDecideOption::DISABLE_DECISION_EVENT | ||
| if decision_source == Optimizely::DecisionService::DECISION_SOURCES['FEATURE_TEST'] || config.send_flag_decisions | ||
| send_impression(config, experiment, variation_key || '', flag_key, rule_key || '', feature_enabled, decision_source, user_id, attributes) | ||
| decision_event_dispatched = true | ||
| end | ||
| end | ||
|
|
||
| # Generate all variables map if decide options doesn't include excludeVariables | ||
| unless decide_options.include? OptimizelyDecideOption::EXCLUDE_VARIABLES | ||
| feature_flag['variables'].each do |variable| | ||
| variable_value = get_feature_variable_for_variation(key, feature_enabled, variation, variable, user_id) | ||
| all_variables[variable['key']] = Helpers::VariableType.cast_value_to_type(variable_value, variable['type'], @logger) | ||
| end | ||
| end | ||
|
|
||
| should_include_reasons = decide_options.include? OptimizelyDecideOption::INCLUDE_REASONS | ||
|
|
||
| # Send notification | ||
| @notification_center.send_notifications( | ||
| NotificationCenter::NOTIFICATION_TYPES[:DECISION], | ||
| Helpers::Constants::DECISION_NOTIFICATION_TYPES['FLAG'], | ||
| user_id, (attributes || {}), | ||
| flag_key: flag_key, | ||
| enabled: feature_enabled, | ||
| variables: all_variables, | ||
| variation_key: variation_key, | ||
| rule_key: rule_key, | ||
| reasons: should_include_reasons ? reasons : [], | ||
| decision_event_dispatched: decision_event_dispatched | ||
| ) | ||
|
|
||
| OptimizelyDecision.new( | ||
| variation_key: variation_key, | ||
| enabled: feature_enabled, | ||
| variables: all_variables, | ||
| rule_key: rule_key, | ||
| flag_key: flag_key, | ||
| user_context: user_context, | ||
| reasons: should_include_reasons ? reasons : [] | ||
| ) | ||
| end | ||
|
|
||
| def decide_all(user_context, decide_options = []) | ||
| # raising on user context as it is internal and not provided directly by the user. | ||
| raise if user_context.class != OptimizelyUserContext | ||
|
|
||
| # check if SDK is ready | ||
| unless is_valid | ||
| @logger.log(Logger::ERROR, InvalidProjectConfigError.new('decide_all').message) | ||
| return {} | ||
| end | ||
|
|
||
| keys = [] | ||
| project_config.feature_flags.each do |feature_flag| | ||
| keys.push(feature_flag['key']) | ||
| end | ||
| decide_for_keys(user_context, keys, decide_options) | ||
| end | ||
|
|
||
| def decide_for_keys(user_context, keys, decide_options = []) | ||
| # raising on user context as it is internal and not provided directly by the user. | ||
| raise if user_context.class != OptimizelyUserContext | ||
|
|
||
| # check if SDK is ready | ||
| unless is_valid | ||
| @logger.log(Logger::ERROR, InvalidProjectConfigError.new('decide_for_keys').message) | ||
| return {} | ||
| end | ||
|
|
||
| enabled_flags_only = !decide_options.nil? && (decide_options.include? OptimizelyDecideOption::ENABLED_FLAGS_ONLY) | ||
|
||
| decisions = {} | ||
| keys.each do |key| | ||
| decision = decide(user_context, key, decide_options) | ||
| decisions[key] = decision unless enabled_flags_only && !decision.enabled | ||
| end | ||
| decisions | ||
| end | ||
|
|
||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. decide_for_keys() api?
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. added now |
||
| # Buckets visitor and sends impression event to Optimizely. | ||
| # | ||
| # @param experiment_key - Experiment which needs to be activated. | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,28 @@ | ||
| # frozen_string_literal: true | ||
|
|
||
| # Copyright 2020, Optimizely and contributors | ||
| # | ||
| # Licensed under the Apache License, Version 2.0 (the "License"); | ||
| # you may not use this file except in compliance with the License. | ||
| # You may obtain a copy of the License at | ||
| # | ||
| # http://www.apache.org/licenses/LICENSE-2.0 | ||
| # | ||
| # Unless required by applicable law or agreed to in writing, software | ||
| # distributed under the License is distributed on an "AS IS" BASIS, | ||
| # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | ||
| # See the License for the specific language governing permissions and | ||
| # limitations under the License. | ||
| # | ||
|
|
||
| module Optimizely | ||
| module Decide | ||
| module OptimizelyDecideOption | ||
| DISABLE_DECISION_EVENT = 'DISABLE_DECISION_EVENT' | ||
| ENABLED_FLAGS_ONLY = 'ENABLED_FLAGS_ONLY' | ||
| IGNORE_USER_PROFILE_SERVICE = 'IGNORE_USER_PROFILE_SERVICE' | ||
| INCLUDE_REASONS = 'INCLUDE_REASONS' | ||
| EXCLUDE_VARIABLES = 'EXCLUDE_VARIABLES' | ||
| end | ||
| end | ||
| end |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,60 @@ | ||
| # frozen_string_literal: true | ||
|
|
||
| # Copyright 2020, Optimizely and contributors | ||
| # | ||
| # Licensed under the Apache License, Version 2.0 (the "License"); | ||
| # you may not use this file except in compliance with the License. | ||
| # You may obtain a copy of the License at | ||
| # | ||
| # http://www.apache.org/licenses/LICENSE-2.0 | ||
| # | ||
| # Unless required by applicable law or agreed to in writing, software | ||
| # distributed under the License is distributed on an "AS IS" BASIS, | ||
| # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | ||
| # See the License for the specific language governing permissions and | ||
| # limitations under the License. | ||
| # | ||
|
|
||
| require 'json' | ||
|
|
||
| module Optimizely | ||
| module Decide | ||
| class OptimizelyDecision | ||
| attr_reader :variation_key, :enabled, :variables, :rule_key, :flag_key, :user_context, :reasons | ||
|
|
||
| def initialize( | ||
| variation_key: nil, | ||
| enabled: nil, | ||
| variables: nil, | ||
| rule_key: nil, | ||
| flag_key: nil, | ||
| user_context: nil, | ||
| reasons: nil | ||
| ) | ||
| @variation_key = variation_key | ||
| @enabled = enabled || false | ||
| @variables = variables || {} | ||
| @rule_key = rule_key | ||
| @flag_key = flag_key | ||
| @user_context = user_context | ||
| @reasons = reasons || [] | ||
| end | ||
|
|
||
| def as_json | ||
| { | ||
| variation_key: @variation_key, | ||
| enabled: @enabled, | ||
| variables: @variables, | ||
| rule_key: @rule_key, | ||
| flag_key: @flag_key, | ||
| user_context: @user_context.as_json, | ||
| reasons: @reasons | ||
| } | ||
| end | ||
|
|
||
| def to_json(*args) | ||
| as_json.to_json(*args) | ||
| end | ||
| end | ||
| end | ||
| end |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Can we add checking SDK_NOT_READY error here? If not, we should return an empty map instead of a map of error-decisions.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
done