From 32d0eb571230c44d5862a3ea1b2f09a80a117027 Mon Sep 17 00:00:00 2001
From: Mark Siebert <1504059+msiebert@users.noreply.github.com>
Date: Fri, 17 Oct 2025 22:49:52 +0000
Subject: [PATCH 1/3] add support for feature flags
---
README.md | 95 +-
pom.xml | 12 +-
.../demo/LocalEvaluationExample.java | 128 +++
.../demo/RemoteEvaluationExample.java | 125 +++
.../com/mixpanel/mixpanelapi/MixpanelAPI.java | 143 ++-
.../mixpanelapi/featureflags/EventSender.java | 22 +
.../featureflags/config/BaseFlagsConfig.java | 110 ++
.../featureflags/config/LocalFlagsConfig.java | 91 ++
.../config/RemoteFlagsConfig.java | 47 +
.../model/ExperimentationFlag.java | 128 +++
.../featureflags/model/Rollout.java | 108 ++
.../featureflags/model/RuleSet.java | 83 ++
.../featureflags/model/SelectedVariant.java | 124 +++
.../featureflags/model/Variant.java | 71 ++
.../featureflags/model/VariantOverride.java | 37 +
.../provider/BaseFlagsProvider.java | 231 +++++
.../provider/LocalFlagsProvider.java | 571 ++++++++++
.../provider/RemoteFlagsProvider.java | 171 +++
.../featureflags/util/HashUtils.java | 91 ++
.../featureflags/util/TraceparentUtil.java | 41 +
.../featureflags/util/VersionUtil.java | 69 ++
.../resources/mixpanel-version.properties | 1 +
.../provider/BaseExposureTrackerMock.java | 43 +
.../provider/BaseFlagsProviderTest.java | 66 ++
.../provider/LocalFlagsProviderTest.java | 974 ++++++++++++++++++
.../provider/MockHttpProvider.java | 71 ++
.../provider/RemoteFlagsProviderTest.java | 267 +++++
27 files changed, 3893 insertions(+), 27 deletions(-)
create mode 100644 src/demo/java/com/mixpanel/mixpanelapi/featureflags/demo/LocalEvaluationExample.java
create mode 100644 src/demo/java/com/mixpanel/mixpanelapi/featureflags/demo/RemoteEvaluationExample.java
create mode 100644 src/main/java/com/mixpanel/mixpanelapi/featureflags/EventSender.java
create mode 100644 src/main/java/com/mixpanel/mixpanelapi/featureflags/config/BaseFlagsConfig.java
create mode 100644 src/main/java/com/mixpanel/mixpanelapi/featureflags/config/LocalFlagsConfig.java
create mode 100644 src/main/java/com/mixpanel/mixpanelapi/featureflags/config/RemoteFlagsConfig.java
create mode 100644 src/main/java/com/mixpanel/mixpanelapi/featureflags/model/ExperimentationFlag.java
create mode 100644 src/main/java/com/mixpanel/mixpanelapi/featureflags/model/Rollout.java
create mode 100644 src/main/java/com/mixpanel/mixpanelapi/featureflags/model/RuleSet.java
create mode 100644 src/main/java/com/mixpanel/mixpanelapi/featureflags/model/SelectedVariant.java
create mode 100644 src/main/java/com/mixpanel/mixpanelapi/featureflags/model/Variant.java
create mode 100644 src/main/java/com/mixpanel/mixpanelapi/featureflags/model/VariantOverride.java
create mode 100644 src/main/java/com/mixpanel/mixpanelapi/featureflags/provider/BaseFlagsProvider.java
create mode 100644 src/main/java/com/mixpanel/mixpanelapi/featureflags/provider/LocalFlagsProvider.java
create mode 100644 src/main/java/com/mixpanel/mixpanelapi/featureflags/provider/RemoteFlagsProvider.java
create mode 100644 src/main/java/com/mixpanel/mixpanelapi/featureflags/util/HashUtils.java
create mode 100644 src/main/java/com/mixpanel/mixpanelapi/featureflags/util/TraceparentUtil.java
create mode 100644 src/main/java/com/mixpanel/mixpanelapi/featureflags/util/VersionUtil.java
create mode 100644 src/main/resources/mixpanel-version.properties
create mode 100644 src/test/java/com/mixpanel/mixpanelapi/featureflags/provider/BaseExposureTrackerMock.java
create mode 100644 src/test/java/com/mixpanel/mixpanelapi/featureflags/provider/BaseFlagsProviderTest.java
create mode 100644 src/test/java/com/mixpanel/mixpanelapi/featureflags/provider/LocalFlagsProviderTest.java
create mode 100644 src/test/java/com/mixpanel/mixpanelapi/featureflags/provider/MockHttpProvider.java
create mode 100644 src/test/java/com/mixpanel/mixpanelapi/featureflags/provider/RemoteFlagsProviderTest.java
diff --git a/README.md b/README.md
index f96e562..4b0c8d8 100644
--- a/README.md
+++ b/README.md
@@ -1,8 +1,9 @@
-This is the official Mixpanel tracking library for Java.
+This is the official Mixpanel tracking library for Java.
+
+## Latest Version
-Latest Version
---------------
##### _May 08, 2024_ - [v1.5.3](https://github.com/mixpanel/mixpanel-java/releases/tag/mixpanel-java-1.5.3)
+
```
com.mixpanel
@@ -13,8 +14,8 @@ Latest Version
You can alternatively download the library jar directly from Maven Central [here](https://central.sonatype.com/artifact/com.mixpanel/mixpanel-java).
-How To Use
-----------
+## How To Use
+
The library is designed to produce events and people updates in one process or thread, and
consume the events and people updates in another thread or process. Specially formatted JSON objects
are built by `MessageBuilder` objects, and those messages can be consumed by the
@@ -42,17 +43,84 @@ Gzip compression can reduce bandwidth usage and improve performance, especially
The library supports importing historical events (events older than 5 days that are not accepted using /track) via the `/import` endpoint. Project token will be used for basic auth.
-Learn More
-----------
+## Feature Flags
+
+The Mixpanel Java SDK supports feature flags with both local and remote evaluation modes.
+
+### Local Evaluation (Recommended)
+
+Fast, low-latency flag checks with background polling for flag definitions:
+
+```java
+import com.mixpanel.mixpanelapi.*;
+import com.mixpanel.mixpanelapi.featureflags.config.*;
+import java.util.*;
+
+// Initialize with your project token
+LocalFlagsConfig config = LocalFlagsConfig.builder()
+ .projectToken("YOUR_PROJECT_TOKEN")
+ .pollingIntervalSeconds(60)
+ .build();
+
+MixpanelAPI mixpanel = new MixpanelAPI(config);
+
+// Start polling for flag definitions
+mixpanel.getLocalFlags().startPollingForDefinitions();
+
+// Wait for flags to be ready (optional but recommended)
+while (!mixpanel.getLocalFlags().areFlagsReady()) {
+ Thread.sleep(100);
+}
+
+// Evaluate flags
+Map context = new HashMap<>();
+context.put("distinct_id", "user-123");
+
+// Check if a feature is enabled
+boolean isEnabled = mixpanel.getLocalFlags().isEnabled("new-feature", context);
+
+// Get a variant value with fallback
+String theme = mixpanel.getLocalFlags().getVariantValue("ui-theme", "light", context);
+
+// Cleanup
+mixpanel.close();
+```
+
+### Remote Evaluation
+
+Real-time flag evaluation with server-side API calls:
+
+```java
+import com.mixpanel.mixpanelapi.*;
+import com.mixpanel.mixpanelapi.featureflags.config.*;
+import java.util.*;
+
+RemoteFlagsConfig config = RemoteFlagsConfig.builder()
+ .projectToken("YOUR_PROJECT_TOKEN")
+ .build();
+
+try (MixpanelAPI mixpanel = new MixpanelAPI(config)) {
+ Map context = new HashMap<>();
+ context.put("distinct_id", "user-456");
+
+ boolean isEnabled = mixpanel.getRemoteFlags().isEnabled("premium-features", context);
+}
+```
+
+For complete feature flags documentation, configuration options, advanced usage, and best practices, see:
+
+ https://docs.mixpanel.com/docs/tracking-methods/sdks/java/java-flags
+
+## Learn More
+
This library in particular has more in-depth documentation at
https://mixpanel.com/docs/integration-libraries/java
-
+
Mixpanel maintains documentation at
http://www.mixpanel.com/docs
-
The library also contains a simple demo application, that demonstrates
using this library in an asynchronous environment.
@@ -62,9 +130,9 @@ support for persistent properties, etc. Two interesting ones are at:
https://github.com/eranation/mixpanel-java
https://github.com/scalascope/mixpanel-java
-
-Other Mixpanel Libraries
-------------------------
+
+## Other Mixpanel Libraries
+
Mixpanel also maintains a full-featured library for tracking events from Android apps at https://github.com/mixpanel/mixpanel-android
And a full-featured client side library for web applications, in Javascript, that can be loaded
@@ -73,8 +141,7 @@ directly from Mixpanel servers. To learn more about our Javascript library, see:
This library is intended for use in back end applications or API services that can't take
advantage of the Android libraries or the Javascript library.
-License
--------
+## License
```
See LICENSE File for details. The Base64Coder class used by this software
diff --git a/pom.xml b/pom.xml
index 1fb387c..6fee8ff 100644
--- a/pom.xml
+++ b/pom.xml
@@ -3,7 +3,7 @@
com.mixpanelmixpanel-java
- 1.5.4
+ 1.6.0-flagsjarmixpanel-java
@@ -50,6 +50,16 @@
+
+
+ src/main/resources
+ true
+
+ **/*.properties
+
+
+
+
diff --git a/src/demo/java/com/mixpanel/mixpanelapi/featureflags/demo/LocalEvaluationExample.java b/src/demo/java/com/mixpanel/mixpanelapi/featureflags/demo/LocalEvaluationExample.java
new file mode 100644
index 0000000..34855c0
--- /dev/null
+++ b/src/demo/java/com/mixpanel/mixpanelapi/featureflags/demo/LocalEvaluationExample.java
@@ -0,0 +1,128 @@
+package com.mixpanel.mixpanelapi.featureflags.demo;
+
+import com.mixpanel.mixpanelapi.MixpanelAPI;
+import com.mixpanel.mixpanelapi.featureflags.config.LocalFlagsConfig;
+import com.mixpanel.mixpanelapi.featureflags.model.SelectedVariant;
+
+import java.util.HashMap;
+import java.util.Map;
+
+/**
+ * Example demonstrating local feature flag evaluation.
+ *
+ * This example shows how to:
+ * 1. Configure and initialize a local flags client
+ * 2. Start polling for flag definitions
+ * 3. Evaluate flags with different contexts
+ * 4. Properly clean up resources
+ */
+public class LocalEvaluationExample {
+
+ public static void main(String[] args) throws Exception {
+ // Replace with your actual Mixpanel project token
+ String projectToken = "YOUR_PROJECT_TOKEN";
+
+ // 1. Configure local evaluation
+ LocalFlagsConfig config = LocalFlagsConfig.builder()
+ .projectToken(projectToken)
+ .apiHost("api.mixpanel.com") // Use "api-eu.mixpanel.com" for EU
+ .pollingIntervalSeconds(60) // Poll every 60 seconds
+ .enablePolling(true) // Enable background polling
+ .requestTimeoutSeconds(10) // 10 second timeout for HTTP requests
+ .build();
+
+ try (MixpanelAPI mixpanel = new MixpanelAPI(config)) {
+
+ // 2. Start polling for flag definitions
+ System.out.println("Starting flag polling...");
+ mixpanel.getLocalFlags().startPollingForDefinitions();
+
+ System.out.println("Waiting for flags to be ready...");
+ int retries = 0;
+ while (!mixpanel.getLocalFlags().areFlagsReady() && retries < 50) {
+ Thread.sleep(100);
+ retries++;
+ }
+
+ if (!mixpanel.getLocalFlags().areFlagsReady()) {
+ System.err.println("Warning: Flags not ready after 5 seconds, will use fallback values");
+ } else {
+ System.out.println("Flags are ready!");
+ }
+
+ // 3. Example 1: Simple boolean flag check
+ System.out.println("\n=== Example 1: Boolean Flag ===");
+ Map context1 = new HashMap<>();
+ context1.put("distinct_id", "user-123");
+
+ boolean newFeatureEnabled = mixpanel.getLocalFlags().isEnabled(
+ "new-checkout-flow",
+ context1
+ );
+
+ System.out.println("New checkout flow enabled: " + newFeatureEnabled);
+
+ // Example 2: String variant value
+ System.out.println("\n=== Example 2: String Variant ===");
+ String buttonColor = mixpanel.getLocalFlags().getVariantValue(
+ "button-color",
+ "blue", // fallback value
+ context1
+ );
+
+ System.out.println("Button color: " + buttonColor);
+
+ // Example 3: With custom properties for targeting
+ System.out.println("\n=== Example 3: Targeted Flag ===");
+ Map context2 = new HashMap<>();
+ context2.put("distinct_id", "user-456");
+
+ // Add custom properties for runtime evaluation
+ Map customProps = new HashMap<>();
+ customProps.put("subscription_tier", "premium");
+ customProps.put("country", "US");
+ context2.put("custom_properties", customProps);
+
+ boolean premiumFeatureEnabled = mixpanel.getLocalFlags().isEnabled(
+ "premium-analytics-dashboard",
+ context2
+ );
+
+ System.out.println("Premium analytics enabled: " + premiumFeatureEnabled);
+
+ // Example 4: Get full variant information
+ System.out.println("\n=== Example 4: Full Variant Info ===");
+ SelectedVariant
+ *
+ * @param urlString the URL being requested
+ * @return the mock response for this URL
+ * @throws IOException if an exception is configured or no mock found
+ */
+ public String mockHttpGet(String urlString) throws IOException {
+ if (mockException != null) {
+ throw mockException;
+ }
+
+ // Try to find a matching URL pattern
+ for (Map.Entry entry : urlToResponseMap.entrySet()) {
+ if (urlString.contains(entry.getKey())) {
+ return entry.getValue();
+ }
+ }
+
+ // No mock found - throw exception to simulate network error
+ throw new IOException("No mock response configured for URL: " + urlString);
+ }
+}
diff --git a/src/test/java/com/mixpanel/mixpanelapi/featureflags/provider/RemoteFlagsProviderTest.java b/src/test/java/com/mixpanel/mixpanelapi/featureflags/provider/RemoteFlagsProviderTest.java
new file mode 100644
index 0000000..a904b75
--- /dev/null
+++ b/src/test/java/com/mixpanel/mixpanelapi/featureflags/provider/RemoteFlagsProviderTest.java
@@ -0,0 +1,267 @@
+package com.mixpanel.mixpanelapi.featureflags.provider;
+
+import com.mixpanel.mixpanelapi.featureflags.EventSender;
+import com.mixpanel.mixpanelapi.featureflags.config.RemoteFlagsConfig;
+import com.mixpanel.mixpanelapi.featureflags.model.SelectedVariant;
+
+import org.json.JSONObject;
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Test;
+
+import java.io.IOException;
+import java.util.*;
+
+import static org.junit.Assert.*;
+
+/**
+ * Unit tests for RemoteFlagsProvider.
+ * Tests cover error handling, successful variant retrieval, exposure tracking,
+ * and boolean convenience methods for remote flag evaluation.
+ */
+public class RemoteFlagsProviderTest extends BaseFlagsProviderTest {
+
+ private TestableRemoteFlagsProvider provider;
+ private RemoteFlagsConfig config;
+ private MockEventSender eventSender;
+
+ /**
+ * Testable subclass of RemoteFlagsProvider that allows mocking HTTP responses.
+ */
+ private static class TestableRemoteFlagsProvider extends RemoteFlagsProvider {
+ private final MockHttpProvider httpMock = new MockHttpProvider();
+
+ public TestableRemoteFlagsProvider(RemoteFlagsConfig config, String sdkVersion, EventSender eventSender) {
+ super(config, sdkVersion, eventSender);
+ }
+
+ public void setMockResponse(String urlPattern, String response) {
+ httpMock.setMockResponse(urlPattern, response);
+ }
+
+ public void setMockException(IOException exception) {
+ httpMock.setMockException(exception);
+ }
+
+ @Override
+ protected String httpGet(String urlString) throws IOException {
+ return httpMock.mockHttpGet(urlString);
+ }
+ }
+
+ private static class MockEventSender implements EventSender {
+ private final List events = new ArrayList<>();
+
+ static class ExposureEvent {
+ String distinctId;
+ String eventName;
+ JSONObject properties;
+
+ ExposureEvent(String distinctId, String eventName, JSONObject properties) {
+ this.distinctId = distinctId;
+ this.eventName = eventName;
+ this.properties = properties;
+ }
+ }
+
+ @Override
+ public void sendEvent(String distinctId, String eventName, JSONObject properties) {
+ events.add(new ExposureEvent(distinctId, eventName, properties));
+ }
+
+ public List getEvents() {
+ return events;
+ }
+
+ public void reset() {
+ events.clear();
+ }
+ }
+
+ @Before
+ public void setUp() {
+ config = RemoteFlagsConfig.builder()
+ .projectToken(TEST_TOKEN)
+ .requestTimeoutSeconds(5)
+ .build();
+ eventSender = new MockEventSender();
+ }
+
+ @Override
+ protected Object getProvider() {
+ return provider;
+ }
+
+ // #endregion
+
+ // #region Helper Methods
+
+ /**
+ * Builds a mock remote flags API response
+ */
+ private String buildRemoteFlagsResponse(String flagKey, String variantKey, Object variantValue) {
+ try {
+ JSONObject root = new JSONObject();
+ JSONObject flags = new JSONObject();
+ JSONObject flagData = new JSONObject();
+ flagData.put("variant_key", variantKey);
+ flagData.put("variant_value", variantValue);
+ flags.put(flagKey, flagData);
+ root.put("flags", flags);
+ return root.toString();
+ } catch (Exception e) {
+ throw new RuntimeException("Failed to build test response", e);
+ }
+ }
+
+ /**
+ * Creates a test provider with custom HTTP response.
+ * The response will be returned when the flags API URL is called.
+ */
+ private TestableRemoteFlagsProvider createProviderWithResponse(String jsonResponse) {
+ TestableRemoteFlagsProvider testProvider = new TestableRemoteFlagsProvider(config, SDK_VERSION, eventSender);
+
+ if (jsonResponse != null) {
+ // Mock the flags endpoint
+ testProvider.setMockResponse("/flags", jsonResponse);
+ } else {
+ // Simulate network error by setting exception
+ testProvider.setMockException(new IOException("Simulated network error"));
+ }
+
+ return testProvider;
+ }
+
+ // #endregion
+
+ // #region Error Handling Tests
+
+ @Test
+ public void testReturnFallbackWhenAPICallFails() {
+ // Create provider that will throw IOException
+ provider = createProviderWithResponse(null);
+
+ Map context = buildContext("user-123");
+ String result = provider.getVariantValue("test-flag", "fallback", context);
+
+ // Should return fallback due to network error
+ assertEquals("fallback", result);
+ assertEquals(0, eventSender.getEvents().size());
+ }
+
+ @Test
+ public void testReturnFallbackWhenResponseFormatIsInvalid() {
+ provider = new TestableRemoteFlagsProvider(config, SDK_VERSION, eventSender);
+
+ // Set invalid JSON response
+ provider.setMockResponse("/flags", "invalid json {{{");
+
+ Map context = buildContext("user-123");
+ String result = provider.getVariantValue("test-flag", "fallback", context);
+
+ // Should return fallback due to JSON parse error
+ assertEquals("fallback", result);
+ assertEquals(0, eventSender.getEvents().size());
+ }
+
+ @Test
+ public void testReturnFallbackWhenFlagNotFoundInSuccessfulResponse() {
+ // Set response with a different flag
+ String response = buildRemoteFlagsResponse("other-flag", "variant-a", "value-a");
+ provider = createProviderWithResponse(response);
+
+ Map context = buildContext("user-123");
+ String result = provider.getVariantValue("non-existent-flag", "fallback", context);
+
+ // Should return fallback when flag not found
+ assertEquals("fallback", result);
+ assertEquals(0, eventSender.getEvents().size());
+ }
+
+ // #endregion
+
+ // #region Successful Variant Retrieval Tests
+
+ @Test
+ public void testReturnExpectedVariantFromAPI() {
+ // Set up a successful response
+ String response = buildRemoteFlagsResponse("test-flag", "variant-a", "test-value");
+ provider = createProviderWithResponse(response);
+
+ Map context = buildContext("user-123");
+ String result = provider.getVariantValue("test-flag", "fallback", context);
+
+ // Should return the variant value from the API
+ assertEquals("test-value", result);
+ }
+
+ // #endregion
+
+ // #region Exposure Tracking Tests
+
+ @Test
+ public void testTrackExposureWhenVariantIsSelected() {
+ // Set up a successful response
+ String response = buildRemoteFlagsResponse("test-flag", "variant-a", "test-value");
+ provider = createProviderWithResponse(response);
+
+ Map context = buildContext("user-123");
+ provider.getVariantValue("test-flag", "fallback", context);
+
+ // Should track exposure
+ assertEquals(1, eventSender.getEvents().size());
+ MockEventSender.ExposureEvent event = eventSender.getEvents().get(eventSender.getEvents().size() - 1);
+ assertEquals("user-123", event.distinctId);
+ assertEquals("$experiment_started", event.eventName);
+ assertEquals("test-flag", event.properties.getString("Experiment name"));
+ assertEquals("variant-a", event.properties.getString("Variant name"));
+ assertEquals("remote", event.properties.getString("Flag evaluation mode"));
+ assertNotNull(event.properties.getString("Variant fetch start time"));
+ assertNotNull(event.properties.getString("Variant fetch complete time"));
+ }
+
+ @Test
+ public void testDoNotTrackExposureWhenReturningFallback() {
+ // Create provider that will throw IOException
+ provider = createProviderWithResponse(null);
+
+ Map context = buildContext("user-123");
+ provider.getVariantValue("test-flag", "fallback", context);
+
+ // Should not track exposure when returning fallback
+ assertEquals(0, eventSender.getEvents().size());
+ }
+
+ // #endregion
+
+ // #region Boolean Convenience Method Tests
+
+ @Test
+ public void testIsEnabledReturnsTrueForBooleanTrueVariant() {
+ // Set up response with boolean true value
+ String response = buildRemoteFlagsResponse("test-flag", "enabled", true);
+ provider = createProviderWithResponse(response);
+
+ Map context = buildContext("user-123");
+ boolean result = provider.isEnabled("test-flag", context);
+
+ // Should return true for boolean true variant
+ assertTrue(result);
+ }
+
+ @Test
+ public void testIsEnabledReturnsFalseForBooleanFalseVariant() {
+ // Set up response with boolean false value
+ String response = buildRemoteFlagsResponse("test-flag", "disabled", false);
+ provider = createProviderWithResponse(response);
+
+ Map context = buildContext("user-123");
+ boolean result = provider.isEnabled("test-flag", context);
+
+ // Should return false for boolean false variant
+ assertFalse(result);
+ }
+
+ // #endregion
+}
+
From 21b8d8b3a167662674ccd3d6f5994ada3d7c95c1 Mon Sep 17 00:00:00 2001
From: Mark Siebert <1504059+msiebert@users.noreply.github.com>
Date: Thu, 30 Oct 2025 21:08:18 +0000
Subject: [PATCH 2/3] add support for variant_splits in rollouts
---
.../provider/LocalFlagsProvider.java | 59 +++++++++++-
.../provider/LocalFlagsProviderTest.java | 92 +++++++++++++++++++
2 files changed, 146 insertions(+), 5 deletions(-)
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 069e764..1b1f524 100644
--- a/src/main/java/com/mixpanel/mixpanelapi/featureflags/provider/LocalFlagsProvider.java
+++ b/src/main/java/com/mixpanel/mixpanelapi/featureflags/provider/LocalFlagsProvider.java
@@ -393,7 +393,7 @@ public SelectedVariant getVariant(String flagKey, SelectedVariant fall
} else {
// Use variant hash to select from split
float variantHash = HashUtils.normalizedHash(contextValue + flagKey, "variant");
- selectedVariant = selectVariantBySplit(ruleset.getVariants(), variantHash);
+ selectedVariant = selectVariantBySplit(ruleset.getVariants(), variantHash, rollout);
}
if (selectedVariant != null) {
@@ -493,12 +493,61 @@ private Variant findVariantByKey(List variants, String key) {
}
/**
- * Selects a variant based on hash and split percentages.
+ * Applies variant split overrides from a rollout to the flag's variants.
+ *
+ * Creates a new list of variants with updated split values where overrides are specified.
+ * Variants not in the overrides map retain their original split values.
+ *
+ *
+ * @param variants the original list of variants from the flag definition
+ * @param variantSplits the map of variant key to split percentage overrides
+ * @return a new list with variant split overrides applied
*/
- private Variant selectVariantBySplit(List variants, float hash) {
- float cumulative = 0.0f;
+ private List applyVariantSplitOverrides(List variants, Map variantSplits) {
+ List result = new ArrayList<>(variants.size());
for (Variant variant : variants) {
+ if (variantSplits.containsKey(variant.getKey())) {
+ // Create new variant with overridden split value
+ float overriddenSplit = variantSplits.get(variant.getKey());
+ Variant updatedVariant = new Variant(
+ variant.getKey(),
+ variant.getValue(),
+ variant.isControl(),
+ overriddenSplit
+ );
+ result.add(updatedVariant);
+ } else {
+ // Keep original variant with its original split
+ result.add(variant);
+ }
+ }
+
+ return result;
+ }
+
+ /**
+ * Selects a variant based on hash and split percentages.
+ *
+ * If the rollout has variant_splits configured, those override the flag-level splits.
+ * Otherwise, uses the default split values from the variants.
+ *
+ *
+ * @param variants the list of variants to select from
+ * @param hash the normalized hash value (0.0 to 1.0) for selection
+ * @param rollout the rollout being evaluated (may be null or have no variant_splits)
+ * @return the selected variant, or null if variants list is empty
+ */
+ private Variant selectVariantBySplit(List variants, float hash, Rollout rollout) {
+ // Apply variant split overrides if the rollout specifies them
+ List variantsToUse = variants;
+ if (rollout != null && rollout.hasVariantSplits()) {
+ variantsToUse = applyVariantSplitOverrides(variants, rollout.getVariantSplits());
+ }
+
+ // Select variant using cumulative split percentages
+ float cumulative = 0.0f;
+ for (Variant variant : variantsToUse) {
cumulative += variant.getSplit();
if (hash < cumulative) {
return variant;
@@ -506,7 +555,7 @@ private Variant selectVariantBySplit(List variants, float hash) {
}
// If no variant selected (due to rounding), return last variant
- return variants.isEmpty() ? null : variants.get(variants.size() - 1);
+ return variantsToUse.isEmpty() ? null : variantsToUse.get(variantsToUse.size() - 1);
}
/**
diff --git a/src/test/java/com/mixpanel/mixpanelapi/featureflags/provider/LocalFlagsProviderTest.java b/src/test/java/com/mixpanel/mixpanelapi/featureflags/provider/LocalFlagsProviderTest.java
index a557f59..ac376ca 100644
--- a/src/test/java/com/mixpanel/mixpanelapi/featureflags/provider/LocalFlagsProviderTest.java
+++ b/src/test/java/com/mixpanel/mixpanelapi/featureflags/provider/LocalFlagsProviderTest.java
@@ -214,6 +214,10 @@ private JSONObject buildFlagJsonObject(FlagDefinition def, String flagId) {
JSONObject runtimeEval = new JSONObject(r.getRuntimeEvaluationDefinition());
rolloutJson.put("runtime_evaluation_definition", runtimeEval);
}
+ if (r.hasVariantSplits()) {
+ JSONObject variantSplitsObj = new JSONObject(r.getVariantSplits());
+ rolloutJson.put("variant_splits", variantSplitsObj);
+ }
rolloutsArray.put(rolloutJson);
}
ruleset.put("rollout", rolloutsArray);
@@ -970,5 +974,93 @@ public void testIsQaTesterFalseWhenUserGoesThroughNormalRollout() {
assertEquals(Boolean.FALSE, event.properties.getBoolean("$is_qa_tester"));
}
+ // #endregion
+ // #region Variant Splits Tests
+
+ @Test
+ public void testVariantSplitsOverridesFlagLevelSplits() {
+ // Flag defines three variants with flag-level splits
+ List variants = Arrays.asList(
+ new Variant("control", "blue", true, 0.34f), // 34% at flag level
+ new Variant("treatment-a", "red", false, 0.33f), // 33% at flag level
+ new Variant("treatment-b", "green", false, 0.33f) // 33% at flag level
+ );
+
+ // Rollout overrides splits: 100% to treatment-b, 0% to others
+ Map variantSplits = new HashMap<>();
+ variantSplits.put("control", 0.0f);
+ variantSplits.put("treatment-a", 0.0f);
+ variantSplits.put("treatment-b", 1.0f);
+
+ List rollouts = Arrays.asList(new Rollout(1.0f, null, null, variantSplits));
+ String response = buildFlagsResponse("test-flag", "distinct_id", variants, rollouts, null);
+
+ provider = createProviderWithResponse(response);
+ provider.startPollingForDefinitions();
+
+ // Test multiple users - all should get treatment-b due to 100% override
+ for (int i = 0; i < 10; i++) {
+ Map context = buildContext("user-" + i);
+ String result = provider.getVariantValue("test-flag", "fallback", context);
+ assertEquals("All users should get treatment-b due to 100% variant split override",
+ "green", result);
+ }
+ }
+
+ @Test
+ public void testVariantOverrideTakesPrecedenceOverVariantSplits() {
+ // Flag defines variants with flag-level splits
+ List variants = Arrays.asList(
+ new Variant("control", "blue", true, 0.5f),
+ new Variant("treatment", "red", false, 0.5f)
+ );
+
+ // Rollout has both variant_override AND variant_splits
+ // variant_override should take precedence
+ Map variantSplits = new HashMap<>();
+ variantSplits.put("control", 1.0f); // 100% to control via splits
+ variantSplits.put("treatment", 0.0f);
+
+ VariantOverride variantOverride = new VariantOverride("treatment"); // But override forces treatment
+
+ List rollouts = Arrays.asList(new Rollout(1.0f, null, variantOverride, variantSplits));
+ String response = buildFlagsResponse("test-flag", "distinct_id", variants, rollouts, null);
+
+ provider = createProviderWithResponse(response);
+ provider.startPollingForDefinitions();
+
+ // Test multiple users - all should get treatment due to variant_override
+ for (int i = 0; i < 10; i++) {
+ Map context = buildContext("user-" + i);
+ String result = provider.getVariantValue("test-flag", "fallback", context);
+ assertEquals("variant_override should take precedence over variant_splits",
+ "red", result);
+ }
+ }
+
+ @Test
+ public void testNoVariantSplitsUsesDefaultBehavior() {
+ // Flag defines variants with flag-level splits
+ List variants = Arrays.asList(
+ new Variant("control", "blue", true, 0.0f),
+ new Variant("treatment", "red", false, 1.0f) // 100% at flag level
+ );
+
+ // Rollout without variant_splits (null)
+ List rollouts = Arrays.asList(new Rollout(1.0f, null, null, null));
+ String response = buildFlagsResponse("test-flag", "distinct_id", variants, rollouts, null);
+
+ provider = createProviderWithResponse(response);
+ provider.startPollingForDefinitions();
+
+ // Test multiple users - all should get treatment based on flag-level splits
+ for (int i = 0; i < 10; i++) {
+ Map context = buildContext("user-" + i);
+ String result = provider.getVariantValue("test-flag", "fallback", context);
+ assertEquals("Should use flag-level splits when no variant_splits in rollout",
+ "red", result);
+ }
+ }
+
// #endregion
}
From 57b8a6a4d1dc814d1a61a32952993b8ba5fd5b57 Mon Sep 17 00:00:00 2001
From: Mark Siebert <1504059+msiebert@users.noreply.github.com>
Date: Thu, 30 Oct 2025 23:37:43 +0000
Subject: [PATCH 3/3] add support for hash salts in feature flag definitions
and evaluation
---
.../model/ExperimentationFlag.java | 13 +-
.../provider/LocalFlagsProvider.java | 57 ++-
.../provider/LocalFlagsProviderTest.java | 324 +++++++++++++++++-
3 files changed, 387 insertions(+), 7 deletions(-)
diff --git a/src/main/java/com/mixpanel/mixpanelapi/featureflags/model/ExperimentationFlag.java b/src/main/java/com/mixpanel/mixpanelapi/featureflags/model/ExperimentationFlag.java
index e73ccf5..1a796ae 100644
--- a/src/main/java/com/mixpanel/mixpanelapi/featureflags/model/ExperimentationFlag.java
+++ b/src/main/java/com/mixpanel/mixpanelapi/featureflags/model/ExperimentationFlag.java
@@ -22,6 +22,7 @@ public final class ExperimentationFlag {
private final String context;
private final UUID experimentId;
private final Boolean isExperimentActive;
+ private final String hashSalt;
/**
* Creates a new ExperimentationFlag.
@@ -35,8 +36,9 @@ public final class ExperimentationFlag {
* @param context the property name used for rollout hashing (e.g., "distinct_id")
* @param experimentId the experiment ID (may be null)
* @param isExperimentActive whether the experiment is active (may be null)
+ * @param hashSalt the hash salt for this flag (may be null for legacy flags)
*/
- public ExperimentationFlag(String id, String name, String key, String status, int projectId, RuleSet ruleset, String context, UUID experimentId, Boolean isExperimentActive) {
+ public ExperimentationFlag(String id, String name, String key, String status, int projectId, RuleSet ruleset, String context, UUID experimentId, Boolean isExperimentActive, String hashSalt) {
this.id = id;
this.name = name;
this.key = key;
@@ -46,6 +48,7 @@ public ExperimentationFlag(String id, String name, String key, String status, in
this.context = context;
this.experimentId = experimentId;
this.isExperimentActive = isExperimentActive;
+ this.hashSalt = hashSalt;
}
/**
@@ -111,6 +114,13 @@ public Boolean getIsExperimentActive() {
return isExperimentActive;
}
+ /**
+ * @return the hash salt for this flag, or null for legacy flags
+ */
+ public String getHashSalt() {
+ return hashSalt;
+ }
+
@Override
public String toString() {
return "ExperimentationFlag{" +
@@ -123,6 +133,7 @@ public String toString() {
", context='" + context + '\'' +
", experimentId=" + experimentId +
", isExperimentActive=" + isExperimentActive +
+ ", hashSalt='" + hashSalt + '\'' +
'}';
}
}
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 1b1f524..7e064a2 100644
--- a/src/main/java/com/mixpanel/mixpanelapi/featureflags/provider/LocalFlagsProvider.java
+++ b/src/main/java/com/mixpanel/mixpanelapi/featureflags/provider/LocalFlagsProvider.java
@@ -206,9 +206,12 @@ private ExperimentationFlag parseFlag(JSONObject json) {
isExperimentActive = json.optBoolean("is_experiment_active", false);
}
+ // Parse hash_salt (may be null for legacy flags)
+ String hashSalt = json.optString("hash_salt", null);
+
RuleSet ruleset = parseRuleSet(json.optJSONObject("ruleset"));
- return new ExperimentationFlag(id, name, key, status, projectId, ruleset, context, experimentId, isExperimentActive);
+ return new ExperimentationFlag(id, name, key, status, projectId, ruleset, context, experimentId, isExperimentActive, hashSalt);
}
/**
@@ -371,9 +374,13 @@ public SelectedVariant getVariant(String flagKey, SelectedVariant fall
}
// Evaluate rollouts
- float rolloutHash = HashUtils.normalizedHash(contextValue + flagKey, "rollout");
+ List rollouts = ruleset.getRollouts();
+ for (int rolloutIndex = 0; rolloutIndex < rollouts.size(); rolloutIndex++) {
+ Rollout rollout = rollouts.get(rolloutIndex);
+
+ // Calculate rollout hash
+ float rolloutHash = calculateRolloutHash(contextValue, flagKey, flag.getHashSalt(), rolloutIndex);
- for (Rollout rollout : ruleset.getRollouts()) {
if (rolloutHash >= rollout.getRolloutPercentage()) {
continue;
}
@@ -391,8 +398,8 @@ public SelectedVariant getVariant(String flagKey, SelectedVariant fall
if (rollout.hasVariantOverride()) {
selectedVariant = findVariantByKey(ruleset.getVariants(), rollout.getVariantOverride().getKey());
} else {
- // Use variant hash to select from split
- float variantHash = HashUtils.normalizedHash(contextValue + flagKey, "variant");
+ // Calculate variant hash
+ float variantHash = calculateVariantHash(contextValue, flagKey, flag.getHashSalt());
selectedVariant = selectVariantBySplit(ruleset.getVariants(), variantHash, rollout);
}
@@ -558,6 +565,46 @@ private Variant selectVariantBySplit(List variants, float hash, Rollout
return variantsToUse.isEmpty() ? null : variantsToUse.get(variantsToUse.size() - 1);
}
+ /**
+ * Calculates the rollout hash for a given context and rollout index.
+ *
+ * This method can be overridden in tests to verify hash parameters.
+ *
+ *
+ * @param contextValue the context value (e.g., user ID)
+ * @param flagKey the flag key
+ * @param hashSalt the hash salt (null or empty for legacy behavior)
+ * @param rolloutIndex the index of the rollout being evaluated
+ * @return the normalized hash value (0.0 to 1.0)
+ */
+ protected float calculateRolloutHash(String contextValue, String flagKey,
+ String hashSalt, int rolloutIndex) {
+ if (hashSalt != null && !hashSalt.isEmpty()) {
+ return HashUtils.normalizedHash(contextValue + flagKey, hashSalt + rolloutIndex);
+ } else {
+ return HashUtils.normalizedHash(contextValue + flagKey, "rollout");
+ }
+ }
+
+ /**
+ * Calculates the variant hash for a given context.
+ *
+ * This method can be overridden in tests to verify hash parameters.
+ *
+ *
+ * @param contextValue the context value (e.g., user ID)
+ * @param flagKey the flag key
+ * @param hashSalt the hash salt (null or empty for legacy behavior)
+ * @return the normalized hash value (0.0 to 1.0)
+ */
+ protected float calculateVariantHash(String contextValue, String flagKey, String hashSalt) {
+ if (hashSalt != null && !hashSalt.isEmpty()) {
+ return HashUtils.normalizedHash(contextValue + flagKey, hashSalt + "variant");
+ } else {
+ return HashUtils.normalizedHash(contextValue + flagKey, "variant");
+ }
+ }
+
/**
* Evaluates all flags and returns their selected variants.
*
diff --git a/src/test/java/com/mixpanel/mixpanelapi/featureflags/provider/LocalFlagsProviderTest.java b/src/test/java/com/mixpanel/mixpanelapi/featureflags/provider/LocalFlagsProviderTest.java
index ac376ca..80fcc39 100644
--- a/src/test/java/com/mixpanel/mixpanelapi/featureflags/provider/LocalFlagsProviderTest.java
+++ b/src/test/java/com/mixpanel/mixpanelapi/featureflags/provider/LocalFlagsProviderTest.java
@@ -91,6 +91,73 @@ public void reset() {
}
}
+ /**
+ * Testable subclass that captures hash function calls for verification.
+ */
+ private static class TestableHashingLocalFlagsProvider extends TestableLocalFlagsProvider {
+
+ /**
+ * Represents a single hash function call with all parameters.
+ */
+ public static class HashCall {
+ public final String contextValue;
+ public final String flagKey;
+ public final String hashSalt;
+ public final Integer rolloutIndex; // null for variant hashes
+ public final String type; // "rollout" or "variant"
+ public final float result;
+
+ HashCall(String contextValue, String flagKey, String hashSalt,
+ Integer rolloutIndex, String type, float result) {
+ this.contextValue = contextValue;
+ this.flagKey = flagKey;
+ this.hashSalt = hashSalt;
+ this.rolloutIndex = rolloutIndex;
+ this.type = type;
+ this.result = result;
+ }
+ }
+
+ private final List hashCalls = new ArrayList<>();
+
+ public TestableHashingLocalFlagsProvider(LocalFlagsConfig config,
+ String sdkVersion,
+ EventSender eventSender) {
+ super(config, sdkVersion, eventSender);
+ }
+
+ @Override
+ protected float calculateRolloutHash(String contextValue, String flagKey,
+ String hashSalt, int rolloutIndex) {
+ float result = super.calculateRolloutHash(contextValue, flagKey, hashSalt, rolloutIndex);
+ // Compute the actual salt used (same logic as parent method)
+ String actualSalt = (hashSalt != null && !hashSalt.isEmpty())
+ ? hashSalt + rolloutIndex
+ : "rollout";
+ hashCalls.add(new HashCall(contextValue, flagKey, actualSalt, rolloutIndex, "rollout", result));
+ return result;
+ }
+
+ @Override
+ protected float calculateVariantHash(String contextValue, String flagKey, String hashSalt) {
+ float result = super.calculateVariantHash(contextValue, flagKey, hashSalt);
+ // Compute the actual salt used (same logic as parent method)
+ String actualSalt = (hashSalt != null && !hashSalt.isEmpty())
+ ? hashSalt + "variant"
+ : "variant";
+ hashCalls.add(new HashCall(contextValue, flagKey, actualSalt, null, "variant", result));
+ return result;
+ }
+
+ public List getHashCalls() {
+ return new ArrayList<>(hashCalls);
+ }
+
+ public void clearHashCalls() {
+ hashCalls.clear();
+ }
+ }
+
@Before
public void setUp() {
config = LocalFlagsConfig.builder()
@@ -186,6 +253,11 @@ private JSONObject buildFlagJsonObject(FlagDefinition def, String flagId) {
flag.put("is_experiment_active", def.isExperimentActive);
}
+ // Add hash_salt if provided
+ if (def.hashSalt != null) {
+ flag.put("hash_salt", def.hashSalt);
+ }
+
JSONObject ruleset = new JSONObject();
// Add variants
@@ -245,13 +317,19 @@ private static class FlagDefinition {
Map testUsers;
UUID experimentId;
Boolean isExperimentActive;
+ String hashSalt;
FlagDefinition(String flagKey, String context, List variants, List rollouts) {
- this(flagKey, context, variants, rollouts, null, null, null);
+ this(flagKey, context, variants, rollouts, null, null, null, null);
}
FlagDefinition(String flagKey, String context, List variants, List rollouts,
Map testUsers, UUID experimentId, Boolean isExperimentActive) {
+ this(flagKey, context, variants, rollouts, testUsers, experimentId, isExperimentActive, null);
+ }
+
+ FlagDefinition(String flagKey, String context, List variants, List rollouts,
+ Map testUsers, UUID experimentId, Boolean isExperimentActive, String hashSalt) {
this.flagKey = flagKey;
this.context = context;
this.variants = variants;
@@ -259,6 +337,7 @@ private static class FlagDefinition {
this.testUsers = testUsers;
this.experimentId = experimentId;
this.isExperimentActive = isExperimentActive;
+ this.hashSalt = hashSalt;
}
}
@@ -1062,5 +1141,248 @@ public void testNoVariantSplitsUsesDefaultBehavior() {
}
}
+ // #region Hash Salt Tests
+
+ @Test
+ public void testHashSaltIsUsedForRolloutCalculation() {
+ // Create a flag with hash_salt
+ List variants = Arrays.asList(
+ new Variant("control", false, true, 0.5f),
+ new Variant("treatment", true, false, 0.5f)
+ );
+
+ List rollouts = Arrays.asList(new Rollout(1.0f, null, null, null)); // 100% rollout
+
+ // Create flag definition with hash_salt
+ String hashSalt = "abc123def456abc123def456abc12345"; // 32-char hex string
+ FlagDefinition flagDef = new FlagDefinition("test-flag", "distinct_id", variants, rollouts, null, null, null, hashSalt);
+
+ JSONObject root = new JSONObject();
+ JSONArray flagsArray = new JSONArray();
+ flagsArray.put(buildFlagJsonObject(flagDef, "flag-1"));
+ root.put("flags", flagsArray);
+ String response = root.toString();
+
+ TestableHashingLocalFlagsProvider hashingProvider = new TestableHashingLocalFlagsProvider(config, SDK_VERSION, eventSender);
+ hashingProvider.setMockResponse("/flags/definitions", response);
+ hashingProvider.startPollingForDefinitions();
+
+ // Evaluate the flag
+ Map context = buildContext("user-123");
+ hashingProvider.getVariantValue("test-flag", "fallback", context);
+
+ // Verify hash calls
+ List hashCalls = hashingProvider.getHashCalls();
+ assertFalse("Should have made hash calls", hashCalls.isEmpty());
+
+ // Find the rollout hash call
+ TestableHashingLocalFlagsProvider.HashCall rolloutHashCall = null;
+ for (TestableHashingLocalFlagsProvider.HashCall call : hashCalls) {
+ if ("rollout".equals(call.type)) {
+ rolloutHashCall = call;
+ break;
+ }
+ }
+
+ assertNotNull("Should have called calculateRolloutHash", rolloutHashCall);
+ assertEquals("Context value should be user-123", "user-123", rolloutHashCall.contextValue);
+ assertEquals("Flag key should be test-flag", "test-flag", rolloutHashCall.flagKey);
+ assertEquals("Hash salt should include rollout index 0", hashSalt + "0", rolloutHashCall.hashSalt);
+ assertEquals("Rollout index should be 0", Integer.valueOf(0), rolloutHashCall.rolloutIndex);
+ }
+
+ @Test
+ public void testHashSaltIsUsedForVariantCalculation() {
+ // Create a flag with hash_salt
+ List variants = Arrays.asList(
+ new Variant("control", "blue", true, 0.5f),
+ new Variant("treatment", "red", false, 0.5f)
+ );
+
+ List rollouts = Arrays.asList(new Rollout(1.0f, null, null, null)); // 100% rollout
+
+ // Create flag definition with hash_salt
+ String hashSalt = "def789abc012def789abc012def78901"; // 32-char hex string
+ FlagDefinition flagDef = new FlagDefinition("test-flag", "distinct_id", variants, rollouts, null, null, null, hashSalt);
+
+ JSONObject root = new JSONObject();
+ JSONArray flagsArray = new JSONArray();
+ flagsArray.put(buildFlagJsonObject(flagDef, "flag-1"));
+ root.put("flags", flagsArray);
+ String response = root.toString();
+
+ TestableHashingLocalFlagsProvider hashingProvider = new TestableHashingLocalFlagsProvider(config, SDK_VERSION, eventSender);
+ hashingProvider.setMockResponse("/flags/definitions", response);
+ hashingProvider.startPollingForDefinitions();
+
+ // Evaluate the flag
+ Map context = buildContext("user-456");
+ hashingProvider.getVariantValue("test-flag", "fallback", context);
+
+ // Verify hash calls
+ List hashCalls = hashingProvider.getHashCalls();
+
+ // Find the variant hash call
+ TestableHashingLocalFlagsProvider.HashCall variantHashCall = null;
+ for (TestableHashingLocalFlagsProvider.HashCall call : hashCalls) {
+ if ("variant".equals(call.type)) {
+ variantHashCall = call;
+ break;
+ }
+ }
+
+ assertNotNull("Should have called calculateVariantHash", variantHashCall);
+ assertEquals("Context value should be user-456", "user-456", variantHashCall.contextValue);
+ assertEquals("Flag key should be test-flag", "test-flag", variantHashCall.flagKey);
+ assertEquals("Hash salt should include 'variant'", hashSalt + "variant", variantHashCall.hashSalt);
+ assertNull("Rollout index should be null for variant hash", variantHashCall.rolloutIndex);
+ }
+
+ @Test
+ public void testMultipleRolloutsWithHashSaltUseDifferentHashes() {
+ // Create a flag with hash_salt and multiple rollouts
+ List variants = Arrays.asList(
+ new Variant("control", false, true, 0.5f),
+ new Variant("treatment", true, false, 0.5f)
+ );
+
+ // First rollout: 0% (will be evaluated but not match, forcing evaluation of second)
+ // Second rollout: 100% (will be evaluated and match)
+ List rollouts = Arrays.asList(
+ new Rollout(0.0f, null, null, null), // 0% - evaluated but doesn't match
+ new Rollout(1.0f, null, null, null) // 100% - evaluated and matches
+ );
+
+ // Create flag definition with hash_salt
+ String hashSalt = "012345678901234567890123456789ab"; // 32-char hex string
+ FlagDefinition flagDef = new FlagDefinition("test-flag", "distinct_id", variants, rollouts, null, null, null, hashSalt);
+
+ JSONObject root = new JSONObject();
+ JSONArray flagsArray = new JSONArray();
+ flagsArray.put(buildFlagJsonObject(flagDef, "flag-1"));
+ root.put("flags", flagsArray);
+ String response = root.toString();
+
+ TestableHashingLocalFlagsProvider hashingProvider = new TestableHashingLocalFlagsProvider(config, SDK_VERSION, eventSender);
+ hashingProvider.setMockResponse("/flags/definitions", response);
+ hashingProvider.startPollingForDefinitions();
+
+ // Evaluate the flag
+ Map context = buildContext("user-789");
+ hashingProvider.getVariantValue("test-flag", "fallback", context);
+
+ // Verify hash calls - should have 2 rollout hash calls with indices 0 and 1
+ List hashCalls = hashingProvider.getHashCalls();
+
+ // Find all rollout hash calls
+ List rolloutCalls = new ArrayList<>();
+ for (TestableHashingLocalFlagsProvider.HashCall call : hashCalls) {
+ if ("rollout".equals(call.type)) {
+ rolloutCalls.add(call);
+ }
+ }
+
+ // Should have evaluated both rollouts
+ assertEquals("Should have made 2 rollout hash calls", 2, rolloutCalls.size());
+
+ // Verify the first rollout hash call uses index 0
+ TestableHashingLocalFlagsProvider.HashCall firstRolloutCall = rolloutCalls.get(0);
+ assertEquals("First rollout should use index 0", Integer.valueOf(0), firstRolloutCall.rolloutIndex);
+ assertEquals("First rollout hash salt should be hashSalt + 0", hashSalt + "0", firstRolloutCall.hashSalt);
+
+ // Verify the second rollout hash call uses index 1
+ TestableHashingLocalFlagsProvider.HashCall secondRolloutCall = rolloutCalls.get(1);
+ assertEquals("Second rollout should use index 1", Integer.valueOf(1), secondRolloutCall.rolloutIndex);
+ assertEquals("Second rollout hash salt should be hashSalt + 1", hashSalt + "1", secondRolloutCall.hashSalt);
+ }
+
+ @Test
+ public void testLegacyFlagsWithoutHashSaltUseRolloutSalt() {
+ // Create a flag WITHOUT hash_salt (legacy behavior)
+ List variants = Arrays.asList(
+ new Variant("control", false, true, 0.5f),
+ new Variant("treatment", true, false, 0.5f)
+ );
+
+ List rollouts = Arrays.asList(new Rollout(1.0f, null, null, null)); // 100% rollout
+
+ // Create flag definition WITHOUT hash_salt (null)
+ FlagDefinition flagDef = new FlagDefinition("test-flag", "distinct_id", variants, rollouts, null, null, null, null);
+
+ JSONObject root = new JSONObject();
+ JSONArray flagsArray = new JSONArray();
+ flagsArray.put(buildFlagJsonObject(flagDef, "flag-1"));
+ root.put("flags", flagsArray);
+ String response = root.toString();
+
+ TestableHashingLocalFlagsProvider hashingProvider = new TestableHashingLocalFlagsProvider(config, SDK_VERSION, eventSender);
+ hashingProvider.setMockResponse("/flags/definitions", response);
+ hashingProvider.startPollingForDefinitions();
+
+ // Evaluate the flag
+ Map context = buildContext("user-legacy");
+ hashingProvider.getVariantValue("test-flag", "fallback", context);
+
+ // Verify hash calls use legacy "rollout" salt
+ List hashCalls = hashingProvider.getHashCalls();
+
+ // Find rollout hash call
+ TestableHashingLocalFlagsProvider.HashCall rolloutHashCall = null;
+ for (TestableHashingLocalFlagsProvider.HashCall call : hashCalls) {
+ if ("rollout".equals(call.type)) {
+ rolloutHashCall = call;
+ break;
+ }
+ }
+
+ assertNotNull("Should have called calculateRolloutHash", rolloutHashCall);
+ assertEquals("Legacy rollout hash should use 'rollout' salt", "rollout", rolloutHashCall.hashSalt);
+ }
+
+ @Test
+ public void testLegacyFlagsWithoutHashSaltUseVariantSalt() {
+ // Create a flag WITHOUT hash_salt (legacy behavior)
+ List variants = Arrays.asList(
+ new Variant("control", "blue", true, 0.5f),
+ new Variant("treatment", "red", false, 0.5f)
+ );
+
+ List rollouts = Arrays.asList(new Rollout(1.0f, null, null, null)); // 100% rollout
+
+ // Create flag definition WITHOUT hash_salt (null)
+ FlagDefinition flagDef = new FlagDefinition("test-flag", "distinct_id", variants, rollouts, null, null, null, null);
+
+ JSONObject root = new JSONObject();
+ JSONArray flagsArray = new JSONArray();
+ flagsArray.put(buildFlagJsonObject(flagDef, "flag-1"));
+ root.put("flags", flagsArray);
+ String response = root.toString();
+
+ TestableHashingLocalFlagsProvider hashingProvider = new TestableHashingLocalFlagsProvider(config, SDK_VERSION, eventSender);
+ hashingProvider.setMockResponse("/flags/definitions", response);
+ hashingProvider.startPollingForDefinitions();
+
+ // Evaluate the flag
+ Map context = buildContext("user-legacy-variant");
+ hashingProvider.getVariantValue("test-flag", "fallback", context);
+
+ // Verify hash calls use legacy "variant" salt
+ List hashCalls = hashingProvider.getHashCalls();
+
+ // Find variant hash call
+ TestableHashingLocalFlagsProvider.HashCall variantHashCall = null;
+ for (TestableHashingLocalFlagsProvider.HashCall call : hashCalls) {
+ if ("variant".equals(call.type)) {
+ variantHashCall = call;
+ break;
+ }
+ }
+
+ assertNotNull("Should have called calculateVariantHash", variantHashCall);
+ assertEquals("Legacy variant hash should use 'variant' salt", "variant", variantHashCall.hashSalt);
+ }
+
+ // #endregion
+
// #endregion
}