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.mixpanel mixpanel-java - 1.5.4 + 1.6.0-flags jar mixpanel-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 variant = mixpanel.getLocalFlags().getVariant( + "recommendation-algorithm", + new SelectedVariant<>("default-algorithm"), // fallback + context1 + ); + + if (variant.isSuccess()) { + System.out.println("Variant key: " + variant.getVariantKey()); + System.out.println("Variant value: " + variant.getVariantValue()); + } else { + System.out.println("Using fallback variant"); + } + + // Example 5: Number variant + System.out.println("\n=== Example 5: Number Variant ==="); + Integer maxItems = mixpanel.getLocalFlags().getVariantValue( + "max-cart-items", + 10, // fallback value + context1 + ); + + System.out.println("Max cart items: " + maxItems); + + System.out.println("\n=== Example Complete ==="); + System.out.println("MixpanelAPI will be automatically closed"); + + // 4. Properly clean up resources + mixpanel.close(); + + } + + System.out.println("Resources cleaned up successfully"); + } +} diff --git a/src/demo/java/com/mixpanel/mixpanelapi/featureflags/demo/RemoteEvaluationExample.java b/src/demo/java/com/mixpanel/mixpanelapi/featureflags/demo/RemoteEvaluationExample.java new file mode 100644 index 0000000..0e85276 --- /dev/null +++ b/src/demo/java/com/mixpanel/mixpanelapi/featureflags/demo/RemoteEvaluationExample.java @@ -0,0 +1,125 @@ +package com.mixpanel.mixpanelapi.featureflags.demo; + +import com.mixpanel.mixpanelapi.MixpanelAPI; +import com.mixpanel.mixpanelapi.featureflags.config.RemoteFlagsConfig; +import com.mixpanel.mixpanelapi.featureflags.model.SelectedVariant; + +import java.util.HashMap; +import java.util.Map; + +/** + * Example demonstrating remote feature flag evaluation. + * + * Remote evaluation makes an API call for each flag check, providing + * real-time flag updates but with higher latency. + */ +public class RemoteEvaluationExample { + + public static void main(String[] args) { + // Replace with your actual Mixpanel project token + String projectToken = "YOUR_PROJECT_TOKEN"; + + // 1. Configure remote evaluation + RemoteFlagsConfig config = RemoteFlagsConfig.builder() + .projectToken(projectToken) + .apiHost("api.mixpanel.com") // Use "api-eu.mixpanel.com" for EU + .requestTimeoutSeconds(5) // 5 second timeout + .build(); + + // 2. Create MixpanelAPI with flags support + try (MixpanelAPI mixpanel = new MixpanelAPI(config)) { + + System.out.println("Remote flags initialized"); + + // 3. Example 1: Simple flag check + System.out.println("\n=== Example 1: Simple Flag Check ==="); + Map context1 = new HashMap<>(); + context1.put("distinct_id", "user-789"); + + // Each call makes an API request + boolean featureEnabled = mixpanel.getRemoteFlags().isEnabled( + "experimental-feature", + context1 + ); + + System.out.println("Feature enabled: " + featureEnabled); + + // 4. Example 2: Admin access check with targeting + System.out.println("\n=== Example 2: Admin Access Check ==="); + Map adminContext = new HashMap<>(); + adminContext.put("distinct_id", "admin-user-1"); + + Map customProps = new HashMap<>(); + customProps.put("role", "admin"); + customProps.put("department", "engineering"); + adminContext.put("custom_properties", customProps); + + boolean hasAdminAccess = mixpanel.getRemoteFlags().isEnabled( + "admin-panel-access", + adminContext + ); + + System.out.println("Admin access granted: " + hasAdminAccess); + + // 5. Example 3: Get variant value for A/B test + System.out.println("\n=== Example 3: A/B Test Variant ==="); + Map context2 = new HashMap<>(); + context2.put("distinct_id", "user-456"); + + String landingPageVariant = mixpanel.getRemoteFlags().getVariantValue( + "landing-page-test", + "control", // fallback to control variant + context2 + ); + + System.out.println("Landing page variant: " + landingPageVariant); + + // 6. Example 4: Full variant information + System.out.println("\n=== Example 4: Full Variant Info ==="); + SelectedVariant variant = mixpanel.getRemoteFlags().getVariant( + "pricing-tier-experiment", + new SelectedVariant<>(null), + context1 + ); + + if (variant.isSuccess()) { + System.out.println("Assigned to variant: " + variant.getVariantKey()); + System.out.println("Pricing tier: " + variant.getVariantValue()); + } else { + System.out.println("Using default pricing"); + } + + // 7. Example 5: Dynamic configuration value + System.out.println("\n=== Example 5: Dynamic Config ==="); + Integer apiRateLimit = mixpanel.getRemoteFlags().getVariantValue( + "api-rate-limit", + 1000, // default rate limit + context1 + ); + + System.out.println("API rate limit: " + apiRateLimit + " requests/hour"); + + // 8. Example 6: Batch checking multiple users + System.out.println("\n=== Example 6: Check Multiple Users ==="); + for (int i = 0; i < 3; i++) { + Map userContext = new HashMap<>(); + userContext.put("distinct_id", "user-beta-" + i); + + boolean betaAccess = mixpanel.getRemoteFlags().isEnabled( + "beta-program", + userContext + ); + + System.out.println("User beta-" + i + " has beta access: " + betaAccess); + } + + System.out.println("\n=== Example Complete ==="); + + } catch (Exception e) { + System.err.println("Error: " + e.getMessage()); + e.printStackTrace(); + } + + System.out.println("Resources cleaned up successfully"); + } +} diff --git a/src/main/java/com/mixpanel/mixpanelapi/MixpanelAPI.java b/src/main/java/com/mixpanel/mixpanelapi/MixpanelAPI.java index e1734a4..97e648d 100644 --- a/src/main/java/com/mixpanel/mixpanelapi/MixpanelAPI.java +++ b/src/main/java/com/mixpanel/mixpanelapi/MixpanelAPI.java @@ -15,6 +15,14 @@ import org.json.JSONException; import org.json.JSONObject; +import com.mixpanel.mixpanelapi.featureflags.EventSender; +import com.mixpanel.mixpanelapi.featureflags.config.BaseFlagsConfig; +import com.mixpanel.mixpanelapi.featureflags.config.LocalFlagsConfig; +import com.mixpanel.mixpanelapi.featureflags.config.RemoteFlagsConfig; +import com.mixpanel.mixpanelapi.featureflags.provider.LocalFlagsProvider; +import com.mixpanel.mixpanelapi.featureflags.provider.RemoteFlagsProvider; +import com.mixpanel.mixpanelapi.featureflags.util.VersionUtil; + /** * Simple interface to the Mixpanel tracking API, intended for use in * server-side applications. Users are encouraged to review our Javascript @@ -26,7 +34,7 @@ * * */ -public class MixpanelAPI { +public class MixpanelAPI implements AutoCloseable { private static final int BUFFER_SIZE = 256; // Small, we expect small responses. @@ -38,6 +46,8 @@ public class MixpanelAPI { protected final String mGroupsEndpoint; protected final String mImportEndpoint; protected final boolean mUseGzipCompression; + protected final LocalFlagsProvider mLocalFlags; + protected final RemoteFlagsProvider mRemoteFlags; /** * Constructs a MixpanelAPI object associated with the production, Mixpanel services. @@ -52,7 +62,53 @@ public MixpanelAPI() { * @param useGzipCompression whether to use gzip compression for network requests */ public MixpanelAPI(boolean useGzipCompression) { - this(Config.BASE_ENDPOINT + "/track", Config.BASE_ENDPOINT + "/engage", Config.BASE_ENDPOINT + "/groups", Config.BASE_ENDPOINT + "/import", useGzipCompression); + this(Config.BASE_ENDPOINT + "/track", Config.BASE_ENDPOINT + "/engage", Config.BASE_ENDPOINT + "/groups", Config.BASE_ENDPOINT + "/import", useGzipCompression, null, null); + } + + /** + * Constructs a MixpanelAPI object with local feature flags evaluation. + * + * @param localFlagsConfig configuration for local feature flags evaluation + */ + public MixpanelAPI(LocalFlagsConfig localFlagsConfig) { + this(localFlagsConfig, null); + } + + /** + * Constructs a MixpanelAPI object with remote feature flags evaluation. + * + * @param remoteFlagsConfig configuration for remote feature flags evaluation + */ + public MixpanelAPI(RemoteFlagsConfig remoteFlagsConfig) { + this(null, remoteFlagsConfig); + } + + /** + * Private constructor for feature flags configurations. + * Initializes with default endpoints and no gzip compression. + * + * @param localFlagsConfig configuration for local feature flags evaluation (can be null) + * @param remoteFlagsConfig configuration for remote feature flags evaluation (can be null) + */ + private MixpanelAPI(LocalFlagsConfig localFlagsConfig, RemoteFlagsConfig remoteFlagsConfig) { + mEventsEndpoint = Config.BASE_ENDPOINT + "/track"; + mPeopleEndpoint = Config.BASE_ENDPOINT + "/engage"; + mGroupsEndpoint = Config.BASE_ENDPOINT + "/groups"; + mImportEndpoint = Config.BASE_ENDPOINT + "/import"; + mUseGzipCompression = false; + + if (localFlagsConfig != null) { + EventSender eventSender = createEventSender(localFlagsConfig, this); + mLocalFlags = new LocalFlagsProvider(localFlagsConfig, VersionUtil.getVersion(), eventSender); + mRemoteFlags = null; + } else if (remoteFlagsConfig != null) { + EventSender eventSender = createEventSender(remoteFlagsConfig, this); + mLocalFlags = null; + mRemoteFlags = new RemoteFlagsProvider(remoteFlagsConfig, VersionUtil.getVersion(), eventSender); + } else { + mLocalFlags = null; + mRemoteFlags = null; + } } /** @@ -65,7 +121,7 @@ public MixpanelAPI(boolean useGzipCompression) { * @see #MixpanelAPI() */ public MixpanelAPI(String eventsEndpoint, String peopleEndpoint) { - this(eventsEndpoint, peopleEndpoint, Config.BASE_ENDPOINT + "/groups", Config.BASE_ENDPOINT + "/import", false); + this(eventsEndpoint, peopleEndpoint, Config.BASE_ENDPOINT + "/groups", Config.BASE_ENDPOINT + "/import", false, null, null); } /** @@ -79,7 +135,7 @@ public MixpanelAPI(String eventsEndpoint, String peopleEndpoint) { * @see #MixpanelAPI() */ public MixpanelAPI(String eventsEndpoint, String peopleEndpoint, String groupsEndpoint) { - this(eventsEndpoint, peopleEndpoint, groupsEndpoint, Config.BASE_ENDPOINT + "/import", false); + this(eventsEndpoint, peopleEndpoint, groupsEndpoint, Config.BASE_ENDPOINT + "/import", false, null, null); } /** @@ -94,7 +150,7 @@ public MixpanelAPI(String eventsEndpoint, String peopleEndpoint, String groupsEn * @see #MixpanelAPI() */ public MixpanelAPI(String eventsEndpoint, String peopleEndpoint, String groupsEndpoint, String importEndpoint) { - this(eventsEndpoint, peopleEndpoint, groupsEndpoint, importEndpoint, false); + this(eventsEndpoint, peopleEndpoint, groupsEndpoint, importEndpoint, false, null, null); } /** @@ -110,11 +166,28 @@ public MixpanelAPI(String eventsEndpoint, String peopleEndpoint, String groupsEn * @see #MixpanelAPI() */ public MixpanelAPI(String eventsEndpoint, String peopleEndpoint, String groupsEndpoint, String importEndpoint, boolean useGzipCompression) { + this(eventsEndpoint, peopleEndpoint, groupsEndpoint, importEndpoint, useGzipCompression, null, null); + } + + /** + * Main constructor used by all other constructors. + * + * @param eventsEndpoint a URL that will accept Mixpanel events messages + * @param peopleEndpoint a URL that will accept Mixpanel people messages + * @param groupsEndpoint a URL that will accept Mixpanel groups messages + * @param importEndpoint a URL that will accept Mixpanel import messages + * @param useGzipCompression whether to use gzip compression for network requests + * @param localFlags optional LocalFlagsProvider for local feature flags (can be null) + * @param remoteFlags optional RemoteFlagsProvider for remote feature flags (can be null) + */ + private MixpanelAPI(String eventsEndpoint, String peopleEndpoint, String groupsEndpoint, String importEndpoint, boolean useGzipCompression, LocalFlagsProvider localFlags, RemoteFlagsProvider remoteFlags) { mEventsEndpoint = eventsEndpoint; mPeopleEndpoint = peopleEndpoint; mGroupsEndpoint = groupsEndpoint; mImportEndpoint = importEndpoint; mUseGzipCompression = useGzipCompression; + mLocalFlags = localFlags; + mRemoteFlags = remoteFlags; } /** @@ -213,10 +286,10 @@ protected String encodeDataString(String dataString) { // Use gzip compression conn.setRequestProperty("Content-Type", "application/x-www-form-urlencoded;charset=utf8"); conn.setRequestProperty("Content-Encoding", "gzip"); - + String encodedData = encodeDataString(dataString); String encodedQuery = "data=" + encodedData; - + // Compress the data java.io.ByteArrayOutputStream byteStream = new java.io.ByteArrayOutputStream(); GZIPOutputStream gzipStream = null; @@ -357,7 +430,7 @@ private String dataString(List messages) { conn.setDoOutput(true); conn.setRequestMethod("POST"); conn.setRequestProperty("Content-Type", "application/json"); - + // Add Basic Auth header: username is token, password is empty try { String authString = token + ":"; @@ -372,7 +445,7 @@ private String dataString(List messages) { if (mUseGzipCompression) { // Use gzip compression conn.setRequestProperty("Content-Encoding", "gzip"); - + // Compress the data java.io.ByteArrayOutputStream byteStream = new java.io.ByteArrayOutputStream(); GZIPOutputStream gzipStream = null; @@ -444,15 +517,15 @@ private String dataString(List messages) { if (response == null) { return false; } - + // Parse JSON response try { JSONObject jsonResponse = new JSONObject(response); - + // Check for {"status":"OK"} and {"code":200} boolean statusOk = jsonResponse.has("status") && "OK".equals(jsonResponse.getString("status")); boolean codeOk = jsonResponse.has("code") && jsonResponse.getInt("code") == 200; - + return statusOk && codeOk; } catch (JSONException e) { // Not valid JSON or missing expected fields @@ -476,4 +549,50 @@ private String slurp(InputStream in) throws IOException { return out.toString(); } + /** + * Gets the local flags provider for evaluating feature flags locally. + * + * @return the LocalFlagsProvider, or null if not configured + */ + public LocalFlagsProvider getLocalFlags() { + return mLocalFlags; + } + + /** + * Gets the remote flags provider for evaluating feature flags remotely. + * + * @return the RemoteFlagsProvider, or null if not configured + */ + public RemoteFlagsProvider getRemoteFlags() { + return mRemoteFlags; + } + + /** + * Creates an EventSender that uses the provided MixpanelAPI instance for sending events. + * This is shared by both local and remote flag evaluation modes. + */ + private static EventSender createEventSender(BaseFlagsConfig config, MixpanelAPI api) { + final MessageBuilder builder = new MessageBuilder(config.getProjectToken()); + + return (distinctId, eventName, properties) -> { + try { + JSONObject event = builder.event(distinctId, eventName, properties); + api.sendMessage(event); + } catch (IOException e) { + // Silently fail - exposure tracking should not break flag evaluation + } + }; + } + + /** + * Closes this MixpanelAPI instance and releases any resources held by the flags providers. + * This method should be called when the MixpanelAPI instance is no longer needed. + */ + @Override + public void close() { + if (mLocalFlags != null) { + mLocalFlags.close(); + } + } + } diff --git a/src/main/java/com/mixpanel/mixpanelapi/featureflags/EventSender.java b/src/main/java/com/mixpanel/mixpanelapi/featureflags/EventSender.java new file mode 100644 index 0000000..cd21e9e --- /dev/null +++ b/src/main/java/com/mixpanel/mixpanelapi/featureflags/EventSender.java @@ -0,0 +1,22 @@ +package com.mixpanel.mixpanelapi.featureflags; + +import org.json.JSONObject; + +/** + * Interface for sending events to an analytics backend. + *

+ * Implementations are responsible for constructing the event payload + * and delivering it to the appropriate destination. + *

+ */ +@FunctionalInterface +public interface EventSender { + /** + * Sends an event with the specified properties. + * + * @param distinctId the user's distinct ID + * @param eventName the name of the event (e.g., "$experiment_started") + * @param properties the event properties as a JSONObject + */ + void sendEvent(String distinctId, String eventName, JSONObject properties); +} diff --git a/src/main/java/com/mixpanel/mixpanelapi/featureflags/config/BaseFlagsConfig.java b/src/main/java/com/mixpanel/mixpanelapi/featureflags/config/BaseFlagsConfig.java new file mode 100644 index 0000000..3d7af70 --- /dev/null +++ b/src/main/java/com/mixpanel/mixpanelapi/featureflags/config/BaseFlagsConfig.java @@ -0,0 +1,110 @@ +package com.mixpanel.mixpanelapi.featureflags.config; + +/** + * Base configuration for feature flags providers. + *

+ * Contains common configuration settings shared by both local and remote evaluation modes. + *

+ */ +public class BaseFlagsConfig { + private final String projectToken; + private final String apiHost; + private final int requestTimeoutSeconds; + + /** + * Creates a new BaseFlagsConfig with specified settings. + * + * @param projectToken the Mixpanel project token + * @param apiHost the API endpoint host + * @param requestTimeoutSeconds HTTP request timeout in seconds + */ + protected BaseFlagsConfig(String projectToken, String apiHost, int requestTimeoutSeconds) { + this.projectToken = projectToken; + this.apiHost = apiHost; + this.requestTimeoutSeconds = requestTimeoutSeconds; + } + + /** + * @return the Mixpanel project token + */ + public String getProjectToken() { + return projectToken; + } + + /** + * @return the API endpoint host + */ + public String getApiHost() { + return apiHost; + } + + /** + * @return the HTTP request timeout in seconds + */ + public int getRequestTimeoutSeconds() { + return requestTimeoutSeconds; + } + + /** + * Builder for BaseFlagsConfig. + * + * @param the type of builder (for subclass builders) + */ + @SuppressWarnings("unchecked") + public static class Builder> { + protected String projectToken; + protected String apiHost = "api.mixpanel.com"; + protected int requestTimeoutSeconds = 10; + + /** + * Sets the project token. + * + * @param projectToken the Mixpanel project token + * @return this builder + */ + public T projectToken(String projectToken) { + this.projectToken = projectToken; + return (T) this; + } + + /** + * Sets the API host. + * + * @param apiHost the API endpoint host (e.g., "api.mixpanel.com", "api-eu.mixpanel.com") + * @return this builder + */ + public T apiHost(String apiHost) { + this.apiHost = apiHost; + return (T) this; + } + + /** + * Sets the request timeout. + * + * @param requestTimeoutSeconds HTTP request timeout in seconds + * @return this builder + */ + public T requestTimeoutSeconds(int requestTimeoutSeconds) { + this.requestTimeoutSeconds = requestTimeoutSeconds; + return (T) this; + } + + /** + * Builds the BaseFlagsConfig instance. + * + * @return a new BaseFlagsConfig + */ + public BaseFlagsConfig build() { + return new BaseFlagsConfig(projectToken, apiHost, requestTimeoutSeconds); + } + } + + /** + * Creates a new builder for BaseFlagsConfig. + * + * @return a new builder instance + */ + public static Builder builder() { + return new Builder<>(); + } +} diff --git a/src/main/java/com/mixpanel/mixpanelapi/featureflags/config/LocalFlagsConfig.java b/src/main/java/com/mixpanel/mixpanelapi/featureflags/config/LocalFlagsConfig.java new file mode 100644 index 0000000..6a03ca2 --- /dev/null +++ b/src/main/java/com/mixpanel/mixpanelapi/featureflags/config/LocalFlagsConfig.java @@ -0,0 +1,91 @@ +package com.mixpanel.mixpanelapi.featureflags.config; + +/** + * Configuration for local feature flags evaluation. + *

+ * Extends {@link BaseFlagsConfig} with settings specific to local evaluation mode, + * including polling configuration for periodic flag definition synchronization. + *

+ */ +public final class LocalFlagsConfig extends BaseFlagsConfig { + private final boolean enablePolling; + private final int pollingIntervalSeconds; + + /** + * Creates a new LocalFlagsConfig with all settings. + * + * @param projectToken the Mixpanel project token + * @param apiHost the API endpoint host + * @param requestTimeoutSeconds HTTP request timeout in seconds + * @param enablePolling whether to periodically refresh flag definitions + * @param pollingIntervalSeconds time between refresh cycles in seconds + */ + private LocalFlagsConfig(String projectToken, String apiHost, int requestTimeoutSeconds, boolean enablePolling, int pollingIntervalSeconds) { + super(projectToken, apiHost, requestTimeoutSeconds); + this.enablePolling = enablePolling; + this.pollingIntervalSeconds = pollingIntervalSeconds; + } + + /** + * @return true if polling is enabled + */ + public boolean isEnablePolling() { + return enablePolling; + } + + /** + * @return the polling interval in seconds + */ + public int getPollingIntervalSeconds() { + return pollingIntervalSeconds; + } + + /** + * Builder for LocalFlagsConfig. + */ + public static final class Builder extends BaseFlagsConfig.Builder { + private boolean enablePolling = true; + private int pollingIntervalSeconds = 60; + + /** + * Sets whether polling should be enabled. + * + * @param enablePolling true to enable periodic flag definition refresh + * @return this builder + */ + public Builder enablePolling(boolean enablePolling) { + this.enablePolling = enablePolling; + return this; + } + + /** + * Sets the polling interval. + * + * @param pollingIntervalSeconds time between refresh cycles in seconds + * @return this builder + */ + public Builder pollingIntervalSeconds(int pollingIntervalSeconds) { + this.pollingIntervalSeconds = pollingIntervalSeconds; + return this; + } + + /** + * Builds the LocalFlagsConfig instance. + * + * @return a new LocalFlagsConfig + */ + @Override + public LocalFlagsConfig build() { + return new LocalFlagsConfig(projectToken, apiHost, requestTimeoutSeconds, enablePolling, pollingIntervalSeconds); + } + } + + /** + * Creates a new builder for LocalFlagsConfig. + * + * @return a new builder instance + */ + public static Builder builder() { + return new Builder(); + } +} diff --git a/src/main/java/com/mixpanel/mixpanelapi/featureflags/config/RemoteFlagsConfig.java b/src/main/java/com/mixpanel/mixpanelapi/featureflags/config/RemoteFlagsConfig.java new file mode 100644 index 0000000..0c65699 --- /dev/null +++ b/src/main/java/com/mixpanel/mixpanelapi/featureflags/config/RemoteFlagsConfig.java @@ -0,0 +1,47 @@ +package com.mixpanel.mixpanelapi.featureflags.config; + +/** + * Configuration for remote feature flags evaluation. + *

+ * Extends {@link BaseFlagsConfig} with settings specific to remote evaluation mode. + * Currently contains no additional configuration beyond the base settings. + *

+ */ +public final class RemoteFlagsConfig extends BaseFlagsConfig { + + /** + * Creates a new RemoteFlagsConfig with specified settings. + * + * @param projectToken the Mixpanel project token + * @param apiHost the API endpoint host + * @param requestTimeoutSeconds HTTP request timeout in seconds + */ + private RemoteFlagsConfig(String projectToken, String apiHost, int requestTimeoutSeconds) { + super(projectToken, apiHost, requestTimeoutSeconds); + } + + /** + * Builder for RemoteFlagsConfig. + */ + public static final class Builder extends BaseFlagsConfig.Builder { + + /** + * Builds the RemoteFlagsConfig instance. + * + * @return a new RemoteFlagsConfig + */ + @Override + public RemoteFlagsConfig build() { + return new RemoteFlagsConfig(projectToken, apiHost, requestTimeoutSeconds); + } + } + + /** + * Creates a new builder for RemoteFlagsConfig. + * + * @return a new builder instance + */ + public static Builder builder() { + return new Builder(); + } +} diff --git a/src/main/java/com/mixpanel/mixpanelapi/featureflags/model/ExperimentationFlag.java b/src/main/java/com/mixpanel/mixpanelapi/featureflags/model/ExperimentationFlag.java new file mode 100644 index 0000000..e73ccf5 --- /dev/null +++ b/src/main/java/com/mixpanel/mixpanelapi/featureflags/model/ExperimentationFlag.java @@ -0,0 +1,128 @@ +package com.mixpanel.mixpanelapi.featureflags.model; + +import java.util.UUID; + +/** + * Represents a complete feature flag definition. + *

+ * An experimentation flag contains metadata (id, name, key, status, project) + * and the ruleset that defines how variants are assigned to users. + *

+ *

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

+ */ +public final class ExperimentationFlag { + private final String id; + private final String name; + private final String key; + private final String status; + private final int projectId; + private final RuleSet ruleset; + private final String context; + private final UUID experimentId; + private final Boolean isExperimentActive; + + /** + * Creates a new ExperimentationFlag. + * + * @param id the unique identifier for this flag + * @param name the human-readable name of this flag + * @param key the key used to reference this flag in code + * @param status the current status of this flag + * @param projectId the Mixpanel project ID this flag belongs to + * @param ruleset the ruleset defining variant assignment logic + * @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) + */ + public ExperimentationFlag(String id, String name, String key, String status, int projectId, RuleSet ruleset, String context, UUID experimentId, Boolean isExperimentActive) { + this.id = id; + this.name = name; + this.key = key; + this.status = status; + this.projectId = projectId; + this.ruleset = ruleset; + this.context = context; + this.experimentId = experimentId; + this.isExperimentActive = isExperimentActive; + } + + /** + * @return the unique identifier for this flag + */ + public String getId() { + return id; + } + + /** + * @return the human-readable name + */ + public String getName() { + return name; + } + + /** + * @return the key used to reference this flag + */ + public String getKey() { + return key; + } + + /** + * @return the current status + */ + public String getStatus() { + return status; + } + + /** + * @return the project ID + */ + public int getProjectId() { + return projectId; + } + + /** + * @return the ruleset defining variant assignment + */ + public RuleSet getRuleset() { + return ruleset; + } + + /** + * @return the property name used for rollout hashing (e.g., "distinct_id") + */ + public String getContext() { + return context; + } + + /** + * @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 + */ + public Boolean getIsExperimentActive() { + return isExperimentActive; + } + + @Override + public String toString() { + return "ExperimentationFlag{" + + "id=" + id + + ", name='" + name + '\'' + + ", key='" + key + '\'' + + ", status=" + status + + ", projectId=" + projectId + + ", ruleset=" + ruleset + + ", context='" + context + '\'' + + ", experimentId=" + experimentId + + ", isExperimentActive=" + isExperimentActive + + '}'; + } +} diff --git a/src/main/java/com/mixpanel/mixpanelapi/featureflags/model/Rollout.java b/src/main/java/com/mixpanel/mixpanelapi/featureflags/model/Rollout.java new file mode 100644 index 0000000..ed4ba32 --- /dev/null +++ b/src/main/java/com/mixpanel/mixpanelapi/featureflags/model/Rollout.java @@ -0,0 +1,108 @@ +package com.mixpanel.mixpanelapi.featureflags.model; + +import java.util.Collections; +import java.util.Map; + +/** + * Represents a rollout rule within a feature flag experiment. + *

+ * A rollout defines the percentage of users that should receive this experiment, + * optional runtime evaluation criteria, and an optional variant override. + *

+ *

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

+ */ +public final class Rollout { + private final float rolloutPercentage; + private final Map runtimeEvaluationDefinition; + private final VariantOverride variantOverride; + private final Map 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 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 runtimeEvaluationDefinition, VariantOverride variantOverride, Map variantSplits) { + this.rolloutPercentage = rolloutPercentage; + this.runtimeEvaluationDefinition = runtimeEvaluationDefinition != null + ? Collections.unmodifiableMap(runtimeEvaluationDefinition) + : null; + this.variantOverride = variantOverride; + this.variantSplits = variantSplits != null + ? Collections.unmodifiableMap(variantSplits) + : null; + } + + /** + * Creates a new Rollout without runtime evaluation or variant override. + * + * @param rolloutPercentage the percentage of users to include (0.0-1.0) + */ + public Rollout(float rolloutPercentage) { + this(rolloutPercentage, null, null, null); + } + + /** + * @return the percentage of users to include in this rollout (0.0-1.0) + */ + public float getRolloutPercentage() { + return rolloutPercentage; + } + + /** + * @return optional map of property name to expected value for runtime evaluation, or null if not set + */ + public Map getRuntimeEvaluationDefinition() { + return runtimeEvaluationDefinition; + } + + /** + * @return optional variant override to force selection, or null if not set + */ + public VariantOverride getVariantOverride() { + return variantOverride; + } + + /** + * @return optional map of variant key to split percentage at assignment group level, or null if not set + */ + public Map getVariantSplits() { + return variantSplits; + } + + /** + * @return true if this rollout has runtime evaluation criteria + */ + public boolean hasRuntimeEvaluation() { + return runtimeEvaluationDefinition != null && !runtimeEvaluationDefinition.isEmpty(); + } + + /** + * @return true if this rollout has a variant override + */ + public boolean hasVariantOverride() { + return variantOverride != null; + } + + /** + * @return true if this rollout has variant splits + */ + public boolean hasVariantSplits() { + return variantSplits != null && !variantSplits.isEmpty(); + } + + @Override + public String toString() { + return "Rollout{" + + "rolloutPercentage=" + rolloutPercentage + + ", runtimeEvaluationDefinition=" + runtimeEvaluationDefinition + + ", variantOverride='" + variantOverride + '\'' + + ", variantSplits=" + variantSplits + + '}'; + } +} diff --git a/src/main/java/com/mixpanel/mixpanelapi/featureflags/model/RuleSet.java b/src/main/java/com/mixpanel/mixpanelapi/featureflags/model/RuleSet.java new file mode 100644 index 0000000..168ae20 --- /dev/null +++ b/src/main/java/com/mixpanel/mixpanelapi/featureflags/model/RuleSet.java @@ -0,0 +1,83 @@ +package com.mixpanel.mixpanelapi.featureflags.model; + +import java.util.Collections; +import java.util.List; +import java.util.Map; + +/** + * Represents the complete set of rules for a feature flag experiment. + *

+ * A ruleset contains all variants available for the flag, rollout rules + * (evaluated in order), and optional test user overrides. + *

+ *

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

+ */ +public final class RuleSet { + private final List variants; + private final List rollouts; + private final Map testUserOverrides; + + /** + * Creates a new RuleSet with all components. + * + * @param variants the list of available variants for this flag + * @param rollouts the list of rollout rules (evaluated in order) + * @param testUserOverrides optional map of distinct_id to variant key for test users + */ + public RuleSet(List variants, List rollouts, Map testUserOverrides) { + this.variants = variants != null ? Collections.unmodifiableList(variants) : Collections.emptyList(); + this.rollouts = rollouts != null ? Collections.unmodifiableList(rollouts) : Collections.emptyList(); + this.testUserOverrides = testUserOverrides != null + ? Collections.unmodifiableMap(testUserOverrides) + : null; + } + + /** + * Creates a new RuleSet without test user overrides. + * + * @param variants the list of available variants for this flag + * @param rollouts the list of rollout rules (evaluated in order) + */ + public RuleSet(List variants, List rollouts) { + this(variants, rollouts, null); + } + + /** + * @return the list of available variants + */ + public List getVariants() { + return variants; + } + + /** + * @return the list of rollout rules + */ + public List getRollouts() { + return rollouts; + } + + /** + * @return the map of test user overrides (distinct_id to variant key), or null if not set + */ + public Map getTestUserOverrides() { + return testUserOverrides; + } + + /** + * @return true if test user overrides are configured + */ + public boolean hasTestUserOverrides() { + return testUserOverrides != null && !testUserOverrides.isEmpty(); + } + + @Override + public String toString() { + return "RuleSet{" + + "variants=" + variants + + ", rollouts=" + rollouts + + ", testUserOverrides=" + testUserOverrides + + '}'; + } +} diff --git a/src/main/java/com/mixpanel/mixpanelapi/featureflags/model/SelectedVariant.java b/src/main/java/com/mixpanel/mixpanelapi/featureflags/model/SelectedVariant.java new file mode 100644 index 0000000..4a41830 --- /dev/null +++ b/src/main/java/com/mixpanel/mixpanelapi/featureflags/model/SelectedVariant.java @@ -0,0 +1,124 @@ +package com.mixpanel.mixpanelapi.featureflags.model; + +import java.util.UUID; + +/** + * 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. + *

+ * + * @param the type of the variant value + */ +public final class SelectedVariant { + private final String variantKey; + private final T variantValue; + private final UUID experimentId; + private final Boolean isExperimentActive; + private final Boolean isQaTester; + + /** + * Creates a SelectedVariant with only a value (key is null). + * This is typically used for fallback responses. + * + * @param variantValue the fallback value + */ + public SelectedVariant(T variantValue) { + this(null, variantValue, null, null, null); + } + + /** + * Creates a new SelectedVariant with experimentation metadata. + * + * @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) + */ + public SelectedVariant(String variantKey, T variantValue, UUID experimentId, Boolean isExperimentActive, Boolean isQaTester) { + this.variantKey = variantKey; + this.variantValue = variantValue; + this.experimentId = experimentId; + this.isExperimentActive = isExperimentActive; + this.isQaTester = isQaTester; + } + + /** + * @return the variant key, or null if this is a fallback + */ + public String getVariantKey() { + return variantKey; + } + + /** + * @return the variant value + */ + public T getVariantValue() { + return variantValue; + } + + /** + * @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 + */ + public Boolean getIsExperimentActive() { + return isExperimentActive; + } + + /** + * @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) + */ + public boolean isSuccess() { + return variantKey != null; + } + + /** + * @return true if this represents a fallback value + */ + public boolean isFallback() { + return variantKey == null; + } + + @Override + public String toString() { + return "SelectedVariant{" + + "variantKey='" + variantKey + '\'' + + ", variantValue=" + variantValue + + ", experimentId=" + experimentId + + ", isExperimentActive=" + isExperimentActive + + ", isQaTester=" + isQaTester + + '}'; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + + SelectedVariant that = (SelectedVariant) o; + + if (variantKey != null ? !variantKey.equals(that.variantKey) : that.variantKey != null) return false; + 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; + } +} diff --git a/src/main/java/com/mixpanel/mixpanelapi/featureflags/model/Variant.java b/src/main/java/com/mixpanel/mixpanelapi/featureflags/model/Variant.java new file mode 100644 index 0000000..b949992 --- /dev/null +++ b/src/main/java/com/mixpanel/mixpanelapi/featureflags/model/Variant.java @@ -0,0 +1,71 @@ +package com.mixpanel.mixpanelapi.featureflags.model; + +/** + * Represents a variant within a feature flag experiment. + *

+ * A variant defines a specific variation of a feature flag with its key, value, + * control status, and percentage split allocation. + *

+ *

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

+ */ +public final class Variant { + private final String key; + private final Object value; + private final boolean isControl; + private final float split; + + /** + * Creates a new Variant. + * + * @param key the unique identifier for this variant + * @param value the value associated with this variant (can be boolean, string, number, or JSON object) + * @param isControl whether this variant is the control variant + * @param split the percentage split allocation for this variant (0.0-1.0) + */ + public Variant(String key, Object value, boolean isControl, float split) { + this.key = key; + this.value = value; + this.isControl = isControl; + this.split = split; + } + + /** + * @return the unique identifier for this variant + */ + public String getKey() { + return key; + } + + /** + * @return the value associated with this variant + */ + public Object getValue() { + return value; + } + + /** + * @return true if this is the control variant + */ + public boolean isControl() { + return isControl; + } + + /** + * @return the percentage split allocation (0.0-1.0) + */ + public float getSplit() { + return split; + } + + @Override + public String toString() { + return "Variant{" + + "key='" + key + '\'' + + ", value=" + value + + ", isControl=" + isControl + + ", split=" + split + + '}'; + } +} diff --git a/src/main/java/com/mixpanel/mixpanelapi/featureflags/model/VariantOverride.java b/src/main/java/com/mixpanel/mixpanelapi/featureflags/model/VariantOverride.java new file mode 100644 index 0000000..c324db9 --- /dev/null +++ b/src/main/java/com/mixpanel/mixpanelapi/featureflags/model/VariantOverride.java @@ -0,0 +1,37 @@ +package com.mixpanel.mixpanelapi.featureflags.model; + +/** + * Represents a variant override within a rollout rule. + *

+ * A variant override forces selection of a specific variant when a rollout matches. + *

+ *

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

+ */ +public final class VariantOverride { + private final String key; + + /** + * Creates a new VariantOverride. + * + * @param key the variant key to force selection of + */ + public VariantOverride(String key) { + this.key = key; + } + + /** + * @return the variant key + */ + public String getKey() { + return key; + } + + @Override + public String toString() { + return "VariantOverride{" + + "key='" + key + '\'' + + '}'; + } +} diff --git a/src/main/java/com/mixpanel/mixpanelapi/featureflags/provider/BaseFlagsProvider.java b/src/main/java/com/mixpanel/mixpanelapi/featureflags/provider/BaseFlagsProvider.java new file mode 100644 index 0000000..b1b7825 --- /dev/null +++ b/src/main/java/com/mixpanel/mixpanelapi/featureflags/provider/BaseFlagsProvider.java @@ -0,0 +1,231 @@ +package com.mixpanel.mixpanelapi.featureflags.provider; + +import com.mixpanel.mixpanelapi.featureflags.EventSender; +import com.mixpanel.mixpanelapi.featureflags.config.BaseFlagsConfig; +import com.mixpanel.mixpanelapi.featureflags.model.SelectedVariant; +import com.mixpanel.mixpanelapi.featureflags.util.TraceparentUtil; + +import org.json.JSONObject; + +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.net.URL; +import java.net.URLConnection; +import java.nio.charset.StandardCharsets; +import java.util.Map; +import java.util.UUID; +import java.util.function.Consumer; +import java.util.logging.Level; +import java.util.logging.Logger; + +/** + * Base class for feature flags providers. + *

+ * Contains shared HTTP functionality and common evaluation helpers. + * Subclasses implement specific evaluation strategies (local or remote). + *

+ * + * @param the config type extending BaseFlagsConfig + */ +public abstract class BaseFlagsProvider { + protected static final int BUFFER_SIZE = 4096; + + protected final String projectToken; + protected final C config; + protected final String sdkVersion; + protected final EventSender eventSender; + + /** + * Creates a new BaseFlagsProvider. + * + * @param projectToken the Mixpanel project token + * @param config the flags configuration + * @param sdkVersion the SDK version string + * @param eventSender the EventSender implementation for tracking exposure events + */ + protected BaseFlagsProvider(String projectToken, C config, String sdkVersion, EventSender eventSender) { + this.projectToken = projectToken; + this.config = config; + this.sdkVersion = sdkVersion; + this.eventSender = eventSender; + } + + // #region HTTP Methods + + /** + * Performs an HTTP GET request with Basic Auth. + *

+ * This method is protected to allow test subclasses to override HTTP behavior. + *

+ */ + protected String httpGet(String urlString) throws IOException { + URL url = new URL(urlString); + URLConnection conn = url.openConnection(); + conn.setConnectTimeout(config.getRequestTimeoutSeconds() * 1000); + conn.setReadTimeout(config.getRequestTimeoutSeconds() * 1000); + + // Set Basic Auth header (token as username, empty password) + String auth = projectToken + ":"; + String encodedAuth = java.util.Base64.getEncoder().encodeToString(auth.getBytes(StandardCharsets.UTF_8)); + conn.setRequestProperty("Authorization", "Basic " + encodedAuth); + + // Set custom headers + conn.setRequestProperty("X-Scheme", "https"); + conn.setRequestProperty("X-Forwarded-Proto", "https"); + conn.setRequestProperty("Content-Type", "application/json"); + conn.setRequestProperty("traceparent", TraceparentUtil.generateTraceparent()); + + InputStream responseStream = null; + try { + responseStream = conn.getInputStream(); + return readStream(responseStream); + } finally { + if (responseStream != null) { + try { + responseStream.close(); + } catch (IOException e) { + getLogger().log(Level.WARNING, "Failed to close response stream", e); + } + } + } + } + + /** + * Reads an input stream to a string. + */ + protected String readStream(InputStream in) throws IOException { + StringBuilder out = new StringBuilder(); + InputStreamReader reader = new InputStreamReader(in, StandardCharsets.UTF_8); + + char[] buffer = new char[BUFFER_SIZE]; + int count; + while ((count = reader.read(buffer)) != -1) { + out.append(buffer, 0, count); + } + + return out.toString(); + } + + // #endregion + + // #region Abstract Methods + + /** + * Evaluates a flag and returns the selected variant. + *

+ * Subclasses must implement this method to provide local or remote evaluation logic. + *

+ * + * @param flagKey the flag key to evaluate + * @param fallback the fallback variant to return if evaluation fails + * @param context the evaluation context + * @param reportExposure whether to track an exposure event for this flag evaluation + * @param the type of the variant value + * @return the selected variant or fallback + */ + public abstract SelectedVariant getVariant(String flagKey, SelectedVariant fallback, Map context, boolean reportExposure); + + /** + * Evaluates a flag and returns the selected variant. + *

+ * This is a convenience method that defaults reportExposure to true. + *

+ * + * @param flagKey the flag key to evaluate + * @param fallback the fallback variant to return if evaluation fails + * @param context the evaluation context + * @param the type of the variant value + * @return the selected variant or fallback + */ + public SelectedVariant getVariant(String flagKey, SelectedVariant fallback, Map context) { + return getVariant(flagKey, fallback, context, true); + } + + /** + * Gets the logger for this provider. + * Subclasses should override this to return their class-specific logger. + * + * @return the logger instance + */ + protected abstract Logger getLogger(); + + // #endregion + + // #region Variant Value Methods + + /** + * Evaluates a flag and returns the variant value. + * + * @param flagKey the flag key to evaluate + * @param fallbackValue the fallback value to return if evaluation fails + * @param context the evaluation context + * @param the type of the variant value + * @return the selected variant value or fallback + */ + public T getVariantValue(String flagKey, T fallbackValue, Map context) { + SelectedVariant fallback = new SelectedVariant<>(fallbackValue); + SelectedVariant result = getVariant(flagKey, fallback, context, true); + return result.getVariantValue(); + } + + /** + * Evaluates a flag and returns whether it is enabled. + *

+ * Returns true only if the variant value is exactly Boolean true. + * Returns false for all other cases (false, null, numbers, strings, etc.). + *

+ * + * @param flagKey the flag key to evaluate + * @param context the evaluation context + * @return true if the variant value is exactly Boolean true, false otherwise + */ + public boolean isEnabled(String flagKey, Map context) { + SelectedVariant result = getVariant(flagKey, new SelectedVariant<>(false), context, true); + Object value = result.getVariantValue(); + + return value instanceof Boolean && (Boolean) value; + } + + // #endregion + + // #region Exposure Tracking + + /** + * Common helper method for tracking exposure events. + */ + protected void trackExposure(String distinctId, String flagKey, String variantKey, + String evaluationMode, Consumer addTimingProperties, + UUID experimentId, Boolean isExperimentActive, Boolean isQaTester) { + try { + JSONObject properties = new JSONObject(); + properties.put("Experiment name", flagKey); + properties.put("Variant name", variantKey); + properties.put("$experiment_type", "feature_flag"); + properties.put("Flag evaluation mode", evaluationMode); + + // Add experiment metadata + if (experimentId != null) { + properties.put("$experiment_id", experimentId.toString()); + } + if (isExperimentActive != null) { + properties.put("$is_experiment_active", isExperimentActive); + } + if (isQaTester != null) { + properties.put("$is_qa_tester", isQaTester); + } + + // Add timing-specific properties + addTimingProperties.accept(properties); + + // Send via EventSender interface + eventSender.sendEvent(distinctId, "$experiment_started", properties); + + getLogger().log(Level.FINE, "Tracked exposure event for flag: " + flagKey + ", variant: " + variantKey); + } catch (Exception e) { + getLogger().log(Level.WARNING, "Error tracking exposure event for flag: " + flagKey + ", variant: " + variantKey + " - " + e.getMessage(), e); + } + } + + // #endregion +} diff --git a/src/main/java/com/mixpanel/mixpanelapi/featureflags/provider/LocalFlagsProvider.java b/src/main/java/com/mixpanel/mixpanelapi/featureflags/provider/LocalFlagsProvider.java new file mode 100644 index 0000000..069e764 --- /dev/null +++ b/src/main/java/com/mixpanel/mixpanelapi/featureflags/provider/LocalFlagsProvider.java @@ -0,0 +1,571 @@ +package com.mixpanel.mixpanelapi.featureflags.provider; + +import com.mixpanel.mixpanelapi.featureflags.EventSender; +import com.mixpanel.mixpanelapi.featureflags.config.LocalFlagsConfig; +import com.mixpanel.mixpanelapi.featureflags.model.*; +import com.mixpanel.mixpanelapi.featureflags.util.HashUtils; + +import org.json.JSONArray; +import org.json.JSONObject; + +import java.io.UnsupportedEncodingException; +import java.net.URLEncoder; +import java.util.*; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicReference; +import java.util.logging.Level; +import java.util.logging.Logger; + +/** + * Local feature flags evaluation provider. + *

+ * This provider fetches flag definitions from the Mixpanel API and evaluates + * variants locally using the FNV-1a hash algorithm. Supports optional background + * polling for automatic definition refresh. + *

+ *

+ * This class is thread-safe and implements AutoCloseable for resource cleanup. + *

+ */ +public class LocalFlagsProvider extends BaseFlagsProvider implements AutoCloseable { + private static final Logger logger = Logger.getLogger(LocalFlagsProvider.class.getName()); + + private final AtomicReference> flagDefinitions; + private final AtomicBoolean ready; + private final AtomicBoolean closed; + + private ScheduledExecutorService pollingExecutor; + + /** + * Creates a new LocalFlagsProvider. + * + * @param config the local flags configuration + * @param sdkVersion the SDK version string + * @param eventSender the EventSender implementation for tracking exposure events + */ + public LocalFlagsProvider(LocalFlagsConfig config, String sdkVersion, EventSender eventSender) { + super(config.getProjectToken(), config, sdkVersion, eventSender); + + this.flagDefinitions = new AtomicReference<>(new HashMap<>()); + this.ready = new AtomicBoolean(false); + this.closed = new AtomicBoolean(false); + } + + // #region Polling + + /** + * Starts polling for flag definitions. + *

+ * Performs an initial fetch, then starts background polling if enabled in configuration. + *

+ */ + public void startPollingForDefinitions() { + if (closed.get()) { + logger.log(Level.WARNING, "Cannot start polling: provider is closed"); + return; + } + + // Initial fetch + fetchDefinitions(); + + // Start background polling if enabled + if (config.isEnablePolling()) { + pollingExecutor = Executors.newSingleThreadScheduledExecutor(r -> { + Thread t = new Thread(r, "mixpanel-flags-poller"); + t.setDaemon(true); + return t; + }); + + pollingExecutor.scheduleAtFixedRate( + this::fetchDefinitions, + config.getPollingIntervalSeconds(), + config.getPollingIntervalSeconds(), + TimeUnit.SECONDS + ); + + logger.log(Level.INFO, "Started polling for flag definitions every " + config.getPollingIntervalSeconds() + " seconds"); + } + } + + /** + * Stops polling for flag definitions and releases resources. + */ + public void stopPollingForDefinitions() { + if (pollingExecutor != null) { + pollingExecutor.shutdown(); + try { + if (!pollingExecutor.awaitTermination(5, TimeUnit.SECONDS)) { + pollingExecutor.shutdownNow(); + } + } catch (InterruptedException e) { + pollingExecutor.shutdownNow(); + Thread.currentThread().interrupt(); + } + pollingExecutor = null; + } + } + + // #endregion + + // #region Fetch Definitions + + /** + * @return true if flag definitions have been successfully fetched at least once + */ + public boolean areFlagsReady() { + return ready.get(); + } + + /** + * Fetches flag definitions from the Mixpanel API. + */ + private void fetchDefinitions() { + try { + String endpoint = buildDefinitionsUrl(); + String response = httpGet(endpoint); + + Map newDefinitions = parseDefinitions(response); + flagDefinitions.set(newDefinitions); + ready.set(true); + + logger.log(Level.FINE, "Successfully fetched " + newDefinitions.size() + " flag definitions"); + } catch (Exception e) { + logger.log(Level.WARNING, "Failed to fetch flag definitions", e); + } + } + + /** + * Builds the URL for fetching flag definitions. + */ + private String buildDefinitionsUrl() throws UnsupportedEncodingException { + StringBuilder url = new StringBuilder(); + url.append("https://").append(config.getApiHost()).append("/flags/definitions"); + url.append("?mp_lib=").append(URLEncoder.encode("java", "UTF-8")); + url.append("&lib_version=").append(URLEncoder.encode(sdkVersion, "UTF-8")); + url.append("&token=").append(URLEncoder.encode(projectToken, "UTF-8")); + return url.toString(); + } + + // #endregion + + // #region JSON Parsing + + /** + * Parses flag definitions from JSON response. + */ + private Map parseDefinitions(String jsonResponse) { + Map definitions = new HashMap<>(); + + try { + JSONObject root = new JSONObject(jsonResponse); + JSONArray flags = root.optJSONArray("flags"); + + if (flags == null) { + return definitions; + } + + for (int i = 0; i < flags.length(); i++) { + JSONObject flagJson = flags.getJSONObject(i); + ExperimentationFlag flag = parseFlag(flagJson); + definitions.put(flag.getKey(), flag); + } + } catch (Exception e) { + logger.log(Level.SEVERE, "Failed to parse flag definitions", e); + } + + return definitions; + } + + /** + * Parses a single flag from JSON. + */ + private ExperimentationFlag parseFlag(JSONObject json) { + String id = json.optString("id", ""); + String name = json.optString("name", ""); + String key = json.optString("key", ""); + String status = json.optString("status", ""); + int projectId = json.optInt("project_id", 0); + String context = json.optString("context", "distinct_id"); + + // Parse experiment metadata + UUID experimentId = null; + String experimentIdString = json.optString("experiment_id", null); + if (experimentIdString != null && !experimentIdString.isEmpty()) { + try { + experimentId = UUID.fromString(experimentIdString); + } catch (IllegalArgumentException e) { + logger.log(Level.WARNING, "Invalid UUID for experiment_id: " + experimentIdString); + } + } + + Boolean isExperimentActive = null; + if (json.has("is_experiment_active")) { + isExperimentActive = json.optBoolean("is_experiment_active", false); + } + + RuleSet ruleset = parseRuleSet(json.optJSONObject("ruleset")); + + return new ExperimentationFlag(id, name, key, status, projectId, ruleset, context, experimentId, isExperimentActive); + } + + /** + * Parses a ruleset from JSON. + */ + private RuleSet parseRuleSet(JSONObject json) { + if (json == null) { + return new RuleSet(Collections.emptyList(), Collections.emptyList()); + } + + // Parse variants + List variants = new ArrayList<>(); + JSONArray variantsJson = json.optJSONArray("variants"); + if (variantsJson != null) { + for (int i = 0; i < variantsJson.length(); i++) { + variants.add(parseVariant(variantsJson.getJSONObject(i))); + } + } + + // Sort variants by key for consistent ordering + variants.sort(Comparator.comparing(Variant::getKey)); + + // Parse rollouts + List rollouts = new ArrayList<>(); + JSONArray rolloutsJson = json.optJSONArray("rollout"); + if (rolloutsJson != null) { + for (int i = 0; i < rolloutsJson.length(); i++) { + rollouts.add(parseRollout(rolloutsJson.getJSONObject(i))); + } + } + + // Parse test user overrides + Map testOverrides = null; + JSONObject testJson = json.optJSONObject("test"); + if (testJson != null) { + JSONObject usersJson = testJson.optJSONObject("users"); + if (usersJson != null) { + testOverrides = new HashMap<>(); + for (String distinctId : usersJson.keySet()) { + testOverrides.put(distinctId, usersJson.getString(distinctId)); + } + } + } + + return new RuleSet(variants, rollouts, testOverrides); + } + + /** + * Parses a variant from JSON. + */ + private Variant parseVariant(JSONObject json) { + String key = json.optString("key", ""); + Object value = json.opt("value"); + boolean isControl = json.optBoolean("is_control", false); + float split = (float) json.optDouble("split", 0.0); + + return new Variant(key, value, isControl, split); + } + + /** + * Parses a rollout from JSON. + */ + private Rollout parseRollout(JSONObject json) { + float rolloutPercentage = (float) json.optDouble("rollout_percentage", 0.0); + VariantOverride variantOverride = null; + + if (json.has("variant_override") && !json.isNull("variant_override")) { + JSONObject variantObj = json.optJSONObject("variant_override"); + if (variantObj != null) { + String key = variantObj.optString("key", ""); + if (!key.isEmpty()) { + variantOverride = new VariantOverride(key); + } + } + } + + Map 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)); + } + } + + Map variantSplits = null; + JSONObject variantSplitsJson = json.optJSONObject("variant_splits"); + if (variantSplitsJson != null) { + variantSplits = new HashMap<>(); + for (String key : variantSplitsJson.keySet()) { + variantSplits.put(key, (float) variantSplitsJson.optDouble(key, 0.0)); + } + } + + return new Rollout(rolloutPercentage, runtimeEval, variantOverride, variantSplits); + } + + // #endregion + + // #region Evaluation + + /** + * Evaluates a flag and returns the selected variant. + * + * @param flagKey the flag key to evaluate + * @param fallback the fallback variant to return if evaluation fails + * @param context the evaluation context (must contain the property specified in flag's context field) + * @param reportExposure whether to track an exposure event for this flag evaluation + * @param the type of the variant value + * @return the selected variant or fallback + */ + public SelectedVariant getVariant(String flagKey, SelectedVariant fallback, Map context, boolean reportExposure) { + long startTime = System.currentTimeMillis(); + + try { + // Get flag definition + Map definitions = flagDefinitions.get(); + ExperimentationFlag flag = definitions.get(flagKey); + + if (flag == null) { + logger.log(Level.WARNING, "Flag not found: " + flagKey); + return fallback; + } + + // 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; + } + String contextValue = contextValueObj.toString(); + + // Check test user overrides + RuleSet ruleset = flag.getRuleset(); + Boolean isQaTester = null; + if (ruleset.hasTestUserOverrides()) { + String distinctId = context.get("distinct_id") != null ? context.get("distinct_id").toString() : null; + if (distinctId != null) { + String testVariantKey = ruleset.getTestUserOverrides().get(distinctId); + if (testVariantKey != null) { + Variant variant = findVariantByKey(ruleset.getVariants(), testVariantKey); + if (variant != null) { + isQaTester = true; + @SuppressWarnings("unchecked") + SelectedVariant result = new SelectedVariant<>( + variant.getKey(), + (T) variant.getValue(), + flag.getExperimentId(), + flag.getIsExperimentActive(), + isQaTester + ); + if (reportExposure) { + trackLocalExposure(context, flagKey, variant.getKey(), System.currentTimeMillis() - startTime, flag.getExperimentId(), flag.getIsExperimentActive(), isQaTester); + } + return result; + } + } + } + } + + // Evaluate rollouts + float rolloutHash = HashUtils.normalizedHash(contextValue + flagKey, "rollout"); + + for (Rollout rollout : ruleset.getRollouts()) { + if (rolloutHash >= rollout.getRolloutPercentage()) { + continue; + } + + // Check runtime evaluation, continue if this rollout has runtime conditions and it doesn't match + if (rollout.hasRuntimeEvaluation()) { + if (!matchesRuntimeConditions(rollout, context)) { + continue; + } + } + + // This rollout is selected - determine variant + Variant selectedVariant = null; + + 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"); + selectedVariant = selectVariantBySplit(ruleset.getVariants(), variantHash); + } + + if (selectedVariant != null) { + if (isQaTester == null) { + isQaTester = false; + } + @SuppressWarnings("unchecked") + SelectedVariant result = new SelectedVariant<>( + selectedVariant.getKey(), + (T) selectedVariant.getValue(), + flag.getExperimentId(), + flag.getIsExperimentActive(), + isQaTester + ); + if (reportExposure) { + trackLocalExposure(context, flagKey, selectedVariant.getKey(), System.currentTimeMillis() - startTime, flag.getExperimentId(), flag.getIsExperimentActive(), isQaTester); + } + return result; + } + + break; // Rollout selected but no variant found + } + + // No rollout matched + return fallback; + + } catch (Exception e) { + logger.log(Level.WARNING, "Error evaluating flag: " + flagKey, e); + return fallback; + } + } + + /** + * 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 context) { + Map customProperties = getCustomProperties(context); + if (customProperties == null) { + return false; + } + + Map runtimeEval = rollout.getRuntimeEvaluationDefinition(); + for (Map.Entry entry : runtimeEval.entrySet()) { + String key = entry.getKey(); + Object expectedValue = entry.getValue(); + Object actualValue = customProperties.get(key); + + // Case-insensitive comparison for strings + if (!valuesEqual(expectedValue, actualValue)) { + return false; + } + } + + return true; + } + + /** + * Extracts custom_properties from context. + */ + @SuppressWarnings("unchecked") + private Map getCustomProperties(Map context) { + Object customPropsObj = context.get("custom_properties"); + if (customPropsObj instanceof Map) { + return (Map) customPropsObj; + } + return null; + } + + /** + * Compares two values with case-insensitive string comparison. + */ + private boolean valuesEqual(Object expected, Object actual) { + if (expected == null || actual == null) { + return expected == actual; + } + + // Case-insensitive comparison for strings + if (expected instanceof String && actual instanceof String) { + return ((String) expected).equalsIgnoreCase((String) actual); + } + + return expected.equals(actual); + } + + /** + * Finds a variant by key. + */ + private Variant findVariantByKey(List variants, String key) { + for (Variant variant : variants) { + if (variant.getKey().equals(key)) { + return variant; + } + } + return null; + } + + /** + * Selects a variant based on hash and split percentages. + */ + private Variant selectVariantBySplit(List variants, float hash) { + float cumulative = 0.0f; + + for (Variant variant : variants) { + cumulative += variant.getSplit(); + if (hash < cumulative) { + return variant; + } + } + + // If no variant selected (due to rounding), return last variant + return variants.isEmpty() ? null : variants.get(variants.size() - 1); + } + + /** + * Evaluates all flags and returns their selected variants. + *

+ * This method evaluates all flag definitions for the given context and returns + * a list of successfully selected variants (excludes fallbacks). + *

+ * + * @param context the evaluation context + * @param reportExposure whether to track exposure events for flag evaluations + * @return list of selected variants for all flags where a variant was selected + */ + public List> getAllVariants(Map context, boolean reportExposure) { + List> results = new ArrayList<>(); + Map definitions = flagDefinitions.get(); + + for (ExperimentationFlag flag : definitions.values()) { + SelectedVariant fallback = new SelectedVariant<>(null); + SelectedVariant result = getVariant(flag.getKey(), fallback, context, reportExposure); + + // Only include successfully selected variants (not fallbacks) + if (result.isSuccess()) { + results.add(result); + } + } + + return results; + } + + /** + * Tracks an exposure event for local evaluation. + */ + private void trackLocalExposure(Map context, String flagKey, String variantKey, long latencyMs, UUID experimentId, Boolean isExperimentActive, Boolean isQaTester) { + if (eventSender == null) { + return; + } + + Object distinctIdObj = context.get("distinct_id"); + if (distinctIdObj == null) { + return; + } + + trackExposure(distinctIdObj.toString(), flagKey, variantKey, "local", properties -> { + properties.put("Variant fetch latency (ms)", latencyMs); + }, experimentId, isExperimentActive, isQaTester); + } + + // #endregion + + @Override + protected Logger getLogger() { + return logger; + } + + @Override + public void close() { + if (closed.compareAndSet(false, true)) { + stopPollingForDefinitions(); + } + } +} diff --git a/src/main/java/com/mixpanel/mixpanelapi/featureflags/provider/RemoteFlagsProvider.java b/src/main/java/com/mixpanel/mixpanelapi/featureflags/provider/RemoteFlagsProvider.java new file mode 100644 index 0000000..4d8fd90 --- /dev/null +++ b/src/main/java/com/mixpanel/mixpanelapi/featureflags/provider/RemoteFlagsProvider.java @@ -0,0 +1,171 @@ +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 java.io.UnsupportedEncodingException; +import java.net.URLEncoder; +import java.text.SimpleDateFormat; +import java.util.Date; +import java.util.Map; +import java.util.TimeZone; +import java.util.UUID; +import java.util.logging.Level; +import java.util.logging.Logger; + +/** + * Remote feature flags evaluation provider. + *

+ * This provider evaluates flags by making HTTP requests to the Mixpanel API. + * Each evaluation results in a network call to fetch the variant from the server. + *

+ *

+ * This class is thread-safe. + *

+ */ +public class RemoteFlagsProvider extends BaseFlagsProvider { + private static final Logger logger = Logger.getLogger(RemoteFlagsProvider.class.getName()); + + /** + * Creates a new RemoteFlagsProvider. + * + * @param config the remote flags configuration + * @param sdkVersion the SDK version string + * @param eventSender the EventSender implementation for tracking exposure events + */ + public RemoteFlagsProvider(RemoteFlagsConfig config, String sdkVersion, EventSender eventSender) { + super(config.getProjectToken(), config, sdkVersion, eventSender); + } + + // #region Evaluation + + /** + * Evaluates a flag remotely and returns the selected variant. + * + * @param flagKey the flag key to evaluate + * @param fallback the fallback variant to return if evaluation fails + * @param context the evaluation context + * @param reportExposure whether to track an exposure event for this flag evaluation + * @param the type of the variant value + * @return the selected variant or fallback + */ + public SelectedVariant getVariant(String flagKey, SelectedVariant fallback, Map context, boolean reportExposure) { + String startTime = getCurrentIso8601Timestamp(); + + try { + String endpoint = buildFlagsUrl(flagKey, context); + + String response = httpGet(endpoint); + + JSONObject root = new JSONObject(response); + JSONObject flags = root.optJSONObject("flags"); + + if (flags == null || !flags.has(flagKey)) { + logger.log(Level.WARNING, "Flag not found in response: " + flagKey); + return fallback; + } + + JSONObject flagData = flags.getJSONObject(flagKey); + String variantKey = flagData.optString("variant_key", null); + Object variantValue = flagData.opt("variant_value"); + + if (variantKey == null) { + return fallback; + } + + // Parse experiment metadata + UUID experimentId = null; + String experimentIdString = flagData.optString("experiment_id", null); + if (experimentIdString != null && !experimentIdString.isEmpty()) { + try { + experimentId = UUID.fromString(experimentIdString); + } catch (IllegalArgumentException e) { + logger.log(Level.WARNING, "Invalid UUID for experiment_id: " + experimentIdString); + } + } + + Boolean isExperimentActive = null; + if (flagData.has("is_experiment_active")) { + isExperimentActive = flagData.optBoolean("is_experiment_active", false); + } + + Boolean isQaTester = null; + if (flagData.has("is_qa_tester")) { + isQaTester = flagData.optBoolean("is_qa_tester", false); + } + + // Track exposure + String completeTime = getCurrentIso8601Timestamp(); + if (reportExposure) { + trackRemoteExposure(context, flagKey, variantKey, startTime, completeTime, experimentId, isExperimentActive, isQaTester); + } + + @SuppressWarnings("unchecked") + SelectedVariant result = new SelectedVariant<>(variantKey, (T) variantValue, experimentId, isExperimentActive, isQaTester); + return result; + + } catch (Exception e) { + logger.log(Level.WARNING, "Error evaluating flag remotely: " + flagKey, e); + return fallback; + } + } + + // #endregion + // #region HTTP Helpers + + /** + * Builds the URL for remote flag evaluation. + */ + private String buildFlagsUrl(String flagKey, Map context) throws UnsupportedEncodingException { + StringBuilder url = new StringBuilder(); + url.append("https://").append(config.getApiHost()).append("/flags"); + url.append("?mp_lib=").append(URLEncoder.encode("jdk", "UTF-8")); + url.append("&lib_version=").append(URLEncoder.encode(sdkVersion, "UTF-8")); + url.append("&token=").append(URLEncoder.encode(projectToken, "UTF-8")); + url.append("&flag_key=").append(URLEncoder.encode(flagKey, "UTF-8")); + + JSONObject contextJson = new JSONObject(context); + String contextString = contextJson.toString(); + url.append("&context=").append(URLEncoder.encode(contextString, "UTF-8")); + + return url.toString(); + } + + /** + * Gets current timestamp in ISO 8601 format. + */ + private String getCurrentIso8601Timestamp() { + SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'"); + sdf.setTimeZone(TimeZone.getTimeZone("UTC")); + return sdf.format(new Date()); + } + + // #endregion + + /** + * Tracks an exposure event for remote evaluation. + */ + private void trackRemoteExposure(Map context, String flagKey, String variantKey, String startTime, String completeTime, UUID experimentId, Boolean isExperimentActive, Boolean isQaTester) { + if (eventSender == null) { + return; + } + + Object distinctIdObj = context.get("distinct_id"); + if (distinctIdObj == null) { + return; + } + + trackExposure(distinctIdObj.toString(), flagKey, variantKey, "remote", properties -> { + properties.put("Variant fetch start time", startTime); + properties.put("Variant fetch complete time", completeTime); + }, experimentId, isExperimentActive, isQaTester); + } + + @Override + protected Logger getLogger() { + return logger; + } +} diff --git a/src/main/java/com/mixpanel/mixpanelapi/featureflags/util/HashUtils.java b/src/main/java/com/mixpanel/mixpanelapi/featureflags/util/HashUtils.java new file mode 100644 index 0000000..e793fbb --- /dev/null +++ b/src/main/java/com/mixpanel/mixpanelapi/featureflags/util/HashUtils.java @@ -0,0 +1,91 @@ +package com.mixpanel.mixpanelapi.featureflags.util; + +import java.nio.charset.StandardCharsets; + +/** + * Utility class for hashing operations used in feature flag evaluation. + *

+ * Implements the FNV-1a (Fowler-Noll-Vo hash, variant 1a) algorithm to generate + * deterministic, uniformly distributed hash values in the range [0.0, 1.0). + *

+ *

+ * This class is thread-safe and all methods are static. + *

+ */ +public final class HashUtils { + + /** + * FNV-1a 64-bit offset basis constant. + */ + private static final long FNV_OFFSET_BASIS_64 = 0xcbf29ce484222325L; + + /** + * FNV-1a 64-bit prime constant. + */ + private static final long FNV_PRIME_64 = 0x100000001b3L; + + // Private constructor to prevent instantiation + private HashUtils() { + throw new AssertionError("HashUtils should not be instantiated"); + } + + /** + * Generates a normalized hash value in the range [0.0, 1.0) using the FNV-1a algorithm. + * + * @param key the input string to hash (typically user identifier + flag key) + * @param salt the salt to append to the input (e.g., "rollout" or "variant") + * @return a float value in the range [0.0, 1.0) + * @throws IllegalArgumentException if key or salt is null + */ + public static float normalizedHash(String key, String salt) { + if (key == null) { + throw new IllegalArgumentException("Key cannot be null"); + } + if (salt == null) { + throw new IllegalArgumentException("Salt cannot be null"); + } + + // Combine key and salt + String combined = key + salt; + byte[] bytes = combined.getBytes(StandardCharsets.UTF_8); + + // FNV-1a 64-bit hash + long hash = FNV_OFFSET_BASIS_64; + for (byte b : bytes) { + // XOR with byte (converting to unsigned) + hash ^= (b & 0xff); + // Multiply by FNV prime + hash *= FNV_PRIME_64; + } + + // Normalize to [0.0, 1.0) matching Python's approach + // Use Long.remainderUnsigned to handle negative values correctly + return (float) (Long.remainderUnsigned(hash, 100) / 100.0); + } + + /** + * Generates a normalized hash value for rollout selection. + *

+ * Convenience method that uses "rollout" as the salt. + *

+ * + * @param input the input string to hash (typically user identifier + flag key) + * @return a float value in the range [0.0, 1.0) + */ + public static float rolloutHash(String input) { + return normalizedHash(input, "rollout"); + } + + /** + * Generates a normalized hash value for variant selection. + *

+ * Convenience method that uses "variant" as the salt. + *

+ * + * @param input the input string to hash (typically user identifier + flag key) + * @return a float value in the range [0.0, 1.0) + */ + public static float variantHash(String input) { + return normalizedHash(input, "variant"); + } +} diff --git a/src/main/java/com/mixpanel/mixpanelapi/featureflags/util/TraceparentUtil.java b/src/main/java/com/mixpanel/mixpanelapi/featureflags/util/TraceparentUtil.java new file mode 100644 index 0000000..a961d07 --- /dev/null +++ b/src/main/java/com/mixpanel/mixpanelapi/featureflags/util/TraceparentUtil.java @@ -0,0 +1,41 @@ +package com.mixpanel.mixpanelapi.featureflags.util; + +import java.util.UUID; + +/** + * Utility class for generating W3C Trace Context traceparent headers. + *

+ * Generates traceparent headers in the format: 00-{trace_id}-{span_id}-01 + * where trace_id is a 32-character hex string and span_id is a 16-character hex string. + *

+ *

+ * This class is thread-safe. + *

+ * + * @see W3C Trace Context + */ +public final class TraceparentUtil { + + /** + * Private constructor to prevent instantiation. + */ + private TraceparentUtil() { + throw new AssertionError("TraceparentUtil should not be instantiated"); + } + + /** + * Generates a W3C traceparent header value. + *

+ * Format: 00-{trace_id}-{span_id}-01 + * Uses two separate UUIDs with dashes removed - one for trace_id (32 chars) + * and one for span_id (16 chars). + *

+ * + * @return a traceparent header value + */ + public static String generateTraceparent() { + String traceId = UUID.randomUUID().toString().replace("-", ""); + String spanId = UUID.randomUUID().toString().replace("-", "").substring(0, 16); + return "00-" + traceId + "-" + spanId + "-01"; + } +} diff --git a/src/main/java/com/mixpanel/mixpanelapi/featureflags/util/VersionUtil.java b/src/main/java/com/mixpanel/mixpanelapi/featureflags/util/VersionUtil.java new file mode 100644 index 0000000..bb6f449 --- /dev/null +++ b/src/main/java/com/mixpanel/mixpanelapi/featureflags/util/VersionUtil.java @@ -0,0 +1,69 @@ +package com.mixpanel.mixpanelapi.featureflags.util; + +import java.io.IOException; +import java.io.InputStream; +import java.util.Properties; +import java.util.logging.Level; +import java.util.logging.Logger; + +/** + * Utility class for accessing the SDK version. + *

+ * The version is loaded from the mixpanel-version.properties file, + * which is populated by Maven during the build process. + *

+ */ +public class VersionUtil { + private static final Logger logger = Logger.getLogger(VersionUtil.class.getName()); + private static final String VERSION_FILE = "mixpanel-version.properties"; + private static final String VERSION_KEY = "version"; + private static final String UNKNOWN_VERSION = "unknown"; + + private static String cachedVersion = null; + + private VersionUtil() { + // Utility class - prevent instantiation + } + + /** + * Gets the SDK version. + *

+ * The version is loaded from the properties file on first access and cached. + * Returns "unknown" if the version cannot be determined (e.g., running in IDE without build). + *

+ * + * @return the SDK version string + */ + public static String getVersion() { + if (cachedVersion == null) { + cachedVersion = loadVersion(); + } + return cachedVersion; + } + + /** + * Loads the version from the properties file. + */ + private static String loadVersion() { + try (InputStream input = VersionUtil.class.getClassLoader().getResourceAsStream(VERSION_FILE)) { + if (input == null) { + logger.log(Level.WARNING, "Version file not found: " + VERSION_FILE + " (using fallback version)"); + return UNKNOWN_VERSION; + } + + Properties props = new Properties(); + props.load(input); + + String version = props.getProperty(VERSION_KEY); + if (version == null || version.isEmpty()) { + logger.log(Level.WARNING, "Version property not found in " + VERSION_FILE); + return UNKNOWN_VERSION; + } + + return version; + } catch (IOException e) { + logger.log(Level.WARNING, "Failed to load version from " + VERSION_FILE, e); + return UNKNOWN_VERSION; + } + } +} diff --git a/src/main/resources/mixpanel-version.properties b/src/main/resources/mixpanel-version.properties new file mode 100644 index 0000000..defbd48 --- /dev/null +++ b/src/main/resources/mixpanel-version.properties @@ -0,0 +1 @@ +version=${project.version} diff --git a/src/test/java/com/mixpanel/mixpanelapi/featureflags/provider/BaseExposureTrackerMock.java b/src/test/java/com/mixpanel/mixpanelapi/featureflags/provider/BaseExposureTrackerMock.java new file mode 100644 index 0000000..f1f01e7 --- /dev/null +++ b/src/test/java/com/mixpanel/mixpanelapi/featureflags/provider/BaseExposureTrackerMock.java @@ -0,0 +1,43 @@ +package com.mixpanel.mixpanelapi.featureflags.provider; + +import java.util.ArrayList; +import java.util.List; + +/** + * Base class for exposure tracker mocks. + * Provides common event storage and retrieval functionality. + *

+ * Subclasses should extend this class and implement the specific ExposureTracker interface + * for their provider type (LocalFlagsProvider.ExposureTracker or RemoteFlagsProvider.ExposureTracker). + *

+ * + * @param the type of exposure event + */ +public abstract class BaseExposureTrackerMock { + protected final List events = new ArrayList<>(); + + /** + * Reset the tracker by clearing all recorded events. + */ + public void reset() { + events.clear(); + } + + /** + * Get the count of tracked exposure events. + * + * @return the number of events tracked + */ + public int getEventCount() { + return events.size(); + } + + /** + * Get the most recently tracked exposure event. + * + * @return the last event, or null if no events have been tracked + */ + public E getLastEvent() { + return events.isEmpty() ? null : events.get(events.size() - 1); + } +} diff --git a/src/test/java/com/mixpanel/mixpanelapi/featureflags/provider/BaseFlagsProviderTest.java b/src/test/java/com/mixpanel/mixpanelapi/featureflags/provider/BaseFlagsProviderTest.java new file mode 100644 index 0000000..ce1b72b --- /dev/null +++ b/src/test/java/com/mixpanel/mixpanelapi/featureflags/provider/BaseFlagsProviderTest.java @@ -0,0 +1,66 @@ +package com.mixpanel.mixpanelapi.featureflags.provider; + +import org.junit.After; + +import java.util.HashMap; +import java.util.Map; + +/** + * Base class for feature flags provider tests. + * Provides shared test infrastructure, lifecycle management, and helper methods. + */ +public abstract class BaseFlagsProviderTest { + + // Shared constants + protected static final String TEST_TOKEN = "test-token"; + protected static final String SDK_VERSION = "1.0.0"; + protected static final String TEST_USER = "user-123"; + + /** + * Shared test lifecycle - closes the provider after each test if it's closeable. + */ + @After + public void tearDown() { + Object provider = getProvider(); + if (provider instanceof AutoCloseable) { + try { + ((AutoCloseable) provider).close(); + } catch (Exception e) { + // Ignore cleanup errors + } + } + } + + /** + * Abstract method for subclasses to provide their provider instance. + * This allows the base class to manage the lifecycle. + * + * @return the provider instance to be closed after each test (if closeable) + */ + protected abstract Object getProvider(); + + /** + * Helper to build a simple context with distinct_id. + * + * @param distinctId the distinct ID to include in the context + * @return a context map with distinct_id + */ + protected Map buildContext(String distinctId) { + Map context = new HashMap<>(); + context.put("distinct_id", distinctId); + return context; + } + + /** + * Helper to build context with custom properties. + * + * @param distinctId the distinct ID to include in the context + * @param customProps custom properties to include + * @return a context map with distinct_id and custom_properties + */ + protected Map buildContextWithProperties(String distinctId, Map customProps) { + Map context = buildContext(distinctId); + context.put("custom_properties", customProps); + return context; + } +} diff --git a/src/test/java/com/mixpanel/mixpanelapi/featureflags/provider/LocalFlagsProviderTest.java b/src/test/java/com/mixpanel/mixpanelapi/featureflags/provider/LocalFlagsProviderTest.java new file mode 100644 index 0000000..a557f59 --- /dev/null +++ b/src/test/java/com/mixpanel/mixpanelapi/featureflags/provider/LocalFlagsProviderTest.java @@ -0,0 +1,974 @@ +package com.mixpanel.mixpanelapi.featureflags.provider; + +import com.mixpanel.mixpanelapi.featureflags.EventSender; +import com.mixpanel.mixpanelapi.featureflags.config.LocalFlagsConfig; +import com.mixpanel.mixpanelapi.featureflags.model.*; + +import org.json.JSONArray; +import org.json.JSONObject; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.net.HttpURLConnection; +import java.net.URL; +import java.net.URLConnection; +import java.net.URLStreamHandler; +import java.util.*; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; + +import static org.junit.Assert.*; + +/** + * Unit tests for LocalFlagsProvider. + * Tests cover all aspects of local feature flag evaluation including fallback behavior, + * test user configuration, rollout/distribution, runtime evaluation, exposure tracking, + * readiness checks, and polling. + */ +public class LocalFlagsProviderTest extends BaseFlagsProviderTest { + + private TestableLocalFlagsProvider provider; + private LocalFlagsConfig config; + private MockEventSender eventSender; + + // #region Mocks + + /** + * Testable subclass of LocalFlagsProvider that allows mocking HTTP responses. + */ + private static class TestableLocalFlagsProvider extends LocalFlagsProvider { + private final MockHttpProvider httpMock = new MockHttpProvider(); + + public TestableLocalFlagsProvider(LocalFlagsConfig 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 = LocalFlagsConfig.builder() + .projectToken(TEST_TOKEN) + .enablePolling(false) + .build(); + eventSender = new MockEventSender(); + } + + @Override + protected Object getProvider() { + return provider; + } + + // #endregion + + // #region Helper Methods + + /** + * Creates a test provider with custom HTTP response. + * The response will be returned when the flags definitions URL is called. + */ + private TestableLocalFlagsProvider createProviderWithResponse(String jsonResponse) { + TestableLocalFlagsProvider testProvider = new TestableLocalFlagsProvider(config, SDK_VERSION, eventSender); + + if (jsonResponse != null) { + // Mock the flags definitions endpoint + testProvider.setMockResponse("/flags/definitions", jsonResponse); + } else { + // Simulate network error by setting exception + testProvider.setMockException(new IOException("Simulated network error")); + } + + return testProvider; + } + + /** + * Builds a complete flag definition JSON response + */ + private String buildFlagsResponse(String flagKey, String context, List variants, + List rollouts, Map testUsers) { + return buildFlagsResponse(flagKey, context, variants, rollouts, testUsers, null, null); + } + + /** + * Builds a complete flag definition JSON response with experiment metadata + */ + private String buildFlagsResponse(String flagKey, String context, List variants, + List rollouts, Map testUsers, + String experimentId, Boolean isExperimentActive) { + // Convert String experimentId to UUID if provided + UUID experimentUuid = null; + if (experimentId != null && !experimentId.isEmpty()) { + try { + experimentUuid = UUID.fromString(experimentId); + } catch (IllegalArgumentException e) { + // If it's not a valid UUID, leave it as null + } + } + + // Create FlagDefinition and use shared helper + FlagDefinition flagDef = new FlagDefinition(flagKey, context, variants, rollouts, testUsers, experimentUuid, isExperimentActive); + + try { + JSONObject root = new JSONObject(); + JSONArray flagsArray = new JSONArray(); + flagsArray.put(buildFlagJsonObject(flagDef, "flag-1")); + root.put("flags", flagsArray); + return root.toString(); + } catch (Exception e) { + throw new RuntimeException("Failed to build test response", e); + } + } + + /** + * Builds a single flag JSONObject from a FlagDefinition. + * This is a shared helper used by both buildFlagsResponse and buildMultipleFlagsResponse. + */ + private JSONObject buildFlagJsonObject(FlagDefinition def, String flagId) { + JSONObject flag = new JSONObject(); + flag.put("id", flagId); + flag.put("name", "Test Flag " + def.flagKey); + flag.put("key", def.flagKey); + flag.put("status", "active"); + flag.put("project_id", 123); + flag.put("context", def.context); + + // Add experiment metadata if provided + if (def.experimentId != null) { + flag.put("experiment_id", def.experimentId.toString()); + } + if (def.isExperimentActive != null) { + flag.put("is_experiment_active", def.isExperimentActive); + } + + JSONObject ruleset = new JSONObject(); + + // Add variants + JSONArray variantsArray = new JSONArray(); + for (Variant v : def.variants) { + JSONObject variantJson = new JSONObject(); + variantJson.put("key", v.getKey()); + variantJson.put("value", v.getValue()); + variantJson.put("is_control", v.isControl()); + variantJson.put("split", v.getSplit()); + variantsArray.put(variantJson); + } + ruleset.put("variants", variantsArray); + + // Add rollouts + JSONArray rolloutsArray = new JSONArray(); + for (Rollout r : def.rollouts) { + JSONObject rolloutJson = new JSONObject(); + rolloutJson.put("rollout_percentage", r.getRolloutPercentage()); + if (r.hasVariantOverride()) { + JSONObject variantOverrideObj = new JSONObject(); + variantOverrideObj.put("key", r.getVariantOverride().getKey()); + rolloutJson.put("variant_override", variantOverrideObj); + } + if (r.hasRuntimeEvaluation()) { + JSONObject runtimeEval = new JSONObject(r.getRuntimeEvaluationDefinition()); + rolloutJson.put("runtime_evaluation_definition", runtimeEval); + } + rolloutsArray.put(rolloutJson); + } + ruleset.put("rollout", rolloutsArray); + + // Add test users + if (def.testUsers != null && !def.testUsers.isEmpty()) { + JSONObject testJson = new JSONObject(); + JSONObject usersJson = new JSONObject(def.testUsers); + testJson.put("users", usersJson); + ruleset.put("test", testJson); + } + + flag.put("ruleset", ruleset); + return flag; + } + + /** + * Helper class to build a flag for buildMultipleFlagsResponse + */ + private static class FlagDefinition { + String flagKey; + String context; + List variants; + List rollouts; + Map testUsers; + UUID experimentId; + Boolean isExperimentActive; + + FlagDefinition(String flagKey, String context, List variants, List rollouts) { + this(flagKey, context, variants, rollouts, null, null, null); + } + + FlagDefinition(String flagKey, String context, List variants, List rollouts, + Map testUsers, UUID experimentId, Boolean isExperimentActive) { + this.flagKey = flagKey; + this.context = context; + this.variants = variants; + this.rollouts = rollouts; + this.testUsers = testUsers; + this.experimentId = experimentId; + this.isExperimentActive = isExperimentActive; + } + } + + /** + * Builds a response with multiple flag definitions + */ + private String buildMultipleFlagsResponse(List flagDefs) { + try { + JSONObject root = new JSONObject(); + JSONArray flagsArray = new JSONArray(); + + int flagId = 1; + for (FlagDefinition def : flagDefs) { + flagsArray.put(buildFlagJsonObject(def, "flag-" + flagId++)); + } + + root.put("flags", flagsArray); + return root.toString(); + } catch (Exception e) { + throw new RuntimeException("Failed to build multiple flags response", e); + } + } + + // #endregion + + // #region Fallback Behavior Tests + + @Test + public void testReturnFallbackWhenNoFlagDefinitionsExist() { + // Create provider with empty flags response + String response = "{\"flags\":[]}"; + provider = createProviderWithResponse(response); + provider.startPollingForDefinitions(); + + Map context = buildContext("user-123"); + String result = provider.getVariantValue("any-flag", "fallback", context); + + assertEquals("fallback", result); + assertEquals(0, eventSender.getEvents().size()); + } + + @Test + public void testReturnFallbackWhenFlagDefinitionAPICallFails() { + // Create provider that will throw IOException + provider = createProviderWithResponse(null); + provider.startPollingForDefinitions(); + + Map context = buildContext("user-123"); + String result = provider.getVariantValue("test-flag", "fallback", context); + + assertEquals("fallback", result); + assertEquals(0, eventSender.getEvents().size()); + } + + @Test + public void testReturnFallbackWhenRequestedFlagDoesNotExist() { + // Create provider with one flag, but request a different one + List variants = Arrays.asList(new Variant("control", "blue", false, 1.0f)); + List rollouts = Arrays.asList(new Rollout(1.0f)); + String response = buildFlagsResponse("existing-flag", "distinct_id", variants, rollouts, null); + + provider = createProviderWithResponse(response); + provider.startPollingForDefinitions(); + + Map context = buildContext("user-123"); + String result = provider.getVariantValue("non-existent-flag", "fallback", context); + + assertEquals("fallback", result); + assertEquals(0, eventSender.getEvents().size()); + } + + @Test + public void testReturnFallbackWhenNoContextProvided() { + List variants = Arrays.asList(new Variant("variant-a", "value-a", false, 1.0f)); + List rollouts = Arrays.asList(new Rollout(1.0f)); + String response = buildFlagsResponse("test-flag", "distinct_id", variants, rollouts, null); + + provider = createProviderWithResponse(response); + provider.startPollingForDefinitions(); + + // Empty context - missing required distinct_id + Map context = new HashMap<>(); + String result = provider.getVariantValue("test-flag", "fallback", context); + + assertEquals("fallback", result); + assertEquals(0, eventSender.getEvents().size()); + } + + @Test + public void testReturnFallbackWhenWrongContextKeyProvided() { + // Flag configured to use "user_id" as context property + List variants = Arrays.asList(new Variant("variant-a", "value-a", false, 1.0f)); + List rollouts = Arrays.asList(new Rollout(1.0f)); + String response = buildFlagsResponse("test-flag", "user_id", variants, rollouts, null); + + provider = createProviderWithResponse(response); + provider.startPollingForDefinitions(); + + // Context provides distinct_id but flag needs user_id + Map context = buildContext("user-123"); + String result = provider.getVariantValue("test-flag", "fallback", context); + + assertEquals("fallback", result); + assertEquals(0, eventSender.getEvents().size()); + } + + @Test + public void testReturnFallbackWhenRolloutPercentageIsZero() { + List variants = Arrays.asList(new Variant("variant-a", "value-a", false, 1.0f)); + List rollouts = Arrays.asList(new Rollout(0.0f)); // 0% rollout + String response = buildFlagsResponse("test-flag", "distinct_id", variants, rollouts, null); + + provider = createProviderWithResponse(response); + provider.startPollingForDefinitions(); + + Map context = buildContext("user-123"); + String result = provider.getVariantValue("test-flag", "fallback", context); + + assertEquals("fallback", result); + assertEquals(0, eventSender.getEvents().size()); + } + + // #endregion + // #region Test User Configuration Tests + + @Test + public void testReturnTestUserVariantWhenConfigured() { + 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)); + + // Configure test user to always get "treatment" variant + Map testUsers = new HashMap<>(); + testUsers.put("test-user-456", "treatment"); + + String response = buildFlagsResponse("test-flag", "distinct_id", variants, rollouts, testUsers); + + provider = createProviderWithResponse(response); + provider.startPollingForDefinitions(); + + Map context = buildContext("test-user-456"); + String result = provider.getVariantValue("test-flag", "fallback", context); + + assertEquals("red", result); + assertEquals(1, eventSender.getEvents().size()); + assertEquals("treatment", eventSender.getEvents().get(eventSender.getEvents().size() - 1).properties.getString("Variant name")); + } + + @Test + public void testFallbackToNormalEvaluationWhenTestUserVariantIsInvalid() { + List variants = Arrays.asList( + new Variant("control", "blue", true, 1.0f) + ); + List rollouts = Arrays.asList(new Rollout(1.0f)); + + // Configure test user with non-existent variant + Map testUsers = new HashMap<>(); + testUsers.put("test-user-789", "non-existent-variant"); + + String response = buildFlagsResponse("test-flag", "distinct_id", variants, rollouts, testUsers); + + provider = createProviderWithResponse(response); + + provider.startPollingForDefinitions(); + Map context = buildContext("test-user-789"); + String result = provider.getVariantValue("test-flag", "fallback", context); + + // Should fall through to normal evaluation and select "control" based on 100% split + assertEquals("blue", result); + assertEquals(1, eventSender.getEvents().size()); + assertEquals("control", eventSender.getEvents().get(eventSender.getEvents().size() - 1).properties.getString("Variant name")); + } + + // #endregion + // #region Rollout and Distribution Tests + + @Test + public void testReturnVariantWhenRolloutPercentageIs100() { + List variants = Arrays.asList(new Variant("variant-a", "value-a", false, 1.0f)); + List rollouts = Arrays.asList(new Rollout(1.0f)); // 100% rollout + String response = buildFlagsResponse("test-flag", "distinct_id", variants, rollouts, null); + + provider = createProviderWithResponse(response); + + provider.startPollingForDefinitions(); + Map context = buildContext("user-123"); + String result = provider.getVariantValue("test-flag", "fallback", context); + + assertEquals("value-a", result); + assertEquals(1, eventSender.getEvents().size()); + } + + @Test + public void testSelectCorrectVariantWith100PercentSplit() { + List variants = Arrays.asList( + new Variant("variant-a", "value-a", false, 0.0f), + new Variant("variant-b", "value-b", false, 1.0f), // 100% split + new Variant("variant-c", "value-c", false, 0.0f) + ); + List rollouts = Arrays.asList(new Rollout(1.0f)); + String response = buildFlagsResponse("test-flag", "distinct_id", variants, rollouts, null); + + provider = createProviderWithResponse(response); + + Map context = buildContext("user-123"); + provider.startPollingForDefinitions(); + String result = provider.getVariantValue("test-flag", "fallback", context); + + assertEquals("value-b", result); + assertEquals(1, eventSender.getEvents().size()); + assertEquals("variant-b", eventSender.getEvents().get(eventSender.getEvents().size() - 1).properties.getString("Variant name")); + } + + @Test + public void testApplyVariantOverrideCorrectly() { + List variants = Arrays.asList( + new Variant("control", "blue", true, 1.0f), + new Variant("treatment", "red", false, 0.0f) + ); + // Rollout with variant override - forces "treatment" regardless of split + List rollouts = Arrays.asList(new Rollout(1.0f, null, new VariantOverride("treatment"), null)); + String response = buildFlagsResponse("test-flag", "distinct_id", variants, rollouts, null); + + provider = createProviderWithResponse(response); + + Map context = buildContext("user-123"); + provider.startPollingForDefinitions(); + String result = provider.getVariantValue("test-flag", "fallback", context); + + assertEquals("red", result); + assertEquals(1, eventSender.getEvents().size()); + assertEquals("treatment", eventSender.getEvents().get(eventSender.getEvents().size() - 1).properties.getString("Variant name")); + } + + // #endregion + // #region Runtime Evaluation Tests + + @Test + public void testReturnVariantWhenRuntimeEvaluationConditionsSatisfied() { + List variants = Arrays.asList(new Variant("premium-variant", "gold", false, 1.0f)); + + // Runtime evaluation: requires plan=premium + Map runtimeEval = new HashMap<>(); + runtimeEval.put("plan", "premium"); + List rollouts = Arrays.asList(new Rollout(1.0f, runtimeEval, null, null)); + + String response = buildFlagsResponse("test-flag", "distinct_id", variants, rollouts, null); + + provider = createProviderWithResponse(response); + + // Context with matching custom properties + provider.startPollingForDefinitions(); + Map customProps = new HashMap<>(); + customProps.put("plan", "premium"); + Map context = buildContextWithProperties("user-123", customProps); + + String result = provider.getVariantValue("test-flag", "fallback", context); + + assertEquals("gold", result); + assertEquals(1, eventSender.getEvents().size()); + } + + @Test + public void testReturnFallbackWhenRuntimeEvaluationConditionsNotSatisfied() { + List variants = Arrays.asList(new Variant("premium-variant", "gold", false, 1.0f)); + + // Runtime evaluation: requires plan=premium + Map runtimeEval = new HashMap<>(); + runtimeEval.put("plan", "premium"); + List rollouts = Arrays.asList(new Rollout(1.0f, runtimeEval, null, null)); + + String response = buildFlagsResponse("test-flag", "distinct_id", variants, rollouts, null); + + provider = createProviderWithResponse(response); + + // Context with non-matching custom properties + provider.startPollingForDefinitions(); + Map customProps = new HashMap<>(); + customProps.put("plan", "free"); + Map context = buildContextWithProperties("user-123", customProps); + + String result = provider.getVariantValue("test-flag", "fallback", context); + + assertEquals("fallback", result); + assertEquals(0, eventSender.getEvents().size()); + } + + // #endregion + // #region Exposure Tracking Tests + + @Test + public void testTrackExposureWhenVariantIsSelected() { + List variants = Arrays.asList(new Variant("variant-a", "value-a", false, 1.0f)); + List rollouts = Arrays.asList(new Rollout(1.0f)); + String response = buildFlagsResponse("test-flag", "distinct_id", variants, rollouts, null); + + provider = createProviderWithResponse(response); + + Map context = buildContext("user-123"); + provider.startPollingForDefinitions(); + provider.getVariantValue("test-flag", "fallback", context); + + 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("local", event.properties.getString("Flag evaluation mode")); + assertTrue(event.properties.getLong("Variant fetch latency (ms)") >= 0); + } + + @Test + public void testDoNotTrackExposureWhenReturningFallback() { + // Empty flags - will return fallback + String response = "{\"flags\":[]}"; + provider = createProviderWithResponse(response); + + Map context = buildContext("user-123"); + provider.startPollingForDefinitions(); + provider.getVariantValue("test-flag", "fallback", context); + + assertEquals(0, eventSender.getEvents().size()); + } + + @Test + public void testDoNotTrackExposureWhenDistinctIdIsMissing() { + List variants = Arrays.asList(new Variant("variant-a", "value-a", false, 1.0f)); + List rollouts = Arrays.asList(new Rollout(1.0f)); + String response = buildFlagsResponse("test-flag", "distinct_id", variants, rollouts, null); + + provider = createProviderWithResponse(response); + + // Context without distinct_id + provider.startPollingForDefinitions(); + Map context = new HashMap<>(); + provider.getVariantValue("test-flag", "fallback", context); + + // No exposure should be tracked (and it returns fallback anyway) + assertEquals(0, eventSender.getEvents().size()); + } + + // #endregion + // #region Readiness Tests + + @Test + public void testReturnReadyWhenFlagsAreLoaded() { + List variants = Arrays.asList(new Variant("variant-a", "value-a", false, 1.0f)); + List rollouts = Arrays.asList(new Rollout(1.0f)); + String response = buildFlagsResponse("test-flag", "distinct_id", variants, rollouts, null); + + provider = createProviderWithResponse(response); + + // Should not be ready before fetching + assertFalse("Should not be ready before fetching", provider.areFlagsReady()); + + // Fetch flag definitions + provider.startPollingForDefinitions(); + + // Should be ready after successful fetch + assertTrue("Should be ready after successful fetch", provider.areFlagsReady()); + } + + @Test + public void testReturnReadyWhenEmptyFlagsAreLoaded() { + String response = "{\"flags\":[]}"; + + provider = createProviderWithResponse(response); + + // Should not be ready before fetching + assertFalse("Should not be ready before fetching", provider.areFlagsReady()); + + // Fetch flag definitions + provider.startPollingForDefinitions(); + + // Should be ready even with empty flags + assertTrue("Should be ready even with empty flags", provider.areFlagsReady()); + } + + // #endregion + // #region Boolean Convenience Method Tests + + @Test + public void testIsEnabledReturnsFalseForNonexistentFlag() { + String response = "{\"flags\":[]}"; + provider = createProviderWithResponse(response); + + Map context = buildContext("user-123"); + provider.startPollingForDefinitions(); + boolean result = provider.isEnabled("non-existent-flag", context); + + assertFalse(result); + } + + @Test + public void testIsEnabledReturnsTrueForBooleanTrueVariant() { + List variants = Arrays.asList(new Variant("enabled", true, false, 1.0f)); + List rollouts = Arrays.asList(new Rollout(1.0f)); + String response = buildFlagsResponse("test-flag", "distinct_id", variants, rollouts, null); + + provider = createProviderWithResponse(response); + + Map context = buildContext("user-123"); + provider.startPollingForDefinitions(); + boolean result = provider.isEnabled("test-flag", context); + + assertTrue(result); + } + + // #endregion + // #region Polling Tests + + @Test + public void testUseMostRecentPolledFlagDefinitions() throws Exception { + // Enable polling with very short interval + config = LocalFlagsConfig.builder() + .projectToken(TEST_TOKEN) + .enablePolling(true) + .pollingIntervalSeconds(1) + .build(); + + // Start with initial flag definition + List variants1 = Arrays.asList(new Variant("variant-old", "old-value", false, 1.0f)); + List rollouts1 = Arrays.asList(new Rollout(1.0f)); + String response1 = buildFlagsResponse("test-flag", "distinct_id", variants1, rollouts1, null); + + provider = new TestableLocalFlagsProvider(config, SDK_VERSION, eventSender); + provider.setMockResponse("/flags/definitions", response1); + provider.startPollingForDefinitions(); + + Map context = buildContext("user-123"); + + // First evaluation should return old value + String result1 = provider.getVariantValue("test-flag", "fallback", context); + assertEquals("old-value", result1); + + // Simulate a polling update by changing the mock response + List variants2 = Arrays.asList(new Variant("variant-new", "new-value", false, 1.0f)); + List rollouts2 = Arrays.asList(new Rollout(1.0f)); + String response2 = buildFlagsResponse("test-flag", "distinct_id", variants2, rollouts2, null); + provider.setMockResponse("/flags/definitions", response2); + + // Wait for polling to occur + Thread.sleep(1500); + + // Second evaluation should return new value after polling update + String result2 = provider.getVariantValue("test-flag", "fallback", context); + assertEquals("new-value", result2); + + provider.stopPollingForDefinitions(); + } + + // #endregion + // #region getAllVariants Tests + + @Test + public void testGetAllVariantsReturnsAllSuccessfullySelectedVariants() { + // Create multiple flags with 100% rollout + List flags = Arrays.asList( + new FlagDefinition("flag-1", "distinct_id", + Arrays.asList(new Variant("variant-a", "value-a", false, 1.0f)), + Arrays.asList(new Rollout(1.0f))), + new FlagDefinition("flag-2", "distinct_id", + Arrays.asList(new Variant("variant-b", "value-b", false, 1.0f)), + Arrays.asList(new Rollout(1.0f))), + new FlagDefinition("flag-3", "distinct_id", + Arrays.asList(new Variant("variant-c", "value-c", false, 1.0f)), + Arrays.asList(new Rollout(1.0f))) + ); + + String response = buildMultipleFlagsResponse(flags); + provider = createProviderWithResponse(response); + provider.startPollingForDefinitions(); + + Map context = buildContext("user-123"); + List> results = provider.getAllVariants(context, true); + + assertEquals(3, results.size()); + + // Verify all variants are successful (not fallbacks) + for (SelectedVariant variant : results) { + assertTrue(variant.isSuccess()); + assertNotNull(variant.getVariantKey()); + } + } + + @Test + public void testGetAllVariantsReturnsEmptyListWhenNoFlagsDefined() { + String response = "{\"flags\":[]}"; + provider = createProviderWithResponse(response); + provider.startPollingForDefinitions(); + + Map context = buildContext("user-123"); + List> results = provider.getAllVariants(context, true); + + assertNotNull(results); + assertEquals(0, results.size()); + } + + @Test + public void testGetAllVariantsReturnsOnlySuccessfulVariants() { + // Create flags with mixed rollout percentages + List flags = Arrays.asList( + new FlagDefinition("flag-success-1", "distinct_id", + Arrays.asList(new Variant("variant-a", "value-a", false, 1.0f)), + Arrays.asList(new Rollout(1.0f))), // 100% rollout - will succeed + new FlagDefinition("flag-fail-1", "distinct_id", + Arrays.asList(new Variant("variant-b", "value-b", false, 1.0f)), + Arrays.asList(new Rollout(0.0f))), // 0% rollout - will fallback + new FlagDefinition("flag-success-2", "distinct_id", + Arrays.asList(new Variant("variant-c", "value-c", false, 1.0f)), + Arrays.asList(new Rollout(1.0f))) // 100% rollout - will succeed + ); + + String response = buildMultipleFlagsResponse(flags); + provider = createProviderWithResponse(response); + provider.startPollingForDefinitions(); + + Map context = buildContext("user-123"); + List> results = provider.getAllVariants(context, true); + + // Should only return the 2 successful variants + assertEquals(2, results.size()); + + // Verify all returned variants are successful + for (SelectedVariant variant : results) { + assertTrue(variant.isSuccess()); + } + } + + @Test + public void testGetAllVariantsTracksExposureEventsWhenReportExposureTrue() { + // Create 3 flags with 100% rollout + List flags = Arrays.asList( + new FlagDefinition("flag-1", "distinct_id", + Arrays.asList(new Variant("variant-a", "value-a", false, 1.0f)), + Arrays.asList(new Rollout(1.0f))), + new FlagDefinition("flag-2", "distinct_id", + Arrays.asList(new Variant("variant-b", "value-b", false, 1.0f)), + Arrays.asList(new Rollout(1.0f))), + new FlagDefinition("flag-3", "distinct_id", + Arrays.asList(new Variant("variant-c", "value-c", false, 1.0f)), + Arrays.asList(new Rollout(1.0f))) + ); + + String response = buildMultipleFlagsResponse(flags); + provider = createProviderWithResponse(response); + provider.startPollingForDefinitions(); + + eventSender.reset(); + Map context = buildContext("user-123"); + List> results = provider.getAllVariants(context, true); + + // Should track 3 exposure events (one for each successful flag) + assertEquals(3, eventSender.getEvents().size()); + assertEquals(3, results.size()); + } + + @Test + public void testGetAllVariantsDoesNotTrackExposureEventsWhenReportExposureFalse() { + // Create 3 flags with 100% rollout + List flags = Arrays.asList( + new FlagDefinition("flag-1", "distinct_id", + Arrays.asList(new Variant("variant-a", "value-a", false, 1.0f)), + Arrays.asList(new Rollout(1.0f))), + new FlagDefinition("flag-2", "distinct_id", + Arrays.asList(new Variant("variant-b", "value-b", false, 1.0f)), + Arrays.asList(new Rollout(1.0f))), + new FlagDefinition("flag-3", "distinct_id", + Arrays.asList(new Variant("variant-c", "value-c", false, 1.0f)), + Arrays.asList(new Rollout(1.0f))) + ); + + String response = buildMultipleFlagsResponse(flags); + provider = createProviderWithResponse(response); + provider.startPollingForDefinitions(); + + eventSender.reset(); + Map context = buildContext("user-123"); + List> results = provider.getAllVariants(context, false); + + // Should NOT track any exposure events + assertEquals(0, eventSender.getEvents().size()); + + // But should still return all 3 variants + assertEquals(3, results.size()); + for (SelectedVariant variant : results) { + assertTrue(variant.isSuccess()); + } + } + + @Test + public void testGetAllVariantsReturnsVariantsWithExperimentMetadata() { + // Create test UUIDs + UUID experimentId1 = UUID.fromString("123e4567-e89b-12d3-a456-426614174000"); + UUID experimentId2 = UUID.fromString("223e4567-e89b-12d3-a456-426614174001"); + + // Create flags with experiment metadata + List flags = Arrays.asList( + new FlagDefinition("flag-1", "distinct_id", + Arrays.asList(new Variant("variant-a", "value-a", false, 1.0f)), + Arrays.asList(new Rollout(1.0f)), + null, experimentId1, true), + new FlagDefinition("flag-2", "distinct_id", + Arrays.asList(new Variant("variant-b", "value-b", false, 1.0f)), + Arrays.asList(new Rollout(1.0f)), + null, experimentId2, false) + ); + + String response = buildMultipleFlagsResponse(flags); + provider = createProviderWithResponse(response); + provider.startPollingForDefinitions(); + + Map context = buildContext("user-123"); + List> results = provider.getAllVariants(context, true); + + assertEquals(2, results.size()); + + // Find variants by their value (order is not guaranteed from HashMap) + SelectedVariant variantA = null; + SelectedVariant variantB = null; + for (SelectedVariant variant : results) { + if ("value-a".equals(variant.getVariantValue())) { + variantA = variant; + } else if ("value-b".equals(variant.getVariantValue())) { + variantB = variant; + } + } + + // Verify both variants were found with their experiment metadata + assertNotNull("variant-a should be present", variantA); + assertNotNull(variantA.getExperimentId()); + assertEquals(experimentId1, variantA.getExperimentId()); + assertEquals(true, variantA.getIsExperimentActive()); + + assertNotNull("variant-b should be present", variantB); + assertNotNull(variantB.getExperimentId()); + assertEquals(experimentId2, variantB.getExperimentId()); + assertEquals(false, variantB.getIsExperimentActive()); + } + + // #endregion + // #region isQaTester Calculation Tests + + @Test + public void testIsQaTesterTrueWhenUserIsInTestUserOverrides() { + 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)); + + // Configure test user override + Map testUsers = new HashMap<>(); + testUsers.put("test-user-123", "treatment"); + + String response = buildFlagsResponse("test-flag", "distinct_id", variants, rollouts, testUsers); + provider = createProviderWithResponse(response); + provider.startPollingForDefinitions(); + + eventSender.reset(); + Map context = buildContext("test-user-123"); + SelectedVariant result = provider.getVariant("test-flag", new SelectedVariant<>("fallback"), context, true); + + // Verify variant was selected + assertTrue(result.isSuccess()); + assertEquals("red", result.getVariantValue()); + assertEquals("treatment", result.getVariantKey()); + + // Verify exposure event was tracked with isQaTester=true + assertEquals(1, eventSender.getEvents().size()); + MockEventSender.ExposureEvent event = eventSender.getEvents().get(eventSender.getEvents().size() - 1); + assertEquals("test-user-123", event.distinctId); + assertEquals("$experiment_started", event.eventName); + assertEquals("test-flag", event.properties.getString("Experiment name")); + assertEquals("treatment", event.properties.getString("Variant name")); + assertEquals(Boolean.TRUE, event.properties.getBoolean("$is_qa_tester")); + } + + @Test + public void testIsQaTesterFalseWhenUserGoesThroughNormalRollout() { + List variants = Arrays.asList( + new Variant("control", "blue", true, 1.0f) + ); + List rollouts = Arrays.asList(new Rollout(1.0f)); + + // Configure test user overrides for a different user + Map testUsers = new HashMap<>(); + testUsers.put("different-user", "control"); + + String response = buildFlagsResponse("test-flag", "distinct_id", variants, rollouts, testUsers); + provider = createProviderWithResponse(response); + provider.startPollingForDefinitions(); + + eventSender.reset(); + Map context = buildContext("normal-user-456"); + SelectedVariant result = provider.getVariant("test-flag", new SelectedVariant<>("fallback"), context, true); + + // Verify variant was selected via normal rollout + assertTrue(result.isSuccess()); + assertEquals("blue", result.getVariantValue()); + assertEquals("control", result.getVariantKey()); + + // Verify exposure event was tracked with isQaTester=false + assertEquals(1, eventSender.getEvents().size()); + MockEventSender.ExposureEvent event = eventSender.getEvents().get(eventSender.getEvents().size() - 1); + assertEquals("normal-user-456", event.distinctId); + assertEquals("$experiment_started", event.eventName); + assertEquals("test-flag", event.properties.getString("Experiment name")); + assertEquals("control", event.properties.getString("Variant name")); + assertEquals(Boolean.FALSE, event.properties.getBoolean("$is_qa_tester")); + } + + // #endregion +} diff --git a/src/test/java/com/mixpanel/mixpanelapi/featureflags/provider/MockHttpProvider.java b/src/test/java/com/mixpanel/mixpanelapi/featureflags/provider/MockHttpProvider.java new file mode 100644 index 0000000..6fc0fbe --- /dev/null +++ b/src/test/java/com/mixpanel/mixpanelapi/featureflags/provider/MockHttpProvider.java @@ -0,0 +1,71 @@ +package com.mixpanel.mixpanelapi.featureflags.provider; + +import java.io.IOException; +import java.util.HashMap; +import java.util.Map; + +/** + * Utility class providing HTTP mocking infrastructure for testing providers. + * This class provides URL-pattern-based HTTP response mocking. + *

+ * Used by test subclasses to override httpGet() behavior without making real network calls. + *

+ */ +public class MockHttpProvider { + private final Map urlToResponseMap = new HashMap<>(); + private IOException mockException; + + /** + * Set a mock response for a specific URL pattern. + * The URL pattern can be a substring that the actual URL should contain. + * + * @param urlPattern the URL pattern to match (substring match) + * @param response the response to return for matching URLs + */ + public void setMockResponse(String urlPattern, String response) { + this.urlToResponseMap.put(urlPattern, response); + this.mockException = null; + } + + /** + * Set a mock exception to be thrown on any HTTP call. + * This simulates network failures or other HTTP errors. + * + * @param exception the exception to throw + */ + public void setMockException(IOException exception) { + this.mockException = exception; + this.urlToResponseMap.clear(); + } + + /** + * Mock implementation of httpGet that returns configured responses. + *

+ * This method: + *

    + *
  • Throws the configured exception if one is set
  • + *
  • Returns a matching mock response based on URL pattern
  • + *
  • Throws an IOException if no mock is configured
  • + *
+ *

+ * + * @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 }