-
Notifications
You must be signed in to change notification settings - Fork 32
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
feat(decide): add a new set of decide apis #406
Changes from all commits
9f6c40c
d476539
8bea738
6800e66
642f957
e8745ef
d4e2b1c
2d426a5
b8a19a7
e3cedab
2c83fa2
bb9f4dc
3223a64
7792926
31cce4f
77e09d5
4bfa1f7
fdbf75a
c0b6c85
a93fc23
f803cd7
43f7b01
7ce768d
5b6e798
29d3de4
a5954e4
6f40cb0
0ecc4b6
28493df
fe6cde5
9933238
82e9036
73668a9
e0cb8fb
4c68f74
0fbe851
b3165f6
a1202c3
de508fb
b9e9e34
dd47439
af0f1f9
4a35f33
21c2a9d
f0de871
ff67de0
70373c1
819b7db
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 |
---|---|---|
|
@@ -34,6 +34,11 @@ | |
import com.optimizely.ab.optimizelyconfig.OptimizelyConfig; | ||
import com.optimizely.ab.optimizelyconfig.OptimizelyConfigManager; | ||
import com.optimizely.ab.optimizelyconfig.OptimizelyConfigService; | ||
import com.optimizely.ab.optimizelydecision.DecisionMessage; | ||
import com.optimizely.ab.optimizelydecision.DecisionReasons; | ||
import com.optimizely.ab.optimizelydecision.DefaultDecisionReasons; | ||
import com.optimizely.ab.optimizelydecision.OptimizelyDecideOption; | ||
import com.optimizely.ab.optimizelydecision.OptimizelyDecision; | ||
import com.optimizely.ab.optimizelyjson.OptimizelyJSON; | ||
import org.slf4j.Logger; | ||
import org.slf4j.LoggerFactory; | ||
|
@@ -76,7 +81,6 @@ public class Optimizely implements AutoCloseable { | |
|
||
private static final Logger logger = LoggerFactory.getLogger(Optimizely.class); | ||
|
||
@VisibleForTesting | ||
final DecisionService decisionService; | ||
@VisibleForTesting | ||
@Deprecated | ||
|
@@ -86,6 +90,8 @@ public class Optimizely implements AutoCloseable { | |
@VisibleForTesting | ||
final ErrorHandler errorHandler; | ||
|
||
public final List<OptimizelyDecideOption> defaultDecideOptions; | ||
|
||
private final ProjectConfigManager projectConfigManager; | ||
|
||
@Nullable | ||
|
@@ -104,7 +110,8 @@ private Optimizely(@Nonnull EventHandler eventHandler, | |
@Nullable UserProfileService userProfileService, | ||
@Nonnull ProjectConfigManager projectConfigManager, | ||
@Nullable OptimizelyConfigManager optimizelyConfigManager, | ||
@Nonnull NotificationCenter notificationCenter | ||
@Nonnull NotificationCenter notificationCenter, | ||
@Nonnull List<OptimizelyDecideOption> defaultDecideOptions | ||
) { | ||
this.eventHandler = eventHandler; | ||
this.eventProcessor = eventProcessor; | ||
|
@@ -114,6 +121,7 @@ private Optimizely(@Nonnull EventHandler eventHandler, | |
this.projectConfigManager = projectConfigManager; | ||
this.optimizelyConfigManager = optimizelyConfigManager; | ||
this.notificationCenter = notificationCenter; | ||
this.defaultDecideOptions = defaultDecideOptions; | ||
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. we should clone it. 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. It's cloned already in builder, so no need for it here. |
||
} | ||
|
||
/** | ||
|
@@ -779,7 +787,6 @@ <T> T getFeatureVariableValueForType(@Nonnull String featureKey, | |
} | ||
|
||
// Helper method which takes type and variable value and convert it to object to use in Listener DecisionInfo object variable value | ||
@VisibleForTesting | ||
Object convertStringToType(String variableValue, String type) { | ||
if (variableValue != null) { | ||
switch (type) { | ||
|
@@ -1129,6 +1136,202 @@ public OptimizelyConfig getOptimizelyConfig() { | |
return new OptimizelyConfigService(projectConfig).getConfig(); | ||
} | ||
|
||
//============ decide ============// | ||
|
||
/** | ||
* Create a context of the user for which decision APIs will be called. | ||
* | ||
* A user context will be created successfully even when the SDK is not fully configured yet. | ||
* | ||
* @param userId The user ID to be used for bucketing. | ||
* @param attributes: A map of attribute names to current user attribute values. | ||
* @return An OptimizelyUserContext associated with this OptimizelyClient. | ||
*/ | ||
public OptimizelyUserContext createUserContext(@Nonnull String userId, | ||
@Nonnull Map<String, Object> attributes) { | ||
if (userId == null) { | ||
logger.warn("The userId parameter must be nonnull."); | ||
return null; | ||
} | ||
|
||
return new OptimizelyUserContext(this, userId, attributes); | ||
} | ||
|
||
public OptimizelyUserContext createUserContext(@Nonnull String userId) { | ||
return new OptimizelyUserContext(this, userId); | ||
} | ||
|
||
OptimizelyDecision decide(@Nonnull OptimizelyUserContext user, | ||
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. FWIW, I think having consistent signatures would be preferable. So in this case keep the userId and userAttributes in the signatures within 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. I agree. We better keep "user" and I hope we eventually make them all consistent. But for legacy APIs to be deprecated, we keep them as is. 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. Hm I see, you have this as package private. My suggestion would not require that and still provide access to this functionality. 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. I'd like to keep these methods in OptimizelyClient as private for all SDKs to mitigate confusion - they can use the methods via user-context only. Let me know if I miss substantial benefits for keeping them public. |
||
@Nonnull String key, | ||
@Nonnull List<OptimizelyDecideOption> options) { | ||
|
||
ProjectConfig projectConfig = getProjectConfig(); | ||
if (projectConfig == null) { | ||
return OptimizelyDecision.newErrorDecision(key, user, DecisionMessage.SDK_NOT_READY.reason()); | ||
} | ||
|
||
FeatureFlag flag = projectConfig.getFeatureKeyMapping().get(key); | ||
if (flag == null) { | ||
return OptimizelyDecision.newErrorDecision(key, user, DecisionMessage.FLAG_KEY_INVALID.reason(key)); | ||
} | ||
|
||
String userId = user.getUserId(); | ||
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. am not sure, but we should add a check userContext shouldn't be null 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. No need since this private is called thru a valid user context |
||
Map<String, Object> attributes = user.getAttributes(); | ||
Boolean decisionEventDispatched = false; | ||
List<OptimizelyDecideOption> allOptions = getAllOptions(options); | ||
DecisionReasons decisionReasons = DefaultDecisionReasons.newInstance(allOptions); | ||
|
||
Map<String, ?> copiedAttributes = new HashMap<>(attributes); | ||
FeatureDecision flagDecision = decisionService.getVariationForFeature( | ||
flag, | ||
userId, | ||
copiedAttributes, | ||
projectConfig, | ||
allOptions, | ||
decisionReasons); | ||
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. It appears that 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. A good point! We also consider that option as well. It's dropped since it's a bit complicated to process and merge (and/or audience, etc) at each stage. We can find better solutions. I think you can try with other sdks with your suggestion and I can come back to this if we see a good solution. |
||
|
||
Boolean flagEnabled = false; | ||
if (flagDecision.variation != null) { | ||
if (flagDecision.variation.getFeatureEnabled()) { | ||
flagEnabled = true; | ||
} | ||
} | ||
logger.info("Feature \"{}\" is enabled for user \"{}\"? {}", key, userId, flagEnabled); | ||
|
||
Map<String, Object> variableMap = new HashMap<>(); | ||
if (!allOptions.contains(OptimizelyDecideOption.EXCLUDE_VARIABLES)) { | ||
variableMap = getDecisionVariableMap( | ||
flag, | ||
flagDecision.variation, | ||
flagEnabled, | ||
decisionReasons); | ||
} | ||
OptimizelyJSON optimizelyJSON = new OptimizelyJSON(variableMap); | ||
|
||
FeatureDecision.DecisionSource decisionSource = FeatureDecision.DecisionSource.ROLLOUT; | ||
if (flagDecision.decisionSource != null) { | ||
decisionSource = flagDecision.decisionSource; | ||
} | ||
|
||
List<String> reasonsToReport = decisionReasons.toReport(); | ||
String variationKey = flagDecision.variation != null ? flagDecision.variation.getKey() : null; | ||
// TODO: add ruleKey values when available later. use a copy of experimentKey until then. | ||
// add to event metadata as well (currently set to experimentKey) | ||
String ruleKey = flagDecision.experiment != null ? flagDecision.experiment.getKey() : null; | ||
|
||
if (!allOptions.contains(OptimizelyDecideOption.DISABLE_DECISION_EVENT)) { | ||
sendImpression( | ||
projectConfig, | ||
flagDecision.experiment, | ||
userId, | ||
copiedAttributes, | ||
flagDecision.variation, | ||
key, | ||
decisionSource.toString(), | ||
flagEnabled); | ||
decisionEventDispatched = true; | ||
} | ||
|
||
DecisionNotification decisionNotification = DecisionNotification.newFlagDecisionNotificationBuilder() | ||
.withUserId(userId) | ||
.withAttributes(copiedAttributes) | ||
.withFlagKey(key) | ||
.withEnabled(flagEnabled) | ||
.withVariables(variableMap) | ||
.withVariationKey(variationKey) | ||
.withRuleKey(ruleKey) | ||
.withReasons(reasonsToReport) | ||
.withDecisionEventDispatched(decisionEventDispatched) | ||
.build(); | ||
notificationCenter.send(decisionNotification); | ||
|
||
return new OptimizelyDecision( | ||
variationKey, | ||
flagEnabled, | ||
optimizelyJSON, | ||
ruleKey, | ||
key, | ||
user, | ||
reasonsToReport); | ||
} | ||
|
||
Map<String, OptimizelyDecision> decideForKeys(@Nonnull OptimizelyUserContext user, | ||
@Nonnull List<String> keys, | ||
@Nonnull List<OptimizelyDecideOption> options) { | ||
Map<String, OptimizelyDecision> decisionMap = new HashMap<>(); | ||
|
||
ProjectConfig projectConfig = getProjectConfig(); | ||
if (projectConfig == null) { | ||
logger.error("Optimizely instance is not valid, failing isFeatureEnabled call."); | ||
return decisionMap; | ||
} | ||
|
||
if (keys.isEmpty()) return decisionMap; | ||
|
||
List<OptimizelyDecideOption> allOptions = getAllOptions(options); | ||
|
||
for (String key : keys) { | ||
OptimizelyDecision decision = decide(user, key, options); | ||
if (!allOptions.contains(OptimizelyDecideOption.ENABLED_FLAGS_ONLY) || decision.getEnabled()) { | ||
decisionMap.put(key, decision); | ||
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. When ENABLED_FLAGS_ONLY is true, why we are sending notification? 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. Not sure about this comment. Clarification? |
||
} | ||
} | ||
|
||
return decisionMap; | ||
} | ||
|
||
Map<String, OptimizelyDecision> decideAll(@Nonnull OptimizelyUserContext user, | ||
@Nonnull List<OptimizelyDecideOption> options) { | ||
Map<String, OptimizelyDecision> decisionMap = new HashMap<>(); | ||
|
||
ProjectConfig projectConfig = getProjectConfig(); | ||
if (projectConfig == null) { | ||
logger.error("Optimizely instance is not valid, failing isFeatureEnabled call."); | ||
return decisionMap; | ||
} | ||
|
||
List<FeatureFlag> allFlags = projectConfig.getFeatureFlags(); | ||
List<String> allFlagKeys = new ArrayList<>(); | ||
for (int i = 0; i < allFlags.size(); i++) allFlagKeys.add(allFlags.get(i).getKey()); | ||
|
||
return decideForKeys(user, allFlagKeys, options); | ||
} | ||
|
||
private List<OptimizelyDecideOption> getAllOptions(List<OptimizelyDecideOption> options) { | ||
List<OptimizelyDecideOption> copiedOptions = new ArrayList(defaultDecideOptions); | ||
if (options != null) { | ||
copiedOptions.addAll(options); | ||
} | ||
return copiedOptions; | ||
} | ||
|
||
private Map<String, Object> getDecisionVariableMap(@Nonnull FeatureFlag flag, | ||
@Nonnull Variation variation, | ||
@Nonnull Boolean featureEnabled, | ||
@Nonnull DecisionReasons decisionReasons) { | ||
Map<String, Object> valuesMap = new HashMap<String, Object>(); | ||
for (FeatureVariable variable : flag.getVariables()) { | ||
String value = variable.getDefaultValue(); | ||
if (featureEnabled) { | ||
FeatureVariableUsageInstance instance = variation.getVariableIdToFeatureVariableUsageInstanceMap().get(variable.getId()); | ||
if (instance != null) { | ||
value = instance.getValue(); | ||
} | ||
} | ||
|
||
Object convertedValue = convertStringToType(value, variable.getType()); | ||
if (convertedValue == null) { | ||
decisionReasons.addError(DecisionMessage.VARIABLE_VALUE_INVALID.reason(variable.getKey())); | ||
} else if (convertedValue instanceof OptimizelyJSON) { | ||
convertedValue = ((OptimizelyJSON) convertedValue).toMap(); | ||
} | ||
|
||
valuesMap.put(variable.getKey(), convertedValue); | ||
} | ||
|
||
return valuesMap; | ||
} | ||
|
||
/** | ||
* Helper method which makes separate copy of attributesMap variable and returns it | ||
* | ||
|
@@ -1233,6 +1436,7 @@ public static class Builder { | |
private OptimizelyConfigManager optimizelyConfigManager; | ||
private UserProfileService userProfileService; | ||
private NotificationCenter notificationCenter; | ||
private List<OptimizelyDecideOption> defaultDecideOptions; | ||
|
||
// For backwards compatibility | ||
private AtomicProjectConfigManager fallbackConfigManager = new AtomicProjectConfigManager(); | ||
|
@@ -1304,6 +1508,11 @@ public Builder withDatafile(String datafile) { | |
return this; | ||
} | ||
|
||
public Builder withDefaultDecideOptions(List<OptimizelyDecideOption> defaultDecideOtions) { | ||
this.defaultDecideOptions = defaultDecideOtions; | ||
return this; | ||
} | ||
|
||
// Helper functions for making testing easier | ||
protected Builder withBucketing(Bucketer bucketer) { | ||
this.bucketer = bucketer; | ||
|
@@ -1372,7 +1581,13 @@ public Optimizely build() { | |
eventProcessor = new ForwardingEventProcessor(eventHandler, notificationCenter); | ||
} | ||
|
||
return new Optimizely(eventHandler, eventProcessor, errorHandler, decisionService, userProfileService, projectConfigManager, optimizelyConfigManager, notificationCenter); | ||
if (defaultDecideOptions != null) { | ||
defaultDecideOptions = Collections.unmodifiableList(defaultDecideOptions); | ||
} else { | ||
defaultDecideOptions = Collections.emptyList(); | ||
} | ||
|
||
return new Optimizely(eventHandler, eventProcessor, errorHandler, decisionService, userProfileService, projectConfigManager, optimizelyConfigManager, notificationCenter, defaultDecideOptions); | ||
} | ||
} | ||
} |
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 you add an overloaded method and pass
[]
without defaultDecideOptions?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.
@msohailhussain can you elaborate? this is a private constructor any defaults should be handled in the builder. I don't think we should add another constructor.
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.
Yeah i missed private, this is fine.