From 3d95d43fd7769d998038ea5079d0b637c57c96ac Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Thu, 28 Jun 2018 15:01:44 -0700 Subject: [PATCH 01/50] 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/50] 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/50] 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/50] 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/50] 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/50] 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/50] 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/50] 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/50] 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/50] 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/50] 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/50] 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/50] 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/50] 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/50] 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/50] 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/50] 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/50] 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/50] 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/50] 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/50] 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/50] 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/50] 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/50] 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/50] 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/50] 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/50] 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/50] 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/50] 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/50] 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/50] 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/50] 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/50] 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/50] 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/50] 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/50] 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/50] 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/50] 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/50] 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/50] 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/50] 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/50] 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 00ad42866516759d2411dac7be9412927e3c8b9e Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Tue, 11 Sep 2018 11:09:18 -0700 Subject: [PATCH 43/50] send correct event schema version --- .../launchdarkly/client/DefaultEventProcessor.java | 2 +- .../client/DefaultEventProcessorTest.java | 13 +++++++++++++ 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/src/main/java/com/launchdarkly/client/DefaultEventProcessor.java b/src/main/java/com/launchdarkly/client/DefaultEventProcessor.java index eed973fa2..d242ed623 100644 --- a/src/main/java/com/launchdarkly/client/DefaultEventProcessor.java +++ b/src/main/java/com/launchdarkly/client/DefaultEventProcessor.java @@ -37,7 +37,7 @@ final class DefaultEventProcessor implements EventProcessor { private static final Logger logger = LoggerFactory.getLogger(DefaultEventProcessor.class); private static final int CHANNEL_BLOCK_MILLIS = 1000; private static final String EVENT_SCHEMA_HEADER = "X-LaunchDarkly-Event-Schema"; - private static final String EVENT_SCHEMA_VERSION = "2"; + private static final String EVENT_SCHEMA_VERSION = "3"; private final BlockingQueue inputChannel; private final ScheduledExecutorService scheduler; diff --git a/src/test/java/com/launchdarkly/client/DefaultEventProcessorTest.java b/src/test/java/com/launchdarkly/client/DefaultEventProcessorTest.java index c8184c38c..03cba9c09 100644 --- a/src/test/java/com/launchdarkly/client/DefaultEventProcessorTest.java +++ b/src/test/java/com/launchdarkly/client/DefaultEventProcessorTest.java @@ -410,6 +410,19 @@ public void sdkKeyIsSent() throws Exception { assertThat(req.getHeader("Authorization"), equalTo(SDK_KEY)); } + @Test + public void eventSchemaIsSent() throws Exception { + ep = new DefaultEventProcessor(SDK_KEY, configBuilder.build()); + Event e = EventFactory.DEFAULT.newIdentifyEvent(user); + ep.sendEvent(e); + + server.enqueue(new MockResponse()); + ep.close(); + RecordedRequest req = server.takeRequest(); + + assertThat(req.getHeader("X-LaunchDarkly-Event-Schema"), equalTo("3")); + } + @Test public void http400ErrorIsRecoverable() throws Exception { testRecoverableHttpError(400); From 7c24d76bb1edbb0ac9af7a9dc340a2f3ab863bdf Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Wed, 26 Sep 2018 11:18:19 -0700 Subject: [PATCH 44/50] new option for sending less bootstrap data based on event tracking status --- .../client/FeatureFlagsState.java | 23 +++++--- .../launchdarkly/client/FlagsStateOption.java | 8 +++ .../client/FeatureFlagsStateTest.java | 2 +- .../client/LDClientEvaluationTest.java | 52 ++++++++++++++++++- 4 files changed, 74 insertions(+), 11 deletions(-) diff --git a/src/main/java/com/launchdarkly/client/FeatureFlagsState.java b/src/main/java/com/launchdarkly/client/FeatureFlagsState.java index bd962538b..559c219ff 100644 --- a/src/main/java/com/launchdarkly/client/FeatureFlagsState.java +++ b/src/main/java/com/launchdarkly/client/FeatureFlagsState.java @@ -33,16 +33,16 @@ public class FeatureFlagsState { static class FlagMetadata { final Integer variation; final EvaluationReason reason; - final int version; - final boolean trackEvents; + final Integer version; + final Boolean trackEvents; final Long debugEventsUntilDate; - FlagMetadata(Integer variation, EvaluationReason reason, int version, boolean trackEvents, + FlagMetadata(Integer variation, EvaluationReason reason, Integer version, boolean trackEvents, Long debugEventsUntilDate) { this.variation = variation; this.reason = reason; this.version = version; - this.trackEvents = trackEvents; + this.trackEvents = trackEvents ? Boolean.TRUE : null; this.debugEventsUntilDate = debugEventsUntilDate; } @@ -51,8 +51,8 @@ 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(version, o.version) && + Objects.equal(trackEvents, o.trackEvents) && Objects.equal(debugEventsUntilDate, o.debugEventsUntilDate); } return false; @@ -131,10 +131,12 @@ static class Builder { private Map flagValues = new HashMap<>(); private Map flagMetadata = new HashMap<>(); private final boolean saveReasons; + private final boolean detailsOnlyForTrackedFlags; private boolean valid = true; Builder(FlagsStateOption... options) { saveReasons = FlagsStateOption.hasOption(options, FlagsStateOption.WITH_REASONS); + detailsOnlyForTrackedFlags = FlagsStateOption.hasOption(options, FlagsStateOption.DETAILS_ONLY_FOR_TRACKED_FLAGS); } Builder valid(boolean valid) { @@ -144,9 +146,14 @@ Builder valid(boolean valid) { Builder addFlag(FeatureFlag flag, EvaluationDetail eval) { flagValues.put(flag.getKey(), eval.getValue()); + final boolean flagIsTracked = flag.isTrackEvents() || + (flag.getDebugEventsUntilDate() != null && flag.getDebugEventsUntilDate() > System.currentTimeMillis()); + final boolean wantDetails = !detailsOnlyForTrackedFlags || flagIsTracked; FlagMetadata data = new FlagMetadata(eval.getVariationIndex(), - saveReasons ? eval.getReason() : null, - flag.getVersion(), flag.isTrackEvents(), flag.getDebugEventsUntilDate()); + (saveReasons && wantDetails) ? eval.getReason() : null, + wantDetails ? flag.getVersion() : null, + flag.isTrackEvents(), + flag.getDebugEventsUntilDate()); flagMetadata.put(flag.getKey(), data); return this; } diff --git a/src/main/java/com/launchdarkly/client/FlagsStateOption.java b/src/main/java/com/launchdarkly/client/FlagsStateOption.java index f519f1bc8..71cb14829 100644 --- a/src/main/java/com/launchdarkly/client/FlagsStateOption.java +++ b/src/main/java/com/launchdarkly/client/FlagsStateOption.java @@ -27,6 +27,14 @@ public String toString() { */ public static final FlagsStateOption WITH_REASONS = new FlagsStateOption("WITH_REASONS"); + /** + * Specifies that any flag metadata that is normally only used for event generation - such as flag versions and + * evaluation reasons - should be omitted for any flag that does not have event tracking or debugging turned on. + * This reduces the size of the JSON data if you are passing the flag state to the front end. + * @since 4.4.0 + */ + public static final FlagsStateOption DETAILS_ONLY_FOR_TRACKED_FLAGS = new FlagsStateOption("DETAILS_ONLY_FOR_TRACKED_FLAGS"); + static boolean hasOption(FlagsStateOption[] options, FlagsStateOption option) { for (FlagsStateOption o: options) { if (o == option) { diff --git a/src/test/java/com/launchdarkly/client/FeatureFlagsStateTest.java b/src/test/java/com/launchdarkly/client/FeatureFlagsStateTest.java index 86944409c..bff704643 100644 --- a/src/test/java/com/launchdarkly/client/FeatureFlagsStateTest.java +++ b/src/test/java/com/launchdarkly/client/FeatureFlagsStateTest.java @@ -89,7 +89,7 @@ public void canConvertToJson() { String json = "{\"key1\":\"value1\",\"key2\":\"value2\"," + "\"$flagsState\":{" + "\"key1\":{" + - "\"variation\":0,\"version\":100,\"reason\":{\"kind\":\"OFF\"},\"trackEvents\":false" + + "\"variation\":0,\"version\":100,\"reason\":{\"kind\":\"OFF\"}" + // note, "trackEvents: false" is omitted "},\"key2\":{" + "\"variation\":1,\"version\":200,\"reason\":{\"kind\":\"FALLTHROUGH\"},\"trackEvents\":true,\"debugEventsUntilDate\":1000" + "}" + diff --git a/src/test/java/com/launchdarkly/client/LDClientEvaluationTest.java b/src/test/java/com/launchdarkly/client/LDClientEvaluationTest.java index e07777647..8a5ef6586 100644 --- a/src/test/java/com/launchdarkly/client/LDClientEvaluationTest.java +++ b/src/test/java/com/launchdarkly/client/LDClientEvaluationTest.java @@ -293,7 +293,7 @@ public void allFlagsStateReturnsState() throws Exception { String json = "{\"key1\":\"value1\",\"key2\":\"value2\"," + "\"$flagsState\":{" + "\"key1\":{" + - "\"variation\":0,\"version\":100,\"trackEvents\":false" + + "\"variation\":0,\"version\":100" + "},\"key2\":{" + "\"variation\":1,\"version\":200,\"trackEvents\":true,\"debugEventsUntilDate\":1000" + "}" + @@ -350,7 +350,7 @@ public void allFlagsStateReturnsStateWithReasons() { String json = "{\"key1\":\"value1\",\"key2\":\"value2\"," + "\"$flagsState\":{" + "\"key1\":{" + - "\"variation\":0,\"version\":100,\"reason\":{\"kind\":\"OFF\"},\"trackEvents\":false" + + "\"variation\":0,\"version\":100,\"reason\":{\"kind\":\"OFF\"}" + "},\"key2\":{" + "\"variation\":1,\"version\":200,\"reason\":{\"kind\":\"FALLTHROUGH\"},\"trackEvents\":true,\"debugEventsUntilDate\":1000" + "}" + @@ -361,6 +361,54 @@ public void allFlagsStateReturnsStateWithReasons() { assertEquals(expected, gson.toJsonTree(state)); } + @Test + public void allFlagsStateCanOmitDetailsForUntrackedFlags() { + long futureTime = System.currentTimeMillis() + 1000000; + 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) + .on(true) + .fallthrough(fallthroughVariation(1)) + .variations(js("off"), js("value2")) + .build(); + FeatureFlag flag3 = new FeatureFlagBuilder("key3") + .version(300) + .trackEvents(false) + .debugEventsUntilDate(futureTime) // event tracking is turned on temporarily even though trackEvents is false + .on(false) + .offVariation(0) + .variations(js("value3")) + .build(); + featureStore.upsert(FEATURES, flag1); + featureStore.upsert(FEATURES, flag2); + featureStore.upsert(FEATURES, flag3); + + FeatureFlagsState state = client.allFlagsState(user, FlagsStateOption.WITH_REASONS, FlagsStateOption.DETAILS_ONLY_FOR_TRACKED_FLAGS); + assertTrue(state.isValid()); + + String json = "{\"key1\":\"value1\",\"key2\":\"value2\",\"key3\":\"value3\"," + + "\"$flagsState\":{" + + "\"key1\":{" + + "\"variation\":0" + // note, version and reason are omitted, and so is trackEvents: false + "},\"key2\":{" + + "\"variation\":1,\"version\":200,\"reason\":{\"kind\":\"FALLTHROUGH\"},\"trackEvents\":true" + + "},\"key3\":{" + + "\"variation\":0,\"version\":300,\"reason\":{\"kind\":\"OFF\"},\"debugEventsUntilDate\":" + futureTime + + "}" + + "}," + + "\"$valid\":true" + + "}"; + JsonElement expected = gson.fromJson(json, JsonElement.class); + assertEquals(expected, gson.toJsonTree(state)); + } + @Test public void allFlagsStateReturnsEmptyStateForNullUser() throws Exception { featureStore.upsert(FEATURES, flagWithValue("key", js("value"))); From dcdad5e00df8f16cb7ead91fd6627fad74134082 Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Sat, 13 Oct 2018 16:41:49 -0700 Subject: [PATCH 45/50] change Gradle build to fix pom --- build.gradle | 116 +++++++++++++++++++++++++----------------------- settings.gradle | 1 + 2 files changed, 61 insertions(+), 56 deletions(-) create mode 100644 settings.gradle diff --git a/build.gradle b/build.gradle index 192251f42..ba7eaec9c 100644 --- a/build.gradle +++ b/build.gradle @@ -1,5 +1,5 @@ apply plugin: 'java' -apply plugin: 'maven' +apply plugin: 'maven-publish' apply plugin: 'org.ajoberstar.github-pages' apply plugin: 'signing' apply plugin: 'idea' @@ -27,7 +27,7 @@ allprojects { ext.libraries = [:] -libraries.shaded = [ +libraries.internal = [ "commons-codec:commons-codec:1.10", "com.google.guava:guava:19.0", "joda-time:joda-time:2.9.3", @@ -35,27 +35,25 @@ libraries.shaded = [ "redis.clients:jedis:2.9.0" ] -libraries.unshaded = [ +libraries.external = [ "com.google.code.gson:gson:2.7", "org.slf4j:slf4j-api:1.7.21" ] -libraries.testCompile = [ +libraries.test = [ "com.squareup.okhttp3:mockwebserver:3.10.0", "org.hamcrest:hamcrest-all:1.3", "org.easymock:easymock:3.4", - "junit:junit:4.12" -] - -libraries.testRuntime = [ + "junit:junit:4.12", "ch.qos.logback:logback-classic:1.1.7" ] dependencies { - compile libraries.shaded, libraries.unshaded - testCompile libraries.testCompile - testRuntime libraries.testRuntime - shadow libraries.unshaded + implementation libraries.internal + compileClasspath libraries.external + runtime libraries.internal, libraries.external + testImplementation libraries.test, libraries.internal, libraries.external + shadow libraries.external } jar { @@ -96,11 +94,6 @@ task javadocJar(type: Jar, dependsOn: javadoc) { from javadoc.destinationDir } -// add javadoc/source jar tasks as artifacts -artifacts { - archives sourcesJar, javadocJar, shadowJar -} - githubPages { repoUri = 'https://github.com/launchdarkly/java-client.git' pages { @@ -185,7 +178,7 @@ task shadowJarAll(type: com.github.jengelman.gradle.plugins.shadow.tasks.ShadowJ } artifacts { - archives shadowJarAll + archives sourcesJar, javadocJar, shadowJar, shadowJarAll } test { @@ -200,7 +193,6 @@ signing { sign configurations.archives } - idea { module { downloadJavadoc = true @@ -213,47 +205,59 @@ nexusStaging { packageGroup = "com.launchdarkly" } -uploadArchives { - repositories { - mavenDeployer { - beforeDeployment { MavenDeployment deployment -> signing.signPom(deployment) } +def pomConfig = { + name 'LaunchDarkly SDK for Java' + packaging 'jar' + url 'https://github.com/launchdarkly/java-client' - repository(url: "https://oss.sonatype.org/service/local/staging/deploy/maven2/") { - authentication(userName: ossrhUsername, password: ossrhPassword) - } + licenses { + license { + name 'The Apache License, Version 2.0' + url 'http://www.apache.org/licenses/LICENSE-2.0.txt' + } + } - snapshotRepository(url: "https://oss.sonatype.org/content/repositories/snapshots/") { - authentication(userName: ossrhUsername, password: ossrhPassword) - } + developers { + developer { + id 'jkodumal' + name 'John Kodumal' + email 'john@catamorphic.com' + } + } + + scm { + connection 'scm:git:git://github.com/launchdarkly/java-client.git' + developerConnection 'scm:git:ssh:git@github.com:launchdarkly/java-client.git' + url 'https://github.com/launchdarkly/java-client' + } +} - pom.project { - name 'LaunchDarkly SDK for Java' - packaging 'jar' - description 'Official LaunchDarkly SDK for Java' - url 'https://github.com/launchdarkly/java-client' - - licenses { - license { - name 'The Apache License, Version 2.0' - url 'http://www.apache.org/licenses/LICENSE-2.0.txt' - } - } - - developers { - developer { - id 'jkodumal' - name 'John Kodumal' - email 'john@catamorphic.com' - } - } - - scm { - connection 'scm:git:git://github.com/launchdarkly/java-client.git' - developerConnection 'scm:git:ssh:git@github.com:launchdarkly/java-client.git' - url 'https://github.com/launchdarkly/java-client' - } +publishing { + publications { + shadow(MavenPublication) { publication -> + project.shadow.component(publication) + + artifactId = 'launchdarkly-client' + artifact sourcesJar + artifact javadocJar + + pom.withXml { + def root = asNode() + root.appendNode('description', 'Official LaunchDarkly SDK for Java') + asNode().children().last() + pomConfig + } + } + } + repositories { + mavenLocal() + maven { + def releasesRepoUrl = "https://oss.sonatype.org/service/local/staging/deploy/maven2/" + def snapshotsRepoUrl = "https://oss.sonatype.org/content/repositories/snapshots/" + url = version.endsWith('SNAPSHOT') ? snapshotsRepoUrl : releasesRepoUrl + credentials { + username ossrhUsername + password ossrhPassword } } } } - diff --git a/settings.gradle b/settings.gradle new file mode 100644 index 000000000..80a446a15 --- /dev/null +++ b/settings.gradle @@ -0,0 +1 @@ +rootProject.name = 'launchdarkly-client' From 5bba52a703a766eb10dd11aef072bedeb326ed0c Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Sat, 13 Oct 2018 16:52:06 -0700 Subject: [PATCH 46/50] misc build fixes --- build.gradle | 3 ++- scripts/release.sh | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/build.gradle b/build.gradle index ba7eaec9c..c03dcd125 100644 --- a/build.gradle +++ b/build.gradle @@ -178,7 +178,7 @@ task shadowJarAll(type: com.github.jengelman.gradle.plugins.shadow.tasks.ShadowJ } artifacts { - archives sourcesJar, javadocJar, shadowJar, shadowJarAll + archives jar, sourcesJar, javadocJar, shadowJar, shadowJarAll } test { @@ -240,6 +240,7 @@ publishing { artifactId = 'launchdarkly-client' artifact sourcesJar artifact javadocJar + artifact shadowJarAll pom.withXml { def root = asNode() diff --git a/scripts/release.sh b/scripts/release.sh index a637262ca..154e8a0ab 100755 --- a/scripts/release.sh +++ b/scripts/release.sh @@ -21,6 +21,6 @@ rm -f gradle.properties.bak sed -i.bak "s/.*<\/version>/${VERSION}<\/version>/" README.md rm -f README.md.bak -./gradlew clean install sourcesJar javadocJar uploadArchives closeAndReleaseRepository +./gradlew clean install sourcesJar javadocJar publish closeAndReleaseRepository ./gradlew publishGhPages echo "Finished java-client release." From 8af7519df898eb26e96c1d95e93e9fa39fbee28b Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Sat, 13 Oct 2018 17:02:36 -0700 Subject: [PATCH 47/50] misc fixes --- build.gradle | 3 +++ 1 file changed, 3 insertions(+) diff --git a/build.gradle b/build.gradle index c03dcd125..ba9dd82fb 100644 --- a/build.gradle +++ b/build.gradle @@ -142,6 +142,8 @@ shadowJar { } } +// This builds the "-all"/"fat" jar which also includes the external dependencies - SLF4J, +// Gson, and javax.annotations - in unshaded form, as well as all the internal shaded classes. task shadowJarAll(type: com.github.jengelman.gradle.plugins.shadow.tasks.ShadowJar) { baseName = 'launchdarkly-client' classifier = 'all' @@ -238,6 +240,7 @@ publishing { project.shadow.component(publication) artifactId = 'launchdarkly-client' + artifact jar artifact sourcesJar artifact javadocJar artifact shadowJarAll From 2faeeb33504806e6255c2f51ee4c20f10701c6bd Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Mon, 15 Oct 2018 11:54:46 -0700 Subject: [PATCH 48/50] fix John's email --- build.gradle | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/build.gradle b/build.gradle index ba9dd82fb..7d08113ce 100644 --- a/build.gradle +++ b/build.gradle @@ -223,7 +223,7 @@ def pomConfig = { developer { id 'jkodumal' name 'John Kodumal' - email 'john@catamorphic.com' + email 'john@launchdarkly.com' } } From e26180112705726b5c0f7f562b9aa2bc9c1a000b Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Wed, 17 Oct 2018 13:49:49 -0700 Subject: [PATCH 49/50] fix release script --- scripts/release.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/release.sh b/scripts/release.sh index 154e8a0ab..e9afb20e7 100755 --- a/scripts/release.sh +++ b/scripts/release.sh @@ -21,6 +21,6 @@ rm -f gradle.properties.bak sed -i.bak "s/.*<\/version>/${VERSION}<\/version>/" README.md rm -f README.md.bak -./gradlew clean install sourcesJar javadocJar publish closeAndReleaseRepository +./gradlew clean publish closeAndReleaseRepository ./gradlew publishGhPages echo "Finished java-client release." From d79ef2f0830d8bd42233667dbe72b8a865b9c0a2 Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Thu, 18 Oct 2018 11:50:50 -0700 Subject: [PATCH 50/50] fix build so jar signatures will be published --- build.gradle | 10 +++++----- gradle/wrapper/gradle-wrapper.properties | 2 +- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/build.gradle b/build.gradle index 7d08113ce..02d336d7f 100644 --- a/build.gradle +++ b/build.gradle @@ -67,7 +67,7 @@ jar { } task wrapper(type: Wrapper) { - gradleVersion = '4.2.1' + gradleVersion = '4.10.2' } buildscript { @@ -191,10 +191,6 @@ test { } } -signing { - sign configurations.archives -} - idea { module { downloadJavadoc = true @@ -265,3 +261,7 @@ publishing { } } } + +signing { + sign publishing.publications.shadow +} diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index 74bb77845..fb7ef980f 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -2,4 +2,4 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-4.2.1-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-4.10.2-bin.zip