From 3d95d43fd7769d998038ea5079d0b637c57c96ac Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Thu, 28 Jun 2018 15:01:44 -0700 Subject: [PATCH 01/43] don't give up permanently after a 400 error --- src/main/java/com/launchdarkly/client/Util.java | 1 + .../com/launchdarkly/client/DefaultEventProcessorTest.java | 5 +++++ .../java/com/launchdarkly/client/PollingProcessorTest.java | 5 +++++ .../java/com/launchdarkly/client/StreamProcessorTest.java | 5 +++++ 4 files changed, 16 insertions(+) diff --git a/src/main/java/com/launchdarkly/client/Util.java b/src/main/java/com/launchdarkly/client/Util.java index bb4bccd4b..78ea7b759 100644 --- a/src/main/java/com/launchdarkly/client/Util.java +++ b/src/main/java/com/launchdarkly/client/Util.java @@ -41,6 +41,7 @@ static Request.Builder getRequestBuilder(String sdkKey) { static boolean isHttpErrorRecoverable(int statusCode) { if (statusCode >= 400 && statusCode < 500) { switch (statusCode) { + case 400: // bad request case 408: // request timeout case 429: // too many requests return true; diff --git a/src/test/java/com/launchdarkly/client/DefaultEventProcessorTest.java b/src/test/java/com/launchdarkly/client/DefaultEventProcessorTest.java index d4b250e0a..36bc2097e 100644 --- a/src/test/java/com/launchdarkly/client/DefaultEventProcessorTest.java +++ b/src/test/java/com/launchdarkly/client/DefaultEventProcessorTest.java @@ -391,6 +391,11 @@ public void sdkKeyIsSent() throws Exception { assertThat(req.getHeader("Authorization"), equalTo(SDK_KEY)); } + @Test + public void http400ErrorIsRecoverable() throws Exception { + testRecoverableHttpError(400); + } + @Test public void http401ErrorIsUnrecoverable() throws Exception { testUnrecoverableHttpError(401); diff --git a/src/test/java/com/launchdarkly/client/PollingProcessorTest.java b/src/test/java/com/launchdarkly/client/PollingProcessorTest.java index 10d1a3722..dc18f1421 100644 --- a/src/test/java/com/launchdarkly/client/PollingProcessorTest.java +++ b/src/test/java/com/launchdarkly/client/PollingProcessorTest.java @@ -53,6 +53,11 @@ public void testConnectionProblem() throws Exception { pollingProcessor.close(); verifyAll(); } + + @Test + public void http400ErrorIsRecoverable() throws Exception { + testRecoverableHttpError(400); + } @Test public void http401ErrorIsUnrecoverable() throws Exception { diff --git a/src/test/java/com/launchdarkly/client/StreamProcessorTest.java b/src/test/java/com/launchdarkly/client/StreamProcessorTest.java index 8b342359e..ee29937c9 100644 --- a/src/test/java/com/launchdarkly/client/StreamProcessorTest.java +++ b/src/test/java/com/launchdarkly/client/StreamProcessorTest.java @@ -282,6 +282,11 @@ public void streamWillReconnectAfterGeneralIOException() throws Exception { ConnectionErrorHandler.Action action = errorHandler.onConnectionError(new IOException()); assertEquals(ConnectionErrorHandler.Action.PROCEED, action); } + + @Test + public void http400ErrorIsRecoverable() throws Exception { + testRecoverableHttpError(400); + } @Test public void http401ErrorIsUnrecoverable() throws Exception { From 398202aa9b1f36af2656931fdd5e0d5501645336 Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Fri, 29 Jun 2018 13:35:31 -0700 Subject: [PATCH 02/43] implement evaluation with explanation --- .../client/EvaluationDetails.java | 139 ++++++++++++++++++ .../client/EvaluationException.java | 1 + .../com/launchdarkly/client/EventFactory.java | 8 +- .../com/launchdarkly/client/FeatureFlag.java | 131 +++++++++-------- .../com/launchdarkly/client/LDClient.java | 87 ++++++++--- .../client/LDClientInterface.java | 57 ++++++- .../java/com/launchdarkly/client/Rule.java | 8 +- .../launchdarkly/client/VariationType.java | 55 +++---- .../client/DefaultEventProcessorTest.java | 26 ++-- .../client/EventSummarizerTest.java | 8 +- .../launchdarkly/client/FeatureFlagTest.java | 53 +++---- .../client/LDClientEvaluationTest.java | 27 ++-- .../com/launchdarkly/client/TestUtil.java | 22 ++- 13 files changed, 445 insertions(+), 177 deletions(-) create mode 100644 src/main/java/com/launchdarkly/client/EvaluationDetails.java diff --git a/src/main/java/com/launchdarkly/client/EvaluationDetails.java b/src/main/java/com/launchdarkly/client/EvaluationDetails.java new file mode 100644 index 000000000..99a172942 --- /dev/null +++ b/src/main/java/com/launchdarkly/client/EvaluationDetails.java @@ -0,0 +1,139 @@ +package com.launchdarkly.client; + +import com.google.common.base.Objects; +import com.google.gson.JsonElement; + +/** + * An object returned by the "variation detail" methods such as {@link LDClientInterface#boolVariationDetails(String, LDUser, boolean), + * combining the result of a flag evaluation with an explanation of how it was calculated. + * @since 4.3.0 + */ +public class EvaluationDetails { + + /** + * Enum values used in {@link EvaluationDetails} to explain why a flag evaluated to a particular value. + * @since 4.3.0 + */ + public static enum Reason { + /** + * Indicates that the flag was off and therefore returned its configured off value. + */ + OFF, + /** + * Indicates that the user key was specifically targeted for this flag. + */ + TARGET_MATCH, + /** + * Indicates that the user matched one of the flag's rules. + */ + RULE_MATCH, + /** + * Indicates that the flag was treated as if it was off because it had a prerequisite flag that + * either was off or did not return the expected variation., + */ + PREREQUISITE_FAILED, + /** + * Indicates that the flag was on but the user did not match any targets or rules. + */ + FALLTHROUGH, + /** + * Indicates that the default value (passed as a parameter to one of the {@code variation} methods0 + * was returned. This normally indicates an error condition. + */ + DEFAULT; + } + + private final Reason reason; + private final Integer variationIndex; + private final T value; + private final Integer matchIndex; + private final String matchId; + + public EvaluationDetails(Reason reason, Integer variationIndex, T value, Integer matchIndex, String matchId) { + super(); + this.reason = reason; + this.variationIndex = variationIndex; + this.value = value; + this.matchIndex = matchIndex; + this.matchId = matchId; + } + + static EvaluationDetails off(Integer offVariation, JsonElement value) { + return new EvaluationDetails(Reason.OFF, offVariation, value, null, null); + } + + static EvaluationDetails fallthrough(int variationIndex, JsonElement value) { + return new EvaluationDetails(Reason.FALLTHROUGH, variationIndex, value, null, null); + } + + static EvaluationDetails defaultValue(T value) { + return new EvaluationDetails(Reason.DEFAULT, null, value, null, null); + } + + /** + * An enum describing the main factor that influenced the flag evaluation value. + * @return a {@link Reason} + */ + public Reason getReason() { + return reason; + } + + /** + * The index of the returned value within the flag's list of variations, e.g. 0 for the first variation - + * or {@code null} if the default value was returned. + * @return the variation index or null + */ + public Integer getVariationIndex() { + return variationIndex; + } + + /** + * The result of the flag evaluation. This will be either one of the flag's variations or the default + * value that was passed to the {@code variation} method. + * @return the flag value + */ + public T getValue() { + return value; + } + + /** + * A number whose meaning depends on the {@link Reason}. For {@link Reason#TARGET_MATCH}, it is the + * zero-based index of the matched target. For {@link Reason#RULE_MATCH}, it is the zero-based index + * of the matched rule. For all other reasons, it is {@code null}. + * @return the index of the matched item or null + */ + public Integer getMatchIndex() { + return matchIndex; + } + + /** + * A string whose meaning depends on the {@link Reason}. For {@link Reason#RULE_MATCH}, it is the + * unique identifier of the matched rule, if any. For {@link Reason#PREREQUISITE_FAILED}, it is the + * flag key of the prerequisite flag that stopped evaluation. For all other reasons, it is {@code null}. + * @return a rule ID, flag key, or null + */ + public String getMatchId() { + return matchId; + } + + @Override + public boolean equals(Object other) { + if (other instanceof EvaluationDetails) { + @SuppressWarnings("unchecked") + EvaluationDetails o = (EvaluationDetails)other; + return reason == o.reason && variationIndex == o.variationIndex && Objects.equal(value, o.value) + && matchIndex == o.matchIndex && Objects.equal(matchId, o.matchId); + } + return false; + } + + @Override + public int hashCode() { + return Objects.hashCode(reason, variationIndex, value, matchIndex, matchId); + } + + @Override + public String toString() { + return "{" + reason + ", " + variationIndex + ", " + value + ", " + matchIndex + ", " + matchId + "}"; + } +} diff --git a/src/main/java/com/launchdarkly/client/EvaluationException.java b/src/main/java/com/launchdarkly/client/EvaluationException.java index f7adf563f..174a2417e 100644 --- a/src/main/java/com/launchdarkly/client/EvaluationException.java +++ b/src/main/java/com/launchdarkly/client/EvaluationException.java @@ -3,6 +3,7 @@ /** * An error indicating an abnormal result from evaluating a feature */ +@SuppressWarnings("serial") class EvaluationException extends Exception { public EvaluationException(String message) { super(message); diff --git a/src/main/java/com/launchdarkly/client/EventFactory.java b/src/main/java/com/launchdarkly/client/EventFactory.java index fce94a49c..345619b0b 100644 --- a/src/main/java/com/launchdarkly/client/EventFactory.java +++ b/src/main/java/com/launchdarkly/client/EventFactory.java @@ -7,9 +7,9 @@ abstract class EventFactory { protected abstract long getTimestamp(); - public Event.FeatureRequest newFeatureRequestEvent(FeatureFlag flag, LDUser user, FeatureFlag.VariationAndValue result, JsonElement defaultVal) { + public Event.FeatureRequest newFeatureRequestEvent(FeatureFlag flag, LDUser user, EvaluationDetails result, JsonElement defaultVal) { return new Event.FeatureRequest(getTimestamp(), flag.getKey(), user, flag.getVersion(), - result == null ? null : result.getVariation(), result == null ? null : result.getValue(), + result == null ? null : result.getVariationIndex(), result == null ? null : result.getValue(), defaultVal, null, flag.isTrackEvents(), flag.getDebugEventsUntilDate(), false); } @@ -22,10 +22,10 @@ public Event.FeatureRequest newUnknownFeatureRequestEvent(String key, LDUser use return new Event.FeatureRequest(getTimestamp(), key, user, null, null, defaultValue, defaultValue, null, false, null, false); } - public Event.FeatureRequest newPrerequisiteFeatureRequestEvent(FeatureFlag prereqFlag, LDUser user, FeatureFlag.VariationAndValue result, + public Event.FeatureRequest newPrerequisiteFeatureRequestEvent(FeatureFlag prereqFlag, LDUser user, EvaluationDetails result, FeatureFlag prereqOf) { return new Event.FeatureRequest(getTimestamp(), prereqFlag.getKey(), user, prereqFlag.getVersion(), - result == null ? null : result.getVariation(), result == null ? null : result.getValue(), + result == null ? null : result.getVariationIndex(), result == null ? null : result.getValue(), null, prereqOf.getKey(), prereqFlag.isTrackEvents(), prereqFlag.getDebugEventsUntilDate(), false); } diff --git a/src/main/java/com/launchdarkly/client/FeatureFlag.java b/src/main/java/com/launchdarkly/client/FeatureFlag.java index faff33c6b..0ab75e23c 100644 --- a/src/main/java/com/launchdarkly/client/FeatureFlag.java +++ b/src/main/java/com/launchdarkly/client/FeatureFlag.java @@ -2,6 +2,8 @@ import com.google.gson.JsonElement; import com.google.gson.reflect.TypeToken; +import com.launchdarkly.client.EvaluationDetails.Reason; + import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -70,65 +72,86 @@ EvalResult evaluate(LDUser user, FeatureStore featureStore, EventFactory eventFa } if (isOn()) { - VariationAndValue result = evaluate(user, featureStore, prereqEvents, eventFactory); - if (result != null) { - return new EvalResult(result, prereqEvents); - } + EvaluationDetails details = evaluate(user, featureStore, prereqEvents, eventFactory); + return new EvalResult(details, prereqEvents); } - return new EvalResult(new VariationAndValue(offVariation, getOffVariationValue()), prereqEvents); + + EvaluationDetails details = EvaluationDetails.off(offVariation, getOffVariationValue()); + return new EvalResult(details, prereqEvents); } // Returning either a JsonElement or null indicating prereq failure/error. - private VariationAndValue evaluate(LDUser user, FeatureStore featureStore, List events, + private EvaluationDetails evaluate(LDUser user, FeatureStore featureStore, List events, EventFactory eventFactory) throws EvaluationException { - boolean prereqOk = true; - if (prerequisites != null) { - for (Prerequisite prereq : prerequisites) { - FeatureFlag prereqFeatureFlag = featureStore.get(FEATURES, prereq.getKey()); - VariationAndValue prereqEvalResult = null; - if (prereqFeatureFlag == null) { - logger.error("Could not retrieve prerequisite flag: " + prereq.getKey() + " when evaluating: " + key); - return null; - } else if (prereqFeatureFlag.isOn()) { - prereqEvalResult = prereqFeatureFlag.evaluate(user, featureStore, events, eventFactory); - if (prereqEvalResult == null || prereqEvalResult.getVariation() != prereq.getVariation()) { - prereqOk = false; - } - } else { - prereqOk = false; - } - //We don't short circuit and also send events for each prereq. - events.add(eventFactory.newPrerequisiteFeatureRequestEvent(prereqFeatureFlag, user, prereqEvalResult, this)); - } + EvaluationDetails prereqErrorResult = checkPrerequisites(user, featureStore, events, eventFactory); + if (prereqErrorResult != null) { + return prereqErrorResult; } - if (prereqOk) { - Integer index = evaluateIndex(user, featureStore); - return new VariationAndValue(index, getVariation(index)); - } - return null; - } - - private Integer evaluateIndex(LDUser user, FeatureStore store) { + // Check to see if targets match if (targets != null) { - for (Target target : targets) { + for (int i = 0; i < targets.size(); i++) { + Target target = targets.get(i); for (String v : target.getValues()) { if (v.equals(user.getKey().getAsString())) { - return target.getVariation(); + return new EvaluationDetails(Reason.TARGET_MATCH, + target.getVariation(), getVariation(target.getVariation()), i, null); } } } } // Now walk through the rules and see if any match if (rules != null) { - for (Rule rule : rules) { - if (rule.matchesUser(store, user)) { - return rule.variationIndexForUser(user, key, salt); + for (int i = 0; i < rules.size(); i++) { + Rule rule = rules.get(i); + if (rule.matchesUser(featureStore, user)) { + int index = rule.variationIndexForUser(user, key, salt); + return new EvaluationDetails(Reason.RULE_MATCH, + index, getVariation(index), i, rule.getId()); } } } // Walk through the fallthrough and see if it matches - return fallthrough.variationIndexForUser(user, key, salt); + int index = fallthrough.variationIndexForUser(user, key, salt); + return EvaluationDetails.fallthrough(index, getVariation(index)); + } + + // Checks prerequisites if any; returns null if successful, or an EvaluationDetails if we have to + // short-circuit due to a prerequisite failure. + private EvaluationDetails checkPrerequisites(LDUser user, FeatureStore featureStore, List events, + EventFactory eventFactory) throws EvaluationException { + if (prerequisites == null) { + return null; + } + EvaluationDetails ret = null; + boolean prereqOk = true; + for (int i = 0; i < prerequisites.size(); i++) { + Prerequisite prereq = prerequisites.get(i); + FeatureFlag prereqFeatureFlag = featureStore.get(FEATURES, prereq.getKey()); + EvaluationDetails prereqEvalResult = null; + if (prereqFeatureFlag == null) { + logger.error("Could not retrieve prerequisite flag: " + prereq.getKey() + " when evaluating: " + key); + prereqOk = false; + } else if (prereqFeatureFlag.isOn()) { + prereqEvalResult = prereqFeatureFlag.evaluate(user, featureStore, events, eventFactory); + if (prereqEvalResult == null || prereqEvalResult.getVariationIndex() != prereq.getVariation()) { + prereqOk = false; + } + } else { + prereqOk = false; + } + // We continue to evaluate all prerequisites even if one failed, but set the result to the first failure if any. + if (prereqFeatureFlag != null) { + events.add(eventFactory.newPrerequisiteFeatureRequestEvent(prereqFeatureFlag, user, prereqEvalResult, this)); + } + if (!prereqOk) { + if (ret == null) { + ret = new EvaluationDetails(Reason.PREREQUISITE_FAILED, + offVariation, getOffVariationValue(), i, prereq.getKey()); + } + } + } + return ret; } JsonElement getOffVariationValue() throws EvaluationException { @@ -207,37 +230,21 @@ List getVariations() { return variations; } - Integer getOffVariation() { return offVariation; } - - static class VariationAndValue { - private final Integer variation; - private final JsonElement value; - - VariationAndValue(Integer variation, JsonElement value) { - this.variation = variation; - this.value = value; - } - - Integer getVariation() { - return variation; - } - - JsonElement getValue() { - return value; - } + Integer getOffVariation() { + return offVariation; } static class EvalResult { - private final VariationAndValue result; + private final EvaluationDetails details; private final List prerequisiteEvents; - private EvalResult(VariationAndValue result, List prerequisiteEvents) { - this.result = result; + private EvalResult(EvaluationDetails details, List prerequisiteEvents) { + this.details = details; this.prerequisiteEvents = prerequisiteEvents; } - VariationAndValue getResult() { - return result; + EvaluationDetails getDetails() { + return details; } List getPrerequisiteEvents() { diff --git a/src/main/java/com/launchdarkly/client/LDClient.java b/src/main/java/com/launchdarkly/client/LDClient.java index 39d193272..3bf8eed5f 100644 --- a/src/main/java/com/launchdarkly/client/LDClient.java +++ b/src/main/java/com/launchdarkly/client/LDClient.java @@ -2,6 +2,7 @@ import com.google.gson.JsonElement; import com.google.gson.JsonPrimitive; +import com.launchdarkly.client.EvaluationDetails.Reason; import org.apache.commons.codec.binary.Hex; import org.slf4j.Logger; @@ -165,7 +166,7 @@ public Map allFlags(LDUser user) { for (Map.Entry entry : flags.entrySet()) { try { - JsonElement evalResult = entry.getValue().evaluate(user, featureStore, eventFactory).getResult().getValue(); + JsonElement evalResult = entry.getValue().evaluate(user, featureStore, eventFactory).getDetails().getValue(); result.put(entry.getKey(), evalResult); } catch (EvaluationException e) { @@ -177,34 +178,54 @@ public Map allFlags(LDUser user) { @Override public boolean boolVariation(String featureKey, LDUser user, boolean defaultValue) { - JsonElement value = evaluate(featureKey, user, new JsonPrimitive(defaultValue), VariationType.Boolean); - return value.getAsJsonPrimitive().getAsBoolean(); + return evaluate(featureKey, user, defaultValue, new JsonPrimitive(defaultValue), VariationType.Boolean); } @Override public Integer intVariation(String featureKey, LDUser user, int defaultValue) { - JsonElement value = evaluate(featureKey, user, new JsonPrimitive(defaultValue), VariationType.Integer); - return value.getAsJsonPrimitive().getAsInt(); + return evaluate(featureKey, user, defaultValue, new JsonPrimitive(defaultValue), VariationType.Integer); } @Override public Double doubleVariation(String featureKey, LDUser user, Double defaultValue) { - JsonElement value = evaluate(featureKey, user, new JsonPrimitive(defaultValue), VariationType.Double); - return value.getAsJsonPrimitive().getAsDouble(); + return evaluate(featureKey, user, defaultValue, new JsonPrimitive(defaultValue), VariationType.Double); } @Override public String stringVariation(String featureKey, LDUser user, String defaultValue) { - JsonElement value = evaluate(featureKey, user, new JsonPrimitive(defaultValue), VariationType.String); - return value.getAsJsonPrimitive().getAsString(); + return evaluate(featureKey, user, defaultValue, new JsonPrimitive(defaultValue), VariationType.String); } @Override public JsonElement jsonVariation(String featureKey, LDUser user, JsonElement defaultValue) { - JsonElement value = evaluate(featureKey, user, defaultValue, VariationType.Json); - return value; + return evaluate(featureKey, user, defaultValue, defaultValue, VariationType.Json); } + @Override + public EvaluationDetails boolVariationDetails(String featureKey, LDUser user, boolean defaultValue) { + return evaluateDetail(featureKey, user, defaultValue, new JsonPrimitive(defaultValue), VariationType.Boolean); + } + + @Override + public EvaluationDetails intVariationDetails(String featureKey, LDUser user, int defaultValue) { + return evaluateDetail(featureKey, user, defaultValue, new JsonPrimitive(defaultValue), VariationType.Integer); + } + + @Override + public EvaluationDetails doubleVariationDetails(String featureKey, LDUser user, double defaultValue) { + return evaluateDetail(featureKey, user, defaultValue, new JsonPrimitive(defaultValue), VariationType.Double); + } + + @Override + public EvaluationDetails stringVariationDetails(String featureKey, LDUser user, String defaultValue) { + return evaluateDetail(featureKey, user, defaultValue, new JsonPrimitive(defaultValue), VariationType.String); + } + + @Override + public EvaluationDetails jsonVariationDetails(String featureKey, LDUser user, JsonElement defaultValue) { + return evaluateDetail(featureKey, user, defaultValue, defaultValue, VariationType.Json); + } + @Override public boolean isFlagKnown(String featureKey) { if (!initialized()) { @@ -227,14 +248,36 @@ public boolean isFlagKnown(String featureKey) { return false; } - private JsonElement evaluate(String featureKey, LDUser user, JsonElement defaultValue, VariationType expectedType) { + private T evaluate(String featureKey, LDUser user, T defaultValue, JsonElement defaultJson, VariationType expectedType) { + return evaluateDetail(featureKey, user, defaultValue, defaultJson, expectedType).getValue(); + } + + private EvaluationDetails evaluateDetail(String featureKey, LDUser user, T defaultValue, + JsonElement defaultJson, VariationType expectedType) { + EvaluationDetails details = evaluateInternal(featureKey, user, defaultJson); + T resultValue; + if (details.getReason() == Reason.DEFAULT) { + resultValue = defaultValue; + } else { + try { + resultValue = expectedType.coerceValue(details.getValue()); + } catch (EvaluationException e) { + logger.error("Encountered exception in LaunchDarkly client: " + e); + resultValue = defaultValue; + } + } + return new EvaluationDetails(details.getReason(), details.getVariationIndex(), resultValue, + details.getMatchIndex(), details.getMatchId()); + } + + private EvaluationDetails evaluateInternal(String featureKey, LDUser user, JsonElement defaultValue) { if (!initialized()) { if (featureStore.initialized()) { logger.warn("Evaluation called before client initialized for feature flag " + featureKey + "; using last known values from feature store"); } else { logger.warn("Evaluation called before client initialized for feature flag " + featureKey + "; feature store unavailable, returning default value"); sendFlagRequestEvent(eventFactory.newUnknownFeatureRequestEvent(featureKey, user, defaultValue)); - return defaultValue; + return EvaluationDetails.defaultValue(defaultValue); } } @@ -243,12 +286,12 @@ private JsonElement evaluate(String featureKey, LDUser user, JsonElement default if (featureFlag == null) { logger.info("Unknown feature flag " + featureKey + "; returning default value"); sendFlagRequestEvent(eventFactory.newUnknownFeatureRequestEvent(featureKey, user, defaultValue)); - return defaultValue; + return EvaluationDetails.defaultValue(defaultValue); } if (user == null || user.getKey() == null) { logger.warn("Null user or null user key when evaluating flag: " + featureKey + "; returning default value"); sendFlagRequestEvent(eventFactory.newDefaultFeatureRequestEvent(featureFlag, user, defaultValue)); - return defaultValue; + return EvaluationDetails.defaultValue(defaultValue); } if (user.getKeyAsString().isEmpty()) { logger.warn("User key is blank. Flag evaluation will proceed, but the user will not be stored in LaunchDarkly"); @@ -257,19 +300,19 @@ private JsonElement evaluate(String featureKey, LDUser user, JsonElement default for (Event.FeatureRequest event : evalResult.getPrerequisiteEvents()) { eventProcessor.sendEvent(event); } - if (evalResult.getResult() != null && evalResult.getResult().getValue() != null) { - expectedType.assertResultType(evalResult.getResult().getValue()); - sendFlagRequestEvent(eventFactory.newFeatureRequestEvent(featureFlag, user, evalResult.getResult(), defaultValue)); - return evalResult.getResult().getValue(); + if (evalResult.getDetails() != null && evalResult.getDetails().getValue() != null) { + sendFlagRequestEvent(eventFactory.newFeatureRequestEvent(featureFlag, user, evalResult.getDetails(), defaultValue)); + return evalResult.getDetails(); } else { sendFlagRequestEvent(eventFactory.newDefaultFeatureRequestEvent(featureFlag, user, defaultValue)); - return defaultValue; + return EvaluationDetails.defaultValue(defaultValue); } } catch (Exception e) { - logger.error("Encountered exception in LaunchDarkly client", e); + logger.error("Encountered exception in LaunchDarkly client: " + e); + logger.debug(e.getMessage(), e); } sendFlagRequestEvent(eventFactory.newUnknownFeatureRequestEvent(featureKey, user, defaultValue)); - return defaultValue; + return EvaluationDetails.defaultValue(defaultValue); } @Override diff --git a/src/main/java/com/launchdarkly/client/LDClientInterface.java b/src/main/java/com/launchdarkly/client/LDClientInterface.java index 94ee3f060..c5fb4280a 100644 --- a/src/main/java/com/launchdarkly/client/LDClientInterface.java +++ b/src/main/java/com/launchdarkly/client/LDClientInterface.java @@ -58,7 +58,7 @@ public interface LDClientInterface extends Closeable { * @return whether or not the flag should be enabled, or {@code defaultValue} if the flag is disabled in the LaunchDarkly control panel */ boolean boolVariation(String featureKey, LDUser user, boolean defaultValue); - + /** * Calculates the integer value of a feature flag for a given user. * @@ -99,6 +99,61 @@ public interface LDClientInterface extends Closeable { */ JsonElement jsonVariation(String featureKey, LDUser user, JsonElement defaultValue); + /** + * Calculates the value of a feature flag for a given user, and returns an object that describes the + * way the value was determined. + * @param featureKey the unique key for the feature flag + * @param user the end user requesting the flag + * @param defaultValue the default value of the flag + * @return an {@link EvaluationDetails} object + * @since 2.3.0 + */ + EvaluationDetails boolVariationDetails(String featureKey, LDUser user, boolean defaultValue); + + /** + * Calculates the value of a feature flag for a given user, and returns an object that describes the + * way the value was determined. + * @param featureKey the unique key for the feature flag + * @param user the end user requesting the flag + * @param defaultValue the default value of the flag + * @return an {@link EvaluationDetails} object + * @since 2.3.0 + */ + EvaluationDetails intVariationDetails(String featureKey, LDUser user, int defaultValue); + + /** + * Calculates the value of a feature flag for a given user, and returns an object that describes the + * way the value was determined. + * @param featureKey the unique key for the feature flag + * @param user the end user requesting the flag + * @param defaultValue the default value of the flag + * @return an {@link EvaluationDetails} object + * @since 2.3.0 + */ + EvaluationDetails doubleVariationDetails(String featureKey, LDUser user, double defaultValue); + + /** + * Calculates the value of a feature flag for a given user, and returns an object that describes the + * way the value was determined. + * @param featureKey the unique key for the feature flag + * @param user the end user requesting the flag + * @param defaultValue the default value of the flag + * @return an {@link EvaluationDetails} object + * @since 2.3.0 + */ + EvaluationDetails stringVariationDetails(String featureKey, LDUser user, String defaultValue); + + /** + * Calculates the value of a feature flag for a given user, and returns an object that describes the + * way the value was determined. + * @param featureKey the unique key for the feature flag + * @param user the end user requesting the flag + * @param defaultValue the default value of the flag + * @return an {@link EvaluationDetails} object + * @since 2.3.0 + */ + EvaluationDetails jsonVariationDetails(String featureKey, LDUser user, JsonElement defaultValue); + /** * Returns true if the specified feature flag currently exists. * @param featureKey the unique key for the feature flag diff --git a/src/main/java/com/launchdarkly/client/Rule.java b/src/main/java/com/launchdarkly/client/Rule.java index 6009abc74..cee3d7ae0 100644 --- a/src/main/java/com/launchdarkly/client/Rule.java +++ b/src/main/java/com/launchdarkly/client/Rule.java @@ -8,6 +8,7 @@ * Invariant: one of the variation or rollout must be non-nil. */ class Rule extends VariationOrRollout { + private String id; private List clauses; // We need this so Gson doesn't complain in certain java environments that restrict unsafe allocation @@ -15,11 +16,16 @@ class Rule extends VariationOrRollout { super(); } - Rule(List clauses, Integer variation, Rollout rollout) { + Rule(String id, List clauses, Integer variation, Rollout rollout) { super(variation, rollout); + this.id = id; this.clauses = clauses; } + String getId() { + return id; + } + boolean matchesUser(FeatureStore store, LDUser user) { for (Clause clause : clauses) { if (!clause.matchesUser(store, user)) { diff --git a/src/main/java/com/launchdarkly/client/VariationType.java b/src/main/java/com/launchdarkly/client/VariationType.java index 8d8228f1b..c222d57a4 100644 --- a/src/main/java/com/launchdarkly/client/VariationType.java +++ b/src/main/java/com/launchdarkly/client/VariationType.java @@ -3,48 +3,51 @@ import com.google.gson.JsonElement; -enum VariationType { - Boolean { - @Override - void assertResultType(JsonElement result) throws EvaluationException { +abstract class VariationType { + abstract T coerceValue(JsonElement result) throws EvaluationException; + + private VariationType() { + } + + static VariationType Boolean = new VariationType() { + Boolean coerceValue(JsonElement result) throws EvaluationException { if (result.isJsonPrimitive() && result.getAsJsonPrimitive().isBoolean()) { - return; + return result.getAsBoolean(); } throw new EvaluationException("Feature flag evaluation expected result as boolean type, but got non-boolean type."); } - }, - Integer { - @Override - void assertResultType(JsonElement result) throws EvaluationException { + }; + + static VariationType Integer = new VariationType() { + Integer coerceValue(JsonElement result) throws EvaluationException { if (result.isJsonPrimitive() && result.getAsJsonPrimitive().isNumber()) { - return; + return result.getAsInt(); } throw new EvaluationException("Feature flag evaluation expected result as number type, but got non-number type."); } - }, - Double { - @Override - void assertResultType(JsonElement result) throws EvaluationException { + }; + + static VariationType Double = new VariationType() { + Double coerceValue(JsonElement result) throws EvaluationException { if (result.isJsonPrimitive() && result.getAsJsonPrimitive().isNumber()) { - return; + return result.getAsDouble(); } throw new EvaluationException("Feature flag evaluation expected result as number type, but got non-number type."); } - }, - String { - @Override - void assertResultType(JsonElement result) throws EvaluationException { + }; + + static VariationType String = new VariationType() { + String coerceValue(JsonElement result) throws EvaluationException { if (result.isJsonPrimitive() && result.getAsJsonPrimitive().isString()) { - return; + return result.getAsString(); } throw new EvaluationException("Feature flag evaluation expected result as string type, but got non-string type."); } - }, - Json { - @Override - void assertResultType(JsonElement result) { + }; + + static VariationType Json = new VariationType() { + JsonElement coerceValue(JsonElement result) throws EvaluationException { + return result; } }; - - abstract void assertResultType(JsonElement result) throws EvaluationException; } diff --git a/src/test/java/com/launchdarkly/client/DefaultEventProcessorTest.java b/src/test/java/com/launchdarkly/client/DefaultEventProcessorTest.java index d4b250e0a..bb20af90d 100644 --- a/src/test/java/com/launchdarkly/client/DefaultEventProcessorTest.java +++ b/src/test/java/com/launchdarkly/client/DefaultEventProcessorTest.java @@ -87,7 +87,7 @@ public void individualFeatureEventIsQueuedWithIndexEvent() throws Exception { ep = new DefaultEventProcessor(SDK_KEY, configBuilder.build()); FeatureFlag flag = new FeatureFlagBuilder("flagkey").version(11).trackEvents(true).build(); Event.FeatureRequest fe = EventFactory.DEFAULT.newFeatureRequestEvent(flag, user, - new FeatureFlag.VariationAndValue(new Integer(1), new JsonPrimitive("value")), null); + EvaluationDetails.fallthrough(1, new JsonPrimitive("value")), null); ep.sendEvent(fe); JsonArray output = flushAndGetEvents(new MockResponse()); @@ -105,7 +105,7 @@ public void userIsFilteredInIndexEvent() throws Exception { ep = new DefaultEventProcessor(SDK_KEY, configBuilder.build()); FeatureFlag flag = new FeatureFlagBuilder("flagkey").version(11).trackEvents(true).build(); Event.FeatureRequest fe = EventFactory.DEFAULT.newFeatureRequestEvent(flag, user, - new FeatureFlag.VariationAndValue(new Integer(1), new JsonPrimitive("value")), null); + EvaluationDetails.fallthrough(1, new JsonPrimitive("value")), null); ep.sendEvent(fe); JsonArray output = flushAndGetEvents(new MockResponse()); @@ -123,7 +123,7 @@ public void featureEventCanContainInlineUser() throws Exception { ep = new DefaultEventProcessor(SDK_KEY, configBuilder.build()); FeatureFlag flag = new FeatureFlagBuilder("flagkey").version(11).trackEvents(true).build(); Event.FeatureRequest fe = EventFactory.DEFAULT.newFeatureRequestEvent(flag, user, - new FeatureFlag.VariationAndValue(new Integer(1), new JsonPrimitive("value")), null); + EvaluationDetails.fallthrough(1, new JsonPrimitive("value")), null); ep.sendEvent(fe); JsonArray output = flushAndGetEvents(new MockResponse()); @@ -140,7 +140,7 @@ public void userIsFilteredInFeatureEvent() throws Exception { ep = new DefaultEventProcessor(SDK_KEY, configBuilder.build()); FeatureFlag flag = new FeatureFlagBuilder("flagkey").version(11).trackEvents(true).build(); Event.FeatureRequest fe = EventFactory.DEFAULT.newFeatureRequestEvent(flag, user, - new FeatureFlag.VariationAndValue(new Integer(1), new JsonPrimitive("value")), null); + EvaluationDetails.fallthrough(1, new JsonPrimitive("value")), null); ep.sendEvent(fe); JsonArray output = flushAndGetEvents(new MockResponse()); @@ -157,7 +157,7 @@ public void indexEventIsStillGeneratedIfInlineUsersIsTrueButFeatureEventIsNotTra ep = new DefaultEventProcessor(SDK_KEY, configBuilder.build()); FeatureFlag flag = new FeatureFlagBuilder("flagkey").version(11).trackEvents(false).build(); Event.FeatureRequest fe = EventFactory.DEFAULT.newFeatureRequestEvent(flag, user, - new FeatureFlag.VariationAndValue(new Integer(1), new JsonPrimitive("value")), null); + EvaluationDetails.fallthrough(1, new JsonPrimitive("value")), null); ep.sendEvent(fe); JsonArray output = flushAndGetEvents(new MockResponse()); @@ -174,7 +174,7 @@ public void eventKindIsDebugIfFlagIsTemporarilyInDebugMode() throws Exception { long futureTime = System.currentTimeMillis() + 1000000; FeatureFlag flag = new FeatureFlagBuilder("flagkey").version(11).debugEventsUntilDate(futureTime).build(); Event.FeatureRequest fe = EventFactory.DEFAULT.newFeatureRequestEvent(flag, user, - new FeatureFlag.VariationAndValue(new Integer(1), new JsonPrimitive("value")), null); + EvaluationDetails.fallthrough(1, new JsonPrimitive("value")), null); ep.sendEvent(fe); JsonArray output = flushAndGetEvents(new MockResponse()); @@ -193,7 +193,7 @@ public void eventCanBeBothTrackedAndDebugged() throws Exception { FeatureFlag flag = new FeatureFlagBuilder("flagkey").version(11).trackEvents(true) .debugEventsUntilDate(futureTime).build(); Event.FeatureRequest fe = EventFactory.DEFAULT.newFeatureRequestEvent(flag, user, - new FeatureFlag.VariationAndValue(new Integer(1), new JsonPrimitive("value")), null); + EvaluationDetails.fallthrough(1, new JsonPrimitive("value")), null); ep.sendEvent(fe); JsonArray output = flushAndGetEvents(new MockResponse()); @@ -222,7 +222,7 @@ public void debugModeExpiresBasedOnClientTimeIfClientTimeIsLaterThanServerTime() long debugUntil = serverTime + 1000; FeatureFlag flag = new FeatureFlagBuilder("flagkey").version(11).debugEventsUntilDate(debugUntil).build(); Event.FeatureRequest fe = EventFactory.DEFAULT.newFeatureRequestEvent(flag, user, - new FeatureFlag.VariationAndValue(new Integer(1), new JsonPrimitive("value")), null); + EvaluationDetails.fallthrough(1, new JsonPrimitive("value")), null); ep.sendEvent(fe); // Should get a summary event only, not a full feature event @@ -250,7 +250,7 @@ public void debugModeExpiresBasedOnServerTimeIfServerTimeIsLaterThanClientTime() long debugUntil = serverTime - 1000; FeatureFlag flag = new FeatureFlagBuilder("flagkey").version(11).debugEventsUntilDate(debugUntil).build(); Event.FeatureRequest fe = EventFactory.DEFAULT.newFeatureRequestEvent(flag, user, - new FeatureFlag.VariationAndValue(new Integer(1), new JsonPrimitive("value")), null); + EvaluationDetails.fallthrough(1, new JsonPrimitive("value")), null); ep.sendEvent(fe); // Should get a summary event only, not a full feature event @@ -269,9 +269,9 @@ public void twoFeatureEventsForSameUserGenerateOnlyOneIndexEvent() throws Except FeatureFlag flag2 = new FeatureFlagBuilder("flagkey2").version(22).trackEvents(true).build(); JsonElement value = new JsonPrimitive("value"); Event.FeatureRequest fe1 = EventFactory.DEFAULT.newFeatureRequestEvent(flag1, user, - new FeatureFlag.VariationAndValue(new Integer(1), value), null); + EvaluationDetails.fallthrough(1, value), null); Event.FeatureRequest fe2 = EventFactory.DEFAULT.newFeatureRequestEvent(flag2, user, - new FeatureFlag.VariationAndValue(new Integer(1), value), null); + EvaluationDetails.fallthrough(1, value), null); ep.sendEvent(fe1); ep.sendEvent(fe2); @@ -294,9 +294,9 @@ public void nonTrackedEventsAreSummarized() throws Exception { JsonElement default1 = new JsonPrimitive("default1"); JsonElement default2 = new JsonPrimitive("default2"); Event fe1 = EventFactory.DEFAULT.newFeatureRequestEvent(flag1, user, - new FeatureFlag.VariationAndValue(new Integer(2), value), default1); + EvaluationDetails.fallthrough(2, value), default1); Event fe2 = EventFactory.DEFAULT.newFeatureRequestEvent(flag2, user, - new FeatureFlag.VariationAndValue(new Integer(2), value), default2); + EvaluationDetails.fallthrough(2, value), default2); ep.sendEvent(fe1); ep.sendEvent(fe2); diff --git a/src/test/java/com/launchdarkly/client/EventSummarizerTest.java b/src/test/java/com/launchdarkly/client/EventSummarizerTest.java index 6a7a83875..664fb8e56 100644 --- a/src/test/java/com/launchdarkly/client/EventSummarizerTest.java +++ b/src/test/java/com/launchdarkly/client/EventSummarizerTest.java @@ -65,13 +65,13 @@ public void summarizeEventIncrementsCounters() { FeatureFlag flag2 = new FeatureFlagBuilder("key2").version(22).build(); String unknownFlagKey = "badkey"; Event event1 = eventFactory.newFeatureRequestEvent(flag1, user, - new FeatureFlag.VariationAndValue(1, js("value1")), js("default1")); + EvaluationDetails.fallthrough(1, js("value1")), js("default1")); Event event2 = eventFactory.newFeatureRequestEvent(flag1, user, - new FeatureFlag.VariationAndValue(2, js("value2")), js("default1")); + EvaluationDetails.fallthrough(2, js("value2")), js("default1")); Event event3 = eventFactory.newFeatureRequestEvent(flag2, user, - new FeatureFlag.VariationAndValue(1, js("value99")), js("default2")); + EvaluationDetails.fallthrough(1, js("value99")), js("default2")); Event event4 = eventFactory.newFeatureRequestEvent(flag1, user, - new FeatureFlag.VariationAndValue(1, js("value1")), js("default1")); + EvaluationDetails.fallthrough(1, js("value1")), js("default1")); Event event5 = eventFactory.newUnknownFeatureRequestEvent(unknownFlagKey, user, js("default3")); es.summarizeEvent(event1); es.summarizeEvent(event2); diff --git a/src/test/java/com/launchdarkly/client/FeatureFlagTest.java b/src/test/java/com/launchdarkly/client/FeatureFlagTest.java index 70bc94c9f..be062eb47 100644 --- a/src/test/java/com/launchdarkly/client/FeatureFlagTest.java +++ b/src/test/java/com/launchdarkly/client/FeatureFlagTest.java @@ -2,6 +2,7 @@ import com.google.gson.Gson; import com.google.gson.JsonElement; +import com.launchdarkly.client.EvaluationDetails.Reason; import org.junit.Before; import org.junit.Test; @@ -17,7 +18,6 @@ import static com.launchdarkly.client.VersionedDataKind.SEGMENTS; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertNotNull; -import static org.junit.Assert.assertNull; public class FeatureFlagTest { @@ -40,7 +40,7 @@ public void flagReturnsOffVariationIfFlagIsOff() throws Exception { .build(); FeatureFlag.EvalResult result = f.evaluate(BASE_USER, featureStore, EventFactory.DEFAULT); - assertEquals(js("off"), result.getResult().getValue()); + assertEquals(EvaluationDetails.off(1, js("off")), result.getDetails()); assertEquals(0, result.getPrerequisiteEvents().size()); } @@ -53,7 +53,7 @@ public void flagReturnsNullIfFlagIsOffAndOffVariationIsUnspecified() throws Exce .build(); FeatureFlag.EvalResult result = f.evaluate(BASE_USER, featureStore, EventFactory.DEFAULT); - assertNull(result.getResult().getValue()); + assertEquals(EvaluationDetails.off(null, null), result.getDetails()); assertEquals(0, result.getPrerequisiteEvents().size()); } @@ -68,7 +68,7 @@ public void flagReturnsOffVariationIfPrerequisiteIsNotFound() throws Exception { .build(); FeatureFlag.EvalResult result = f0.evaluate(BASE_USER, featureStore, EventFactory.DEFAULT); - assertEquals(js("off"), result.getResult().getValue()); + assertEquals(new EvaluationDetails(Reason.PREREQUISITE_FAILED, 1, js("off"), 0, "feature1"), result.getDetails()); assertEquals(0, result.getPrerequisiteEvents().size()); } @@ -91,7 +91,7 @@ public void flagReturnsOffVariationAndEventIfPrerequisiteIsNotMet() throws Excep featureStore.upsert(FEATURES, f1); FeatureFlag.EvalResult result = f0.evaluate(BASE_USER, featureStore, EventFactory.DEFAULT); - assertEquals(js("off"), result.getResult().getValue()); + assertEquals(new EvaluationDetails(Reason.PREREQUISITE_FAILED, 1, js("off"), 0, "feature1"), result.getDetails()); assertEquals(1, result.getPrerequisiteEvents().size()); Event.FeatureRequest event = result.getPrerequisiteEvents().get(0); @@ -120,7 +120,7 @@ public void flagReturnsFallthroughVariationAndEventIfPrerequisiteIsMetAndThereAr featureStore.upsert(FEATURES, f1); FeatureFlag.EvalResult result = f0.evaluate(BASE_USER, featureStore, EventFactory.DEFAULT); - assertEquals(js("fall"), result.getResult().getValue()); + assertEquals(EvaluationDetails.fallthrough(0, js("fall")), result.getDetails()); assertEquals(1, result.getPrerequisiteEvents().size()); Event.FeatureRequest event = result.getPrerequisiteEvents().get(0); @@ -157,7 +157,7 @@ public void multipleLevelsOfPrerequisitesProduceMultipleEvents() throws Exceptio featureStore.upsert(FEATURES, f2); FeatureFlag.EvalResult result = f0.evaluate(BASE_USER, featureStore, EventFactory.DEFAULT); - assertEquals(js("fall"), result.getResult().getValue()); + assertEquals(EvaluationDetails.fallthrough(0, js("fall")), result.getDetails()); assertEquals(2, result.getPrerequisiteEvents().size()); Event.FeatureRequest event0 = result.getPrerequisiteEvents().get(0); @@ -185,14 +185,14 @@ public void flagMatchesUserFromTargets() throws Exception { LDUser user = new LDUser.Builder("userkey").build(); FeatureFlag.EvalResult result = f.evaluate(user, featureStore, EventFactory.DEFAULT); - assertEquals(js("on"), result.getResult().getValue()); + assertEquals(new EvaluationDetails(Reason.TARGET_MATCH, 2, js("on"), 0, null), result.getDetails()); assertEquals(0, result.getPrerequisiteEvents().size()); } @Test public void flagMatchesUserFromRules() throws Exception { Clause clause = new Clause("key", Operator.in, Arrays.asList(js("userkey")), false); - Rule rule = new Rule(Arrays.asList(clause), 2, null); + Rule rule = new Rule("ruleid", Arrays.asList(clause), 2, null); FeatureFlag f = new FeatureFlagBuilder("feature") .on(true) .rules(Arrays.asList(rule)) @@ -203,44 +203,44 @@ public void flagMatchesUserFromRules() throws Exception { LDUser user = new LDUser.Builder("userkey").build(); FeatureFlag.EvalResult result = f.evaluate(user, featureStore, EventFactory.DEFAULT); - assertEquals(js("on"), result.getResult().getValue()); + assertEquals(new EvaluationDetails(Reason.RULE_MATCH, 2, js("on"), 0, "ruleid"), result.getDetails()); assertEquals(0, result.getPrerequisiteEvents().size()); } @Test public void clauseCanMatchBuiltInAttribute() throws Exception { Clause clause = new Clause("name", Operator.in, Arrays.asList(js("Bob")), false); - FeatureFlag f = booleanFlagWithClauses(clause); + FeatureFlag f = booleanFlagWithClauses("flag", clause); LDUser user = new LDUser.Builder("key").name("Bob").build(); - assertEquals(jbool(true), f.evaluate(user, featureStore, EventFactory.DEFAULT).getResult().getValue()); + assertEquals(jbool(true), f.evaluate(user, featureStore, EventFactory.DEFAULT).getDetails().getValue()); } @Test public void clauseCanMatchCustomAttribute() throws Exception { Clause clause = new Clause("legs", Operator.in, Arrays.asList(jint(4)), false); - FeatureFlag f = booleanFlagWithClauses(clause); + FeatureFlag f = booleanFlagWithClauses("flag", clause); LDUser user = new LDUser.Builder("key").custom("legs", 4).build(); - assertEquals(jbool(true), f.evaluate(user, featureStore, EventFactory.DEFAULT).getResult().getValue()); + assertEquals(jbool(true), f.evaluate(user, featureStore, EventFactory.DEFAULT).getDetails().getValue()); } @Test public void clauseReturnsFalseForMissingAttribute() throws Exception { Clause clause = new Clause("legs", Operator.in, Arrays.asList(jint(4)), false); - FeatureFlag f = booleanFlagWithClauses(clause); + FeatureFlag f = booleanFlagWithClauses("flag", clause); LDUser user = new LDUser.Builder("key").name("Bob").build(); - assertEquals(jbool(false), f.evaluate(user, featureStore, EventFactory.DEFAULT).getResult().getValue()); + assertEquals(jbool(false), f.evaluate(user, featureStore, EventFactory.DEFAULT).getDetails().getValue()); } @Test public void clauseCanBeNegated() throws Exception { Clause clause = new Clause("name", Operator.in, Arrays.asList(js("Bob")), true); - FeatureFlag f = booleanFlagWithClauses(clause); + FeatureFlag f = booleanFlagWithClauses("flag", clause); LDUser user = new LDUser.Builder("key").name("Bob").build(); - assertEquals(jbool(false), f.evaluate(user, featureStore, EventFactory.DEFAULT).getResult().getValue()); + assertEquals(jbool(false), f.evaluate(user, featureStore, EventFactory.DEFAULT).getDetails().getValue()); } @Test @@ -261,18 +261,18 @@ public void clauseWithUnsupportedOperatorStringIsUnmarshalledWithNullOperator() @Test public void clauseWithNullOperatorDoesNotMatch() throws Exception { Clause badClause = new Clause("name", null, Arrays.asList(js("Bob")), false); - FeatureFlag f = booleanFlagWithClauses(badClause); + FeatureFlag f = booleanFlagWithClauses("flag", badClause); LDUser user = new LDUser.Builder("key").name("Bob").build(); - assertEquals(jbool(false), f.evaluate(user, featureStore, EventFactory.DEFAULT).getResult().getValue()); + assertEquals(jbool(false), f.evaluate(user, featureStore, EventFactory.DEFAULT).getDetails().getValue()); } @Test public void clauseWithNullOperatorDoesNotStopSubsequentRuleFromMatching() throws Exception { Clause badClause = new Clause("name", null, Arrays.asList(js("Bob")), false); - Rule badRule = new Rule(Arrays.asList(badClause), 1, null); + Rule badRule = new Rule("rule1", Arrays.asList(badClause), 1, null); Clause goodClause = new Clause("name", Operator.in, Arrays.asList(js("Bob")), false); - Rule goodRule = new Rule(Arrays.asList(goodClause), 1, null); + Rule goodRule = new Rule("rule2", Arrays.asList(goodClause), 1, null); FeatureFlag f = new FeatureFlagBuilder("feature") .on(true) .rules(Arrays.asList(badRule, goodRule)) @@ -282,7 +282,8 @@ public void clauseWithNullOperatorDoesNotStopSubsequentRuleFromMatching() throws .build(); LDUser user = new LDUser.Builder("key").name("Bob").build(); - assertEquals(jbool(true), f.evaluate(user, featureStore, EventFactory.DEFAULT).getResult().getValue()); + EvaluationDetails details = f.evaluate(user, featureStore, EventFactory.DEFAULT).getDetails(); + assertEquals(new EvaluationDetails(Reason.RULE_MATCH, 1, jbool(true), 1, "rule2"), details); } @Test @@ -297,7 +298,7 @@ public void testSegmentMatchClauseRetrievesSegmentFromStore() throws Exception { LDUser user = new LDUser.Builder("foo").build(); FeatureFlag.EvalResult result = flag.evaluate(user, featureStore, EventFactory.DEFAULT); - assertEquals(jbool(true), result.getResult().getValue()); + assertEquals(jbool(true), result.getDetails().getValue()); } @Test @@ -306,11 +307,11 @@ public void testSegmentMatchClauseFallsThroughIfSegmentNotFound() throws Excepti LDUser user = new LDUser.Builder("foo").build(); FeatureFlag.EvalResult result = flag.evaluate(user, featureStore, EventFactory.DEFAULT); - assertEquals(jbool(false), result.getResult().getValue()); + assertEquals(jbool(false), result.getDetails().getValue()); } private FeatureFlag segmentMatchBooleanFlag(String segmentKey) { Clause clause = new Clause("", Operator.segmentMatch, Arrays.asList(js(segmentKey)), false); - return booleanFlagWithClauses(clause); + return booleanFlagWithClauses("flag", clause); } } diff --git a/src/test/java/com/launchdarkly/client/LDClientEvaluationTest.java b/src/test/java/com/launchdarkly/client/LDClientEvaluationTest.java index f6d175dc0..c6c17ebba 100644 --- a/src/test/java/com/launchdarkly/client/LDClientEvaluationTest.java +++ b/src/test/java/com/launchdarkly/client/LDClientEvaluationTest.java @@ -7,6 +7,10 @@ import java.util.Arrays; +import static com.launchdarkly.client.TestUtil.booleanFlagWithClauses; +import static com.launchdarkly.client.TestUtil.flagWithValue; +import static com.launchdarkly.client.TestUtil.jbool; +import static com.launchdarkly.client.TestUtil.jdouble; import static com.launchdarkly.client.TestUtil.jint; import static com.launchdarkly.client.TestUtil.js; import static com.launchdarkly.client.TestUtil.specificFeatureStore; @@ -19,7 +23,7 @@ public class LDClientEvaluationTest { private static final LDUser user = new LDUser("userkey"); - private TestFeatureStore featureStore = new TestFeatureStore(); + private FeatureStore featureStore = TestUtil.initedFeatureStore(); private LDConfig config = new LDConfig.Builder() .featureStoreFactory(specificFeatureStore(featureStore)) .eventProcessorFactory(Components.nullEventProcessor()) @@ -29,7 +33,7 @@ public class LDClientEvaluationTest { @Test public void boolVariationReturnsFlagValue() throws Exception { - featureStore.setFeatureTrue("key"); + featureStore.upsert(FEATURES, flagWithValue("key", jbool(true))); assertTrue(client.boolVariation("key", user, false)); } @@ -41,7 +45,7 @@ public void boolVariationReturnsDefaultValueForUnknownFlag() throws Exception { @Test public void intVariationReturnsFlagValue() throws Exception { - featureStore.setIntegerValue("key", 2); + featureStore.upsert(FEATURES, flagWithValue("key", jint(2))); assertEquals(new Integer(2), client.intVariation("key", user, 1)); } @@ -53,7 +57,7 @@ public void intVariationReturnsDefaultValueForUnknownFlag() throws Exception { @Test public void doubleVariationReturnsFlagValue() throws Exception { - featureStore.setDoubleValue("key", 2.5d); + featureStore.upsert(FEATURES, flagWithValue("key", jdouble(2.5d))); assertEquals(new Double(2.5d), client.doubleVariation("key", user, 1.0d)); } @@ -65,7 +69,7 @@ public void doubleVariationReturnsDefaultValueForUnknownFlag() throws Exception @Test public void stringVariationReturnsFlagValue() throws Exception { - featureStore.setStringValue("key", "b"); + featureStore.upsert(FEATURES, flagWithValue("key", js("b"))); assertEquals("b", client.stringVariation("key", user, "a")); } @@ -79,7 +83,7 @@ public void stringVariationReturnsDefaultValueForUnknownFlag() throws Exception public void jsonVariationReturnsFlagValue() throws Exception { JsonObject data = new JsonObject(); data.addProperty("thing", "stuff"); - featureStore.setJsonValue("key", data); + featureStore.upsert(FEATURES, flagWithValue("key", data)); assertEquals(data, client.jsonVariation("key", user, jint(42))); } @@ -100,16 +104,9 @@ public void canMatchUserBySegment() throws Exception { featureStore.upsert(SEGMENTS, segment); Clause clause = new Clause("", Operator.segmentMatch, Arrays.asList(js("segment1")), false); - Rule rule = new Rule(Arrays.asList(clause), 0, null); - FeatureFlag feature = new FeatureFlagBuilder("test-feature") - .version(1) - .rules(Arrays.asList(rule)) - .variations(TestFeatureStore.TRUE_FALSE_VARIATIONS) - .on(true) - .fallthrough(new VariationOrRollout(1, null)) - .build(); + FeatureFlag feature = booleanFlagWithClauses("feature", clause); featureStore.upsert(FEATURES, feature); - assertTrue(client.boolVariation("test-feature", user, false)); + assertTrue(client.boolVariation("feature", user, false)); } } diff --git a/src/test/java/com/launchdarkly/client/TestUtil.java b/src/test/java/com/launchdarkly/client/TestUtil.java index 01304c620..494586c26 100644 --- a/src/test/java/com/launchdarkly/client/TestUtil.java +++ b/src/test/java/com/launchdarkly/client/TestUtil.java @@ -11,7 +11,9 @@ import java.io.IOException; import java.util.ArrayList; import java.util.Arrays; +import java.util.Collections; import java.util.List; +import java.util.Map; import static org.hamcrest.Matchers.equalTo; @@ -25,6 +27,12 @@ public FeatureStore createFeatureStore() { }; } + public static FeatureStore initedFeatureStore() { + FeatureStore store = new InMemoryFeatureStore(); + store.init(Collections., Map>emptyMap()); + return store; + } + public static EventProcessorFactory specificEventProcessor(final EventProcessor ep) { return new EventProcessorFactory() { public EventProcessor createEventProcessor(String sdkKey, LDConfig config) { @@ -76,9 +84,9 @@ public static VariationOrRollout fallthroughVariation(int variation) { return new VariationOrRollout(variation, null); } - public static FeatureFlag booleanFlagWithClauses(Clause... clauses) { - Rule rule = new Rule(Arrays.asList(clauses), 1, null); - return new FeatureFlagBuilder("feature") + public static FeatureFlag booleanFlagWithClauses(String key, Clause... clauses) { + Rule rule = new Rule(null, Arrays.asList(clauses), 1, null); + return new FeatureFlagBuilder(key) .on(true) .rules(Arrays.asList(rule)) .fallthrough(fallthroughVariation(0)) @@ -87,6 +95,14 @@ public static FeatureFlag booleanFlagWithClauses(Clause... clauses) { .build(); } + public static FeatureFlag flagWithValue(String key, JsonElement value) { + return new FeatureFlagBuilder(key) + .on(false) + .offVariation(0) + .variations(value) + .build(); + } + public static Matcher hasJsonProperty(final String name, JsonElement value) { return hasJsonProperty(name, equalTo(value)); } From 8abe79cbdab67fb2d4725b7dff51d951fed35d7a Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Fri, 29 Jun 2018 16:07:45 -0700 Subject: [PATCH 03/43] use case-class-like type instead of enum + optional fields --- .../client/EvaluationDetails.java | 85 +----- .../launchdarkly/client/EvaluationReason.java | 260 ++++++++++++++++++ .../com/launchdarkly/client/FeatureFlag.java | 44 +-- .../com/launchdarkly/client/LDClient.java | 6 +- .../client/DefaultEventProcessorTest.java | 27 +- .../client/EventSummarizerTest.java | 9 +- .../launchdarkly/client/FeatureFlagTest.java | 62 ++++- .../com/launchdarkly/client/TestUtil.java | 4 + 8 files changed, 368 insertions(+), 129 deletions(-) create mode 100644 src/main/java/com/launchdarkly/client/EvaluationReason.java diff --git a/src/main/java/com/launchdarkly/client/EvaluationDetails.java b/src/main/java/com/launchdarkly/client/EvaluationDetails.java index 99a172942..86abb7578 100644 --- a/src/main/java/com/launchdarkly/client/EvaluationDetails.java +++ b/src/main/java/com/launchdarkly/client/EvaluationDetails.java @@ -1,7 +1,6 @@ package com.launchdarkly.client; import com.google.common.base.Objects; -import com.google.gson.JsonElement; /** * An object returned by the "variation detail" methods such as {@link LDClientInterface#boolVariationDetails(String, LDUser, boolean), @@ -10,71 +9,26 @@ */ public class EvaluationDetails { - /** - * Enum values used in {@link EvaluationDetails} to explain why a flag evaluated to a particular value. - * @since 4.3.0 - */ - public static enum Reason { - /** - * Indicates that the flag was off and therefore returned its configured off value. - */ - OFF, - /** - * Indicates that the user key was specifically targeted for this flag. - */ - TARGET_MATCH, - /** - * Indicates that the user matched one of the flag's rules. - */ - RULE_MATCH, - /** - * Indicates that the flag was treated as if it was off because it had a prerequisite flag that - * either was off or did not return the expected variation., - */ - PREREQUISITE_FAILED, - /** - * Indicates that the flag was on but the user did not match any targets or rules. - */ - FALLTHROUGH, - /** - * Indicates that the default value (passed as a parameter to one of the {@code variation} methods0 - * was returned. This normally indicates an error condition. - */ - DEFAULT; - } - - private final Reason reason; + private final EvaluationReason reason; private final Integer variationIndex; private final T value; - private final Integer matchIndex; - private final String matchId; - public EvaluationDetails(Reason reason, Integer variationIndex, T value, Integer matchIndex, String matchId) { + public EvaluationDetails(EvaluationReason reason, Integer variationIndex, T value) { super(); this.reason = reason; this.variationIndex = variationIndex; this.value = value; - this.matchIndex = matchIndex; - this.matchId = matchId; - } - - static EvaluationDetails off(Integer offVariation, JsonElement value) { - return new EvaluationDetails(Reason.OFF, offVariation, value, null, null); - } - - static EvaluationDetails fallthrough(int variationIndex, JsonElement value) { - return new EvaluationDetails(Reason.FALLTHROUGH, variationIndex, value, null, null); } static EvaluationDetails defaultValue(T value) { - return new EvaluationDetails(Reason.DEFAULT, null, value, null, null); + return new EvaluationDetails<>(EvaluationReason.defaultValue(), null, value); } /** - * An enum describing the main factor that influenced the flag evaluation value. - * @return a {@link Reason} + * An object describing the main factor that influenced the flag evaluation value. + * @return an {@link EvaluationReason} */ - public Reason getReason() { + public EvaluationReason getReason() { return reason; } @@ -95,45 +49,24 @@ public Integer getVariationIndex() { public T getValue() { return value; } - - /** - * A number whose meaning depends on the {@link Reason}. For {@link Reason#TARGET_MATCH}, it is the - * zero-based index of the matched target. For {@link Reason#RULE_MATCH}, it is the zero-based index - * of the matched rule. For all other reasons, it is {@code null}. - * @return the index of the matched item or null - */ - public Integer getMatchIndex() { - return matchIndex; - } - - /** - * A string whose meaning depends on the {@link Reason}. For {@link Reason#RULE_MATCH}, it is the - * unique identifier of the matched rule, if any. For {@link Reason#PREREQUISITE_FAILED}, it is the - * flag key of the prerequisite flag that stopped evaluation. For all other reasons, it is {@code null}. - * @return a rule ID, flag key, or null - */ - public String getMatchId() { - return matchId; - } @Override public boolean equals(Object other) { if (other instanceof EvaluationDetails) { @SuppressWarnings("unchecked") EvaluationDetails o = (EvaluationDetails)other; - return reason == o.reason && variationIndex == o.variationIndex && Objects.equal(value, o.value) - && matchIndex == o.matchIndex && Objects.equal(matchId, o.matchId); + return Objects.equal(reason, o.reason) && variationIndex == o.variationIndex && Objects.equal(value, o.value); } return false; } @Override public int hashCode() { - return Objects.hashCode(reason, variationIndex, value, matchIndex, matchId); + return Objects.hashCode(reason, variationIndex, value); } @Override public String toString() { - return "{" + reason + ", " + variationIndex + ", " + value + ", " + matchIndex + ", " + matchId + "}"; + return "{" + reason + "," + variationIndex + "," + value + "}"; } } diff --git a/src/main/java/com/launchdarkly/client/EvaluationReason.java b/src/main/java/com/launchdarkly/client/EvaluationReason.java new file mode 100644 index 000000000..592c880be --- /dev/null +++ b/src/main/java/com/launchdarkly/client/EvaluationReason.java @@ -0,0 +1,260 @@ +package com.launchdarkly.client; + +import com.google.common.base.Joiner; +import com.google.common.collect.ImmutableList; + +import java.util.Objects; + +/** + * Describes the reason that a flag evaluation produced a particular value. This is returned by + * methods such as {@link LDClientInterface#boolVariationDetails(String, LDUser, boolean). + * + * Note that this is an enum-like class hierarchy rather than an enum, because some of the + * possible reasons have their own properties. + * + * @since 4.3.0 + */ +public abstract class EvaluationReason { + + public static enum Kind { + /** + * Indicates that the flag was off and therefore returned its configured off value. + */ + OFF, + /** + * Indicates that the user key was specifically targeted for this flag. + */ + TARGET_MATCH, + /** + * Indicates that the user matched one of the flag's rules. + */ + RULE_MATCH, + /** + * Indicates that the flag was considered off because it had at least one prerequisite flag + * that either was off or did not return the desired variation. + */ + PREREQUISITES_FAILED, + /** + * Indicates that the flag was on but the user did not match any targets or rules. + */ + FALLTHROUGH, + /** + * Indicates that the default value (passed as a parameter to one of the {@code variation} methods) + * was returned. This normally indicates an error condition. + */ + DEFAULT; + } + + /** + * Returns an enum indicating the general category of the reason. + * @return a {@link Kind} value + */ + public abstract Kind getKind(); + + @Override + public String toString() { + return getKind().name(); + } + + private EvaluationReason() { } + + /** + * Returns an instance of {@link Off}. + */ + public static Off off() { + return Off.instance; + } + + /** + * Returns an instance of {@link TargetMatch}. + */ + public static TargetMatch targetMatch(int targetIndex) { + return new TargetMatch(targetIndex); + } + + /** + * Returns an instance of {@link RuleMatch}. + */ + public static RuleMatch ruleMatch(int ruleIndex, String ruleId) { + return new RuleMatch(ruleIndex, ruleId); + } + + /** + * Returns an instance of {@link PrerequisitesFailed}. + */ + public static PrerequisitesFailed prerequisitesFailed(Iterable prerequisiteKeys) { + return new PrerequisitesFailed(prerequisiteKeys); + } + + /** + * Returns an instance of {@link Fallthrough}. + */ + public static Fallthrough fallthrough() { + return Fallthrough.instance; + } + + /** + * Returns an instance of {@link Default}. + */ + public static Default defaultValue() { + return Default.instance; + } + + /** + * Subclass of {@link EvaluationReason} that indicates that the flag was off and therefore returned + * its configured off value. + */ + public static class Off extends EvaluationReason { + public Kind getKind() { + return Kind.OFF; + } + + private static final Off instance = new Off(); + } + + /** + * Subclass of {@link EvaluationReason} that indicates that the user key was specifically targeted + * for this flag. + */ + public static class TargetMatch extends EvaluationReason { + private final int targetIndex; + + private TargetMatch(int targetIndex) { + this.targetIndex = targetIndex; + } + + public Kind getKind() { + return Kind.TARGET_MATCH; + } + + public int getTargetIndex() { + return targetIndex; + } + + @Override + public boolean equals(Object other) { + if (other instanceof TargetMatch) { + TargetMatch o = (TargetMatch)other; + return targetIndex == o.targetIndex; + } + return false; + } + + @Override + public int hashCode() { + return targetIndex; + } + + @Override + public String toString() { + return getKind().name() + "(" + targetIndex + ")"; + } + } + + /** + * Subclass of {@link EvaluationReason} that indicates that the user matched one of the flag's rules. + */ + public static class RuleMatch extends EvaluationReason { + private final int ruleIndex; + private final String ruleId; + + private RuleMatch(int ruleIndex, String ruleId) { + this.ruleIndex = ruleIndex; + this.ruleId = ruleId; + } + + public Kind getKind() { + return Kind.RULE_MATCH; + } + + public int getRuleIndex() { + return ruleIndex; + } + + public String getRuleId() { + return ruleId; + } + + @Override + public boolean equals(Object other) { + if (other instanceof RuleMatch) { + RuleMatch o = (RuleMatch)other; + return ruleIndex == o.ruleIndex && Objects.equals(ruleId, o.ruleId); + } + return false; + } + + @Override + public int hashCode() { + return Objects.hash(ruleIndex, ruleId); + } + + @Override + public String toString() { + return getKind().name() + "(" + (ruleId == null ? String.valueOf(ruleIndex) : ruleId + ")"); + } + } + + /** + * Subclass of {@link EvaluationReason} that indicates that the flag was considered off because it + * had at least one prerequisite flag that either was off or did not return the desired variation. + */ + public static class PrerequisitesFailed extends EvaluationReason { + private final ImmutableList prerequisiteKeys; + + private PrerequisitesFailed(Iterable prerequisiteKeys) { + this.prerequisiteKeys = ImmutableList.copyOf(prerequisiteKeys); + } + + public Kind getKind() { + return Kind.PREREQUISITES_FAILED; + } + + public Iterable getPrerequisiteKeys() { + return prerequisiteKeys; + } + + @Override + public boolean equals(Object other) { + if (other instanceof PrerequisitesFailed) { + PrerequisitesFailed o = (PrerequisitesFailed)other; + return prerequisiteKeys.equals(o.prerequisiteKeys); + } + return false; + } + + @Override + public int hashCode() { + return prerequisiteKeys.hashCode(); + } + + @Override + public String toString() { + return getKind().name() + "(" + Joiner.on(",").join(prerequisiteKeys) + ")"; + } + } + + /** + * Subclass of {@link EvaluationReason} that indicates that the flag was on but the user did not + * match any targets or rules. + */ + public static class Fallthrough extends EvaluationReason { + public Kind getKind() { + return Kind.FALLTHROUGH; + } + + private static final Fallthrough instance = new Fallthrough(); + } + + /** + * Subclass of {@link EvaluationReason} that indicates that the default value (passed as a parameter + * to one of the {@code variation} methods) was returned. This normally indicates an error condition. + */ + public static class Default extends EvaluationReason { + public Kind getKind() { + return Kind.DEFAULT; + } + + private static final Default instance = new Default(); + } +} diff --git a/src/main/java/com/launchdarkly/client/FeatureFlag.java b/src/main/java/com/launchdarkly/client/FeatureFlag.java index 0ab75e23c..032d75be7 100644 --- a/src/main/java/com/launchdarkly/client/FeatureFlag.java +++ b/src/main/java/com/launchdarkly/client/FeatureFlag.java @@ -2,18 +2,17 @@ import com.google.gson.JsonElement; import com.google.gson.reflect.TypeToken; -import com.launchdarkly.client.EvaluationDetails.Reason; import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import static com.launchdarkly.client.VersionedDataKind.FEATURES; - import java.lang.reflect.Type; import java.util.ArrayList; import java.util.List; import java.util.Map; +import static com.launchdarkly.client.VersionedDataKind.FEATURES; + class FeatureFlag implements VersionedData { private final static Logger logger = LoggerFactory.getLogger(FeatureFlag.class); @@ -76,16 +75,16 @@ EvalResult evaluate(LDUser user, FeatureStore featureStore, EventFactory eventFa return new EvalResult(details, prereqEvents); } - EvaluationDetails details = EvaluationDetails.off(offVariation, getOffVariationValue()); + EvaluationDetails details = new EvaluationDetails<>(EvaluationReason.off(), offVariation, getOffVariationValue()); return new EvalResult(details, prereqEvents); } // Returning either a JsonElement or null indicating prereq failure/error. private EvaluationDetails evaluate(LDUser user, FeatureStore featureStore, List events, EventFactory eventFactory) throws EvaluationException { - EvaluationDetails prereqErrorResult = checkPrerequisites(user, featureStore, events, eventFactory); - if (prereqErrorResult != null) { - return prereqErrorResult; + EvaluationReason prereqFailureReason = checkPrerequisites(user, featureStore, events, eventFactory); + if (prereqFailureReason != null) { + return new EvaluationDetails<>(prereqFailureReason, offVariation, getOffVariationValue()); } // Check to see if targets match @@ -94,8 +93,8 @@ private EvaluationDetails evaluate(LDUser user, FeatureStore featur Target target = targets.get(i); for (String v : target.getValues()) { if (v.equals(user.getKey().getAsString())) { - return new EvaluationDetails(Reason.TARGET_MATCH, - target.getVariation(), getVariation(target.getVariation()), i, null); + return new EvaluationDetails<>(EvaluationReason.targetMatch(i), + target.getVariation(), getVariation(target.getVariation())); } } } @@ -106,26 +105,26 @@ private EvaluationDetails evaluate(LDUser user, FeatureStore featur Rule rule = rules.get(i); if (rule.matchesUser(featureStore, user)) { int index = rule.variationIndexForUser(user, key, salt); - return new EvaluationDetails(Reason.RULE_MATCH, - index, getVariation(index), i, rule.getId()); + return new EvaluationDetails<>(EvaluationReason.ruleMatch(i, rule.getId()), + index, getVariation(index)); } } } // Walk through the fallthrough and see if it matches int index = fallthrough.variationIndexForUser(user, key, salt); - return EvaluationDetails.fallthrough(index, getVariation(index)); + return new EvaluationDetails<>(EvaluationReason.fallthrough(), index, getVariation(index)); } - // Checks prerequisites if any; returns null if successful, or an EvaluationDetails if we have to + // Checks prerequisites if any; returns null if successful, or an EvaluationReason if we have to // short-circuit due to a prerequisite failure. - private EvaluationDetails checkPrerequisites(LDUser user, FeatureStore featureStore, List events, + private EvaluationReason checkPrerequisites(LDUser user, FeatureStore featureStore, List events, EventFactory eventFactory) throws EvaluationException { if (prerequisites == null) { return null; } - EvaluationDetails ret = null; - boolean prereqOk = true; + List failedPrereqs = null; for (int i = 0; i < prerequisites.size(); i++) { + boolean prereqOk = true; Prerequisite prereq = prerequisites.get(i); FeatureFlag prereqFeatureFlag = featureStore.get(FEATURES, prereq.getKey()); EvaluationDetails prereqEvalResult = null; @@ -140,18 +139,21 @@ private EvaluationDetails checkPrerequisites(LDUser user, FeatureSt } else { prereqOk = false; } - // We continue to evaluate all prerequisites even if one failed, but set the result to the first failure if any. + // We continue to evaluate all prerequisites even if one failed. if (prereqFeatureFlag != null) { events.add(eventFactory.newPrerequisiteFeatureRequestEvent(prereqFeatureFlag, user, prereqEvalResult, this)); } if (!prereqOk) { - if (ret == null) { - ret = new EvaluationDetails(Reason.PREREQUISITE_FAILED, - offVariation, getOffVariationValue(), i, prereq.getKey()); + if (failedPrereqs == null) { + failedPrereqs = new ArrayList<>(); } + failedPrereqs.add(prereq.getKey()); } } - return ret; + if (failedPrereqs != null && !failedPrereqs.isEmpty()) { + return EvaluationReason.prerequisitesFailed(failedPrereqs); + } + return null; } JsonElement getOffVariationValue() throws EvaluationException { diff --git a/src/main/java/com/launchdarkly/client/LDClient.java b/src/main/java/com/launchdarkly/client/LDClient.java index 3bf8eed5f..6272d745c 100644 --- a/src/main/java/com/launchdarkly/client/LDClient.java +++ b/src/main/java/com/launchdarkly/client/LDClient.java @@ -2,7 +2,6 @@ import com.google.gson.JsonElement; import com.google.gson.JsonPrimitive; -import com.launchdarkly.client.EvaluationDetails.Reason; import org.apache.commons.codec.binary.Hex; import org.slf4j.Logger; @@ -256,7 +255,7 @@ private EvaluationDetails evaluateDetail(String featureKey, LDUser user, JsonElement defaultJson, VariationType expectedType) { EvaluationDetails details = evaluateInternal(featureKey, user, defaultJson); T resultValue; - if (details.getReason() == Reason.DEFAULT) { + if (details.getReason().getKind() == EvaluationReason.Kind.DEFAULT) { resultValue = defaultValue; } else { try { @@ -266,8 +265,7 @@ private EvaluationDetails evaluateDetail(String featureKey, LDUser user, resultValue = defaultValue; } } - return new EvaluationDetails(details.getReason(), details.getVariationIndex(), resultValue, - details.getMatchIndex(), details.getMatchId()); + return new EvaluationDetails(details.getReason(), details.getVariationIndex(), resultValue); } private EvaluationDetails evaluateInternal(String featureKey, LDUser user, JsonElement defaultValue) { diff --git a/src/test/java/com/launchdarkly/client/DefaultEventProcessorTest.java b/src/test/java/com/launchdarkly/client/DefaultEventProcessorTest.java index bb20af90d..eee257264 100644 --- a/src/test/java/com/launchdarkly/client/DefaultEventProcessorTest.java +++ b/src/test/java/com/launchdarkly/client/DefaultEventProcessorTest.java @@ -17,6 +17,7 @@ import static com.launchdarkly.client.TestUtil.hasJsonProperty; import static com.launchdarkly.client.TestUtil.isJsonArray; +import static com.launchdarkly.client.TestUtil.simpleEvaluation; import static org.hamcrest.MatcherAssert.assertThat; import static org.hamcrest.Matchers.allOf; import static org.hamcrest.Matchers.equalTo; @@ -87,7 +88,7 @@ public void individualFeatureEventIsQueuedWithIndexEvent() throws Exception { ep = new DefaultEventProcessor(SDK_KEY, configBuilder.build()); FeatureFlag flag = new FeatureFlagBuilder("flagkey").version(11).trackEvents(true).build(); Event.FeatureRequest fe = EventFactory.DEFAULT.newFeatureRequestEvent(flag, user, - EvaluationDetails.fallthrough(1, new JsonPrimitive("value")), null); + simpleEvaluation(1, new JsonPrimitive("value")), null); ep.sendEvent(fe); JsonArray output = flushAndGetEvents(new MockResponse()); @@ -105,7 +106,7 @@ public void userIsFilteredInIndexEvent() throws Exception { ep = new DefaultEventProcessor(SDK_KEY, configBuilder.build()); FeatureFlag flag = new FeatureFlagBuilder("flagkey").version(11).trackEvents(true).build(); Event.FeatureRequest fe = EventFactory.DEFAULT.newFeatureRequestEvent(flag, user, - EvaluationDetails.fallthrough(1, new JsonPrimitive("value")), null); + simpleEvaluation(1, new JsonPrimitive("value")), null); ep.sendEvent(fe); JsonArray output = flushAndGetEvents(new MockResponse()); @@ -123,7 +124,7 @@ public void featureEventCanContainInlineUser() throws Exception { ep = new DefaultEventProcessor(SDK_KEY, configBuilder.build()); FeatureFlag flag = new FeatureFlagBuilder("flagkey").version(11).trackEvents(true).build(); Event.FeatureRequest fe = EventFactory.DEFAULT.newFeatureRequestEvent(flag, user, - EvaluationDetails.fallthrough(1, new JsonPrimitive("value")), null); + simpleEvaluation(1, new JsonPrimitive("value")), null); ep.sendEvent(fe); JsonArray output = flushAndGetEvents(new MockResponse()); @@ -140,7 +141,7 @@ public void userIsFilteredInFeatureEvent() throws Exception { ep = new DefaultEventProcessor(SDK_KEY, configBuilder.build()); FeatureFlag flag = new FeatureFlagBuilder("flagkey").version(11).trackEvents(true).build(); Event.FeatureRequest fe = EventFactory.DEFAULT.newFeatureRequestEvent(flag, user, - EvaluationDetails.fallthrough(1, new JsonPrimitive("value")), null); + simpleEvaluation(1, new JsonPrimitive("value")), null); ep.sendEvent(fe); JsonArray output = flushAndGetEvents(new MockResponse()); @@ -157,7 +158,7 @@ public void indexEventIsStillGeneratedIfInlineUsersIsTrueButFeatureEventIsNotTra ep = new DefaultEventProcessor(SDK_KEY, configBuilder.build()); FeatureFlag flag = new FeatureFlagBuilder("flagkey").version(11).trackEvents(false).build(); Event.FeatureRequest fe = EventFactory.DEFAULT.newFeatureRequestEvent(flag, user, - EvaluationDetails.fallthrough(1, new JsonPrimitive("value")), null); + simpleEvaluation(1, new JsonPrimitive("value")), null); ep.sendEvent(fe); JsonArray output = flushAndGetEvents(new MockResponse()); @@ -174,7 +175,7 @@ public void eventKindIsDebugIfFlagIsTemporarilyInDebugMode() throws Exception { long futureTime = System.currentTimeMillis() + 1000000; FeatureFlag flag = new FeatureFlagBuilder("flagkey").version(11).debugEventsUntilDate(futureTime).build(); Event.FeatureRequest fe = EventFactory.DEFAULT.newFeatureRequestEvent(flag, user, - EvaluationDetails.fallthrough(1, new JsonPrimitive("value")), null); + simpleEvaluation(1, new JsonPrimitive("value")), null); ep.sendEvent(fe); JsonArray output = flushAndGetEvents(new MockResponse()); @@ -193,7 +194,7 @@ public void eventCanBeBothTrackedAndDebugged() throws Exception { FeatureFlag flag = new FeatureFlagBuilder("flagkey").version(11).trackEvents(true) .debugEventsUntilDate(futureTime).build(); Event.FeatureRequest fe = EventFactory.DEFAULT.newFeatureRequestEvent(flag, user, - EvaluationDetails.fallthrough(1, new JsonPrimitive("value")), null); + simpleEvaluation(1, new JsonPrimitive("value")), null); ep.sendEvent(fe); JsonArray output = flushAndGetEvents(new MockResponse()); @@ -222,7 +223,7 @@ public void debugModeExpiresBasedOnClientTimeIfClientTimeIsLaterThanServerTime() long debugUntil = serverTime + 1000; FeatureFlag flag = new FeatureFlagBuilder("flagkey").version(11).debugEventsUntilDate(debugUntil).build(); Event.FeatureRequest fe = EventFactory.DEFAULT.newFeatureRequestEvent(flag, user, - EvaluationDetails.fallthrough(1, new JsonPrimitive("value")), null); + simpleEvaluation(1, new JsonPrimitive("value")), null); ep.sendEvent(fe); // Should get a summary event only, not a full feature event @@ -250,7 +251,7 @@ public void debugModeExpiresBasedOnServerTimeIfServerTimeIsLaterThanClientTime() long debugUntil = serverTime - 1000; FeatureFlag flag = new FeatureFlagBuilder("flagkey").version(11).debugEventsUntilDate(debugUntil).build(); Event.FeatureRequest fe = EventFactory.DEFAULT.newFeatureRequestEvent(flag, user, - EvaluationDetails.fallthrough(1, new JsonPrimitive("value")), null); + simpleEvaluation(1, new JsonPrimitive("value")), null); ep.sendEvent(fe); // Should get a summary event only, not a full feature event @@ -269,9 +270,9 @@ public void twoFeatureEventsForSameUserGenerateOnlyOneIndexEvent() throws Except FeatureFlag flag2 = new FeatureFlagBuilder("flagkey2").version(22).trackEvents(true).build(); JsonElement value = new JsonPrimitive("value"); Event.FeatureRequest fe1 = EventFactory.DEFAULT.newFeatureRequestEvent(flag1, user, - EvaluationDetails.fallthrough(1, value), null); + simpleEvaluation(1, value), null); Event.FeatureRequest fe2 = EventFactory.DEFAULT.newFeatureRequestEvent(flag2, user, - EvaluationDetails.fallthrough(1, value), null); + simpleEvaluation(1, value), null); ep.sendEvent(fe1); ep.sendEvent(fe2); @@ -294,9 +295,9 @@ public void nonTrackedEventsAreSummarized() throws Exception { JsonElement default1 = new JsonPrimitive("default1"); JsonElement default2 = new JsonPrimitive("default2"); Event fe1 = EventFactory.DEFAULT.newFeatureRequestEvent(flag1, user, - EvaluationDetails.fallthrough(2, value), default1); + simpleEvaluation(2, value), default1); Event fe2 = EventFactory.DEFAULT.newFeatureRequestEvent(flag2, user, - EvaluationDetails.fallthrough(2, value), default2); + simpleEvaluation(2, value), default2); ep.sendEvent(fe1); ep.sendEvent(fe2); diff --git a/src/test/java/com/launchdarkly/client/EventSummarizerTest.java b/src/test/java/com/launchdarkly/client/EventSummarizerTest.java index 664fb8e56..0c101b3b8 100644 --- a/src/test/java/com/launchdarkly/client/EventSummarizerTest.java +++ b/src/test/java/com/launchdarkly/client/EventSummarizerTest.java @@ -6,6 +6,7 @@ import java.util.Map; import static com.launchdarkly.client.TestUtil.js; +import static com.launchdarkly.client.TestUtil.simpleEvaluation; import static org.hamcrest.MatcherAssert.assertThat; import static org.hamcrest.Matchers.equalTo; import static org.junit.Assert.assertEquals; @@ -65,13 +66,13 @@ public void summarizeEventIncrementsCounters() { FeatureFlag flag2 = new FeatureFlagBuilder("key2").version(22).build(); String unknownFlagKey = "badkey"; Event event1 = eventFactory.newFeatureRequestEvent(flag1, user, - EvaluationDetails.fallthrough(1, js("value1")), js("default1")); + simpleEvaluation(1, js("value1")), js("default1")); Event event2 = eventFactory.newFeatureRequestEvent(flag1, user, - EvaluationDetails.fallthrough(2, js("value2")), js("default1")); + simpleEvaluation(2, js("value2")), js("default1")); Event event3 = eventFactory.newFeatureRequestEvent(flag2, user, - EvaluationDetails.fallthrough(1, js("value99")), js("default2")); + simpleEvaluation(1, js("value99")), js("default2")); Event event4 = eventFactory.newFeatureRequestEvent(flag1, user, - EvaluationDetails.fallthrough(1, js("value1")), js("default1")); + simpleEvaluation(1, js("value1")), js("default1")); Event event5 = eventFactory.newUnknownFeatureRequestEvent(unknownFlagKey, user, js("default3")); es.summarizeEvent(event1); es.summarizeEvent(event2); diff --git a/src/test/java/com/launchdarkly/client/FeatureFlagTest.java b/src/test/java/com/launchdarkly/client/FeatureFlagTest.java index be062eb47..530daaf77 100644 --- a/src/test/java/com/launchdarkly/client/FeatureFlagTest.java +++ b/src/test/java/com/launchdarkly/client/FeatureFlagTest.java @@ -1,8 +1,8 @@ package com.launchdarkly.client; +import com.google.common.collect.ImmutableList; import com.google.gson.Gson; import com.google.gson.JsonElement; -import com.launchdarkly.client.EvaluationDetails.Reason; import org.junit.Before; import org.junit.Test; @@ -40,7 +40,7 @@ public void flagReturnsOffVariationIfFlagIsOff() throws Exception { .build(); FeatureFlag.EvalResult result = f.evaluate(BASE_USER, featureStore, EventFactory.DEFAULT); - assertEquals(EvaluationDetails.off(1, js("off")), result.getDetails()); + assertEquals(new EvaluationDetails<>(EvaluationReason.off(), 1, js("off")), result.getDetails()); assertEquals(0, result.getPrerequisiteEvents().size()); } @@ -53,7 +53,7 @@ public void flagReturnsNullIfFlagIsOffAndOffVariationIsUnspecified() throws Exce .build(); FeatureFlag.EvalResult result = f.evaluate(BASE_USER, featureStore, EventFactory.DEFAULT); - assertEquals(EvaluationDetails.off(null, null), result.getDetails()); + assertEquals(new EvaluationDetails<>(EvaluationReason.off(), null, null), result.getDetails()); assertEquals(0, result.getPrerequisiteEvents().size()); } @@ -68,7 +68,8 @@ public void flagReturnsOffVariationIfPrerequisiteIsNotFound() throws Exception { .build(); FeatureFlag.EvalResult result = f0.evaluate(BASE_USER, featureStore, EventFactory.DEFAULT); - assertEquals(new EvaluationDetails(Reason.PREREQUISITE_FAILED, 1, js("off"), 0, "feature1"), result.getDetails()); + EvaluationReason expectedReason = EvaluationReason.prerequisitesFailed(ImmutableList.of("feature1")); + assertEquals(new EvaluationDetails<>(expectedReason, 1, js("off")), result.getDetails()); assertEquals(0, result.getPrerequisiteEvents().size()); } @@ -91,7 +92,8 @@ public void flagReturnsOffVariationAndEventIfPrerequisiteIsNotMet() throws Excep featureStore.upsert(FEATURES, f1); FeatureFlag.EvalResult result = f0.evaluate(BASE_USER, featureStore, EventFactory.DEFAULT); - assertEquals(new EvaluationDetails(Reason.PREREQUISITE_FAILED, 1, js("off"), 0, "feature1"), result.getDetails()); + EvaluationReason expectedReason = EvaluationReason.prerequisitesFailed(ImmutableList.of("feature1")); + assertEquals(new EvaluationDetails<>(expectedReason, 1, js("off")), result.getDetails()); assertEquals(1, result.getPrerequisiteEvents().size()); Event.FeatureRequest event = result.getPrerequisiteEvents().get(0); @@ -120,7 +122,7 @@ public void flagReturnsFallthroughVariationAndEventIfPrerequisiteIsMetAndThereAr featureStore.upsert(FEATURES, f1); FeatureFlag.EvalResult result = f0.evaluate(BASE_USER, featureStore, EventFactory.DEFAULT); - assertEquals(EvaluationDetails.fallthrough(0, js("fall")), result.getDetails()); + assertEquals(new EvaluationDetails<>(EvaluationReason.fallthrough(), 0, js("fall")), result.getDetails()); assertEquals(1, result.getPrerequisiteEvents().size()); Event.FeatureRequest event = result.getPrerequisiteEvents().get(0); @@ -157,7 +159,7 @@ public void multipleLevelsOfPrerequisitesProduceMultipleEvents() throws Exceptio featureStore.upsert(FEATURES, f2); FeatureFlag.EvalResult result = f0.evaluate(BASE_USER, featureStore, EventFactory.DEFAULT); - assertEquals(EvaluationDetails.fallthrough(0, js("fall")), result.getDetails()); + assertEquals(new EvaluationDetails<>(EvaluationReason.fallthrough(), 0, js("fall")), result.getDetails()); assertEquals(2, result.getPrerequisiteEvents().size()); Event.FeatureRequest event0 = result.getPrerequisiteEvents().get(0); @@ -173,6 +175,44 @@ public void multipleLevelsOfPrerequisitesProduceMultipleEvents() throws Exceptio assertEquals(f0.getKey(), event1.prereqOf); } + @Test + public void multiplePrerequisiteFailuresAreAllRecorded() throws Exception { + FeatureFlag f0 = new FeatureFlagBuilder("feature0") + .on(true) + .prerequisites(Arrays.asList(new Prerequisite("feature1", 0), new Prerequisite("feature2", 0))) + .fallthrough(fallthroughVariation(0)) + .offVariation(1) + .variations(js("fall"), js("off"), js("on")) + .version(1) + .build(); + FeatureFlag f1 = new FeatureFlagBuilder("feature1") + .on(true) + .fallthrough(fallthroughVariation(1)) + .variations(js("nogo"), js("go")) + .version(2) + .build(); + FeatureFlag f2 = new FeatureFlagBuilder("feature2") + .on(true) + .fallthrough(fallthroughVariation(1)) + .variations(js("nogo"), js("go")) + .version(3) + .build(); + featureStore.upsert(FEATURES, f1); + featureStore.upsert(FEATURES, f2); + FeatureFlag.EvalResult result = f0.evaluate(BASE_USER, featureStore, EventFactory.DEFAULT); + + EvaluationReason expectedReason = EvaluationReason.prerequisitesFailed(ImmutableList.of("feature1", "feature2")); + assertEquals(new EvaluationDetails<>(expectedReason, 1, js("off")), result.getDetails()); + + assertEquals(2, result.getPrerequisiteEvents().size()); + + Event.FeatureRequest event0 = result.getPrerequisiteEvents().get(0); + assertEquals(f1.getKey(), event0.key); + + Event.FeatureRequest event1 = result.getPrerequisiteEvents().get(1); + assertEquals(f2.getKey(), event1.key); + } + @Test public void flagMatchesUserFromTargets() throws Exception { FeatureFlag f = new FeatureFlagBuilder("feature") @@ -185,7 +225,7 @@ public void flagMatchesUserFromTargets() throws Exception { LDUser user = new LDUser.Builder("userkey").build(); FeatureFlag.EvalResult result = f.evaluate(user, featureStore, EventFactory.DEFAULT); - assertEquals(new EvaluationDetails(Reason.TARGET_MATCH, 2, js("on"), 0, null), result.getDetails()); + assertEquals(new EvaluationDetails<>(EvaluationReason.targetMatch(0), 2, js("on")), result.getDetails()); assertEquals(0, result.getPrerequisiteEvents().size()); } @@ -203,7 +243,7 @@ public void flagMatchesUserFromRules() throws Exception { LDUser user = new LDUser.Builder("userkey").build(); FeatureFlag.EvalResult result = f.evaluate(user, featureStore, EventFactory.DEFAULT); - assertEquals(new EvaluationDetails(Reason.RULE_MATCH, 2, js("on"), 0, "ruleid"), result.getDetails()); + assertEquals(new EvaluationDetails<>(EvaluationReason.ruleMatch(0, "ruleid"), 2, js("on")), result.getDetails()); assertEquals(0, result.getPrerequisiteEvents().size()); } @@ -282,8 +322,8 @@ public void clauseWithNullOperatorDoesNotStopSubsequentRuleFromMatching() throws .build(); LDUser user = new LDUser.Builder("key").name("Bob").build(); - EvaluationDetails details = f.evaluate(user, featureStore, EventFactory.DEFAULT).getDetails(); - assertEquals(new EvaluationDetails(Reason.RULE_MATCH, 1, jbool(true), 1, "rule2"), details); + EvaluationDetails details = f.evaluate(user, featureStore, EventFactory.DEFAULT).getDetails(); + assertEquals(new EvaluationDetails<>(EvaluationReason.ruleMatch(1, "rule2"), 1, jbool(true)), details); } @Test diff --git a/src/test/java/com/launchdarkly/client/TestUtil.java b/src/test/java/com/launchdarkly/client/TestUtil.java index 494586c26..a092eaabe 100644 --- a/src/test/java/com/launchdarkly/client/TestUtil.java +++ b/src/test/java/com/launchdarkly/client/TestUtil.java @@ -103,6 +103,10 @@ public static FeatureFlag flagWithValue(String key, JsonElement value) { .build(); } + public static EvaluationDetails simpleEvaluation(int variation, JsonElement value) { + return new EvaluationDetails<>(EvaluationReason.fallthrough(), 0, value); + } + public static Matcher hasJsonProperty(final String name, JsonElement value) { return hasJsonProperty(name, equalTo(value)); } From 1874e200c1b2b24685273d7603bfeb2ab2e1428f Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Fri, 29 Jun 2018 16:15:23 -0700 Subject: [PATCH 04/43] fix tests --- src/test/java/com/launchdarkly/client/TestUtil.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/test/java/com/launchdarkly/client/TestUtil.java b/src/test/java/com/launchdarkly/client/TestUtil.java index a092eaabe..c36aa6cf0 100644 --- a/src/test/java/com/launchdarkly/client/TestUtil.java +++ b/src/test/java/com/launchdarkly/client/TestUtil.java @@ -104,7 +104,7 @@ public static FeatureFlag flagWithValue(String key, JsonElement value) { } public static EvaluationDetails simpleEvaluation(int variation, JsonElement value) { - return new EvaluationDetails<>(EvaluationReason.fallthrough(), 0, value); + return new EvaluationDetails<>(EvaluationReason.fallthrough(), variation, value); } public static Matcher hasJsonProperty(final String name, JsonElement value) { From e694ab47f37028a45156841f41e4fea4bc655ac7 Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Fri, 29 Jun 2018 17:28:44 -0700 Subject: [PATCH 05/43] misc refactoring & tests --- ...tionDetails.java => EvaluationDetail.java} | 16 +-- .../launchdarkly/client/EvaluationReason.java | 119 ++++++++++++------ .../com/launchdarkly/client/EventFactory.java | 4 +- .../com/launchdarkly/client/FeatureFlag.java | 30 ++--- .../com/launchdarkly/client/LDClient.java | 41 +++--- .../client/LDClientInterface.java | 20 +-- .../launchdarkly/client/FeatureFlagTest.java | 22 ++-- .../client/LDClientEvaluationTest.java | 62 +++++++++ .../com/launchdarkly/client/TestUtil.java | 55 +++++++- 9 files changed, 259 insertions(+), 110 deletions(-) rename src/main/java/com/launchdarkly/client/{EvaluationDetails.java => EvaluationDetail.java} (77%) diff --git a/src/main/java/com/launchdarkly/client/EvaluationDetails.java b/src/main/java/com/launchdarkly/client/EvaluationDetail.java similarity index 77% rename from src/main/java/com/launchdarkly/client/EvaluationDetails.java rename to src/main/java/com/launchdarkly/client/EvaluationDetail.java index 86abb7578..83eecfe89 100644 --- a/src/main/java/com/launchdarkly/client/EvaluationDetails.java +++ b/src/main/java/com/launchdarkly/client/EvaluationDetail.java @@ -2,26 +2,28 @@ import com.google.common.base.Objects; +import static com.google.common.base.Preconditions.checkNotNull; + /** * An object returned by the "variation detail" methods such as {@link LDClientInterface#boolVariationDetails(String, LDUser, boolean), * combining the result of a flag evaluation with an explanation of how it was calculated. * @since 4.3.0 */ -public class EvaluationDetails { +public class EvaluationDetail { private final EvaluationReason reason; private final Integer variationIndex; private final T value; - public EvaluationDetails(EvaluationReason reason, Integer variationIndex, T value) { - super(); + public EvaluationDetail(EvaluationReason reason, Integer variationIndex, T value) { + checkNotNull(reason); this.reason = reason; this.variationIndex = variationIndex; this.value = value; } - static EvaluationDetails defaultValue(T value) { - return new EvaluationDetails<>(EvaluationReason.defaultValue(), null, value); + static EvaluationDetail error(EvaluationReason.ErrorKind errorKind, T defaultValue) { + return new EvaluationDetail<>(EvaluationReason.error(errorKind), null, defaultValue); } /** @@ -52,9 +54,9 @@ public T getValue() { @Override public boolean equals(Object other) { - if (other instanceof EvaluationDetails) { + if (other instanceof EvaluationDetail) { @SuppressWarnings("unchecked") - EvaluationDetails o = (EvaluationDetails)other; + EvaluationDetail o = (EvaluationDetail)other; return Objects.equal(reason, o.reason) && variationIndex == o.variationIndex && Objects.equal(value, o.value); } return false; diff --git a/src/main/java/com/launchdarkly/client/EvaluationReason.java b/src/main/java/com/launchdarkly/client/EvaluationReason.java index 592c880be..a1eb6936e 100644 --- a/src/main/java/com/launchdarkly/client/EvaluationReason.java +++ b/src/main/java/com/launchdarkly/client/EvaluationReason.java @@ -5,6 +5,8 @@ import java.util.Objects; +import static com.google.common.base.Preconditions.checkNotNull; + /** * Describes the reason that a flag evaluation produced a particular value. This is returned by * methods such as {@link LDClientInterface#boolVariationDetails(String, LDUser, boolean). @@ -16,6 +18,10 @@ */ public abstract class EvaluationReason { + /** + * Enumerated type defining the possible values of {@link EvaluationReason#getKind()}. + * @since 4.3.0 + */ public static enum Kind { /** * Indicates that the flag was off and therefore returned its configured off value. @@ -39,10 +45,38 @@ public static enum Kind { */ FALLTHROUGH, /** - * Indicates that the default value (passed as a parameter to one of the {@code variation} methods) - * was returned. This normally indicates an error condition. + * Indicates that the flag could not be evaluated, e.g. because it does not exist or due to an unexpected + * error. In this case the result value will be the default value that the caller passed to the client. + */ + ERROR; + } + + /** + * Enumerated type defining the possible values of {@link EvaluationReason.Error#getErrorKind()}. + * @since 4.3.0 + */ + public static enum ErrorKind { + /** + * Indicates that the caller tried to evaluate a flag before the client had successfully initialized. + */ + CLIENT_NOT_READY, + /** + * Indicates that the caller provided a flag key that did not match any known flag. + */ + FLAG_NOT_FOUND, + /** + * Indicates that the caller passed {@code null} for the user parameter, or the user lacked a key. */ - DEFAULT; + USER_NOT_SPECIFIED, + /** + * Indicates that the result value was not of the requested type, e.g. you called + * {@link LDClientInterface#boolVariationDetail(String, LDUser, boolean)} but the value was an integer. + */ + WRONG_TYPE, + /** + * Indicates that an unexpected exception stopped flag evaluation; check the log for details. + */ + EXCEPTION } /** @@ -68,8 +102,8 @@ public static Off off() { /** * Returns an instance of {@link TargetMatch}. */ - public static TargetMatch targetMatch(int targetIndex) { - return new TargetMatch(targetIndex); + public static TargetMatch targetMatch() { + return TargetMatch.instance; } /** @@ -94,15 +128,16 @@ public static Fallthrough fallthrough() { } /** - * Returns an instance of {@link Default}. + * Returns an instance of {@link Error}. */ - public static Default defaultValue() { - return Default.instance; + public static Error error(ErrorKind errorKind) { + return new Error(errorKind); } /** * Subclass of {@link EvaluationReason} that indicates that the flag was off and therefore returned * its configured off value. + * @since 4.3.0 */ public static class Off extends EvaluationReason { public Kind getKind() { @@ -115,44 +150,19 @@ public Kind getKind() { /** * Subclass of {@link EvaluationReason} that indicates that the user key was specifically targeted * for this flag. + * @since 4.3.0 */ public static class TargetMatch extends EvaluationReason { - private final int targetIndex; - - private TargetMatch(int targetIndex) { - this.targetIndex = targetIndex; - } - public Kind getKind() { return Kind.TARGET_MATCH; } - public int getTargetIndex() { - return targetIndex; - } - - @Override - public boolean equals(Object other) { - if (other instanceof TargetMatch) { - TargetMatch o = (TargetMatch)other; - return targetIndex == o.targetIndex; - } - return false; - } - - @Override - public int hashCode() { - return targetIndex; - } - - @Override - public String toString() { - return getKind().name() + "(" + targetIndex + ")"; - } + private static final TargetMatch instance = new TargetMatch(); } /** * Subclass of {@link EvaluationReason} that indicates that the user matched one of the flag's rules. + * @since 4.3.0 */ public static class RuleMatch extends EvaluationReason { private final int ruleIndex; @@ -198,11 +208,13 @@ public String toString() { /** * Subclass of {@link EvaluationReason} that indicates that the flag was considered off because it * had at least one prerequisite flag that either was off or did not return the desired variation. + * @since 4.3.0 */ public static class PrerequisitesFailed extends EvaluationReason { private final ImmutableList prerequisiteKeys; private PrerequisitesFailed(Iterable prerequisiteKeys) { + checkNotNull(prerequisiteKeys); this.prerequisiteKeys = ImmutableList.copyOf(prerequisiteKeys); } @@ -237,6 +249,7 @@ public String toString() { /** * Subclass of {@link EvaluationReason} that indicates that the flag was on but the user did not * match any targets or rules. + * @since 4.3.0 */ public static class Fallthrough extends EvaluationReason { public Kind getKind() { @@ -247,14 +260,38 @@ public Kind getKind() { } /** - * Subclass of {@link EvaluationReason} that indicates that the default value (passed as a parameter - * to one of the {@code variation} methods) was returned. This normally indicates an error condition. + * Subclass of {@link EvaluationReason} that indicates that the flag could not be evaluated. + * @since 4.3.0 */ - public static class Default extends EvaluationReason { + public static class Error extends EvaluationReason { + private final ErrorKind errorKind; + + private Error(ErrorKind errorKind) { + checkNotNull(errorKind); + this.errorKind = errorKind; + } + public Kind getKind() { - return Kind.DEFAULT; + return Kind.ERROR; } - private static final Default instance = new Default(); + public ErrorKind getErrorKind() { + return errorKind; + } + + @Override + public boolean equals(Object other) { + return other instanceof Error && errorKind == ((Error) other).errorKind; + } + + @Override + public int hashCode() { + return errorKind.hashCode(); + } + + @Override + public String toString() { + return getKind().name() + "(" + errorKind.name() + ")"; + } } } diff --git a/src/main/java/com/launchdarkly/client/EventFactory.java b/src/main/java/com/launchdarkly/client/EventFactory.java index 345619b0b..c68468f0b 100644 --- a/src/main/java/com/launchdarkly/client/EventFactory.java +++ b/src/main/java/com/launchdarkly/client/EventFactory.java @@ -7,7 +7,7 @@ abstract class EventFactory { protected abstract long getTimestamp(); - public Event.FeatureRequest newFeatureRequestEvent(FeatureFlag flag, LDUser user, EvaluationDetails result, JsonElement defaultVal) { + public Event.FeatureRequest newFeatureRequestEvent(FeatureFlag flag, LDUser user, EvaluationDetail result, JsonElement defaultVal) { return new Event.FeatureRequest(getTimestamp(), flag.getKey(), user, flag.getVersion(), result == null ? null : result.getVariationIndex(), result == null ? null : result.getValue(), defaultVal, null, flag.isTrackEvents(), flag.getDebugEventsUntilDate(), false); @@ -22,7 +22,7 @@ public Event.FeatureRequest newUnknownFeatureRequestEvent(String key, LDUser use return new Event.FeatureRequest(getTimestamp(), key, user, null, null, defaultValue, defaultValue, null, false, null, false); } - public Event.FeatureRequest newPrerequisiteFeatureRequestEvent(FeatureFlag prereqFlag, LDUser user, EvaluationDetails result, + public Event.FeatureRequest newPrerequisiteFeatureRequestEvent(FeatureFlag prereqFlag, LDUser user, EvaluationDetail result, FeatureFlag prereqOf) { return new Event.FeatureRequest(getTimestamp(), prereqFlag.getKey(), user, prereqFlag.getVersion(), result == null ? null : result.getVariationIndex(), result == null ? null : result.getValue(), diff --git a/src/main/java/com/launchdarkly/client/FeatureFlag.java b/src/main/java/com/launchdarkly/client/FeatureFlag.java index 032d75be7..5a64f4b01 100644 --- a/src/main/java/com/launchdarkly/client/FeatureFlag.java +++ b/src/main/java/com/launchdarkly/client/FeatureFlag.java @@ -11,6 +11,7 @@ import java.util.List; import java.util.Map; +import static com.google.common.base.Preconditions.checkNotNull; import static com.launchdarkly.client.VersionedDataKind.FEATURES; class FeatureFlag implements VersionedData { @@ -66,25 +67,24 @@ EvalResult evaluate(LDUser user, FeatureStore featureStore, EventFactory eventFa List prereqEvents = new ArrayList<>(); if (user == null || user.getKey() == null) { - logger.warn("Null user or null user key when evaluating flag: " + key + "; returning null"); - return new EvalResult(null, prereqEvents); + // this should have been prevented by LDClient.evaluateInternal + throw new EvaluationException("null user or null user key"); } if (isOn()) { - EvaluationDetails details = evaluate(user, featureStore, prereqEvents, eventFactory); + EvaluationDetail details = evaluate(user, featureStore, prereqEvents, eventFactory); return new EvalResult(details, prereqEvents); } - EvaluationDetails details = new EvaluationDetails<>(EvaluationReason.off(), offVariation, getOffVariationValue()); + EvaluationDetail details = new EvaluationDetail<>(EvaluationReason.off(), offVariation, getOffVariationValue()); return new EvalResult(details, prereqEvents); } - // Returning either a JsonElement or null indicating prereq failure/error. - private EvaluationDetails evaluate(LDUser user, FeatureStore featureStore, List events, + private EvaluationDetail evaluate(LDUser user, FeatureStore featureStore, List events, EventFactory eventFactory) throws EvaluationException { EvaluationReason prereqFailureReason = checkPrerequisites(user, featureStore, events, eventFactory); if (prereqFailureReason != null) { - return new EvaluationDetails<>(prereqFailureReason, offVariation, getOffVariationValue()); + return new EvaluationDetail<>(prereqFailureReason, offVariation, getOffVariationValue()); } // Check to see if targets match @@ -93,7 +93,7 @@ private EvaluationDetails evaluate(LDUser user, FeatureStore featur Target target = targets.get(i); for (String v : target.getValues()) { if (v.equals(user.getKey().getAsString())) { - return new EvaluationDetails<>(EvaluationReason.targetMatch(i), + return new EvaluationDetail<>(EvaluationReason.targetMatch(), target.getVariation(), getVariation(target.getVariation())); } } @@ -105,14 +105,14 @@ private EvaluationDetails evaluate(LDUser user, FeatureStore featur Rule rule = rules.get(i); if (rule.matchesUser(featureStore, user)) { int index = rule.variationIndexForUser(user, key, salt); - return new EvaluationDetails<>(EvaluationReason.ruleMatch(i, rule.getId()), + return new EvaluationDetail<>(EvaluationReason.ruleMatch(i, rule.getId()), index, getVariation(index)); } } } // Walk through the fallthrough and see if it matches int index = fallthrough.variationIndexForUser(user, key, salt); - return new EvaluationDetails<>(EvaluationReason.fallthrough(), index, getVariation(index)); + return new EvaluationDetail<>(EvaluationReason.fallthrough(), index, getVariation(index)); } // Checks prerequisites if any; returns null if successful, or an EvaluationReason if we have to @@ -127,7 +127,7 @@ private EvaluationReason checkPrerequisites(LDUser user, FeatureStore featureSto boolean prereqOk = true; Prerequisite prereq = prerequisites.get(i); FeatureFlag prereqFeatureFlag = featureStore.get(FEATURES, prereq.getKey()); - EvaluationDetails prereqEvalResult = null; + EvaluationDetail prereqEvalResult = null; if (prereqFeatureFlag == null) { logger.error("Could not retrieve prerequisite flag: " + prereq.getKey() + " when evaluating: " + key); prereqOk = false; @@ -237,15 +237,17 @@ Integer getOffVariation() { } static class EvalResult { - private final EvaluationDetails details; + private final EvaluationDetail details; private final List prerequisiteEvents; - private EvalResult(EvaluationDetails details, List prerequisiteEvents) { + private EvalResult(EvaluationDetail details, List prerequisiteEvents) { + checkNotNull(details); + checkNotNull(prerequisiteEvents); this.details = details; this.prerequisiteEvents = prerequisiteEvents; } - EvaluationDetails getDetails() { + EvaluationDetail getDetails() { return details; } diff --git a/src/main/java/com/launchdarkly/client/LDClient.java b/src/main/java/com/launchdarkly/client/LDClient.java index 6272d745c..6827585f1 100644 --- a/src/main/java/com/launchdarkly/client/LDClient.java +++ b/src/main/java/com/launchdarkly/client/LDClient.java @@ -201,27 +201,27 @@ public JsonElement jsonVariation(String featureKey, LDUser user, JsonElement def } @Override - public EvaluationDetails boolVariationDetails(String featureKey, LDUser user, boolean defaultValue) { + public EvaluationDetail boolVariationDetail(String featureKey, LDUser user, boolean defaultValue) { return evaluateDetail(featureKey, user, defaultValue, new JsonPrimitive(defaultValue), VariationType.Boolean); } @Override - public EvaluationDetails intVariationDetails(String featureKey, LDUser user, int defaultValue) { + public EvaluationDetail intVariationDetail(String featureKey, LDUser user, int defaultValue) { return evaluateDetail(featureKey, user, defaultValue, new JsonPrimitive(defaultValue), VariationType.Integer); } @Override - public EvaluationDetails doubleVariationDetails(String featureKey, LDUser user, double defaultValue) { + public EvaluationDetail doubleVariationDetail(String featureKey, LDUser user, double defaultValue) { return evaluateDetail(featureKey, user, defaultValue, new JsonPrimitive(defaultValue), VariationType.Double); } @Override - public EvaluationDetails stringVariationDetails(String featureKey, LDUser user, String defaultValue) { + public EvaluationDetail stringVariationDetail(String featureKey, LDUser user, String defaultValue) { return evaluateDetail(featureKey, user, defaultValue, new JsonPrimitive(defaultValue), VariationType.String); } @Override - public EvaluationDetails jsonVariationDetails(String featureKey, LDUser user, JsonElement defaultValue) { + public EvaluationDetail jsonVariationDetail(String featureKey, LDUser user, JsonElement defaultValue) { return evaluateDetail(featureKey, user, defaultValue, defaultValue, VariationType.Json); } @@ -251,31 +251,31 @@ private T evaluate(String featureKey, LDUser user, T defaultValue, JsonEleme return evaluateDetail(featureKey, user, defaultValue, defaultJson, expectedType).getValue(); } - private EvaluationDetails evaluateDetail(String featureKey, LDUser user, T defaultValue, + private EvaluationDetail evaluateDetail(String featureKey, LDUser user, T defaultValue, JsonElement defaultJson, VariationType expectedType) { - EvaluationDetails details = evaluateInternal(featureKey, user, defaultJson); + EvaluationDetail details = evaluateInternal(featureKey, user, defaultJson); T resultValue; - if (details.getReason().getKind() == EvaluationReason.Kind.DEFAULT) { + if (details.getReason().getKind() == EvaluationReason.Kind.ERROR) { resultValue = defaultValue; } else { try { resultValue = expectedType.coerceValue(details.getValue()); } catch (EvaluationException e) { logger.error("Encountered exception in LaunchDarkly client: " + e); - resultValue = defaultValue; + return EvaluationDetail.error(EvaluationReason.ErrorKind.WRONG_TYPE, defaultValue); } } - return new EvaluationDetails(details.getReason(), details.getVariationIndex(), resultValue); + return new EvaluationDetail(details.getReason(), details.getVariationIndex(), resultValue); } - private EvaluationDetails evaluateInternal(String featureKey, LDUser user, JsonElement defaultValue) { + private EvaluationDetail evaluateInternal(String featureKey, LDUser user, JsonElement defaultValue) { if (!initialized()) { if (featureStore.initialized()) { logger.warn("Evaluation called before client initialized for feature flag " + featureKey + "; using last known values from feature store"); } else { logger.warn("Evaluation called before client initialized for feature flag " + featureKey + "; feature store unavailable, returning default value"); sendFlagRequestEvent(eventFactory.newUnknownFeatureRequestEvent(featureKey, user, defaultValue)); - return EvaluationDetails.defaultValue(defaultValue); + return EvaluationDetail.error(EvaluationReason.ErrorKind.CLIENT_NOT_READY, defaultValue); } } @@ -284,12 +284,12 @@ private EvaluationDetails evaluateInternal(String featureKey, LDUse if (featureFlag == null) { logger.info("Unknown feature flag " + featureKey + "; returning default value"); sendFlagRequestEvent(eventFactory.newUnknownFeatureRequestEvent(featureKey, user, defaultValue)); - return EvaluationDetails.defaultValue(defaultValue); + return EvaluationDetail.error(EvaluationReason.ErrorKind.FLAG_NOT_FOUND, defaultValue); } if (user == null || user.getKey() == null) { logger.warn("Null user or null user key when evaluating flag: " + featureKey + "; returning default value"); sendFlagRequestEvent(eventFactory.newDefaultFeatureRequestEvent(featureFlag, user, defaultValue)); - return EvaluationDetails.defaultValue(defaultValue); + return EvaluationDetail.error(EvaluationReason.ErrorKind.USER_NOT_SPECIFIED, defaultValue); } if (user.getKeyAsString().isEmpty()) { logger.warn("User key is blank. Flag evaluation will proceed, but the user will not be stored in LaunchDarkly"); @@ -298,19 +298,14 @@ private EvaluationDetails evaluateInternal(String featureKey, LDUse for (Event.FeatureRequest event : evalResult.getPrerequisiteEvents()) { eventProcessor.sendEvent(event); } - if (evalResult.getDetails() != null && evalResult.getDetails().getValue() != null) { - sendFlagRequestEvent(eventFactory.newFeatureRequestEvent(featureFlag, user, evalResult.getDetails(), defaultValue)); - return evalResult.getDetails(); - } else { - sendFlagRequestEvent(eventFactory.newDefaultFeatureRequestEvent(featureFlag, user, defaultValue)); - return EvaluationDetails.defaultValue(defaultValue); - } + sendFlagRequestEvent(eventFactory.newFeatureRequestEvent(featureFlag, user, evalResult.getDetails(), defaultValue)); + return evalResult.getDetails(); } catch (Exception e) { logger.error("Encountered exception in LaunchDarkly client: " + e); logger.debug(e.getMessage(), e); + sendFlagRequestEvent(eventFactory.newUnknownFeatureRequestEvent(featureKey, user, defaultValue)); + return EvaluationDetail.error(EvaluationReason.ErrorKind.EXCEPTION, defaultValue); } - sendFlagRequestEvent(eventFactory.newUnknownFeatureRequestEvent(featureKey, user, defaultValue)); - return EvaluationDetails.defaultValue(defaultValue); } @Override diff --git a/src/main/java/com/launchdarkly/client/LDClientInterface.java b/src/main/java/com/launchdarkly/client/LDClientInterface.java index c5fb4280a..f1d984d86 100644 --- a/src/main/java/com/launchdarkly/client/LDClientInterface.java +++ b/src/main/java/com/launchdarkly/client/LDClientInterface.java @@ -105,10 +105,10 @@ public interface LDClientInterface extends Closeable { * @param featureKey the unique key for the feature flag * @param user the end user requesting the flag * @param defaultValue the default value of the flag - * @return an {@link EvaluationDetails} object + * @return an {@link EvaluationDetail} object * @since 2.3.0 */ - EvaluationDetails boolVariationDetails(String featureKey, LDUser user, boolean defaultValue); + EvaluationDetail boolVariationDetail(String featureKey, LDUser user, boolean defaultValue); /** * Calculates the value of a feature flag for a given user, and returns an object that describes the @@ -116,10 +116,10 @@ public interface LDClientInterface extends Closeable { * @param featureKey the unique key for the feature flag * @param user the end user requesting the flag * @param defaultValue the default value of the flag - * @return an {@link EvaluationDetails} object + * @return an {@link EvaluationDetail} object * @since 2.3.0 */ - EvaluationDetails intVariationDetails(String featureKey, LDUser user, int defaultValue); + EvaluationDetail intVariationDetail(String featureKey, LDUser user, int defaultValue); /** * Calculates the value of a feature flag for a given user, and returns an object that describes the @@ -127,10 +127,10 @@ public interface LDClientInterface extends Closeable { * @param featureKey the unique key for the feature flag * @param user the end user requesting the flag * @param defaultValue the default value of the flag - * @return an {@link EvaluationDetails} object + * @return an {@link EvaluationDetail} object * @since 2.3.0 */ - EvaluationDetails doubleVariationDetails(String featureKey, LDUser user, double defaultValue); + EvaluationDetail doubleVariationDetail(String featureKey, LDUser user, double defaultValue); /** * Calculates the value of a feature flag for a given user, and returns an object that describes the @@ -138,10 +138,10 @@ public interface LDClientInterface extends Closeable { * @param featureKey the unique key for the feature flag * @param user the end user requesting the flag * @param defaultValue the default value of the flag - * @return an {@link EvaluationDetails} object + * @return an {@link EvaluationDetail} object * @since 2.3.0 */ - EvaluationDetails stringVariationDetails(String featureKey, LDUser user, String defaultValue); + EvaluationDetail stringVariationDetail(String featureKey, LDUser user, String defaultValue); /** * Calculates the value of a feature flag for a given user, and returns an object that describes the @@ -149,10 +149,10 @@ public interface LDClientInterface extends Closeable { * @param featureKey the unique key for the feature flag * @param user the end user requesting the flag * @param defaultValue the default value of the flag - * @return an {@link EvaluationDetails} object + * @return an {@link EvaluationDetail} object * @since 2.3.0 */ - EvaluationDetails jsonVariationDetails(String featureKey, LDUser user, JsonElement defaultValue); + EvaluationDetail jsonVariationDetail(String featureKey, LDUser user, JsonElement defaultValue); /** * Returns true if the specified feature flag currently exists. diff --git a/src/test/java/com/launchdarkly/client/FeatureFlagTest.java b/src/test/java/com/launchdarkly/client/FeatureFlagTest.java index 530daaf77..ac32c3a89 100644 --- a/src/test/java/com/launchdarkly/client/FeatureFlagTest.java +++ b/src/test/java/com/launchdarkly/client/FeatureFlagTest.java @@ -40,7 +40,7 @@ public void flagReturnsOffVariationIfFlagIsOff() throws Exception { .build(); FeatureFlag.EvalResult result = f.evaluate(BASE_USER, featureStore, EventFactory.DEFAULT); - assertEquals(new EvaluationDetails<>(EvaluationReason.off(), 1, js("off")), result.getDetails()); + assertEquals(new EvaluationDetail<>(EvaluationReason.off(), 1, js("off")), result.getDetails()); assertEquals(0, result.getPrerequisiteEvents().size()); } @@ -53,7 +53,7 @@ public void flagReturnsNullIfFlagIsOffAndOffVariationIsUnspecified() throws Exce .build(); FeatureFlag.EvalResult result = f.evaluate(BASE_USER, featureStore, EventFactory.DEFAULT); - assertEquals(new EvaluationDetails<>(EvaluationReason.off(), null, null), result.getDetails()); + assertEquals(new EvaluationDetail<>(EvaluationReason.off(), null, null), result.getDetails()); assertEquals(0, result.getPrerequisiteEvents().size()); } @@ -69,7 +69,7 @@ public void flagReturnsOffVariationIfPrerequisiteIsNotFound() throws Exception { FeatureFlag.EvalResult result = f0.evaluate(BASE_USER, featureStore, EventFactory.DEFAULT); EvaluationReason expectedReason = EvaluationReason.prerequisitesFailed(ImmutableList.of("feature1")); - assertEquals(new EvaluationDetails<>(expectedReason, 1, js("off")), result.getDetails()); + assertEquals(new EvaluationDetail<>(expectedReason, 1, js("off")), result.getDetails()); assertEquals(0, result.getPrerequisiteEvents().size()); } @@ -93,7 +93,7 @@ public void flagReturnsOffVariationAndEventIfPrerequisiteIsNotMet() throws Excep FeatureFlag.EvalResult result = f0.evaluate(BASE_USER, featureStore, EventFactory.DEFAULT); EvaluationReason expectedReason = EvaluationReason.prerequisitesFailed(ImmutableList.of("feature1")); - assertEquals(new EvaluationDetails<>(expectedReason, 1, js("off")), result.getDetails()); + assertEquals(new EvaluationDetail<>(expectedReason, 1, js("off")), result.getDetails()); assertEquals(1, result.getPrerequisiteEvents().size()); Event.FeatureRequest event = result.getPrerequisiteEvents().get(0); @@ -122,7 +122,7 @@ public void flagReturnsFallthroughVariationAndEventIfPrerequisiteIsMetAndThereAr featureStore.upsert(FEATURES, f1); FeatureFlag.EvalResult result = f0.evaluate(BASE_USER, featureStore, EventFactory.DEFAULT); - assertEquals(new EvaluationDetails<>(EvaluationReason.fallthrough(), 0, js("fall")), result.getDetails()); + assertEquals(new EvaluationDetail<>(EvaluationReason.fallthrough(), 0, js("fall")), result.getDetails()); assertEquals(1, result.getPrerequisiteEvents().size()); Event.FeatureRequest event = result.getPrerequisiteEvents().get(0); @@ -159,7 +159,7 @@ public void multipleLevelsOfPrerequisitesProduceMultipleEvents() throws Exceptio featureStore.upsert(FEATURES, f2); FeatureFlag.EvalResult result = f0.evaluate(BASE_USER, featureStore, EventFactory.DEFAULT); - assertEquals(new EvaluationDetails<>(EvaluationReason.fallthrough(), 0, js("fall")), result.getDetails()); + assertEquals(new EvaluationDetail<>(EvaluationReason.fallthrough(), 0, js("fall")), result.getDetails()); assertEquals(2, result.getPrerequisiteEvents().size()); Event.FeatureRequest event0 = result.getPrerequisiteEvents().get(0); @@ -202,7 +202,7 @@ public void multiplePrerequisiteFailuresAreAllRecorded() throws Exception { FeatureFlag.EvalResult result = f0.evaluate(BASE_USER, featureStore, EventFactory.DEFAULT); EvaluationReason expectedReason = EvaluationReason.prerequisitesFailed(ImmutableList.of("feature1", "feature2")); - assertEquals(new EvaluationDetails<>(expectedReason, 1, js("off")), result.getDetails()); + assertEquals(new EvaluationDetail<>(expectedReason, 1, js("off")), result.getDetails()); assertEquals(2, result.getPrerequisiteEvents().size()); @@ -225,7 +225,7 @@ public void flagMatchesUserFromTargets() throws Exception { LDUser user = new LDUser.Builder("userkey").build(); FeatureFlag.EvalResult result = f.evaluate(user, featureStore, EventFactory.DEFAULT); - assertEquals(new EvaluationDetails<>(EvaluationReason.targetMatch(0), 2, js("on")), result.getDetails()); + assertEquals(new EvaluationDetail<>(EvaluationReason.targetMatch(), 2, js("on")), result.getDetails()); assertEquals(0, result.getPrerequisiteEvents().size()); } @@ -243,7 +243,7 @@ public void flagMatchesUserFromRules() throws Exception { LDUser user = new LDUser.Builder("userkey").build(); FeatureFlag.EvalResult result = f.evaluate(user, featureStore, EventFactory.DEFAULT); - assertEquals(new EvaluationDetails<>(EvaluationReason.ruleMatch(0, "ruleid"), 2, js("on")), result.getDetails()); + assertEquals(new EvaluationDetail<>(EvaluationReason.ruleMatch(0, "ruleid"), 2, js("on")), result.getDetails()); assertEquals(0, result.getPrerequisiteEvents().size()); } @@ -322,8 +322,8 @@ public void clauseWithNullOperatorDoesNotStopSubsequentRuleFromMatching() throws .build(); LDUser user = new LDUser.Builder("key").name("Bob").build(); - EvaluationDetails details = f.evaluate(user, featureStore, EventFactory.DEFAULT).getDetails(); - assertEquals(new EvaluationDetails<>(EvaluationReason.ruleMatch(1, "rule2"), 1, jbool(true)), details); + EvaluationDetail details = f.evaluate(user, featureStore, EventFactory.DEFAULT).getDetails(); + assertEquals(new EvaluationDetail<>(EvaluationReason.ruleMatch(1, "rule2"), 1, jbool(true)), details); } @Test diff --git a/src/test/java/com/launchdarkly/client/LDClientEvaluationTest.java b/src/test/java/com/launchdarkly/client/LDClientEvaluationTest.java index c6c17ebba..ca978d4f2 100644 --- a/src/test/java/com/launchdarkly/client/LDClientEvaluationTest.java +++ b/src/test/java/com/launchdarkly/client/LDClientEvaluationTest.java @@ -8,12 +8,15 @@ import java.util.Arrays; import static com.launchdarkly.client.TestUtil.booleanFlagWithClauses; +import static com.launchdarkly.client.TestUtil.failedUpdateProcessor; +import static com.launchdarkly.client.TestUtil.featureStoreThatThrowsException; import static com.launchdarkly.client.TestUtil.flagWithValue; import static com.launchdarkly.client.TestUtil.jbool; import static com.launchdarkly.client.TestUtil.jdouble; import static com.launchdarkly.client.TestUtil.jint; import static com.launchdarkly.client.TestUtil.js; import static com.launchdarkly.client.TestUtil.specificFeatureStore; +import static com.launchdarkly.client.TestUtil.specificUpdateProcessor; import static com.launchdarkly.client.VersionedDataKind.FEATURES; import static com.launchdarkly.client.VersionedDataKind.SEGMENTS; import static org.junit.Assert.assertEquals; @@ -109,4 +112,63 @@ public void canMatchUserBySegment() throws Exception { assertTrue(client.boolVariation("feature", user, false)); } + + @Test + public void canGetDetailsForSuccessfulEvaluation() throws Exception { + featureStore.upsert(FEATURES, flagWithValue("key", jbool(true))); + + EvaluationDetail expectedResult = new EvaluationDetail<>(EvaluationReason.off(), 0, true); + assertEquals(expectedResult, client.boolVariationDetail("key", user, false)); + } + + @Test + public void appropriateErrorIfClientNotInitialized() throws Exception { + FeatureStore badFeatureStore = new InMemoryFeatureStore(); + LDConfig badConfig = new LDConfig.Builder() + .featureStoreFactory(specificFeatureStore(badFeatureStore)) + .eventProcessorFactory(Components.nullEventProcessor()) + .updateProcessorFactory(specificUpdateProcessor(failedUpdateProcessor())) + .startWaitMillis(0) + .build(); + try (LDClientInterface badClient = new LDClient("SDK_KEY", badConfig)) { + EvaluationDetail expectedResult = EvaluationDetail.error(EvaluationReason.ErrorKind.CLIENT_NOT_READY, false); + assertEquals(expectedResult, badClient.boolVariationDetail("key", user, false)); + } + } + + @Test + public void appropriateErrorIfFlagDoesNotExist() throws Exception { + EvaluationDetail expectedResult = EvaluationDetail.error(EvaluationReason.ErrorKind.FLAG_NOT_FOUND, false); + assertEquals(expectedResult, client.boolVariationDetail("key", user, false)); + } + + @Test + public void appropriateErrorIfUserNotSpecified() throws Exception { + featureStore.upsert(FEATURES, flagWithValue("key", jbool(true))); + + EvaluationDetail expectedResult = EvaluationDetail.error(EvaluationReason.ErrorKind.USER_NOT_SPECIFIED, false); + assertEquals(expectedResult, client.boolVariationDetail("key", null, false)); + } + + @Test + public void appropriateErrorIfValueWrongType() throws Exception { + featureStore.upsert(FEATURES, flagWithValue("key", jbool(true))); + + EvaluationDetail expectedResult = EvaluationDetail.error(EvaluationReason.ErrorKind.WRONG_TYPE, 3); + assertEquals(expectedResult, client.intVariationDetail("key", user, 3)); + } + + @Test + public void appropriateErrorForUnexpectedException() throws Exception { + FeatureStore badFeatureStore = featureStoreThatThrowsException(new RuntimeException("sorry")); + LDConfig badConfig = new LDConfig.Builder() + .featureStoreFactory(specificFeatureStore(badFeatureStore)) + .eventProcessorFactory(Components.nullEventProcessor()) + .updateProcessorFactory(Components.nullUpdateProcessor()) + .build(); + try (LDClientInterface badClient = new LDClient("SDK_KEY", badConfig)) { + EvaluationDetail expectedResult = EvaluationDetail.error(EvaluationReason.ErrorKind.EXCEPTION, false); + assertEquals(expectedResult, badClient.boolVariationDetail("key", user, false)); + } + } } diff --git a/src/test/java/com/launchdarkly/client/TestUtil.java b/src/test/java/com/launchdarkly/client/TestUtil.java index c36aa6cf0..a55f81fb9 100644 --- a/src/test/java/com/launchdarkly/client/TestUtil.java +++ b/src/test/java/com/launchdarkly/client/TestUtil.java @@ -1,5 +1,6 @@ package com.launchdarkly.client; +import com.google.common.util.concurrent.SettableFuture; import com.google.gson.JsonArray; import com.google.gson.JsonElement; import com.google.gson.JsonPrimitive; @@ -14,6 +15,7 @@ import java.util.Collections; import java.util.List; import java.util.Map; +import java.util.concurrent.Future; import static org.hamcrest.Matchers.equalTo; @@ -48,7 +50,56 @@ public UpdateProcessor createUpdateProcessor(String sdkKey, LDConfig config, Fea } }; } + + public static FeatureStore featureStoreThatThrowsException(final RuntimeException e) { + return new FeatureStore() { + @Override + public void close() throws IOException { } + + @Override + public T get(VersionedDataKind kind, String key) { + throw e; + } + + @Override + public Map all(VersionedDataKind kind) { + throw e; + } + + @Override + public void init(Map, Map> allData) { } + + @Override + public void delete(VersionedDataKind kind, String key, int version) { } + + @Override + public void upsert(VersionedDataKind kind, T item) { } + + @Override + public boolean initialized() { + return true; + } + }; + } + public static UpdateProcessor failedUpdateProcessor() { + return new UpdateProcessor() { + @Override + public Future start() { + return SettableFuture.create(); + } + + @Override + public boolean initialized() { + return false; + } + + @Override + public void close() throws IOException { + } + }; + } + public static class TestEventProcessor implements EventProcessor { List events = new ArrayList<>(); @@ -103,8 +154,8 @@ public static FeatureFlag flagWithValue(String key, JsonElement value) { .build(); } - public static EvaluationDetails simpleEvaluation(int variation, JsonElement value) { - return new EvaluationDetails<>(EvaluationReason.fallthrough(), variation, value); + public static EvaluationDetail simpleEvaluation(int variation, JsonElement value) { + return new EvaluationDetail<>(EvaluationReason.fallthrough(), variation, value); } public static Matcher hasJsonProperty(final String name, JsonElement value) { From d692bb325c1fc1cabe756dcee2fe1af031646305 Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Fri, 29 Jun 2018 17:38:27 -0700 Subject: [PATCH 06/43] don't need array index --- src/main/java/com/launchdarkly/client/FeatureFlag.java | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/main/java/com/launchdarkly/client/FeatureFlag.java b/src/main/java/com/launchdarkly/client/FeatureFlag.java index 5a64f4b01..736c20f6b 100644 --- a/src/main/java/com/launchdarkly/client/FeatureFlag.java +++ b/src/main/java/com/launchdarkly/client/FeatureFlag.java @@ -89,8 +89,7 @@ private EvaluationDetail evaluate(LDUser user, FeatureStore feature // Check to see if targets match if (targets != null) { - for (int i = 0; i < targets.size(); i++) { - Target target = targets.get(i); + for (Target target: targets) { for (String v : target.getValues()) { if (v.equals(user.getKey().getAsString())) { return new EvaluationDetail<>(EvaluationReason.targetMatch(), From 9eb8b34acdce026e0840931cd9c9c171117d8395 Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Fri, 29 Jun 2018 17:56:07 -0700 Subject: [PATCH 07/43] stop using deprecated TestFeatureStore in tests --- .../client/LDClientEventTest.java | 18 ++++++++----- .../client/LDClientLddModeTest.java | 8 ++++-- .../client/LDClientOfflineTest.java | 9 +++++-- .../com/launchdarkly/client/LDClientTest.java | 27 +++++++++---------- 4 files changed, 38 insertions(+), 24 deletions(-) diff --git a/src/test/java/com/launchdarkly/client/LDClientEventTest.java b/src/test/java/com/launchdarkly/client/LDClientEventTest.java index 8123e6e8f..ca6cf285d 100644 --- a/src/test/java/com/launchdarkly/client/LDClientEventTest.java +++ b/src/test/java/com/launchdarkly/client/LDClientEventTest.java @@ -9,6 +9,7 @@ import java.util.Arrays; import static com.launchdarkly.client.TestUtil.fallthroughVariation; +import static com.launchdarkly.client.TestUtil.flagWithValue; import static com.launchdarkly.client.TestUtil.jbool; import static com.launchdarkly.client.TestUtil.jdouble; import static com.launchdarkly.client.TestUtil.jint; @@ -22,7 +23,7 @@ public class LDClientEventTest { private static final LDUser user = new LDUser("userkey"); - private TestFeatureStore featureStore = new TestFeatureStore(); + private FeatureStore featureStore = TestUtil.initedFeatureStore(); private TestUtil.TestEventProcessor eventSink = new TestUtil.TestEventProcessor(); private LDConfig config = new LDConfig.Builder() .featureStoreFactory(specificFeatureStore(featureStore)) @@ -72,7 +73,8 @@ public void trackSendsEventWithData() throws Exception { @Test public void boolVariationSendsEvent() throws Exception { - FeatureFlag flag = featureStore.setFeatureTrue("key"); + FeatureFlag flag = flagWithValue("key", jbool(true)); + featureStore.upsert(FEATURES, flag); client.boolVariation("key", user, false); assertEquals(1, eventSink.events.size()); @@ -88,7 +90,8 @@ public void boolVariationSendsEventForUnknownFlag() throws Exception { @Test public void intVariationSendsEvent() throws Exception { - FeatureFlag flag = featureStore.setIntegerValue("key", 2); + FeatureFlag flag = flagWithValue("key", jint(2)); + featureStore.upsert(FEATURES, flag); client.intVariation("key", user, 1); assertEquals(1, eventSink.events.size()); @@ -104,7 +107,8 @@ public void intVariationSendsEventForUnknownFlag() throws Exception { @Test public void doubleVariationSendsEvent() throws Exception { - FeatureFlag flag = featureStore.setDoubleValue("key", 2.5d); + FeatureFlag flag = flagWithValue("key", jdouble(2.5d)); + featureStore.upsert(FEATURES, flag); client.doubleVariation("key", user, 1.0d); assertEquals(1, eventSink.events.size()); @@ -120,7 +124,8 @@ public void doubleVariationSendsEventForUnknownFlag() throws Exception { @Test public void stringVariationSendsEvent() throws Exception { - FeatureFlag flag = featureStore.setStringValue("key", "b"); + FeatureFlag flag = flagWithValue("key", js("b")); + featureStore.upsert(FEATURES, flag); client.stringVariation("key", user, "a"); assertEquals(1, eventSink.events.size()); @@ -138,7 +143,8 @@ public void stringVariationSendsEventForUnknownFlag() throws Exception { public void jsonVariationSendsEvent() throws Exception { JsonObject data = new JsonObject(); data.addProperty("thing", "stuff"); - FeatureFlag flag = featureStore.setJsonValue("key", data); + FeatureFlag flag = flagWithValue("key", data); + featureStore.upsert(FEATURES, flag); JsonElement defaultVal = new JsonPrimitive(42); client.jsonVariation("key", user, defaultVal); diff --git a/src/test/java/com/launchdarkly/client/LDClientLddModeTest.java b/src/test/java/com/launchdarkly/client/LDClientLddModeTest.java index 62a584c35..f77030875 100644 --- a/src/test/java/com/launchdarkly/client/LDClientLddModeTest.java +++ b/src/test/java/com/launchdarkly/client/LDClientLddModeTest.java @@ -4,7 +4,10 @@ import java.io.IOException; +import static com.launchdarkly.client.TestUtil.flagWithValue; +import static com.launchdarkly.client.TestUtil.initedFeatureStore; import static com.launchdarkly.client.TestUtil.specificFeatureStore; +import static com.launchdarkly.client.VersionedDataKind.FEATURES; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertTrue; @@ -41,12 +44,13 @@ public void lddModeClientIsInitialized() throws IOException { @Test public void lddModeClientGetsFlagFromFeatureStore() throws IOException { - TestFeatureStore testFeatureStore = new TestFeatureStore(); + FeatureStore testFeatureStore = initedFeatureStore(); LDConfig config = new LDConfig.Builder() .useLdd(true) .featureStoreFactory(specificFeatureStore(testFeatureStore)) .build(); - testFeatureStore.setFeatureTrue("key"); + FeatureFlag flag = flagWithValue("key", TestUtil.jbool(true)); + testFeatureStore.upsert(FEATURES, flag); try (LDClient client = new LDClient("SDK_KEY", config)) { assertTrue(client.boolVariation("key", new LDUser("user"), false)); } diff --git a/src/test/java/com/launchdarkly/client/LDClientOfflineTest.java b/src/test/java/com/launchdarkly/client/LDClientOfflineTest.java index 5a8369f49..51377e123 100644 --- a/src/test/java/com/launchdarkly/client/LDClientOfflineTest.java +++ b/src/test/java/com/launchdarkly/client/LDClientOfflineTest.java @@ -8,7 +8,11 @@ import java.io.IOException; import java.util.Map; +import static com.launchdarkly.client.TestUtil.flagWithValue; +import static com.launchdarkly.client.TestUtil.initedFeatureStore; +import static com.launchdarkly.client.TestUtil.jbool; import static com.launchdarkly.client.TestUtil.specificFeatureStore; +import static com.launchdarkly.client.VersionedDataKind.FEATURES; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertNotNull; import static org.junit.Assert.assertTrue; @@ -56,12 +60,13 @@ public void offlineClientReturnsDefaultValue() throws IOException { @Test public void offlineClientGetsAllFlagsFromFeatureStore() throws IOException { - TestFeatureStore testFeatureStore = new TestFeatureStore(); + FeatureStore testFeatureStore = initedFeatureStore(); LDConfig config = new LDConfig.Builder() .offline(true) .featureStoreFactory(specificFeatureStore(testFeatureStore)) .build(); - testFeatureStore.setFeatureTrue("key"); + FeatureFlag flag = flagWithValue("key", jbool(true)); + testFeatureStore.upsert(FEATURES, flag); try (LDClient client = new LDClient("SDK_KEY", config)) { Map allFlags = client.allFlags(new LDUser("user")); assertNotNull(allFlags); diff --git a/src/test/java/com/launchdarkly/client/LDClientTest.java b/src/test/java/com/launchdarkly/client/LDClientTest.java index cd50a1c57..b95b070e3 100644 --- a/src/test/java/com/launchdarkly/client/LDClientTest.java +++ b/src/test/java/com/launchdarkly/client/LDClientTest.java @@ -10,7 +10,11 @@ import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeoutException; +import static com.launchdarkly.client.TestUtil.flagWithValue; +import static com.launchdarkly.client.TestUtil.initedFeatureStore; +import static com.launchdarkly.client.TestUtil.jint; import static com.launchdarkly.client.TestUtil.specificFeatureStore; +import static com.launchdarkly.client.VersionedDataKind.FEATURES; import static org.easymock.EasyMock.anyObject; import static org.easymock.EasyMock.expect; import static org.easymock.EasyMock.expectLastCall; @@ -152,8 +156,7 @@ public void clientCatchesRuntimeExceptionFromUpdateProcessor() throws Exception @Test public void isFlagKnownReturnsTrueForExistingFlag() throws Exception { - TestFeatureStore testFeatureStore = new TestFeatureStore(); - testFeatureStore.setInitialized(true); + FeatureStore testFeatureStore = initedFeatureStore(); LDConfig.Builder config = new LDConfig.Builder() .startWaitMillis(0) .featureStoreFactory(specificFeatureStore(testFeatureStore)); @@ -163,15 +166,14 @@ public void isFlagKnownReturnsTrueForExistingFlag() throws Exception { client = createMockClient(config); - testFeatureStore.setIntegerValue("key", 1); + testFeatureStore.upsert(FEATURES, flagWithValue("key", jint(1))); assertTrue(client.isFlagKnown("key")); verifyAll(); } @Test public void isFlagKnownReturnsFalseForUnknownFlag() throws Exception { - TestFeatureStore testFeatureStore = new TestFeatureStore(); - testFeatureStore.setInitialized(true); + FeatureStore testFeatureStore = initedFeatureStore(); LDConfig.Builder config = new LDConfig.Builder() .startWaitMillis(0) .featureStoreFactory(specificFeatureStore(testFeatureStore)); @@ -187,8 +189,7 @@ public void isFlagKnownReturnsFalseForUnknownFlag() throws Exception { @Test public void isFlagKnownReturnsFalseIfStoreAndClientAreNotInitialized() throws Exception { - TestFeatureStore testFeatureStore = new TestFeatureStore(); - testFeatureStore.setInitialized(false); + FeatureStore testFeatureStore = new InMemoryFeatureStore(); LDConfig.Builder config = new LDConfig.Builder() .startWaitMillis(0) .featureStoreFactory(specificFeatureStore(testFeatureStore)); @@ -198,15 +199,14 @@ public void isFlagKnownReturnsFalseIfStoreAndClientAreNotInitialized() throws Ex client = createMockClient(config); - testFeatureStore.setIntegerValue("key", 1); + testFeatureStore.upsert(FEATURES, flagWithValue("key", jint(1))); assertFalse(client.isFlagKnown("key")); verifyAll(); } @Test public void isFlagKnownUsesStoreIfStoreIsInitializedButClientIsNot() throws Exception { - TestFeatureStore testFeatureStore = new TestFeatureStore(); - testFeatureStore.setInitialized(true); + FeatureStore testFeatureStore = initedFeatureStore(); LDConfig.Builder config = new LDConfig.Builder() .startWaitMillis(0) .featureStoreFactory(specificFeatureStore(testFeatureStore)); @@ -216,15 +216,14 @@ public void isFlagKnownUsesStoreIfStoreIsInitializedButClientIsNot() throws Exce client = createMockClient(config); - testFeatureStore.setIntegerValue("key", 1); + testFeatureStore.upsert(FEATURES, flagWithValue("key", jint(1))); assertTrue(client.isFlagKnown("key")); verifyAll(); } @Test public void evaluationUsesStoreIfStoreIsInitializedButClientIsNot() throws Exception { - TestFeatureStore testFeatureStore = new TestFeatureStore(); - testFeatureStore.setInitialized(true); + FeatureStore testFeatureStore = initedFeatureStore(); LDConfig.Builder config = new LDConfig.Builder() .featureStoreFactory(specificFeatureStore(testFeatureStore)) .startWaitMillis(0L); @@ -235,7 +234,7 @@ public void evaluationUsesStoreIfStoreIsInitializedButClientIsNot() throws Excep client = createMockClient(config); - testFeatureStore.setIntegerValue("key", 1); + testFeatureStore.upsert(FEATURES, flagWithValue("key", jint(1))); assertEquals(new Integer(1), client.intVariation("key", new LDUser("user"), 0)); verifyAll(); From afdb396ef78d4220affd45718ddd8da56a1c2bac Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Mon, 2 Jul 2018 17:47:29 -0700 Subject: [PATCH 08/43] add brief Java compatibility note to readme --- README.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/README.md b/README.md index 9f7c2f290..876ef8950 100644 --- a/README.md +++ b/README.md @@ -5,6 +5,11 @@ LaunchDarkly SDK for Java [![Javadocs](http://javadoc.io/badge/com.launchdarkly/launchdarkly-client.svg)](http://javadoc.io/doc/com.launchdarkly/launchdarkly-client) [![FOSSA Status](https://app.fossa.io/api/projects/git%2Bhttps%3A%2F%2Fgithub.com%2Flaunchdarkly%2Fjava-client.svg?type=shield)](https://app.fossa.io/projects/git%2Bhttps%3A%2F%2Fgithub.com%2Flaunchdarkly%2Fjava-client?ref=badge_shield) +Supported Java versions +----------------------- + +This version of the LaunchDarkly SDK works with Java 7 and above. + Quick setup ----------- From b9e06344cba629593e84257382253c7d8bccdea0 Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Fri, 13 Jul 2018 17:02:46 -0700 Subject: [PATCH 09/43] avoid unnecessary retry after Redis update --- src/main/java/com/launchdarkly/client/RedisFeatureStore.java | 1 + 1 file changed, 1 insertion(+) diff --git a/src/main/java/com/launchdarkly/client/RedisFeatureStore.java b/src/main/java/com/launchdarkly/client/RedisFeatureStore.java index d4724a40f..1a9c1935a 100644 --- a/src/main/java/com/launchdarkly/client/RedisFeatureStore.java +++ b/src/main/java/com/launchdarkly/client/RedisFeatureStore.java @@ -259,6 +259,7 @@ private void updateItemWithVersioning(VersionedDataKin if (cache != null) { cache.invalidate(new CacheKey(kind, newItem.getKey())); } + return; } finally { if (jedis != null) { jedis.unwatch(); From 742514eef27fdc50bfec7c37903439482c691e48 Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Fri, 20 Jul 2018 12:58:00 -0700 Subject: [PATCH 10/43] fix javadoc errors --- .../java/com/launchdarkly/client/EvaluationDetail.java | 2 +- .../java/com/launchdarkly/client/EvaluationReason.java | 8 +++++++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/src/main/java/com/launchdarkly/client/EvaluationDetail.java b/src/main/java/com/launchdarkly/client/EvaluationDetail.java index 83eecfe89..5ea06b20b 100644 --- a/src/main/java/com/launchdarkly/client/EvaluationDetail.java +++ b/src/main/java/com/launchdarkly/client/EvaluationDetail.java @@ -5,7 +5,7 @@ import static com.google.common.base.Preconditions.checkNotNull; /** - * An object returned by the "variation detail" methods such as {@link LDClientInterface#boolVariationDetails(String, LDUser, boolean), + * An object returned by the "variation detail" methods such as {@link LDClientInterface#boolVariationDetail(String, LDUser, boolean)}, * combining the result of a flag evaluation with an explanation of how it was calculated. * @since 4.3.0 */ diff --git a/src/main/java/com/launchdarkly/client/EvaluationReason.java b/src/main/java/com/launchdarkly/client/EvaluationReason.java index a1eb6936e..03f7a0ca8 100644 --- a/src/main/java/com/launchdarkly/client/EvaluationReason.java +++ b/src/main/java/com/launchdarkly/client/EvaluationReason.java @@ -9,7 +9,7 @@ /** * Describes the reason that a flag evaluation produced a particular value. This is returned by - * methods such as {@link LDClientInterface#boolVariationDetails(String, LDUser, boolean). + * methods such as {@link LDClientInterface#boolVariationDetail(String, LDUser, boolean)}. * * Note that this is an enum-like class hierarchy rather than an enum, because some of the * possible reasons have their own properties. @@ -94,6 +94,7 @@ private EvaluationReason() { } /** * Returns an instance of {@link Off}. + * @return a reason object */ public static Off off() { return Off.instance; @@ -101,6 +102,7 @@ public static Off off() { /** * Returns an instance of {@link TargetMatch}. + * @return a reason object */ public static TargetMatch targetMatch() { return TargetMatch.instance; @@ -108,6 +110,7 @@ public static TargetMatch targetMatch() { /** * Returns an instance of {@link RuleMatch}. + * @return a reason object */ public static RuleMatch ruleMatch(int ruleIndex, String ruleId) { return new RuleMatch(ruleIndex, ruleId); @@ -115,6 +118,7 @@ public static RuleMatch ruleMatch(int ruleIndex, String ruleId) { /** * Returns an instance of {@link PrerequisitesFailed}. + * @return a reason object */ public static PrerequisitesFailed prerequisitesFailed(Iterable prerequisiteKeys) { return new PrerequisitesFailed(prerequisiteKeys); @@ -122,6 +126,7 @@ public static PrerequisitesFailed prerequisitesFailed(Iterable prerequis /** * Returns an instance of {@link Fallthrough}. + * @return a reason object */ public static Fallthrough fallthrough() { return Fallthrough.instance; @@ -129,6 +134,7 @@ public static Fallthrough fallthrough() { /** * Returns an instance of {@link Error}. + * @return a reason object */ public static Error error(ErrorKind errorKind) { return new Error(errorKind); From b388b63eea3317c9bd4de28d111b0bccab88d0b1 Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Fri, 20 Jul 2018 14:52:20 -0700 Subject: [PATCH 11/43] include explanations, if requested, in full feature request events --- .../com/launchdarkly/client/Components.java | 8 + .../launchdarkly/client/EvaluationReason.java | 4 + .../java/com/launchdarkly/client/Event.java | 10 +- .../com/launchdarkly/client/EventFactory.java | 35 +++- .../com/launchdarkly/client/EventOutput.java | 7 +- .../com/launchdarkly/client/LDClient.java | 58 ++++-- .../launchdarkly/client/TestFeatureStore.java | 7 + .../client/EventSummarizerTest.java | 7 +- .../client/LDClientEventTest.java | 179 ++++++++++++++++-- 9 files changed, 265 insertions(+), 50 deletions(-) diff --git a/src/main/java/com/launchdarkly/client/Components.java b/src/main/java/com/launchdarkly/client/Components.java index 5fcc53be2..ac017b7a0 100644 --- a/src/main/java/com/launchdarkly/client/Components.java +++ b/src/main/java/com/launchdarkly/client/Components.java @@ -18,6 +18,7 @@ public abstract class Components { /** * Returns a factory for the default in-memory implementation of {@link FeatureStore}. + * @return a factory object */ public static FeatureStoreFactory inMemoryFeatureStore() { return inMemoryFeatureStoreFactory; @@ -26,6 +27,7 @@ public static FeatureStoreFactory inMemoryFeatureStore() { /** * Returns a factory with builder methods for creating a Redis-backed implementation of {@link FeatureStore}, * using {@link RedisFeatureStoreBuilder#DEFAULT_URI}. + * @return a factory/builder object */ public static RedisFeatureStoreBuilder redisFeatureStore() { return new RedisFeatureStoreBuilder(); @@ -34,6 +36,8 @@ public static RedisFeatureStoreBuilder redisFeatureStore() { /** * Returns a factory with builder methods for creating a Redis-backed implementation of {@link FeatureStore}, * specifying the Redis URI. + * @param redisUri the URI of the Redis host + * @return a factory/builder object */ public static RedisFeatureStoreBuilder redisFeatureStore(URI redisUri) { return new RedisFeatureStoreBuilder(redisUri); @@ -43,6 +47,7 @@ public static RedisFeatureStoreBuilder redisFeatureStore(URI redisUri) { * Returns a factory for the default implementation of {@link EventProcessor}, which * forwards all analytics events to LaunchDarkly (unless the client is offline or you have * set {@link LDConfig.Builder#sendEvents(boolean)} to {@code false}). + * @return a factory object */ public static EventProcessorFactory defaultEventProcessor() { return defaultEventProcessorFactory; @@ -51,6 +56,7 @@ public static EventProcessorFactory defaultEventProcessor() { /** * Returns a factory for a null implementation of {@link EventProcessor}, which will discard * all analytics events and not send them to LaunchDarkly, regardless of any other configuration. + * @return a factory object */ public static EventProcessorFactory nullEventProcessor() { return nullEventProcessorFactory; @@ -60,6 +66,7 @@ public static EventProcessorFactory nullEventProcessor() { * Returns a factory for the default implementation of {@link UpdateProcessor}, which receives * feature flag data from LaunchDarkly using either streaming or polling as configured (or does * nothing if the client is offline, or in LDD mode). + * @return a factory object */ public static UpdateProcessorFactory defaultUpdateProcessor() { return defaultUpdateProcessorFactory; @@ -68,6 +75,7 @@ public static UpdateProcessorFactory defaultUpdateProcessor() { /** * Returns a factory for a null implementation of {@link UpdateProcessor}, which does not * connect to LaunchDarkly, regardless of any other configuration. + * @return a factory object */ public static UpdateProcessorFactory nullUpdateProcessor() { return nullUpdateProcessorFactory; diff --git a/src/main/java/com/launchdarkly/client/EvaluationReason.java b/src/main/java/com/launchdarkly/client/EvaluationReason.java index 03f7a0ca8..679b007e3 100644 --- a/src/main/java/com/launchdarkly/client/EvaluationReason.java +++ b/src/main/java/com/launchdarkly/client/EvaluationReason.java @@ -110,6 +110,8 @@ public static TargetMatch targetMatch() { /** * Returns an instance of {@link RuleMatch}. + * @param ruleIndex the rule index + * @param ruleId the rule identifier * @return a reason object */ public static RuleMatch ruleMatch(int ruleIndex, String ruleId) { @@ -118,6 +120,7 @@ public static RuleMatch ruleMatch(int ruleIndex, String ruleId) { /** * Returns an instance of {@link PrerequisitesFailed}. + * @param prerequisiteKeys the list of flag keys of prerequisites that failed * @return a reason object */ public static PrerequisitesFailed prerequisitesFailed(Iterable prerequisiteKeys) { @@ -134,6 +137,7 @@ public static Fallthrough fallthrough() { /** * Returns an instance of {@link Error}. + * @param errorKind describes the type of error * @return a reason object */ public static Error error(ErrorKind errorKind) { diff --git a/src/main/java/com/launchdarkly/client/Event.java b/src/main/java/com/launchdarkly/client/Event.java index ec10cbbef..a65a27d01 100644 --- a/src/main/java/com/launchdarkly/client/Event.java +++ b/src/main/java/com/launchdarkly/client/Event.java @@ -46,10 +46,17 @@ public static final class FeatureRequest extends Event { final String prereqOf; final boolean trackEvents; final Long debugEventsUntilDate; + final EvaluationReason reason; final boolean debug; - + + @Deprecated public FeatureRequest(long timestamp, String key, LDUser user, Integer version, Integer variation, JsonElement value, JsonElement defaultVal, String prereqOf, boolean trackEvents, Long debugEventsUntilDate, boolean debug) { + this(timestamp, key, user, version, variation, value, defaultVal, prereqOf, trackEvents, debugEventsUntilDate, null, debug); + } + + public FeatureRequest(long timestamp, String key, LDUser user, Integer version, Integer variation, JsonElement value, + JsonElement defaultVal, String prereqOf, boolean trackEvents, Long debugEventsUntilDate, EvaluationReason reason, boolean debug) { super(timestamp, user); this.key = key; this.version = version; @@ -59,6 +66,7 @@ public FeatureRequest(long timestamp, String key, LDUser user, Integer version, this.prereqOf = prereqOf; this.trackEvents = trackEvents; this.debugEventsUntilDate = debugEventsUntilDate; + this.reason = reason; this.debug = debug; } } diff --git a/src/main/java/com/launchdarkly/client/EventFactory.java b/src/main/java/com/launchdarkly/client/EventFactory.java index c68468f0b..13559cf97 100644 --- a/src/main/java/com/launchdarkly/client/EventFactory.java +++ b/src/main/java/com/launchdarkly/client/EventFactory.java @@ -3,35 +3,43 @@ import com.google.gson.JsonElement; abstract class EventFactory { - public static final EventFactory DEFAULT = new DefaultEventFactory(); + public static final EventFactory DEFAULT = new DefaultEventFactory(false); + public static final EventFactory DEFAULT_WITH_REASONS = new DefaultEventFactory(true); protected abstract long getTimestamp(); + protected abstract boolean isIncludeReasons(); public Event.FeatureRequest newFeatureRequestEvent(FeatureFlag flag, LDUser user, EvaluationDetail result, JsonElement defaultVal) { return new Event.FeatureRequest(getTimestamp(), flag.getKey(), user, flag.getVersion(), result == null ? null : result.getVariationIndex(), result == null ? null : result.getValue(), - defaultVal, null, flag.isTrackEvents(), flag.getDebugEventsUntilDate(), false); + defaultVal, null, flag.isTrackEvents(), flag.getDebugEventsUntilDate(), + isIncludeReasons() ? result.getReason() : null, false); } - public Event.FeatureRequest newDefaultFeatureRequestEvent(FeatureFlag flag, LDUser user, JsonElement defaultValue) { + public Event.FeatureRequest newDefaultFeatureRequestEvent(FeatureFlag flag, LDUser user, JsonElement defaultValue, + EvaluationReason.ErrorKind errorKind) { return new Event.FeatureRequest(getTimestamp(), flag.getKey(), user, flag.getVersion(), - null, defaultValue, defaultValue, null, flag.isTrackEvents(), flag.getDebugEventsUntilDate(), false); + null, defaultValue, defaultValue, null, flag.isTrackEvents(), flag.getDebugEventsUntilDate(), + isIncludeReasons() ? EvaluationReason.error(errorKind) : null, false); } - public Event.FeatureRequest newUnknownFeatureRequestEvent(String key, LDUser user, JsonElement defaultValue) { - return new Event.FeatureRequest(getTimestamp(), key, user, null, null, defaultValue, defaultValue, null, false, null, false); + public Event.FeatureRequest newUnknownFeatureRequestEvent(String key, LDUser user, JsonElement defaultValue, + EvaluationReason.ErrorKind errorKind) { + return new Event.FeatureRequest(getTimestamp(), key, user, null, null, defaultValue, defaultValue, null, false, null, + isIncludeReasons() ? EvaluationReason.error(errorKind) : null, false); } public Event.FeatureRequest newPrerequisiteFeatureRequestEvent(FeatureFlag prereqFlag, LDUser user, EvaluationDetail result, FeatureFlag prereqOf) { return new Event.FeatureRequest(getTimestamp(), prereqFlag.getKey(), user, prereqFlag.getVersion(), result == null ? null : result.getVariationIndex(), result == null ? null : result.getValue(), - null, prereqOf.getKey(), prereqFlag.isTrackEvents(), prereqFlag.getDebugEventsUntilDate(), false); + null, prereqOf.getKey(), prereqFlag.isTrackEvents(), prereqFlag.getDebugEventsUntilDate(), + isIncludeReasons() ? result.getReason() : null, false); } public Event.FeatureRequest newDebugEvent(Event.FeatureRequest from) { return new Event.FeatureRequest(from.creationDate, from.key, from.user, from.version, from.variation, from.value, - from.defaultVal, from.prereqOf, from.trackEvents, from.debugEventsUntilDate, true); + from.defaultVal, from.prereqOf, from.trackEvents, from.debugEventsUntilDate, from.reason, true); } public Event.Custom newCustomEvent(String key, LDUser user, JsonElement data) { @@ -43,9 +51,20 @@ public Event.Identify newIdentifyEvent(LDUser user) { } public static class DefaultEventFactory extends EventFactory { + private final boolean includeReasons; + + public DefaultEventFactory(boolean includeReasons) { + this.includeReasons = includeReasons; + } + @Override protected long getTimestamp() { return System.currentTimeMillis(); } + + @Override + protected boolean isIncludeReasons() { + return includeReasons; + } } } diff --git a/src/main/java/com/launchdarkly/client/EventOutput.java b/src/main/java/com/launchdarkly/client/EventOutput.java index 016e52f32..7d471086e 100644 --- a/src/main/java/com/launchdarkly/client/EventOutput.java +++ b/src/main/java/com/launchdarkly/client/EventOutput.java @@ -44,9 +44,11 @@ static final class FeatureRequest extends EventOutputWithTimestamp { private final JsonElement value; @SerializedName("default") private final JsonElement defaultVal; private final String prereqOf; + private final EvaluationReason reason; FeatureRequest(long creationDate, String key, String userKey, LDUser user, - Integer version, Integer variation, JsonElement value, JsonElement defaultVal, String prereqOf, boolean debug) { + Integer version, Integer variation, JsonElement value, JsonElement defaultVal, String prereqOf, + EvaluationReason reason, boolean debug) { super(debug ? "debug" : "feature", creationDate); this.key = key; this.userKey = userKey; @@ -56,6 +58,7 @@ static final class FeatureRequest extends EventOutputWithTimestamp { this.value = value; this.defaultVal = defaultVal; this.prereqOf = prereqOf; + this.reason = reason; } } @@ -163,7 +166,7 @@ private EventOutput createOutputEvent(Event e) { return new EventOutput.FeatureRequest(fe.creationDate, fe.key, inlineThisUser ? null : userKey, inlineThisUser ? e.user : null, - fe.version, fe.variation, fe.value, fe.defaultVal, fe.prereqOf, fe.debug); + fe.version, fe.variation, fe.value, fe.defaultVal, fe.prereqOf, fe.reason, fe.debug); } else if (e instanceof Event.Identify) { return new EventOutput.Identify(e.creationDate, e.user); } else if (e instanceof Event.Custom) { diff --git a/src/main/java/com/launchdarkly/client/LDClient.java b/src/main/java/com/launchdarkly/client/LDClient.java index 6827585f1..d7e3fb3a5 100644 --- a/src/main/java/com/launchdarkly/client/LDClient.java +++ b/src/main/java/com/launchdarkly/client/LDClient.java @@ -40,8 +40,7 @@ public final class LDClient implements LDClientInterface { final UpdateProcessor updateProcessor; final FeatureStore featureStore; final boolean shouldCloseFeatureStore; - private final EventFactory eventFactory = EventFactory.DEFAULT; - + /** * Creates a new client instance that connects to LaunchDarkly with the default configuration. In most * cases, you should use this constructor. @@ -116,7 +115,7 @@ public void track(String eventName, LDUser user, JsonElement data) { if (user == null || user.getKey() == null) { logger.warn("Track called with null user or null user key!"); } - eventProcessor.sendEvent(eventFactory.newCustomEvent(eventName, user, data)); + eventProcessor.sendEvent(EventFactory.DEFAULT.newCustomEvent(eventName, user, data)); } @Override @@ -132,7 +131,7 @@ public void identify(LDUser user) { if (user == null || user.getKey() == null) { logger.warn("Identify called with null user or null user key!"); } - eventProcessor.sendEvent(eventFactory.newIdentifyEvent(user)); + eventProcessor.sendEvent(EventFactory.DEFAULT.newIdentifyEvent(user)); } private void sendFlagRequestEvent(Event.FeatureRequest event) { @@ -165,9 +164,8 @@ public Map allFlags(LDUser user) { for (Map.Entry entry : flags.entrySet()) { try { - JsonElement evalResult = entry.getValue().evaluate(user, featureStore, eventFactory).getDetails().getValue(); - result.put(entry.getKey(), evalResult); - + JsonElement evalResult = entry.getValue().evaluate(user, featureStore, EventFactory.DEFAULT).getDetails().getValue(); + result.put(entry.getKey(), evalResult); } catch (EvaluationException e) { logger.error("Exception caught when evaluating all flags:", e); } @@ -202,27 +200,32 @@ public JsonElement jsonVariation(String featureKey, LDUser user, JsonElement def @Override public EvaluationDetail boolVariationDetail(String featureKey, LDUser user, boolean defaultValue) { - return evaluateDetail(featureKey, user, defaultValue, new JsonPrimitive(defaultValue), VariationType.Boolean); + return evaluateDetail(featureKey, user, defaultValue, new JsonPrimitive(defaultValue), VariationType.Boolean, + EventFactory.DEFAULT_WITH_REASONS); } @Override public EvaluationDetail intVariationDetail(String featureKey, LDUser user, int defaultValue) { - return evaluateDetail(featureKey, user, defaultValue, new JsonPrimitive(defaultValue), VariationType.Integer); + return evaluateDetail(featureKey, user, defaultValue, new JsonPrimitive(defaultValue), VariationType.Integer, + EventFactory.DEFAULT_WITH_REASONS); } @Override public EvaluationDetail doubleVariationDetail(String featureKey, LDUser user, double defaultValue) { - return evaluateDetail(featureKey, user, defaultValue, new JsonPrimitive(defaultValue), VariationType.Double); + return evaluateDetail(featureKey, user, defaultValue, new JsonPrimitive(defaultValue), VariationType.Double, + EventFactory.DEFAULT_WITH_REASONS); } @Override public EvaluationDetail stringVariationDetail(String featureKey, LDUser user, String defaultValue) { - return evaluateDetail(featureKey, user, defaultValue, new JsonPrimitive(defaultValue), VariationType.String); + return evaluateDetail(featureKey, user, defaultValue, new JsonPrimitive(defaultValue), VariationType.String, + EventFactory.DEFAULT_WITH_REASONS); } @Override public EvaluationDetail jsonVariationDetail(String featureKey, LDUser user, JsonElement defaultValue) { - return evaluateDetail(featureKey, user, defaultValue, defaultValue, VariationType.Json); + return evaluateDetail(featureKey, user, defaultValue, defaultValue, VariationType.Json, + EventFactory.DEFAULT_WITH_REASONS); } @Override @@ -248,12 +251,12 @@ public boolean isFlagKnown(String featureKey) { } private T evaluate(String featureKey, LDUser user, T defaultValue, JsonElement defaultJson, VariationType expectedType) { - return evaluateDetail(featureKey, user, defaultValue, defaultJson, expectedType).getValue(); + return evaluateDetail(featureKey, user, defaultValue, defaultJson, expectedType, EventFactory.DEFAULT).getValue(); } private EvaluationDetail evaluateDetail(String featureKey, LDUser user, T defaultValue, - JsonElement defaultJson, VariationType expectedType) { - EvaluationDetail details = evaluateInternal(featureKey, user, defaultJson); + JsonElement defaultJson, VariationType expectedType, EventFactory eventFactory) { + EvaluationDetail details = evaluateInternal(featureKey, user, defaultJson, eventFactory); T resultValue; if (details.getReason().getKind() == EvaluationReason.Kind.ERROR) { resultValue = defaultValue; @@ -268,13 +271,14 @@ private EvaluationDetail evaluateDetail(String featureKey, LDUser user, T return new EvaluationDetail(details.getReason(), details.getVariationIndex(), resultValue); } - private EvaluationDetail evaluateInternal(String featureKey, LDUser user, JsonElement defaultValue) { + private EvaluationDetail evaluateInternal(String featureKey, LDUser user, JsonElement defaultValue, EventFactory eventFactory) { if (!initialized()) { if (featureStore.initialized()) { logger.warn("Evaluation called before client initialized for feature flag " + featureKey + "; using last known values from feature store"); } else { logger.warn("Evaluation called before client initialized for feature flag " + featureKey + "; feature store unavailable, returning default value"); - sendFlagRequestEvent(eventFactory.newUnknownFeatureRequestEvent(featureKey, user, defaultValue)); + sendFlagRequestEvent(eventFactory.newUnknownFeatureRequestEvent(featureKey, user, defaultValue, + EvaluationReason.ErrorKind.CLIENT_NOT_READY)); return EvaluationDetail.error(EvaluationReason.ErrorKind.CLIENT_NOT_READY, defaultValue); } } @@ -283,18 +287,29 @@ private EvaluationDetail evaluateInternal(String featureKey, LDUser FeatureFlag featureFlag = featureStore.get(FEATURES, featureKey); if (featureFlag == null) { logger.info("Unknown feature flag " + featureKey + "; returning default value"); - sendFlagRequestEvent(eventFactory.newUnknownFeatureRequestEvent(featureKey, user, defaultValue)); + sendFlagRequestEvent(eventFactory.newUnknownFeatureRequestEvent(featureKey, user, defaultValue, + EvaluationReason.ErrorKind.FLAG_NOT_FOUND)); return EvaluationDetail.error(EvaluationReason.ErrorKind.FLAG_NOT_FOUND, defaultValue); } if (user == null || user.getKey() == null) { logger.warn("Null user or null user key when evaluating flag: " + featureKey + "; returning default value"); - sendFlagRequestEvent(eventFactory.newDefaultFeatureRequestEvent(featureFlag, user, defaultValue)); + sendFlagRequestEvent(eventFactory.newDefaultFeatureRequestEvent(featureFlag, user, defaultValue, + EvaluationReason.ErrorKind.USER_NOT_SPECIFIED)); return EvaluationDetail.error(EvaluationReason.ErrorKind.USER_NOT_SPECIFIED, defaultValue); } if (user.getKeyAsString().isEmpty()) { logger.warn("User key is blank. Flag evaluation will proceed, but the user will not be stored in LaunchDarkly"); } - FeatureFlag.EvalResult evalResult = featureFlag.evaluate(user, featureStore, eventFactory); + FeatureFlag.EvalResult evalResult; + try { + evalResult = featureFlag.evaluate(user, featureStore, eventFactory); + } catch (Exception e) { + logger.error("Encountered exception in LaunchDarkly client: " + e); + logger.debug(e.getMessage(), e);; + sendFlagRequestEvent(eventFactory.newDefaultFeatureRequestEvent(featureFlag, user, defaultValue, + EvaluationReason.ErrorKind.EXCEPTION)); + return EvaluationDetail.error(EvaluationReason.ErrorKind.EXCEPTION, defaultValue); + } for (Event.FeatureRequest event : evalResult.getPrerequisiteEvents()) { eventProcessor.sendEvent(event); } @@ -303,7 +318,8 @@ private EvaluationDetail evaluateInternal(String featureKey, LDUser } catch (Exception e) { logger.error("Encountered exception in LaunchDarkly client: " + e); logger.debug(e.getMessage(), e); - sendFlagRequestEvent(eventFactory.newUnknownFeatureRequestEvent(featureKey, user, defaultValue)); + sendFlagRequestEvent(eventFactory.newUnknownFeatureRequestEvent(featureKey, user, defaultValue, + EvaluationReason.ErrorKind.EXCEPTION)); return EvaluationDetail.error(EvaluationReason.ErrorKind.EXCEPTION, defaultValue); } } diff --git a/src/main/java/com/launchdarkly/client/TestFeatureStore.java b/src/main/java/com/launchdarkly/client/TestFeatureStore.java index afebbad2f..39d20e7cd 100644 --- a/src/main/java/com/launchdarkly/client/TestFeatureStore.java +++ b/src/main/java/com/launchdarkly/client/TestFeatureStore.java @@ -32,6 +32,7 @@ public class TestFeatureStore extends InMemoryFeatureStore { * * @param key the key of the feature flag * @param value the new value of the feature flag + * @return the feature flag */ public FeatureFlag setBooleanValue(String key, Boolean value) { FeatureFlag newFeature = new FeatureFlagBuilder(key) @@ -49,6 +50,7 @@ public FeatureFlag setBooleanValue(String key, Boolean value) { * If the feature rule is not currently in the store, it will create one that is true for every {@link LDUser}. * * @param key the key of the feature flag to evaluate to true + * @return the feature flag */ public FeatureFlag setFeatureTrue(String key) { return setBooleanValue(key, true); @@ -59,6 +61,7 @@ public FeatureFlag setFeatureTrue(String key) { * If the feature rule is not currently in the store, it will create one that is false for every {@link LDUser}. * * @param key the key of the feature flag to evaluate to false + * @return the feature flag */ public FeatureFlag setFeatureFalse(String key) { return setBooleanValue(key, false); @@ -68,6 +71,7 @@ public FeatureFlag setFeatureFalse(String key) { * Sets the value of an integer multivariate feature flag, for all users. * @param key the key of the flag * @param value the new value of the flag + * @return the feature flag */ public FeatureFlag setIntegerValue(String key, Integer value) { return setJsonValue(key, new JsonPrimitive(value)); @@ -77,6 +81,7 @@ public FeatureFlag setIntegerValue(String key, Integer value) { * Sets the value of a double multivariate feature flag, for all users. * @param key the key of the flag * @param value the new value of the flag + * @return the feature flag */ public FeatureFlag setDoubleValue(String key, Double value) { return setJsonValue(key, new JsonPrimitive(value)); @@ -86,6 +91,7 @@ public FeatureFlag setDoubleValue(String key, Double value) { * Sets the value of a string multivariate feature flag, for all users. * @param key the key of the flag * @param value the new value of the flag + * @return the feature flag */ public FeatureFlag setStringValue(String key, String value) { return setJsonValue(key, new JsonPrimitive(value)); @@ -95,6 +101,7 @@ public FeatureFlag setStringValue(String key, String value) { * Sets the value of a JsonElement multivariate feature flag, for all users. * @param key the key of the flag * @param value the new value of the flag + * @return the feature flag */ public FeatureFlag setJsonValue(String key, JsonElement value) { FeatureFlag newFeature = new FeatureFlagBuilder(key) diff --git a/src/test/java/com/launchdarkly/client/EventSummarizerTest.java b/src/test/java/com/launchdarkly/client/EventSummarizerTest.java index 0c101b3b8..f64ba29bd 100644 --- a/src/test/java/com/launchdarkly/client/EventSummarizerTest.java +++ b/src/test/java/com/launchdarkly/client/EventSummarizerTest.java @@ -20,6 +20,11 @@ public class EventSummarizerTest { protected long getTimestamp() { return eventTimestamp; } + + @Override + protected boolean isIncludeReasons() { + return false; + } }; @Test @@ -73,7 +78,7 @@ public void summarizeEventIncrementsCounters() { simpleEvaluation(1, js("value99")), js("default2")); Event event4 = eventFactory.newFeatureRequestEvent(flag1, user, simpleEvaluation(1, js("value1")), js("default1")); - Event event5 = eventFactory.newUnknownFeatureRequestEvent(unknownFlagKey, user, js("default3")); + Event event5 = eventFactory.newUnknownFeatureRequestEvent(unknownFlagKey, user, js("default3"), EvaluationReason.ErrorKind.FLAG_NOT_FOUND); es.summarizeEvent(event1); es.summarizeEvent(event2); es.summarizeEvent(event3); diff --git a/src/test/java/com/launchdarkly/client/LDClientEventTest.java b/src/test/java/com/launchdarkly/client/LDClientEventTest.java index ca6cf285d..7c231a39c 100644 --- a/src/test/java/com/launchdarkly/client/LDClientEventTest.java +++ b/src/test/java/com/launchdarkly/client/LDClientEventTest.java @@ -1,8 +1,10 @@ package com.launchdarkly.client; +import com.google.common.collect.ImmutableList; import com.google.gson.JsonElement; import com.google.gson.JsonObject; import com.google.gson.JsonPrimitive; +import com.launchdarkly.client.EvaluationReason.ErrorKind; import org.junit.Test; @@ -78,16 +80,34 @@ public void boolVariationSendsEvent() throws Exception { client.boolVariation("key", user, false); assertEquals(1, eventSink.events.size()); - checkFeatureEvent(eventSink.events.get(0), flag, jbool(true), jbool(false), null); + checkFeatureEvent(eventSink.events.get(0), flag, jbool(true), jbool(false), null, null); } @Test public void boolVariationSendsEventForUnknownFlag() throws Exception { client.boolVariation("key", user, false); assertEquals(1, eventSink.events.size()); - checkUnknownFeatureEvent(eventSink.events.get(0), "key", jbool(false), null); + checkUnknownFeatureEvent(eventSink.events.get(0), "key", jbool(false), null, null); } + @Test + public void boolVariationDetailSendsEvent() throws Exception { + FeatureFlag flag = flagWithValue("key", jbool(true)); + featureStore.upsert(FEATURES, flag); + + client.boolVariationDetail("key", user, false); + assertEquals(1, eventSink.events.size()); + checkFeatureEvent(eventSink.events.get(0), flag, jbool(true), jbool(false), null, EvaluationReason.off()); + } + + @Test + public void boolVariationDetailSendsEventForUnknownFlag() throws Exception { + client.boolVariationDetail("key", user, false); + assertEquals(1, eventSink.events.size()); + checkUnknownFeatureEvent(eventSink.events.get(0), "key", jbool(false), null, + EvaluationReason.error(ErrorKind.FLAG_NOT_FOUND)); + } + @Test public void intVariationSendsEvent() throws Exception { FeatureFlag flag = flagWithValue("key", jint(2)); @@ -95,14 +115,32 @@ public void intVariationSendsEvent() throws Exception { client.intVariation("key", user, 1); assertEquals(1, eventSink.events.size()); - checkFeatureEvent(eventSink.events.get(0), flag, jint(2), jint(1), null); + checkFeatureEvent(eventSink.events.get(0), flag, jint(2), jint(1), null, null); } @Test public void intVariationSendsEventForUnknownFlag() throws Exception { client.intVariation("key", user, 1); assertEquals(1, eventSink.events.size()); - checkUnknownFeatureEvent(eventSink.events.get(0), "key", jint(1), null); + checkUnknownFeatureEvent(eventSink.events.get(0), "key", jint(1), null, null); + } + + @Test + public void intVariationDetailSendsEvent() throws Exception { + FeatureFlag flag = flagWithValue("key", jint(2)); + featureStore.upsert(FEATURES, flag); + + client.intVariationDetail("key", user, 1); + assertEquals(1, eventSink.events.size()); + checkFeatureEvent(eventSink.events.get(0), flag, jint(2), jint(1), null, EvaluationReason.off()); + } + + @Test + public void intVariationDetailSendsEventForUnknownFlag() throws Exception { + client.intVariationDetail("key", user, 1); + assertEquals(1, eventSink.events.size()); + checkUnknownFeatureEvent(eventSink.events.get(0), "key", jint(1), null, + EvaluationReason.error(ErrorKind.FLAG_NOT_FOUND)); } @Test @@ -112,16 +150,34 @@ public void doubleVariationSendsEvent() throws Exception { client.doubleVariation("key", user, 1.0d); assertEquals(1, eventSink.events.size()); - checkFeatureEvent(eventSink.events.get(0), flag, jdouble(2.5d), jdouble(1.0d), null); + checkFeatureEvent(eventSink.events.get(0), flag, jdouble(2.5d), jdouble(1.0d), null, null); } @Test public void doubleVariationSendsEventForUnknownFlag() throws Exception { client.doubleVariation("key", user, 1.0d); assertEquals(1, eventSink.events.size()); - checkUnknownFeatureEvent(eventSink.events.get(0), "key", jdouble(1.0), null); + checkUnknownFeatureEvent(eventSink.events.get(0), "key", jdouble(1.0), null, null); } - + + @Test + public void doubleVariationDetailSendsEvent() throws Exception { + FeatureFlag flag = flagWithValue("key", jdouble(2.5d)); + featureStore.upsert(FEATURES, flag); + + client.doubleVariationDetail("key", user, 1.0d); + assertEquals(1, eventSink.events.size()); + checkFeatureEvent(eventSink.events.get(0), flag, jdouble(2.5d), jdouble(1.0d), null, EvaluationReason.off()); + } + + @Test + public void doubleVariationDetailSendsEventForUnknownFlag() throws Exception { + client.doubleVariationDetail("key", user, 1.0d); + assertEquals(1, eventSink.events.size()); + checkUnknownFeatureEvent(eventSink.events.get(0), "key", jdouble(1.0), null, + EvaluationReason.error(ErrorKind.FLAG_NOT_FOUND)); + } + @Test public void stringVariationSendsEvent() throws Exception { FeatureFlag flag = flagWithValue("key", js("b")); @@ -129,16 +185,34 @@ public void stringVariationSendsEvent() throws Exception { client.stringVariation("key", user, "a"); assertEquals(1, eventSink.events.size()); - checkFeatureEvent(eventSink.events.get(0), flag, js("b"), js("a"), null); + checkFeatureEvent(eventSink.events.get(0), flag, js("b"), js("a"), null, null); } @Test public void stringVariationSendsEventForUnknownFlag() throws Exception { client.stringVariation("key", user, "a"); assertEquals(1, eventSink.events.size()); - checkUnknownFeatureEvent(eventSink.events.get(0), "key", js("a"), null); + checkUnknownFeatureEvent(eventSink.events.get(0), "key", js("a"), null, null); } - + + @Test + public void stringVariationDetailSendsEvent() throws Exception { + FeatureFlag flag = flagWithValue("key", js("b")); + featureStore.upsert(FEATURES, flag); + + client.stringVariationDetail("key", user, "a"); + assertEquals(1, eventSink.events.size()); + checkFeatureEvent(eventSink.events.get(0), flag, js("b"), js("a"), null, EvaluationReason.off()); + } + + @Test + public void stringVariationDetailSendsEventForUnknownFlag() throws Exception { + client.stringVariationDetail("key", user, "a"); + assertEquals(1, eventSink.events.size()); + checkUnknownFeatureEvent(eventSink.events.get(0), "key", js("a"), null, + EvaluationReason.error(ErrorKind.FLAG_NOT_FOUND)); + } + @Test public void jsonVariationSendsEvent() throws Exception { JsonObject data = new JsonObject(); @@ -149,7 +223,7 @@ public void jsonVariationSendsEvent() throws Exception { client.jsonVariation("key", user, defaultVal); assertEquals(1, eventSink.events.size()); - checkFeatureEvent(eventSink.events.get(0), flag, data, defaultVal, null); + checkFeatureEvent(eventSink.events.get(0), flag, data, defaultVal, null, null); } @Test @@ -158,7 +232,30 @@ public void jsonVariationSendsEventForUnknownFlag() throws Exception { client.jsonVariation("key", user, defaultVal); assertEquals(1, eventSink.events.size()); - checkUnknownFeatureEvent(eventSink.events.get(0), "key", defaultVal, null); + checkUnknownFeatureEvent(eventSink.events.get(0), "key", defaultVal, null, null); + } + + @Test + public void jsonVariationDetailSendsEvent() throws Exception { + JsonObject data = new JsonObject(); + data.addProperty("thing", "stuff"); + FeatureFlag flag = flagWithValue("key", data); + featureStore.upsert(FEATURES, flag); + JsonElement defaultVal = new JsonPrimitive(42); + + client.jsonVariationDetail("key", user, defaultVal); + assertEquals(1, eventSink.events.size()); + checkFeatureEvent(eventSink.events.get(0), flag, data, defaultVal, null, EvaluationReason.off()); + } + + @Test + public void jsonVariationDetailSendsEventForUnknownFlag() throws Exception { + JsonElement defaultVal = new JsonPrimitive(42); + + client.jsonVariationDetail("key", user, defaultVal); + assertEquals(1, eventSink.events.size()); + checkUnknownFeatureEvent(eventSink.events.get(0), "key", defaultVal, null, + EvaluationReason.error(ErrorKind.FLAG_NOT_FOUND)); } @Test @@ -183,8 +280,34 @@ public void eventIsSentForExistingPrererequisiteFlag() throws Exception { client.stringVariation("feature0", user, "default"); assertEquals(2, eventSink.events.size()); - checkFeatureEvent(eventSink.events.get(0), f1, js("go"), null, "feature0"); - checkFeatureEvent(eventSink.events.get(1), f0, js("fall"), js("default"), null); + checkFeatureEvent(eventSink.events.get(0), f1, js("go"), null, "feature0", null); + checkFeatureEvent(eventSink.events.get(1), f0, js("fall"), js("default"), null, null); + } + + @Test + public void eventIsSentWithReasonForExistingPrererequisiteFlag() throws Exception { + FeatureFlag f0 = new FeatureFlagBuilder("feature0") + .on(true) + .prerequisites(Arrays.asList(new Prerequisite("feature1", 1))) + .fallthrough(fallthroughVariation(0)) + .offVariation(1) + .variations(js("fall"), js("off"), js("on")) + .version(1) + .build(); + FeatureFlag f1 = new FeatureFlagBuilder("feature1") + .on(true) + .fallthrough(fallthroughVariation(1)) + .variations(js("nogo"), js("go")) + .version(2) + .build(); + featureStore.upsert(FEATURES, f0); + featureStore.upsert(FEATURES, f1); + + client.stringVariationDetail("feature0", user, "default"); + + assertEquals(2, eventSink.events.size()); + checkFeatureEvent(eventSink.events.get(0), f1, js("go"), null, "feature0", EvaluationReason.fallthrough()); + checkFeatureEvent(eventSink.events.get(1), f0, js("fall"), js("default"), null, EvaluationReason.fallthrough()); } @Test @@ -202,11 +325,30 @@ public void eventIsNotSentForUnknownPrererequisiteFlag() throws Exception { client.stringVariation("feature0", user, "default"); assertEquals(1, eventSink.events.size()); - checkFeatureEvent(eventSink.events.get(0), f0, js("off"), js("default"), null); + checkFeatureEvent(eventSink.events.get(0), f0, js("off"), js("default"), null, null); + } + + @Test + public void failureReasonIsGivenForUnknownPrererequisiteFlagIfDetailsWereRequested() throws Exception { + FeatureFlag f0 = new FeatureFlagBuilder("feature0") + .on(true) + .prerequisites(Arrays.asList(new Prerequisite("feature1", 1))) + .fallthrough(fallthroughVariation(0)) + .offVariation(1) + .variations(js("fall"), js("off"), js("on")) + .version(1) + .build(); + featureStore.upsert(FEATURES, f0); + + client.stringVariationDetail("feature0", user, "default"); + + assertEquals(1, eventSink.events.size()); + checkFeatureEvent(eventSink.events.get(0), f0, js("off"), js("default"), null, + EvaluationReason.prerequisitesFailed(ImmutableList.of("feature1"))); } private void checkFeatureEvent(Event e, FeatureFlag flag, JsonElement value, JsonElement defaultVal, - String prereqOf) { + String prereqOf, EvaluationReason reason) { assertEquals(Event.FeatureRequest.class, e.getClass()); Event.FeatureRequest fe = (Event.FeatureRequest)e; assertEquals(flag.getKey(), fe.key); @@ -215,9 +357,11 @@ private void checkFeatureEvent(Event e, FeatureFlag flag, JsonElement value, Jso assertEquals(value, fe.value); assertEquals(defaultVal, fe.defaultVal); assertEquals(prereqOf, fe.prereqOf); + assertEquals(reason, fe.reason); } - private void checkUnknownFeatureEvent(Event e, String key, JsonElement defaultVal, String prereqOf) { + private void checkUnknownFeatureEvent(Event e, String key, JsonElement defaultVal, String prereqOf, + EvaluationReason reason) { assertEquals(Event.FeatureRequest.class, e.getClass()); Event.FeatureRequest fe = (Event.FeatureRequest)e; assertEquals(key, fe.key); @@ -226,5 +370,6 @@ private void checkUnknownFeatureEvent(Event e, String key, JsonElement defaultVa assertEquals(defaultVal, fe.value); assertEquals(defaultVal, fe.defaultVal); assertEquals(prereqOf, fe.prereqOf); + assertEquals(reason, fe.reason); } } From 64fe12e88a83ac7ac82bf393cafaa90e4dd73d26 Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Fri, 20 Jul 2018 15:01:49 -0700 Subject: [PATCH 12/43] add unit test for reason property in full feature event --- .../client/DefaultEventProcessorTest.java | 29 +++++++++++++++++-- 1 file changed, 27 insertions(+), 2 deletions(-) diff --git a/src/test/java/com/launchdarkly/client/DefaultEventProcessorTest.java b/src/test/java/com/launchdarkly/client/DefaultEventProcessorTest.java index ab63f6432..c8184c38c 100644 --- a/src/test/java/com/launchdarkly/client/DefaultEventProcessorTest.java +++ b/src/test/java/com/launchdarkly/client/DefaultEventProcessorTest.java @@ -151,6 +151,24 @@ public void userIsFilteredInFeatureEvent() throws Exception { )); } + @SuppressWarnings("unchecked") + @Test + public void featureEventCanContainReason() throws Exception { + ep = new DefaultEventProcessor(SDK_KEY, configBuilder.build()); + FeatureFlag flag = new FeatureFlagBuilder("flagkey").version(11).trackEvents(true).build(); + EvaluationReason reason = EvaluationReason.ruleMatch(1, null); + Event.FeatureRequest fe = EventFactory.DEFAULT_WITH_REASONS.newFeatureRequestEvent(flag, user, + new EvaluationDetail(reason, 1, new JsonPrimitive("value")), null); + ep.sendEvent(fe); + + JsonArray output = flushAndGetEvents(new MockResponse()); + assertThat(output, hasItems( + isIndexEvent(fe, userJson), + isFeatureEvent(fe, flag, false, null, reason), + isSummaryEvent() + )); + } + @SuppressWarnings("unchecked") @Test public void indexEventIsStillGeneratedIfInlineUsersIsTrueButFeatureEventIsNotTracked() throws Exception { @@ -494,8 +512,13 @@ private Matcher isIndexEvent(Event sourceEvent, JsonElement user) { ); } - @SuppressWarnings("unchecked") private Matcher isFeatureEvent(Event.FeatureRequest sourceEvent, FeatureFlag flag, boolean debug, JsonElement inlineUser) { + return isFeatureEvent(sourceEvent, flag, debug, inlineUser, null); + } + + @SuppressWarnings("unchecked") + private Matcher isFeatureEvent(Event.FeatureRequest sourceEvent, FeatureFlag flag, boolean debug, JsonElement inlineUser, + EvaluationReason reason) { return allOf( hasJsonProperty("kind", debug ? "debug" : "feature"), hasJsonProperty("creationDate", (double)sourceEvent.creationDate), @@ -506,7 +529,9 @@ private Matcher isFeatureEvent(Event.FeatureRequest sourceEvent, Fe (inlineUser != null) ? hasJsonProperty("userKey", nullValue(JsonElement.class)) : hasJsonProperty("userKey", sourceEvent.user.getKeyAsString()), (inlineUser != null) ? hasJsonProperty("user", inlineUser) : - hasJsonProperty("user", nullValue(JsonElement.class)) + hasJsonProperty("user", nullValue(JsonElement.class)), + (reason == null) ? hasJsonProperty("reason", nullValue(JsonElement.class)) : + hasJsonProperty("reason", gson.toJsonTree(reason)) ); } From ebfb18a230c82883e194247eeaa3b6ccb11e2fdc Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Fri, 20 Jul 2018 15:15:05 -0700 Subject: [PATCH 13/43] add javadoc note about reasons in events --- .../launchdarkly/client/LDClientInterface.java | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/src/main/java/com/launchdarkly/client/LDClientInterface.java b/src/main/java/com/launchdarkly/client/LDClientInterface.java index f1d984d86..c851d38ef 100644 --- a/src/main/java/com/launchdarkly/client/LDClientInterface.java +++ b/src/main/java/com/launchdarkly/client/LDClientInterface.java @@ -101,7 +101,8 @@ public interface LDClientInterface extends Closeable { /** * Calculates the value of a feature flag for a given user, and returns an object that describes the - * way the value was determined. + * way the value was determined. The {@code reason} property in the result will also be included in + * analytics events, if you are capturing detailed event data for this flag. * @param featureKey the unique key for the feature flag * @param user the end user requesting the flag * @param defaultValue the default value of the flag @@ -112,7 +113,8 @@ public interface LDClientInterface extends Closeable { /** * Calculates the value of a feature flag for a given user, and returns an object that describes the - * way the value was determined. + * way the value was determined. The {@code reason} property in the result will also be included in + * analytics events, if you are capturing detailed event data for this flag. * @param featureKey the unique key for the feature flag * @param user the end user requesting the flag * @param defaultValue the default value of the flag @@ -123,7 +125,8 @@ public interface LDClientInterface extends Closeable { /** * Calculates the value of a feature flag for a given user, and returns an object that describes the - * way the value was determined. + * way the value was determined. The {@code reason} property in the result will also be included in + * analytics events, if you are capturing detailed event data for this flag. * @param featureKey the unique key for the feature flag * @param user the end user requesting the flag * @param defaultValue the default value of the flag @@ -134,7 +137,8 @@ public interface LDClientInterface extends Closeable { /** * Calculates the value of a feature flag for a given user, and returns an object that describes the - * way the value was determined. + * way the value was determined. The {@code reason} property in the result will also be included in + * analytics events, if you are capturing detailed event data for this flag. * @param featureKey the unique key for the feature flag * @param user the end user requesting the flag * @param defaultValue the default value of the flag @@ -145,7 +149,8 @@ public interface LDClientInterface extends Closeable { /** * Calculates the value of a feature flag for a given user, and returns an object that describes the - * way the value was determined. + * way the value was determined. The {@code reason} property in the result will also be included in + * analytics events, if you are capturing detailed event data for this flag. * @param featureKey the unique key for the feature flag * @param user the end user requesting the flag * @param defaultValue the default value of the flag From 4810c14178584f84a596153106758b0ecfd6d21d Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Mon, 13 Aug 2018 11:01:46 -0700 Subject: [PATCH 14/43] add unit test to verify that the reason object can return a non-zero rule index --- .../java/com/launchdarkly/client/FeatureFlagTest.java | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/src/test/java/com/launchdarkly/client/FeatureFlagTest.java b/src/test/java/com/launchdarkly/client/FeatureFlagTest.java index ac32c3a89..d77fa3c3d 100644 --- a/src/test/java/com/launchdarkly/client/FeatureFlagTest.java +++ b/src/test/java/com/launchdarkly/client/FeatureFlagTest.java @@ -231,11 +231,13 @@ public void flagMatchesUserFromTargets() throws Exception { @Test public void flagMatchesUserFromRules() throws Exception { - Clause clause = new Clause("key", Operator.in, Arrays.asList(js("userkey")), false); - Rule rule = new Rule("ruleid", Arrays.asList(clause), 2, null); + Clause clause0 = new Clause("key", Operator.in, Arrays.asList(js("wrongkey")), false); + Clause clause1 = new Clause("key", Operator.in, Arrays.asList(js("userkey")), false); + Rule rule0 = new Rule("ruleid0", Arrays.asList(clause0), 2, null); + Rule rule1 = new Rule("ruleid1", Arrays.asList(clause1), 2, null); FeatureFlag f = new FeatureFlagBuilder("feature") .on(true) - .rules(Arrays.asList(rule)) + .rules(Arrays.asList(rule0, rule1)) .fallthrough(fallthroughVariation(0)) .offVariation(1) .variations(js("fall"), js("off"), js("on")) @@ -243,7 +245,7 @@ public void flagMatchesUserFromRules() throws Exception { LDUser user = new LDUser.Builder("userkey").build(); FeatureFlag.EvalResult result = f.evaluate(user, featureStore, EventFactory.DEFAULT); - assertEquals(new EvaluationDetail<>(EvaluationReason.ruleMatch(0, "ruleid"), 2, js("on")), result.getDetails()); + assertEquals(new EvaluationDetail<>(EvaluationReason.ruleMatch(1, "ruleid1"), 2, js("on")), result.getDetails()); assertEquals(0, result.getPrerequisiteEvents().size()); } From 52951a89ad1fe35d991a5fc5640687f8d8ed489e Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Mon, 13 Aug 2018 19:56:49 -0700 Subject: [PATCH 15/43] always include ruleIndex in toString() --- src/main/java/com/launchdarkly/client/EvaluationReason.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/com/launchdarkly/client/EvaluationReason.java b/src/main/java/com/launchdarkly/client/EvaluationReason.java index 679b007e3..b9c2a8809 100644 --- a/src/main/java/com/launchdarkly/client/EvaluationReason.java +++ b/src/main/java/com/launchdarkly/client/EvaluationReason.java @@ -211,7 +211,7 @@ public int hashCode() { @Override public String toString() { - return getKind().name() + "(" + (ruleId == null ? String.valueOf(ruleIndex) : ruleId + ")"); + return getKind().name() + "(" + ruleIndex + (ruleId == null ? "" : ("," + ruleId)) + ")"; } } From fa5df96e796c8662bd72ad54f7ee68e145368bb0 Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Mon, 13 Aug 2018 19:57:03 -0700 Subject: [PATCH 16/43] make sure kind property gets serialized to JSON --- .../launchdarkly/client/EvaluationReason.java | 41 ++++++++++--------- 1 file changed, 21 insertions(+), 20 deletions(-) diff --git a/src/main/java/com/launchdarkly/client/EvaluationReason.java b/src/main/java/com/launchdarkly/client/EvaluationReason.java index b9c2a8809..66900cc2f 100644 --- a/src/main/java/com/launchdarkly/client/EvaluationReason.java +++ b/src/main/java/com/launchdarkly/client/EvaluationReason.java @@ -79,18 +79,26 @@ public static enum ErrorKind { EXCEPTION } + private final Kind kind; + /** * Returns an enum indicating the general category of the reason. * @return a {@link Kind} value */ - public abstract Kind getKind(); + public Kind getKind() + { + return kind; + } @Override public String toString() { return getKind().name(); } - private EvaluationReason() { } + protected EvaluationReason(Kind kind) + { + this.kind = kind; + } /** * Returns an instance of {@link Off}. @@ -150,8 +158,8 @@ public static Error error(ErrorKind errorKind) { * @since 4.3.0 */ public static class Off extends EvaluationReason { - public Kind getKind() { - return Kind.OFF; + private Off() { + super(Kind.OFF); } private static final Off instance = new Off(); @@ -163,8 +171,9 @@ public Kind getKind() { * @since 4.3.0 */ public static class TargetMatch extends EvaluationReason { - public Kind getKind() { - return Kind.TARGET_MATCH; + private TargetMatch() + { + super(Kind.TARGET_MATCH); } private static final TargetMatch instance = new TargetMatch(); @@ -179,14 +188,11 @@ public static class RuleMatch extends EvaluationReason { private final String ruleId; private RuleMatch(int ruleIndex, String ruleId) { + super(Kind.RULE_MATCH); this.ruleIndex = ruleIndex; this.ruleId = ruleId; } - public Kind getKind() { - return Kind.RULE_MATCH; - } - public int getRuleIndex() { return ruleIndex; } @@ -224,14 +230,11 @@ public static class PrerequisitesFailed extends EvaluationReason { private final ImmutableList prerequisiteKeys; private PrerequisitesFailed(Iterable prerequisiteKeys) { + super(Kind.PREREQUISITES_FAILED); checkNotNull(prerequisiteKeys); this.prerequisiteKeys = ImmutableList.copyOf(prerequisiteKeys); } - public Kind getKind() { - return Kind.PREREQUISITES_FAILED; - } - public Iterable getPrerequisiteKeys() { return prerequisiteKeys; } @@ -262,8 +265,9 @@ public String toString() { * @since 4.3.0 */ public static class Fallthrough extends EvaluationReason { - public Kind getKind() { - return Kind.FALLTHROUGH; + private Fallthrough() + { + super(Kind.FALLTHROUGH); } private static final Fallthrough instance = new Fallthrough(); @@ -277,14 +281,11 @@ public static class Error extends EvaluationReason { private final ErrorKind errorKind; private Error(ErrorKind errorKind) { + super(Kind.ERROR); checkNotNull(errorKind); this.errorKind = errorKind; } - public Kind getKind() { - return Kind.ERROR; - } - public ErrorKind getErrorKind() { return errorKind; } From 849f085a86a05cc5c1a8fd82d351d835a4f255c5 Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Mon, 13 Aug 2018 19:58:04 -0700 Subject: [PATCH 17/43] version 4.3.0-SNAPSHOT --- gradle.properties | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle.properties b/gradle.properties index 3035e6ef6..8c62155b0 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,3 +1,3 @@ -version=4.2.0 +version=4.3.0-SNAPSHOT ossrhUsername= ossrhPassword= From 2bae5ab2ca7ea2100bcd64a8aeda5eabacdaa891 Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Thu, 16 Aug 2018 10:59:35 -0700 Subject: [PATCH 18/43] better error logging practices --- .../java/com/launchdarkly/client/Clause.java | 6 +-- .../client/DefaultEventProcessor.java | 8 ++-- .../com/launchdarkly/client/FeatureFlag.java | 4 +- .../com/launchdarkly/client/LDClient.java | 38 ++++++++++++------- .../client/NewRelicReflector.java | 3 +- .../launchdarkly/client/PollingProcessor.java | 3 +- .../launchdarkly/client/StreamProcessor.java | 10 +++-- 7 files changed, 43 insertions(+), 29 deletions(-) diff --git a/src/main/java/com/launchdarkly/client/Clause.java b/src/main/java/com/launchdarkly/client/Clause.java index 65cb756dc..2f69bf79b 100644 --- a/src/main/java/com/launchdarkly/client/Clause.java +++ b/src/main/java/com/launchdarkly/client/Clause.java @@ -38,7 +38,7 @@ boolean matchesUserNoSegments(LDUser user) { JsonArray array = userValue.getAsJsonArray(); for (JsonElement jsonElement : array) { if (!jsonElement.isJsonPrimitive()) { - logger.error("Invalid custom attribute value in user object: " + jsonElement); + logger.error("Invalid custom attribute value in user object for user key \"{}\": {}", user.getKey(), jsonElement); return false; } if (matchAny(jsonElement.getAsJsonPrimitive())) { @@ -49,8 +49,8 @@ boolean matchesUserNoSegments(LDUser user) { } else if (userValue.isJsonPrimitive()) { return maybeNegate(matchAny(userValue.getAsJsonPrimitive())); } - logger.warn("Got unexpected user attribute type: " + userValue.getClass().getName() + " for user key: " - + user.getKey() + " and attribute: " + attribute); + logger.warn("Got unexpected user attribute type \"{}\" for user key \"{}\" and attribute \"{}\"", + userValue.getClass().getName(), user.getKey(), attribute); return false; } diff --git a/src/main/java/com/launchdarkly/client/DefaultEventProcessor.java b/src/main/java/com/launchdarkly/client/DefaultEventProcessor.java index c165d8f9b..eed973fa2 100644 --- a/src/main/java/com/launchdarkly/client/DefaultEventProcessor.java +++ b/src/main/java/com/launchdarkly/client/DefaultEventProcessor.java @@ -255,8 +255,8 @@ private void runMainLoop(BlockingQueue inputChannel, message.completed(); } catch (InterruptedException e) { } catch (Exception e) { - logger.error("Unexpected error in event processor: " + e); - logger.debug(e.getMessage(), e); + logger.error("Unexpected error in event processor: {}", e.toString()); + logger.debug(e.toString(), e); } } } @@ -493,8 +493,8 @@ public void run() { postEvents(eventsOut); } } catch (Exception e) { - logger.error("Unexpected error in event processor: " + e); - logger.debug(e.getMessage(), e); + logger.error("Unexpected error in event processor: {}", e.toString()); + logger.debug(e.toString(), e); } synchronized (activeFlushWorkersCount) { activeFlushWorkersCount.decrementAndGet(); diff --git a/src/main/java/com/launchdarkly/client/FeatureFlag.java b/src/main/java/com/launchdarkly/client/FeatureFlag.java index faff33c6b..2b06efe03 100644 --- a/src/main/java/com/launchdarkly/client/FeatureFlag.java +++ b/src/main/java/com/launchdarkly/client/FeatureFlag.java @@ -65,7 +65,7 @@ EvalResult evaluate(LDUser user, FeatureStore featureStore, EventFactory eventFa List prereqEvents = new ArrayList<>(); if (user == null || user.getKey() == null) { - logger.warn("Null user or null user key when evaluating flag: " + key + "; returning null"); + logger.warn("Null user or null user key when evaluating flag \"{}\"; returning null", key); return new EvalResult(null, prereqEvents); } @@ -87,7 +87,7 @@ private VariationAndValue evaluate(LDUser user, FeatureStore featureStore, List< FeatureFlag prereqFeatureFlag = featureStore.get(FEATURES, prereq.getKey()); VariationAndValue prereqEvalResult = null; if (prereqFeatureFlag == null) { - logger.error("Could not retrieve prerequisite flag: " + prereq.getKey() + " when evaluating: " + key); + logger.error("Could not retrieve prerequisite flag \"{}\" when evaluating \"{}\"", prereq.getKey(), key); return null; } else if (prereqFeatureFlag.isOn()) { prereqEvalResult = prereqFeatureFlag.evaluate(user, featureStore, events, eventFactory); diff --git a/src/main/java/com/launchdarkly/client/LDClient.java b/src/main/java/com/launchdarkly/client/LDClient.java index 39d193272..9dba6eca0 100644 --- a/src/main/java/com/launchdarkly/client/LDClient.java +++ b/src/main/java/com/launchdarkly/client/LDClient.java @@ -95,7 +95,8 @@ public LDClient(String sdkKey, LDConfig config) { } catch (TimeoutException e) { logger.error("Timeout encountered waiting for LaunchDarkly client initialization"); } catch (Exception e) { - logger.error("Exception encountered waiting for LaunchDarkly client initialization", e); + logger.error("Exception encountered waiting for LaunchDarkly client initialization: {}", e.toString()); + logger.debug(e.toString(), e); } if (!updateProcessor.initialized()) { logger.warn("LaunchDarkly client was not successfully initialized"); @@ -169,7 +170,8 @@ public Map allFlags(LDUser user) { result.put(entry.getKey(), evalResult); } catch (EvaluationException e) { - logger.error("Exception caught when evaluating all flags:", e); + logger.error("Exception caught for feature flag \"{}\" when evaluating all flags: {}", entry.getKey(), e.toString()); + logger.debug(e.toString(), e); } } return result; @@ -209,9 +211,9 @@ public JsonElement jsonVariation(String featureKey, LDUser user, JsonElement def public boolean isFlagKnown(String featureKey) { if (!initialized()) { if (featureStore.initialized()) { - logger.warn("isFlagKnown called before client initialized for feature flag " + featureKey + "; using last known values from feature store"); + logger.warn("isFlagKnown called before client initialized for feature flag \"{}\"; using last known values from feature store", featureKey); } else { - logger.warn("isFlagKnown called before client initialized for feature flag " + featureKey + "; feature store unavailable, returning false"); + logger.warn("isFlagKnown called before client initialized for feature flag \"{}\"; feature store unavailable, returning false", featureKey); return false; } } @@ -221,7 +223,8 @@ public boolean isFlagKnown(String featureKey) { return true; } } catch (Exception e) { - logger.error("Encountered exception in LaunchDarkly client", e); + logger.error("Encountered exception while calling isFlagKnown for feature flag \"{}\": {}", e.toString()); + logger.debug(e.toString(), e); } return false; @@ -230,23 +233,24 @@ public boolean isFlagKnown(String featureKey) { private JsonElement evaluate(String featureKey, LDUser user, JsonElement defaultValue, VariationType expectedType) { if (!initialized()) { if (featureStore.initialized()) { - logger.warn("Evaluation called before client initialized for feature flag " + featureKey + "; using last known values from feature store"); + logger.warn("Evaluation called before client initialized for feature flag \"{}\"; using last known values from feature store", featureKey); } else { - logger.warn("Evaluation called before client initialized for feature flag " + featureKey + "; feature store unavailable, returning default value"); + logger.warn("Evaluation called before client initialized for feature flag \"{}\"; feature store unavailable, returning default value", featureKey); sendFlagRequestEvent(eventFactory.newUnknownFeatureRequestEvent(featureKey, user, defaultValue)); return defaultValue; } } + FeatureFlag featureFlag = null; try { - FeatureFlag featureFlag = featureStore.get(FEATURES, featureKey); + featureFlag = featureStore.get(FEATURES, featureKey); if (featureFlag == null) { - logger.info("Unknown feature flag " + featureKey + "; returning default value"); + logger.info("Unknown feature flag \"{}\"; returning default value", featureKey); sendFlagRequestEvent(eventFactory.newUnknownFeatureRequestEvent(featureKey, user, defaultValue)); return defaultValue; } if (user == null || user.getKey() == null) { - logger.warn("Null user or null user key when evaluating flag: " + featureKey + "; returning default value"); + logger.warn("Null user or null user key when evaluating flag \"{}\"; returning default value", featureKey); sendFlagRequestEvent(eventFactory.newDefaultFeatureRequestEvent(featureFlag, user, defaultValue)); return defaultValue; } @@ -266,10 +270,15 @@ private JsonElement evaluate(String featureKey, LDUser user, JsonElement default return defaultValue; } } catch (Exception e) { - logger.error("Encountered exception in LaunchDarkly client", e); + logger.error("Encountered exception while evaluating feature flag \"{}\": {}", featureKey, e.toString()); + logger.debug(e.toString(), e); + if (featureFlag == null) { + sendFlagRequestEvent(eventFactory.newUnknownFeatureRequestEvent(featureKey, user, defaultValue)); + } else { + sendFlagRequestEvent(eventFactory.newDefaultFeatureRequestEvent(featureFlag, user, defaultValue)); + } + return defaultValue; } - sendFlagRequestEvent(eventFactory.newUnknownFeatureRequestEvent(featureKey, user, defaultValue)); - return defaultValue; } @Override @@ -314,7 +323,8 @@ public String secureModeHash(LDUser user) { mac.init(new SecretKeySpec(sdkKey.getBytes(), HMAC_ALGORITHM)); return Hex.encodeHexString(mac.doFinal(user.getKeyAsString().getBytes("UTF8"))); } catch (InvalidKeyException | UnsupportedEncodingException | NoSuchAlgorithmException e) { - logger.error("Could not generate secure mode hash", e); + logger.error("Could not generate secure mode hash: {}", e.toString()); + logger.debug(e.toString(), e); } return null; } diff --git a/src/main/java/com/launchdarkly/client/NewRelicReflector.java b/src/main/java/com/launchdarkly/client/NewRelicReflector.java index f01832d6f..8a9c1c0cb 100644 --- a/src/main/java/com/launchdarkly/client/NewRelicReflector.java +++ b/src/main/java/com/launchdarkly/client/NewRelicReflector.java @@ -28,7 +28,8 @@ static void annotateTransaction(String featureKey, String value) { try { addCustomParameter.invoke(null, featureKey, value); } catch (Exception e) { - logger.error("Unexpected error in LaunchDarkly NewRelic integration"); + logger.error("Unexpected error in LaunchDarkly NewRelic integration: {}", e.toString()); + logger.debug(e.toString(), e); } } } diff --git a/src/main/java/com/launchdarkly/client/PollingProcessor.java b/src/main/java/com/launchdarkly/client/PollingProcessor.java index bb261236a..ad8fdaead 100644 --- a/src/main/java/com/launchdarkly/client/PollingProcessor.java +++ b/src/main/java/com/launchdarkly/client/PollingProcessor.java @@ -70,7 +70,8 @@ public void run() { initFuture.set(null); // if client is initializing, make it stop waiting; has no effect if already inited } } catch (IOException e) { - logger.error("Encountered exception in LaunchDarkly client when retrieving update", e); + logger.error("Encountered exception in LaunchDarkly client when retrieving update: {}", e.toString()); + logger.debug(e.toString(), e); } } }, 0L, config.pollingIntervalMillis, TimeUnit.MILLISECONDS); diff --git a/src/main/java/com/launchdarkly/client/StreamProcessor.java b/src/main/java/com/launchdarkly/client/StreamProcessor.java index 1cdf5b7d1..6c98f4ee0 100644 --- a/src/main/java/com/launchdarkly/client/StreamProcessor.java +++ b/src/main/java/com/launchdarkly/client/StreamProcessor.java @@ -133,7 +133,8 @@ public void onMessage(String name, MessageEvent event) throws Exception { logger.info("Initialized LaunchDarkly client."); } } catch (IOException e) { - logger.error("Encountered exception in LaunchDarkly client", e); + logger.error("Encountered exception in LaunchDarkly client: {}", e.toString()); + logger.debug(e.toString(), e); } break; case INDIRECT_PATCH: @@ -151,7 +152,8 @@ public void onMessage(String name, MessageEvent event) throws Exception { } } } catch (IOException e) { - logger.error("Encountered exception in LaunchDarkly client", e); + logger.error("Encountered exception in LaunchDarkly client: {}", e.toString()); + logger.debug(e.toString(), e); } break; default: @@ -167,8 +169,8 @@ public void onComment(String comment) { @Override public void onError(Throwable throwable) { - logger.error("Encountered EventSource error: " + throwable.getMessage()); - logger.debug("", throwable); + logger.error("Encountered EventSource error: {}" + throwable.toString()); + logger.debug(throwable.toString(), throwable); } }; From 361849ca42ae4f40c03048a7e324e72ae29b3cc7 Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Thu, 16 Aug 2018 11:40:17 -0700 Subject: [PATCH 19/43] add new version of allFlags() that captures more metadata --- .../client/FeatureFlagsState.java | 106 +++++++++++++++ .../com/launchdarkly/client/LDClient.java | 41 +++--- .../client/LDClientInterface.java | 16 +++ .../client/FeatureFlagsStateTest.java | 65 +++++++++ .../client/LDClientEvaluationTest.java | 123 +++++++++++++++++- .../client/LDClientOfflineTest.java | 29 ++++- 6 files changed, 356 insertions(+), 24 deletions(-) create mode 100644 src/main/java/com/launchdarkly/client/FeatureFlagsState.java create mode 100644 src/test/java/com/launchdarkly/client/FeatureFlagsStateTest.java diff --git a/src/main/java/com/launchdarkly/client/FeatureFlagsState.java b/src/main/java/com/launchdarkly/client/FeatureFlagsState.java new file mode 100644 index 000000000..a9faa10f2 --- /dev/null +++ b/src/main/java/com/launchdarkly/client/FeatureFlagsState.java @@ -0,0 +1,106 @@ +package com.launchdarkly.client; + +import com.google.common.collect.ImmutableMap; +import com.google.gson.Gson; +import com.google.gson.JsonElement; + +import java.util.HashMap; +import java.util.Map; + +/** + * A snapshot of the state of all feature flags with regard to a specific user, generated by + * calling {@link LDClientInterface#allFlagsState(LDUser)}. + * + * @since 4.3.0 + */ +public class FeatureFlagsState { + private static final Gson gson = new Gson(); + + private final ImmutableMap flagValues; + private final ImmutableMap flagMetadata; + private final boolean valid; + + static class FlagMetadata { + final Integer variation; + final int version; + final boolean trackEvents; + final Long debugEventsUntilDate; + + FlagMetadata(Integer variation, int version, boolean trackEvents, + Long debugEventsUntilDate) { + this.variation = variation; + this.version = version; + this.trackEvents = trackEvents; + this.debugEventsUntilDate = debugEventsUntilDate; + } + } + + private FeatureFlagsState(Builder builder) { + this.flagValues = builder.flagValues.build(); + this.flagMetadata = builder.flagMetadata.build(); + this.valid = builder.valid; + } + + /** + * Returns true if this object contains a valid snapshot of feature flag state, or false if the + * state could not be computed (for instance, because the client was offline or there was no user). + * @return true if the state is valid + */ + public boolean isValid() { + return valid; + } + + /** + * Returns the value of an individual feature flag at the time the state was recorded. + * @param key the feature flag key + * @return the flag's JSON value; null if the flag returned the default value, or if there was no such flag + */ + public JsonElement getFlagValue(String key) { + return flagValues.get(key); + } + + /** + * Returns a map of flag keys to flag values. If a flag would have evaluated to the default value, + * its value will be null. + * @return an immutable map of flag keys to JSON values + */ + public Map toValuesMap() { + return flagValues; + } + + /** + * Returns a JSON string representation of the entire state map, in the format used by the + * LaunchDarkly JavaScript SDK. Use this method if you are passing data to the front end that + * will be used to "bootstrap" the JavaScript client. + * @return a JSON representation of the state object + */ + public String toJsonString() { + Map outerMap = new HashMap<>(); + outerMap.putAll(flagValues); + outerMap.put("$flagsState", flagMetadata); + return gson.toJson(outerMap); + } + + static class Builder { + private ImmutableMap.Builder flagValues = ImmutableMap.builder(); + private ImmutableMap.Builder flagMetadata = ImmutableMap.builder(); + private boolean valid = true; + + Builder valid(boolean valid) { + this.valid = valid; + return this; + } + + Builder addFlag(FeatureFlag flag, FeatureFlag.VariationAndValue eval) { + flagValues.put(flag.getKey(), eval.getValue()); + FlagMetadata data = new FlagMetadata(eval.getVariation(), + flag.getVersion(), flag.isTrackEvents(), flag.getDebugEventsUntilDate()); + flagMetadata.put(flag.getKey(), data); + return this; + } + + FeatureFlagsState build() { + return new FeatureFlagsState(this); + } + } +} diff --git a/src/main/java/com/launchdarkly/client/LDClient.java b/src/main/java/com/launchdarkly/client/LDClient.java index 39d193272..75a6499db 100644 --- a/src/main/java/com/launchdarkly/client/LDClient.java +++ b/src/main/java/com/launchdarkly/client/LDClient.java @@ -12,7 +12,6 @@ import java.net.URL; import java.security.InvalidKeyException; import java.security.NoSuchAlgorithmException; -import java.util.HashMap; import java.util.Map; import java.util.concurrent.Future; import java.util.concurrent.TimeUnit; @@ -142,39 +141,49 @@ private void sendFlagRequestEvent(Event.FeatureRequest event) { @Override public Map allFlags(LDUser user) { - if (isOffline()) { - logger.debug("allFlags() was called when client is in offline mode."); + FeatureFlagsState state = allFlagsState(user); + if (!state.isValid()) { + return null; } + return state.toValuesMap(); + } + @Override + public FeatureFlagsState allFlagsState(LDUser user) { + FeatureFlagsState.Builder builder = new FeatureFlagsState.Builder(); + + if (isOffline()) { + logger.debug("allFlagsState() was called when client is in offline mode."); + } + if (!initialized()) { if (featureStore.initialized()) { - logger.warn("allFlags() was called before client initialized; using last known values from feature store"); + logger.warn("allFlagsState() was called before client initialized; using last known values from feature store"); } else { - logger.warn("allFlags() was called before client initialized; feature store unavailable, returning null"); - return null; + logger.warn("allFlagsState() was called before client initialized; feature store unavailable, returning no data"); + return builder.valid(false).build(); } } if (user == null || user.getKey() == null) { - logger.warn("allFlags() was called with null user or null user key! returning null"); - return null; + logger.warn("allFlagsState() was called with null user or null user key! returning no data"); + return builder.valid(false).build(); } Map flags = featureStore.all(FEATURES); - Map result = new HashMap<>(); - for (Map.Entry entry : flags.entrySet()) { try { - JsonElement evalResult = entry.getValue().evaluate(user, featureStore, eventFactory).getResult().getValue(); - result.put(entry.getKey(), evalResult); - + FeatureFlag.VariationAndValue eval = entry.getValue().evaluate(user, featureStore, EventFactory.DEFAULT).getResult(); + builder.addFlag(entry.getValue(), eval); } catch (EvaluationException e) { - logger.error("Exception caught when evaluating all flags:", e); + logger.error("Exception caught for feature flag \"{}\" when evaluating all flags: {}", entry.getKey(), e.toString()); + logger.debug(e.toString(), e); + builder.addFlag(entry.getValue(), new FeatureFlag.VariationAndValue(null, null)); } } - return result; + return builder.build(); } - + @Override public boolean boolVariation(String featureKey, LDUser user, boolean defaultValue) { JsonElement value = evaluate(featureKey, user, new JsonPrimitive(defaultValue), VariationType.Boolean); diff --git a/src/main/java/com/launchdarkly/client/LDClientInterface.java b/src/main/java/com/launchdarkly/client/LDClientInterface.java index 94ee3f060..33941085a 100644 --- a/src/main/java/com/launchdarkly/client/LDClientInterface.java +++ b/src/main/java/com/launchdarkly/client/LDClientInterface.java @@ -46,9 +46,25 @@ public interface LDClientInterface extends Closeable { * * @param user the end user requesting the feature flags * @return a map from feature flag keys to {@code JsonElement} for the specified user + * + * @deprecated Use {@link #allFlagsState} instead. Current versions of the client-side SDK will not generate analytics + * events correctly if you pass the result of {@code allFlags()}. */ + @Deprecated Map allFlags(LDUser user); + /** + * Returns an object that encapsulates the state of all feature flags for a given user, including the flag + * values and, optionally, their {@link EvaluationReason}s. + *

+ * The most common use case for this method is to bootstrap a set of client-side feature flags from a back-end service. + * + * @param user the end user requesting the feature flags + * @return a {@link FeatureFlagsState} object (will never be null; see {@link FeatureFlagsState#isValid()} + * @since 4.3.0 + */ + FeatureFlagsState allFlagsState(LDUser user); + /** * Calculates the value of a feature flag for a given user. * diff --git a/src/test/java/com/launchdarkly/client/FeatureFlagsStateTest.java b/src/test/java/com/launchdarkly/client/FeatureFlagsStateTest.java new file mode 100644 index 000000000..bda54ad46 --- /dev/null +++ b/src/test/java/com/launchdarkly/client/FeatureFlagsStateTest.java @@ -0,0 +1,65 @@ +package com.launchdarkly.client; + +import com.google.common.collect.ImmutableMap; +import com.google.gson.Gson; +import com.google.gson.JsonElement; + +import org.junit.Test; + +import static com.launchdarkly.client.TestUtil.js; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNull; + +public class FeatureFlagsStateTest { + private static final Gson gson = new Gson(); + + @Test + public void canGetFlagValue() { + FeatureFlag.VariationAndValue eval = new FeatureFlag.VariationAndValue(1, js("value")); + FeatureFlag flag = new FeatureFlagBuilder("key").build(); + FeatureFlagsState state = new FeatureFlagsState.Builder().addFlag(flag, eval).build(); + + assertEquals(js("value"), state.getFlagValue("key")); + } + + @Test + public void unknownFlagReturnsNullValue() { + FeatureFlagsState state = new FeatureFlagsState.Builder().build(); + + assertNull(state.getFlagValue("key")); + } + + @Test + public void canConvertToValuesMap() { + FeatureFlag.VariationAndValue eval1 = new FeatureFlag.VariationAndValue(0, js("value1")); + FeatureFlag flag1 = new FeatureFlagBuilder("key1").build(); + FeatureFlag.VariationAndValue eval2 = new FeatureFlag.VariationAndValue(1, js("value2")); + FeatureFlag flag2 = new FeatureFlagBuilder("key2").build(); + FeatureFlagsState state = new FeatureFlagsState.Builder() + .addFlag(flag1, eval1).addFlag(flag2, eval2).build(); + + ImmutableMap expected = ImmutableMap.of("key1", js("value1"), "key2", js("value2")); + assertEquals(expected, state.toValuesMap()); + } + + @Test + public void canConvertToJson() { + FeatureFlag.VariationAndValue eval1 = new FeatureFlag.VariationAndValue(0, js("value1")); + FeatureFlag flag1 = new FeatureFlagBuilder("key1").version(100).trackEvents(false).build(); + FeatureFlag.VariationAndValue eval2 = new FeatureFlag.VariationAndValue(1, js("value2")); + FeatureFlag flag2 = new FeatureFlagBuilder("key2").version(200).trackEvents(true).debugEventsUntilDate(1000L).build(); + FeatureFlagsState state = new FeatureFlagsState.Builder() + .addFlag(flag1, eval1).addFlag(flag2, eval2).build(); + + String json = "{\"key1\":\"value1\",\"key2\":\"value2\"," + + "\"$flagsState\":{" + + "\"key1\":{" + + "\"variation\":0,\"version\":100,\"trackEvents\":false" + + "},\"key2\":{" + + "\"variation\":1,\"version\":200,\"trackEvents\":true,\"debugEventsUntilDate\":1000" + + "}" + + "}}"; + JsonElement expected = gson.fromJson(json, JsonElement.class); + assertEquals(expected, gson.fromJson(state.toJsonString(), JsonElement.class)); + } +} diff --git a/src/test/java/com/launchdarkly/client/LDClientEvaluationTest.java b/src/test/java/com/launchdarkly/client/LDClientEvaluationTest.java index f6d175dc0..cfb5fdce0 100644 --- a/src/test/java/com/launchdarkly/client/LDClientEvaluationTest.java +++ b/src/test/java/com/launchdarkly/client/LDClientEvaluationTest.java @@ -1,12 +1,16 @@ package com.launchdarkly.client; +import com.google.common.collect.ImmutableMap; +import com.google.gson.Gson; import com.google.gson.JsonElement; import com.google.gson.JsonObject; import org.junit.Test; import java.util.Arrays; +import java.util.Map; +import static com.launchdarkly.client.TestUtil.fallthroughVariation; import static com.launchdarkly.client.TestUtil.jint; import static com.launchdarkly.client.TestUtil.js; import static com.launchdarkly.client.TestUtil.specificFeatureStore; @@ -14,11 +18,14 @@ import static com.launchdarkly.client.VersionedDataKind.SEGMENTS; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNull; import static org.junit.Assert.assertTrue; public class LDClientEvaluationTest { private static final LDUser user = new LDUser("userkey"); - + private static final LDUser userWithNullKey = new LDUser.Builder((String)null).build(); + private static final Gson gson = new Gson(); + private TestFeatureStore featureStore = new TestFeatureStore(); private LDConfig config = new LDConfig.Builder() .featureStoreFactory(specificFeatureStore(featureStore)) @@ -112,4 +119,118 @@ public void canMatchUserBySegment() throws Exception { assertTrue(client.boolVariation("test-feature", user, false)); } + + @SuppressWarnings("deprecation") + @Test + public void allFlagsReturnsFlagValues() throws Exception { + featureStore.setStringValue("key1","value1"); + featureStore.setStringValue("key2", "value2"); + + Map result = client.allFlags(user); + assertEquals(ImmutableMap.of("key1", js("value1"), "key2", js("value2")), result); + } + + @SuppressWarnings("deprecation") + @Test + public void allFlagsReturnsNullForNullUser() throws Exception { + featureStore.setStringValue("key", "value"); + + assertNull(client.allFlags(null)); + } + + @SuppressWarnings("deprecation") + @Test + public void allFlagsReturnsNullForNullUserKey() throws Exception { + featureStore.setStringValue("key", "value"); + + assertNull(client.allFlags(userWithNullKey)); + } + + @Test + public void allFlagsStateReturnsState() throws Exception { + FeatureFlag flag1 = new FeatureFlagBuilder("key1") + .version(100) + .trackEvents(false) + .on(false) + .offVariation(0) + .variations(js("value1")) + .build(); + FeatureFlag flag2 = new FeatureFlagBuilder("key2") + .version(200) + .trackEvents(true) + .debugEventsUntilDate(1000L) + .on(true) + .fallthrough(fallthroughVariation(1)) + .variations(js("off"), js("value2")) + .build(); + featureStore.upsert(FEATURES, flag1); + featureStore.upsert(FEATURES, flag2); + + FeatureFlagsState state = client.allFlagsState(user); + assertTrue(state.isValid()); + + String json = "{\"key1\":\"value1\",\"key2\":\"value2\"," + + "\"$flagsState\":{" + + "\"key1\":{" + + "\"variation\":0,\"version\":100,\"trackEvents\":false" + + "},\"key2\":{" + + "\"variation\":1,\"version\":200,\"trackEvents\":true,\"debugEventsUntilDate\":1000" + + "}" + + "}}"; + JsonElement expected = gson.fromJson(json, JsonElement.class); + assertEquals(expected, gson.fromJson(state.toJsonString(), JsonElement.class)); + } + + @Test + public void allFlagsStateReturnsStateWithReasons() throws Exception { + FeatureFlag flag1 = new FeatureFlagBuilder("key1") + .version(100) + .trackEvents(false) + .on(false) + .offVariation(0) + .variations(js("value1")) + .build(); + FeatureFlag flag2 = new FeatureFlagBuilder("key2") + .version(200) + .trackEvents(true) + .debugEventsUntilDate(1000L) + .on(true) + .fallthrough(fallthroughVariation(1)) + .variations(js("off"), js("value2")) + .build(); + featureStore.upsert(FEATURES, flag1); + featureStore.upsert(FEATURES, flag2); + + FeatureFlagsState state = client.allFlagsState(user); + assertTrue(state.isValid()); + + String json = "{\"key1\":\"value1\",\"key2\":\"value2\"," + + "\"$flagsState\":{" + + "\"key1\":{" + + "\"variation\":0,\"version\":100,\"trackEvents\":false" + + "},\"key2\":{" + + "\"variation\":1,\"version\":200,\"trackEvents\":true,\"debugEventsUntilDate\":1000" + + "}" + + "}}"; + JsonElement expected = gson.fromJson(json, JsonElement.class); + assertEquals(expected, gson.fromJson(state.toJsonString(), JsonElement.class)); + } + + @Test + public void allFlagsStateReturnsEmptyStateForNullUser() throws Exception { + featureStore.setStringValue("key", "value"); + + FeatureFlagsState state = client.allFlagsState(null); + assertFalse(state.isValid()); + assertEquals(0, state.toValuesMap().size()); + } + + @Test + public void allFlagsStateReturnsEmptyStateForNullUserKey() throws Exception { + featureStore.setStringValue("key", "value"); + + FeatureFlagsState state = client.allFlagsState(userWithNullKey); + assertFalse(state.isValid()); + assertEquals(0, state.toValuesMap().size()); + } } diff --git a/src/test/java/com/launchdarkly/client/LDClientOfflineTest.java b/src/test/java/com/launchdarkly/client/LDClientOfflineTest.java index 5a8369f49..10c3b46b2 100644 --- a/src/test/java/com/launchdarkly/client/LDClientOfflineTest.java +++ b/src/test/java/com/launchdarkly/client/LDClientOfflineTest.java @@ -1,19 +1,21 @@ package com.launchdarkly.client; +import com.google.common.collect.ImmutableMap; import com.google.gson.JsonElement; -import com.google.gson.JsonPrimitive; import org.junit.Test; import java.io.IOException; import java.util.Map; +import static com.launchdarkly.client.TestUtil.jbool; import static com.launchdarkly.client.TestUtil.specificFeatureStore; import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertNotNull; import static org.junit.Assert.assertTrue; public class LDClientOfflineTest { + private static final LDUser user = new LDUser("user"); + @Test public void offlineClientHasNullUpdateProcessor() throws IOException { LDConfig config = new LDConfig.Builder() @@ -50,7 +52,7 @@ public void offlineClientReturnsDefaultValue() throws IOException { .offline(true) .build(); try (LDClient client = new LDClient("SDK_KEY", config)) { - assertEquals("x", client.stringVariation("key", new LDUser("user"), "x")); + assertEquals("x", client.stringVariation("key", user, "x")); } } @@ -63,13 +65,26 @@ public void offlineClientGetsAllFlagsFromFeatureStore() throws IOException { .build(); testFeatureStore.setFeatureTrue("key"); try (LDClient client = new LDClient("SDK_KEY", config)) { - Map allFlags = client.allFlags(new LDUser("user")); - assertNotNull(allFlags); - assertEquals(1, allFlags.size()); - assertEquals(new JsonPrimitive(true), allFlags.get("key")); + Map allFlags = client.allFlags(user); + assertEquals(ImmutableMap.of("key", jbool(true)), allFlags); } } + @Test + public void offlineClientGetsFlagsStateFromFeatureStore() throws IOException { + TestFeatureStore testFeatureStore = new TestFeatureStore(); + LDConfig config = new LDConfig.Builder() + .offline(true) + .featureStoreFactory(specificFeatureStore(testFeatureStore)) + .build(); + testFeatureStore.setFeatureTrue("key"); + try (LDClient client = new LDClient("SDK_KEY", config)) { + FeatureFlagsState state = client.allFlagsState(user); + assertTrue(state.isValid()); + assertEquals(ImmutableMap.of("key", jbool(true)), state.toValuesMap()); + } + } + @Test public void testSecureModeHash() throws IOException { LDConfig config = new LDConfig.Builder() From 1889fb5a71f29a0b9224567e1d98ab476cbcc596 Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Thu, 16 Aug 2018 13:57:11 -0700 Subject: [PATCH 20/43] clarify comment --- src/main/java/com/launchdarkly/client/LDClientInterface.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/main/java/com/launchdarkly/client/LDClientInterface.java b/src/main/java/com/launchdarkly/client/LDClientInterface.java index 33941085a..e6420ca13 100644 --- a/src/main/java/com/launchdarkly/client/LDClientInterface.java +++ b/src/main/java/com/launchdarkly/client/LDClientInterface.java @@ -47,8 +47,8 @@ public interface LDClientInterface extends Closeable { * @param user the end user requesting the feature flags * @return a map from feature flag keys to {@code JsonElement} for the specified user * - * @deprecated Use {@link #allFlagsState} instead. Current versions of the client-side SDK will not generate analytics - * events correctly if you pass the result of {@code allFlags()}. + * @deprecated Use {@link #allFlagsState} instead. Current versions of the client-side SDK (2.0.0 and later) + * will not generate analytics events correctly if you pass the result of {@code allFlags()}. */ @Deprecated Map allFlags(LDUser user); From a4a56953d2447b0193a455305c7bcf153d6525d0 Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Thu, 16 Aug 2018 14:05:55 -0700 Subject: [PATCH 21/43] remove FOSSA upload step from CI --- .circleci/config.yml | 8 -------- 1 file changed, 8 deletions(-) diff --git a/.circleci/config.yml b/.circleci/config.yml index 2da9f6d9d..d938b76e3 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -22,11 +22,3 @@ jobs: path: ~/junit - store_artifacts: path: ~/junit - - run: - name: Upload FOSSA analysis (from master only) - command: | - if [[ ( -n "$FOSSA_API_KEY" ) && ( "$CIRCLE_BRANCH" == "master" ) ]]; then - curl -s -H 'Cache-Control: no-cache' https://raw.githubusercontent.com/fossas/fossa-cli/master/install.sh | sudo bash; - fossa init; - FOSSA_API_KEY=$FOSSA_API_KEY fossa; - fi From 7e91572dd802680fb3ad8b0f4481ea627b900329 Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Thu, 16 Aug 2018 14:49:54 -0700 Subject: [PATCH 22/43] rm duplicated test --- .../client/LDClientEvaluationTest.java | 35 ------------------- 1 file changed, 35 deletions(-) diff --git a/src/test/java/com/launchdarkly/client/LDClientEvaluationTest.java b/src/test/java/com/launchdarkly/client/LDClientEvaluationTest.java index cfb5fdce0..e717b3a11 100644 --- a/src/test/java/com/launchdarkly/client/LDClientEvaluationTest.java +++ b/src/test/java/com/launchdarkly/client/LDClientEvaluationTest.java @@ -181,41 +181,6 @@ public void allFlagsStateReturnsState() throws Exception { assertEquals(expected, gson.fromJson(state.toJsonString(), JsonElement.class)); } - @Test - public void allFlagsStateReturnsStateWithReasons() throws Exception { - FeatureFlag flag1 = new FeatureFlagBuilder("key1") - .version(100) - .trackEvents(false) - .on(false) - .offVariation(0) - .variations(js("value1")) - .build(); - FeatureFlag flag2 = new FeatureFlagBuilder("key2") - .version(200) - .trackEvents(true) - .debugEventsUntilDate(1000L) - .on(true) - .fallthrough(fallthroughVariation(1)) - .variations(js("off"), js("value2")) - .build(); - featureStore.upsert(FEATURES, flag1); - featureStore.upsert(FEATURES, flag2); - - FeatureFlagsState state = client.allFlagsState(user); - assertTrue(state.isValid()); - - String json = "{\"key1\":\"value1\",\"key2\":\"value2\"," + - "\"$flagsState\":{" + - "\"key1\":{" + - "\"variation\":0,\"version\":100,\"trackEvents\":false" + - "},\"key2\":{" + - "\"variation\":1,\"version\":200,\"trackEvents\":true,\"debugEventsUntilDate\":1000" + - "}" + - "}}"; - JsonElement expected = gson.fromJson(json, JsonElement.class); - assertEquals(expected, gson.fromJson(state.toJsonString(), JsonElement.class)); - } - @Test public void allFlagsStateReturnsEmptyStateForNullUser() throws Exception { featureStore.setStringValue("key", "value"); From a0dcfa72c1bed8d4300dbcd0f788e7546a574169 Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Thu, 16 Aug 2018 15:28:06 -0700 Subject: [PATCH 23/43] add error explanation for malformed flags --- .../launchdarkly/client/EvaluationDetail.java | 2 +- .../launchdarkly/client/EvaluationReason.java | 9 +- .../com/launchdarkly/client/FeatureFlag.java | 59 +++++------ .../com/launchdarkly/client/LDClient.java | 2 +- .../client/VariationOrRollout.java | 2 + .../launchdarkly/client/FeatureFlagTest.java | 97 +++++++++++++++++-- 6 files changed, 126 insertions(+), 45 deletions(-) diff --git a/src/main/java/com/launchdarkly/client/EvaluationDetail.java b/src/main/java/com/launchdarkly/client/EvaluationDetail.java index 5ea06b20b..c5535f47b 100644 --- a/src/main/java/com/launchdarkly/client/EvaluationDetail.java +++ b/src/main/java/com/launchdarkly/client/EvaluationDetail.java @@ -57,7 +57,7 @@ public boolean equals(Object other) { if (other instanceof EvaluationDetail) { @SuppressWarnings("unchecked") EvaluationDetail o = (EvaluationDetail)other; - return Objects.equal(reason, o.reason) && variationIndex == o.variationIndex && Objects.equal(value, o.value); + return Objects.equal(reason, o.reason) && Objects.equal(variationIndex, o.variationIndex) && Objects.equal(value, o.value); } return false; } diff --git a/src/main/java/com/launchdarkly/client/EvaluationReason.java b/src/main/java/com/launchdarkly/client/EvaluationReason.java index 66900cc2f..32654afc9 100644 --- a/src/main/java/com/launchdarkly/client/EvaluationReason.java +++ b/src/main/java/com/launchdarkly/client/EvaluationReason.java @@ -47,6 +47,7 @@ public static enum Kind { /** * Indicates that the flag could not be evaluated, e.g. because it does not exist or due to an unexpected * error. In this case the result value will be the default value that the caller passed to the client. + * Check the errorKind property for more details on the problem. */ ERROR; } @@ -64,6 +65,11 @@ public static enum ErrorKind { * Indicates that the caller provided a flag key that did not match any known flag. */ FLAG_NOT_FOUND, + /** + * Indicates that there was an internal inconsistency in the flag data, e.g. a rule specified a nonexistent + * variation. An error message will always be logged in this case. + */ + MALFORMED_FLAG, /** * Indicates that the caller passed {@code null} for the user parameter, or the user lacked a key. */ @@ -74,7 +80,8 @@ public static enum ErrorKind { */ WRONG_TYPE, /** - * Indicates that an unexpected exception stopped flag evaluation; check the log for details. + * Indicates that an unexpected exception stopped flag evaluation. An error message will always be logged + * in this case. */ EXCEPTION } diff --git a/src/main/java/com/launchdarkly/client/FeatureFlag.java b/src/main/java/com/launchdarkly/client/FeatureFlag.java index a8b99b7f9..b7be4a6f0 100644 --- a/src/main/java/com/launchdarkly/client/FeatureFlag.java +++ b/src/main/java/com/launchdarkly/client/FeatureFlag.java @@ -63,7 +63,7 @@ static Map fromJsonMap(LDConfig config, String json) { this.deleted = deleted; } - EvalResult evaluate(LDUser user, FeatureStore featureStore, EventFactory eventFactory) throws EvaluationException { + EvalResult evaluate(LDUser user, FeatureStore featureStore, EventFactory eventFactory) { List prereqEvents = new ArrayList<>(); if (user == null || user.getKey() == null) { @@ -77,15 +77,14 @@ EvalResult evaluate(LDUser user, FeatureStore featureStore, EventFactory eventFa return new EvalResult(details, prereqEvents); } - EvaluationDetail details = new EvaluationDetail<>(EvaluationReason.off(), offVariation, getOffVariationValue()); - return new EvalResult(details, prereqEvents); + return new EvalResult(getOffValue(EvaluationReason.off()), prereqEvents); } private EvaluationDetail evaluate(LDUser user, FeatureStore featureStore, List events, - EventFactory eventFactory) throws EvaluationException { + EventFactory eventFactory) { EvaluationReason prereqFailureReason = checkPrerequisites(user, featureStore, events, eventFactory); if (prereqFailureReason != null) { - return new EvaluationDetail<>(prereqFailureReason, offVariation, getOffVariationValue()); + return getOffValue(prereqFailureReason); } // Check to see if targets match @@ -93,8 +92,7 @@ private EvaluationDetail evaluate(LDUser user, FeatureStore feature for (Target target: targets) { for (String v : target.getValues()) { if (v.equals(user.getKey().getAsString())) { - return new EvaluationDetail<>(EvaluationReason.targetMatch(), - target.getVariation(), getVariation(target.getVariation())); + return getVariation(target.getVariation(), EvaluationReason.targetMatch()); } } } @@ -104,21 +102,18 @@ private EvaluationDetail evaluate(LDUser user, FeatureStore feature for (int i = 0; i < rules.size(); i++) { Rule rule = rules.get(i); if (rule.matchesUser(featureStore, user)) { - int index = rule.variationIndexForUser(user, key, salt); - return new EvaluationDetail<>(EvaluationReason.ruleMatch(i, rule.getId()), - index, getVariation(index)); + return getValueForVariationOrRollout(rule, user, EvaluationReason.ruleMatch(i, rule.getId())); } } } // Walk through the fallthrough and see if it matches - int index = fallthrough.variationIndexForUser(user, key, salt); - return new EvaluationDetail<>(EvaluationReason.fallthrough(), index, getVariation(index)); + return getValueForVariationOrRollout(fallthrough, user, EvaluationReason.fallthrough()); } // Checks prerequisites if any; returns null if successful, or an EvaluationReason if we have to // short-circuit due to a prerequisite failure. private EvaluationReason checkPrerequisites(LDUser user, FeatureStore featureStore, List events, - EventFactory eventFactory) throws EvaluationException { + EventFactory eventFactory) { if (prerequisites == null) { return null; } @@ -156,34 +151,30 @@ private EvaluationReason checkPrerequisites(LDUser user, FeatureStore featureSto return null; } - JsonElement getOffVariationValue() throws EvaluationException { - if (offVariation == null) { - return null; + private EvaluationDetail getVariation(int variation, EvaluationReason reason) { + if (variation < 0 || variation >= variations.size()) { + logger.error("Data inconsistency in feature flag \"{}\": invalid variation index", key); + return EvaluationDetail.error(EvaluationReason.ErrorKind.MALFORMED_FLAG, null); } + return new EvaluationDetail(reason, variation, variations.get(variation)); + } - if (offVariation >= variations.size()) { - throw new EvaluationException("Invalid off variation index"); + private EvaluationDetail getOffValue(EvaluationReason reason) { + if (offVariation == null) { // off variation unspecified - return default value + return new EvaluationDetail(reason, null, null); } - - return variations.get(offVariation); + return getVariation(offVariation, reason); } - - private JsonElement getVariation(Integer index) throws EvaluationException { - // If the supplied index is null, then rules didn't match, and we want to return - // the off variation + + private EvaluationDetail getValueForVariationOrRollout(VariationOrRollout vr, LDUser user, EvaluationReason reason) { + Integer index = vr.variationIndexForUser(user, key, salt); if (index == null) { - return null; - } - // If the index doesn't refer to a valid variation, that's an unexpected exception and we will - // return the default variation - else if (index >= variations.size()) { - throw new EvaluationException("Invalid index"); - } - else { - return variations.get(index); + logger.error("Data inconsistency in feature flag \"{}\": variation/rollout object with no variation or rollout", key); + return EvaluationDetail.error(EvaluationReason.ErrorKind.MALFORMED_FLAG, null); } + return getVariation(index, reason); } - + public int getVersion() { return version; } diff --git a/src/main/java/com/launchdarkly/client/LDClient.java b/src/main/java/com/launchdarkly/client/LDClient.java index 3c22a629a..f0eff55d5 100644 --- a/src/main/java/com/launchdarkly/client/LDClient.java +++ b/src/main/java/com/launchdarkly/client/LDClient.java @@ -167,7 +167,7 @@ public Map allFlags(LDUser user) { try { JsonElement evalResult = entry.getValue().evaluate(user, featureStore, EventFactory.DEFAULT).getDetails().getValue(); result.put(entry.getKey(), evalResult); - } catch (EvaluationException e) { + } catch (Exception e) { logger.error("Exception caught for feature flag \"{}\" when evaluating all flags: {}", entry.getKey(), e.toString()); logger.debug(e.toString(), e); } diff --git a/src/main/java/com/launchdarkly/client/VariationOrRollout.java b/src/main/java/com/launchdarkly/client/VariationOrRollout.java index 56537e498..41f58a676 100644 --- a/src/main/java/com/launchdarkly/client/VariationOrRollout.java +++ b/src/main/java/com/launchdarkly/client/VariationOrRollout.java @@ -24,6 +24,8 @@ class VariationOrRollout { this.rollout = rollout; } + // Attempt to determine the variation index for a given user. Returns null if no index can be computed + // due to internal inconsistency of the data (i.e. a malformed flag). Integer variationIndexForUser(LDUser user, String key, String salt) { if (variation != null) { return variation; diff --git a/src/test/java/com/launchdarkly/client/FeatureFlagTest.java b/src/test/java/com/launchdarkly/client/FeatureFlagTest.java index d77fa3c3d..f3f55e459 100644 --- a/src/test/java/com/launchdarkly/client/FeatureFlagTest.java +++ b/src/test/java/com/launchdarkly/client/FeatureFlagTest.java @@ -57,6 +57,34 @@ public void flagReturnsNullIfFlagIsOffAndOffVariationIsUnspecified() throws Exce assertEquals(0, result.getPrerequisiteEvents().size()); } + @Test + public void flagReturnsErrorIfFlagIsOffAndOffVariationIsTooHigh() throws Exception { + FeatureFlag f = new FeatureFlagBuilder("feature") + .on(false) + .offVariation(999) + .fallthrough(fallthroughVariation(0)) + .variations(js("fall"), js("off"), js("on")) + .build(); + FeatureFlag.EvalResult result = f.evaluate(BASE_USER, featureStore, EventFactory.DEFAULT); + + assertEquals(EvaluationDetail.error(EvaluationReason.ErrorKind.MALFORMED_FLAG, null), result.getDetails()); + assertEquals(0, result.getPrerequisiteEvents().size()); + } + + @Test + public void flagReturnsErrorIfFlagIsOffAndOffVariationIsNegative() throws Exception { + FeatureFlag f = new FeatureFlagBuilder("feature") + .on(false) + .offVariation(-1) + .fallthrough(fallthroughVariation(0)) + .variations(js("fall"), js("off"), js("on")) + .build(); + FeatureFlag.EvalResult result = f.evaluate(BASE_USER, featureStore, EventFactory.DEFAULT); + + assertEquals(EvaluationDetail.error(EvaluationReason.ErrorKind.MALFORMED_FLAG, null), result.getDetails()); + assertEquals(0, result.getPrerequisiteEvents().size()); + } + @Test public void flagReturnsOffVariationIfPrerequisiteIsNotFound() throws Exception { FeatureFlag f0 = new FeatureFlagBuilder("feature0") @@ -230,18 +258,12 @@ public void flagMatchesUserFromTargets() throws Exception { } @Test - public void flagMatchesUserFromRules() throws Exception { + public void flagMatchesUserFromRules() { Clause clause0 = new Clause("key", Operator.in, Arrays.asList(js("wrongkey")), false); Clause clause1 = new Clause("key", Operator.in, Arrays.asList(js("userkey")), false); Rule rule0 = new Rule("ruleid0", Arrays.asList(clause0), 2, null); Rule rule1 = new Rule("ruleid1", Arrays.asList(clause1), 2, null); - FeatureFlag f = new FeatureFlagBuilder("feature") - .on(true) - .rules(Arrays.asList(rule0, rule1)) - .fallthrough(fallthroughVariation(0)) - .offVariation(1) - .variations(js("fall"), js("off"), js("on")) - .build(); + FeatureFlag f = featureFlagWithRules("feature", rule0, rule1); LDUser user = new LDUser.Builder("userkey").build(); FeatureFlag.EvalResult result = f.evaluate(user, featureStore, EventFactory.DEFAULT); @@ -249,6 +271,55 @@ public void flagMatchesUserFromRules() throws Exception { assertEquals(0, result.getPrerequisiteEvents().size()); } + @Test + public void ruleWithTooHighVariationReturnsMalformedFlagError() { + Clause clause = new Clause("key", Operator.in, Arrays.asList(js("userkey")), false); + Rule rule = new Rule("ruleid", Arrays.asList(clause), 999, null); + FeatureFlag f = featureFlagWithRules("feature", rule); + LDUser user = new LDUser.Builder("userkey").build(); + FeatureFlag.EvalResult result = f.evaluate(user, featureStore, EventFactory.DEFAULT); + + assertEquals(EvaluationDetail.error(EvaluationReason.ErrorKind.MALFORMED_FLAG, null), result.getDetails()); + assertEquals(0, result.getPrerequisiteEvents().size()); + } + + @Test + public void ruleWithNegativeVariationReturnsMalformedFlagError() { + Clause clause = new Clause("key", Operator.in, Arrays.asList(js("userkey")), false); + Rule rule = new Rule("ruleid", Arrays.asList(clause), -1, null); + FeatureFlag f = featureFlagWithRules("feature", rule); + LDUser user = new LDUser.Builder("userkey").build(); + FeatureFlag.EvalResult result = f.evaluate(user, featureStore, EventFactory.DEFAULT); + + assertEquals(EvaluationDetail.error(EvaluationReason.ErrorKind.MALFORMED_FLAG, null), result.getDetails()); + assertEquals(0, result.getPrerequisiteEvents().size()); + } + + @Test + public void ruleWithNoVariationOrRolloutReturnsMalformedFlagError() { + Clause clause = new Clause("key", Operator.in, Arrays.asList(js("userkey")), false); + Rule rule = new Rule("ruleid", Arrays.asList(clause), null, null); + FeatureFlag f = featureFlagWithRules("feature", rule); + LDUser user = new LDUser.Builder("userkey").build(); + FeatureFlag.EvalResult result = f.evaluate(user, featureStore, EventFactory.DEFAULT); + + assertEquals(EvaluationDetail.error(EvaluationReason.ErrorKind.MALFORMED_FLAG, null), result.getDetails()); + assertEquals(0, result.getPrerequisiteEvents().size()); + } + + @Test + public void ruleWithRolloutWithEmptyVariationsListReturnsMalformedFlagError() { + Clause clause = new Clause("key", Operator.in, Arrays.asList(js("userkey")), false); + Rule rule = new Rule("ruleid", Arrays.asList(clause), null, + new VariationOrRollout.Rollout(ImmutableList.of(), null)); + FeatureFlag f = featureFlagWithRules("feature", rule); + LDUser user = new LDUser.Builder("userkey").build(); + FeatureFlag.EvalResult result = f.evaluate(user, featureStore, EventFactory.DEFAULT); + + assertEquals(EvaluationDetail.error(EvaluationReason.ErrorKind.MALFORMED_FLAG, null), result.getDetails()); + assertEquals(0, result.getPrerequisiteEvents().size()); + } + @Test public void clauseCanMatchBuiltInAttribute() throws Exception { Clause clause = new Clause("name", Operator.in, Arrays.asList(js("Bob")), false); @@ -352,6 +423,16 @@ public void testSegmentMatchClauseFallsThroughIfSegmentNotFound() throws Excepti assertEquals(jbool(false), result.getDetails().getValue()); } + private FeatureFlag featureFlagWithRules(String flagKey, Rule... rules) { + return new FeatureFlagBuilder(flagKey) + .on(true) + .rules(Arrays.asList(rules)) + .fallthrough(fallthroughVariation(0)) + .offVariation(1) + .variations(js("fall"), js("off"), js("on")) + .build(); + } + private FeatureFlag segmentMatchBooleanFlag(String segmentKey) { Clause clause = new Clause("", Operator.segmentMatch, Arrays.asList(js(segmentKey)), false); return booleanFlagWithClauses("flag", clause); From 712bed4796b0ec66b2fcc45bbb5dbb375c0fac1e Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Thu, 16 Aug 2018 15:39:51 -0700 Subject: [PATCH 24/43] add tests for more error conditions --- .../launchdarkly/client/FeatureFlagTest.java | 71 +++++++++++++++++++ 1 file changed, 71 insertions(+) diff --git a/src/test/java/com/launchdarkly/client/FeatureFlagTest.java b/src/test/java/com/launchdarkly/client/FeatureFlagTest.java index f3f55e459..db0d310a2 100644 --- a/src/test/java/com/launchdarkly/client/FeatureFlagTest.java +++ b/src/test/java/com/launchdarkly/client/FeatureFlagTest.java @@ -85,6 +85,77 @@ public void flagReturnsErrorIfFlagIsOffAndOffVariationIsNegative() throws Except assertEquals(0, result.getPrerequisiteEvents().size()); } + @Test + public void flagReturnsFallthroughIfFlagIsOnAndThereAreNoRules() throws Exception { + FeatureFlag f = new FeatureFlagBuilder("feature") + .on(true) + .offVariation(1) + .fallthrough(fallthroughVariation(0)) + .variations(js("fall"), js("off"), js("on")) + .build(); + FeatureFlag.EvalResult result = f.evaluate(BASE_USER, featureStore, EventFactory.DEFAULT); + + assertEquals(new EvaluationDetail<>(EvaluationReason.fallthrough(), 0, js("fall")), result.getDetails()); + assertEquals(0, result.getPrerequisiteEvents().size()); + } + + @Test + public void flagReturnsErrorIfFallthroughHasTooHighVariation() throws Exception { + FeatureFlag f = new FeatureFlagBuilder("feature") + .on(true) + .offVariation(1) + .fallthrough(fallthroughVariation(999)) + .variations(js("fall"), js("off"), js("on")) + .build(); + FeatureFlag.EvalResult result = f.evaluate(BASE_USER, featureStore, EventFactory.DEFAULT); + + assertEquals(EvaluationDetail.error(EvaluationReason.ErrorKind.MALFORMED_FLAG, null), result.getDetails()); + assertEquals(0, result.getPrerequisiteEvents().size()); + } + + @Test + public void flagReturnsErrorIfFallthroughHasNegativeVariation() throws Exception { + FeatureFlag f = new FeatureFlagBuilder("feature") + .on(true) + .offVariation(1) + .fallthrough(fallthroughVariation(-1)) + .variations(js("fall"), js("off"), js("on")) + .build(); + FeatureFlag.EvalResult result = f.evaluate(BASE_USER, featureStore, EventFactory.DEFAULT); + + assertEquals(EvaluationDetail.error(EvaluationReason.ErrorKind.MALFORMED_FLAG, null), result.getDetails()); + assertEquals(0, result.getPrerequisiteEvents().size()); + } + + @Test + public void flagReturnsErrorIfFallthroughHasNeitherVariationNorRollout() throws Exception { + FeatureFlag f = new FeatureFlagBuilder("feature") + .on(true) + .offVariation(1) + .fallthrough(new VariationOrRollout(null, null)) + .variations(js("fall"), js("off"), js("on")) + .build(); + FeatureFlag.EvalResult result = f.evaluate(BASE_USER, featureStore, EventFactory.DEFAULT); + + assertEquals(EvaluationDetail.error(EvaluationReason.ErrorKind.MALFORMED_FLAG, null), result.getDetails()); + assertEquals(0, result.getPrerequisiteEvents().size()); + } + + @Test + public void flagReturnsErrorIfFallthroughHasEmptyRolloutVariationList() throws Exception { + FeatureFlag f = new FeatureFlagBuilder("feature") + .on(true) + .offVariation(1) + .fallthrough(new VariationOrRollout(null, + new VariationOrRollout.Rollout(ImmutableList.of(), null))) + .variations(js("fall"), js("off"), js("on")) + .build(); + FeatureFlag.EvalResult result = f.evaluate(BASE_USER, featureStore, EventFactory.DEFAULT); + + assertEquals(EvaluationDetail.error(EvaluationReason.ErrorKind.MALFORMED_FLAG, null), result.getDetails()); + assertEquals(0, result.getPrerequisiteEvents().size()); + } + @Test public void flagReturnsOffVariationIfPrerequisiteIsNotFound() throws Exception { FeatureFlag f0 = new FeatureFlagBuilder("feature0") From 564db864446a0a115c357a6798389dad34b8f787 Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Fri, 17 Aug 2018 10:59:36 -0700 Subject: [PATCH 25/43] change options to be more enum-like --- .../client/FeatureFlagsState.java | 2 +- .../launchdarkly/client/FlagsStateOption.java | 23 ++++++------------- .../client/FeatureFlagsStateTest.java | 4 ++-- .../client/LDClientEvaluationTest.java | 2 +- 4 files changed, 11 insertions(+), 20 deletions(-) diff --git a/src/main/java/com/launchdarkly/client/FeatureFlagsState.java b/src/main/java/com/launchdarkly/client/FeatureFlagsState.java index 4ba3f0a7b..752faa60b 100644 --- a/src/main/java/com/launchdarkly/client/FeatureFlagsState.java +++ b/src/main/java/com/launchdarkly/client/FeatureFlagsState.java @@ -100,7 +100,7 @@ static class Builder { private boolean valid = true; Builder(FlagsStateOption... options) { - saveReasons = FlagsStateOption.isWithReasons(options); + saveReasons = FlagsStateOption.hasOption(options, FlagsStateOption.WITH_REASONS); } Builder valid(boolean valid) { diff --git a/src/main/java/com/launchdarkly/client/FlagsStateOption.java b/src/main/java/com/launchdarkly/client/FlagsStateOption.java index c1ad9f757..96cc96995 100644 --- a/src/main/java/com/launchdarkly/client/FlagsStateOption.java +++ b/src/main/java/com/launchdarkly/client/FlagsStateOption.java @@ -6,25 +6,16 @@ */ public abstract class FlagsStateOption { /** - * Specifies whether {@link EvaluationReason} data should be captured in the state object. By default, it is not. - * @param value true if evaluation reasons should be stored - * @return an option object + * Specifies that {@link EvaluationReason} data should be captured in the state object. By default, it is not. */ - public static FlagsStateOption withReasons(boolean value) { - return new WithReasons(value); - } + public static final FlagsStateOption WITH_REASONS = new WithReasons(); - private static class WithReasons extends FlagsStateOption { - final boolean value; - WithReasons(boolean value) { - this.value = value; - } - } - - static boolean isWithReasons(FlagsStateOption[] options) { + private static class WithReasons extends FlagsStateOption { } + + static boolean hasOption(FlagsStateOption[] options, FlagsStateOption option) { for (FlagsStateOption o: options) { - if (o instanceof WithReasons) { - return ((WithReasons)o).value; + if (o.equals(option)) { + return true; } } return false; diff --git a/src/test/java/com/launchdarkly/client/FeatureFlagsStateTest.java b/src/test/java/com/launchdarkly/client/FeatureFlagsStateTest.java index 65398fc62..64797ced0 100644 --- a/src/test/java/com/launchdarkly/client/FeatureFlagsStateTest.java +++ b/src/test/java/com/launchdarkly/client/FeatureFlagsStateTest.java @@ -33,7 +33,7 @@ public void unknownFlagReturnsNullValue() { public void canGetFlagReason() { EvaluationDetail eval = new EvaluationDetail(EvaluationReason.off(), 1, js("value")); FeatureFlag flag = new FeatureFlagBuilder("key").build(); - FeatureFlagsState state = new FeatureFlagsState.Builder(FlagsStateOption.withReasons(true)) + FeatureFlagsState state = new FeatureFlagsState.Builder(FlagsStateOption.WITH_REASONS) .addFlag(flag, eval).build(); assertEquals(EvaluationReason.off(), state.getFlagReason("key")); @@ -74,7 +74,7 @@ public void canConvertToJson() { FeatureFlag flag1 = new FeatureFlagBuilder("key1").version(100).trackEvents(false).build(); EvaluationDetail eval2 = new EvaluationDetail(EvaluationReason.fallthrough(), 1, js("value2")); FeatureFlag flag2 = new FeatureFlagBuilder("key2").version(200).trackEvents(true).debugEventsUntilDate(1000L).build(); - FeatureFlagsState state = new FeatureFlagsState.Builder(FlagsStateOption.withReasons(true)) + FeatureFlagsState state = new FeatureFlagsState.Builder(FlagsStateOption.WITH_REASONS) .addFlag(flag1, eval1).addFlag(flag2, eval2).build(); String json = "{\"key1\":\"value1\",\"key2\":\"value2\"," + diff --git a/src/test/java/com/launchdarkly/client/LDClientEvaluationTest.java b/src/test/java/com/launchdarkly/client/LDClientEvaluationTest.java index 7a844f083..cf41ba1de 100644 --- a/src/test/java/com/launchdarkly/client/LDClientEvaluationTest.java +++ b/src/test/java/com/launchdarkly/client/LDClientEvaluationTest.java @@ -261,7 +261,7 @@ public void allFlagsStateReturnsStateWithReasons() throws Exception { featureStore.upsert(FEATURES, flag1); featureStore.upsert(FEATURES, flag2); - FeatureFlagsState state = client.allFlagsState(user, FlagsStateOption.withReasons(true)); + FeatureFlagsState state = client.allFlagsState(user, FlagsStateOption.WITH_REASONS); assertTrue(state.isValid()); String json = "{\"key1\":\"value1\",\"key2\":\"value2\"," + From e29c2e059698dfd04d4bd47fbb67c69ee007adef Mon Sep 17 00:00:00 2001 From: Eli Bishop <35503443+eli-darkly@users.noreply.github.com> Date: Fri, 17 Aug 2018 11:15:23 -0700 Subject: [PATCH 26/43] version 4.2.2 (#88) --- CHANGELOG.md | 5 +++++ gradle.properties | 2 +- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 852f409bf..f23b75b19 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,11 @@ All notable changes to the LaunchDarkly Java SDK will be documented in this file. This project adheres to [Semantic Versioning](http://semver.org). +## [4.2.2] - 2018-08-17 +### Fixed: +- When logging errors related to the evaluation of a specific flag, the log message now always includes the flag key. +- Exception stacktraces are now logged only at DEBUG level. Previously, some were being logged at ERROR level. + ## [4.2.1] - 2018-07-16 ### Fixed: - Should not permanently give up on posting events if the server returns a 400 error. diff --git a/gradle.properties b/gradle.properties index ebf7f6ded..e95b27cb1 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,3 +1,3 @@ -version=4.2.1 +version=4.2.2 ossrhUsername= ossrhPassword= From 91f9cb2f83c7aa2f400e3653a50f972702cd63a8 Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Fri, 17 Aug 2018 13:27:57 -0700 Subject: [PATCH 27/43] add option to select only client-side flags in allFlagsState() --- .../com/launchdarkly/client/FeatureFlag.java | 8 ++++- .../client/FeatureFlagBuilder.java | 10 ++++-- .../launchdarkly/client/FlagsStateOption.java | 34 +++++++++++++++++++ .../com/launchdarkly/client/LDClient.java | 7 +++- .../client/LDClientInterface.java | 7 ++-- .../client/LDClientEvaluationTest.java | 20 +++++++++++ 6 files changed, 79 insertions(+), 7 deletions(-) create mode 100644 src/main/java/com/launchdarkly/client/FlagsStateOption.java diff --git a/src/main/java/com/launchdarkly/client/FeatureFlag.java b/src/main/java/com/launchdarkly/client/FeatureFlag.java index 2b06efe03..2dc9b08da 100644 --- a/src/main/java/com/launchdarkly/client/FeatureFlag.java +++ b/src/main/java/com/launchdarkly/client/FeatureFlag.java @@ -28,6 +28,7 @@ class FeatureFlag implements VersionedData { private VariationOrRollout fallthrough; private Integer offVariation; //optional private List variations; + private boolean clientSide; private boolean trackEvents; private Long debugEventsUntilDate; private boolean deleted; @@ -45,7 +46,7 @@ static Map fromJsonMap(LDConfig config, String json) { FeatureFlag(String key, int version, boolean on, List prerequisites, String salt, List targets, List rules, VariationOrRollout fallthrough, Integer offVariation, List variations, - boolean trackEvents, Long debugEventsUntilDate, boolean deleted) { + boolean clientSide, boolean trackEvents, Long debugEventsUntilDate, boolean deleted) { this.key = key; this.version = version; this.on = on; @@ -56,6 +57,7 @@ static Map fromJsonMap(LDConfig config, String json) { this.fallthrough = fallthrough; this.offVariation = offVariation; this.variations = variations; + this.clientSide = clientSide; this.trackEvents = trackEvents; this.debugEventsUntilDate = debugEventsUntilDate; this.deleted = deleted; @@ -209,6 +211,10 @@ List getVariations() { Integer getOffVariation() { return offVariation; } + boolean isClientSide() { + return clientSide; + } + static class VariationAndValue { private final Integer variation; private final JsonElement value; diff --git a/src/main/java/com/launchdarkly/client/FeatureFlagBuilder.java b/src/main/java/com/launchdarkly/client/FeatureFlagBuilder.java index 84f769966..2d7d86832 100644 --- a/src/main/java/com/launchdarkly/client/FeatureFlagBuilder.java +++ b/src/main/java/com/launchdarkly/client/FeatureFlagBuilder.java @@ -17,6 +17,7 @@ class FeatureFlagBuilder { private VariationOrRollout fallthrough; private Integer offVariation; private List variations = new ArrayList<>(); + private boolean clientSide; private boolean trackEvents; private Long debugEventsUntilDate; private boolean deleted; @@ -37,13 +38,13 @@ class FeatureFlagBuilder { this.fallthrough = f.getFallthrough(); this.offVariation = f.getOffVariation(); this.variations = f.getVariations(); + this.clientSide = f.isClientSide(); this.trackEvents = f.isTrackEvents(); this.debugEventsUntilDate = f.getDebugEventsUntilDate(); this.deleted = f.isDeleted(); } } - FeatureFlagBuilder version(int version) { this.version = version; return this; @@ -93,6 +94,11 @@ FeatureFlagBuilder variations(JsonElement... variations) { return variations(Arrays.asList(variations)); } + FeatureFlagBuilder clientSide(boolean clientSide) { + this.clientSide = clientSide; + return this; + } + FeatureFlagBuilder trackEvents(boolean trackEvents) { this.trackEvents = trackEvents; return this; @@ -110,6 +116,6 @@ FeatureFlagBuilder deleted(boolean deleted) { FeatureFlag build() { return new FeatureFlag(key, version, on, prerequisites, salt, targets, rules, fallthrough, offVariation, variations, - trackEvents, debugEventsUntilDate, deleted); + clientSide, trackEvents, debugEventsUntilDate, deleted); } } \ No newline at end of file diff --git a/src/main/java/com/launchdarkly/client/FlagsStateOption.java b/src/main/java/com/launchdarkly/client/FlagsStateOption.java new file mode 100644 index 000000000..edc90f0a1 --- /dev/null +++ b/src/main/java/com/launchdarkly/client/FlagsStateOption.java @@ -0,0 +1,34 @@ +package com.launchdarkly.client; + +/** + * Optional parameters that can be passed to {@link LDClientInterface#allFlagsState(LDUser, FlagsStateOption...). + * + * @since 4.3.0 + */ +public class FlagsStateOption { + private final String description; + + private FlagsStateOption(String description) { + this.description = description; + } + + @Override + public String toString() { + return description; + } + + /** + * Specifies that only flags marked for use with the client-side SDK should be included in the state object. + * By default, all flags are included. + */ + public static final FlagsStateOption CLIENT_SIDE_ONLY = new FlagsStateOption("CLIENT_SIDE_ONLY"); + + static boolean hasOption(FlagsStateOption[] options, FlagsStateOption option) { + for (FlagsStateOption o: options) { + if (o == option) { + return true; + } + } + return false; + } +} \ No newline at end of file diff --git a/src/main/java/com/launchdarkly/client/LDClient.java b/src/main/java/com/launchdarkly/client/LDClient.java index c430e4474..433cb124e 100644 --- a/src/main/java/com/launchdarkly/client/LDClient.java +++ b/src/main/java/com/launchdarkly/client/LDClient.java @@ -150,7 +150,7 @@ public Map allFlags(LDUser user) { } @Override - public FeatureFlagsState allFlagsState(LDUser user) { + public FeatureFlagsState allFlagsState(LDUser user, FlagsStateOption... options) { FeatureFlagsState.Builder builder = new FeatureFlagsState.Builder(); if (isOffline()) { @@ -171,8 +171,13 @@ public FeatureFlagsState allFlagsState(LDUser user) { return builder.valid(false).build(); } + boolean clientSideOnly = FlagsStateOption.hasOption(options, FlagsStateOption.CLIENT_SIDE_ONLY); Map flags = featureStore.all(FEATURES); for (Map.Entry entry : flags.entrySet()) { + FeatureFlag flag = entry.getValue(); + if (clientSideOnly && !flag.isClientSide()) { + continue; + } try { FeatureFlag.VariationAndValue eval = entry.getValue().evaluate(user, featureStore, EventFactory.DEFAULT).getResult(); builder.addFlag(entry.getValue(), eval); diff --git a/src/main/java/com/launchdarkly/client/LDClientInterface.java b/src/main/java/com/launchdarkly/client/LDClientInterface.java index e6420ca13..77db52731 100644 --- a/src/main/java/com/launchdarkly/client/LDClientInterface.java +++ b/src/main/java/com/launchdarkly/client/LDClientInterface.java @@ -54,16 +54,17 @@ public interface LDClientInterface extends Closeable { Map allFlags(LDUser user); /** - * Returns an object that encapsulates the state of all feature flags for a given user, including the flag - * values and, optionally, their {@link EvaluationReason}s. + * Returns an object that encapsulates the state of all feature flags for a given user. *

* The most common use case for this method is to bootstrap a set of client-side feature flags from a back-end service. * * @param user the end user requesting the feature flags + * @param options optional {@link FlagsStateOption} values affecting how the state is computed - for + * instance, to filter the set of flags to only include the client-side-enabled ones * @return a {@link FeatureFlagsState} object (will never be null; see {@link FeatureFlagsState#isValid()} * @since 4.3.0 */ - FeatureFlagsState allFlagsState(LDUser user); + FeatureFlagsState allFlagsState(LDUser user, FlagsStateOption... options); /** * Calculates the value of a feature flag for a given user. diff --git a/src/test/java/com/launchdarkly/client/LDClientEvaluationTest.java b/src/test/java/com/launchdarkly/client/LDClientEvaluationTest.java index e717b3a11..c8112c49f 100644 --- a/src/test/java/com/launchdarkly/client/LDClientEvaluationTest.java +++ b/src/test/java/com/launchdarkly/client/LDClientEvaluationTest.java @@ -181,6 +181,26 @@ public void allFlagsStateReturnsState() throws Exception { assertEquals(expected, gson.fromJson(state.toJsonString(), JsonElement.class)); } + @Test + public void allFlagsStateCanFilterForOnlyClientSideFlags() { + FeatureFlag flag1 = new FeatureFlagBuilder("server-side-1").build(); + FeatureFlag flag2 = new FeatureFlagBuilder("server-side-2").build(); + FeatureFlag flag3 = new FeatureFlagBuilder("client-side-1").clientSide(true) + .variations(js("value1")).offVariation(0).build(); + FeatureFlag flag4 = new FeatureFlagBuilder("client-side-2").clientSide(true) + .variations(js("value2")).offVariation(0).build(); + featureStore.upsert(FEATURES, flag1); + featureStore.upsert(FEATURES, flag2); + featureStore.upsert(FEATURES, flag3); + featureStore.upsert(FEATURES, flag4); + + FeatureFlagsState state = client.allFlagsState(user, FlagsStateOption.CLIENT_SIDE_ONLY); + assertTrue(state.isValid()); + + Map allValues = state.toValuesMap(); + assertEquals(ImmutableMap.of("client-side-1", js("value1"), "client-side-2", js("value2")), allValues); + } + @Test public void allFlagsStateReturnsEmptyStateForNullUser() throws Exception { featureStore.setStringValue("key", "value"); From a060bcc8bb7eb18b39d76e332c0d491c28630095 Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Fri, 17 Aug 2018 14:36:58 -0700 Subject: [PATCH 28/43] fix comment --- src/main/java/com/launchdarkly/client/LDClientInterface.java | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/main/java/com/launchdarkly/client/LDClientInterface.java b/src/main/java/com/launchdarkly/client/LDClientInterface.java index e6420ca13..a04d794ac 100644 --- a/src/main/java/com/launchdarkly/client/LDClientInterface.java +++ b/src/main/java/com/launchdarkly/client/LDClientInterface.java @@ -55,7 +55,8 @@ public interface LDClientInterface extends Closeable { /** * Returns an object that encapsulates the state of all feature flags for a given user, including the flag - * values and, optionally, their {@link EvaluationReason}s. + * values and also metadata that can be used on the front end. This method does not send analytics events + * back to LaunchDarkly. *

* The most common use case for this method is to bootstrap a set of client-side feature flags from a back-end service. * From 8939c8b375aa305b726699ce0f981960eb34077c Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Mon, 20 Aug 2018 11:55:26 -0700 Subject: [PATCH 29/43] serialize FeatureFlagsState to a JsonElement, not a string --- .../client/FeatureFlagsState.java | 23 +++++++++++++------ .../client/LDClientInterface.java | 4 ++-- .../client/FeatureFlagsStateTest.java | 2 +- .../client/LDClientEvaluationTest.java | 2 +- 4 files changed, 20 insertions(+), 11 deletions(-) diff --git a/src/main/java/com/launchdarkly/client/FeatureFlagsState.java b/src/main/java/com/launchdarkly/client/FeatureFlagsState.java index a9faa10f2..3f2f1d624 100644 --- a/src/main/java/com/launchdarkly/client/FeatureFlagsState.java +++ b/src/main/java/com/launchdarkly/client/FeatureFlagsState.java @@ -3,6 +3,7 @@ import com.google.common.collect.ImmutableMap; import com.google.gson.Gson; import com.google.gson.JsonElement; +import com.google.gson.JsonObject; import java.util.HashMap; import java.util.Map; @@ -62,6 +63,9 @@ public JsonElement getFlagValue(String key) { /** * Returns a map of flag keys to flag values. If a flag would have evaluated to the default value, * its value will be null. + *

+ * Do not use this method if you are passing data to the front end to "bootstrap" the JavaScript client. + * Instead, use {@link #toJson()}. * @return an immutable map of flag keys to JSON values */ public Map toValuesMap() { @@ -69,16 +73,21 @@ public Map toValuesMap() { } /** - * Returns a JSON string representation of the entire state map, in the format used by the - * LaunchDarkly JavaScript SDK. Use this method if you are passing data to the front end that + * Returns a JSON representation of the entire state map (as a Gson object), in the format used by + * the LaunchDarkly JavaScript SDK. Use this method if you are passing data to the front end that * will be used to "bootstrap" the JavaScript client. + *

+ * Do not rely on the exact shape of this data, as it may change in future to support the needs of + * the JavaScript client. * @return a JSON representation of the state object */ - public String toJsonString() { - Map outerMap = new HashMap<>(); - outerMap.putAll(flagValues); - outerMap.put("$flagsState", flagMetadata); - return gson.toJson(outerMap); + public JsonElement toJson() { + JsonObject outerMap = new JsonObject(); + for (Map.Entry entry: flagValues.entrySet()) { + outerMap.add(entry.getKey(), entry.getValue()); + } + outerMap.add("$flagsState", gson.toJsonTree(flagMetadata)); + return outerMap; } static class Builder { diff --git a/src/main/java/com/launchdarkly/client/LDClientInterface.java b/src/main/java/com/launchdarkly/client/LDClientInterface.java index a04d794ac..bc409c00e 100644 --- a/src/main/java/com/launchdarkly/client/LDClientInterface.java +++ b/src/main/java/com/launchdarkly/client/LDClientInterface.java @@ -47,8 +47,8 @@ public interface LDClientInterface extends Closeable { * @param user the end user requesting the feature flags * @return a map from feature flag keys to {@code JsonElement} for the specified user * - * @deprecated Use {@link #allFlagsState} instead. Current versions of the client-side SDK (2.0.0 and later) - * will not generate analytics events correctly if you pass the result of {@code allFlags()}. + * @deprecated Use {@link #allFlagsState} instead. Current versions of the client-side SDK will not + * generate analytics events correctly if you pass the result of {@code allFlags()}. */ @Deprecated Map allFlags(LDUser user); diff --git a/src/test/java/com/launchdarkly/client/FeatureFlagsStateTest.java b/src/test/java/com/launchdarkly/client/FeatureFlagsStateTest.java index bda54ad46..3b049dbb7 100644 --- a/src/test/java/com/launchdarkly/client/FeatureFlagsStateTest.java +++ b/src/test/java/com/launchdarkly/client/FeatureFlagsStateTest.java @@ -60,6 +60,6 @@ public void canConvertToJson() { "}" + "}}"; JsonElement expected = gson.fromJson(json, JsonElement.class); - assertEquals(expected, gson.fromJson(state.toJsonString(), JsonElement.class)); + assertEquals(expected, state.toJson()); } } diff --git a/src/test/java/com/launchdarkly/client/LDClientEvaluationTest.java b/src/test/java/com/launchdarkly/client/LDClientEvaluationTest.java index e717b3a11..eaf4677c7 100644 --- a/src/test/java/com/launchdarkly/client/LDClientEvaluationTest.java +++ b/src/test/java/com/launchdarkly/client/LDClientEvaluationTest.java @@ -178,7 +178,7 @@ public void allFlagsStateReturnsState() throws Exception { "}" + "}}"; JsonElement expected = gson.fromJson(json, JsonElement.class); - assertEquals(expected, gson.fromJson(state.toJsonString(), JsonElement.class)); + assertEquals(expected, state.toJson()); } @Test From 78611fbbb30bbbacd1007fdc25bda2f7c6539000 Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Mon, 20 Aug 2018 11:57:41 -0700 Subject: [PATCH 30/43] rm unused import --- src/main/java/com/launchdarkly/client/FeatureFlagsState.java | 1 - 1 file changed, 1 deletion(-) diff --git a/src/main/java/com/launchdarkly/client/FeatureFlagsState.java b/src/main/java/com/launchdarkly/client/FeatureFlagsState.java index 3f2f1d624..386a5d572 100644 --- a/src/main/java/com/launchdarkly/client/FeatureFlagsState.java +++ b/src/main/java/com/launchdarkly/client/FeatureFlagsState.java @@ -5,7 +5,6 @@ import com.google.gson.JsonElement; import com.google.gson.JsonObject; -import java.util.HashMap; import java.util.Map; /** From 8200e44ee4e18caeca690c24614a727f6a6c0526 Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Mon, 20 Aug 2018 12:45:21 -0700 Subject: [PATCH 31/43] edit comment --- src/main/java/com/launchdarkly/client/FeatureFlagsState.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/main/java/com/launchdarkly/client/FeatureFlagsState.java b/src/main/java/com/launchdarkly/client/FeatureFlagsState.java index 386a5d572..f6f07cf5d 100644 --- a/src/main/java/com/launchdarkly/client/FeatureFlagsState.java +++ b/src/main/java/com/launchdarkly/client/FeatureFlagsState.java @@ -73,8 +73,8 @@ public Map toValuesMap() { /** * Returns a JSON representation of the entire state map (as a Gson object), in the format used by - * the LaunchDarkly JavaScript SDK. Use this method if you are passing data to the front end that - * will be used to "bootstrap" the JavaScript client. + * the LaunchDarkly JavaScript SDK. Use this method if you are passing data to the front end in + * order to "bootstrap" the JavaScript client. *

* Do not rely on the exact shape of this data, as it may change in future to support the needs of * the JavaScript client. From bb209336ecf5d517ccf36006a5db6eedcf297ef7 Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Mon, 20 Aug 2018 19:39:09 -0700 Subject: [PATCH 32/43] use custom Gson serializer --- .../client/FeatureFlagsState.java | 113 ++++++++++++++---- .../client/FeatureFlagsStateTest.java | 20 +++- .../client/LDClientEvaluationTest.java | 6 +- 3 files changed, 113 insertions(+), 26 deletions(-) diff --git a/src/main/java/com/launchdarkly/client/FeatureFlagsState.java b/src/main/java/com/launchdarkly/client/FeatureFlagsState.java index f6f07cf5d..22ba3e9b7 100644 --- a/src/main/java/com/launchdarkly/client/FeatureFlagsState.java +++ b/src/main/java/com/launchdarkly/client/FeatureFlagsState.java @@ -1,18 +1,27 @@ package com.launchdarkly.client; +import com.google.common.base.Objects; import com.google.common.collect.ImmutableMap; import com.google.gson.Gson; import com.google.gson.JsonElement; -import com.google.gson.JsonObject; +import com.google.gson.TypeAdapter; +import com.google.gson.annotations.JsonAdapter; +import com.google.gson.stream.JsonReader; +import com.google.gson.stream.JsonWriter; +import java.io.IOException; import java.util.Map; /** * A snapshot of the state of all feature flags with regard to a specific user, generated by * calling {@link LDClientInterface#allFlagsState(LDUser)}. + *

+ * Serializing this object to JSON using Gson will produce the appropriate data structure for + * bootstrapping the LaunchDarkly JavaScript client. * * @since 4.3.0 */ +@JsonAdapter(FeatureFlagsState.JsonSerialization.class) public class FeatureFlagsState { private static final Gson gson = new Gson(); @@ -33,12 +42,30 @@ static class FlagMetadata { this.trackEvents = trackEvents; this.debugEventsUntilDate = debugEventsUntilDate; } + + @Override + public boolean equals(Object other) { + if (other instanceof FlagMetadata) { + FlagMetadata o = (FlagMetadata)other; + return Objects.equal(variation, o.variation) && + version == o.version && + trackEvents == o.trackEvents && + Objects.equal(debugEventsUntilDate, o.debugEventsUntilDate); + } + return false; + } + + @Override + public int hashCode() { + return Objects.hashCode(variation, version, trackEvents, debugEventsUntilDate); + } } - private FeatureFlagsState(Builder builder) { - this.flagValues = builder.flagValues.build(); - this.flagMetadata = builder.flagMetadata.build(); - this.valid = builder.valid; + private FeatureFlagsState(ImmutableMap flagValues, + ImmutableMap flagMetadata, boolean valid) { + this.flagValues = flagValues; + this.flagMetadata = flagMetadata; + this.valid = valid; } /** @@ -64,29 +91,27 @@ public JsonElement getFlagValue(String key) { * its value will be null. *

* Do not use this method if you are passing data to the front end to "bootstrap" the JavaScript client. - * Instead, use {@link #toJson()}. + * Instead, serialize the FeatureFlagsState object to JSON using {@code Gson.toJson()} or {@code Gson.toJsonTree()}. * @return an immutable map of flag keys to JSON values */ public Map toValuesMap() { return flagValues; } - /** - * Returns a JSON representation of the entire state map (as a Gson object), in the format used by - * the LaunchDarkly JavaScript SDK. Use this method if you are passing data to the front end in - * order to "bootstrap" the JavaScript client. - *

- * Do not rely on the exact shape of this data, as it may change in future to support the needs of - * the JavaScript client. - * @return a JSON representation of the state object - */ - public JsonElement toJson() { - JsonObject outerMap = new JsonObject(); - for (Map.Entry entry: flagValues.entrySet()) { - outerMap.add(entry.getKey(), entry.getValue()); + @Override + public boolean equals(Object other) { + if (other instanceof FeatureFlagsState) { + FeatureFlagsState o = (FeatureFlagsState)other; + return flagValues.equals(o.flagValues) && + flagMetadata.equals(o.flagMetadata) && + valid == o.valid; } - outerMap.add("$flagsState", gson.toJsonTree(flagMetadata)); - return outerMap; + return false; + } + + @Override + public int hashCode() { + return Objects.hashCode(flagValues, flagMetadata, valid); } static class Builder { @@ -108,7 +133,51 @@ Builder addFlag(FeatureFlag flag, FeatureFlag.VariationAndValue eval) { } FeatureFlagsState build() { - return new FeatureFlagsState(this); + return new FeatureFlagsState(flagValues.build(), flagMetadata.build(), valid); + } + } + + static class JsonSerialization extends TypeAdapter { + @Override + public void write(JsonWriter out, FeatureFlagsState state) throws IOException { + out.beginObject(); + for (Map.Entry entry: state.flagValues.entrySet()) { + out.name(entry.getKey()); + gson.toJson(entry.getValue(), out); + } + out.name("$flagsState"); + gson.toJson(state.flagMetadata, Map.class, out); + out.name("$valid"); + out.value(state.valid); + out.endObject(); + } + + // There isn't really a use case for deserializing this, but we have to implement it + @Override + public FeatureFlagsState read(JsonReader in) throws IOException { + ImmutableMap.Builder flagValues = ImmutableMap.builder(); + ImmutableMap.Builder flagMetadata = ImmutableMap.builder(); + boolean valid = true; + in.beginObject(); + while (in.hasNext()) { + String name = in.nextName(); + if (name.equals("$flagsState")) { + in.beginObject(); + while (in.hasNext()) { + String metaName = in.nextName(); + FlagMetadata meta = gson.fromJson(in, FlagMetadata.class); + flagMetadata.put(metaName, meta); + } + in.endObject(); + } else if (name.equals("$valid")) { + valid = in.nextBoolean(); + } else { + JsonElement value = gson.fromJson(in, JsonElement.class); + flagValues.put(name, value); + } + } + in.endObject(); + return new FeatureFlagsState(flagValues.build(), flagMetadata.build(), valid); } } } diff --git a/src/test/java/com/launchdarkly/client/FeatureFlagsStateTest.java b/src/test/java/com/launchdarkly/client/FeatureFlagsStateTest.java index 3b049dbb7..f00722fdf 100644 --- a/src/test/java/com/launchdarkly/client/FeatureFlagsStateTest.java +++ b/src/test/java/com/launchdarkly/client/FeatureFlagsStateTest.java @@ -58,8 +58,24 @@ public void canConvertToJson() { "},\"key2\":{" + "\"variation\":1,\"version\":200,\"trackEvents\":true,\"debugEventsUntilDate\":1000" + "}" + - "}}"; + "}," + + "\"$valid\":true" + + "}"; JsonElement expected = gson.fromJson(json, JsonElement.class); - assertEquals(expected, state.toJson()); + assertEquals(expected, gson.toJsonTree(state)); + } + + @Test + public void canConvertFromJson() { + FeatureFlag.VariationAndValue eval1 = new FeatureFlag.VariationAndValue(0, js("value1")); + FeatureFlag flag1 = new FeatureFlagBuilder("key1").version(100).trackEvents(false).build(); + FeatureFlag.VariationAndValue eval2 = new FeatureFlag.VariationAndValue(1, js("value2")); + FeatureFlag flag2 = new FeatureFlagBuilder("key2").version(200).trackEvents(true).debugEventsUntilDate(1000L).build(); + FeatureFlagsState state = new FeatureFlagsState.Builder() + .addFlag(flag1, eval1).addFlag(flag2, eval2).build(); + + String json = gson.toJson(state); + FeatureFlagsState state1 = gson.fromJson(json, FeatureFlagsState.class); + assertEquals(state, state1); } } diff --git a/src/test/java/com/launchdarkly/client/LDClientEvaluationTest.java b/src/test/java/com/launchdarkly/client/LDClientEvaluationTest.java index eaf4677c7..ab2ce9fa0 100644 --- a/src/test/java/com/launchdarkly/client/LDClientEvaluationTest.java +++ b/src/test/java/com/launchdarkly/client/LDClientEvaluationTest.java @@ -176,9 +176,11 @@ public void allFlagsStateReturnsState() throws Exception { "},\"key2\":{" + "\"variation\":1,\"version\":200,\"trackEvents\":true,\"debugEventsUntilDate\":1000" + "}" + - "}}"; + "}," + + "\"$valid\":true" + + "}"; JsonElement expected = gson.fromJson(json, JsonElement.class); - assertEquals(expected, state.toJson()); + assertEquals(expected, gson.toJsonTree(state)); } @Test From 190cdb980a9ceae92fbdcf1982d57ffebd5fb59d Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Wed, 22 Aug 2018 11:13:37 -0700 Subject: [PATCH 33/43] add tests for JSON serialization of evaluation reasons --- .../launchdarkly/client/EvaluationReason.java | 2 +- .../client/EvaluationReasonTest.java | 67 +++++++++++++++++++ 2 files changed, 68 insertions(+), 1 deletion(-) create mode 100644 src/test/java/com/launchdarkly/client/EvaluationReasonTest.java diff --git a/src/main/java/com/launchdarkly/client/EvaluationReason.java b/src/main/java/com/launchdarkly/client/EvaluationReason.java index 66900cc2f..48f877af0 100644 --- a/src/main/java/com/launchdarkly/client/EvaluationReason.java +++ b/src/main/java/com/launchdarkly/client/EvaluationReason.java @@ -205,7 +205,7 @@ public String getRuleId() { public boolean equals(Object other) { if (other instanceof RuleMatch) { RuleMatch o = (RuleMatch)other; - return ruleIndex == o.ruleIndex && Objects.equals(ruleId, o.ruleId); + return ruleIndex == o.ruleIndex && Objects.equals(ruleId, o.ruleId); } return false; } diff --git a/src/test/java/com/launchdarkly/client/EvaluationReasonTest.java b/src/test/java/com/launchdarkly/client/EvaluationReasonTest.java new file mode 100644 index 000000000..c5a74035d --- /dev/null +++ b/src/test/java/com/launchdarkly/client/EvaluationReasonTest.java @@ -0,0 +1,67 @@ +package com.launchdarkly.client; + +import com.google.common.collect.ImmutableList; +import com.google.gson.Gson; +import com.google.gson.JsonElement; + +import org.junit.Test; + +import static org.junit.Assert.assertEquals; + +public class EvaluationReasonTest { + private static final Gson gson = new Gson(); + + @Test + public void testOffReasonSerialization() { + EvaluationReason reason = EvaluationReason.off(); + String json = "{\"kind\":\"OFF\"}"; + assertJsonEqual(json, gson.toJson(reason)); + assertEquals("OFF", reason.toString()); + } + + @Test + public void testTargetMatchSerialization() { + EvaluationReason reason = EvaluationReason.targetMatch(); + String json = "{\"kind\":\"TARGET_MATCH\"}"; + assertJsonEqual(json, gson.toJson(reason)); + assertEquals("TARGET_MATCH", reason.toString()); + } + + @Test + public void testRuleMatchSerialization() { + EvaluationReason reason = EvaluationReason.ruleMatch(1, "id"); + String json = "{\"kind\":\"RULE_MATCH\",\"ruleIndex\":1,\"ruleId\":\"id\"}"; + assertJsonEqual(json, gson.toJson(reason)); + assertEquals("RULE_MATCH(1,id)", reason.toString()); + } + + @Test + public void testPrerequisitesFailedSerialization() { + EvaluationReason reason = EvaluationReason.prerequisitesFailed(ImmutableList.of("key1", "key2")); + String json = "{\"kind\":\"PREREQUISITES_FAILED\",\"prerequisiteKeys\":[\"key1\",\"key2\"]}"; + assertJsonEqual(json, gson.toJson(reason)); + assertEquals("PREREQUISITES_FAILED(key1,key2)", reason.toString()); + } + + @Test + public void testFallthrougSerialization() { + EvaluationReason reason = EvaluationReason.fallthrough(); + String json = "{\"kind\":\"FALLTHROUGH\"}"; + assertJsonEqual(json, gson.toJson(reason)); + assertEquals("FALLTHROUGH", reason.toString()); + } + + @Test + public void testErrorSerialization() { + EvaluationReason reason = EvaluationReason.error(EvaluationReason.ErrorKind.EXCEPTION); + String json = "{\"kind\":\"ERROR\",\"errorKind\":\"EXCEPTION\"}"; + assertJsonEqual(json, gson.toJson(reason)); + assertEquals("ERROR(EXCEPTION)", reason.toString()); + } + + private void assertJsonEqual(String expectedString, String actualString) { + JsonElement expected = gson.fromJson(expectedString, JsonElement.class); + JsonElement actual = gson.fromJson(actualString, JsonElement.class); + assertEquals(expected, actual); + } +} From 8f90c71aec1b468e2ee3ff5431a2d99355820fb2 Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Wed, 22 Aug 2018 18:10:11 -0700 Subject: [PATCH 34/43] misc cleanup --- .../launchdarkly/client/EvaluationReason.java | 8 ++++---- .../client/EvaluationReasonTest.java | 16 ++++++++-------- 2 files changed, 12 insertions(+), 12 deletions(-) diff --git a/src/main/java/com/launchdarkly/client/EvaluationReason.java b/src/main/java/com/launchdarkly/client/EvaluationReason.java index d092c8677..164047866 100644 --- a/src/main/java/com/launchdarkly/client/EvaluationReason.java +++ b/src/main/java/com/launchdarkly/client/EvaluationReason.java @@ -27,6 +27,10 @@ public static enum Kind { * Indicates that the flag was off and therefore returned its configured off value. */ OFF, + /** + * Indicates that the flag was on but the user did not match any targets or rules. + */ + FALLTHROUGH, /** * Indicates that the user key was specifically targeted for this flag. */ @@ -40,10 +44,6 @@ public static enum Kind { * that either was off or did not return the desired variation. */ PREREQUISITES_FAILED, - /** - * Indicates that the flag was on but the user did not match any targets or rules. - */ - FALLTHROUGH, /** * Indicates that the flag could not be evaluated, e.g. because it does not exist or due to an unexpected * error. In this case the result value will be the default value that the caller passed to the client. diff --git a/src/test/java/com/launchdarkly/client/EvaluationReasonTest.java b/src/test/java/com/launchdarkly/client/EvaluationReasonTest.java index c5a74035d..f37392d02 100644 --- a/src/test/java/com/launchdarkly/client/EvaluationReasonTest.java +++ b/src/test/java/com/launchdarkly/client/EvaluationReasonTest.java @@ -18,6 +18,14 @@ public void testOffReasonSerialization() { assertJsonEqual(json, gson.toJson(reason)); assertEquals("OFF", reason.toString()); } + + @Test + public void testFallthroughSerialization() { + EvaluationReason reason = EvaluationReason.fallthrough(); + String json = "{\"kind\":\"FALLTHROUGH\"}"; + assertJsonEqual(json, gson.toJson(reason)); + assertEquals("FALLTHROUGH", reason.toString()); + } @Test public void testTargetMatchSerialization() { @@ -43,14 +51,6 @@ public void testPrerequisitesFailedSerialization() { assertEquals("PREREQUISITES_FAILED(key1,key2)", reason.toString()); } - @Test - public void testFallthrougSerialization() { - EvaluationReason reason = EvaluationReason.fallthrough(); - String json = "{\"kind\":\"FALLTHROUGH\"}"; - assertJsonEqual(json, gson.toJson(reason)); - assertEquals("FALLTHROUGH", reason.toString()); - } - @Test public void testErrorSerialization() { EvaluationReason reason = EvaluationReason.error(EvaluationReason.ErrorKind.EXCEPTION); From ccaddda15b3f0cd4ca5e38db4dbb1ebbfa4c8782 Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Wed, 22 Aug 2018 20:36:57 -0700 Subject: [PATCH 35/43] don't keep evaluating prerequisites if one fails --- .../launchdarkly/client/EvaluationReason.java | 39 +++++++---------- .../com/launchdarkly/client/FeatureFlag.java | 9 +--- .../launchdarkly/client/FeatureFlagTest.java | 42 +------------------ .../client/LDClientEventTest.java | 3 +- 4 files changed, 20 insertions(+), 73 deletions(-) diff --git a/src/main/java/com/launchdarkly/client/EvaluationReason.java b/src/main/java/com/launchdarkly/client/EvaluationReason.java index 32654afc9..d63f03d4d 100644 --- a/src/main/java/com/launchdarkly/client/EvaluationReason.java +++ b/src/main/java/com/launchdarkly/client/EvaluationReason.java @@ -1,8 +1,5 @@ package com.launchdarkly.client; -import com.google.common.base.Joiner; -import com.google.common.collect.ImmutableList; - import java.util.Objects; import static com.google.common.base.Preconditions.checkNotNull; @@ -39,7 +36,7 @@ public static enum Kind { * Indicates that the flag was considered off because it had at least one prerequisite flag * that either was off or did not return the desired variation. */ - PREREQUISITES_FAILED, + PREREQUISITE_FAILED, /** * Indicates that the flag was on but the user did not match any targets or rules. */ @@ -134,12 +131,12 @@ public static RuleMatch ruleMatch(int ruleIndex, String ruleId) { } /** - * Returns an instance of {@link PrerequisitesFailed}. - * @param prerequisiteKeys the list of flag keys of prerequisites that failed + * Returns an instance of {@link PrerequisiteFailed}. + * @param prerequisiteKey the flag key of the prerequisite that failed * @return a reason object */ - public static PrerequisitesFailed prerequisitesFailed(Iterable prerequisiteKeys) { - return new PrerequisitesFailed(prerequisiteKeys); + public static PrerequisiteFailed prerequisiteFailed(String prerequisiteKey) { + return new PrerequisiteFailed(prerequisiteKey); } /** @@ -233,36 +230,32 @@ public String toString() { * had at least one prerequisite flag that either was off or did not return the desired variation. * @since 4.3.0 */ - public static class PrerequisitesFailed extends EvaluationReason { - private final ImmutableList prerequisiteKeys; + public static class PrerequisiteFailed extends EvaluationReason { + private final String prerequisiteKey; - private PrerequisitesFailed(Iterable prerequisiteKeys) { - super(Kind.PREREQUISITES_FAILED); - checkNotNull(prerequisiteKeys); - this.prerequisiteKeys = ImmutableList.copyOf(prerequisiteKeys); + private PrerequisiteFailed(String prerequisiteKey) { + super(Kind.PREREQUISITE_FAILED); + this.prerequisiteKey = checkNotNull(prerequisiteKey); } - public Iterable getPrerequisiteKeys() { - return prerequisiteKeys; + public String getPrerequisiteKey() { + return prerequisiteKey; } @Override public boolean equals(Object other) { - if (other instanceof PrerequisitesFailed) { - PrerequisitesFailed o = (PrerequisitesFailed)other; - return prerequisiteKeys.equals(o.prerequisiteKeys); - } - return false; + return (other instanceof PrerequisiteFailed) && + ((PrerequisiteFailed)other).prerequisiteKey.equals(prerequisiteKey); } @Override public int hashCode() { - return prerequisiteKeys.hashCode(); + return prerequisiteKey.hashCode(); } @Override public String toString() { - return getKind().name() + "(" + Joiner.on(",").join(prerequisiteKeys) + ")"; + return getKind().name() + "(" + prerequisiteKey + ")"; } } diff --git a/src/main/java/com/launchdarkly/client/FeatureFlag.java b/src/main/java/com/launchdarkly/client/FeatureFlag.java index 614a776e3..c13464c7a 100644 --- a/src/main/java/com/launchdarkly/client/FeatureFlag.java +++ b/src/main/java/com/launchdarkly/client/FeatureFlag.java @@ -119,7 +119,6 @@ private EvaluationReason checkPrerequisites(LDUser user, FeatureStore featureSto if (prerequisites == null) { return null; } - List failedPrereqs = null; for (int i = 0; i < prerequisites.size(); i++) { boolean prereqOk = true; Prerequisite prereq = prerequisites.get(i); @@ -141,15 +140,9 @@ private EvaluationReason checkPrerequisites(LDUser user, FeatureStore featureSto events.add(eventFactory.newPrerequisiteFeatureRequestEvent(prereqFeatureFlag, user, prereqEvalResult, this)); } if (!prereqOk) { - if (failedPrereqs == null) { - failedPrereqs = new ArrayList<>(); - } - failedPrereqs.add(prereq.getKey()); + return EvaluationReason.prerequisiteFailed(prereq.getKey()); } } - if (failedPrereqs != null && !failedPrereqs.isEmpty()) { - return EvaluationReason.prerequisitesFailed(failedPrereqs); - } return null; } diff --git a/src/test/java/com/launchdarkly/client/FeatureFlagTest.java b/src/test/java/com/launchdarkly/client/FeatureFlagTest.java index db0d310a2..6ba5317b5 100644 --- a/src/test/java/com/launchdarkly/client/FeatureFlagTest.java +++ b/src/test/java/com/launchdarkly/client/FeatureFlagTest.java @@ -167,7 +167,7 @@ public void flagReturnsOffVariationIfPrerequisiteIsNotFound() throws Exception { .build(); FeatureFlag.EvalResult result = f0.evaluate(BASE_USER, featureStore, EventFactory.DEFAULT); - EvaluationReason expectedReason = EvaluationReason.prerequisitesFailed(ImmutableList.of("feature1")); + EvaluationReason expectedReason = EvaluationReason.prerequisiteFailed("feature1"); assertEquals(new EvaluationDetail<>(expectedReason, 1, js("off")), result.getDetails()); assertEquals(0, result.getPrerequisiteEvents().size()); } @@ -191,7 +191,7 @@ public void flagReturnsOffVariationAndEventIfPrerequisiteIsNotMet() throws Excep featureStore.upsert(FEATURES, f1); FeatureFlag.EvalResult result = f0.evaluate(BASE_USER, featureStore, EventFactory.DEFAULT); - EvaluationReason expectedReason = EvaluationReason.prerequisitesFailed(ImmutableList.of("feature1")); + EvaluationReason expectedReason = EvaluationReason.prerequisiteFailed("feature1"); assertEquals(new EvaluationDetail<>(expectedReason, 1, js("off")), result.getDetails()); assertEquals(1, result.getPrerequisiteEvents().size()); @@ -274,44 +274,6 @@ public void multipleLevelsOfPrerequisitesProduceMultipleEvents() throws Exceptio assertEquals(f0.getKey(), event1.prereqOf); } - @Test - public void multiplePrerequisiteFailuresAreAllRecorded() throws Exception { - FeatureFlag f0 = new FeatureFlagBuilder("feature0") - .on(true) - .prerequisites(Arrays.asList(new Prerequisite("feature1", 0), new Prerequisite("feature2", 0))) - .fallthrough(fallthroughVariation(0)) - .offVariation(1) - .variations(js("fall"), js("off"), js("on")) - .version(1) - .build(); - FeatureFlag f1 = new FeatureFlagBuilder("feature1") - .on(true) - .fallthrough(fallthroughVariation(1)) - .variations(js("nogo"), js("go")) - .version(2) - .build(); - FeatureFlag f2 = new FeatureFlagBuilder("feature2") - .on(true) - .fallthrough(fallthroughVariation(1)) - .variations(js("nogo"), js("go")) - .version(3) - .build(); - featureStore.upsert(FEATURES, f1); - featureStore.upsert(FEATURES, f2); - FeatureFlag.EvalResult result = f0.evaluate(BASE_USER, featureStore, EventFactory.DEFAULT); - - EvaluationReason expectedReason = EvaluationReason.prerequisitesFailed(ImmutableList.of("feature1", "feature2")); - assertEquals(new EvaluationDetail<>(expectedReason, 1, js("off")), result.getDetails()); - - assertEquals(2, result.getPrerequisiteEvents().size()); - - Event.FeatureRequest event0 = result.getPrerequisiteEvents().get(0); - assertEquals(f1.getKey(), event0.key); - - Event.FeatureRequest event1 = result.getPrerequisiteEvents().get(1); - assertEquals(f2.getKey(), event1.key); - } - @Test public void flagMatchesUserFromTargets() throws Exception { FeatureFlag f = new FeatureFlagBuilder("feature") diff --git a/src/test/java/com/launchdarkly/client/LDClientEventTest.java b/src/test/java/com/launchdarkly/client/LDClientEventTest.java index 7c231a39c..ded8dbb7b 100644 --- a/src/test/java/com/launchdarkly/client/LDClientEventTest.java +++ b/src/test/java/com/launchdarkly/client/LDClientEventTest.java @@ -1,6 +1,5 @@ package com.launchdarkly.client; -import com.google.common.collect.ImmutableList; import com.google.gson.JsonElement; import com.google.gson.JsonObject; import com.google.gson.JsonPrimitive; @@ -344,7 +343,7 @@ public void failureReasonIsGivenForUnknownPrererequisiteFlagIfDetailsWereRequest assertEquals(1, eventSink.events.size()); checkFeatureEvent(eventSink.events.get(0), f0, js("off"), js("default"), null, - EvaluationReason.prerequisitesFailed(ImmutableList.of("feature1"))); + EvaluationReason.prerequisiteFailed("feature1")); } private void checkFeatureEvent(Event e, FeatureFlag flag, JsonElement value, JsonElement defaultVal, From 9a00c078eae4eb594e41e246ae5463663571d029 Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Thu, 23 Aug 2018 14:04:49 -0700 Subject: [PATCH 36/43] avoid some inappropriate uses of Guava's ImmutableMap --- .../client/FeatureFlagsState.java | 26 ++++++++++--------- .../java/com/launchdarkly/client/LDUser.java | 18 ++++++++----- .../client/FeatureFlagsStateTest.java | 9 +++++++ 3 files changed, 35 insertions(+), 18 deletions(-) diff --git a/src/main/java/com/launchdarkly/client/FeatureFlagsState.java b/src/main/java/com/launchdarkly/client/FeatureFlagsState.java index 92ee4dd5e..40c8aa22a 100644 --- a/src/main/java/com/launchdarkly/client/FeatureFlagsState.java +++ b/src/main/java/com/launchdarkly/client/FeatureFlagsState.java @@ -10,6 +10,8 @@ import com.google.gson.stream.JsonWriter; import java.io.IOException; +import java.util.Collections; +import java.util.HashMap; import java.util.Map; /** @@ -25,8 +27,8 @@ public class FeatureFlagsState { private static final Gson gson = new Gson(); - private final ImmutableMap flagValues; - private final ImmutableMap flagMetadata; + private final Map flagValues; + private final Map flagMetadata; private final boolean valid; static class FlagMetadata { @@ -61,10 +63,10 @@ public int hashCode() { } } - private FeatureFlagsState(ImmutableMap flagValues, - ImmutableMap flagMetadata, boolean valid) { - this.flagValues = flagValues; - this.flagMetadata = flagMetadata; + private FeatureFlagsState(Map flagValues, + Map flagMetadata, boolean valid) { + this.flagValues = Collections.unmodifiableMap(flagValues); + this.flagMetadata = Collections.unmodifiableMap(flagMetadata); this.valid = valid; } @@ -115,8 +117,8 @@ public int hashCode() { } static class Builder { - private ImmutableMap.Builder flagValues = ImmutableMap.builder(); - private ImmutableMap.Builder flagMetadata = ImmutableMap.builder(); + private Map flagValues = new HashMap<>(); + private Map flagMetadata = new HashMap<>(); private boolean valid = true; Builder valid(boolean valid) { @@ -133,7 +135,7 @@ Builder addFlag(FeatureFlag flag, EvaluationDetail eval) { } FeatureFlagsState build() { - return new FeatureFlagsState(flagValues.build(), flagMetadata.build(), valid); + return new FeatureFlagsState(flagValues, flagMetadata, valid); } } @@ -155,8 +157,8 @@ public void write(JsonWriter out, FeatureFlagsState state) throws IOException { // There isn't really a use case for deserializing this, but we have to implement it @Override public FeatureFlagsState read(JsonReader in) throws IOException { - ImmutableMap.Builder flagValues = ImmutableMap.builder(); - ImmutableMap.Builder flagMetadata = ImmutableMap.builder(); + Map flagValues = new HashMap<>(); + Map flagMetadata = new HashMap<>(); boolean valid = true; in.beginObject(); while (in.hasNext()) { @@ -177,7 +179,7 @@ public FeatureFlagsState read(JsonReader in) throws IOException { } } in.endObject(); - return new FeatureFlagsState(flagValues.build(), flagMetadata.build(), valid); + return new FeatureFlagsState(flagValues, flagMetadata, valid); } } } diff --git a/src/main/java/com/launchdarkly/client/LDUser.java b/src/main/java/com/launchdarkly/client/LDUser.java index adde857bc..5bf6b06c5 100644 --- a/src/main/java/com/launchdarkly/client/LDUser.java +++ b/src/main/java/com/launchdarkly/client/LDUser.java @@ -1,16 +1,22 @@ package com.launchdarkly.client; -import com.google.common.collect.ImmutableSet; -import com.google.gson.*; +import com.google.gson.JsonArray; +import com.google.gson.JsonElement; +import com.google.gson.JsonPrimitive; +import com.google.gson.TypeAdapter; import com.google.gson.internal.Streams; import com.google.gson.stream.JsonReader; import com.google.gson.stream.JsonWriter; -import com.google.common.collect.ImmutableMap; + import org.slf4j.Logger; import org.slf4j.LoggerFactory; import java.io.IOException; -import java.util.*; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; import java.util.regex.Pattern; /** @@ -350,8 +356,8 @@ public Builder(LDUser user) { this.avatar = user.getAvatar() != null ? user.getAvatar().getAsString() : null; this.anonymous = user.getAnonymous() != null ? user.getAnonymous().getAsBoolean() : null; this.country = user.getCountry() != null ? LDCountryCode.valueOf(user.getCountry().getAsString()) : null; - this.custom = ImmutableMap.copyOf(user.custom); - this.privateAttrNames = ImmutableSet.copyOf(user.privateAttributeNames); + this.custom = new HashMap<>(user.custom); + this.privateAttrNames = new HashSet<>(user.privateAttributeNames); } /** diff --git a/src/test/java/com/launchdarkly/client/FeatureFlagsStateTest.java b/src/test/java/com/launchdarkly/client/FeatureFlagsStateTest.java index 2e478a3ba..5885cc850 100644 --- a/src/test/java/com/launchdarkly/client/FeatureFlagsStateTest.java +++ b/src/test/java/com/launchdarkly/client/FeatureFlagsStateTest.java @@ -29,6 +29,15 @@ public void unknownFlagReturnsNullValue() { assertNull(state.getFlagValue("key")); } + @Test + public void flagCanHaveNullValue() { + EvaluationDetail eval = new EvaluationDetail(null, 1, null); + FeatureFlag flag = new FeatureFlagBuilder("key").build(); + FeatureFlagsState state = new FeatureFlagsState.Builder().addFlag(flag, eval).build(); + + assertNull(state.getFlagValue("key")); + } + @Test public void canConvertToValuesMap() { EvaluationDetail eval1 = new EvaluationDetail(null, 0, js("value1")); From d026e18e4d0c5f3abad5761cbf097f1f66638acb Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Thu, 23 Aug 2018 14:14:56 -0700 Subject: [PATCH 37/43] make map & set in the User immutable --- src/main/java/com/launchdarkly/client/LDUser.java | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/main/java/com/launchdarkly/client/LDUser.java b/src/main/java/com/launchdarkly/client/LDUser.java index 5bf6b06c5..6dc26e836 100644 --- a/src/main/java/com/launchdarkly/client/LDUser.java +++ b/src/main/java/com/launchdarkly/client/LDUser.java @@ -1,5 +1,7 @@ package com.launchdarkly.client; +import com.google.common.collect.ImmutableMap; +import com.google.common.collect.ImmutableSet; import com.google.gson.JsonArray; import com.google.gson.JsonElement; import com.google.gson.JsonPrimitive; @@ -61,8 +63,8 @@ protected LDUser(Builder builder) { this.name = builder.name == null ? null : new JsonPrimitive(builder.name); this.avatar = builder.avatar == null ? null : new JsonPrimitive(builder.avatar); this.anonymous = builder.anonymous == null ? null : new JsonPrimitive(builder.anonymous); - this.custom = new HashMap<>(builder.custom); - this.privateAttributeNames = new HashSet<>(builder.privateAttrNames); + this.custom = ImmutableMap.copyOf(builder.custom); + this.privateAttributeNames = ImmutableSet.copyOf(builder.privateAttrNames); } /** From 18ed2d67a63c8fa0a597eac2c5c146a7b3dcdcfa Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Thu, 23 Aug 2018 17:04:31 -0700 Subject: [PATCH 38/43] fix default value logic --- .../launchdarkly/client/EvaluationDetail.java | 9 +++++++ .../com/launchdarkly/client/LDClient.java | 12 ++++++--- .../client/LDClientEvaluationTest.java | 27 ++++++++++++++++--- 3 files changed, 40 insertions(+), 8 deletions(-) diff --git a/src/main/java/com/launchdarkly/client/EvaluationDetail.java b/src/main/java/com/launchdarkly/client/EvaluationDetail.java index 7469f9ea3..0bb2880b9 100644 --- a/src/main/java/com/launchdarkly/client/EvaluationDetail.java +++ b/src/main/java/com/launchdarkly/client/EvaluationDetail.java @@ -49,6 +49,15 @@ public T getValue() { return value; } + /** + * Returns true if the flag evaluation returned the default value, rather than one of the flag's + * variations. + * @return true if this is the default value + */ + public boolean isDefaultValue() { + return variationIndex == null; + } + @Override public boolean equals(Object other) { if (other instanceof EvaluationDetail) { diff --git a/src/main/java/com/launchdarkly/client/LDClient.java b/src/main/java/com/launchdarkly/client/LDClient.java index d02bcdd22..253b5be2d 100644 --- a/src/main/java/com/launchdarkly/client/LDClient.java +++ b/src/main/java/com/launchdarkly/client/LDClient.java @@ -274,10 +274,10 @@ private T evaluate(String featureKey, LDUser user, T defaultValue, JsonEleme private EvaluationDetail evaluateDetail(String featureKey, LDUser user, T defaultValue, JsonElement defaultJson, VariationType expectedType, EventFactory eventFactory) { EvaluationDetail details = evaluateInternal(featureKey, user, defaultJson, eventFactory); - T resultValue; + T resultValue = null; if (details.getReason().getKind() == EvaluationReason.Kind.ERROR) { resultValue = defaultValue; - } else { + } else if (details.getValue() != null) { try { resultValue = expectedType.coerceValue(details.getValue()); } catch (EvaluationException e) { @@ -322,8 +322,12 @@ private EvaluationDetail evaluateInternal(String featureKey, LDUser for (Event.FeatureRequest event : evalResult.getPrerequisiteEvents()) { eventProcessor.sendEvent(event); } - sendFlagRequestEvent(eventFactory.newFeatureRequestEvent(featureFlag, user, evalResult.getDetails(), defaultValue)); - return evalResult.getDetails(); + EvaluationDetail details = evalResult.getDetails(); + if (details.isDefaultValue()) { + details = new EvaluationDetail(details.getReason(), null, defaultValue); + } + sendFlagRequestEvent(eventFactory.newFeatureRequestEvent(featureFlag, user, details, defaultValue)); + return details; } catch (Exception e) { logger.error("Encountered exception while evaluating feature flag \"{}\": {}", featureKey, e.toString()); logger.debug(e.toString(), e); diff --git a/src/test/java/com/launchdarkly/client/LDClientEvaluationTest.java b/src/test/java/com/launchdarkly/client/LDClientEvaluationTest.java index afdb9f54d..e4631b3ab 100644 --- a/src/test/java/com/launchdarkly/client/LDClientEvaluationTest.java +++ b/src/test/java/com/launchdarkly/client/LDClientEvaluationTest.java @@ -128,6 +128,25 @@ public void canGetDetailsForSuccessfulEvaluation() throws Exception { assertEquals(expectedResult, client.boolVariationDetail("key", user, false)); } + @Test + public void variationReturnsDefaultIfFlagEvaluatesToNull() { + FeatureFlag flag = new FeatureFlagBuilder("key").on(false).offVariation(null).build(); + featureStore.upsert(FEATURES, flag); + + assertEquals("default", client.stringVariation("key", user, "default")); + } + + @Test + public void variationDetailReturnsDefaultIfFlagEvaluatesToNull() { + FeatureFlag flag = new FeatureFlagBuilder("key").on(false).offVariation(null).build(); + featureStore.upsert(FEATURES, flag); + + EvaluationDetail expected = new EvaluationDetail(EvaluationReason.off(), null, "default"); + EvaluationDetail actual = client.stringVariationDetail("key", user, "default"); + assertEquals(expected, actual); + assertTrue(actual.isDefaultValue()); + } + @Test public void appropriateErrorIfClientNotInitialized() throws Exception { FeatureStore badFeatureStore = new InMemoryFeatureStore(); @@ -145,16 +164,16 @@ public void appropriateErrorIfClientNotInitialized() throws Exception { @Test public void appropriateErrorIfFlagDoesNotExist() throws Exception { - EvaluationDetail expectedResult = EvaluationDetail.error(EvaluationReason.ErrorKind.FLAG_NOT_FOUND, false); - assertEquals(expectedResult, client.boolVariationDetail("key", user, false)); + EvaluationDetail expectedResult = EvaluationDetail.error(EvaluationReason.ErrorKind.FLAG_NOT_FOUND, "default"); + assertEquals(expectedResult, client.stringVariationDetail("key", user, "default")); } @Test public void appropriateErrorIfUserNotSpecified() throws Exception { featureStore.upsert(FEATURES, flagWithValue("key", jbool(true))); - EvaluationDetail expectedResult = EvaluationDetail.error(EvaluationReason.ErrorKind.USER_NOT_SPECIFIED, false); - assertEquals(expectedResult, client.boolVariationDetail("key", null, false)); + EvaluationDetail expectedResult = EvaluationDetail.error(EvaluationReason.ErrorKind.USER_NOT_SPECIFIED, "default"); + assertEquals(expectedResult, client.stringVariationDetail("key", null, "default")); } @Test From d21466cdfaf9e842ca97dfa697d41dc51361d19f Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Mon, 27 Aug 2018 13:26:56 -0700 Subject: [PATCH 39/43] javadoc fix --- src/main/java/com/launchdarkly/client/FeatureFlagsState.java | 2 +- src/main/java/com/launchdarkly/client/FlagsStateOption.java | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/main/java/com/launchdarkly/client/FeatureFlagsState.java b/src/main/java/com/launchdarkly/client/FeatureFlagsState.java index 97df3c5fc..bd962538b 100644 --- a/src/main/java/com/launchdarkly/client/FeatureFlagsState.java +++ b/src/main/java/com/launchdarkly/client/FeatureFlagsState.java @@ -15,7 +15,7 @@ /** * A snapshot of the state of all feature flags with regard to a specific user, generated by - * calling {@link LDClientInterface#allFlagsState(LDUser)}. + * calling {@link LDClientInterface#allFlagsState(LDUser, FlagsStateOption...)}. *

* Serializing this object to JSON using Gson will produce the appropriate data structure for * bootstrapping the LaunchDarkly JavaScript client. diff --git a/src/main/java/com/launchdarkly/client/FlagsStateOption.java b/src/main/java/com/launchdarkly/client/FlagsStateOption.java index 1cda057be..f519f1bc8 100644 --- a/src/main/java/com/launchdarkly/client/FlagsStateOption.java +++ b/src/main/java/com/launchdarkly/client/FlagsStateOption.java @@ -1,7 +1,7 @@ package com.launchdarkly.client; /** - * Optional parameters that can be passed to {@link LDClientInterface#allFlagsState(LDUser)}. + * Optional parameters that can be passed to {@link LDClientInterface#allFlagsState(LDUser, FlagsStateOption...)}. * @since 4.3.0 */ public final class FlagsStateOption { From e0a41a5ce0acfb4ce6a2b5c610cff0fad338a19d Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Tue, 28 Aug 2018 17:46:21 -0700 Subject: [PATCH 40/43] make LDUser serialize correctly as JSON and add more test coverage --- .../com/launchdarkly/client/LDConfig.java | 2 +- .../java/com/launchdarkly/client/LDUser.java | 135 +++--- .../com/launchdarkly/client/LDUserTest.java | 405 ++++++++++++------ 3 files changed, 341 insertions(+), 201 deletions(-) diff --git a/src/main/java/com/launchdarkly/client/LDConfig.java b/src/main/java/com/launchdarkly/client/LDConfig.java index cc753e6e8..32409a07f 100644 --- a/src/main/java/com/launchdarkly/client/LDConfig.java +++ b/src/main/java/com/launchdarkly/client/LDConfig.java @@ -29,7 +29,7 @@ */ public final class LDConfig { private static final Logger logger = LoggerFactory.getLogger(LDConfig.class); - final Gson gson = new GsonBuilder().registerTypeAdapter(LDUser.class, new LDUser.UserAdapter(this)).create(); + final Gson gson = new GsonBuilder().registerTypeAdapter(LDUser.class, new LDUser.UserAdapterWithPrivateAttributeBehavior(this)).create(); private static final URI DEFAULT_BASE_URI = URI.create("https://app.launchdarkly.com"); private static final URI DEFAULT_EVENTS_URI = URI.create("https://events.launchdarkly.com"); diff --git a/src/main/java/com/launchdarkly/client/LDUser.java b/src/main/java/com/launchdarkly/client/LDUser.java index 6dc26e836..8fcada638 100644 --- a/src/main/java/com/launchdarkly/client/LDUser.java +++ b/src/main/java/com/launchdarkly/client/LDUser.java @@ -2,11 +2,11 @@ import com.google.common.collect.ImmutableMap; import com.google.common.collect.ImmutableSet; +import com.google.gson.Gson; import com.google.gson.JsonArray; import com.google.gson.JsonElement; import com.google.gson.JsonPrimitive; import com.google.gson.TypeAdapter; -import com.google.gson.internal.Streams; import com.google.gson.stream.JsonReader; import com.google.gson.stream.JsonWriter; @@ -18,6 +18,7 @@ import java.util.HashSet; import java.util.List; import java.util.Map; +import java.util.Objects; import java.util.Set; import java.util.regex.Pattern; @@ -32,8 +33,15 @@ *

* Custom attributes are not parsed by LaunchDarkly. They can be used in custom rules-- for example, a custom attribute such as "customer_ranking" can be used to * launch a feature to the top 10% of users on a site. + *

+ * If you want to pass an LDUser object to the front end to be used with the JavaScript SDK, simply call Gson.toJson() or + * Gson.toJsonTree() on it. */ public class LDUser { + private static final Logger logger = LoggerFactory.getLogger(LDUser.class); + + // Note that these fields are all stored internally as JsonPrimitive rather than String so that + // we don't waste time repeatedly converting them to JsonPrimitive in the rule evaluation logic. private final JsonPrimitive key; private JsonPrimitive secondary; private JsonPrimitive ip; @@ -44,10 +52,8 @@ public class LDUser { private JsonPrimitive lastName; private JsonPrimitive anonymous; private JsonPrimitive country; - protected Map custom; - // This is set as transient as we'll use a custom serializer to marshal it - protected transient Set privateAttributeNames; - private static final Logger logger = LoggerFactory.getLogger(LDUser.class); + private Map custom; + Set privateAttributeNames; protected LDUser(Builder builder) { if (builder.key == null || builder.key.equals("")) { @@ -63,8 +69,8 @@ protected LDUser(Builder builder) { this.name = builder.name == null ? null : new JsonPrimitive(builder.name); this.avatar = builder.avatar == null ? null : new JsonPrimitive(builder.avatar); this.anonymous = builder.anonymous == null ? null : new JsonPrimitive(builder.anonymous); - this.custom = ImmutableMap.copyOf(builder.custom); - this.privateAttributeNames = ImmutableSet.copyOf(builder.privateAttrNames); + this.custom = builder.custom == null ? null : ImmutableMap.copyOf(builder.custom); + this.privateAttributeNames = builder.privateAttrNames == null ? null : ImmutableSet.copyOf(builder.privateAttrNames); } /** @@ -74,8 +80,8 @@ protected LDUser(Builder builder) { */ public LDUser(String key) { this.key = new JsonPrimitive(key); - this.custom = new HashMap<>(); - this.privateAttributeNames = new HashSet(); + this.custom = null; + this.privateAttributeNames = null; } protected JsonElement getValueForEvaluation(String attribute) { @@ -142,7 +148,7 @@ JsonElement getCustom(String key) { } return null; } - + @Override public boolean equals(Object o) { if (this == o) return true; @@ -150,41 +156,31 @@ public boolean equals(Object o) { LDUser ldUser = (LDUser) o; - if (key != null ? !key.equals(ldUser.key) : ldUser.key != null) return false; - if (secondary != null ? !secondary.equals(ldUser.secondary) : ldUser.secondary != null) return false; - if (ip != null ? !ip.equals(ldUser.ip) : ldUser.ip != null) return false; - if (email != null ? !email.equals(ldUser.email) : ldUser.email != null) return false; - if (name != null ? !name.equals(ldUser.name) : ldUser.name != null) return false; - if (avatar != null ? !avatar.equals(ldUser.avatar) : ldUser.avatar != null) return false; - if (firstName != null ? !firstName.equals(ldUser.firstName) : ldUser.firstName != null) return false; - if (lastName != null ? !lastName.equals(ldUser.lastName) : ldUser.lastName != null) return false; - if (anonymous != null ? !anonymous.equals(ldUser.anonymous) : ldUser.anonymous != null) return false; - if (country != null ? !country.equals(ldUser.country) : ldUser.country != null) return false; - if (custom != null ? !custom.equals(ldUser.custom) : ldUser.custom != null) return false; - return privateAttributeNames != null ? privateAttributeNames.equals(ldUser.privateAttributeNames) : ldUser.privateAttributeNames == null; + return Objects.equals(key, ldUser.key) && + Objects.equals(secondary, ldUser.secondary) && + Objects.equals(ip, ldUser.ip) && + Objects.equals(email, ldUser.email) && + Objects.equals(name, ldUser.name) && + Objects.equals(avatar, ldUser.avatar) && + Objects.equals(firstName, ldUser.firstName) && + Objects.equals(lastName, ldUser.lastName) && + Objects.equals(anonymous, ldUser.anonymous) && + Objects.equals(country, ldUser.country) && + Objects.equals(custom, ldUser.custom) && + Objects.equals(privateAttributeNames, ldUser.privateAttributeNames); } @Override public int hashCode() { - int result = key != null ? key.hashCode() : 0; - result = 31 * result + (secondary != null ? secondary.hashCode() : 0); - result = 31 * result + (ip != null ? ip.hashCode() : 0); - result = 31 * result + (email != null ? email.hashCode() : 0); - result = 31 * result + (name != null ? name.hashCode() : 0); - result = 31 * result + (avatar != null ? avatar.hashCode() : 0); - result = 31 * result + (firstName != null ? firstName.hashCode() : 0); - result = 31 * result + (lastName != null ? lastName.hashCode() : 0); - result = 31 * result + (anonymous != null ? anonymous.hashCode() : 0); - result = 31 * result + (country != null ? country.hashCode() : 0); - result = 31 * result + (custom != null ? custom.hashCode() : 0); - result = 31 * result + (privateAttributeNames != null ? privateAttributeNames.hashCode() : 0); - return result; + return Objects.hash(key, secondary, ip, email, name, avatar, firstName, lastName, anonymous, country, custom, privateAttributeNames); } - static class UserAdapter extends TypeAdapter { + // Used internally when including users in analytics events, to ensure that private attributes are stripped out. + static class UserAdapterWithPrivateAttributeBehavior extends TypeAdapter { + private static final Gson gson = new Gson(); private final LDConfig config; - public UserAdapter(LDConfig config) { + public UserAdapterWithPrivateAttributeBehavior(LDConfig config) { this.config = config; } @@ -284,10 +280,7 @@ private void writeCustomAttrs(JsonWriter out, LDUser user, Set privateAt beganObject = true; } out.name(entry.getKey()); - // NB: this accesses part of the internal GSON api. However, it's likely - // the only way to write a JsonElement directly: - // https://groups.google.com/forum/#!topic/google-gson/JpHbpZ9mTOk - Streams.write(entry.getValue(), out); + gson.toJson(entry.getValue(), JsonElement.class, out); } } if (beganObject) { @@ -333,8 +326,6 @@ public static class Builder { */ public Builder(String key) { this.key = key; - this.custom = new HashMap<>(); - this.privateAttrNames = new HashSet<>(); } /** @@ -358,8 +349,8 @@ public Builder(LDUser user) { this.avatar = user.getAvatar() != null ? user.getAvatar().getAsString() : null; this.anonymous = user.getAnonymous() != null ? user.getAnonymous().getAsBoolean() : null; this.country = user.getCountry() != null ? LDCountryCode.valueOf(user.getCountry().getAsString()) : null; - this.custom = new HashMap<>(user.custom); - this.privateAttrNames = new HashSet<>(user.privateAttributeNames); + this.custom = user.custom == null ? null : new HashMap<>(user.custom); + this.privateAttrNames = user.privateAttributeNames == null ? null : new HashSet<>(user.privateAttributeNames); } /** @@ -380,7 +371,7 @@ public Builder ip(String s) { * @return the builder */ public Builder privateIp(String s) { - privateAttrNames.add("ip"); + addPrivate("ip"); return ip(s); } @@ -404,7 +395,7 @@ public Builder secondary(String s) { * @return the builder */ public Builder privateSecondary(String s) { - privateAttrNames.add("secondary"); + addPrivate("secondary"); return secondary(s); } @@ -455,7 +446,7 @@ public Builder country(String s) { * @return the builder */ public Builder privateCountry(String s) { - privateAttrNames.add("country"); + addPrivate("country"); return country(s); } @@ -477,7 +468,7 @@ public Builder country(LDCountryCode country) { * @return the builder */ public Builder privateCountry(LDCountryCode country) { - privateAttrNames.add("country"); + addPrivate("country"); return country(country); } @@ -500,7 +491,7 @@ public Builder firstName(String firstName) { * @return the builder */ public Builder privateFirstName(String firstName) { - privateAttrNames.add("firstName"); + addPrivate("firstName"); return firstName(firstName); } @@ -534,7 +525,7 @@ public Builder lastName(String lastName) { * @return the builder */ public Builder privateLastName(String lastName) { - privateAttrNames.add("lastName"); + addPrivate("lastName"); return lastName(lastName); } @@ -557,7 +548,7 @@ public Builder name(String name) { * @return the builder */ public Builder privateName(String name) { - privateAttrNames.add("name"); + addPrivate("name"); return name(name); } @@ -579,7 +570,7 @@ public Builder avatar(String avatar) { * @return the builder */ public Builder privateAvatar(String avatar) { - privateAttrNames.add("avatar"); + addPrivate("avatar"); return avatar(avatar); } @@ -602,7 +593,7 @@ public Builder email(String email) { * @return the builder */ public Builder privateEmail(String email) { - privateAttrNames.add("email"); + addPrivate("email"); return email(email); } @@ -657,6 +648,9 @@ public Builder custom(String k, Boolean b) { public Builder custom(String k, JsonElement v) { checkCustomAttribute(k); if (k != null && v != null) { + if (custom == null) { + custom = new HashMap<>(); + } custom.put(k, v); } return this; @@ -672,15 +666,13 @@ public Builder custom(String k, JsonElement v) { * @return the builder */ public Builder customString(String k, List vs) { - checkCustomAttribute(k); JsonArray array = new JsonArray(); for (String v : vs) { if (v != null) { array.add(new JsonPrimitive(v)); } } - custom.put(k, array); - return this; + return custom(k, array); } /** @@ -693,15 +685,13 @@ public Builder customString(String k, List vs) { * @return the builder */ public Builder customNumber(String k, List vs) { - checkCustomAttribute(k); JsonArray array = new JsonArray(); for (Number v : vs) { if (v != null) { array.add(new JsonPrimitive(v)); } } - custom.put(k, array); - return this; + return custom(k, array); } /** @@ -714,15 +704,13 @@ public Builder customNumber(String k, List vs) { * @return the builder */ public Builder customValues(String k, List vs) { - checkCustomAttribute(k); JsonArray array = new JsonArray(); for (JsonElement v : vs) { if (v != null) { array.add(v); } } - custom.put(k, array); - return this; + return custom(k, array); } /** @@ -736,7 +724,7 @@ public Builder customValues(String k, List vs) { * @return the builder */ public Builder privateCustom(String k, String v) { - privateAttrNames.add(k); + addPrivate(k); return custom(k, v); } @@ -751,7 +739,7 @@ public Builder privateCustom(String k, String v) { * @return the builder */ public Builder privateCustom(String k, Number n) { - privateAttrNames.add(k); + addPrivate(k); return custom(k, n); } @@ -766,7 +754,7 @@ public Builder privateCustom(String k, Number n) { * @return the builder */ public Builder privateCustom(String k, Boolean b) { - privateAttrNames.add(k); + addPrivate(k); return custom(k, b); } @@ -781,7 +769,7 @@ public Builder privateCustom(String k, Boolean b) { * @return the builder */ public Builder privateCustom(String k, JsonElement v) { - privateAttrNames.add(k); + addPrivate(k); return custom(k, v); } @@ -796,7 +784,7 @@ public Builder privateCustom(String k, JsonElement v) { * @return the builder */ public Builder privateCustomString(String k, List vs) { - privateAttrNames.add(k); + addPrivate(k); return customString(k, vs); } @@ -811,7 +799,7 @@ public Builder privateCustomString(String k, List vs) { * @return the builder */ public Builder privateCustomNumber(String k, List vs) { - privateAttrNames.add(k); + addPrivate(k); return customNumber(k, vs); } @@ -826,7 +814,7 @@ public Builder privateCustomNumber(String k, List vs) { * @return the builder */ public Builder privateCustomValues(String k, List vs) { - privateAttrNames.add(k); + addPrivate(k); return customValues(k, vs); } @@ -839,6 +827,13 @@ private void checkCustomAttribute(String key) { } } + private void addPrivate(String key) { + if (privateAttrNames == null) { + privateAttrNames = new HashSet<>(); + } + privateAttrNames.add(key); + } + /** * Builds the configured {@link com.launchdarkly.client.LDUser} object. * diff --git a/src/test/java/com/launchdarkly/client/LDUserTest.java b/src/test/java/com/launchdarkly/client/LDUserTest.java index ca8a9f7f4..ccbfe8c95 100644 --- a/src/test/java/com/launchdarkly/client/LDUserTest.java +++ b/src/test/java/com/launchdarkly/client/LDUserTest.java @@ -1,6 +1,8 @@ package com.launchdarkly.client; import com.google.common.collect.ImmutableList; +import com.google.common.collect.ImmutableMap; +import com.google.common.collect.ImmutableSet; import com.google.gson.Gson; import com.google.gson.JsonArray; import com.google.gson.JsonElement; @@ -11,20 +13,22 @@ import org.junit.Test; import java.lang.reflect.Type; +import java.util.HashSet; +import java.util.List; import java.util.Map; +import java.util.Set; import static com.launchdarkly.client.TestUtil.jbool; import static com.launchdarkly.client.TestUtil.jdouble; import static com.launchdarkly.client.TestUtil.jint; import static com.launchdarkly.client.TestUtil.js; import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; import static org.junit.Assert.assertNull; -import static org.junit.Assert.assertTrue; public class LDUserTest { - - private JsonPrimitive us = new JsonPrimitive(LDCountryCode.US.getAlpha2()); - + private static final Gson defaultGson = new Gson(); + @Test public void testLDUserConstructor() { LDUser user = new LDUser.Builder("key") @@ -44,169 +48,324 @@ public void testLDUserConstructor() { } @Test - public void testValidCountryCodeSetsCountry() { - LDUser user = new LDUser.Builder("key").country(LDCountryCode.US).build(); + public void canSetKey() { + LDUser user = new LDUser.Builder("k").build(); + assertEquals("k", user.getKeyAsString()); + } + + @Test + public void canSetSecondary() { + LDUser user = new LDUser.Builder("key").secondary("s").build(); + assertEquals("s", user.getSecondary().getAsString()); + } - assert(user.getCountry().equals(us)); + @Test + public void canSetPrivateSecondary() { + LDUser user = new LDUser.Builder("key").privateSecondary("s").build(); + assertEquals("s", user.getSecondary().getAsString()); + assertEquals(ImmutableSet.of("secondary"), user.privateAttributeNames); + } + + @Test + public void canSetIp() { + LDUser user = new LDUser.Builder("key").ip("i").build(); + assertEquals("i", user.getIp().getAsString()); + } + + @Test + public void canSetPrivateIp() { + LDUser user = new LDUser.Builder("key").privateIp("i").build(); + assertEquals("i", user.getIp().getAsString()); + assertEquals(ImmutableSet.of("ip"), user.privateAttributeNames); } + @Test + public void canSetEmail() { + LDUser user = new LDUser.Builder("key").email("e").build(); + assertEquals("e", user.getEmail().getAsString()); + } + + @Test + public void canSetPrivateEmail() { + LDUser user = new LDUser.Builder("key").privateEmail("e").build(); + assertEquals("e", user.getEmail().getAsString()); + assertEquals(ImmutableSet.of("email"), user.privateAttributeNames); + } @Test - public void testValidCountryCodeStringSetsCountry() { - LDUser user = new LDUser.Builder("key").country("US").build(); + public void canSetName() { + LDUser user = new LDUser.Builder("key").name("n").build(); + assertEquals("n", user.getName().getAsString()); + } + + @Test + public void canSetPrivateName() { + LDUser user = new LDUser.Builder("key").privateName("n").build(); + assertEquals("n", user.getName().getAsString()); + assertEquals(ImmutableSet.of("name"), user.privateAttributeNames); + } - assert(user.getCountry().equals(us)); + @Test + public void canSetAvatar() { + LDUser user = new LDUser.Builder("key").avatar("a").build(); + assertEquals("a", user.getAvatar().getAsString()); + } + + @Test + public void canSetPrivateAvatar() { + LDUser user = new LDUser.Builder("key").privateAvatar("a").build(); + assertEquals("a", user.getAvatar().getAsString()); + assertEquals(ImmutableSet.of("avatar"), user.privateAttributeNames); } @Test - public void testValidCountryCode3SetsCountry() { - LDUser user = new LDUser.Builder("key").country("USA").build(); + public void canSetFirstName() { + LDUser user = new LDUser.Builder("key").firstName("f").build(); + assertEquals("f", user.getFirstName().getAsString()); + } + + @Test + public void canSetPrivateFirstName() { + LDUser user = new LDUser.Builder("key").privateFirstName("f").build(); + assertEquals("f", user.getFirstName().getAsString()); + assertEquals(ImmutableSet.of("firstName"), user.privateAttributeNames); + } + + @Test + public void canSetLastName() { + LDUser user = new LDUser.Builder("key").lastName("l").build(); + assertEquals("l", user.getLastName().getAsString()); + } + + @Test + public void canSetPrivateLastName() { + LDUser user = new LDUser.Builder("key").privateLastName("l").build(); + assertEquals("l", user.getLastName().getAsString()); + assertEquals(ImmutableSet.of("lastName"), user.privateAttributeNames); + } + + @Test + public void canSetAnonymous() { + LDUser user = new LDUser.Builder("key").anonymous(true).build(); + assertEquals(true, user.getAnonymous().getAsBoolean()); + } + + @Test + public void canSetCountry() { + LDUser user = new LDUser.Builder("key").country(LDCountryCode.US).build(); + assertEquals("US", user.getCountry().getAsString()); + } + + @Test + public void canSetCountryAsString() { + LDUser user = new LDUser.Builder("key").country("US").build(); + assertEquals("US", user.getCountry().getAsString()); + } - assert(user.getCountry().equals(us)); + @Test + public void canSetCountryAs3CharacterString() { + LDUser user = new LDUser.Builder("key").country("USA").build(); + assertEquals("US", user.getCountry().getAsString()); } @Test - public void testAmbiguousCountryNameSetsCountryWithExactMatch() { + public void ambiguousCountryNameSetsCountryWithExactMatch() { // "United States" is ambiguous: can also match "United States Minor Outlying Islands" LDUser user = new LDUser.Builder("key").country("United States").build(); - assert(user.getCountry().equals(us)); + assertEquals("US", user.getCountry().getAsString()); } @Test - public void testAmbiguousCountryNameSetsCountryWithPartialMatch() { + public void ambiguousCountryNameSetsCountryWithPartialMatch() { // For an ambiguous match, we return the first match LDUser user = new LDUser.Builder("key").country("United St").build(); - assert(user.getCountry() != null); + assertNotNull(user.getCountry()); } - @Test - public void testPartialUniqueMatchSetsCountry() { + public void partialUniqueMatchSetsCountry() { LDUser user = new LDUser.Builder("key").country("United States Minor").build(); - assert(user.getCountry().equals(new JsonPrimitive(LDCountryCode.UM.getAlpha2()))); + assertEquals("UM", user.getCountry().getAsString()); } @Test - public void testInvalidCountryNameDoesNotSetCountry() { + public void invalidCountryNameDoesNotSetCountry() { LDUser user = new LDUser.Builder("key").country("East Jibip").build(); - assert(user.getCountry() == null); + assertNull(user.getCountry()); } @Test - public void testLDUserJsonSerializationContainsCountryAsTwoDigitCode() { - LDConfig config = LDConfig.DEFAULT; - Gson gson = config.gson; - LDUser user = new LDUser.Builder("key").country(LDCountryCode.US).build(); - - String jsonStr = gson.toJson(user); - Type type = new TypeToken>(){}.getType(); - Map json = gson.fromJson(jsonStr, type); - - assert(json.get("country").equals(us)); + public void canSetPrivateCountry() { + LDUser user = new LDUser.Builder("key").privateCountry(LDCountryCode.US).build(); + assertEquals("US", user.getCountry().getAsString()); + assertEquals(ImmutableSet.of("country"), user.privateAttributeNames); } @Test - public void testLDUserCustomMarshalWithPrivateAttrsProducesEquivalentLDUserIfNoAttrsArePrivate() { - LDConfig config = LDConfig.DEFAULT; - LDUser user = new LDUser.Builder("key") - .anonymous(true) - .avatar("avatar") - .country(LDCountryCode.AC) - .ip("127.0.0.1") - .firstName("bob") - .lastName("loblaw") - .email("bob@example.com") - .custom("foo", 42) - .build(); - - String jsonStr = new Gson().toJson(user); - Type type = new TypeToken>(){}.getType(); - Map json = config.gson.fromJson(jsonStr, type); - Map privateJson = config.gson.fromJson(config.gson.toJson(user), type); - - assertEquals(json, privateJson); + public void canSetCustomString() { + LDUser user = new LDUser.Builder("key").custom("thing", "value").build(); + assertEquals("value", user.getCustom("thing").getAsString()); } - + @Test - public void testLDUserCustomMarshalWithAllPrivateAttributesReturnsKey() { - LDConfig config = new LDConfig.Builder().allAttributesPrivate(true).build(); - LDUser user = new LDUser.Builder("key") - .email("foo@bar.com") - .custom("bar", 43) - .build(); + public void canSetPrivateCustomString() { + LDUser user = new LDUser.Builder("key").privateCustom("thing", "value").build(); + assertEquals("value", user.getCustom("thing").getAsString()); + assertEquals(ImmutableSet.of("thing"), user.privateAttributeNames); + } - Type type = new TypeToken>(){}.getType(); - Map privateJson = config.gson.fromJson(config.gson.toJson(user), type); + @Test + public void canSetCustomInt() { + LDUser user = new LDUser.Builder("key").custom("thing", 1).build(); + assertEquals(1, user.getCustom("thing").getAsInt()); + } + + @Test + public void canSetPrivateCustomInt() { + LDUser user = new LDUser.Builder("key").privateCustom("thing", 1).build(); + assertEquals(1, user.getCustom("thing").getAsInt()); + assertEquals(ImmutableSet.of("thing"), user.privateAttributeNames); + } + + @Test + public void canSetCustomBoolean() { + LDUser user = new LDUser.Builder("key").custom("thing", true).build(); + assertEquals(true, user.getCustom("thing").getAsBoolean()); + } + + @Test + public void canSetPrivateCustomBoolean() { + LDUser user = new LDUser.Builder("key").privateCustom("thing", true).build(); + assertEquals(true, user.getCustom("thing").getAsBoolean()); + assertEquals(ImmutableSet.of("thing"), user.privateAttributeNames); + } + + @Test + public void canSetCustomJsonValue() { + JsonObject value = new JsonObject(); + LDUser user = new LDUser.Builder("key").custom("thing", value).build(); + assertEquals(value, user.getCustom("thing")); + } - assertNull(privateJson.get("custom")); - assertEquals(privateJson.get("key").getAsString(), "key"); + @Test + public void canSetPrivateCustomJsonValue() { + JsonObject value = new JsonObject(); + LDUser user = new LDUser.Builder("key").privateCustom("thing", value).build(); + assertEquals(value, user.getCustom("thing")); + assertEquals(ImmutableSet.of("thing"), user.privateAttributeNames); + } - // email and custom are private - assert(privateJson.get("privateAttrs").getAsJsonArray().size() == 2); - assertNull(privateJson.get("email")); + @Test + public void testAllPropertiesInDefaultEncoding() { + for (Map.Entry e: getUserPropertiesJsonMap().entrySet()) { + JsonElement expected = defaultGson.fromJson(e.getValue(), JsonElement.class); + JsonElement actual = defaultGson.toJsonTree(e.getKey()); + assertEquals(expected, actual); + } + } + + @Test + public void testAllPropertiesInPrivateAttributeEncoding() { + for (Map.Entry e: getUserPropertiesJsonMap().entrySet()) { + JsonElement expected = defaultGson.fromJson(e.getValue(), JsonElement.class); + JsonElement actual = LDConfig.DEFAULT.gson.toJsonTree(e.getKey()); + assertEquals(expected, actual); + } } + private Map getUserPropertiesJsonMap() { + ImmutableMap.Builder builder = ImmutableMap.builder(); + builder.put(new LDUser.Builder("userkey").build(), "{\"key\":\"userkey\"}"); + builder.put(new LDUser.Builder("userkey").secondary("value").build(), + "{\"key\":\"userkey\",\"secondary\":\"value\"}"); + builder.put(new LDUser.Builder("userkey").ip("value").build(), + "{\"key\":\"userkey\",\"ip\":\"value\"}"); + builder.put(new LDUser.Builder("userkey").email("value").build(), + "{\"key\":\"userkey\",\"email\":\"value\"}"); + builder.put(new LDUser.Builder("userkey").name("value").build(), + "{\"key\":\"userkey\",\"name\":\"value\"}"); + builder.put(new LDUser.Builder("userkey").avatar("value").build(), + "{\"key\":\"userkey\",\"avatar\":\"value\"}"); + builder.put(new LDUser.Builder("userkey").firstName("value").build(), + "{\"key\":\"userkey\",\"firstName\":\"value\"}"); + builder.put(new LDUser.Builder("userkey").lastName("value").build(), + "{\"key\":\"userkey\",\"lastName\":\"value\"}"); + builder.put(new LDUser.Builder("userkey").anonymous(true).build(), + "{\"key\":\"userkey\",\"anonymous\":true}"); + builder.put(new LDUser.Builder("userkey").country(LDCountryCode.US).build(), + "{\"key\":\"userkey\",\"country\":\"US\"}"); + builder.put(new LDUser.Builder("userkey").custom("thing", "value").build(), + "{\"key\":\"userkey\",\"custom\":{\"thing\":\"value\"}}"); + return builder.build(); + } + + @Test + public void defaultJsonEncodingHasPrivateAttributeNames() { + LDUser user = new LDUser.Builder("userkey").privateName("x").privateEmail("y").build(); + String expected = "{\"key\":\"userkey\",\"name\":\"x\",\"email\":\"y\",\"privateAttributeNames\":[\"name\",\"email\"]}"; + assertEquals(defaultGson.fromJson(expected, JsonElement.class), defaultGson.toJsonTree(user)); + } + @Test - public void testLDUserAnonymousAttributeIsNeverPrivate() { + public void privateAttributeEncodingRedactsAllPrivateAttributes() { LDConfig config = new LDConfig.Builder().allAttributesPrivate(true).build(); - LDUser user = new LDUser.Builder("key") + LDUser user = new LDUser.Builder("userkey") + .secondary("s") + .ip("i") + .email("e") + .name("n") + .avatar("a") + .firstName("f") + .lastName("l") .anonymous(true) + .country(LDCountryCode.US) + .custom("thing", "value") .build(); + Set redacted = ImmutableSet.of("secondary", "ip", "email", "name", "avatar", "firstName", "lastName", "country", "thing"); - Type type = new TypeToken>(){}.getType(); - Map privateJson = config.gson.fromJson(config.gson.toJson(user), type); - - assertEquals(privateJson.get("anonymous").getAsBoolean(), true); - assertNull(privateJson.get("privateAttrs")); + JsonObject o = config.gson.toJsonTree(user).getAsJsonObject(); + assertEquals("userkey", o.get("key").getAsString()); + assertEquals(true, o.get("anonymous").getAsBoolean()); + for (String attr: redacted) { + assertNull(o.get(attr)); + } + assertNull(o.get("custom")); + assertEquals(redacted, getPrivateAttrs(o)); } - + @Test - public void testLDUserCustomMarshalWithPrivateAttrsRedactsCorrectAttrs() { - LDConfig config = LDConfig.DEFAULT; - LDUser user = new LDUser.Builder("key") - .privateCustom("foo", 42) + public void privateAttributeEncodingRedactsSpecificPerUserPrivateAttributes() { + LDUser user = new LDUser.Builder("userkey") + .email("e") + .privateName("n") .custom("bar", 43) - .build(); - - Type type = new TypeToken>(){}.getType(); - Map privateJson = config.gson.fromJson(config.gson.toJson(user), type); - - assertNull(privateJson.get("custom").getAsJsonObject().get("foo")); - assertEquals(privateJson.get("key").getAsString(), "key"); - assertEquals(privateJson.get("custom").getAsJsonObject().get("bar"), new JsonPrimitive(43)); - } - - @Test - public void testLDUserCustomMarshalWithPrivateGlobalAttributesRedactsCorrectAttrs() { - LDConfig config = new LDConfig.Builder().privateAttributeNames("foo", "bar").build(); - - LDUser user = new LDUser.Builder("key") .privateCustom("foo", 42) - .custom("bar", 43) - .custom("baz", 44) - .privateCustom("bum", 45) .build(); - - Type type = new TypeToken>(){}.getType(); - Map privateJson = config.gson.fromJson(config.gson.toJson(user), type); - - assertNull(privateJson.get("custom").getAsJsonObject().get("foo")); - assertNull(privateJson.get("custom").getAsJsonObject().get("bar")); - assertNull(privateJson.get("custom").getAsJsonObject().get("bum")); - assertEquals(privateJson.get("custom").getAsJsonObject().get("baz"), new JsonPrimitive(44)); + + JsonObject o = LDConfig.DEFAULT.gson.toJsonTree(user).getAsJsonObject(); + assertEquals("e", o.get("email").getAsString()); + assertNull(o.get("name")); + assertEquals(43, o.get("custom").getAsJsonObject().get("bar").getAsInt()); + assertNull(o.get("custom").getAsJsonObject().get("foo")); + assertEquals(ImmutableSet.of("name", "foo"), getPrivateAttrs(o)); } @Test - public void testLDUserCustomMarshalWithBuiltInAttributesRedactsCorrectAttrs() { - LDConfig config = LDConfig.DEFAULT; - LDUser user = new LDUser.Builder("key") - .privateEmail("foo@bar.com") + public void privateAttributeEncodingRedactsSpecificGlobalPrivateAttributes() { + LDConfig config = new LDConfig.Builder().privateAttributeNames("name", "foo").build(); + LDUser user = new LDUser.Builder("userkey") + .email("e") + .name("n") .custom("bar", 43) + .custom("foo", 42) .build(); - - Type type = new TypeToken>(){}.getType(); - Map privateJson = config.gson.fromJson(config.gson.toJson(user), type); - assertNull(privateJson.get("email")); + + JsonObject o = config.gson.toJsonTree(user).getAsJsonObject(); + assertEquals("e", o.get("email").getAsString()); + assertNull(o.get("name")); + assertEquals(43, o.get("custom").getAsJsonObject().get("bar").getAsInt()); + assertNull(o.get("custom").getAsJsonObject().get("foo")); + assertEquals(ImmutableSet.of("name", "foo"), getPrivateAttrs(o)); } @Test @@ -242,25 +401,6 @@ public void getValueReturnsNullIfNotFound() { assertNull(user.getValueForEvaluation("height")); } - @Test - public void canAddCustomAttrWithJsonValue() { - JsonElement value = new JsonPrimitive("x"); - LDUser user = new LDUser.Builder("key") - .custom("foo", value) - .build(); - assertEquals(value, user.getCustom("foo")); - } - - @Test - public void canAddPrivateCustomAttrWithJsonValue() { - JsonElement value = new JsonPrimitive("x"); - LDUser user = new LDUser.Builder("key") - .privateCustom("foo", value) - .build(); - assertEquals(value, user.getCustom("foo")); - assertTrue(user.privateAttributeNames.contains("foo")); - } - @Test public void canAddCustomAttrWithListOfStrings() { LDUser user = new LDUser.Builder("key") @@ -300,4 +440,9 @@ private JsonElement makeCustomAttrWithListOfValues(String name, JsonElement... v ret.add(name, a); return ret; } + + private Set getPrivateAttrs(JsonObject o) { + Type type = new TypeToken>(){}.getType(); + return new HashSet(defaultGson.>fromJson(o.get("privateAttrs"), type)); + } } From 6abbb228a73c2921a9a2486065878e73e65d1d8e Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Wed, 29 Aug 2018 11:05:58 -0700 Subject: [PATCH 41/43] preserve prerequisite flag value in event even if flag was off --- .../com/launchdarkly/client/FeatureFlag.java | 26 ++++++-------- .../launchdarkly/client/FeatureFlagTest.java | 34 ++++++++++++++++++- 2 files changed, 44 insertions(+), 16 deletions(-) diff --git a/src/main/java/com/launchdarkly/client/FeatureFlag.java b/src/main/java/com/launchdarkly/client/FeatureFlag.java index c13464c7a..7cc7dde91 100644 --- a/src/main/java/com/launchdarkly/client/FeatureFlag.java +++ b/src/main/java/com/launchdarkly/client/FeatureFlag.java @@ -74,16 +74,16 @@ EvalResult evaluate(LDUser user, FeatureStore featureStore, EventFactory eventFa return new EvalResult(EvaluationDetail.error(EvaluationReason.ErrorKind.USER_NOT_SPECIFIED, null), prereqEvents); } - if (isOn()) { - EvaluationDetail details = evaluate(user, featureStore, prereqEvents, eventFactory); - return new EvalResult(details, prereqEvents); - } - - return new EvalResult(getOffValue(EvaluationReason.off()), prereqEvents); + EvaluationDetail details = evaluate(user, featureStore, prereqEvents, eventFactory); + return new EvalResult(details, prereqEvents); } private EvaluationDetail evaluate(LDUser user, FeatureStore featureStore, List events, EventFactory eventFactory) { + if (!isOn()) { + return getOffValue(EvaluationReason.off()); + } + EvaluationReason prereqFailureReason = checkPrerequisites(user, featureStore, events, eventFactory); if (prereqFailureReason != null) { return getOffValue(prereqFailureReason); @@ -123,20 +123,16 @@ private EvaluationReason checkPrerequisites(LDUser user, FeatureStore featureSto boolean prereqOk = true; Prerequisite prereq = prerequisites.get(i); FeatureFlag prereqFeatureFlag = featureStore.get(FEATURES, prereq.getKey()); - EvaluationDetail prereqEvalResult = null; if (prereqFeatureFlag == null) { logger.error("Could not retrieve prerequisite flag \"{}\" when evaluating \"{}\"", prereq.getKey(), key); prereqOk = false; - } else if (prereqFeatureFlag.isOn()) { - prereqEvalResult = prereqFeatureFlag.evaluate(user, featureStore, events, eventFactory); - if (prereqEvalResult == null || prereqEvalResult.getVariationIndex() != prereq.getVariation()) { + } else { + EvaluationDetail prereqEvalResult = prereqFeatureFlag.evaluate(user, featureStore, events, eventFactory); + // Note that if the prerequisite flag is off, we don't consider it a match no matter what its + // off variation was. But we still need to evaluate it in order to generate an event. + if (!prereqFeatureFlag.isOn() || prereqEvalResult == null || prereqEvalResult.getVariationIndex() != prereq.getVariation()) { prereqOk = false; } - } else { - prereqOk = false; - } - // We continue to evaluate all prerequisites even if one failed. - if (prereqFeatureFlag != null) { events.add(eventFactory.newPrerequisiteFeatureRequestEvent(prereqFeatureFlag, user, prereqEvalResult, this)); } if (!prereqOk) { diff --git a/src/test/java/com/launchdarkly/client/FeatureFlagTest.java b/src/test/java/com/launchdarkly/client/FeatureFlagTest.java index 6ba5317b5..1d3c800c5 100644 --- a/src/test/java/com/launchdarkly/client/FeatureFlagTest.java +++ b/src/test/java/com/launchdarkly/client/FeatureFlagTest.java @@ -171,7 +171,39 @@ public void flagReturnsOffVariationIfPrerequisiteIsNotFound() throws Exception { assertEquals(new EvaluationDetail<>(expectedReason, 1, js("off")), result.getDetails()); assertEquals(0, result.getPrerequisiteEvents().size()); } - + + @Test + public void flagReturnsOffVariationAndEventIfPrerequisiteIsOff() throws Exception { + FeatureFlag f0 = new FeatureFlagBuilder("feature0") + .on(true) + .prerequisites(Arrays.asList(new Prerequisite("feature1", 1))) + .fallthrough(fallthroughVariation(0)) + .offVariation(1) + .variations(js("fall"), js("off"), js("on")) + .version(1) + .build(); + FeatureFlag f1 = new FeatureFlagBuilder("feature1") + .on(false) + .offVariation(1) + // note that even though it returns the desired variation, it is still off and therefore not a match + .fallthrough(fallthroughVariation(0)) + .variations(js("nogo"), js("go")) + .version(2) + .build(); + featureStore.upsert(FEATURES, f1); + FeatureFlag.EvalResult result = f0.evaluate(BASE_USER, featureStore, EventFactory.DEFAULT); + + EvaluationReason expectedReason = EvaluationReason.prerequisiteFailed("feature1"); + assertEquals(new EvaluationDetail<>(expectedReason, 1, js("off")), result.getDetails()); + + assertEquals(1, result.getPrerequisiteEvents().size()); + Event.FeatureRequest event = result.getPrerequisiteEvents().get(0); + assertEquals(f1.getKey(), event.key); + assertEquals(js("go"), event.value); + assertEquals(f1.getVersion(), event.version.intValue()); + assertEquals(f0.getKey(), event.prereqOf); + } + @Test public void flagReturnsOffVariationAndEventIfPrerequisiteIsNotMet() throws Exception { FeatureFlag f0 = new FeatureFlagBuilder("feature0") From c18a1e17daa091289cf7afddc690bde065b0a5b8 Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Wed, 29 Aug 2018 16:26:31 -0700 Subject: [PATCH 42/43] more test coverage for requesting values of different types --- .../client/LDClientEvaluationTest.java | 42 +++++++++++++++++++ 1 file changed, 42 insertions(+) diff --git a/src/test/java/com/launchdarkly/client/LDClientEvaluationTest.java b/src/test/java/com/launchdarkly/client/LDClientEvaluationTest.java index 201978b9f..e07777647 100644 --- a/src/test/java/com/launchdarkly/client/LDClientEvaluationTest.java +++ b/src/test/java/com/launchdarkly/client/LDClientEvaluationTest.java @@ -54,6 +54,13 @@ public void boolVariationReturnsDefaultValueForUnknownFlag() throws Exception { assertFalse(client.boolVariation("key", user, false)); } + @Test + public void boolVariationReturnsDefaultValueForWrongType() throws Exception { + featureStore.upsert(FEATURES, flagWithValue("key", js("wrong"))); + + assertFalse(client.boolVariation("key", user, false)); + } + @Test public void intVariationReturnsFlagValue() throws Exception { featureStore.upsert(FEATURES, flagWithValue("key", jint(2))); @@ -61,11 +68,25 @@ public void intVariationReturnsFlagValue() throws Exception { assertEquals(new Integer(2), client.intVariation("key", user, 1)); } + @Test + public void intVariationReturnsFlagValueEvenIfEncodedAsDouble() throws Exception { + featureStore.upsert(FEATURES, flagWithValue("key", jdouble(2.0))); + + assertEquals(new Integer(2), client.intVariation("key", user, 1)); + } + @Test public void intVariationReturnsDefaultValueForUnknownFlag() throws Exception { assertEquals(new Integer(1), client.intVariation("key", user, 1)); } + @Test + public void intVariationReturnsDefaultValueForWrongType() throws Exception { + featureStore.upsert(FEATURES, flagWithValue("key", js("wrong"))); + + assertEquals(new Integer(1), client.intVariation("key", user, 1)); + } + @Test public void doubleVariationReturnsFlagValue() throws Exception { featureStore.upsert(FEATURES, flagWithValue("key", jdouble(2.5d))); @@ -73,11 +94,25 @@ public void doubleVariationReturnsFlagValue() throws Exception { assertEquals(new Double(2.5d), client.doubleVariation("key", user, 1.0d)); } + @Test + public void doubleVariationReturnsFlagValueEvenIfEncodedAsInt() throws Exception { + featureStore.upsert(FEATURES, flagWithValue("key", jint(2))); + + assertEquals(new Double(2.0d), client.doubleVariation("key", user, 1.0d)); + } + @Test public void doubleVariationReturnsDefaultValueForUnknownFlag() throws Exception { assertEquals(new Double(1.0d), client.doubleVariation("key", user, 1.0d)); } + @Test + public void doubleVariationReturnsDefaultValueForWrongType() throws Exception { + featureStore.upsert(FEATURES, flagWithValue("key", js("wrong"))); + + assertEquals(new Double(1.0d), client.doubleVariation("key", user, 1.0d)); + } + @Test public void stringVariationReturnsFlagValue() throws Exception { featureStore.upsert(FEATURES, flagWithValue("key", js("b"))); @@ -90,6 +125,13 @@ public void stringVariationReturnsDefaultValueForUnknownFlag() throws Exception assertEquals("a", client.stringVariation("key", user, "a")); } + @Test + public void stringVariationReturnsDefaultValueForWrongType() throws Exception { + featureStore.upsert(FEATURES, flagWithValue("key", jbool(true))); + + assertEquals("a", client.stringVariation("key", user, "a")); + } + @Test public void jsonVariationReturnsFlagValue() throws Exception { JsonObject data = new JsonObject(); From 982572f6962d1388dc74db811078db6cc237b173 Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Tue, 4 Sep 2018 16:30:53 -0700 Subject: [PATCH 43/43] version 4.3.1 --- CHANGELOG.md | 5 +++++ gradle.properties | 2 +- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1309ba075..a5dca8328 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,11 @@ All notable changes to the LaunchDarkly Java SDK will be documented in this file. This project adheres to [Semantic Versioning](http://semver.org). +## [4.3.1] - 2018-09-04 +### Fixed: +- When evaluating a prerequisite feature flag, the analytics event for the evaluation did not include the result value if the prerequisite flag was off. +- The default Gson serialization for `LDUser` now includes all user properties. Previously, it omitted `privateAttributeNames`. + ## [4.3.0] - 2018-08-27 ### Added: - The new `LDClient` method `allFlagsState()` should be used instead of `allFlags()` if you are passing flag data to the front end for use with the JavaScript SDK. It preserves some flag metadata that the front end requires in order to send analytics events correctly. Versions 2.5.0 and above of the JavaScript SDK are able to use this metadata, but the output of `allFlagsState()` will still work with older versions. diff --git a/gradle.properties b/gradle.properties index 7a06d1b5c..37967c5fb 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,3 +1,3 @@ -version=4.3.0 +version=4.3.1 ossrhUsername= ossrhPassword=