Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,14 @@

/**
* Represents the result of a feature flag evaluation.
* <p>
* Contains the selected variant key and its value. Both may be null if the
* fallback was returned (e.g., flag not found, evaluation error).
* </p>
* <p>
* This class is immutable and thread-safe.
* </p>
*
* <p>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.</p>
*
* <p>This class is immutable and thread-safe.</p>
*
* @param <T> the type of the variant value
*/
Expand All @@ -20,81 +21,107 @@ public final class SelectedVariant<T> {
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());
}
Comment thread
tylerjroach marked this conversation as resolved.

/**
* 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<T> withSource(Source source) {
return new SelectedVariant<T>(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() {
Comment thread
tylerjroach marked this conversation as resolved.
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).
* <p>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.</p>
*/
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
Expand All @@ -105,6 +132,7 @@ public String toString() {
", experimentId=" + experimentId +
", isExperimentActive=" + isExperimentActive +
", isQaTester=" + isQaTester +
", source=" + source +
'}';
}

Expand All @@ -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;
}
}
100 changes: 100 additions & 0 deletions src/main/java/com/mixpanel/mixpanelapi/featureflags/model/Source.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
package com.mixpanel.mixpanelapi.featureflags.model;

/**
* Where a {@link SelectedVariant} came from.
*
* <p>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.</p>
*
* <p>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)}.</p>
*/
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.
*
* <p>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.</p>
*/
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 <clinit> 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"; }
}
Comment thread
tylerjroach marked this conversation as resolved.

/** 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,
}
Comment thread
tylerjroach marked this conversation as resolved.

/** 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 + ")"; }
}
}
Comment thread
tylerjroach marked this conversation as resolved.
Original file line number Diff line number Diff line change
Expand Up @@ -338,15 +338,15 @@ public <T> SelectedVariant<T> getVariant(String flagKey, SelectedVariant<T> 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
String contextProperty = flag.getContext();
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();

Expand Down Expand Up @@ -403,11 +403,11 @@ public <T> SelectedVariant<T> getVariant(String flagKey, SelectedVariant<T> 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));
}
}

Expand All @@ -418,12 +418,13 @@ public <T> SelectedVariant<T> getVariant(String flagKey, SelectedVariant<T> fall
private <T> SelectedVariant<T> buildResult(Variant variant, ExperimentationFlag flag, boolean isQaTester,
String flagKey, Map<String, Object> context,
long startTime, boolean reportExposure) {
SelectedVariant<T> result = new SelectedVariant<>(
SelectedVariant<T> result = new SelectedVariant<T>(
variant.getKey(),
(T) variant.getValue(),
flag.getExperimentId(),
flag.getIsExperimentActive(),
isQaTester
isQaTester,
Source.local()
);
if (reportExposure) {
trackLocalExposure(context, flagKey, variant.getKey(), System.currentTimeMillis() - startTime,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -63,17 +64,21 @@ public <T> SelectedVariant<T> getVariant(String flagKey, SelectedVariant<T> 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);
String variantKey = flagData.optString("variant_key", null);
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
Expand Down Expand Up @@ -104,12 +109,12 @@ public <T> SelectedVariant<T> getVariant(String flagKey, SelectedVariant<T> fall
}

@SuppressWarnings("unchecked")
SelectedVariant<T> result = new SelectedVariant<>(variantKey, (T) variantValue, experimentId, isExperimentActive, isQaTester);
SelectedVariant<T> result = new SelectedVariant<T>(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));
}
}

Expand Down
Loading