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));
}
}