Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
1bee4e4
add runtime eval rule; deprecate legacy one
joshua-koehler Nov 26, 2025
5a322de
succint test harness
joshua-koehler Dec 1, 2025
0361a6e
test should fail, but doesn't
joshua-koehler Dec 1, 2025
f5b2303
runtime rules engine basic exact match ❌
joshua-koehler Dec 1, 2025
1cb4b98
add plumbing of method that doesn't work
joshua-koehler Dec 1, 2025
6f981b7
try json logic - doesn't work yet
joshua-koehler Dec 1, 2025
4bda0c3
simple exact match rule ✅
joshua-koehler Dec 1, 2025
d89a959
don't convert in a getter
joshua-koehler Dec 1, 2025
a0aebb3
case insensitive params ❌
joshua-koehler Dec 1, 2025
9c3a9e6
wrap third-party lib, use util classes
joshua-koehler Dec 1, 2025
db94845
case-insensitive params ✅
joshua-koehler Dec 1, 2025
bea8b88
case insensitive rule ❌
joshua-koehler Dec 1, 2025
f175961
complex rule case insensitive ❌
joshua-koehler Dec 1, 2025
507862a
simple case insensitive rule ✅
joshua-koehler Dec 1, 2025
a8ba289
evaluate complex rule case insensitive ✅
joshua-koehler Dec 1, 2025
337e1b7
Add more tests for parity with other sdks ✅
joshua-koehler Dec 1, 2025
5e585ac
log to stdout when running tests 💡
joshua-koehler Dec 1, 2025
0bf760c
correct logging; remove TODO
joshua-koehler Dec 1, 2025
d74ddee
roll our own Map.of() for Java 8 support
joshua-koehler Dec 1, 2025
ebed7ff
Update src/main/java/com/mixpanel/mixpanelapi/featureflags/util/JsonL…
joshua-koehler Dec 1, 2025
eb9b950
Update src/main/java/com/mixpanel/mixpanelapi/featureflags/util/JsonC…
joshua-koehler Dec 1, 2025
7157550
Update src/main/java/com/mixpanel/mixpanelapi/featureflags/provider/L…
joshua-koehler Dec 1, 2025
ce3117e
Update src/main/java/com/mixpanel/mixpanelapi/featureflags/model/Roll…
joshua-koehler Dec 2, 2025
1e35fc7
docs; handle array inputs; memory management;
joshua-koehler Dec 2, 2025
12334ff
Merge branch 'runtime-rules-engine' of github.com:joshua-koehler/mixp…
joshua-koehler Dec 2, 2025
a4fff2b
final var in supplier
joshua-koehler Dec 2, 2025
cbbe908
add test util (forgot the git add file)
joshua-koehler Dec 2, 2025
a817ed8
add case-insensitive util tests
joshua-koehler Dec 2, 2025
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
18 changes: 18 additions & 0 deletions pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,18 @@
</execution>
</executions>
</plugin>

<!-- Surefire plugin for test configuration -->
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
<version>3.2.5</version>
<configuration>
<systemPropertyVariables>
<java.util.logging.config.file>src/test/resources/logging.properties</java.util.logging.config.file>
</systemPropertyVariables>
</configuration>
</plugin>
</plugins>
</build>

Expand All @@ -139,6 +151,12 @@
<version>20231013</version>
</dependency>

<dependency>
<groupId>io.github.jamsesso</groupId>
<artifactId>json-logic-java</artifactId>
<version>1.1.0</version>
</dependency>

<!-- Jackson for high-performance JSON serialization (optional) -->
<!-- Users can include this dependency for improved performance with large batches -->
<dependency>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@
import java.util.Collections;
import java.util.Map;

import org.json.JSONObject;

/**
* Represents a rollout rule within a feature flag experiment.
* <p>
Expand All @@ -15,23 +17,46 @@
*/
public final class Rollout {
private final float rolloutPercentage;
private final Map<String, Object> runtimeEvaluationDefinition;
private final JSONObject runtimeEvaluationRule;
private final Map<String, Object> legacyRuntimeEvaluationDefinition;
private final VariantOverride variantOverride;
private final Map<String, Float> variantSplits;

/**
* Creates a new Rollout with all parameters.
*
* @param rolloutPercentage the percentage of users to include (0.0-1.0)
* @param runtimeEvaluationDefinition optional map of property name to expected value for targeting
* @param runtimeEvaluationRule optional JSONObject containing jsonLogic rule for targeting
* @param legacyRuntimeEvaluationDefinition optional map of property name to expected value for targeting
* @param variantOverride optional variant override to force selection
* @param variantSplits optional map of variant key to split percentage at assignment group level
*/
public Rollout(float rolloutPercentage, JSONObject runtimeEvaluationRule, Map<String, Object> legacyRuntimeEvaluationDefinition, VariantOverride variantOverride, Map<String, Float> variantSplits) {
this.rolloutPercentage = rolloutPercentage;
this.legacyRuntimeEvaluationDefinition = legacyRuntimeEvaluationDefinition != null
? Collections.unmodifiableMap(legacyRuntimeEvaluationDefinition)
: null;
this.runtimeEvaluationRule = runtimeEvaluationRule;
this.variantOverride = variantOverride;
this.variantSplits = variantSplits != null
? Collections.unmodifiableMap(variantSplits)
: null;
}

/**
* Creates a new Rollout with all legacy parameters.
*
* @param rolloutPercentage the percentage of users to include (0.0-1.0)
* @param legacyRuntimeEvaluationDefinition optional map of property name to expected value for targeting
* @param variantOverride optional variant override to force selection
* @param variantSplits optional map of variant key to split percentage at assignment group level
*/
public Rollout(float rolloutPercentage, Map<String, Object> runtimeEvaluationDefinition, VariantOverride variantOverride, Map<String, Float> variantSplits) {
public Rollout(float rolloutPercentage, Map<String, Object> legacyRuntimeEvaluationDefinition, VariantOverride variantOverride, Map<String, Float> variantSplits) {
this.rolloutPercentage = rolloutPercentage;
this.runtimeEvaluationDefinition = runtimeEvaluationDefinition != null
? Collections.unmodifiableMap(runtimeEvaluationDefinition)
this.legacyRuntimeEvaluationDefinition = legacyRuntimeEvaluationDefinition != null
? Collections.unmodifiableMap(legacyRuntimeEvaluationDefinition)
: null;
this.runtimeEvaluationRule = null;
this.variantOverride = variantOverride;
this.variantSplits = variantSplits != null
? Collections.unmodifiableMap(variantSplits)
Expand All @@ -57,8 +82,8 @@ public float getRolloutPercentage() {
/**
* @return optional map of property name to expected value for runtime evaluation, or null if not set
*/
public Map<String, Object> getRuntimeEvaluationDefinition() {
return runtimeEvaluationDefinition;
public Map<String, Object> getLegacyRuntimeEvaluationDefinition() {
return legacyRuntimeEvaluationDefinition;
}

/**
Expand All @@ -75,11 +100,18 @@ public Map<String, Float> getVariantSplits() {
return variantSplits;
}

/**
* @return true if this rollout has runtime evaluation criteria
*/
public boolean hasLegacyRuntimeEvaluation() {
return legacyRuntimeEvaluationDefinition != null && !legacyRuntimeEvaluationDefinition.isEmpty();
}

/**
* @return true if this rollout has runtime evaluation criteria
*/
public boolean hasRuntimeEvaluation() {
return runtimeEvaluationDefinition != null && !runtimeEvaluationDefinition.isEmpty();
return runtimeEvaluationRule != null && runtimeEvaluationRule.length() > 0;
}

/**
Expand All @@ -100,9 +132,17 @@ public boolean hasVariantSplits() {
public String toString() {
return "Rollout{" +
"rolloutPercentage=" + rolloutPercentage +
", runtimeEvaluationDefinition=" + runtimeEvaluationDefinition +
", legacyRuntimeEvaluationDefinition=" + legacyRuntimeEvaluationDefinition +
", runtimeEvaluationRule=" + runtimeEvaluationRule +
", variantOverride='" + variantOverride + '\'' +
", variantSplits=" + variantSplits +
'}';
}

/**
* @return optional JSONObject containing JsonLogic rule for runtime evaluation, or null if not set
*/
public JSONObject getRuntimeEvaluationRule() {
return runtimeEvaluationRule;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
import com.mixpanel.mixpanelapi.featureflags.config.LocalFlagsConfig;
import com.mixpanel.mixpanelapi.featureflags.model.*;
import com.mixpanel.mixpanelapi.featureflags.util.HashUtils;
import com.mixpanel.mixpanelapi.featureflags.util.JsonLogicEngine;

import org.json.JSONArray;
import org.json.JSONObject;
Expand All @@ -19,6 +20,7 @@
import java.util.logging.Level;
import java.util.logging.Logger;


/**
* Local feature flags evaluation provider.
* <p>
Expand Down Expand Up @@ -288,15 +290,18 @@ private Rollout parseRollout(JSONObject json) {
}
}

Map<String, Object> runtimeEval = null;
JSONObject runtimeEvalJson = json.optJSONObject("runtime_evaluation_definition");
if (runtimeEvalJson != null) {
runtimeEval = new HashMap<>();
for (String key : runtimeEvalJson.keySet()) {
runtimeEval.put(key, runtimeEvalJson.get(key));
// Parse legacy runtime evaluation (simple key-value format)
Map<String, Object> legacyRuntimeEval = null;
JSONObject legacyRuntimeEvalJson = json.optJSONObject("runtime_evaluation_definition");
if (legacyRuntimeEvalJson != null) {
legacyRuntimeEval = new HashMap<>();
for (String key : legacyRuntimeEvalJson.keySet()) {
legacyRuntimeEval.put(key, legacyRuntimeEvalJson.get(key));
}
}

JSONObject runtimeEvaluationRule = json.optJSONObject("runtime_evaluation_rule");

Map<String, Float> variantSplits = null;
JSONObject variantSplitsJson = json.optJSONObject("variant_splits");
if (variantSplitsJson != null) {
Expand All @@ -306,7 +311,7 @@ private Rollout parseRollout(JSONObject json) {
}
}

return new Rollout(rolloutPercentage, runtimeEval, variantOverride, variantSplits);
return new Rollout(rolloutPercentage, runtimeEvaluationRule, legacyRuntimeEval, variantOverride, variantSplits);
}

// #endregion
Expand Down Expand Up @@ -386,6 +391,12 @@ public <T> SelectedVariant<T> getVariant(String flagKey, SelectedVariant<T> fall
}

// Check runtime evaluation, continue if this rollout has runtime conditions and it doesn't match
if (rollout.hasLegacyRuntimeEvaluation()) {
if (!matchesLegacyRuntimeConditions(rollout, context)) {
continue;
}
}

if (rollout.hasRuntimeEvaluation()) {
if (!matchesRuntimeConditions(rollout, context)) {
continue;
Expand Down Expand Up @@ -433,18 +444,23 @@ public <T> SelectedVariant<T> getVariant(String flagKey, SelectedVariant<T> fall
}
}

private boolean matchesRuntimeConditions(Rollout rollout, Map<String,Object> context) {
Map<String, Object> customProperties = getCustomProperties(context);
return JsonLogicEngine.evaluate(rollout.getRuntimeEvaluationRule(), customProperties);
}

/**
* Evaluates runtime conditions for a rollout.
*
* @return true if all runtime conditions match, false otherwise (or if custom_properties is missing)
*/
private boolean matchesRuntimeConditions(Rollout rollout, Map<String, Object> context) {
private boolean matchesLegacyRuntimeConditions(Rollout rollout, Map<String, Object> context) {
Map<String, Object> customProperties = getCustomProperties(context);
if (customProperties == null) {
return false;
}

Map<String, Object> runtimeEval = rollout.getRuntimeEvaluationDefinition();
Map<String, Object> runtimeEval = rollout.getLegacyRuntimeEvaluationDefinition();
for (Map.Entry<String, Object> entry : runtimeEval.entrySet()) {
String key = entry.getKey();
Object expectedValue = entry.getValue();
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
package com.mixpanel.mixpanelapi.featureflags.util;

import java.util.Map;

/**
* Implements case-insensitive comparison for runtime evaluation rule definitions and runtime parameters.
*/
public class JsonCaseDesensitizer {
public static Object lowercaseLeafNodes(Object object) {
if (object == null) {
return null;
}
else if (object instanceof String){
return ((String) object).toLowerCase();
} else if (object instanceof org.json.JSONObject) {
org.json.JSONObject jsonObject = (org.json.JSONObject) object;
org.json.JSONObject result = new org.json.JSONObject();
for (String key : jsonObject.keySet()) {
result.put(key, lowercaseLeafNodes(jsonObject.get(key)));
}
return result;
} else if (object instanceof org.json.JSONArray) {
org.json.JSONArray jsonArray = (org.json.JSONArray) object;
org.json.JSONArray result = new org.json.JSONArray();
for (int i = 0; i < jsonArray.length(); i++) {
result.put(lowercaseLeafNodes(jsonArray.get(i)));
}
return result;
} else {
return object;
}
}
public static Object lowercaseAllNodes(Object object) {
if (object == null) {
return null;
}
else if (object instanceof String){
return ((String) object).toLowerCase();
} else if (object instanceof Map) {
Map<?, ?> map = (Map<?, ?>) object;
Map<Object, Object> result = new java.util.HashMap<>();
for (Map.Entry<?, ?> entry : map.entrySet()) {
Object lowerKey = entry.getKey() instanceof String
? ((String) entry.getKey()).toLowerCase()
: entry.getKey();
result.put(lowerKey, lowercaseAllNodes(entry.getValue()));
}
return result;
} else if( object instanceof Iterable) {
Iterable<?> iterable = (Iterable<?>) object;
java.util.List<Object> result = new java.util.ArrayList<>();
for (Object item : iterable) {
result.add(lowercaseAllNodes(item));
}
return result;
} else {
return object;
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
package com.mixpanel.mixpanelapi.featureflags.util;

import java.util.HashMap;
import java.util.Map;
import java.util.logging.Level;

import org.json.JSONObject;

import io.github.jamsesso.jsonlogic.JsonLogic;

/**
* Wrapper for third-party library to evaluate JsonLogic DML rules.
*/
public class JsonLogicEngine {
private static final java.util.logging.Logger logger = java.util.logging.Logger.getLogger(JsonLogicEngine.class.getName());

private static final JsonLogic jsonLogic = new JsonLogic();

public static boolean evaluate(JSONObject rule, Map<String, Object> data) {
if (data == null) {
data = new HashMap<>();
}
Map<String, Object> lowercasedData = (Map<String, Object>) JsonCaseDesensitizer.lowercaseAllNodes(data);
try {
String ruleJson = JsonCaseDesensitizer.lowercaseLeafNodes(rule).toString();
logger.log(Level.FINE, () -> "Evaluating JsonLogic rule: " + ruleJson + " with data: " + lowercasedData.toString());
Object result = jsonLogic.apply(ruleJson, lowercasedData);
return JsonLogic.truthy(result);
} catch (Exception e) {
logger.log(Level.WARNING, "Error evaluating runtime rule", e);
return false;
}
}
}
Loading