Skip to content

Commit e41b2a0

Browse files
authored
Implement isFeatureEnabled API (#132)
* support isFeatureEnabled API. add variationIdToExperiment mapping in project config * abstract sending the impression event * change behavior of isFeatureEnabled APIs to return false when feature flag is not found * unit test to make sure isFeatureEnabled returns false when feature flag key is invalid * unit test to ensure isFeatureEnabled returns false when the user is not bucketed into any variation for the feature * unit test for isFeatureEnabled to verify that no event is sent when the user is bucketed into a variation that is not part of an experiment for a feature * add integration-ish test for isFeatureEnabled when bucketed into an experiment and make sure we send an impression event
1 parent 9acfab2 commit e41b2a0

File tree

3 files changed

+224
-9
lines changed

3 files changed

+224
-9
lines changed

core-api/src/main/java/com/optimizely/ab/Optimizely.java

Lines changed: 46 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -175,6 +175,16 @@ Variation activate(@Nonnull ProjectConfig projectConfig,
175175
return null;
176176
}
177177

178+
sendImpression(projectConfig, experiment, userId, filteredAttributes, variation);
179+
180+
return variation;
181+
}
182+
183+
private void sendImpression(@Nonnull ProjectConfig projectConfig,
184+
@Nonnull Experiment experiment,
185+
@Nonnull String userId,
186+
@Nonnull Map<String, String> filteredAttributes,
187+
@Nonnull Variation variation) {
178188
if (experiment.isRunning()) {
179189
LogEvent impressionEvent = eventBuilder.createImpressionEvent(
180190
projectConfig,
@@ -196,8 +206,6 @@ Variation activate(@Nonnull ProjectConfig projectConfig,
196206
} else {
197207
logger.info("Experiment has \"Launched\" status so not dispatching event during activation.");
198208
}
199-
200-
return variation;
201209
}
202210

203211
//======== track calls ========//
@@ -293,10 +301,9 @@ public void track(@Nonnull String eventName,
293301
* @param userId The ID of the user.
294302
* @return True if the feature is enabled.
295303
* False if the feature is disabled.
296-
* Will always return True if toggling the feature is disabled.
297-
* Will return Null if the feature is not found.
304+
* False if the feature is not found.
298305
*/
299-
public @Nullable Boolean isFeatureEnabled(@Nonnull String featureKey,
306+
public @Nonnull Boolean isFeatureEnabled(@Nonnull String featureKey,
300307
@Nonnull String userId) {
301308
return isFeatureEnabled(featureKey, userId, Collections.<String, String>emptyMap());
302309
}
@@ -310,13 +317,43 @@ public void track(@Nonnull String eventName,
310317
* @param attributes The user's attributes.
311318
* @return True if the feature is enabled.
312319
* False if the feature is disabled.
313-
* Will always return True if toggling the feature is disabled.
314-
* Will return Null if the feature is not found.
320+
* False if the feature is not found.
315321
*/
316-
public @Nullable Boolean isFeatureEnabled(@Nonnull String featureKey,
322+
public @Nonnull Boolean isFeatureEnabled(@Nonnull String featureKey,
317323
@Nonnull String userId,
318324
@Nonnull Map<String, String> attributes) {
319-
return getFeatureVariableBoolean(featureKey, "", userId, attributes);
325+
FeatureFlag featureFlag = projectConfig.getFeatureKeyMapping().get(featureKey);
326+
if (featureFlag == null) {
327+
logger.info("No feature flag was found for key \"" + featureKey + "\".");
328+
return false;
329+
}
330+
331+
Map<String, String> filteredAttributes = filterAttributes(projectConfig, attributes);
332+
333+
Variation variation = decisionService.getVariationForFeature(featureFlag, userId, filteredAttributes);
334+
335+
if (variation != null) {
336+
Experiment experiment = projectConfig.getExperimentForVariationId(variation.getId());
337+
if (experiment != null) {
338+
// the user is in an experiment for the feature
339+
sendImpression(
340+
projectConfig,
341+
experiment,
342+
userId,
343+
filteredAttributes,
344+
variation);
345+
}
346+
else {
347+
logger.info("The user \"" + userId +
348+
"\" is not being experimented on in feature \"" + featureKey + "\".");
349+
}
350+
logger.info("Feature \"" + featureKey + "\" is enabled for user \"" + userId + "\".");
351+
return true;
352+
}
353+
else {
354+
logger.info("Feature \"" + featureKey + "\" is not enabled for user \"" + userId + "\".");
355+
return false;
356+
}
320357
}
321358

322359
/**

core-api/src/main/java/com/optimizely/ab/config/ProjectConfig.java

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,9 +20,11 @@
2020
import com.optimizely.ab.config.audience.Audience;
2121
import com.optimizely.ab.config.audience.Condition;
2222

23+
import javax.annotation.Nullable;
2324
import javax.annotation.concurrent.Immutable;
2425
import java.util.ArrayList;
2526
import java.util.Collections;
27+
import java.util.HashMap;
2628
import java.util.List;
2729
import java.util.Map;
2830

@@ -80,6 +82,7 @@ public String toString() {
8082
// other mappings
8183
private final Map<String, List<Experiment>> liveVariableIdToExperimentsMapping;
8284
private final Map<String, Map<String, LiveVariableUsageInstance>> variationToLiveVariableUsageInstanceMapping;
85+
private final Map<String, Experiment> variationIdToExperimentMapping;
8386

8487
// v2 constructor
8588
public ProjectConfig(String accountId, String projectId, String version, String revision, List<Group> groups,
@@ -146,6 +149,14 @@ public ProjectConfig(String accountId,
146149
allExperiments.addAll(aggregateGroupExperiments(groups));
147150
this.experiments = Collections.unmodifiableList(allExperiments);
148151

152+
Map<String, Experiment> variationIdToExperimentMap = new HashMap<String, Experiment>();
153+
for (Experiment experiment : this.experiments) {
154+
for (Variation variation: experiment.getVariations()) {
155+
variationIdToExperimentMap.put(variation.getId(), experiment);
156+
}
157+
}
158+
this.variationIdToExperimentMapping = Collections.unmodifiableMap(variationIdToExperimentMap);
159+
149160
// generate the name mappers
150161
this.attributeKeyMapping = ProjectConfigUtils.generateNameMapping(attributes);
151162
this.eventNameMapping = ProjectConfigUtils.generateNameMapping(this.events);
@@ -172,6 +183,10 @@ public ProjectConfig(String accountId,
172183
}
173184
}
174185

186+
public @Nullable Experiment getExperimentForVariationId(String variationId) {
187+
return this.variationIdToExperimentMapping.get(variationId);
188+
}
189+
175190
private List<Experiment> aggregateGroupExperiments(List<Group> groups) {
176191
List<Experiment> groupExperiments = new ArrayList<Experiment>();
177192
for (Group group : groups) {

core-api/src/test/java/com/optimizely/ab/OptimizelyTest.java

Lines changed: 163 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
import com.optimizely.ab.config.Attribute;
2424
import com.optimizely.ab.config.EventType;
2525
import com.optimizely.ab.config.Experiment;
26+
import com.optimizely.ab.config.FeatureFlag;
2627
import com.optimizely.ab.config.LiveVariable;
2728
import com.optimizely.ab.config.LiveVariableUsageInstance;
2829
import com.optimizely.ab.config.ProjectConfig;
@@ -36,6 +37,7 @@
3637
import com.optimizely.ab.event.LogEvent;
3738
import com.optimizely.ab.event.internal.EventBuilder;
3839
import com.optimizely.ab.event.internal.EventBuilderV2;
40+
import com.optimizely.ab.event.internal.payload.Feature;
3941
import com.optimizely.ab.internal.LogbackVerifier;
4042
import com.optimizely.ab.notification.NotificationListener;
4143
import edu.umd.cs.findbugs.annotations.SuppressFBWarnings;
@@ -97,6 +99,7 @@
9799
import static org.hamcrest.Matchers.hasEntry;
98100
import static org.hamcrest.Matchers.hasKey;
99101
import static org.junit.Assert.assertEquals;
102+
import static org.junit.Assert.assertFalse;
100103
import static org.junit.Assert.assertNotNull;
101104
import static org.junit.Assert.assertNull;
102105
import static org.junit.Assume.assumeTrue;
@@ -2371,6 +2374,166 @@ public void getFeatureVariableValueReturnsVariationValueWhenUserGetsBucketedToVa
23712374
assertEquals(expectedValue, value);
23722375
}
23732376

2377+
/**
2378+
* Verify {@link Optimizely#isFeatureEnabled(String, String)} calls into
2379+
* {@link Optimizely#isFeatureEnabled(String, String, Map)} and they both
2380+
* return False
2381+
* when the APIs are called with an feature key that is not in the datafile.
2382+
* @throws Exception
2383+
*/
2384+
@Test
2385+
public void isFeatureEnabledReturnsFalseWhenFeatureFlagKeyIsInvalid() throws Exception {
2386+
2387+
String invalidFeatureKey = "nonexistent feature key";
2388+
2389+
Optimizely spyOptimizely = spy(Optimizely.builder(validDatafile, mockEventHandler)
2390+
.withConfig(validProjectConfig)
2391+
.withDecisionService(mockDecisionService)
2392+
.build());
2393+
2394+
assertFalse(spyOptimizely.isFeatureEnabled(invalidFeatureKey, genericUserId));
2395+
2396+
logbackVerifier.expectMessage(
2397+
Level.INFO,
2398+
"No feature flag was found for key \"" + invalidFeatureKey + "\"."
2399+
);
2400+
verify(spyOptimizely, times(1)).isFeatureEnabled(
2401+
eq(invalidFeatureKey),
2402+
eq(genericUserId),
2403+
eq(Collections.<String, String>emptyMap())
2404+
);
2405+
verify(mockDecisionService, never()).getVariation(
2406+
any(Experiment.class),
2407+
anyString(),
2408+
anyMapOf(String.class, String.class));
2409+
verify(mockEventHandler, never()).dispatchEvent(any(LogEvent.class));
2410+
}
2411+
2412+
/**
2413+
* Verify {@link Optimizely#isFeatureEnabled(String, String)} calls into
2414+
* {@link Optimizely#isFeatureEnabled(String, String, Map)} and they both
2415+
* return False
2416+
* when the user is not bucketed into any variation for the feature.
2417+
* @throws Exception
2418+
*/
2419+
@Test
2420+
public void isFeatureEnabledReturnsFalseWhenUserIsNotBucketedIntoAnyVariation() throws Exception {
2421+
assumeTrue(datafileVersion >= Integer.parseInt(ProjectConfig.Version.V4.toString()));
2422+
2423+
String validFeatureKey = FEATURE_MULTI_VARIATE_FEATURE_KEY;
2424+
2425+
Optimizely spyOptimizely = spy(Optimizely.builder(validDatafile, mockEventHandler)
2426+
.withConfig(validProjectConfig)
2427+
.withDecisionService(mockDecisionService)
2428+
.build());
2429+
2430+
doReturn(null).when(mockDecisionService).getVariationForFeature(
2431+
any(FeatureFlag.class),
2432+
anyString(),
2433+
anyMapOf(String.class, String.class)
2434+
);
2435+
2436+
assertFalse(spyOptimizely.isFeatureEnabled(validFeatureKey, genericUserId));
2437+
2438+
logbackVerifier.expectMessage(
2439+
Level.INFO,
2440+
"Feature \"" + validFeatureKey +
2441+
"\" is not enabled for user \"" + genericUserId + "\"."
2442+
);
2443+
verify(spyOptimizely).isFeatureEnabled(
2444+
eq(validFeatureKey),
2445+
eq(genericUserId),
2446+
eq(Collections.<String, String>emptyMap())
2447+
);
2448+
verify(mockDecisionService).getVariationForFeature(
2449+
eq(FEATURE_FLAG_MULTI_VARIATE_FEATURE),
2450+
eq(genericUserId),
2451+
eq(Collections.<String, String>emptyMap())
2452+
);
2453+
verify(mockEventHandler, never()).dispatchEvent(any(LogEvent.class));
2454+
}
2455+
2456+
/**
2457+
* Verify {@link Optimizely#isFeatureEnabled(String, String)} calls into
2458+
* {@link Optimizely#isFeatureEnabled(String, String, Map)} and they both
2459+
* return True
2460+
* when the user is bucketed into a variation for the feature.
2461+
* An impression event should not be dispatched since the user was not bucketed into an Experiment.
2462+
* @throws Exception
2463+
*/
2464+
@Test
2465+
public void isFeatureEnabledReturnsTrueButDoesNotSendWhenUserIsBucketedIntoVariationWithoutExperiment() throws Exception {
2466+
assumeTrue(datafileVersion >= Integer.parseInt(ProjectConfig.Version.V4.toString()));
2467+
2468+
String validFeatureKey = FEATURE_MULTI_VARIATE_FEATURE_KEY;
2469+
2470+
Optimizely spyOptimizely = spy(Optimizely.builder(validDatafile, mockEventHandler)
2471+
.withConfig(validProjectConfig)
2472+
.withDecisionService(mockDecisionService)
2473+
.build());
2474+
2475+
doReturn(new Variation("variationId", "variationKey")).when(mockDecisionService).getVariationForFeature(
2476+
eq(FEATURE_FLAG_MULTI_VARIATE_FEATURE),
2477+
eq(genericUserId),
2478+
eq(Collections.<String, String>emptyMap())
2479+
);
2480+
2481+
assertTrue(spyOptimizely.isFeatureEnabled(validFeatureKey, genericUserId));
2482+
2483+
logbackVerifier.expectMessage(
2484+
Level.INFO,
2485+
"The user \"" + genericUserId +
2486+
"\" is not being experimented on in feature \"" + validFeatureKey + "\"."
2487+
);
2488+
logbackVerifier.expectMessage(
2489+
Level.INFO,
2490+
"Feature \"" + validFeatureKey +
2491+
"\" is enabled for user \"" + genericUserId + "\"."
2492+
);
2493+
verify(spyOptimizely).isFeatureEnabled(
2494+
eq(validFeatureKey),
2495+
eq(genericUserId),
2496+
eq(Collections.<String, String>emptyMap())
2497+
);
2498+
verify(mockDecisionService).getVariationForFeature(
2499+
eq(FEATURE_FLAG_MULTI_VARIATE_FEATURE),
2500+
eq(genericUserId),
2501+
eq(Collections.<String, String>emptyMap())
2502+
);
2503+
verify(mockEventHandler, never()).dispatchEvent(any(LogEvent.class));
2504+
}
2505+
2506+
/** Integration Test
2507+
* Verify {@link Optimizely#isFeatureEnabled(String, String, Map)}
2508+
* returns True
2509+
* when the user is bucketed into a variation for the feature.
2510+
* The user is also bucketed into an experiment, so we verify that an event is dispatched.
2511+
* @throws Exception
2512+
*/
2513+
@Test
2514+
public void isFeatureEnabledReturnsTrueAndDispatchesEventWhenUserIsBucketedIntoAnExperiment() throws Exception {
2515+
assumeTrue(datafileVersion >= Integer.parseInt(ProjectConfig.Version.V4.toString()));
2516+
2517+
String validFeatureKey = FEATURE_MULTI_VARIATE_FEATURE_KEY;
2518+
2519+
Optimizely optimizely = Optimizely.builder(validDatafile, mockEventHandler)
2520+
.withConfig(validProjectConfig)
2521+
.build();
2522+
2523+
assertTrue(optimizely.isFeatureEnabled(
2524+
validFeatureKey,
2525+
genericUserId,
2526+
Collections.singletonMap(ATTRIBUTE_HOUSE_KEY, AUDIENCE_GRYFFINDOR_VALUE)
2527+
));
2528+
2529+
logbackVerifier.expectMessage(
2530+
Level.INFO,
2531+
"Feature \"" + validFeatureKey +
2532+
"\" is enabled for user \"" + genericUserId + "\"."
2533+
);
2534+
verify(mockEventHandler, times(1)).dispatchEvent(any(LogEvent.class));
2535+
}
2536+
23742537
//======== Helper methods ========//
23752538

23762539
private Experiment createUnknownExperiment() {

0 commit comments

Comments
 (0)