diff --git a/src/main/java/com/mixpanel/mixpanelapi/featureflags/model/SelectedVariant.java b/src/main/java/com/mixpanel/mixpanelapi/featureflags/model/SelectedVariant.java index 4a41830..37da559 100644 --- a/src/main/java/com/mixpanel/mixpanelapi/featureflags/model/SelectedVariant.java +++ b/src/main/java/com/mixpanel/mixpanelapi/featureflags/model/SelectedVariant.java @@ -4,13 +4,14 @@ /** * Represents the result of a feature flag evaluation. - *

- * Contains the selected variant key and its value. Both may be null if the - * fallback was returned (e.g., flag not found, evaluation error). - *

- *

- * This class is immutable and thread-safe. - *

+ * + *

Contains the selected variant key and its value. Both may be null if the + * fallback was returned (e.g., flag not found, evaluation error). The + * {@link Source} on the variant explains where it came from: {@link Source.Local} + * / {@link Source.Remote} on a real evaluation, {@link Source.Fallback} (with a + * specific {@link Source.Fallback.Reason}) when the SDK fell back.

+ * + *

This class is immutable and thread-safe.

* * @param the type of the variant value */ @@ -20,81 +21,107 @@ public final class SelectedVariant { private final UUID experimentId; private final Boolean isExperimentActive; private final Boolean isQaTester; + private final Source source; /** - * Creates a SelectedVariant with only a value (key is null). - * This is typically used for fallback responses. + * Creates a SelectedVariant with only a value (key is null) and a default + * fallback source. Used by callers constructing a fallback to pass into + * {@code getVariant} — the SDK stamps a specific source/reason before + * returning. * * @param variantValue the fallback value */ public SelectedVariant(T variantValue) { - this(null, variantValue, null, null, null); + this(null, variantValue, null, null, null, Source.fallback(Source.Fallback.Reason.FLAG_NOT_FOUND)); } /** * Creates a new SelectedVariant with experimentation metadata. + * Defaults source to {@link Source#local()} — used by the local provider + * when constructing a matched variant. Use the overload below to pass an + * explicit source. * - * @param variantKey the key of the selected variant (may be null for fallback) - * @param variantValue the value of the selected variant (may be null for fallback) - * @param experimentId the experiment ID (may be null) - * @param isExperimentActive whether the experiment is active (may be null) - * @param isQaTester whether the user is a QA tester (may be null) + * @param variantKey the variant key (null if this is a fallback) + * @param variantValue the variant value + * @param experimentId the experiment ID, or null + * @param isExperimentActive whether the experiment is active, or null + * @param isQaTester whether the user is a QA tester, or null */ public SelectedVariant(String variantKey, T variantValue, UUID experimentId, Boolean isExperimentActive, Boolean isQaTester) { + this(variantKey, variantValue, experimentId, isExperimentActive, isQaTester, Source.local()); + } + + /** + * Creates a new SelectedVariant with experimentation metadata and an explicit source. + * + * @param variantKey the variant key (null if this is a fallback) + * @param variantValue the variant value + * @param experimentId the experiment ID, or null + * @param isExperimentActive whether the experiment is active, or null + * @param isQaTester whether the user is a QA tester, or null + * @param source where this variant came from; never null + */ + public SelectedVariant(String variantKey, T variantValue, UUID experimentId, Boolean isExperimentActive, Boolean isQaTester, Source source) { this.variantKey = variantKey; this.variantValue = variantValue; this.experimentId = experimentId; this.isExperimentActive = isExperimentActive; this.isQaTester = isQaTester; + this.source = source; + } + + /** @return where this variant came from; never null. */ + public Source getSource() { + return source; } /** - * @return the variant key, or null if this is a fallback + * Returns a copy of this variant tagged with the given source. + * Used by providers so they don't mutate the caller's fallback object + * when returning it on a no-match path. */ + public SelectedVariant withSource(Source source) { + return new SelectedVariant(variantKey, variantValue, experimentId, isExperimentActive, isQaTester, source); + } + + /** @return the variant key, or null if this is a fallback */ public String getVariantKey() { return variantKey; } - /** - * @return the variant value - */ + /** @return the variant value */ public T getVariantValue() { return variantValue; } - /** - * @return the experiment ID, or null if not set - */ + /** @return the experiment ID, or null if not set */ public UUID getExperimentId() { return experimentId; } - /** - * @return whether the experiment is active, or null if not set - */ + /** @return whether the experiment is active, or null if not set */ public Boolean getIsExperimentActive() { return isExperimentActive; } - /** - * @return whether the user is a QA tester, or null if not set - */ + /** @return whether the user is a QA tester, or null if not set */ public Boolean getIsQaTester() { return isQaTester; } /** - * @return true if this represents a successfully selected variant (not a fallback) + * @return true if this represents a successfully selected variant (not a fallback). + *

Determined by the {@link Source} rather than by {@code variantKey != null}: + * a fallback is whatever the SDK stamped as {@link Source.Fallback}, regardless + * of whether the caller's fallback object happened to carry a key.

*/ public boolean isSuccess() { - return variantKey != null; + return !(source instanceof Source.Fallback); } - /** - * @return true if this represents a fallback value - */ + /** @return true if this represents a fallback value */ public boolean isFallback() { - return variantKey == null; + return source instanceof Source.Fallback; } @Override @@ -105,6 +132,7 @@ public String toString() { ", experimentId=" + experimentId + ", isExperimentActive=" + isExperimentActive + ", isQaTester=" + isQaTester + + ", source=" + source + '}'; } @@ -119,6 +147,7 @@ public boolean equals(Object o) { if (variantValue != null ? !variantValue.equals(that.variantValue) : that.variantValue != null) return false; if (experimentId != null ? !experimentId.equals(that.experimentId) : that.experimentId != null) return false; if (isExperimentActive != null ? !isExperimentActive.equals(that.isExperimentActive) : that.isExperimentActive != null) return false; - return isQaTester != null ? isQaTester.equals(that.isQaTester) : that.isQaTester == null; + if (isQaTester != null ? !isQaTester.equals(that.isQaTester) : that.isQaTester != null) return false; + return source != null ? source.equals(that.source) : that.source == null; } } diff --git a/src/main/java/com/mixpanel/mixpanelapi/featureflags/model/Source.java b/src/main/java/com/mixpanel/mixpanelapi/featureflags/model/Source.java new file mode 100644 index 0000000..de7fde6 --- /dev/null +++ b/src/main/java/com/mixpanel/mixpanelapi/featureflags/model/Source.java @@ -0,0 +1,100 @@ +package com.mixpanel.mixpanelapi.featureflags.model; + +/** + * Where a {@link SelectedVariant} came from. + * + *

The flags providers tag every variant they return so callers — especially + * the OpenFeature wrapper — can distinguish a real evaluation from each of the + * distinct fallback paths. Discriminated union (abstract class + nested static + * finals) rather than a string so the per-case data (the {@link Fallback.Reason} + * tag) lives on the variant it describes; that way invalid states like + * "successful evaluation with a fallback reason" are unrepresentable.

+ * + *

Java 8 source level, so no {@code sealed} keyword — the package-private + * constructor is what makes this closed. Construct via {@link #local()}, + * {@link #remote()}, or {@link #fallback(Fallback.Reason)}.

+ */ +public abstract class Source { + Source() {} + + /** Singleton {@link Local} — every call returns the same instance. */ + public static Local local() { + return Local.INSTANCE; + } + + /** Singleton {@link Remote} — every call returns the same instance. */ + public static Remote remote() { + return Remote.INSTANCE; + } + + /** + * Returns a {@link Fallback} tagged with the given reason. + * + *

The SDK uses this to explain why a fallback was returned (flag missing, + * required context absent, no rollout matched, network error, not ready) so + * the OpenFeature wrapper can map to the correct user-facing error code + * instead of collapsing every fallback to FLAG_NOT_FOUND.

+ */ + public static Fallback fallback(Fallback.Reason reason) { + return new Fallback(reason); + } + + /** Variant produced by local rule evaluation against cached flag definitions. */ + public static final class Local extends Source { + // Held inside the subclass so the outer class's does not reference it, + // sidestepping the "subclass referenced from superclass initializer" deadlock pattern. + static final Local INSTANCE = new Local(); + + Local() {} + + @Override public String toString() { return "Local"; } + } + + /** Variant returned by a remote /flags evaluation call. */ + public static final class Remote extends Source { + static final Remote INSTANCE = new Remote(); + + Remote() {} + + @Override public String toString() { return "Remote"; } + } + + /** Developer-supplied fallback returned because the SDK had no value to serve. */ + public static final class Fallback extends Source { + /** + * Why the SDK returned the developer fallback. Matches the set of reasons + * used by the other Mixpanel SDKs (mixpanel-php in particular). + */ + public enum Reason { + /** Flag key is not in the local definitions or the remote response. */ + FLAG_NOT_FOUND, + /** A property the flag's rollout is keyed on was absent from the evaluation context. */ + MISSING_CONTEXT_KEY, + /** Flag exists, but no rollout in its ruleset matched the supplied context. */ + NO_ROLLOUT_MATCH, + /** Remote evaluation failed (network error, HTTP error, parse error). */ + BACKEND_ERROR, + } + + /** Reason the SDK returned this fallback. */ + public final Reason reason; + + Fallback(Reason reason) { + this.reason = reason; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (!(o instanceof Fallback)) return false; + return reason == ((Fallback) o).reason; + } + + @Override + public int hashCode() { + return reason.hashCode(); + } + + @Override public String toString() { return "Fallback(" + reason + ")"; } + } +} diff --git a/src/main/java/com/mixpanel/mixpanelapi/featureflags/provider/LocalFlagsProvider.java b/src/main/java/com/mixpanel/mixpanelapi/featureflags/provider/LocalFlagsProvider.java index 50357b0..c1b0483 100644 --- a/src/main/java/com/mixpanel/mixpanelapi/featureflags/provider/LocalFlagsProvider.java +++ b/src/main/java/com/mixpanel/mixpanelapi/featureflags/provider/LocalFlagsProvider.java @@ -338,7 +338,7 @@ public SelectedVariant getVariant(String flagKey, SelectedVariant fall if (flag == null) { logger.log(Level.WARNING, "Flag not found: " + flagKey); - return fallback; + return fallback.withSource(Source.fallback(Source.Fallback.Reason.FLAG_NOT_FOUND)); } // Extract context value @@ -346,7 +346,7 @@ public SelectedVariant getVariant(String flagKey, SelectedVariant fall Object contextValueObj = context.get(contextProperty); if (contextValueObj == null) { logger.log(Level.WARNING, "Variant assignment key property '" + contextProperty + "' not found for flag: " + flagKey); - return fallback; + return fallback.withSource(Source.fallback(Source.Fallback.Reason.MISSING_CONTEXT_KEY)); } String contextValue = contextValueObj.toString(); @@ -403,11 +403,11 @@ public SelectedVariant getVariant(String flagKey, SelectedVariant fall } // No rollout matched - return fallback; + return fallback.withSource(Source.fallback(Source.Fallback.Reason.NO_ROLLOUT_MATCH)); } catch (Exception e) { logger.log(Level.WARNING, "Error evaluating flag: " + flagKey, e); - return fallback; + return fallback.withSource(Source.fallback(Source.Fallback.Reason.BACKEND_ERROR)); } } @@ -418,12 +418,13 @@ public SelectedVariant getVariant(String flagKey, SelectedVariant fall private SelectedVariant buildResult(Variant variant, ExperimentationFlag flag, boolean isQaTester, String flagKey, Map context, long startTime, boolean reportExposure) { - SelectedVariant result = new SelectedVariant<>( + SelectedVariant result = new SelectedVariant( variant.getKey(), (T) variant.getValue(), flag.getExperimentId(), flag.getIsExperimentActive(), - isQaTester + isQaTester, + Source.local() ); if (reportExposure) { trackLocalExposure(context, flagKey, variant.getKey(), System.currentTimeMillis() - startTime, diff --git a/src/main/java/com/mixpanel/mixpanelapi/featureflags/provider/RemoteFlagsProvider.java b/src/main/java/com/mixpanel/mixpanelapi/featureflags/provider/RemoteFlagsProvider.java index 4d8fd90..b4dde69 100644 --- a/src/main/java/com/mixpanel/mixpanelapi/featureflags/provider/RemoteFlagsProvider.java +++ b/src/main/java/com/mixpanel/mixpanelapi/featureflags/provider/RemoteFlagsProvider.java @@ -3,6 +3,7 @@ import com.mixpanel.mixpanelapi.featureflags.EventSender; import com.mixpanel.mixpanelapi.featureflags.config.RemoteFlagsConfig; import com.mixpanel.mixpanelapi.featureflags.model.SelectedVariant; +import com.mixpanel.mixpanelapi.featureflags.model.Source; import org.json.JSONObject; @@ -63,9 +64,13 @@ public SelectedVariant getVariant(String flagKey, SelectedVariant fall JSONObject root = new JSONObject(response); JSONObject flags = root.optJSONObject("flags"); + // The /flags endpoint only returns variants the user is enrolled in, + // so a missing key could mean the flag doesn't exist OR the user + // isn't in any rollout. The remote SDK can't tell them apart without + // server-side help — surface as FLAG_NOT_FOUND for now. if (flags == null || !flags.has(flagKey)) { logger.log(Level.WARNING, "Flag not found in response: " + flagKey); - return fallback; + return fallback.withSource(Source.fallback(Source.Fallback.Reason.FLAG_NOT_FOUND)); } JSONObject flagData = flags.getJSONObject(flagKey); @@ -73,7 +78,7 @@ public SelectedVariant getVariant(String flagKey, SelectedVariant fall Object variantValue = flagData.opt("variant_value"); if (variantKey == null) { - return fallback; + return fallback.withSource(Source.fallback(Source.Fallback.Reason.FLAG_NOT_FOUND)); } // Parse experiment metadata @@ -104,12 +109,12 @@ public SelectedVariant getVariant(String flagKey, SelectedVariant fall } @SuppressWarnings("unchecked") - SelectedVariant result = new SelectedVariant<>(variantKey, (T) variantValue, experimentId, isExperimentActive, isQaTester); + SelectedVariant result = new SelectedVariant(variantKey, (T) variantValue, experimentId, isExperimentActive, isQaTester, Source.remote()); return result; } catch (Exception e) { logger.log(Level.WARNING, "Error evaluating flag remotely: " + flagKey, e); - return fallback; + return fallback.withSource(Source.fallback(Source.Fallback.Reason.BACKEND_ERROR)); } }