` interface
+and are registered via `NotificationCenterxaddNotificationHandler`. Note that notifications are called synchronously and have the potential to
+block the main thread.
+
+## ProjectConfig
+The [`ProjectConfig`](https://github.com/optimizely/java-sdk/blob/master/core-api/src/main/java/com/optimizely/ab/config/ProjectConfig.java)
+represents the current state of the Optimizely project as configured through [optimizely.com](https://www.optimizely.com/).
+The interface is currently unstable and only used internally. All public access to this implementation is subject to change
+with each subsequent version.
+
+### DatafileProjectConfig
+The [`DatafileProjectConfig`](https://github.com/optimizely/java-sdk/blob/master/core-api/src/main/java/com/optimizely/ab/config/DatafileProjectConfig.java)
+is an implementation of `ProjectConfig` backed by a file, typically sourced from the Optimizely CDN.
+
+## ProjectConfigManager
+The [`ProjectConfigManager`](https://github.com/optimizely/java-sdk/blob/master/core-api/src/main/java/com/optimizely/ab/config/ProjectConfigManager.java)
+is a factory class that provides `ProjectConfig`. Implementations of this class provide a consistent representation
+of a `ProjectConfig` that can be references between service calls.
+
+### AtomicProjectConfigManager
+The [`AtomicProjectConfigManager`](https://github.com/optimizely/java-sdk/blob/master/core-api/src/main/java/com/optimizely/ab/config/AtomicProjectConfigManager.java)
+is a static provider that can be updated atomically to provide a consistent view of a `ProjectConfig`.
+
+### PollingProjectConfigManager
+The [`PollingProjectConfigManager`](https://github.com/optimizely/java-sdk/blob/master/core-api/src/main/java/com/optimizely/ab/config/PollingProjectConfigManager.java)
+is an abstract class that provides the framework for a dynamic factory that updates asynchronously within a background thread.
+Implementations of this class can be used to poll from an externalized sourced without blocking the main application thread.
diff --git a/core-api/build.gradle b/core-api/build.gradle
index cd1d1fa9e..602131cd3 100644
--- a/core-api/build.gradle
+++ b/core-api/build.gradle
@@ -1,9 +1,10 @@
dependencies {
- compile group: 'org.slf4j', name: 'slf4j-api', version: slf4jVersion
- compile group: 'com.fasterxml.jackson.core', name: 'jackson-annotations', version: jacksonVersion
-
- compile group: 'com.google.code.findbugs', name: 'annotations', version: findbugsVersion
- compile group: 'com.google.code.findbugs', name: 'jsr305', version: findbugsVersion
+ implementation group: 'org.slf4j', name: 'slf4j-api', version: slf4jVersion
+ implementation group: 'com.fasterxml.jackson.core', name: 'jackson-annotations', version: jacksonVersion
+ implementation group: 'com.google.code.findbugs', name: 'annotations', version: findbugsAnnotationVersion
+ implementation group: 'com.google.code.findbugs', name: 'jsr305', version: findbugsJsrVersion
+ testImplementation group: 'junit', name: 'junit', version: junitVersion
+ testImplementation group: 'ch.qos.logback', name: 'logback-classic', version: logbackVersion
// an assortment of json parsers
compileOnly group: 'com.google.code.gson', name: 'gson', version: gsonVersion, optional
@@ -12,6 +13,24 @@ dependencies {
compileOnly group: 'com.fasterxml.jackson.core', name: 'jackson-databind', version: jacksonVersion, optional
}
+tasks.named('processJmhResources') {
+ duplicatesStrategy = DuplicatesStrategy.WARN
+}
+
+
+test {
+ useJUnit {
+ excludeCategories 'com.optimizely.ab.categories.ExhaustiveTest'
+ }
+}
+
+task exhaustiveTest(type: Test) {
+ useJUnit {
+ includeCategories 'com.optimizely.ab.categories.ExhaustiveTest'
+ }
+}
+
+
task generateVersionFile {
// add the build version information into a file that'll go into the distribution
ext.buildVersion = new File(projectDir, "src/main/resources/optimizely-build-version")
diff --git a/core-api/src/jmh/java/com/optimizely/ab/config/parser/JacksonConfigParserBenchmark.java b/core-api/src/jmh/java/com/optimizely/ab/config/parser/JacksonConfigParserBenchmark.java
new file mode 100644
index 000000000..8751da4b1
--- /dev/null
+++ b/core-api/src/jmh/java/com/optimizely/ab/config/parser/JacksonConfigParserBenchmark.java
@@ -0,0 +1,60 @@
+/**
+ *
+ * Copyright 2018-2019 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.
+ */
+package com.optimizely.ab.config.parser;
+
+import com.optimizely.ab.config.ProjectConfig;
+import com.optimizely.ab.config.DatafileProjectConfigTestUtils;
+import org.openjdk.jmh.annotations.*;
+
+import java.io.IOException;
+import java.util.concurrent.TimeUnit;
+
+@BenchmarkMode(Mode.AverageTime)
+@OutputTimeUnit(TimeUnit.MICROSECONDS)
+@Fork(2)
+@Warmup(iterations = 10)
+@Measurement(iterations = 20)
+@State(Scope.Benchmark)
+public class JacksonConfigParserBenchmark {
+ JacksonConfigParser parser;
+ String jsonV2;
+ String jsonV3;
+ String jsonV4;
+
+ @Setup
+ public void setUp() throws IOException {
+ parser = new JacksonConfigParser();
+ jsonV2 = DatafileProjectConfigTestUtils.validConfigJsonV2();
+ jsonV3 = DatafileProjectConfigTestUtils.validConfigJsonV3();
+ jsonV4 = DatafileProjectConfigTestUtils.validConfigJsonV4();
+ }
+
+ @Benchmark
+ public ProjectConfig parseV2() throws ConfigParseException {
+ return parser.parseProjectConfig(jsonV2);
+ }
+
+ @Benchmark
+ public ProjectConfig parseV3() throws ConfigParseException {
+ return parser.parseProjectConfig(jsonV3);
+ }
+
+ @Benchmark
+ public ProjectConfig parseV4() throws ConfigParseException {
+ return parser.parseProjectConfig(jsonV4);
+ }
+}
diff --git a/core-api/src/main/java/com/optimizely/ab/Optimizely.java b/core-api/src/main/java/com/optimizely/ab/Optimizely.java
index 51f5dadbd..6eead11c6 100644
--- a/core-api/src/main/java/com/optimizely/ab/Optimizely.java
+++ b/core-api/src/main/java/com/optimizely/ab/Optimizely.java
@@ -1,5 +1,5 @@
/****************************************************************************
- * Copyright 2016-2018, Optimizely, Inc. and contributors *
+ * Copyright 2016-2024, Optimizely, Inc. and contributors *
* *
* Licensed under the Apache License, Version 2.0 (the "License"); *
* you may not use this file except in compliance with the License. *
@@ -20,45 +20,80 @@
import com.optimizely.ab.bucketing.DecisionService;
import com.optimizely.ab.bucketing.FeatureDecision;
import com.optimizely.ab.bucketing.UserProfileService;
-import com.optimizely.ab.config.Attribute;
+import com.optimizely.ab.config.AtomicProjectConfigManager;
+import com.optimizely.ab.config.DatafileProjectConfig;
import com.optimizely.ab.config.EventType;
import com.optimizely.ab.config.Experiment;
import com.optimizely.ab.config.FeatureFlag;
-import com.optimizely.ab.config.LiveVariable;
-import com.optimizely.ab.config.LiveVariableUsageInstance;
+import com.optimizely.ab.config.FeatureVariable;
+import com.optimizely.ab.config.FeatureVariableUsageInstance;
import com.optimizely.ab.config.ProjectConfig;
+import com.optimizely.ab.config.ProjectConfigManager;
import com.optimizely.ab.config.Variation;
import com.optimizely.ab.config.parser.ConfigParseException;
-import com.optimizely.ab.config.parser.DefaultConfigParser;
import com.optimizely.ab.error.ErrorHandler;
import com.optimizely.ab.error.NoOpErrorHandler;
import com.optimizely.ab.event.EventHandler;
+import com.optimizely.ab.event.EventProcessor;
+import com.optimizely.ab.event.ForwardingEventProcessor;
import com.optimizely.ab.event.LogEvent;
+import com.optimizely.ab.event.NoopEventHandler;
import com.optimizely.ab.event.internal.BuildVersionInfo;
+import com.optimizely.ab.event.internal.ClientEngineInfo;
import com.optimizely.ab.event.internal.EventFactory;
-import com.optimizely.ab.event.internal.payload.EventBatch.ClientEngine;
+import com.optimizely.ab.event.internal.UserEvent;
+import com.optimizely.ab.event.internal.UserEventFactory;
+import com.optimizely.ab.event.internal.payload.EventBatch;
+import com.optimizely.ab.internal.NotificationRegistry;
+import com.optimizely.ab.notification.ActivateNotification;
+import com.optimizely.ab.notification.DecisionNotification;
+import com.optimizely.ab.notification.FeatureTestSourceInfo;
import com.optimizely.ab.notification.NotificationCenter;
+import com.optimizely.ab.notification.NotificationHandler;
+import com.optimizely.ab.notification.RolloutSourceInfo;
+import com.optimizely.ab.notification.SourceInfo;
+import com.optimizely.ab.notification.TrackNotification;
+import com.optimizely.ab.notification.UpdateConfigNotification;
+import com.optimizely.ab.odp.ODPEvent;
+import com.optimizely.ab.odp.ODPManager;
+import com.optimizely.ab.odp.ODPSegmentManager;
+import com.optimizely.ab.odp.ODPSegmentOption;
+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.DecisionResponse;
+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;
import javax.annotation.Nonnull;
import javax.annotation.Nullable;
import javax.annotation.concurrent.ThreadSafe;
+import java.io.Closeable;
import java.util.ArrayList;
+import java.util.Arrays;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
+import java.util.concurrent.locks.ReentrantLock;
+
+import static com.optimizely.ab.internal.SafetyUtils.tryClose;
/**
* Top-level container class for Optimizely functionality.
* Thread-safe, so can be created as a singleton and safely passed around.
- *
+ *
* Example instantiation:
*
* Optimizely optimizely = Optimizely.builder(projectWatcher, eventHandler).build();
*
- *
+ *
* To activate an experiment and perform variation specific processing:
*
* Variation variation = optimizely.activate(experimentKey, userId, attributes);
@@ -76,51 +111,116 @@
* to be logged, and for the "control" variation to be returned.
*/
@ThreadSafe
-public class Optimizely {
+public class Optimizely implements AutoCloseable {
private static final Logger logger = LoggerFactory.getLogger(Optimizely.class);
- @VisibleForTesting final DecisionService decisionService;
- @VisibleForTesting final EventFactory eventFactory;
- @VisibleForTesting final ProjectConfig projectConfig;
- @VisibleForTesting final EventHandler eventHandler;
- @VisibleForTesting final ErrorHandler errorHandler;
- public final NotificationCenter notificationCenter = new NotificationCenter();
+ final DecisionService decisionService;
+ @Deprecated
+ final EventHandler eventHandler;
+ @VisibleForTesting
+ final EventProcessor eventProcessor;
+ @VisibleForTesting
+ final ErrorHandler errorHandler;
+
+ public final List defaultDecideOptions;
- @Nullable private final UserProfileService userProfileService;
+ @VisibleForTesting
+ final ProjectConfigManager projectConfigManager;
- private Optimizely(@Nonnull ProjectConfig projectConfig,
- @Nonnull DecisionService decisionService,
- @Nonnull EventHandler eventHandler,
- @Nonnull EventFactory eventFactory,
+ @Nullable
+ private final OptimizelyConfigManager optimizelyConfigManager;
+
+ // TODO should be private
+ public final NotificationCenter notificationCenter;
+
+ @Nullable
+ private final UserProfileService userProfileService;
+
+ @Nullable
+ private final ODPManager odpManager;
+
+ private final ReentrantLock lock = new ReentrantLock();
+
+ private Optimizely(@Nonnull EventHandler eventHandler,
+ @Nonnull EventProcessor eventProcessor,
@Nonnull ErrorHandler errorHandler,
- @Nullable UserProfileService userProfileService) {
- this.projectConfig = projectConfig;
- this.decisionService = decisionService;
+ @Nonnull DecisionService decisionService,
+ @Nullable UserProfileService userProfileService,
+ @Nonnull ProjectConfigManager projectConfigManager,
+ @Nullable OptimizelyConfigManager optimizelyConfigManager,
+ @Nonnull NotificationCenter notificationCenter,
+ @Nonnull List defaultDecideOptions,
+ @Nullable ODPManager odpManager
+ ) {
this.eventHandler = eventHandler;
- this.eventFactory = eventFactory;
+ this.eventProcessor = eventProcessor;
this.errorHandler = errorHandler;
+ this.decisionService = decisionService;
this.userProfileService = userProfileService;
+ this.projectConfigManager = projectConfigManager;
+ this.optimizelyConfigManager = optimizelyConfigManager;
+ this.notificationCenter = notificationCenter;
+ this.defaultDecideOptions = defaultDecideOptions;
+ this.odpManager = odpManager;
+
+ if (odpManager != null) {
+ odpManager.getEventManager().start();
+ if (projectConfigManager.getCachedConfig() != null) {
+ updateODPSettings();
+ }
+ if (projectConfigManager.getSDKKey() != null) {
+ NotificationRegistry.getInternalNotificationCenter(projectConfigManager.getSDKKey()).
+ addNotificationHandler(UpdateConfigNotification.class,
+ configNotification -> {
+ updateODPSettings();
+ });
+ }
+
+ }
}
- // Do work here that should be done once per Optimizely lifecycle
- @VisibleForTesting
- void initialize() {
+ /**
+ * Determine if the instance of the Optimizely client is valid. An instance can be deemed invalid if it was not
+ * initialized properly due to an invalid datafile being passed in.
+ *
+ * @return True if the Optimizely instance is valid.
+ * False if the Optimizely instance is not valid.
+ */
+ public boolean isValid() {
+ return getProjectConfig() != null;
+ }
+ /**
+ * Checks if eventHandler {@link EventHandler} and projectConfigManager {@link ProjectConfigManager}
+ * are Closeable {@link Closeable} and calls close on them.
+ *
+ * NOTE: There is a chance that this could be long running if the implementations of close are long running.
+ */
+ @Override
+ public void close() {
+ tryClose(eventProcessor);
+ tryClose(eventHandler);
+ tryClose(projectConfigManager);
+ notificationCenter.clearAllNotificationListeners();
+ NotificationRegistry.clearNotificationCenterRegistry(projectConfigManager.getSDKKey());
+ if (odpManager != null) {
+ tryClose(odpManager);
+ }
}
//======== activate calls ========//
- public @Nullable
- Variation activate(@Nonnull String experimentKey,
- @Nonnull String userId) throws UnknownExperimentException {
+ @Nullable
+ public Variation activate(@Nonnull String experimentKey,
+ @Nonnull String userId) throws UnknownExperimentException {
return activate(experimentKey, userId, Collections.emptyMap());
}
- public @Nullable
- Variation activate(@Nonnull String experimentKey,
- @Nonnull String userId,
- @Nonnull Map attributes) throws UnknownExperimentException {
+ @Nullable
+ public Variation activate(@Nonnull String experimentKey,
+ @Nonnull String userId,
+ @Nonnull Map attributes) throws UnknownExperimentException {
if (experimentKey == null) {
logger.error("The experimentKey parameter must be nonnull.");
@@ -132,91 +232,128 @@ Variation activate(@Nonnull String experimentKey,
return null;
}
- ProjectConfig currentConfig = getProjectConfig();
+ ProjectConfig projectConfig = getProjectConfig();
+ if (projectConfig == null) {
+ logger.error("Optimizely instance is not valid, failing activate call.");
+ return null;
+ }
- Experiment experiment = currentConfig.getExperimentForKey(experimentKey, errorHandler);
+ Experiment experiment = projectConfig.getExperimentForKey(experimentKey, errorHandler);
if (experiment == null) {
// if we're unable to retrieve the associated experiment, return null
logger.info("Not activating user \"{}\" for experiment \"{}\".", userId, experimentKey);
return null;
}
- return activate(currentConfig, experiment, userId, attributes);
+ return activate(projectConfig, experiment, userId, attributes);
}
- public @Nullable
- Variation activate(@Nonnull Experiment experiment,
- @Nonnull String userId) {
+ @Nullable
+ public Variation activate(@Nonnull Experiment experiment,
+ @Nonnull String userId) {
return activate(experiment, userId, Collections.emptyMap());
}
- public @Nullable
- Variation activate(@Nonnull Experiment experiment,
- @Nonnull String userId,
- @Nonnull Map attributes) {
-
- ProjectConfig currentConfig = getProjectConfig();
-
- return activate(currentConfig, experiment, userId, attributes);
+ @Nullable
+ public Variation activate(@Nonnull Experiment experiment,
+ @Nonnull String userId,
+ @Nonnull Map attributes) {
+ return activate(getProjectConfig(), experiment, userId, attributes);
}
- private @Nullable
- Variation activate(@Nonnull ProjectConfig projectConfig,
- @Nonnull Experiment experiment,
- @Nonnull String userId,
- @Nonnull Map attributes) {
+ @Nullable
+ private Variation activate(@Nullable ProjectConfig projectConfig,
+ @Nonnull Experiment experiment,
+ @Nonnull String userId,
+ @Nonnull Map attributes) {
+ if (projectConfig == null) {
+ logger.error("Optimizely instance is not valid, failing activate call.");
+ return null;
+ }
- if (!validateUserId(userId)){
+ if (!validateUserId(userId)) {
logger.info("Not activating user \"{}\" for experiment \"{}\".", userId, experiment.getKey());
return null;
}
- // determine whether all the given attributes are present in the project config. If not, filter out the unknown
- // attributes.
- Map filteredAttributes = filterAttributes(projectConfig, attributes);
-
+ Map copiedAttributes = copyAttributes(attributes);
// bucket the user to the given experiment and dispatch an impression event
- Variation variation = decisionService.getVariation(experiment, userId, filteredAttributes);
+ Variation variation = getVariation(projectConfig, experiment, userId, copiedAttributes);
if (variation == null) {
logger.info("Not activating user \"{}\" for experiment \"{}\".", userId, experiment.getKey());
return null;
}
- sendImpression(projectConfig, experiment, userId, filteredAttributes, variation);
+ sendImpression(projectConfig, experiment, userId, copiedAttributes, variation, "experiment");
return variation;
}
+ /**
+ * Creates and sends impression event.
+ *
+ * @param projectConfig the current projectConfig
+ * @param experiment the experiment user bucketed into and dispatch an impression event
+ * @param userId the ID of the user
+ * @param filteredAttributes the attributes of the user
+ * @param variation the variation that was returned from activate.
+ * @param ruleType It can either be experiment in case impression event is sent from activate or it's feature-test or rollout
+ */
private void sendImpression(@Nonnull ProjectConfig projectConfig,
@Nonnull Experiment experiment,
@Nonnull String userId,
- @Nonnull Map filteredAttributes,
- @Nonnull Variation variation) {
- if (experiment.isRunning()) {
- LogEvent impressionEvent = eventFactory.createImpressionEvent(
- projectConfig,
- experiment,
- variation,
- userId,
- filteredAttributes);
- logger.info("Activating user \"{}\" in experiment \"{}\".", userId, experiment.getKey());
-
- if (logger.isDebugEnabled()) {
- logger.debug(
- "Dispatching impression event to URL {} with params {} and payload \"{}\".",
- impressionEvent.getEndpointUrl(), impressionEvent.getRequestParams(), impressionEvent.getBody());
- }
-
- try {
- eventHandler.dispatchEvent(impressionEvent);
- } catch (Exception e) {
- logger.error("Unexpected exception in event dispatcher", e);
- }
+ @Nonnull Map filteredAttributes,
+ @Nonnull Variation variation,
+ @Nonnull String ruleType) {
+ sendImpression(projectConfig, experiment, userId, filteredAttributes, variation, "", ruleType, true);
+ }
- notificationCenter.sendNotifications(NotificationCenter.NotificationType.Activate, experiment, userId,
- filteredAttributes, variation, impressionEvent);
- } else {
- logger.info("Experiment has \"Launched\" status so not dispatching event during activation.");
+ /**
+ * Creates and sends impression event.
+ *
+ * @param projectConfig the current projectConfig
+ * @param experiment the experiment user bucketed into and dispatch an impression event
+ * @param userId the ID of the user
+ * @param filteredAttributes the attributes of the user
+ * @param variation the variation that was returned from activate.
+ * @param flagKey It can either be empty if ruleType is experiment or it's feature key in case ruleType is feature-test or rollout
+ * @param ruleType It can either be experiment in case impression event is sent from activate or it's feature-test or rollout
+ */
+ private boolean sendImpression(@Nonnull ProjectConfig projectConfig,
+ @Nullable Experiment experiment,
+ @Nonnull String userId,
+ @Nonnull Map filteredAttributes,
+ @Nullable Variation variation,
+ @Nonnull String flagKey,
+ @Nonnull String ruleType,
+ @Nonnull boolean enabled) {
+
+ UserEvent userEvent = UserEventFactory.createImpressionEvent(
+ projectConfig,
+ experiment,
+ variation,
+ userId,
+ filteredAttributes,
+ flagKey,
+ ruleType,
+ enabled);
+
+ if (userEvent == null) {
+ return false;
}
+ eventProcessor.process(userEvent);
+ if (experiment != null) {
+ logger.info("Activating user \"{}\" in experiment \"{}\".", userId, experiment.getKey());
+ }
+ // Kept For backwards compatibility.
+ // This notification is deprecated and the new DecisionNotifications
+ // are sent via their respective method calls.
+ if (notificationCenter.getNotificationManager(ActivateNotification.class).size() > 0) {
+ LogEvent impressionEvent = EventFactory.createLogEvent(userEvent);
+ ActivateNotification activateNotification = new ActivateNotification(
+ experiment, userId, filteredAttributes, variation, impressionEvent);
+ notificationCenter.send(activateNotification);
+ }
+ return true;
}
//======== track calls ========//
@@ -228,90 +365,63 @@ public void track(@Nonnull String eventName,
public void track(@Nonnull String eventName,
@Nonnull String userId,
- @Nonnull Map attributes) throws UnknownEventTypeException {
+ @Nonnull Map attributes) throws UnknownEventTypeException {
track(eventName, userId, attributes, Collections.emptyMap());
}
public void track(@Nonnull String eventName,
@Nonnull String userId,
- @Nonnull Map attributes,
+ @Nonnull Map attributes,
@Nonnull Map eventTags) throws UnknownEventTypeException {
-
if (!validateUserId(userId)) {
logger.info("Not tracking event \"{}\".", eventName);
return;
}
- if (eventName == null || eventName.trim().isEmpty()){
+ if (eventName == null || eventName.trim().isEmpty()) {
logger.error("Event Key is null or empty when non-null and non-empty String was expected.");
logger.info("Not tracking event for user \"{}\".", userId);
return;
}
- ProjectConfig currentConfig = getProjectConfig();
+ ProjectConfig projectConfig = getProjectConfig();
+ if (projectConfig == null) {
+ logger.error("Optimizely instance is not valid, failing isFeatureEnabled call.");
+ return;
+ }
+
+ Map copiedAttributes = copyAttributes(attributes);
- EventType eventType = currentConfig.getEventTypeForName(eventName, errorHandler);
+ EventType eventType = projectConfig.getEventTypeForName(eventName, errorHandler);
if (eventType == null) {
// if no matching event type could be found, do not dispatch an event
logger.info("Not tracking event \"{}\" for user \"{}\".", eventName, userId);
return;
}
- // determine whether all the given attributes are present in the project config. If not, filter out the unknown
- // attributes.
- Map filteredAttributes = filterAttributes(currentConfig, attributes);
-
if (eventTags == null) {
logger.warn("Event tags is null when non-null was expected. Defaulting to an empty event tags map.");
- eventTags = Collections.emptyMap();
- }
-
- List experimentsForEvent = projectConfig.getExperimentsForEventKey(eventName);
- Map experimentVariationMap = new HashMap(experimentsForEvent.size());
- for (Experiment experiment : experimentsForEvent) {
- if (experiment.isRunning()) {
- Variation variation = decisionService.getVariation(experiment, userId, filteredAttributes);
- if (variation != null) {
- experimentVariationMap.put(experiment, variation);
- }
- } else {
- logger.info(
- "Not tracking event \"{}\" for experiment \"{}\" because experiment has status \"Launched\".",
- eventType.getKey(), experiment.getKey());
- }
}
- // create the conversion event request parameters, then dispatch
- LogEvent conversionEvent = eventFactory.createConversionEvent(
- projectConfig,
- experimentVariationMap,
- userId,
- eventType.getId(),
- eventType.getKey(),
- filteredAttributes,
- eventTags);
-
- if (conversionEvent == null) {
- logger.info("There are no valid experiments for event \"{}\" to track.", eventName);
- logger.info("Not tracking event \"{}\" for user \"{}\".", eventName, userId);
- return;
- }
+ UserEvent userEvent = UserEventFactory.createConversionEvent(
+ projectConfig,
+ userId,
+ eventType.getId(),
+ eventType.getKey(),
+ copiedAttributes,
+ eventTags);
+ eventProcessor.process(userEvent);
logger.info("Tracking event \"{}\" for user \"{}\".", eventName, userId);
- if (logger.isDebugEnabled()) {
- logger.debug("Dispatching conversion event to URL {} with params {} and payload \"{}\".",
- conversionEvent.getEndpointUrl(), conversionEvent.getRequestParams(), conversionEvent.getBody());
- }
+ if (notificationCenter.getNotificationManager(TrackNotification.class).size() > 0) {
+ // create the conversion event request parameters, then dispatch
+ LogEvent conversionEvent = EventFactory.createLogEvent(userEvent);
+ TrackNotification notification = new TrackNotification(eventName, userId,
+ copiedAttributes, eventTags, conversionEvent);
- try {
- eventHandler.dispatchEvent(conversionEvent);
- } catch (Exception e) {
- logger.error("Unexpected exception in event dispatcher", e);
+ notificationCenter.send(notification);
}
-
- notificationCenter.sendNotifications(NotificationCenter.NotificationType.Track, eventName, userId,
- filteredAttributes, eventTags, conversionEvent);
}
//======== FeatureFlag APIs ========//
@@ -321,14 +431,15 @@ public void track(@Nonnull String eventName,
* Send an impression event if the user is bucketed into an experiment using the feature.
*
* @param featureKey The unique key of the feature.
- * @param userId The ID of the user.
+ * @param userId The ID of the user.
* @return True if the feature is enabled.
- * False if the feature is disabled.
- * False if the feature is not found.
+ * False if the feature is disabled.
+ * False if the feature is not found.
*/
- public @Nonnull Boolean isFeatureEnabled(@Nonnull String featureKey,
- @Nonnull String userId) {
- return isFeatureEnabled(featureKey, userId, Collections.emptyMap());
+ @Nonnull
+ public Boolean isFeatureEnabled(@Nonnull String featureKey,
+ @Nonnull String userId) {
+ return isFeatureEnabled(featureKey, userId, Collections.emptyMap());
}
/**
@@ -336,350 +447,694 @@ public void track(@Nonnull String eventName,
* Send an impression event if the user is bucketed into an experiment using the feature.
*
* @param featureKey The unique key of the feature.
- * @param userId The ID of the user.
+ * @param userId The ID of the user.
* @param attributes The user's attributes.
* @return True if the feature is enabled.
- * False if the feature is disabled.
- * False if the feature is not found.
+ * False if the feature is disabled.
+ * False if the feature is not found.
*/
- public @Nonnull Boolean isFeatureEnabled(@Nonnull String featureKey,
- @Nonnull String userId,
- @Nonnull Map attributes) {
+ @Nonnull
+ public Boolean isFeatureEnabled(@Nonnull String featureKey,
+ @Nonnull String userId,
+ @Nonnull Map attributes) {
+ ProjectConfig projectConfig = getProjectConfig();
+ if (projectConfig == null) {
+ logger.error("Optimizely instance is not valid, failing isFeatureEnabled call.");
+ return false;
+ }
+
+ return isFeatureEnabled(projectConfig, featureKey, userId, attributes);
+ }
+
+ @Nonnull
+ private Boolean isFeatureEnabled(@Nonnull ProjectConfig projectConfig,
+ @Nonnull String featureKey,
+ @Nonnull String userId,
+ @Nonnull Map attributes) {
if (featureKey == null) {
logger.warn("The featureKey parameter must be nonnull.");
return false;
- }
- else if (userId == null) {
+ } else if (userId == null) {
logger.warn("The userId parameter must be nonnull.");
return false;
}
+
FeatureFlag featureFlag = projectConfig.getFeatureKeyMapping().get(featureKey);
if (featureFlag == null) {
logger.info("No feature flag was found for key \"{}\".", featureKey);
return false;
}
- Map filteredAttributes = filterAttributes(projectConfig, attributes);
+ Map copiedAttributes = copyAttributes(attributes);
+ FeatureDecision.DecisionSource decisionSource = FeatureDecision.DecisionSource.ROLLOUT;
+ FeatureDecision featureDecision = decisionService.getVariationForFeature(featureFlag, createUserContextCopy(userId, copiedAttributes), projectConfig).getResult();
+ Boolean featureEnabled = false;
+ SourceInfo sourceInfo = new RolloutSourceInfo();
+ if (featureDecision.decisionSource != null) {
+ decisionSource = featureDecision.decisionSource;
+ }
- FeatureDecision featureDecision = decisionService.getVariationForFeature(featureFlag, userId, filteredAttributes);
if (featureDecision.variation != null) {
- if (featureDecision.decisionSource.equals(FeatureDecision.DecisionSource.EXPERIMENT)) {
- sendImpression(
- projectConfig,
- featureDecision.experiment,
- userId,
- filteredAttributes,
- featureDecision.variation);
+ // This information is only necessary for feature tests.
+ // For rollouts experiments and variations are an implementation detail only.
+ if (featureDecision.decisionSource.equals(FeatureDecision.DecisionSource.FEATURE_TEST)) {
+ sourceInfo = new FeatureTestSourceInfo(featureDecision.experiment.getKey(), featureDecision.variation.getKey());
} else {
logger.info("The user \"{}\" is not included in an experiment for feature \"{}\".",
- userId, featureKey);
+ userId, featureKey);
}
if (featureDecision.variation.getFeatureEnabled()) {
- logger.info("Feature \"{}\" is enabled for user \"{}\".", featureKey, userId);
- return true;
+ featureEnabled = true;
}
}
-
- logger.info("Feature \"{}\" is not enabled for user \"{}\".", featureKey, userId);
- return false;
+ sendImpression(
+ projectConfig,
+ featureDecision.experiment,
+ userId,
+ copiedAttributes,
+ featureDecision.variation,
+ featureKey,
+ decisionSource.toString(),
+ featureEnabled);
+
+ DecisionNotification decisionNotification = DecisionNotification.newFeatureDecisionNotificationBuilder()
+ .withUserId(userId)
+ .withAttributes(copiedAttributes)
+ .withFeatureKey(featureKey)
+ .withFeatureEnabled(featureEnabled)
+ .withSource(decisionSource)
+ .withSourceInfo(sourceInfo)
+ .build();
+
+ notificationCenter.send(decisionNotification);
+
+ logger.info("Feature \"{}\" is enabled for user \"{}\"? {}", featureKey, userId, featureEnabled);
+ return featureEnabled;
}
/**
* Get the Boolean value of the specified variable in the feature.
- * @param featureKey The unique key of the feature.
+ *
+ * @param featureKey The unique key of the feature.
* @param variableKey The unique key of the variable.
- * @param userId The ID of the user.
+ * @param userId The ID of the user.
* @return The Boolean value of the boolean single variable feature.
- * Null if the feature could not be found.
+ * Null if the feature could not be found.
*/
- public @Nullable Boolean getFeatureVariableBoolean(@Nonnull String featureKey,
- @Nonnull String variableKey,
- @Nonnull String userId) {
+ @Nullable
+ public Boolean getFeatureVariableBoolean(@Nonnull String featureKey,
+ @Nonnull String variableKey,
+ @Nonnull String userId) {
return getFeatureVariableBoolean(featureKey, variableKey, userId, Collections.emptyMap());
}
/**
* Get the Boolean value of the specified variable in the feature.
- * @param featureKey The unique key of the feature.
+ *
+ * @param featureKey The unique key of the feature.
* @param variableKey The unique key of the variable.
- * @param userId The ID of the user.
- * @param attributes The user's attributes.
+ * @param userId The ID of the user.
+ * @param attributes The user's attributes.
* @return The Boolean value of the boolean single variable feature.
- * Null if the feature or variable could not be found.
+ * Null if the feature or variable could not be found.
*/
- public @Nullable Boolean getFeatureVariableBoolean(@Nonnull String featureKey,
- @Nonnull String variableKey,
- @Nonnull String userId,
- @Nonnull Map attributes) {
- String variableValue = getFeatureVariableValueForType(
- featureKey,
- variableKey,
- userId,
- attributes,
- LiveVariable.VariableType.BOOLEAN
+ @Nullable
+ public Boolean getFeatureVariableBoolean(@Nonnull String featureKey,
+ @Nonnull String variableKey,
+ @Nonnull String userId,
+ @Nonnull Map attributes) {
+
+ return getFeatureVariableValueForType(
+ featureKey,
+ variableKey,
+ userId,
+ attributes,
+ FeatureVariable.BOOLEAN_TYPE
);
- if (variableValue != null) {
- return Boolean.parseBoolean(variableValue);
- }
- return null;
}
/**
* Get the Double value of the specified variable in the feature.
- * @param featureKey The unique key of the feature.
+ *
+ * @param featureKey The unique key of the feature.
* @param variableKey The unique key of the variable.
- * @param userId The ID of the user.
+ * @param userId The ID of the user.
* @return The Double value of the double single variable feature.
- * Null if the feature or variable could not be found.
+ * Null if the feature or variable could not be found.
*/
- public @Nullable Double getFeatureVariableDouble(@Nonnull String featureKey,
- @Nonnull String variableKey,
- @Nonnull String userId) {
+ @Nullable
+ public Double getFeatureVariableDouble(@Nonnull String featureKey,
+ @Nonnull String variableKey,
+ @Nonnull String userId) {
return getFeatureVariableDouble(featureKey, variableKey, userId, Collections.emptyMap());
}
/**
* Get the Double value of the specified variable in the feature.
- * @param featureKey The unique key of the feature.
+ *
+ * @param featureKey The unique key of the feature.
* @param variableKey The unique key of the variable.
- * @param userId The ID of the user.
- * @param attributes The user's attributes.
+ * @param userId The ID of the user.
+ * @param attributes The user's attributes.
* @return The Double value of the double single variable feature.
- * Null if the feature or variable could not be found.
+ * Null if the feature or variable could not be found.
*/
- public @Nullable Double getFeatureVariableDouble(@Nonnull String featureKey,
- @Nonnull String variableKey,
- @Nonnull String userId,
- @Nonnull Map attributes) {
- String variableValue = getFeatureVariableValueForType(
+ @Nullable
+ public Double getFeatureVariableDouble(@Nonnull String featureKey,
+ @Nonnull String variableKey,
+ @Nonnull String userId,
+ @Nonnull Map attributes) {
+
+ Double variableValue = null;
+ try {
+ variableValue = getFeatureVariableValueForType(
featureKey,
variableKey,
userId,
attributes,
- LiveVariable.VariableType.DOUBLE
- );
- if (variableValue != null) {
- try {
- return Double.parseDouble(variableValue);
- } catch (NumberFormatException exception) {
- logger.error("NumberFormatException while trying to parse \"" + variableValue +
- "\" as Double. " + exception);
- }
+ FeatureVariable.DOUBLE_TYPE
+ );
+ } catch (Exception exception) {
+ logger.error("NumberFormatException while trying to parse \"" + variableValue +
+ "\" as Double. " + exception);
}
- return null;
+
+ return variableValue;
}
/**
* Get the Integer value of the specified variable in the feature.
- * @param featureKey The unique key of the feature.
+ *
+ * @param featureKey The unique key of the feature.
* @param variableKey The unique key of the variable.
- * @param userId The ID of the user.
+ * @param userId The ID of the user.
* @return The Integer value of the integer single variable feature.
- * Null if the feature or variable could not be found.
+ * Null if the feature or variable could not be found.
*/
- public @Nullable Integer getFeatureVariableInteger(@Nonnull String featureKey,
- @Nonnull String variableKey,
- @Nonnull String userId) {
+ @Nullable
+ public Integer getFeatureVariableInteger(@Nonnull String featureKey,
+ @Nonnull String variableKey,
+ @Nonnull String userId) {
return getFeatureVariableInteger(featureKey, variableKey, userId, Collections.emptyMap());
}
/**
* Get the Integer value of the specified variable in the feature.
- * @param featureKey The unique key of the feature.
+ *
+ * @param featureKey The unique key of the feature.
* @param variableKey The unique key of the variable.
- * @param userId The ID of the user.
- * @param attributes The user's attributes.
+ * @param userId The ID of the user.
+ * @param attributes The user's attributes.
* @return The Integer value of the integer single variable feature.
- * Null if the feature or variable could not be found.
+ * Null if the feature or variable could not be found.
*/
- public @Nullable Integer getFeatureVariableInteger(@Nonnull String featureKey,
- @Nonnull String variableKey,
- @Nonnull String userId,
- @Nonnull Map attributes) {
- String variableValue = getFeatureVariableValueForType(
+ @Nullable
+ public Integer getFeatureVariableInteger(@Nonnull String featureKey,
+ @Nonnull String variableKey,
+ @Nonnull String userId,
+ @Nonnull Map attributes) {
+
+ Integer variableValue = null;
+
+ try {
+ variableValue = getFeatureVariableValueForType(
featureKey,
variableKey,
userId,
attributes,
- LiveVariable.VariableType.INTEGER
- );
- if (variableValue != null) {
- try {
- return Integer.parseInt(variableValue);
- } catch (NumberFormatException exception) {
- logger.error("NumberFormatException while trying to parse \"" + variableValue +
- "\" as Integer. " + exception.toString());
- }
+ FeatureVariable.INTEGER_TYPE
+ );
+
+ } catch (Exception exception) {
+ logger.error("NumberFormatException while trying to parse value as Integer. " + exception.toString());
+ }
+
+ return variableValue;
+ }
+
+ /**
+ * Get the Long value of the specified variable in the feature.
+ *
+ * @param featureKey The unique key of the feature.
+ * @param variableKey The unique key of the variable.
+ * @param userId The ID of the user.
+ * @return The Integer value of the integer single variable feature.
+ * Null if the feature or variable could not be found.
+ */
+ @Nullable
+ public Long getFeatureVariableLong(@Nonnull String featureKey,
+ @Nonnull String variableKey,
+ @Nonnull String userId) {
+ return getFeatureVariableLong(featureKey, variableKey, userId, Collections.emptyMap());
+ }
+
+ /**
+ * Get the Integer value of the specified variable in the feature.
+ *
+ * @param featureKey The unique key of the feature.
+ * @param variableKey The unique key of the variable.
+ * @param userId The ID of the user.
+ * @param attributes The user's attributes.
+ * @return The Integer value of the integer single variable feature.
+ * Null if the feature or variable could not be found.
+ */
+ @Nullable
+ public Long getFeatureVariableLong(@Nonnull String featureKey,
+ @Nonnull String variableKey,
+ @Nonnull String userId,
+ @Nonnull Map attributes) {
+ try {
+ return getFeatureVariableValueForType(
+ featureKey,
+ variableKey,
+ userId,
+ attributes,
+ FeatureVariable.INTEGER_TYPE
+ );
+
+ } catch (Exception exception) {
+ logger.error("NumberFormatException while trying to parse value as Long. {}", String.valueOf(exception));
}
+
return null;
}
/**
* Get the String value of the specified variable in the feature.
- * @param featureKey The unique key of the feature.
+ *
+ * @param featureKey The unique key of the feature.
* @param variableKey The unique key of the variable.
- * @param userId The ID of the user.
+ * @param userId The ID of the user.
* @return The String value of the string single variable feature.
- * Null if the feature or variable could not be found.
+ * Null if the feature or variable could not be found.
*/
- public @Nullable String getFeatureVariableString(@Nonnull String featureKey,
- @Nonnull String variableKey,
- @Nonnull String userId) {
+ @Nullable
+ public String getFeatureVariableString(@Nonnull String featureKey,
+ @Nonnull String variableKey,
+ @Nonnull String userId) {
return getFeatureVariableString(featureKey, variableKey, userId, Collections.emptyMap());
}
/**
* Get the String value of the specified variable in the feature.
- * @param featureKey The unique key of the feature.
+ *
+ * @param featureKey The unique key of the feature.
* @param variableKey The unique key of the variable.
- * @param userId The ID of the user.
- * @param attributes The user's attributes.
+ * @param userId The ID of the user.
+ * @param attributes The user's attributes.
* @return The String value of the string single variable feature.
- * Null if the feature or variable could not be found.
+ * Null if the feature or variable could not be found.
*/
- public @Nullable String getFeatureVariableString(@Nonnull String featureKey,
- @Nonnull String variableKey,
- @Nonnull String userId,
- @Nonnull Map attributes) {
+ @Nullable
+ public String getFeatureVariableString(@Nonnull String featureKey,
+ @Nonnull String variableKey,
+ @Nonnull String userId,
+ @Nonnull Map attributes) {
+
return getFeatureVariableValueForType(
- featureKey,
- variableKey,
- userId,
- attributes,
- LiveVariable.VariableType.STRING);
+ featureKey,
+ variableKey,
+ userId,
+ attributes,
+ FeatureVariable.STRING_TYPE);
+ }
+
+ /**
+ * Get the JSON value of the specified variable in the feature.
+ *
+ * @param featureKey The unique key of the feature.
+ * @param variableKey The unique key of the variable.
+ * @param userId The ID of the user.
+ * @return An OptimizelyJSON instance for the JSON variable value.
+ * Null if the feature or variable could not be found.
+ */
+ @Nullable
+ public OptimizelyJSON getFeatureVariableJSON(@Nonnull String featureKey,
+ @Nonnull String variableKey,
+ @Nonnull String userId) {
+ return getFeatureVariableJSON(featureKey, variableKey, userId, Collections.emptyMap());
+ }
+
+ /**
+ * Get the JSON value of the specified variable in the feature.
+ *
+ * @param featureKey The unique key of the feature.
+ * @param variableKey The unique key of the variable.
+ * @param userId The ID of the user.
+ * @param attributes The user's attributes.
+ * @return An OptimizelyJSON instance for the JSON variable value.
+ * Null if the feature or variable could not be found.
+ */
+ @Nullable
+ public OptimizelyJSON getFeatureVariableJSON(@Nonnull String featureKey,
+ @Nonnull String variableKey,
+ @Nonnull String userId,
+ @Nonnull Map attributes) {
+
+ return getFeatureVariableValueForType(
+ featureKey,
+ variableKey,
+ userId,
+ attributes,
+ FeatureVariable.JSON_TYPE);
}
@VisibleForTesting
- String getFeatureVariableValueForType(@Nonnull String featureKey,
- @Nonnull String variableKey,
- @Nonnull String userId,
- @Nonnull Map attributes,
- @Nonnull LiveVariable.VariableType variableType) {
+ T getFeatureVariableValueForType(@Nonnull String featureKey,
+ @Nonnull String variableKey,
+ @Nonnull String userId,
+ @Nonnull Map attributes,
+ @Nonnull String variableType) {
if (featureKey == null) {
logger.warn("The featureKey parameter must be nonnull.");
return null;
- }
- else if (variableKey == null) {
+ } else if (variableKey == null) {
logger.warn("The variableKey parameter must be nonnull.");
return null;
- }
- else if (userId == null) {
+ } else if (userId == null) {
logger.warn("The userId parameter must be nonnull.");
return null;
}
+
+ ProjectConfig projectConfig = getProjectConfig();
+ if (projectConfig == null) {
+ logger.error("Optimizely instance is not valid, failing getFeatureVariableValueForType call. type: {}", variableType);
+ return null;
+ }
+
FeatureFlag featureFlag = projectConfig.getFeatureKeyMapping().get(featureKey);
if (featureFlag == null) {
logger.info("No feature flag was found for key \"{}\".", featureKey);
return null;
}
- LiveVariable variable = featureFlag.getVariableKeyToLiveVariableMap().get(variableKey);
+ FeatureVariable variable = featureFlag.getVariableKeyToFeatureVariableMap().get(variableKey);
if (variable == null) {
logger.info("No feature variable was found for key \"{}\" in feature flag \"{}\".",
- variableKey, featureKey);
+ variableKey, featureKey);
return null;
} else if (!variable.getType().equals(variableType)) {
logger.info("The feature variable \"" + variableKey +
- "\" is actually of type \"" + variable.getType().toString() +
- "\" type. You tried to access it as type \"" + variableType.toString() +
- "\". Please use the appropriate feature variable accessor.");
+ "\" is actually of type \"" + variable.getType().toString() +
+ "\" type. You tried to access it as type \"" + variableType.toString() +
+ "\". Please use the appropriate feature variable accessor.");
return null;
}
String variableValue = variable.getDefaultValue();
-
- FeatureDecision featureDecision = decisionService.getVariationForFeature(featureFlag, userId, attributes);
+ Map copiedAttributes = copyAttributes(attributes);
+ FeatureDecision featureDecision = decisionService.getVariationForFeature(featureFlag, createUserContextCopy(userId, copiedAttributes), projectConfig).getResult();
+ Boolean featureEnabled = false;
if (featureDecision.variation != null) {
- LiveVariableUsageInstance liveVariableUsageInstance =
- featureDecision.variation.getVariableIdToLiveVariableUsageInstanceMap().get(variable.getId());
- if (liveVariableUsageInstance != null) {
- variableValue = liveVariableUsageInstance.getValue();
+ if (featureDecision.variation.getFeatureEnabled()) {
+ FeatureVariableUsageInstance featureVariableUsageInstance =
+ featureDecision.variation.getVariableIdToFeatureVariableUsageInstanceMap().get(variable.getId());
+ if (featureVariableUsageInstance != null) {
+ variableValue = featureVariableUsageInstance.getValue();
+ logger.info("Got variable value \"{}\" for variable \"{}\" of feature flag \"{}\".", variableValue, variableKey, featureKey);
+ } else {
+ variableValue = variable.getDefaultValue();
+ logger.info("Value is not defined for variable \"{}\". Returning default value \"{}\".", variableKey, variableValue);
+ }
} else {
- variableValue = variable.getDefaultValue();
+ logger.info("Feature \"{}\" is not enabled for user \"{}\". " +
+ "Returning the default variable value \"{}\".",
+ featureKey, userId, variableValue
+ );
}
+ featureEnabled = featureDecision.variation.getFeatureEnabled();
} else {
logger.info("User \"{}\" was not bucketed into any variation for feature flag \"{}\". " +
- "The default value \"{}\" for \"{}\" is being returned.",
- userId, featureKey, variableValue, variableKey
+ "The default value \"{}\" for \"{}\" is being returned.",
+ userId, featureKey, variableValue, variableKey
);
}
- return variableValue;
- }
-
- /**
- * Get the list of features that are enabled for the user.
- * @param userId The ID of the user.
- * @param attributes The user's attributes.
- * @return List of the feature keys that are enabled for the user if the userId is empty it will
- * return Empty List.
- */
- public List getEnabledFeatures(@Nonnull String userId, @Nonnull Map attributes) {
- List enabledFeaturesList = new ArrayList();
-
- if (!validateUserId(userId)){
- return enabledFeaturesList;
- }
-
- for (FeatureFlag featureFlag : projectConfig.getFeatureFlags()){
- String featureKey = featureFlag.getKey();
- if(isFeatureEnabled(featureKey, userId, attributes))
- enabledFeaturesList.add(featureKey);
+ Object convertedValue = convertStringToType(variableValue, variableType);
+ Object notificationValue = convertedValue;
+ if (convertedValue instanceof OptimizelyJSON) {
+ notificationValue = ((OptimizelyJSON) convertedValue).toMap();
}
- return enabledFeaturesList;
- }
+ DecisionNotification decisionNotification = DecisionNotification.newFeatureVariableDecisionNotificationBuilder()
+ .withUserId(userId)
+ .withAttributes(copiedAttributes)
+ .withFeatureKey(featureKey)
+ .withFeatureEnabled(featureEnabled)
+ .withVariableKey(variableKey)
+ .withVariableType(variableType)
+ .withVariableValue(notificationValue)
+ .withFeatureDecision(featureDecision)
+ .build();
- //======== getVariation calls ========//
- public @Nullable
- Variation getVariation(@Nonnull Experiment experiment,
- @Nonnull String userId) throws UnknownExperimentException {
+ notificationCenter.send(decisionNotification);
- return getVariation(experiment, userId, Collections.emptyMap());
+ return (T) convertedValue;
}
- public @Nullable
- Variation getVariation(@Nonnull Experiment experiment,
- @Nonnull String userId,
- @Nonnull Map attributes) throws UnknownExperimentException {
-
- Map filteredAttributes = filterAttributes(projectConfig, attributes);
+ // Helper method which takes type and variable value and convert it to object to use in Listener DecisionInfo object variable value
+ Object convertStringToType(String variableValue, String type) {
+ if (variableValue != null) {
+ switch (type) {
+ case FeatureVariable.DOUBLE_TYPE:
+ try {
+ return Double.parseDouble(variableValue);
+ } catch (NumberFormatException exception) {
+ logger.error("NumberFormatException while trying to parse \"" + variableValue +
+ "\" as Double. " + exception);
+ }
+ break;
+ case FeatureVariable.STRING_TYPE:
+ return variableValue;
+ case FeatureVariable.BOOLEAN_TYPE:
+ return Boolean.parseBoolean(variableValue);
+ case FeatureVariable.INTEGER_TYPE:
+ try {
+ return Integer.parseInt(variableValue);
+ } catch (NumberFormatException exception) {
+ try {
+ return Long.parseLong(variableValue);
+ } catch (NumberFormatException longException) {
+ logger.error("NumberFormatException while trying to parse \"{}\" as Integer. {}",
+ variableValue,
+ exception.toString());
+ }
+ }
+ break;
+ case FeatureVariable.JSON_TYPE:
+ return new OptimizelyJSON(variableValue);
+ default:
+ return variableValue;
+ }
+ }
- return decisionService.getVariation(experiment, userId, filteredAttributes);
+ return null;
}
- public @Nullable
- Variation getVariation(@Nonnull String experimentKey,
- @Nonnull String userId) throws UnknownExperimentException {
-
- return getVariation(experimentKey, userId, Collections.emptyMap());
+ /**
+ * Get the values of all variables in the feature.
+ *
+ * @param featureKey The unique key of the feature.
+ * @param userId The ID of the user.
+ * @return An OptimizelyJSON instance for all variable values.
+ * Null if the feature could not be found.
+ */
+ @Nullable
+ public OptimizelyJSON getAllFeatureVariables(@Nonnull String featureKey,
+ @Nonnull String userId) {
+ return getAllFeatureVariables(featureKey, userId, Collections.emptyMap());
}
- public @Nullable
- Variation getVariation(@Nonnull String experimentKey,
- @Nonnull String userId,
- @Nonnull Map attributes) {
- if (!validateUserId(userId)) {
+ /**
+ * Get the values of all variables in the feature.
+ *
+ * @param featureKey The unique key of the feature.
+ * @param userId The ID of the user.
+ * @param attributes The user's attributes.
+ * @return An OptimizelyJSON instance for all variable values.
+ * Null if the feature could not be found.
+ */
+ @Nullable
+ public OptimizelyJSON getAllFeatureVariables(@Nonnull String featureKey,
+ @Nonnull String userId,
+ @Nonnull Map attributes) {
+
+ if (featureKey == null) {
+ logger.warn("The featureKey parameter must be nonnull.");
+ return null;
+ } else if (userId == null) {
+ logger.warn("The userId parameter must be nonnull.");
return null;
}
- if (experimentKey == null || experimentKey.trim().isEmpty()){
- logger.error("The experimentKey parameter must be nonnull.");
+ ProjectConfig projectConfig = getProjectConfig();
+ if (projectConfig == null) {
+ logger.error("Optimizely instance is not valid, failing getAllFeatureVariableValues call. type");
return null;
}
- ProjectConfig currentConfig = getProjectConfig();
-
- Experiment experiment = currentConfig.getExperimentForKey(experimentKey, errorHandler);
- if (experiment == null) {
- // if we're unable to retrieve the associated experiment, return null
+ FeatureFlag featureFlag = projectConfig.getFeatureKeyMapping().get(featureKey);
+ if (featureFlag == null) {
+ logger.info("No feature flag was found for key \"{}\".", featureKey);
return null;
}
- Map filteredAttributes = filterAttributes(projectConfig, attributes);
+ Map copiedAttributes = copyAttributes(attributes);
+ FeatureDecision featureDecision = decisionService.getVariationForFeature(featureFlag, createUserContextCopy(userId, copiedAttributes), projectConfig, Collections.emptyList()).getResult();
+ Boolean featureEnabled = false;
+ Variation variation = featureDecision.variation;
- return decisionService.getVariation(experiment,userId,filteredAttributes);
+ if (variation != null) {
+ featureEnabled = variation.getFeatureEnabled();
+ if (featureEnabled) {
+ logger.info("Feature \"{}\" is enabled for user \"{}\".", featureKey, userId);
+ } else {
+ logger.info("Feature \"{}\" is not enabled for user \"{}\".", featureKey, userId);
+ }
+ } else {
+ logger.info("User \"{}\" was not bucketed into any variation for feature flag \"{}\". " +
+ "The default values are being returned.", userId, featureKey);
+ }
+
+ Map valuesMap = new HashMap();
+ for (FeatureVariable variable : featureFlag.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 instanceof OptimizelyJSON) {
+ convertedValue = ((OptimizelyJSON) convertedValue).toMap();
+ }
+
+ valuesMap.put(variable.getKey(), convertedValue);
+ }
+
+ DecisionNotification decisionNotification = DecisionNotification.newFeatureVariableDecisionNotificationBuilder()
+ .withUserId(userId)
+ .withAttributes(copiedAttributes)
+ .withFeatureKey(featureKey)
+ .withFeatureEnabled(featureEnabled)
+ .withVariableValues(valuesMap)
+ .withFeatureDecision(featureDecision)
+ .build();
+
+ notificationCenter.send(decisionNotification);
+
+ return new OptimizelyJSON(valuesMap);
+ }
+
+ /**
+ * Get the list of features that are enabled for the user.
+ * TODO revisit this method. Calling this as-is can dramatically increase visitor impression counts.
+ *
+ * @param userId The ID of the user.
+ * @param attributes The user's attributes.
+ * @return List of the feature keys that are enabled for the user if the userId is empty it will
+ * return Empty List.
+ */
+ public List getEnabledFeatures(@Nonnull String userId, @Nonnull Map attributes) {
+ List enabledFeaturesList = new ArrayList();
+ if (!validateUserId(userId)) {
+ return enabledFeaturesList;
+ }
+
+ ProjectConfig projectConfig = getProjectConfig();
+ if (projectConfig == null) {
+ logger.error("Optimizely instance is not valid, failing isFeatureEnabled call.");
+ return enabledFeaturesList;
+ }
+
+ Map copiedAttributes = copyAttributes(attributes);
+ for (FeatureFlag featureFlag : projectConfig.getFeatureFlags()) {
+ String featureKey = featureFlag.getKey();
+ if (isFeatureEnabled(projectConfig, featureKey, userId, copiedAttributes))
+ enabledFeaturesList.add(featureKey);
+ }
+
+ return enabledFeaturesList;
+ }
+
+ //======== getVariation calls ========//
+
+ @Nullable
+ public Variation getVariation(@Nonnull Experiment experiment,
+ @Nonnull String userId) throws UnknownExperimentException {
+
+ return getVariation(experiment, userId, Collections.emptyMap());
+ }
+
+ @Nullable
+ public Variation getVariation(@Nonnull Experiment experiment,
+ @Nonnull String userId,
+ @Nonnull Map attributes) throws UnknownExperimentException {
+ return getVariation(getProjectConfig(), experiment, userId, attributes);
+ }
+
+ @Nullable
+ private Variation getVariation(@Nonnull ProjectConfig projectConfig,
+ @Nonnull Experiment experiment,
+ @Nonnull String userId,
+ @Nonnull Map attributes) throws UnknownExperimentException {
+ Map copiedAttributes = copyAttributes(attributes);
+ Variation variation = decisionService.getVariation(experiment, createUserContextCopy(userId, copiedAttributes), projectConfig).getResult();
+ String notificationType = NotificationCenter.DecisionNotificationType.AB_TEST.toString();
+
+ if (projectConfig.getExperimentFeatureKeyMapping().get(experiment.getId()) != null) {
+ notificationType = NotificationCenter.DecisionNotificationType.FEATURE_TEST.toString();
+ }
+
+ DecisionNotification decisionNotification = DecisionNotification.newExperimentDecisionNotificationBuilder()
+ .withUserId(userId)
+ .withAttributes(copiedAttributes)
+ .withExperimentKey(experiment.getKey())
+ .withVariation(variation)
+ .withType(notificationType)
+ .build();
+
+ notificationCenter.send(decisionNotification);
+
+ return variation;
+ }
+
+ @Nullable
+ public Variation getVariation(@Nonnull String experimentKey,
+ @Nonnull String userId) throws UnknownExperimentException {
+
+ return getVariation(experimentKey, userId, Collections.emptyMap());
+ }
+
+ @Nullable
+ public Variation getVariation(@Nonnull String experimentKey,
+ @Nonnull String userId,
+ @Nonnull Map attributes) {
+ if (!validateUserId(userId)) {
+ return null;
+ }
+
+ if (experimentKey == null || experimentKey.trim().isEmpty()) {
+ logger.error("The experimentKey parameter must be nonnull.");
+ return null;
+ }
+
+ ProjectConfig projectConfig = getProjectConfig();
+ if (projectConfig == null) {
+ logger.error("Optimizely instance is not valid, failing isFeatureEnabled call.");
+ return null;
+ }
+
+ Experiment experiment = projectConfig.getExperimentForKey(experimentKey, errorHandler);
+ if (experiment == null) {
+ // if we're unable to retrieve the associated experiment, return null
+ return null;
+ }
+
+ return getVariation(projectConfig, experiment, userId, attributes);
}
/**
@@ -687,140 +1142,567 @@ Variation getVariation(@Nonnull String experimentKey,
* The forced variation value does not persist across application launches.
* If the experiment key is not in the project file, this call fails and returns false.
* If the variationKey is not in the experiment, this call fails.
- * @param experimentKey The key for the experiment.
- * @param userId The user ID to be used for bucketing.
- * @param variationKey The variation key to force the user into. If the variation key is null
- * then the forcedVariation for that experiment is removed.
*
+ * @param experimentKey The key for the experiment.
+ * @param userId The user ID to be used for bucketing.
+ * @param variationKey The variation key to force the user into. If the variation key is null
+ * then the forcedVariation for that experiment is removed.
* @return boolean A boolean value that indicates if the set completed successfully.
*/
public boolean setForcedVariation(@Nonnull String experimentKey,
@Nonnull String userId,
@Nullable String variationKey) {
+ ProjectConfig projectConfig = getProjectConfig();
+ if (projectConfig == null) {
+ logger.error("Optimizely instance is not valid, failing isFeatureEnabled call.");
+ return false;
+ }
+ // if the experiment is not a valid experiment key, don't set it.
+ Experiment experiment = projectConfig.getExperimentKeyMapping().get(experimentKey);
+ if (experiment == null) {
+ logger.error("Experiment {} does not exist in ProjectConfig for project {}", experimentKey, projectConfig.getProjectId());
+ return false;
+ }
- return projectConfig.setForcedVariation(experimentKey, userId, variationKey);
+ // TODO this is problematic if swapping out ProjectConfigs.
+ // This state should be represented elsewhere like in a ephemeral UserProfileService.
+ return decisionService.setForcedVariation(experiment, userId, variationKey);
}
/**
* Gets the forced variation for a given user and experiment.
- * This method just calls into the {@link com.optimizely.ab.config.ProjectConfig#getForcedVariation(String, String)}
+ * This method just calls into the {@link DecisionService#getForcedVariation(Experiment, String)}
* method of the same signature.
*
* @param experimentKey The key for the experiment.
- * @param userId The user ID to be used for bucketing.
- *
+ * @param userId The user ID to be used for bucketing.
* @return The variation the user was bucketed into. This value can be null if the
* forced variation fails.
*/
- public @Nullable Variation getForcedVariation(@Nonnull String experimentKey,
+ @Nullable
+ public Variation getForcedVariation(@Nonnull String experimentKey,
@Nonnull String userId) {
- return projectConfig.getForcedVariation(experimentKey, userId);
+ ProjectConfig projectConfig = getProjectConfig();
+ if (projectConfig == null) {
+ logger.error("Optimizely instance is not valid, failing getForcedVariation call.");
+ return null;
+ }
+
+ Experiment experiment = projectConfig.getExperimentKeyMapping().get(experimentKey);
+ if (experiment == null) {
+ logger.debug("No experiment \"{}\" mapped to user \"{}\" in the forced variation map ", experimentKey, userId);
+ return null;
+ }
+
+ return decisionService.getForcedVariation(experiment, userId).getResult();
}
/**
* @return the current {@link ProjectConfig} instance.
*/
- public @Nonnull ProjectConfig getProjectConfig() {
- return projectConfig;
+ @Nullable
+ public ProjectConfig getProjectConfig() {
+ return projectConfigManager.getConfig();
+ }
+
+ @Nullable
+ public UserProfileService getUserProfileService() {
+ return userProfileService;
+ }
+
+ //======== Helper methods ========//
+
+ /**
+ * Helper function to check that the provided userId is valid
+ *
+ * @param userId the userId being validated
+ * @return whether the user ID is valid
+ */
+ private boolean validateUserId(String userId) {
+ if (userId == null) {
+ logger.error("The user ID parameter must be nonnull.");
+ return false;
+ }
+
+ return true;
}
/**
- * @return a {@link ProjectConfig} instance given a json string
+ * Get {@link OptimizelyConfig} containing experiments and features map
+ *
+ * @return {@link OptimizelyConfig}
*/
- private static ProjectConfig getProjectConfig(String datafile) throws ConfigParseException {
- if (datafile == null) {
- throw new ConfigParseException("Unable to parse null datafile.");
+ public OptimizelyConfig getOptimizelyConfig() {
+ ProjectConfig projectConfig = getProjectConfig();
+ if (projectConfig == null) {
+ logger.error("Optimizely instance is not valid, failing getOptimizelyConfig call.");
+ return null;
}
- if (datafile.length() == 0) {
- throw new ConfigParseException("Unable to parse empty datafile.");
+ if (optimizelyConfigManager != null) {
+ return optimizelyConfigManager.getOptimizelyConfig();
}
+ // Generate and return a new OptimizelyConfig object as a fallback when consumer implements their own ProjectConfigManager without implementing OptimizelyConfigManager.
+ logger.debug("optimizelyConfigManager is null, generating new OptimizelyConfigObject as a fallback");
+ return new OptimizelyConfigService(projectConfig).getConfig();
+ }
- ProjectConfig projectConfig = DefaultConfigParser.getInstance().parseProjectConfig(datafile);
+ //============ decide ============//
- if (projectConfig.getVersion().equals("1")) {
- throw new ConfigParseException("This version of the Java SDK does not support version 1 datafiles. " +
- "Please use a version 2 or 3 datafile with this SDK.");
+ /**
+ * 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 attributes) {
+ if (userId == null) {
+ logger.warn("The userId parameter must be nonnull.");
+ return null;
}
- return projectConfig;
+ return new OptimizelyUserContext(this, userId, attributes);
}
- @Nullable
- public UserProfileService getUserProfileService() {
- return userProfileService;
+ public OptimizelyUserContext createUserContext(@Nonnull String userId) {
+ return new OptimizelyUserContext(this, userId);
}
- //======== Helper methods ========//
+ private OptimizelyUserContext createUserContextCopy(@Nonnull String userId, @Nonnull Map attributes) {
+ if (userId == null) {
+ logger.warn("The userId parameter must be nonnull.");
+ return null;
+ }
+ return new OptimizelyUserContext(this, userId, attributes, Collections.EMPTY_MAP, null, false);
+ }
+
+ OptimizelyDecision decide(@Nonnull OptimizelyUserContext user,
+ @Nonnull String key,
+ @Nonnull List options) {
+ ProjectConfig projectConfig = getProjectConfig();
+ if (projectConfig == null) {
+ return OptimizelyDecision.newErrorDecision(key, user, DecisionMessage.SDK_NOT_READY.reason());
+ }
+
+ List allOptions = getAllOptions(options);
+ allOptions.remove(OptimizelyDecideOption.ENABLED_FLAGS_ONLY);
+
+ return decideForKeys(user, Arrays.asList(key), allOptions, true).get(key);
+ }
+
+ private OptimizelyDecision createOptimizelyDecision(
+ OptimizelyUserContext user,
+ String flagKey,
+ FeatureDecision flagDecision,
+ DecisionReasons decisionReasons,
+ List allOptions,
+ ProjectConfig projectConfig
+ ) {
+ String userId = user.getUserId();
+ String experimentId = null;
+ String variationId = null;
+
+ Boolean flagEnabled = false;
+ if (flagDecision.variation != null) {
+ if (flagDecision.variation.getFeatureEnabled()) {
+ flagEnabled = true;
+ }
+ }
+ logger.info("Feature \"{}\" is enabled for user \"{}\"? {}", flagKey, userId, flagEnabled);
+
+ Map variableMap = new HashMap<>();
+ if (!allOptions.contains(OptimizelyDecideOption.EXCLUDE_VARIABLES)) {
+ DecisionResponse