├── .gitignore ├── src ├── main │ ├── resources │ │ └── mixpanel-version.properties │ └── java │ │ └── com │ │ └── mixpanel │ │ └── mixpanelapi │ │ ├── Config.java │ │ ├── featureflags │ │ ├── EventSender.java │ │ ├── model │ │ │ ├── VariantOverride.java │ │ │ ├── Variant.java │ │ │ ├── RuleSet.java │ │ │ ├── Rollout.java │ │ │ ├── SelectedVariant.java │ │ │ └── ExperimentationFlag.java │ │ ├── util │ │ │ ├── TraceparentUtil.java │ │ │ ├── VersionUtil.java │ │ │ └── HashUtils.java │ │ ├── config │ │ │ ├── RemoteFlagsConfig.java │ │ │ ├── LocalFlagsConfig.java │ │ │ └── BaseFlagsConfig.java │ │ └── provider │ │ │ ├── RemoteFlagsProvider.java │ │ │ ├── BaseFlagsProvider.java │ │ │ └── LocalFlagsProvider.java │ │ ├── internal │ │ ├── OrgJsonSerializer.java │ │ └── JsonSerializer.java │ │ ├── MixpanelServerException.java │ │ ├── MixpanelMessageException.java │ │ ├── ClientDelivery.java │ │ ├── DeliveryOptions.java │ │ └── Base64Coder.java ├── test │ └── java │ │ └── com │ │ └── mixpanel │ │ └── mixpanelapi │ │ ├── featureflags │ │ └── provider │ │ │ ├── BaseExposureTrackerMock.java │ │ │ ├── BaseFlagsProviderTest.java │ │ │ ├── MockHttpProvider.java │ │ │ └── RemoteFlagsProviderTest.java │ │ └── internal │ │ └── JsonSerializerTest.java └── demo │ └── java │ └── com │ └── mixpanel │ └── mixpanelapi │ ├── featureflags │ └── demo │ │ ├── RemoteEvaluationExample.java │ │ └── LocalEvaluationExample.java │ └── demo │ └── MixpanelAPIDemo.java ├── mixpanel-java-extension-jackson ├── README.md ├── src │ ├── main │ │ └── java │ │ │ └── com │ │ │ └── mixpanel │ │ │ └── mixpanelapi │ │ │ └── internal │ │ │ └── JacksonSerializer.java │ └── test │ │ └── java │ │ └── com │ │ └── mixpanel │ │ └── mixpanelapi │ │ └── internal │ │ └── JacksonSerializerTest.java └── pom.xml ├── .github ├── workflows │ ├── copilot-setup-steps.yml │ ├── ci.yml │ └── release.yml └── copilot-instructions.md ├── pom.xml ├── CLAUDE.md ├── README.md └── LICENSE /.gitignore: -------------------------------------------------------------------------------- 1 | .project 2 | .classpath 3 | .metadata 4 | target/ 5 | .vscode/ -------------------------------------------------------------------------------- /src/main/resources/mixpanel-version.properties: -------------------------------------------------------------------------------- 1 | version=${project.version} 2 | -------------------------------------------------------------------------------- /src/main/java/com/mixpanel/mixpanelapi/Config.java: -------------------------------------------------------------------------------- 1 | package com.mixpanel.mixpanelapi; 2 | 3 | /* package */ class Config { 4 | public static final String BASE_ENDPOINT = "https://api.mixpanel.com"; 5 | public static final int MAX_MESSAGE_SIZE = 50; 6 | public static final int IMPORT_MAX_MESSAGE_SIZE = 2000; 7 | } 8 | -------------------------------------------------------------------------------- /src/main/java/com/mixpanel/mixpanelapi/featureflags/EventSender.java: -------------------------------------------------------------------------------- 1 | package com.mixpanel.mixpanelapi.featureflags; 2 | 3 | import org.json.JSONObject; 4 | 5 | /** 6 | * Interface for sending events to an analytics backend. 7 | *

8 | * Implementations are responsible for constructing the event payload 9 | * and delivering it to the appropriate destination. 10 | *

11 | */ 12 | @FunctionalInterface 13 | public interface EventSender { 14 | /** 15 | * Sends an event with the specified properties. 16 | * 17 | * @param distinctId the user's distinct ID 18 | * @param eventName the name of the event (e.g., "$experiment_started") 19 | * @param properties the event properties as a JSONObject 20 | */ 21 | void sendEvent(String distinctId, String eventName, JSONObject properties); 22 | } 23 | -------------------------------------------------------------------------------- /src/main/java/com/mixpanel/mixpanelapi/internal/OrgJsonSerializer.java: -------------------------------------------------------------------------------- 1 | package com.mixpanel.mixpanelapi.internal; 2 | 3 | import org.json.JSONArray; 4 | import org.json.JSONObject; 5 | import java.util.List; 6 | 7 | /** 8 | * JSON serialization implementation using org.json library. 9 | * This is the default implementation that maintains backward compatibility. 10 | * 11 | * @since 1.6.1 12 | */ 13 | public class OrgJsonSerializer implements JsonSerializer { 14 | 15 | @Override 16 | public String serializeArray(List messages) { 17 | if (messages == null || messages.isEmpty()) { 18 | return "[]"; 19 | } 20 | 21 | JSONArray array = new JSONArray(); 22 | for (JSONObject message : messages) { 23 | array.put(message); 24 | } 25 | return array.toString(); 26 | } 27 | } -------------------------------------------------------------------------------- /src/main/java/com/mixpanel/mixpanelapi/internal/JsonSerializer.java: -------------------------------------------------------------------------------- 1 | package com.mixpanel.mixpanelapi.internal; 2 | 3 | import org.json.JSONObject; 4 | import java.io.IOException; 5 | import java.util.List; 6 | 7 | /** 8 | * Internal interface for JSON serialization. 9 | * Provides methods to serialize lists of JSONObjects to various formats. 10 | * This allows for different implementations (org.json, Jackson) to be used 11 | * based on performance requirements and available dependencies. 12 | * 13 | * @since 1.6.1 14 | */ 15 | public interface JsonSerializer { 16 | 17 | /** 18 | * Serializes a list of JSONObjects to a JSON array string. 19 | * 20 | * @param messages The list of JSONObjects to serialize 21 | * @return A JSON array string representation 22 | * @throws IOException if serialization fails 23 | */ 24 | String serializeArray(List messages) throws IOException; 25 | } -------------------------------------------------------------------------------- /src/main/java/com/mixpanel/mixpanelapi/MixpanelServerException.java: -------------------------------------------------------------------------------- 1 | package com.mixpanel.mixpanelapi; 2 | 3 | import java.io.IOException; 4 | import java.util.List; 5 | 6 | import org.json.JSONObject; 7 | 8 | /** 9 | * Thrown when the Mixpanel server refuses to accept a set of messages. 10 | * 11 | * This exception can be thrown when messages are too large, 12 | * event times are too old to accept, the api key is invalid, etc. 13 | */ 14 | public class MixpanelServerException extends IOException { 15 | 16 | private static final long serialVersionUID = 8230724556897575457L; 17 | 18 | private final List mBadDelivery; 19 | 20 | public MixpanelServerException(String message, List badDelivery) { 21 | super(message); 22 | mBadDelivery = badDelivery; 23 | } 24 | 25 | public List getBadDeliveryContents() { 26 | return mBadDelivery; 27 | } 28 | 29 | } 30 | -------------------------------------------------------------------------------- /src/main/java/com/mixpanel/mixpanelapi/featureflags/model/VariantOverride.java: -------------------------------------------------------------------------------- 1 | package com.mixpanel.mixpanelapi.featureflags.model; 2 | 3 | /** 4 | * Represents a variant override within a rollout rule. 5 | *

6 | * A variant override forces selection of a specific variant when a rollout matches. 7 | *

8 | *

9 | * This class is immutable and thread-safe. 10 | *

11 | */ 12 | public final class VariantOverride { 13 | private final String key; 14 | 15 | /** 16 | * Creates a new VariantOverride. 17 | * 18 | * @param key the variant key to force selection of 19 | */ 20 | public VariantOverride(String key) { 21 | this.key = key; 22 | } 23 | 24 | /** 25 | * @return the variant key 26 | */ 27 | public String getKey() { 28 | return key; 29 | } 30 | 31 | @Override 32 | public String toString() { 33 | return "VariantOverride{" + 34 | "key='" + key + '\'' + 35 | '}'; 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/main/java/com/mixpanel/mixpanelapi/MixpanelMessageException.java: -------------------------------------------------------------------------------- 1 | package com.mixpanel.mixpanelapi; 2 | 3 | import org.json.JSONObject; 4 | 5 | /** 6 | * Thrown when the library detects malformed or invalid Mixpanel messages. 7 | * 8 | * Mixpanel messages are represented as JSONObjects, but not all JSONObjects represent valid Mixpanel messages. 9 | * MixpanelMessageExceptions are thrown when a JSONObject is passed to the Mixpanel library that can't be 10 | * passed on to the Mixpanel service. 11 | * 12 | * This is a runtime exception, since in most cases it is thrown due to errors in your application architecture. 13 | */ 14 | public class MixpanelMessageException extends RuntimeException { 15 | 16 | private static final long serialVersionUID = -6256936727567434262L; 17 | 18 | private JSONObject mBadMessage = null; 19 | 20 | /* package */ MixpanelMessageException(String message, JSONObject cause) { 21 | super(message); 22 | mBadMessage = cause; 23 | } 24 | 25 | /** 26 | * @return the (possibly null) JSONObject message associated with the failure 27 | */ 28 | public JSONObject getBadMessage() { 29 | return mBadMessage; 30 | } 31 | 32 | } 33 | -------------------------------------------------------------------------------- /src/test/java/com/mixpanel/mixpanelapi/featureflags/provider/BaseExposureTrackerMock.java: -------------------------------------------------------------------------------- 1 | package com.mixpanel.mixpanelapi.featureflags.provider; 2 | 3 | import java.util.ArrayList; 4 | import java.util.List; 5 | 6 | /** 7 | * Base class for exposure tracker mocks. 8 | * Provides common event storage and retrieval functionality. 9 | *

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

13 | * 14 | * @param the type of exposure event 15 | */ 16 | public abstract class BaseExposureTrackerMock { 17 | protected final List events = new ArrayList<>(); 18 | 19 | /** 20 | * Reset the tracker by clearing all recorded events. 21 | */ 22 | public void reset() { 23 | events.clear(); 24 | } 25 | 26 | /** 27 | * Get the count of tracked exposure events. 28 | * 29 | * @return the number of events tracked 30 | */ 31 | public int getEventCount() { 32 | return events.size(); 33 | } 34 | 35 | /** 36 | * Get the most recently tracked exposure event. 37 | * 38 | * @return the last event, or null if no events have been tracked 39 | */ 40 | public E getLastEvent() { 41 | return events.isEmpty() ? null : events.get(events.size() - 1); 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/main/java/com/mixpanel/mixpanelapi/featureflags/util/TraceparentUtil.java: -------------------------------------------------------------------------------- 1 | package com.mixpanel.mixpanelapi.featureflags.util; 2 | 3 | import java.util.UUID; 4 | 5 | /** 6 | * Utility class for generating W3C Trace Context traceparent headers. 7 | *

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

11 | *

12 | * This class is thread-safe. 13 | *

14 | * 15 | * @see W3C Trace Context 16 | */ 17 | public final class TraceparentUtil { 18 | 19 | /** 20 | * Private constructor to prevent instantiation. 21 | */ 22 | private TraceparentUtil() { 23 | throw new AssertionError("TraceparentUtil should not be instantiated"); 24 | } 25 | 26 | /** 27 | * Generates a W3C traceparent header value. 28 | *

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

33 | * 34 | * @return a traceparent header value 35 | */ 36 | public static String generateTraceparent() { 37 | String traceId = UUID.randomUUID().toString().replace("-", ""); 38 | String spanId = UUID.randomUUID().toString().replace("-", "").substring(0, 16); 39 | return "00-" + traceId + "-" + spanId + "-01"; 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /mixpanel-java-extension-jackson/README.md: -------------------------------------------------------------------------------- 1 | # Mixpanel Java SDK - Jackson Extension 2 | 3 | High-performance Jackson serializer extension for the Mixpanel Java SDK. This extension provides improved JSON serialization performance for large batch operations. 4 | 5 | ## Installation 6 | 7 | Add this dependency to your project: 8 | 9 | ### Maven 10 | ```xml 11 | 12 | com.mixpanel 13 | mixpanel-java-extension-jackson 14 | 1.6.1 15 | 16 | ``` 17 | 18 | ### Gradle 19 | ```gradle 20 | implementation 'com.mixpanel:mixpanel-java-extension-jackson:1.6.1' 21 | ``` 22 | 23 | This extension includes: 24 | - `mixpanel-java` (core SDK) 25 | - `jackson-core` 2.20.0 26 | 27 | ## Usage 28 | 29 | To use the Jackson serializer, pass an instance to the MixpanelAPI builder: 30 | 31 | ```java 32 | import com.mixpanel.mixpanelapi.MixpanelAPI; 33 | import com.mixpanel.mixpanelapi.internal.JacksonSerializer; 34 | 35 | MixpanelAPI mixpanel = new MixpanelAPI.Builder() 36 | .jsonSerializer(new JacksonSerializer()) 37 | .build(); 38 | ``` 39 | 40 | ## Key benefits 41 | - **Significant performance gains**: 2-5x faster serialization for batches of 50+ messages 42 | - **Optimal for `/import`**: Most beneficial when importing large batches (up to 2000 events) 43 | 44 | The performance improvement is most noticeable when: 45 | - Importing historical data via the `/import` endpoint 46 | - Sending batches of 50+ events 47 | - Processing high-volume event streams 48 | 49 | ## License 50 | 51 | ``` 52 | See LICENSE File for details. 53 | ``` -------------------------------------------------------------------------------- /.github/workflows/copilot-setup-steps.yml: -------------------------------------------------------------------------------- 1 | name: "Copilot Setup Steps" 2 | 3 | # Automatically run the setup steps when they are changed to allow for easy validation, and 4 | # allow manual testing through the repository's "Actions" tab 5 | on: 6 | workflow_dispatch: 7 | push: 8 | paths: 9 | - .github/workflows/copilot-setup-steps.yml 10 | pull_request: 11 | paths: 12 | - .github/workflows/copilot-setup-steps.yml 13 | 14 | jobs: 15 | # The job MUST be called `copilot-setup-steps` or it will not be picked up by Copilot. 16 | copilot-setup-steps: 17 | runs-on: ubuntu-latest 18 | 19 | # Set the permissions to the lowest permissions possible needed for your steps. 20 | # Copilot will be given its own token for its operations. 21 | permissions: 22 | # If you want to clone the repository as part of your setup steps, for example to install dependencies, you'll need the `contents: read` permission. If you don't clone the repository in your setup steps, Copilot will do this for you automatically after the steps complete. 23 | contents: read 24 | 25 | # You can define any steps you want, and they will run before the agent starts. 26 | # If you do not check out your code, Copilot will do this for you. 27 | steps: 28 | - name: Checkout code 29 | uses: actions/checkout@v4 30 | 31 | - name: Set up JDK 8 32 | uses: actions/setup-java@v4 33 | with: 34 | java-version: '8' 35 | distribution: 'temurin' 36 | cache: 'maven' 37 | 38 | - name: Install Maven dependencies 39 | run: mvn dependency:resolve --batch-mode --no-transfer-progress 40 | -------------------------------------------------------------------------------- /src/main/java/com/mixpanel/mixpanelapi/featureflags/config/RemoteFlagsConfig.java: -------------------------------------------------------------------------------- 1 | package com.mixpanel.mixpanelapi.featureflags.config; 2 | 3 | /** 4 | * Configuration for remote feature flags evaluation. 5 | *

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

9 | */ 10 | public final class RemoteFlagsConfig extends BaseFlagsConfig { 11 | 12 | /** 13 | * Creates a new RemoteFlagsConfig with specified settings. 14 | * 15 | * @param projectToken the Mixpanel project token 16 | * @param apiHost the API endpoint host 17 | * @param requestTimeoutSeconds HTTP request timeout in seconds 18 | */ 19 | private RemoteFlagsConfig(String projectToken, String apiHost, int requestTimeoutSeconds) { 20 | super(projectToken, apiHost, requestTimeoutSeconds); 21 | } 22 | 23 | /** 24 | * Builder for RemoteFlagsConfig. 25 | */ 26 | public static final class Builder extends BaseFlagsConfig.Builder { 27 | 28 | /** 29 | * Builds the RemoteFlagsConfig instance. 30 | * 31 | * @return a new RemoteFlagsConfig 32 | */ 33 | @Override 34 | public RemoteFlagsConfig build() { 35 | return new RemoteFlagsConfig(projectToken, apiHost, requestTimeoutSeconds); 36 | } 37 | } 38 | 39 | /** 40 | * Creates a new builder for RemoteFlagsConfig. 41 | * 42 | * @return a new builder instance 43 | */ 44 | public static Builder builder() { 45 | return new Builder(); 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: [ master ] 6 | pull_request: 7 | branches: [ master ] 8 | 9 | jobs: 10 | test: 11 | runs-on: ubuntu-latest 12 | strategy: 13 | matrix: 14 | java-version: ['8', '11', '17', '21'] 15 | 16 | steps: 17 | - name: Checkout code 18 | uses: actions/checkout@v4 19 | 20 | - name: Set up JDK ${{ matrix.java-version }} 21 | uses: actions/setup-java@v4 22 | with: 23 | java-version: ${{ matrix.java-version }} 24 | distribution: 'temurin' 25 | 26 | - name: Cache Maven dependencies 27 | uses: actions/cache@v4 28 | with: 29 | path: ~/.m2/repository 30 | key: ${{ runner.os }}-maven-${{ hashFiles('**/pom.xml') }} 31 | restore-keys: | 32 | ${{ runner.os }}-maven- 33 | 34 | - name: Run tests 35 | run: mvn clean test 36 | 37 | - name: Build project 38 | run: mvn clean package 39 | 40 | - name: Generate JavaDoc 41 | run: mvn javadoc:javadoc 42 | 43 | - name: Upload test results 44 | if: always() 45 | uses: actions/upload-artifact@v4 46 | with: 47 | name: test-results-java-${{ matrix.java-version }} 48 | path: target/surefire-reports/ 49 | 50 | code-quality: 51 | runs-on: ubuntu-latest 52 | 53 | steps: 54 | - name: Checkout code 55 | uses: actions/checkout@v4 56 | 57 | - name: Set up JDK 8 58 | uses: actions/setup-java@v4 59 | with: 60 | java-version: '8' 61 | distribution: 'temurin' 62 | 63 | - name: Cache Maven dependencies 64 | uses: actions/cache@v4 65 | with: 66 | path: ~/.m2/repository 67 | key: ${{ runner.os }}-maven-${{ hashFiles('**/pom.xml') }} 68 | restore-keys: | 69 | ${{ runner.os }}-maven- 70 | 71 | - name: Check for dependency updates 72 | run: mvn versions:display-dependency-updates -------------------------------------------------------------------------------- /src/main/java/com/mixpanel/mixpanelapi/featureflags/model/Variant.java: -------------------------------------------------------------------------------- 1 | package com.mixpanel.mixpanelapi.featureflags.model; 2 | 3 | /** 4 | * Represents a variant within a feature flag experiment. 5 | *

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

9 | *

10 | * This class is immutable and thread-safe. 11 | *

12 | */ 13 | public final class Variant { 14 | private final String key; 15 | private final Object value; 16 | private final boolean isControl; 17 | private final float split; 18 | 19 | /** 20 | * Creates a new Variant. 21 | * 22 | * @param key the unique identifier for this variant 23 | * @param value the value associated with this variant (can be boolean, string, number, or JSON object) 24 | * @param isControl whether this variant is the control variant 25 | * @param split the percentage split allocation for this variant (0.0-1.0) 26 | */ 27 | public Variant(String key, Object value, boolean isControl, float split) { 28 | this.key = key; 29 | this.value = value; 30 | this.isControl = isControl; 31 | this.split = split; 32 | } 33 | 34 | /** 35 | * @return the unique identifier for this variant 36 | */ 37 | public String getKey() { 38 | return key; 39 | } 40 | 41 | /** 42 | * @return the value associated with this variant 43 | */ 44 | public Object getValue() { 45 | return value; 46 | } 47 | 48 | /** 49 | * @return true if this is the control variant 50 | */ 51 | public boolean isControl() { 52 | return isControl; 53 | } 54 | 55 | /** 56 | * @return the percentage split allocation (0.0-1.0) 57 | */ 58 | public float getSplit() { 59 | return split; 60 | } 61 | 62 | @Override 63 | public String toString() { 64 | return "Variant{" + 65 | "key='" + key + '\'' + 66 | ", value=" + value + 67 | ", isControl=" + isControl + 68 | ", split=" + split + 69 | '}'; 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /src/test/java/com/mixpanel/mixpanelapi/featureflags/provider/BaseFlagsProviderTest.java: -------------------------------------------------------------------------------- 1 | package com.mixpanel.mixpanelapi.featureflags.provider; 2 | 3 | import org.junit.After; 4 | 5 | import java.util.HashMap; 6 | import java.util.Map; 7 | 8 | /** 9 | * Base class for feature flags provider tests. 10 | * Provides shared test infrastructure, lifecycle management, and helper methods. 11 | */ 12 | public abstract class BaseFlagsProviderTest { 13 | 14 | // Shared constants 15 | protected static final String TEST_TOKEN = "test-token"; 16 | protected static final String SDK_VERSION = "1.0.0"; 17 | protected static final String TEST_USER = "user-123"; 18 | 19 | /** 20 | * Shared test lifecycle - closes the provider after each test if it's closeable. 21 | */ 22 | @After 23 | public void tearDown() { 24 | Object provider = getProvider(); 25 | if (provider instanceof AutoCloseable) { 26 | try { 27 | ((AutoCloseable) provider).close(); 28 | } catch (Exception e) { 29 | // Ignore cleanup errors 30 | } 31 | } 32 | } 33 | 34 | /** 35 | * Abstract method for subclasses to provide their provider instance. 36 | * This allows the base class to manage the lifecycle. 37 | * 38 | * @return the provider instance to be closed after each test (if closeable) 39 | */ 40 | protected abstract Object getProvider(); 41 | 42 | /** 43 | * Helper to build a simple context with distinct_id. 44 | * 45 | * @param distinctId the distinct ID to include in the context 46 | * @return a context map with distinct_id 47 | */ 48 | protected Map buildContext(String distinctId) { 49 | Map context = new HashMap<>(); 50 | context.put("distinct_id", distinctId); 51 | return context; 52 | } 53 | 54 | /** 55 | * Helper to build context with custom properties. 56 | * 57 | * @param distinctId the distinct ID to include in the context 58 | * @param customProps custom properties to include 59 | * @return a context map with distinct_id and custom_properties 60 | */ 61 | protected Map buildContextWithProperties(String distinctId, Map customProps) { 62 | Map context = buildContext(distinctId); 63 | context.put("custom_properties", customProps); 64 | return context; 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /src/main/java/com/mixpanel/mixpanelapi/featureflags/util/VersionUtil.java: -------------------------------------------------------------------------------- 1 | package com.mixpanel.mixpanelapi.featureflags.util; 2 | 3 | import java.io.IOException; 4 | import java.io.InputStream; 5 | import java.util.Properties; 6 | import java.util.logging.Level; 7 | import java.util.logging.Logger; 8 | 9 | /** 10 | * Utility class for accessing the SDK version. 11 | *

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

15 | */ 16 | public class VersionUtil { 17 | private static final Logger logger = Logger.getLogger(VersionUtil.class.getName()); 18 | private static final String VERSION_FILE = "mixpanel-version.properties"; 19 | private static final String VERSION_KEY = "version"; 20 | private static final String UNKNOWN_VERSION = "unknown"; 21 | 22 | private static String cachedVersion = null; 23 | 24 | private VersionUtil() { 25 | // Utility class - prevent instantiation 26 | } 27 | 28 | /** 29 | * Gets the SDK version. 30 | *

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

34 | * 35 | * @return the SDK version string 36 | */ 37 | public static String getVersion() { 38 | if (cachedVersion == null) { 39 | cachedVersion = loadVersion(); 40 | } 41 | return cachedVersion; 42 | } 43 | 44 | /** 45 | * Loads the version from the properties file. 46 | */ 47 | private static String loadVersion() { 48 | try (InputStream input = VersionUtil.class.getClassLoader().getResourceAsStream(VERSION_FILE)) { 49 | if (input == null) { 50 | logger.log(Level.WARNING, "Version file not found: " + VERSION_FILE + " (using fallback version)"); 51 | return UNKNOWN_VERSION; 52 | } 53 | 54 | Properties props = new Properties(); 55 | props.load(input); 56 | 57 | String version = props.getProperty(VERSION_KEY); 58 | if (version == null || version.isEmpty()) { 59 | logger.log(Level.WARNING, "Version property not found in " + VERSION_FILE); 60 | return UNKNOWN_VERSION; 61 | } 62 | 63 | return version; 64 | } catch (IOException e) { 65 | logger.log(Level.WARNING, "Failed to load version from " + VERSION_FILE, e); 66 | return UNKNOWN_VERSION; 67 | } 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /src/test/java/com/mixpanel/mixpanelapi/featureflags/provider/MockHttpProvider.java: -------------------------------------------------------------------------------- 1 | package com.mixpanel.mixpanelapi.featureflags.provider; 2 | 3 | import java.io.IOException; 4 | import java.util.HashMap; 5 | import java.util.Map; 6 | 7 | /** 8 | * Utility class providing HTTP mocking infrastructure for testing providers. 9 | * This class provides URL-pattern-based HTTP response mocking. 10 | *

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

13 | */ 14 | public class MockHttpProvider { 15 | private final Map urlToResponseMap = new HashMap<>(); 16 | private IOException mockException; 17 | 18 | /** 19 | * Set a mock response for a specific URL pattern. 20 | * The URL pattern can be a substring that the actual URL should contain. 21 | * 22 | * @param urlPattern the URL pattern to match (substring match) 23 | * @param response the response to return for matching URLs 24 | */ 25 | public void setMockResponse(String urlPattern, String response) { 26 | this.urlToResponseMap.put(urlPattern, response); 27 | this.mockException = null; 28 | } 29 | 30 | /** 31 | * Set a mock exception to be thrown on any HTTP call. 32 | * This simulates network failures or other HTTP errors. 33 | * 34 | * @param exception the exception to throw 35 | */ 36 | public void setMockException(IOException exception) { 37 | this.mockException = exception; 38 | this.urlToResponseMap.clear(); 39 | } 40 | 41 | /** 42 | * Mock implementation of httpGet that returns configured responses. 43 | *

44 | * This method: 45 | *

    46 | *
  • Throws the configured exception if one is set
  • 47 | *
  • Returns a matching mock response based on URL pattern
  • 48 | *
  • Throws an IOException if no mock is configured
  • 49 | *
50 | *

51 | * 52 | * @param urlString the URL being requested 53 | * @return the mock response for this URL 54 | * @throws IOException if an exception is configured or no mock found 55 | */ 56 | public String mockHttpGet(String urlString) throws IOException { 57 | if (mockException != null) { 58 | throw mockException; 59 | } 60 | 61 | // Try to find a matching URL pattern 62 | for (Map.Entry entry : urlToResponseMap.entrySet()) { 63 | if (urlString.contains(entry.getKey())) { 64 | return entry.getValue(); 65 | } 66 | } 67 | 68 | // No mock found - throw exception to simulate network error 69 | throw new IOException("No mock response configured for URL: " + urlString); 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /src/main/java/com/mixpanel/mixpanelapi/featureflags/model/RuleSet.java: -------------------------------------------------------------------------------- 1 | package com.mixpanel.mixpanelapi.featureflags.model; 2 | 3 | import java.util.Collections; 4 | import java.util.List; 5 | import java.util.Map; 6 | 7 | /** 8 | * Represents the complete set of rules for a feature flag experiment. 9 | *

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

13 | *

14 | * This class is immutable and thread-safe. 15 | *

16 | */ 17 | public final class RuleSet { 18 | private final List variants; 19 | private final List rollouts; 20 | private final Map testUserOverrides; 21 | 22 | /** 23 | * Creates a new RuleSet with all components. 24 | * 25 | * @param variants the list of available variants for this flag 26 | * @param rollouts the list of rollout rules (evaluated in order) 27 | * @param testUserOverrides optional map of distinct_id to variant key for test users 28 | */ 29 | public RuleSet(List variants, List rollouts, Map testUserOverrides) { 30 | this.variants = variants != null ? Collections.unmodifiableList(variants) : Collections.emptyList(); 31 | this.rollouts = rollouts != null ? Collections.unmodifiableList(rollouts) : Collections.emptyList(); 32 | this.testUserOverrides = testUserOverrides != null 33 | ? Collections.unmodifiableMap(testUserOverrides) 34 | : null; 35 | } 36 | 37 | /** 38 | * Creates a new RuleSet without test user overrides. 39 | * 40 | * @param variants the list of available variants for this flag 41 | * @param rollouts the list of rollout rules (evaluated in order) 42 | */ 43 | public RuleSet(List variants, List rollouts) { 44 | this(variants, rollouts, null); 45 | } 46 | 47 | /** 48 | * @return the list of available variants 49 | */ 50 | public List getVariants() { 51 | return variants; 52 | } 53 | 54 | /** 55 | * @return the list of rollout rules 56 | */ 57 | public List getRollouts() { 58 | return rollouts; 59 | } 60 | 61 | /** 62 | * @return the map of test user overrides (distinct_id to variant key), or null if not set 63 | */ 64 | public Map getTestUserOverrides() { 65 | return testUserOverrides; 66 | } 67 | 68 | /** 69 | * @return true if test user overrides are configured 70 | */ 71 | public boolean hasTestUserOverrides() { 72 | return testUserOverrides != null && !testUserOverrides.isEmpty(); 73 | } 74 | 75 | @Override 76 | public String toString() { 77 | return "RuleSet{" + 78 | "variants=" + variants + 79 | ", rollouts=" + rollouts + 80 | ", testUserOverrides=" + testUserOverrides + 81 | '}'; 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /src/main/java/com/mixpanel/mixpanelapi/featureflags/config/LocalFlagsConfig.java: -------------------------------------------------------------------------------- 1 | package com.mixpanel.mixpanelapi.featureflags.config; 2 | 3 | /** 4 | * Configuration for local feature flags evaluation. 5 | *

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

9 | */ 10 | public final class LocalFlagsConfig extends BaseFlagsConfig { 11 | private final boolean enablePolling; 12 | private final int pollingIntervalSeconds; 13 | 14 | /** 15 | * Creates a new LocalFlagsConfig with all settings. 16 | * 17 | * @param projectToken the Mixpanel project token 18 | * @param apiHost the API endpoint host 19 | * @param requestTimeoutSeconds HTTP request timeout in seconds 20 | * @param enablePolling whether to periodically refresh flag definitions 21 | * @param pollingIntervalSeconds time between refresh cycles in seconds 22 | */ 23 | private LocalFlagsConfig(String projectToken, String apiHost, int requestTimeoutSeconds, boolean enablePolling, int pollingIntervalSeconds) { 24 | super(projectToken, apiHost, requestTimeoutSeconds); 25 | this.enablePolling = enablePolling; 26 | this.pollingIntervalSeconds = pollingIntervalSeconds; 27 | } 28 | 29 | /** 30 | * @return true if polling is enabled 31 | */ 32 | public boolean isEnablePolling() { 33 | return enablePolling; 34 | } 35 | 36 | /** 37 | * @return the polling interval in seconds 38 | */ 39 | public int getPollingIntervalSeconds() { 40 | return pollingIntervalSeconds; 41 | } 42 | 43 | /** 44 | * Builder for LocalFlagsConfig. 45 | */ 46 | public static final class Builder extends BaseFlagsConfig.Builder { 47 | private boolean enablePolling = true; 48 | private int pollingIntervalSeconds = 60; 49 | 50 | /** 51 | * Sets whether polling should be enabled. 52 | * 53 | * @param enablePolling true to enable periodic flag definition refresh 54 | * @return this builder 55 | */ 56 | public Builder enablePolling(boolean enablePolling) { 57 | this.enablePolling = enablePolling; 58 | return this; 59 | } 60 | 61 | /** 62 | * Sets the polling interval. 63 | * 64 | * @param pollingIntervalSeconds time between refresh cycles in seconds 65 | * @return this builder 66 | */ 67 | public Builder pollingIntervalSeconds(int pollingIntervalSeconds) { 68 | this.pollingIntervalSeconds = pollingIntervalSeconds; 69 | return this; 70 | } 71 | 72 | /** 73 | * Builds the LocalFlagsConfig instance. 74 | * 75 | * @return a new LocalFlagsConfig 76 | */ 77 | @Override 78 | public LocalFlagsConfig build() { 79 | return new LocalFlagsConfig(projectToken, apiHost, requestTimeoutSeconds, enablePolling, pollingIntervalSeconds); 80 | } 81 | } 82 | 83 | /** 84 | * Creates a new builder for LocalFlagsConfig. 85 | * 86 | * @return a new builder instance 87 | */ 88 | public static Builder builder() { 89 | return new Builder(); 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /src/main/java/com/mixpanel/mixpanelapi/featureflags/util/HashUtils.java: -------------------------------------------------------------------------------- 1 | package com.mixpanel.mixpanelapi.featureflags.util; 2 | 3 | import java.nio.charset.StandardCharsets; 4 | 5 | /** 6 | * Utility class for hashing operations used in feature flag evaluation. 7 | *

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

11 | *

12 | * This class is thread-safe and all methods are static. 13 | *

14 | */ 15 | public final class HashUtils { 16 | 17 | /** 18 | * FNV-1a 64-bit offset basis constant. 19 | */ 20 | private static final long FNV_OFFSET_BASIS_64 = 0xcbf29ce484222325L; 21 | 22 | /** 23 | * FNV-1a 64-bit prime constant. 24 | */ 25 | private static final long FNV_PRIME_64 = 0x100000001b3L; 26 | 27 | // Private constructor to prevent instantiation 28 | private HashUtils() { 29 | throw new AssertionError("HashUtils should not be instantiated"); 30 | } 31 | 32 | /** 33 | * Generates a normalized hash value in the range [0.0, 1.0) using the FNV-1a algorithm. 34 | * 35 | * @param key the input string to hash (typically user identifier + flag key) 36 | * @param salt the salt to append to the input (e.g., "rollout" or "variant") 37 | * @return a float value in the range [0.0, 1.0) 38 | * @throws IllegalArgumentException if key or salt is null 39 | */ 40 | public static float normalizedHash(String key, String salt) { 41 | if (key == null) { 42 | throw new IllegalArgumentException("Key cannot be null"); 43 | } 44 | if (salt == null) { 45 | throw new IllegalArgumentException("Salt cannot be null"); 46 | } 47 | 48 | // Combine key and salt 49 | String combined = key + salt; 50 | byte[] bytes = combined.getBytes(StandardCharsets.UTF_8); 51 | 52 | // FNV-1a 64-bit hash 53 | long hash = FNV_OFFSET_BASIS_64; 54 | for (byte b : bytes) { 55 | // XOR with byte (converting to unsigned) 56 | hash ^= (b & 0xff); 57 | // Multiply by FNV prime 58 | hash *= FNV_PRIME_64; 59 | } 60 | 61 | // Normalize to [0.0, 1.0) matching Python's approach 62 | // Use Long.remainderUnsigned to handle negative values correctly 63 | return (float) (Long.remainderUnsigned(hash, 100) / 100.0); 64 | } 65 | 66 | /** 67 | * Generates a normalized hash value for rollout selection. 68 | *

69 | * Convenience method that uses "rollout" as the salt. 70 | *

71 | * 72 | * @param input the input string to hash (typically user identifier + flag key) 73 | * @return a float value in the range [0.0, 1.0) 74 | */ 75 | public static float rolloutHash(String input) { 76 | return normalizedHash(input, "rollout"); 77 | } 78 | 79 | /** 80 | * Generates a normalized hash value for variant selection. 81 | *

82 | * Convenience method that uses "variant" as the salt. 83 | *

84 | * 85 | * @param input the input string to hash (typically user identifier + flag key) 86 | * @return a float value in the range [0.0, 1.0) 87 | */ 88 | public static float variantHash(String input) { 89 | return normalizedHash(input, "variant"); 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /src/main/java/com/mixpanel/mixpanelapi/featureflags/config/BaseFlagsConfig.java: -------------------------------------------------------------------------------- 1 | package com.mixpanel.mixpanelapi.featureflags.config; 2 | 3 | /** 4 | * Base configuration for feature flags providers. 5 | *

6 | * Contains common configuration settings shared by both local and remote evaluation modes. 7 | *

8 | */ 9 | public class BaseFlagsConfig { 10 | private final String projectToken; 11 | private final String apiHost; 12 | private final int requestTimeoutSeconds; 13 | 14 | /** 15 | * Creates a new BaseFlagsConfig with specified settings. 16 | * 17 | * @param projectToken the Mixpanel project token 18 | * @param apiHost the API endpoint host 19 | * @param requestTimeoutSeconds HTTP request timeout in seconds 20 | */ 21 | protected BaseFlagsConfig(String projectToken, String apiHost, int requestTimeoutSeconds) { 22 | this.projectToken = projectToken; 23 | this.apiHost = apiHost; 24 | this.requestTimeoutSeconds = requestTimeoutSeconds; 25 | } 26 | 27 | /** 28 | * @return the Mixpanel project token 29 | */ 30 | public String getProjectToken() { 31 | return projectToken; 32 | } 33 | 34 | /** 35 | * @return the API endpoint host 36 | */ 37 | public String getApiHost() { 38 | return apiHost; 39 | } 40 | 41 | /** 42 | * @return the HTTP request timeout in seconds 43 | */ 44 | public int getRequestTimeoutSeconds() { 45 | return requestTimeoutSeconds; 46 | } 47 | 48 | /** 49 | * Builder for BaseFlagsConfig. 50 | * 51 | * @param the type of builder (for subclass builders) 52 | */ 53 | @SuppressWarnings("unchecked") 54 | public static class Builder> { 55 | protected String projectToken; 56 | protected String apiHost = "api.mixpanel.com"; 57 | protected int requestTimeoutSeconds = 10; 58 | 59 | /** 60 | * Sets the project token. 61 | * 62 | * @param projectToken the Mixpanel project token 63 | * @return this builder 64 | */ 65 | public T projectToken(String projectToken) { 66 | this.projectToken = projectToken; 67 | return (T) this; 68 | } 69 | 70 | /** 71 | * Sets the API host. 72 | * 73 | * @param apiHost the API endpoint host (e.g., "api.mixpanel.com", "api-eu.mixpanel.com") 74 | * @return this builder 75 | */ 76 | public T apiHost(String apiHost) { 77 | this.apiHost = apiHost; 78 | return (T) this; 79 | } 80 | 81 | /** 82 | * Sets the request timeout. 83 | * 84 | * @param requestTimeoutSeconds HTTP request timeout in seconds 85 | * @return this builder 86 | */ 87 | public T requestTimeoutSeconds(int requestTimeoutSeconds) { 88 | this.requestTimeoutSeconds = requestTimeoutSeconds; 89 | return (T) this; 90 | } 91 | 92 | /** 93 | * Builds the BaseFlagsConfig instance. 94 | * 95 | * @return a new BaseFlagsConfig 96 | */ 97 | public BaseFlagsConfig build() { 98 | return new BaseFlagsConfig(projectToken, apiHost, requestTimeoutSeconds); 99 | } 100 | } 101 | 102 | /** 103 | * Creates a new builder for BaseFlagsConfig. 104 | * 105 | * @return a new builder instance 106 | */ 107 | public static Builder builder() { 108 | return new Builder<>(); 109 | } 110 | } 111 | -------------------------------------------------------------------------------- /src/main/java/com/mixpanel/mixpanelapi/ClientDelivery.java: -------------------------------------------------------------------------------- 1 | package com.mixpanel.mixpanelapi; 2 | 3 | import java.util.ArrayList; 4 | import java.util.List; 5 | 6 | import org.json.JSONException; 7 | import org.json.JSONObject; 8 | 9 | /** 10 | * A ClientDelivery can be used to send multiple messages to Mixpanel. 11 | */ 12 | public class ClientDelivery { 13 | 14 | private final List mEventsMessages = new ArrayList(); 15 | private final List mPeopleMessages = new ArrayList(); 16 | private final List mGroupMessages = new ArrayList(); 17 | private final List mImportMessages = new ArrayList(); 18 | 19 | /** 20 | * Adds an individual message to this delivery. Messages to Mixpanel are often more efficient when sent in batches. 21 | * 22 | * @param message a JSONObject produced by #{@link MessageBuilder}. Arguments not from MessageBuilder will throw an exception. 23 | * @throws MixpanelMessageException if the given JSONObject is not formatted appropriately. 24 | * @see MessageBuilder 25 | */ 26 | public void addMessage(JSONObject message) { 27 | if (! isValidMessage(message)) { 28 | throw new MixpanelMessageException("Given JSONObject was not a valid Mixpanel message", message); 29 | } 30 | // ELSE message is valid 31 | 32 | try { 33 | String messageType = message.getString("message_type"); 34 | JSONObject messageContent = message.getJSONObject("message"); 35 | 36 | if (messageType.equals("event")) { 37 | mEventsMessages.add(messageContent); 38 | } 39 | else if (messageType.equals("people")) { 40 | mPeopleMessages.add(messageContent); 41 | } 42 | else if (messageType.equals("group")) { 43 | mGroupMessages.add(messageContent); 44 | } 45 | else if (messageType.equals("import")) { 46 | mImportMessages.add(messageContent); 47 | } 48 | } catch (JSONException e) { 49 | throw new RuntimeException("Apparently valid mixpanel message could not be interpreted.", e); 50 | } 51 | } 52 | 53 | /** 54 | * Returns true if the given JSONObject appears to be a valid Mixpanel message, created with #{@link MessageBuilder}. 55 | * @param message a JSONObject to be tested 56 | * @return true if the argument appears to be a Mixpanel message 57 | */ 58 | public boolean isValidMessage(JSONObject message) { 59 | // See MessageBuilder for how these messages are formatted. 60 | boolean ret = true; 61 | try { 62 | int envelopeVersion = message.getInt("envelope_version"); 63 | if (envelopeVersion > 0) { 64 | String messageType = message.getString("message_type"); 65 | JSONObject messageContents = message.getJSONObject("message"); 66 | 67 | if (messageContents == null) { 68 | ret = false; 69 | } 70 | else if (!messageType.equals("event") && !messageType.equals("people") && !messageType.equals("group") && !messageType.equals("import")) { 71 | ret = false; 72 | } 73 | } 74 | } catch (JSONException e) { 75 | ret = false; 76 | } 77 | 78 | return ret; 79 | } 80 | 81 | /* package */ List getEventsMessages() { 82 | return mEventsMessages; 83 | } 84 | 85 | /* package */ List getPeopleMessages() { 86 | return mPeopleMessages; 87 | } 88 | 89 | /* package */ List getGroupMessages() { 90 | return mGroupMessages; 91 | } 92 | 93 | /* package */ List getImportMessages() { 94 | return mImportMessages; 95 | } 96 | 97 | } 98 | -------------------------------------------------------------------------------- /src/main/java/com/mixpanel/mixpanelapi/featureflags/model/Rollout.java: -------------------------------------------------------------------------------- 1 | package com.mixpanel.mixpanelapi.featureflags.model; 2 | 3 | import java.util.Collections; 4 | import java.util.Map; 5 | 6 | /** 7 | * Represents a rollout rule within a feature flag experiment. 8 | *

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

12 | *

13 | * This class is immutable and thread-safe. 14 | *

15 | */ 16 | public final class Rollout { 17 | private final float rolloutPercentage; 18 | private final Map runtimeEvaluationDefinition; 19 | private final VariantOverride variantOverride; 20 | private final Map variantSplits; 21 | 22 | /** 23 | * Creates a new Rollout with all parameters. 24 | * 25 | * @param rolloutPercentage the percentage of users to include (0.0-1.0) 26 | * @param runtimeEvaluationDefinition optional map of property name to expected value for targeting 27 | * @param variantOverride optional variant override to force selection 28 | * @param variantSplits optional map of variant key to split percentage at assignment group level 29 | */ 30 | public Rollout(float rolloutPercentage, Map runtimeEvaluationDefinition, VariantOverride variantOverride, Map variantSplits) { 31 | this.rolloutPercentage = rolloutPercentage; 32 | this.runtimeEvaluationDefinition = runtimeEvaluationDefinition != null 33 | ? Collections.unmodifiableMap(runtimeEvaluationDefinition) 34 | : null; 35 | this.variantOverride = variantOverride; 36 | this.variantSplits = variantSplits != null 37 | ? Collections.unmodifiableMap(variantSplits) 38 | : null; 39 | } 40 | 41 | /** 42 | * Creates a new Rollout without runtime evaluation or variant override. 43 | * 44 | * @param rolloutPercentage the percentage of users to include (0.0-1.0) 45 | */ 46 | public Rollout(float rolloutPercentage) { 47 | this(rolloutPercentage, null, null, null); 48 | } 49 | 50 | /** 51 | * @return the percentage of users to include in this rollout (0.0-1.0) 52 | */ 53 | public float getRolloutPercentage() { 54 | return rolloutPercentage; 55 | } 56 | 57 | /** 58 | * @return optional map of property name to expected value for runtime evaluation, or null if not set 59 | */ 60 | public Map getRuntimeEvaluationDefinition() { 61 | return runtimeEvaluationDefinition; 62 | } 63 | 64 | /** 65 | * @return optional variant override to force selection, or null if not set 66 | */ 67 | public VariantOverride getVariantOverride() { 68 | return variantOverride; 69 | } 70 | 71 | /** 72 | * @return optional map of variant key to split percentage at assignment group level, or null if not set 73 | */ 74 | public Map getVariantSplits() { 75 | return variantSplits; 76 | } 77 | 78 | /** 79 | * @return true if this rollout has runtime evaluation criteria 80 | */ 81 | public boolean hasRuntimeEvaluation() { 82 | return runtimeEvaluationDefinition != null && !runtimeEvaluationDefinition.isEmpty(); 83 | } 84 | 85 | /** 86 | * @return true if this rollout has a variant override 87 | */ 88 | public boolean hasVariantOverride() { 89 | return variantOverride != null; 90 | } 91 | 92 | /** 93 | * @return true if this rollout has variant splits 94 | */ 95 | public boolean hasVariantSplits() { 96 | return variantSplits != null && !variantSplits.isEmpty(); 97 | } 98 | 99 | @Override 100 | public String toString() { 101 | return "Rollout{" + 102 | "rolloutPercentage=" + rolloutPercentage + 103 | ", runtimeEvaluationDefinition=" + runtimeEvaluationDefinition + 104 | ", variantOverride='" + variantOverride + '\'' + 105 | ", variantSplits=" + variantSplits + 106 | '}'; 107 | } 108 | } 109 | -------------------------------------------------------------------------------- /src/main/java/com/mixpanel/mixpanelapi/featureflags/model/SelectedVariant.java: -------------------------------------------------------------------------------- 1 | package com.mixpanel.mixpanelapi.featureflags.model; 2 | 3 | import java.util.UUID; 4 | 5 | /** 6 | * Represents the result of a feature flag evaluation. 7 | *

8 | * Contains the selected variant key and its value. Both may be null if the 9 | * fallback was returned (e.g., flag not found, evaluation error). 10 | *

11 | *

12 | * This class is immutable and thread-safe. 13 | *

14 | * 15 | * @param the type of the variant value 16 | */ 17 | public final class SelectedVariant { 18 | private final String variantKey; 19 | private final T variantValue; 20 | private final UUID experimentId; 21 | private final Boolean isExperimentActive; 22 | private final Boolean isQaTester; 23 | 24 | /** 25 | * Creates a SelectedVariant with only a value (key is null). 26 | * This is typically used for fallback responses. 27 | * 28 | * @param variantValue the fallback value 29 | */ 30 | public SelectedVariant(T variantValue) { 31 | this(null, variantValue, null, null, null); 32 | } 33 | 34 | /** 35 | * Creates a new SelectedVariant with experimentation metadata. 36 | * 37 | * @param variantKey the key of the selected variant (may be null for fallback) 38 | * @param variantValue the value of the selected variant (may be null for fallback) 39 | * @param experimentId the experiment ID (may be null) 40 | * @param isExperimentActive whether the experiment is active (may be null) 41 | * @param isQaTester whether the user is a QA tester (may be null) 42 | */ 43 | public SelectedVariant(String variantKey, T variantValue, UUID experimentId, Boolean isExperimentActive, Boolean isQaTester) { 44 | this.variantKey = variantKey; 45 | this.variantValue = variantValue; 46 | this.experimentId = experimentId; 47 | this.isExperimentActive = isExperimentActive; 48 | this.isQaTester = isQaTester; 49 | } 50 | 51 | /** 52 | * @return the variant key, or null if this is a fallback 53 | */ 54 | public String getVariantKey() { 55 | return variantKey; 56 | } 57 | 58 | /** 59 | * @return the variant value 60 | */ 61 | public T getVariantValue() { 62 | return variantValue; 63 | } 64 | 65 | /** 66 | * @return the experiment ID, or null if not set 67 | */ 68 | public UUID getExperimentId() { 69 | return experimentId; 70 | } 71 | 72 | /** 73 | * @return whether the experiment is active, or null if not set 74 | */ 75 | public Boolean getIsExperimentActive() { 76 | return isExperimentActive; 77 | } 78 | 79 | /** 80 | * @return whether the user is a QA tester, or null if not set 81 | */ 82 | public Boolean getIsQaTester() { 83 | return isQaTester; 84 | } 85 | 86 | /** 87 | * @return true if this represents a successfully selected variant (not a fallback) 88 | */ 89 | public boolean isSuccess() { 90 | return variantKey != null; 91 | } 92 | 93 | /** 94 | * @return true if this represents a fallback value 95 | */ 96 | public boolean isFallback() { 97 | return variantKey == null; 98 | } 99 | 100 | @Override 101 | public String toString() { 102 | return "SelectedVariant{" + 103 | "variantKey='" + variantKey + '\'' + 104 | ", variantValue=" + variantValue + 105 | ", experimentId=" + experimentId + 106 | ", isExperimentActive=" + isExperimentActive + 107 | ", isQaTester=" + isQaTester + 108 | '}'; 109 | } 110 | 111 | @Override 112 | public boolean equals(Object o) { 113 | if (this == o) return true; 114 | if (o == null || getClass() != o.getClass()) return false; 115 | 116 | SelectedVariant that = (SelectedVariant) o; 117 | 118 | if (variantKey != null ? !variantKey.equals(that.variantKey) : that.variantKey != null) return false; 119 | if (variantValue != null ? !variantValue.equals(that.variantValue) : that.variantValue != null) return false; 120 | if (experimentId != null ? !experimentId.equals(that.experimentId) : that.experimentId != null) return false; 121 | if (isExperimentActive != null ? !isExperimentActive.equals(that.isExperimentActive) : that.isExperimentActive != null) return false; 122 | return isQaTester != null ? isQaTester.equals(that.isQaTester) : that.isQaTester == null; 123 | } 124 | } 125 | -------------------------------------------------------------------------------- /src/main/java/com/mixpanel/mixpanelapi/featureflags/model/ExperimentationFlag.java: -------------------------------------------------------------------------------- 1 | package com.mixpanel.mixpanelapi.featureflags.model; 2 | 3 | import java.util.UUID; 4 | 5 | /** 6 | * Represents a complete feature flag definition. 7 | *

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

11 | *

12 | * This class is immutable and thread-safe. 13 | *

14 | */ 15 | public final class ExperimentationFlag { 16 | private final String id; 17 | private final String name; 18 | private final String key; 19 | private final String status; 20 | private final int projectId; 21 | private final RuleSet ruleset; 22 | private final String context; 23 | private final UUID experimentId; 24 | private final Boolean isExperimentActive; 25 | private final String hashSalt; 26 | 27 | /** 28 | * Creates a new ExperimentationFlag. 29 | * 30 | * @param id the unique identifier for this flag 31 | * @param name the human-readable name of this flag 32 | * @param key the key used to reference this flag in code 33 | * @param status the current status of this flag 34 | * @param projectId the Mixpanel project ID this flag belongs to 35 | * @param ruleset the ruleset defining variant assignment logic 36 | * @param context the property name used for rollout hashing (e.g., "distinct_id") 37 | * @param experimentId the experiment ID (may be null) 38 | * @param isExperimentActive whether the experiment is active (may be null) 39 | * @param hashSalt the hash salt for this flag (may be null for legacy flags) 40 | */ 41 | public ExperimentationFlag(String id, String name, String key, String status, int projectId, RuleSet ruleset, String context, UUID experimentId, Boolean isExperimentActive, String hashSalt) { 42 | this.id = id; 43 | this.name = name; 44 | this.key = key; 45 | this.status = status; 46 | this.projectId = projectId; 47 | this.ruleset = ruleset; 48 | this.context = context; 49 | this.experimentId = experimentId; 50 | this.isExperimentActive = isExperimentActive; 51 | this.hashSalt = hashSalt; 52 | } 53 | 54 | /** 55 | * @return the unique identifier for this flag 56 | */ 57 | public String getId() { 58 | return id; 59 | } 60 | 61 | /** 62 | * @return the human-readable name 63 | */ 64 | public String getName() { 65 | return name; 66 | } 67 | 68 | /** 69 | * @return the key used to reference this flag 70 | */ 71 | public String getKey() { 72 | return key; 73 | } 74 | 75 | /** 76 | * @return the current status 77 | */ 78 | public String getStatus() { 79 | return status; 80 | } 81 | 82 | /** 83 | * @return the project ID 84 | */ 85 | public int getProjectId() { 86 | return projectId; 87 | } 88 | 89 | /** 90 | * @return the ruleset defining variant assignment 91 | */ 92 | public RuleSet getRuleset() { 93 | return ruleset; 94 | } 95 | 96 | /** 97 | * @return the property name used for rollout hashing (e.g., "distinct_id") 98 | */ 99 | public String getContext() { 100 | return context; 101 | } 102 | 103 | /** 104 | * @return the experiment ID, or null if not set 105 | */ 106 | public UUID getExperimentId() { 107 | return experimentId; 108 | } 109 | 110 | /** 111 | * @return whether the experiment is active, or null if not set 112 | */ 113 | public Boolean getIsExperimentActive() { 114 | return isExperimentActive; 115 | } 116 | 117 | /** 118 | * @return the hash salt for this flag, or null for legacy flags 119 | */ 120 | public String getHashSalt() { 121 | return hashSalt; 122 | } 123 | 124 | @Override 125 | public String toString() { 126 | return "ExperimentationFlag{" + 127 | "id=" + id + 128 | ", name='" + name + '\'' + 129 | ", key='" + key + '\'' + 130 | ", status=" + status + 131 | ", projectId=" + projectId + 132 | ", ruleset=" + ruleset + 133 | ", context='" + context + '\'' + 134 | ", experimentId=" + experimentId + 135 | ", isExperimentActive=" + isExperimentActive + 136 | ", hashSalt='" + hashSalt + '\'' + 137 | '}'; 138 | } 139 | } 140 | -------------------------------------------------------------------------------- /src/main/java/com/mixpanel/mixpanelapi/DeliveryOptions.java: -------------------------------------------------------------------------------- 1 | package com.mixpanel.mixpanelapi; 2 | 3 | /** 4 | * Options for configuring how messages are delivered to Mixpanel. 5 | * Use the {@link Builder} to create instances. 6 | * 7 | *

Different options apply to different message types: 8 | *

    9 | *
  • {@code importStrictMode} - Only applies to import messages
  • 10 | *
  • {@code useIpAddress} - Only applies to events, people, and groups messages (NOT imports)
  • 11 | *
12 | * 13 | *

Example usage: 14 | *

{@code
 15 |  * DeliveryOptions options = new DeliveryOptions.Builder()
 16 |  *     .importStrictMode(false)  // Disable strict validation for imports
 17 |  *     .useIpAddress(true)       // Use IP address for geolocation (events/people/groups only)
 18 |  *     .build();
 19 |  *
 20 |  * mixpanelApi.deliver(delivery, options);
 21 |  * }
22 | */ 23 | public class DeliveryOptions { 24 | 25 | private final boolean mImportStrictMode; 26 | private final boolean mUseIpAddress; 27 | 28 | private DeliveryOptions(Builder builder) { 29 | mImportStrictMode = builder.importStrictMode; 30 | mUseIpAddress = builder.useIpAddress; 31 | } 32 | 33 | /** 34 | * Returns whether strict mode is enabled for import messages. 35 | * 36 | *

Note: This option only applies to import messages (historical events). 37 | * It has no effect on regular events, people, or groups messages. 38 | * 39 | *

When strict mode is enabled (default), the /import endpoint validates each event 40 | * and returns a 400 error if any event has issues. Correctly formed events are still 41 | * ingested, and problematic events are returned in the response with error messages. 42 | * 43 | *

When strict mode is disabled, validation is bypassed and all events are imported 44 | * regardless of their validity. 45 | * 46 | * @return true if strict mode is enabled for imports, false otherwise 47 | */ 48 | public boolean isImportStrictMode() { 49 | return mImportStrictMode; 50 | } 51 | 52 | /** 53 | * Returns whether the IP address should be used for geolocation. 54 | * 55 | *

Note: This option only applies to events, people, and groups messages. 56 | * It does NOT apply to import messages, which use Basic Auth and don't support the ip parameter. 57 | * 58 | * @return true if IP address should be used for geolocation, false otherwise 59 | */ 60 | public boolean useIpAddress() { 61 | return mUseIpAddress; 62 | } 63 | 64 | /** 65 | * Builder for creating {@link DeliveryOptions} instances. 66 | */ 67 | public static class Builder { 68 | private boolean importStrictMode = true; 69 | private boolean useIpAddress = false; 70 | 71 | /** 72 | * Sets whether to use strict mode for import messages. 73 | * 74 | * will validate the supplied events and return a 400 status code if any of the events fail validation with details of the error 75 | * 76 | *

Setting this value to true (default) will validate the supplied events and return 77 | * a 400 status code if any of the events fail validation with details of the error. 78 | * Setting this value to false disables validation. 79 | * 80 | * @param importStrictMode true to enable strict validation (default), false to disable 81 | * @return this Builder instance for method chaining 82 | */ 83 | public Builder importStrictMode(boolean importStrictMode) { 84 | this.importStrictMode = importStrictMode; 85 | return this; 86 | } 87 | 88 | /** 89 | * Sets whether to use the IP address for geolocation. 90 | * 91 | *

Note: This option only applies to events, people, and groups messages. 92 | * It does NOT apply to import messages. 93 | * 94 | *

When enabled, Mixpanel will use the IP address of the request to set 95 | * geolocation properties on events and profiles. 96 | * 97 | * @param useIpAddress true to use IP address for geolocation, false otherwise (default) 98 | * @return this Builder instance for method chaining 99 | */ 100 | public Builder useIpAddress(boolean useIpAddress) { 101 | this.useIpAddress = useIpAddress; 102 | return this; 103 | } 104 | 105 | /** 106 | * Builds and returns a new {@link DeliveryOptions} instance. 107 | * 108 | * @return a new DeliveryOptions with the configured settings 109 | */ 110 | public DeliveryOptions build() { 111 | return new DeliveryOptions(this); 112 | } 113 | } 114 | } 115 | -------------------------------------------------------------------------------- /pom.xml: -------------------------------------------------------------------------------- 1 | 2 | 4.0.0 3 | 4 | com.mixpanel 5 | mixpanel-java 6 | 1.6.1 7 | jar 8 | mixpanel-java 9 | 10 | 11 | 12 | 13 | https://github.com/mixpanel/mixpanel-java 14 | 15 | 16 | 17 | The Apache Software License, Version 2.0 18 | http://www.apache.org/licenses/LICENSE-2.0.txt 19 | repo 20 | A business-friendly OSS license 21 | 22 | 23 | 24 | 25 | scm:git:https://github.com/mixpanel/mixpanel-java.git 26 | scm:git:git@github.com:mixpanel/mixpanel-java.git 27 | https://github.com/mixpanel/mixpanel-java 28 | 29 | 30 | 31 | 32 | mixpanel 33 | Mixpanel, Inc 34 | dev@mixpanel.com 35 | http://www.mixpanel.com 36 | 37 | 38 | 39 | 40 | UTF-8 41 | 1.8 42 | 1.8 43 | 44 | 45 | 46 | 47 | central 48 | https://central.sonatype.com/repository/maven-snapshots/ 49 | 50 | 51 | 52 | 53 | 54 | 55 | src/main/resources 56 | true 57 | 58 | **/*.properties 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | org.sonatype.central 67 | central-publishing-maven-plugin 68 | 0.9.0 69 | true 70 | 71 | central 72 | mixpanel-java-${project.version} 73 | 74 | false 75 | 76 | 77 | 78 | 79 | org.apache.maven.plugins 80 | maven-source-plugin 81 | 2.2.1 82 | 83 | 84 | attach-sources 85 | 86 | jar-no-fork 87 | 88 | 89 | 90 | 91 | 92 | org.apache.maven.plugins 93 | maven-javadoc-plugin 94 | 2.9.1 95 | 96 | 97 | attach-javadocs 98 | 99 | jar 100 | 101 | 102 | 103 | 104 | 105 | 106 | org.apache.maven.plugins 107 | maven-gpg-plugin 108 | 3.2.4 109 | 110 | 111 | sign-artifacts 112 | verify 113 | 114 | sign 115 | 116 | 117 | 118 | --pinentry-mode 119 | loopback 120 | 121 | 122 | 123 | 124 | 125 | 126 | 127 | 128 | 129 | 130 | junit 131 | junit 132 | 4.13.2 133 | test 134 | 135 | 136 | 137 | org.json 138 | json 139 | 20231013 140 | 141 | 142 | 143 | -------------------------------------------------------------------------------- /src/demo/java/com/mixpanel/mixpanelapi/featureflags/demo/RemoteEvaluationExample.java: -------------------------------------------------------------------------------- 1 | package com.mixpanel.mixpanelapi.featureflags.demo; 2 | 3 | import com.mixpanel.mixpanelapi.MixpanelAPI; 4 | import com.mixpanel.mixpanelapi.featureflags.config.RemoteFlagsConfig; 5 | import com.mixpanel.mixpanelapi.featureflags.model.SelectedVariant; 6 | 7 | import java.util.HashMap; 8 | import java.util.Map; 9 | 10 | /** 11 | * Example demonstrating remote feature flag evaluation. 12 | * 13 | * Remote evaluation makes an API call for each flag check, providing 14 | * real-time flag updates but with higher latency. 15 | */ 16 | public class RemoteEvaluationExample { 17 | 18 | public static void main(String[] args) { 19 | // Replace with your actual Mixpanel project token 20 | String projectToken = "YOUR_PROJECT_TOKEN"; 21 | 22 | // 1. Configure remote evaluation 23 | RemoteFlagsConfig config = RemoteFlagsConfig.builder() 24 | .projectToken(projectToken) 25 | .apiHost("api.mixpanel.com") // Use "api-eu.mixpanel.com" for EU 26 | .requestTimeoutSeconds(5) // 5 second timeout 27 | .build(); 28 | 29 | // 2. Create MixpanelAPI with flags support 30 | try (MixpanelAPI mixpanel = new MixpanelAPI(config)) { 31 | 32 | System.out.println("Remote flags initialized"); 33 | 34 | // 3. Example 1: Simple flag check 35 | System.out.println("\n=== Example 1: Simple Flag Check ==="); 36 | Map context1 = new HashMap<>(); 37 | context1.put("distinct_id", "user-789"); 38 | 39 | // Each call makes an API request 40 | boolean featureEnabled = mixpanel.getRemoteFlags().isEnabled( 41 | "experimental-feature", 42 | context1 43 | ); 44 | 45 | System.out.println("Feature enabled: " + featureEnabled); 46 | 47 | // 4. Example 2: Admin access check with targeting 48 | System.out.println("\n=== Example 2: Admin Access Check ==="); 49 | Map adminContext = new HashMap<>(); 50 | adminContext.put("distinct_id", "admin-user-1"); 51 | 52 | Map customProps = new HashMap<>(); 53 | customProps.put("role", "admin"); 54 | customProps.put("department", "engineering"); 55 | adminContext.put("custom_properties", customProps); 56 | 57 | boolean hasAdminAccess = mixpanel.getRemoteFlags().isEnabled( 58 | "admin-panel-access", 59 | adminContext 60 | ); 61 | 62 | System.out.println("Admin access granted: " + hasAdminAccess); 63 | 64 | // 5. Example 3: Get variant value for A/B test 65 | System.out.println("\n=== Example 3: A/B Test Variant ==="); 66 | Map context2 = new HashMap<>(); 67 | context2.put("distinct_id", "user-456"); 68 | 69 | String landingPageVariant = mixpanel.getRemoteFlags().getVariantValue( 70 | "landing-page-test", 71 | "control", // fallback to control variant 72 | context2 73 | ); 74 | 75 | System.out.println("Landing page variant: " + landingPageVariant); 76 | 77 | // 6. Example 4: Full variant information 78 | System.out.println("\n=== Example 4: Full Variant Info ==="); 79 | SelectedVariant variant = mixpanel.getRemoteFlags().getVariant( 80 | "pricing-tier-experiment", 81 | new SelectedVariant<>(null), 82 | context1 83 | ); 84 | 85 | if (variant.isSuccess()) { 86 | System.out.println("Assigned to variant: " + variant.getVariantKey()); 87 | System.out.println("Pricing tier: " + variant.getVariantValue()); 88 | } else { 89 | System.out.println("Using default pricing"); 90 | } 91 | 92 | // 7. Example 5: Dynamic configuration value 93 | System.out.println("\n=== Example 5: Dynamic Config ==="); 94 | Integer apiRateLimit = mixpanel.getRemoteFlags().getVariantValue( 95 | "api-rate-limit", 96 | 1000, // default rate limit 97 | context1 98 | ); 99 | 100 | System.out.println("API rate limit: " + apiRateLimit + " requests/hour"); 101 | 102 | // 8. Example 6: Batch checking multiple users 103 | System.out.println("\n=== Example 6: Check Multiple Users ==="); 104 | for (int i = 0; i < 3; i++) { 105 | Map userContext = new HashMap<>(); 106 | userContext.put("distinct_id", "user-beta-" + i); 107 | 108 | boolean betaAccess = mixpanel.getRemoteFlags().isEnabled( 109 | "beta-program", 110 | userContext 111 | ); 112 | 113 | System.out.println("User beta-" + i + " has beta access: " + betaAccess); 114 | } 115 | 116 | System.out.println("\n=== Example Complete ==="); 117 | 118 | } catch (Exception e) { 119 | System.err.println("Error: " + e.getMessage()); 120 | e.printStackTrace(); 121 | } 122 | 123 | System.out.println("Resources cleaned up successfully"); 124 | } 125 | } 126 | -------------------------------------------------------------------------------- /src/demo/java/com/mixpanel/mixpanelapi/featureflags/demo/LocalEvaluationExample.java: -------------------------------------------------------------------------------- 1 | package com.mixpanel.mixpanelapi.featureflags.demo; 2 | 3 | import com.mixpanel.mixpanelapi.MixpanelAPI; 4 | import com.mixpanel.mixpanelapi.featureflags.config.LocalFlagsConfig; 5 | import com.mixpanel.mixpanelapi.featureflags.model.SelectedVariant; 6 | 7 | import java.util.HashMap; 8 | import java.util.Map; 9 | 10 | /** 11 | * Example demonstrating local feature flag evaluation. 12 | * 13 | * This example shows how to: 14 | * 1. Configure and initialize a local flags client 15 | * 2. Start polling for flag definitions 16 | * 3. Evaluate flags with different contexts 17 | * 4. Properly clean up resources 18 | */ 19 | public class LocalEvaluationExample { 20 | 21 | public static void main(String[] args) throws Exception { 22 | // Replace with your actual Mixpanel project token 23 | String projectToken = "YOUR_PROJECT_TOKEN"; 24 | 25 | // 1. Configure local evaluation 26 | LocalFlagsConfig config = LocalFlagsConfig.builder() 27 | .projectToken(projectToken) 28 | .apiHost("api.mixpanel.com") // Use "api-eu.mixpanel.com" for EU 29 | .pollingIntervalSeconds(60) // Poll every 60 seconds 30 | .enablePolling(true) // Enable background polling 31 | .requestTimeoutSeconds(10) // 10 second timeout for HTTP requests 32 | .build(); 33 | 34 | try (MixpanelAPI mixpanel = new MixpanelAPI(config)) { 35 | 36 | // 2. Start polling for flag definitions 37 | System.out.println("Starting flag polling..."); 38 | mixpanel.getLocalFlags().startPollingForDefinitions(); 39 | 40 | System.out.println("Waiting for flags to be ready..."); 41 | int retries = 0; 42 | while (!mixpanel.getLocalFlags().areFlagsReady() && retries < 50) { 43 | Thread.sleep(100); 44 | retries++; 45 | } 46 | 47 | if (!mixpanel.getLocalFlags().areFlagsReady()) { 48 | System.err.println("Warning: Flags not ready after 5 seconds, will use fallback values"); 49 | } else { 50 | System.out.println("Flags are ready!"); 51 | } 52 | 53 | // 3. Example 1: Simple boolean flag check 54 | System.out.println("\n=== Example 1: Boolean Flag ==="); 55 | Map context1 = new HashMap<>(); 56 | context1.put("distinct_id", "user-123"); 57 | 58 | boolean newFeatureEnabled = mixpanel.getLocalFlags().isEnabled( 59 | "new-checkout-flow", 60 | context1 61 | ); 62 | 63 | System.out.println("New checkout flow enabled: " + newFeatureEnabled); 64 | 65 | // Example 2: String variant value 66 | System.out.println("\n=== Example 2: String Variant ==="); 67 | String buttonColor = mixpanel.getLocalFlags().getVariantValue( 68 | "button-color", 69 | "blue", // fallback value 70 | context1 71 | ); 72 | 73 | System.out.println("Button color: " + buttonColor); 74 | 75 | // Example 3: With custom properties for targeting 76 | System.out.println("\n=== Example 3: Targeted Flag ==="); 77 | Map context2 = new HashMap<>(); 78 | context2.put("distinct_id", "user-456"); 79 | 80 | // Add custom properties for runtime evaluation 81 | Map customProps = new HashMap<>(); 82 | customProps.put("subscription_tier", "premium"); 83 | customProps.put("country", "US"); 84 | context2.put("custom_properties", customProps); 85 | 86 | boolean premiumFeatureEnabled = mixpanel.getLocalFlags().isEnabled( 87 | "premium-analytics-dashboard", 88 | context2 89 | ); 90 | 91 | System.out.println("Premium analytics enabled: " + premiumFeatureEnabled); 92 | 93 | // Example 4: Get full variant information 94 | System.out.println("\n=== Example 4: Full Variant Info ==="); 95 | SelectedVariant variant = mixpanel.getLocalFlags().getVariant( 96 | "recommendation-algorithm", 97 | new SelectedVariant<>("default-algorithm"), // fallback 98 | context1 99 | ); 100 | 101 | if (variant.isSuccess()) { 102 | System.out.println("Variant key: " + variant.getVariantKey()); 103 | System.out.println("Variant value: " + variant.getVariantValue()); 104 | } else { 105 | System.out.println("Using fallback variant"); 106 | } 107 | 108 | // Example 5: Number variant 109 | System.out.println("\n=== Example 5: Number Variant ==="); 110 | Integer maxItems = mixpanel.getLocalFlags().getVariantValue( 111 | "max-cart-items", 112 | 10, // fallback value 113 | context1 114 | ); 115 | 116 | System.out.println("Max cart items: " + maxItems); 117 | 118 | System.out.println("\n=== Example Complete ==="); 119 | System.out.println("MixpanelAPI will be automatically closed"); 120 | 121 | // 4. Properly clean up resources 122 | mixpanel.close(); 123 | 124 | } 125 | 126 | System.out.println("Resources cleaned up successfully"); 127 | } 128 | } 129 | -------------------------------------------------------------------------------- /mixpanel-java-extension-jackson/src/main/java/com/mixpanel/mixpanelapi/internal/JacksonSerializer.java: -------------------------------------------------------------------------------- 1 | package com.mixpanel.mixpanelapi.internal; 2 | 3 | import com.fasterxml.jackson.core.JsonFactory; 4 | import com.fasterxml.jackson.core.JsonGenerator; 5 | import org.json.JSONArray; 6 | import org.json.JSONObject; 7 | 8 | import java.io.IOException; 9 | import java.io.StringWriter; 10 | import java.util.Iterator; 11 | import java.util.List; 12 | 13 | /** 14 | * High-performance JSON serialization implementation using Jackson's streaming API. 15 | * This implementation provides significant performance improvements for large batches 16 | * while maintaining compatibility with org.json JSONObjects. 17 | * 18 | * @since 1.6.1 19 | */ 20 | public class JacksonSerializer implements JsonSerializer { 21 | 22 | private final JsonFactory jsonFactory; 23 | 24 | /** 25 | * Constructs a new JacksonSerializer with default settings. 26 | */ 27 | public JacksonSerializer() { 28 | this.jsonFactory = new JsonFactory(); 29 | } 30 | 31 | @Override 32 | public String serializeArray(List messages) throws IOException { 33 | if (messages == null || messages.isEmpty()) { 34 | return "[]"; 35 | } 36 | 37 | StringWriter writer = new StringWriter(); 38 | try (JsonGenerator generator = jsonFactory.createGenerator(writer)) { 39 | writeJsonArray(generator, messages); 40 | } 41 | return writer.toString(); 42 | } 43 | 44 | /** 45 | * Writes a JSON array of messages using the Jackson streaming API. 46 | */ 47 | private void writeJsonArray(JsonGenerator generator, List messages) throws IOException { 48 | generator.writeStartArray(); 49 | for (JSONObject message : messages) { 50 | writeJsonObject(generator, message); 51 | } 52 | generator.writeEndArray(); 53 | } 54 | 55 | /** 56 | * Recursively writes a JSONObject using Jackson's streaming API. 57 | * This avoids the conversion overhead while leveraging Jackson's performance. 58 | */ 59 | private void writeJsonObject(JsonGenerator generator, JSONObject jsonObject) throws IOException { 60 | generator.writeStartObject(); 61 | 62 | Iterator keys = jsonObject.keys(); 63 | while (keys.hasNext()) { 64 | String key = keys.next(); 65 | Object value = jsonObject.opt(key); 66 | 67 | if (value == null || value == JSONObject.NULL) { 68 | generator.writeNullField(key); 69 | } else if (value instanceof String) { 70 | generator.writeStringField(key, (String) value); 71 | } else if (value instanceof Number) { 72 | if (value instanceof Integer) { 73 | generator.writeNumberField(key, (Integer) value); 74 | } else if (value instanceof Long) { 75 | generator.writeNumberField(key, (Long) value); 76 | } else if (value instanceof Double) { 77 | generator.writeNumberField(key, (Double) value); 78 | } else if (value instanceof Float) { 79 | generator.writeNumberField(key, (Float) value); 80 | } else { 81 | // Handle other Number types 82 | generator.writeNumberField(key, ((Number) value).doubleValue()); 83 | } 84 | } else if (value instanceof Boolean) { 85 | generator.writeBooleanField(key, (Boolean) value); 86 | } else if (value instanceof JSONObject) { 87 | generator.writeFieldName(key); 88 | writeJsonObject(generator, (JSONObject) value); 89 | } else if (value instanceof JSONArray) { 90 | generator.writeFieldName(key); 91 | writeJsonArray(generator, (JSONArray) value); 92 | } else { 93 | // For any other type, use toString() 94 | generator.writeStringField(key, value.toString()); 95 | } 96 | } 97 | 98 | generator.writeEndObject(); 99 | } 100 | 101 | /** 102 | * Recursively writes a JSONArray using Jackson's streaming API. 103 | */ 104 | private void writeJsonArray(JsonGenerator generator, JSONArray jsonArray) throws IOException { 105 | generator.writeStartArray(); 106 | 107 | for (int i = 0; i < jsonArray.length(); i++) { 108 | Object value = jsonArray.opt(i); 109 | 110 | if (value == null || value == JSONObject.NULL) { 111 | generator.writeNull(); 112 | } else if (value instanceof String) { 113 | generator.writeString((String) value); 114 | } else if (value instanceof Number) { 115 | if (value instanceof Integer) { 116 | generator.writeNumber((Integer) value); 117 | } else if (value instanceof Long) { 118 | generator.writeNumber((Long) value); 119 | } else if (value instanceof Double) { 120 | generator.writeNumber((Double) value); 121 | } else if (value instanceof Float) { 122 | generator.writeNumber((Float) value); 123 | } else { 124 | generator.writeNumber(((Number) value).doubleValue()); 125 | } 126 | } else if (value instanceof Boolean) { 127 | generator.writeBoolean((Boolean) value); 128 | } else if (value instanceof JSONObject) { 129 | writeJsonObject(generator, (JSONObject) value); 130 | } else if (value instanceof JSONArray) { 131 | writeJsonArray(generator, (JSONArray) value); 132 | } else { 133 | generator.writeString(value.toString()); 134 | } 135 | } 136 | 137 | generator.writeEndArray(); 138 | } 139 | } 140 | -------------------------------------------------------------------------------- /.github/copilot-instructions.md: -------------------------------------------------------------------------------- 1 | # Copilot Instructions for mixpanel-java 2 | 3 | ## Project Overview 4 | 5 | This is the official Mixpanel tracking library for Java - a production-ready library for sending analytics events and user profile updates to Mixpanel from server-side Java applications. 6 | 7 | **Project Type:** Java library (JAR) 8 | **Build Tool:** Maven 3.x 9 | **Java Version:** 8+ (compiled for Java 8, tested on 8, 11, 17, 21) 10 | **Main Dependency:** org.json:json:20231013 11 | **Test Framework:** JUnit 4.13.2 12 | 13 | ## Build Commands 14 | 15 | Always run these commands from the repository root directory. 16 | 17 | ### Essential Commands 18 | 19 | ```bash 20 | # Run all tests (81 tests, ~5-20 seconds) 21 | mvn test 22 | 23 | # Build JAR without tests 24 | mvn clean package -DskipTests 25 | 26 | # Full build with tests (~20-30 seconds) 27 | mvn clean package 28 | 29 | # Clean build artifacts 30 | mvn clean 31 | 32 | # Generate JavaDoc 33 | mvn javadoc:javadoc 34 | ``` 35 | 36 | ### Running Specific Tests 37 | 38 | ```bash 39 | # Run a specific test class 40 | mvn test -Dtest=MixpanelAPITest 41 | 42 | # Run a specific test method 43 | mvn test -Dtest=MixpanelAPITest#testBuildEventMessage 44 | ``` 45 | 46 | ## Project Structure 47 | 48 | ``` 49 | mixpanel-java/ 50 | ├── pom.xml # Maven build configuration 51 | ├── src/ 52 | │ ├── main/java/com/mixpanel/mixpanelapi/ 53 | │ │ ├── MixpanelAPI.java # Main API class, HTTP communication 54 | │ │ ├── MessageBuilder.java # Constructs JSON messages 55 | │ │ ├── ClientDelivery.java # Batches messages for transmission 56 | │ │ ├── Config.java # API endpoints and constants 57 | │ │ ├── Base64Coder.java # Base64 encoding utility 58 | │ │ ├── MixpanelMessageException.java # Client-side errors 59 | │ │ ├── MixpanelServerException.java # Server-side errors 60 | │ │ ├── featureflags/ # Feature flags implementation 61 | │ │ │ ├── config/ # Flag configuration classes 62 | │ │ │ ├── model/ # Flag data models 63 | │ │ │ ├── provider/ # Flag evaluation providers 64 | │ │ │ └── util/ # Utility classes 65 | │ │ └── internal/ # Internal serialization (Jackson/org.json) 66 | │ ├── main/resources/ 67 | │ │ └── mixpanel-version.properties # Version info (filtered by Maven) 68 | │ ├── test/java/com/mixpanel/mixpanelapi/ 69 | │ │ ├── MixpanelAPITest.java # Main test class (27 tests) 70 | │ │ ├── featureflags/provider/ # Feature flags tests (~54 tests) 71 | │ │ └── internal/ # Serializer tests 72 | │ └── demo/java/com/mixpanel/mixpanelapi/demo/ 73 | │ └── MixpanelAPIDemo.java # Demo application 74 | └── .github/workflows/ 75 | ├── ci.yml # CI pipeline (tests on Java 8, 11, 17, 21) 76 | └── release.yml # Release to Maven Central 77 | ``` 78 | 79 | ## CI/CD Pipeline 80 | 81 | The CI workflow (`.github/workflows/ci.yml`) runs on PRs and pushes to master: 82 | 83 | 1. **Tests:** `mvn clean test` (on Java 8, 11, 17, 21) 84 | 2. **Build:** `mvn clean package` 85 | 3. **JavaDoc:** `mvn javadoc:javadoc` 86 | 4. **Dependency check:** `mvn versions:display-dependency-updates` 87 | 88 | **Before submitting changes, always run:** 89 | ```bash 90 | mvn clean test 91 | ``` 92 | 93 | ## Architecture Notes 94 | 95 | ### Core Design Pattern 96 | The library uses a **Producer-Consumer** pattern: 97 | 1. `MessageBuilder` creates JSON messages on application threads 98 | 2. `ClientDelivery` batches messages (max 50 per request, 2000 for imports) 99 | 3. `MixpanelAPI` sends batched messages to Mixpanel servers 100 | 101 | ### Message Types and Endpoints 102 | - **Events** (`/track`): User actions via `messageBuilder.event()` 103 | - **People** (`/engage`): Profile updates via `messageBuilder.set()`, `increment()`, etc. 104 | - **Groups** (`/groups`): Group profile updates 105 | - **Import** (`/import`): Historical events via `messageBuilder.importEvent()` 106 | 107 | ### Key Constants (Config.java) 108 | - `MAX_MESSAGE_SIZE = 50` (regular batches) 109 | - `IMPORT_MAX_MESSAGE_SIZE = 2000` (import batches) 110 | - Connection timeout: 2 seconds, Read timeout: 10 seconds 111 | 112 | ## Testing Guidelines 113 | 114 | Tests use JUnit 4's `TestCase` style. When adding functionality: 115 | 116 | 1. Add tests in `MixpanelAPITest.java` for core API changes 117 | 2. Add tests in `featureflags/provider/` for flag-related changes 118 | 3. Follow existing test patterns - verify both JSON structure and encoded format 119 | 120 | Example test pattern: 121 | ```java 122 | public void testNewFeature() { 123 | JSONObject message = mBuilder.newMethod("distinctId", params); 124 | // Verify message structure 125 | assertTrue(delivery.isValidMessage(message)); 126 | } 127 | ``` 128 | 129 | ## Common Tasks 130 | 131 | ### Adding a New Message Type 132 | 1. Add method to `MessageBuilder.java` 133 | 2. Validate required fields in the method 134 | 3. Add tests in `MixpanelAPITest.java` 135 | 4. Update `ClientDelivery.java` if special handling needed 136 | 137 | ### Modifying Network Behavior 138 | Network configuration is in `MixpanelAPI.sendData()`. Timeouts are hardcoded but can be made configurable via `Config.java`. 139 | 140 | ## Dependencies 141 | 142 | **Runtime:** 143 | - `org.json:json:20231013` - JSON manipulation (required) 144 | - `com.fasterxml.jackson.core:jackson-databind:2.20.0` - High-performance serialization (optional, provided scope) 145 | 146 | **Test:** 147 | - `junit:junit:4.13.2` 148 | 149 | ## Important Notes 150 | 151 | - `MessageBuilder` instances are NOT thread-safe; create one per thread 152 | - Messages are JSON → Base64 → URL encoded for transmission 153 | - The library does NOT start background threads; applications manage their own threading 154 | - JavaDoc warnings during build are expected and do not indicate failures 155 | 156 | Trust these instructions. Only search for additional information if commands fail or behavior differs from what is documented here. 157 | -------------------------------------------------------------------------------- /src/main/java/com/mixpanel/mixpanelapi/Base64Coder.java: -------------------------------------------------------------------------------- 1 | package com.mixpanel.mixpanelapi; 2 | 3 | //Copyright 2003-2010 Christian d'Heureuse, Inventec Informatik AG, Zurich, Switzerland 4 | //www.source-code.biz, www.inventec.ch/chdh 5 | // 6 | //This module is multi-licensed and may be used under the terms 7 | //of any of the following licenses: 8 | // 9 | //EPL, Eclipse Public License, V1.0 or later, http://www.eclipse.org/legal 10 | //LGPL, GNU Lesser General Public License, V2.1 or later, http://www.gnu.org/licenses/lgpl.html 11 | //GPL, GNU General Public License, V2 or later, http://www.gnu.org/licenses/gpl.html 12 | //AL, Apache License, V2.0 or later, http://www.apache.org/licenses 13 | //BSD, BSD License, http://www.opensource.org/licenses/bsd-license.php 14 | // 15 | //Please contact the author if you need another license. 16 | //This module is provided "as is", without warranties of any kind. 17 | // 18 | // This file has been modified from it's original version by Mixpanel, Inc 19 | 20 | /* package */ class Base64Coder { 21 | 22 | // Mapping table from 6-bit nibbles to Base64 characters. 23 | private static char[] map1 = new char[64]; 24 | static { 25 | int i=0; 26 | for (char c='A'; c<='Z'; c++) map1[i++] = c; 27 | for (char c='a'; c<='z'; c++) map1[i++] = c; 28 | for (char c='0'; c<='9'; c++) map1[i++] = c; 29 | map1[i++] = '+'; map1[i++] = '/'; } 30 | 31 | // Mapping table from Base64 characters to 6-bit nibbles. 32 | private static byte[] map2 = new byte[128]; 33 | static { 34 | for (int i=0; iin. 60 | * @return A character array with the Base64 encoded data. 61 | */ 62 | public static char[] encode (byte[] in, int iLen) { 63 | int oDataLen = (iLen*4+2)/3; // output length without padding 64 | int oLen = ((iLen+2)/3)*4; // output length including padding 65 | char[] out = new char[oLen]; 66 | int ip = 0; 67 | int op = 0; 68 | while (ip < iLen) { 69 | int i0 = in[ip++] & 0xff; 70 | int i1 = ip < iLen ? in[ip++] & 0xff : 0; 71 | int i2 = ip < iLen ? in[ip++] & 0xff : 0; 72 | int o0 = i0 >>> 2; 73 | int o1 = ((i0 & 3) << 4) | (i1 >>> 4); 74 | int o2 = ((i1 & 0xf) << 2) | (i2 >>> 6); 75 | int o3 = i2 & 0x3F; 76 | out[op++] = map1[o0]; 77 | out[op++] = map1[o1]; 78 | out[op] = op < oDataLen ? map1[o2] : '='; op++; 79 | out[op] = op < oDataLen ? map1[o3] : '='; op++; } 80 | return out; } 81 | 82 | /** 83 | * Decodes a string from Base64 format. 84 | * @param s a Base64 String to be decoded. 85 | * @return A String containing the decoded data. 86 | * @throws IllegalArgumentException if the input is not valid Base64 encoded data. 87 | */ 88 | public static String decodeString (String s) { 89 | return new String(decode(s)); } 90 | 91 | /** 92 | * Decodes a byte array from Base64 format. 93 | * @param s a Base64 String to be decoded. 94 | * @return An array containing the decoded data bytes. 95 | * @throws IllegalArgumentException if the input is not valid Base64 encoded data. 96 | */ 97 | public static byte[] decode (String s) { 98 | return decode(s.toCharArray()); } 99 | 100 | /** 101 | * Decodes a byte array from Base64 format. 102 | * No blanks or line breaks are allowed within the Base64 encoded data. 103 | * @param in a character array containing the Base64 encoded data. 104 | * @return An array containing the decoded data bytes. 105 | * @throws IllegalArgumentException if the input is not valid Base64 encoded data. 106 | */ 107 | public static byte[] decode (char[] in) { 108 | int iLen = in.length; 109 | if (iLen%4 != 0) throw new IllegalArgumentException ("Length of Base64 encoded input string is not a multiple of 4."); 110 | while (iLen > 0 && in[iLen-1] == '=') iLen--; 111 | int oLen = (iLen*3) / 4; 112 | byte[] out = new byte[oLen]; 113 | int ip = 0; 114 | int op = 0; 115 | while (ip < iLen) { 116 | int i0 = in[ip++]; 117 | int i1 = in[ip++]; 118 | int i2 = ip < iLen ? in[ip++] : 'A'; 119 | int i3 = ip < iLen ? in[ip++] : 'A'; 120 | if (i0 > 127 || i1 > 127 || i2 > 127 || i3 > 127) 121 | throw new IllegalArgumentException ("Illegal character in Base64 encoded data."); 122 | int b0 = map2[i0]; 123 | int b1 = map2[i1]; 124 | int b2 = map2[i2]; 125 | int b3 = map2[i3]; 126 | if (b0 < 0 || b1 < 0 || b2 < 0 || b3 < 0) 127 | throw new IllegalArgumentException ("Illegal character in Base64 encoded data."); 128 | int o0 = ( b0 <<2) | (b1>>>4); 129 | int o1 = ((b1 & 0xf)<<4) | (b2>>>2); 130 | int o2 = ((b2 & 3)<<6) | b3; 131 | out[op++] = (byte)o0; 132 | if (op messages = new ArrayList<>(); 20 | 21 | String result = serializer.serializeArray(messages); 22 | assertEquals("[]", result); 23 | } 24 | 25 | public void testOrgJsonSerializerSingleMessage() throws IOException { 26 | JsonSerializer serializer = new OrgJsonSerializer(); 27 | JSONObject message = new JSONObject(); 28 | message.put("event", "test_event"); 29 | message.put("properties", new JSONObject().put("key", "value")); 30 | 31 | List messages = Arrays.asList(message); 32 | String result = serializer.serializeArray(messages); 33 | 34 | // Parse result to verify structure 35 | JSONArray array = new JSONArray(result); 36 | assertEquals(1, array.length()); 37 | JSONObject parsed = array.getJSONObject(0); 38 | assertEquals("test_event", parsed.getString("event")); 39 | assertEquals("value", parsed.getJSONObject("properties").getString("key")); 40 | } 41 | 42 | public void testOrgJsonSerializerMultipleMessages() throws IOException { 43 | JsonSerializer serializer = new OrgJsonSerializer(); 44 | List messages = new ArrayList<>(); 45 | 46 | for (int i = 0; i < 5; i++) { 47 | JSONObject message = new JSONObject(); 48 | message.put("event", "event_" + i); 49 | message.put("value", i); 50 | messages.add(message); 51 | } 52 | 53 | String result = serializer.serializeArray(messages); 54 | JSONArray array = new JSONArray(result); 55 | assertEquals(5, array.length()); 56 | 57 | for (int i = 0; i < 5; i++) { 58 | JSONObject parsed = array.getJSONObject(i); 59 | assertEquals("event_" + i, parsed.getString("event")); 60 | assertEquals(i, parsed.getInt("value")); 61 | } 62 | } 63 | 64 | public void testOrgJsonSerializerComplexObject() throws IOException { 65 | JsonSerializer serializer = new OrgJsonSerializer(); 66 | 67 | JSONObject message = new JSONObject(); 68 | message.put("event", "complex_event"); 69 | message.put("null_value", JSONObject.NULL); 70 | message.put("boolean_value", true); 71 | message.put("number_value", 42.5); 72 | message.put("string_value", "test string"); 73 | 74 | JSONObject nested = new JSONObject(); 75 | nested.put("nested_key", "nested_value"); 76 | message.put("nested_object", nested); 77 | 78 | JSONArray array = new JSONArray(); 79 | array.put("item1"); 80 | array.put(2); 81 | array.put(true); 82 | message.put("array_value", array); 83 | 84 | List messages = Arrays.asList(message); 85 | String result = serializer.serializeArray(messages); 86 | 87 | // Verify the result can be parsed back 88 | JSONArray parsedArray = new JSONArray(result); 89 | JSONObject parsed = parsedArray.getJSONObject(0); 90 | 91 | assertEquals("complex_event", parsed.getString("event")); 92 | assertTrue(parsed.isNull("null_value")); 93 | assertEquals(true, parsed.getBoolean("boolean_value")); 94 | assertEquals(42.5, parsed.getDouble("number_value"), 0.001); 95 | assertEquals("test string", parsed.getString("string_value")); 96 | assertEquals("nested_value", parsed.getJSONObject("nested_object").getString("nested_key")); 97 | 98 | JSONArray parsedInnerArray = parsed.getJSONArray("array_value"); 99 | assertEquals(3, parsedInnerArray.length()); 100 | assertEquals("item1", parsedInnerArray.getString(0)); 101 | assertEquals(2, parsedInnerArray.getInt(1)); 102 | assertEquals(true, parsedInnerArray.getBoolean(2)); 103 | } 104 | 105 | public void testLargeBatchSerialization() throws IOException { 106 | // Test with a large batch to verify performance doesn't degrade 107 | JsonSerializer serializer = new OrgJsonSerializer(); 108 | List messages = new ArrayList<>(); 109 | 110 | // Create 2000 messages (max batch size for /import) 111 | for (int i = 0; i < 2000; i++) { 112 | JSONObject message = new JSONObject(); 113 | message.put("event", "batch_event"); 114 | message.put("properties", new JSONObject() 115 | .put("index", i) 116 | .put("timestamp", System.currentTimeMillis()) 117 | .put("data", "Some test data for message " + i)); 118 | messages.add(message); 119 | } 120 | 121 | long startTime = System.currentTimeMillis(); 122 | String result = serializer.serializeArray(messages); 123 | long endTime = System.currentTimeMillis(); 124 | 125 | // Verify the result 126 | assertNotNull(result); 127 | assertTrue(result.startsWith("[")); 128 | assertTrue(result.endsWith("]")); 129 | 130 | // Parse to verify correctness (just check a few) 131 | JSONArray array = new JSONArray(result); 132 | assertEquals(2000, array.length()); 133 | assertEquals("batch_event", array.getJSONObject(0).getString("event")); 134 | assertEquals(0, array.getJSONObject(0).getJSONObject("properties").getInt("index")); 135 | assertEquals(1999, array.getJSONObject(1999).getJSONObject("properties").getInt("index")); 136 | 137 | // Log serialization time for reference 138 | System.out.println("Serialized 2000 messages in " + (endTime - startTime) + 139 | "ms using " + serializer.getClass().getName()); 140 | } 141 | } -------------------------------------------------------------------------------- /mixpanel-java-extension-jackson/pom.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4.0.0 4 | 5 | com.mixpanel 6 | mixpanel-java-extension-jackson 7 | 1.6.1 8 | jar 9 | Mixpanel Java SDK - Jackson Extension 10 | 11 | 12 | 13 | 14 | https://github.com/mixpanel/mixpanel-java 15 | 16 | 17 | 18 | The Apache Software License, Version 2.0 19 | http://www.apache.org/licenses/LICENSE-2.0.txt 20 | repo 21 | A business-friendly OSS license 22 | 23 | 24 | 25 | 26 | scm:git:https://github.com/mixpanel/mixpanel-java.git 27 | scm:git:git@github.com:mixpanel/mixpanel-java.git 28 | https://github.com/mixpanel/mixpanel-java 29 | 30 | 31 | 32 | 33 | mixpanel 34 | Mixpanel, Inc 35 | dev@mixpanel.com 36 | http://www.mixpanel.com 37 | 38 | 39 | 40 | 41 | UTF-8 42 | 1.8 43 | 1.8 44 | 2.20.0 45 | 46 | 47 | 48 | 49 | central 50 | https://central.sonatype.com/repository/maven-snapshots/ 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | org.sonatype.central 59 | central-publishing-maven-plugin 60 | 0.9.0 61 | true 62 | 63 | central 64 | mixpanel-java-extension-jackson-${project.version} 65 | 66 | false 67 | 68 | 69 | 70 | 71 | org.apache.maven.plugins 72 | maven-source-plugin 73 | 2.2.1 74 | 75 | 76 | attach-sources 77 | 78 | jar-no-fork 79 | 80 | 81 | 82 | 83 | 84 | 85 | org.apache.maven.plugins 86 | maven-javadoc-plugin 87 | 2.9.1 88 | 89 | 90 | attach-javadocs 91 | 92 | jar 93 | 94 | 95 | 96 | 97 | 98 | 99 | org.apache.maven.plugins 100 | maven-gpg-plugin 101 | 3.2.4 102 | 103 | 104 | sign-artifacts 105 | verify 106 | 107 | sign 108 | 109 | 110 | 111 | --pinentry-mode 112 | loopback 113 | 114 | 115 | 116 | 117 | 118 | 119 | 120 | 121 | 122 | 123 | 124 | com.mixpanel 125 | mixpanel-java 126 | ${project.version} 127 | 128 | 129 | 130 | 131 | com.fasterxml.jackson.core 132 | jackson-core 133 | ${jackson.version} 134 | 135 | 136 | 137 | 138 | junit 139 | junit 140 | 4.13.2 141 | test 142 | 143 | 144 | 145 | org.skyscreamer 146 | jsonassert 147 | 1.5.1 148 | test 149 | 150 | 151 | 152 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release to Maven Central 2 | 3 | on: 4 | push: 5 | tags: 6 | - 'v*' 7 | workflow_dispatch: 8 | inputs: 9 | version: 10 | description: 'Version to release (e.g., 1.5.4)' 11 | required: true 12 | type: string 13 | 14 | jobs: 15 | release: 16 | runs-on: ubuntu-latest 17 | outputs: 18 | version: ${{ steps.set-version.outputs.version }} 19 | 20 | steps: 21 | - name: Checkout code 22 | uses: actions/checkout@v4 23 | 24 | - name: Set up JDK 8 25 | uses: actions/setup-java@v4 26 | with: 27 | java-version: '8' 28 | distribution: 'temurin' 29 | 30 | - name: Cache Maven dependencies 31 | uses: actions/cache@v4 32 | with: 33 | path: ~/.m2/repository 34 | key: ${{ runner.os }}-maven-${{ hashFiles('**/pom.xml') }} 35 | restore-keys: | 36 | ${{ runner.os }}-maven- 37 | 38 | - name: Import GPG key 39 | env: 40 | GPG_PRIVATE_KEY: ${{ secrets.GPG_PRIVATE_KEY }} 41 | GPG_PASSPHRASE: ${{ secrets.GPG_PASSPHRASE }} 42 | run: | 43 | echo "$GPG_PRIVATE_KEY" | base64 --decode | gpg --batch --import 44 | echo "allow-preset-passphrase" >> ~/.gnupg/gpg-agent.conf 45 | echo "pinentry-mode loopback" >> ~/.gnupg/gpg.conf 46 | gpg --list-secret-keys --keyid-format LONG 47 | 48 | - name: Configure Maven settings 49 | env: 50 | MAVEN_CENTRAL_USERNAME: ${{ secrets.MAVEN_CENTRAL_USERNAME }} 51 | MAVEN_CENTRAL_TOKEN: ${{ secrets.MAVEN_CENTRAL_TOKEN }} 52 | run: | 53 | mkdir -p ~/.m2 54 | cat > ~/.m2/settings.xml << EOF 55 | 56 | 57 | 58 | central 59 | ${MAVEN_CENTRAL_USERNAME} 60 | ${MAVEN_CENTRAL_TOKEN} 61 | 62 | 63 | 64 | EOF 65 | 66 | - name: Set version from tag 67 | id: set-version 68 | if: startsWith(github.ref, 'refs/tags/') 69 | run: | 70 | VERSION=${GITHUB_REF#refs/tags/v} 71 | echo "VERSION=$VERSION" >> $GITHUB_ENV 72 | echo "version=$VERSION" >> $GITHUB_OUTPUT 73 | mvn versions:set -DnewVersion=$VERSION -DgenerateBackupPoms=false 74 | cd mixpanel-java-extension-jackson 75 | mvn versions:set -DnewVersion=$VERSION -DgenerateBackupPoms=false 76 | cd .. 77 | 78 | - name: Set version from input 79 | id: set-version-input 80 | if: github.event_name == 'workflow_dispatch' 81 | run: | 82 | VERSION=${{ github.event.inputs.version }} 83 | echo "VERSION=$VERSION" >> $GITHUB_ENV 84 | echo "version=$VERSION" >> $GITHUB_OUTPUT 85 | mvn versions:set -DnewVersion=$VERSION -DgenerateBackupPoms=false 86 | cd mixpanel-java-extension-jackson 87 | mvn versions:set -DnewVersion=$VERSION -DgenerateBackupPoms=false 88 | cd .. 89 | 90 | - name: Run tests - Main SDK 91 | run: mvn clean test 92 | 93 | - name: Run tests - Jackson Extension 94 | run: | 95 | cd mixpanel-java-extension-jackson 96 | mvn clean test 97 | cd .. 98 | 99 | - name: Deploy to Maven Central 100 | env: 101 | GPG_PASSPHRASE: ${{ secrets.GPG_PASSPHRASE }} 102 | run: | 103 | mvn clean deploy -Dgpg.passphrase=$GPG_PASSPHRASE 104 | cd mixpanel-java-extension-jackson 105 | mvn clean deploy -Dgpg.passphrase=$GPG_PASSPHRASE 106 | cd .. 107 | 108 | - name: Create GitHub Release 109 | if: startsWith(github.ref, 'refs/tags/') 110 | uses: actions/github-script@v7 111 | with: 112 | github-token: ${{ secrets.GITHUB_TOKEN }} 113 | script: | 114 | const releaseBody = `## Mixpanel Java SDK v${process.env.VERSION} 115 | 116 | ### Maven - Main SDK 117 | \`\`\`xml 118 | 119 | com.mixpanel 120 | mixpanel-java 121 | ${process.env.VERSION} 122 | 123 | \`\`\` 124 | 125 | ### Maven - Jackson Extension (Optional) 126 | \`\`\`xml 127 | 128 | com.mixpanel 129 | mixpanel-java-extension-jackson 130 | ${process.env.VERSION} 131 | 132 | \`\`\` 133 | 134 | ### Changes 135 | See [CHANGELOG](https://github.com/mixpanel/mixpanel-java/blob/master/CHANGELOG.md) for details. 136 | 137 | ### Links 138 | - [Maven Central - Main SDK](https://central.sonatype.com/artifact/com.mixpanel/mixpanel-java/${process.env.VERSION}) 139 | - [Maven Central - Jackson Extension](https://central.sonatype.com/artifact/com.mixpanel/mixpanel-java-extension-jackson/${process.env.VERSION}) 140 | - [JavaDoc](http://mixpanel.github.io/mixpanel-java/)`; 141 | 142 | await github.rest.repos.createRelease({ 143 | owner: context.repo.owner, 144 | repo: context.repo.repo, 145 | tag_name: context.ref.replace('refs/tags/', ''), 146 | name: `Release ${process.env.VERSION}`, 147 | body: releaseBody, 148 | draft: false, 149 | prerelease: false 150 | }); 151 | 152 | verify: 153 | needs: release 154 | runs-on: ubuntu-latest 155 | if: success() 156 | 157 | steps: 158 | - name: Wait for Maven Central sync 159 | run: sleep 300 # Wait 5 minutes for synchronization 160 | 161 | - name: Verify artifacts on Maven Central 162 | run: | 163 | VERSION=${{ needs.release.outputs.version }} 164 | 165 | # Verify main SDK 166 | RESPONSE=$(curl -s -o /dev/null -w "%{http_code}" https://repo1.maven.org/maven2/com/mixpanel/mixpanel-java/${VERSION}/mixpanel-java-${VERSION}.jar) 167 | if [ $RESPONSE -eq 200 ]; then 168 | echo "✅ Main SDK successfully published to Maven Central" 169 | else 170 | echo "⚠️ Main SDK not yet available on Maven Central (HTTP $RESPONSE)" 171 | fi 172 | 173 | # Verify Jackson extension 174 | RESPONSE=$(curl -s -o /dev/null -w "%{http_code}" https://repo1.maven.org/maven2/com/mixpanel/mixpanel-java-extension-jackson/${VERSION}/mixpanel-java-extension-jackson-${VERSION}.jar) 175 | if [ $RESPONSE -eq 200 ]; then 176 | echo "✅ Jackson extension successfully published to Maven Central" 177 | else 178 | echo "⚠️ Jackson extension not yet available on Maven Central (HTTP $RESPONSE)" 179 | fi 180 | 181 | echo "Note: Artifacts may take up to 30 minutes to appear on Maven Central" -------------------------------------------------------------------------------- /CLAUDE.md: -------------------------------------------------------------------------------- 1 | # CLAUDE.md 2 | 3 | This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. 4 | 5 | ## Project Overview 6 | 7 | This is the official Mixpanel tracking library for Java - a production-ready library for sending analytics events and user profile updates to Mixpanel from Java server-side applications. 8 | 9 | ## Release Process 10 | 11 | ### Quick Commands for Releases 12 | 13 | ```bash 14 | # 1. Update version (remove -SNAPSHOT from pom.xml) 15 | mvn versions:set -DnewVersion=1.5.4 16 | 17 | # 2. Run tests 18 | mvn clean test 19 | 20 | # 3. Deploy to Maven Central Portal 21 | mvn clean deploy 22 | 23 | # 4. After release, prepare next version 24 | mvn versions:set -DnewVersion=1.5.5-SNAPSHOT 25 | ``` 26 | 27 | ### Key Files 28 | - **RELEASE.md**: Complete release documentation with step-by-step instructions 29 | - **.github/workflows/release.yml**: Automated release workflow triggered by version tags 30 | - **.github/workflows/ci.yml**: Continuous integration for all PRs and master commits 31 | 32 | ### Maven Central Portal 33 | - The project uses the new Maven Central Portal (not the deprecated OSSRH) 34 | - Deployments are visible at: https://central.sonatype.com/publishing/deployments 35 | - Published artifacts: https://central.sonatype.com/artifact/com.mixpanel/mixpanel-java 36 | 37 | ### Required GitHub Secrets for CI/CD 38 | - `GPG_PRIVATE_KEY`: Base64-encoded GPG private key 39 | - `GPG_PASSPHRASE`: GPG key passphrase 40 | - `MAVEN_CENTRAL_USERNAME`: Maven Central Portal username 41 | - `MAVEN_CENTRAL_TOKEN`: Maven Central Portal token 42 | 43 | ## Build and Development Commands 44 | 45 | ```bash 46 | # Build the project and create JAR 47 | mvn clean package 48 | 49 | # Run all tests 50 | mvn test 51 | 52 | # Run a specific test class 53 | mvn test -Dtest=MixpanelAPITest 54 | 55 | # Run a specific test method 56 | mvn test -Dtest=MixpanelAPITest#testBuildEventMessage 57 | 58 | # Install to local Maven repository 59 | mvn install 60 | 61 | # Generate JavaDoc 62 | mvn javadoc:javadoc 63 | 64 | # Clean build artifacts 65 | mvn clean 66 | 67 | # Run the demo application (after building) 68 | java -cp target/mixpanel-java-1.5.3.jar:target/classes:lib/json-20231013.jar com.mixpanel.mixpanelapi.demo.MixpanelAPIDemo 69 | ``` 70 | 71 | ## High-Level Architecture 72 | 73 | ### Core Design Pattern 74 | The library implements a **Producer-Consumer** pattern with intentional thread separation: 75 | 76 | 1. **Message Production** (`MessageBuilder`): Creates properly formatted JSON messages on application threads 77 | 2. **Message Batching** (`ClientDelivery`): Collects messages into efficient batches (max 50 per request) 78 | 3. **Message Transmission** (`MixpanelAPI`): Sends batched messages to Mixpanel servers 79 | 80 | This separation allows for flexible threading models - the library doesn't impose any specific concurrency pattern, letting applications control their own threading strategy. 81 | 82 | ### Key Architectural Decisions 83 | 84 | **No Built-in Threading**: Unlike some analytics libraries, this one doesn't start background threads. Applications must manage their own async patterns, as demonstrated in `MixpanelAPIDemo` which uses a `ConcurrentLinkedQueue` with producer/consumer threads. 85 | 86 | **Message Format Validation**: `MessageBuilder` performs validation during message construction, throwing `MixpanelMessageException` for malformed data before network transmission. 87 | 88 | **Batch Encoding**: Messages are JSON-encoded, then Base64-encoded, then URL-encoded for HTTP POST transmission. This triple encoding ensures compatibility with Mixpanel's API requirements. 89 | 90 | **Network Communication**: Uses Java's built-in `java.net.URL` and `URLConnection` classes - no external HTTP client dependencies. Connection timeout is 2 seconds, read timeout is 10 seconds. 91 | 92 | ### Message Types and Endpoints 93 | 94 | The library supports three message categories, each sent to different endpoints: 95 | 96 | - **Events** (`/track`): User actions and behaviors 97 | - **People** (`/engage`): User profile updates (set, increment, append, etc.) 98 | - **Groups** (`/groups`): Group profile updates 99 | 100 | Each message type has specific JSON structure requirements validated by `MessageBuilder`. 101 | 102 | ## Package Structure 103 | 104 | All production code is in the `com.mixpanel.mixpanelapi` package: 105 | 106 | - `MixpanelAPI`: HTTP communication with Mixpanel servers 107 | - `MessageBuilder`: Constructs and validates JSON messages 108 | - `ClientDelivery`: Batches multiple messages for efficient transmission 109 | - `Config`: Contains API endpoints and configuration constants 110 | - `Base64Coder`: Base64 encoding utility (modified third-party code) 111 | - `MixpanelMessageException`: Runtime exception for message format errors 112 | - `MixpanelServerException`: IOException for server rejection responses 113 | 114 | ## Testing Approach 115 | 116 | Tests extend JUnit 4's `TestCase` and are located in `MixpanelAPITest`. The test suite covers: 117 | 118 | - Message format validation for all message types 119 | - Property operations (set, setOnce, increment, append, union, remove, unset) 120 | - Large batch delivery behavior 121 | - Encoding verification 122 | - Error conditions and exception handling 123 | 124 | When adding new functionality, follow the existing test patterns - each message type operation has corresponding test methods that verify both the JSON structure and the encoded format. 125 | 126 | ## Common Development Tasks 127 | 128 | ### Adding a New Message Type 129 | 1. Add the message construction method to `MessageBuilder` 130 | 2. Validate required fields and structure 131 | 3. Add corresponding tests in `MixpanelAPITest` 132 | 4. Update `ClientDelivery` if special handling is needed 133 | 134 | ### Modifying Network Behavior 135 | Network configuration is centralized in `MixpanelAPI.sendData()`. Connection and read timeouts are hardcoded but could be made configurable by modifying the `Config` class. 136 | 137 | ### Debugging Failed Deliveries 138 | The library throws `MixpanelServerException` with the HTTP response code and server message. Check: 139 | 1. Token validity in `MessageBuilder` constructor 140 | 2. Message size (batches limited to 50 messages) 141 | 3. JSON structure using the test suite patterns 142 | 143 | ## Dependencies 144 | 145 | The library has minimal dependencies: 146 | - **Production**: `org.json:json:20231013` for JSON manipulation 147 | - **Test**: `junit:junit:4.13.2` for unit testing 148 | - **Java Version**: Requires Java 8 or higher 149 | 150 | ## API Patterns to Follow 151 | 152 | When working with this codebase: 153 | 154 | 1. **Immutable Messages**: Once created by `MessageBuilder`, JSON messages should not be modified 155 | 2. **Fail Fast**: Validate message structure early in `MessageBuilder` rather than during transmission 156 | 3. **Preserve Thread Safety**: `MessageBuilder` instances are NOT thread-safe; create one per thread 157 | 4. **Batch Appropriately**: `ClientDelivery` handles batching; don't exceed 50 messages per delivery 158 | 5. **Exception Handling**: Distinguish between `MixpanelMessageException` (client error) and `MixpanelServerException` (server error) -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | This is the official Mixpanel tracking library for Java. 2 | 3 | ## Latest Version 4 | 5 | See the [releases page](https://github.com/mixpanel/mixpanel-java/releases) for the latest version. 6 | 7 | ```xml 8 | 9 | com.mixpanel 10 | mixpanel-java 11 | 1.6.1 12 | 13 | ``` 14 | 15 | You can alternatively download the library jar directly from Maven Central [here](https://central.sonatype.com/artifact/com.mixpanel/mixpanel-java). 16 | 17 | ## How To Use 18 | 19 | The library is designed to produce events and people updates in one process or thread, and 20 | consume the events and people updates in another thread or process. Specially formatted JSON objects 21 | are built by `MessageBuilder` objects, and those messages can be consumed by the 22 | `MixpanelAPI` via `ClientDelivery` objects, possibly after serialization or IPC. 23 | 24 | MessageBuilder messages = new MessageBuilder("my token"); 25 | JSONObject event = messages.event("joe@gribbl.com", "Logged In", null); 26 | 27 | // Later, or elsewhere... 28 | ClientDelivery delivery = new ClientDelivery(); 29 | delivery.addMessage(event); 30 | 31 | MixpanelAPI mixpanel = new MixpanelAPI(); 32 | mixpanel.deliver(delivery); 33 | 34 | ### Gzip Compression 35 | 36 | The library supports gzip compression for both tracking events (`/track`) and importing historical events (`/import`). To enable gzip compression, use the builder: 37 | 38 | ```java 39 | MixpanelAPI mixpanel = new MixpanelAPI.Builder() 40 | .useGzipCompression(true) 41 | .build(); 42 | ``` 43 | 44 | Gzip compression can reduce bandwidth usage and improve performance, especially when sending large batches of events. 45 | 46 | ### Importing Historical Events 47 | 48 | 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. 49 | 50 | ### High-Performance JSON Serialization (Optional) 51 | 52 | For applications that import large batches of events (e.g., using the `/import` endpoint), the library supports optional high-performance JSON serialization using Jackson. The Jackson extension provides **up to 5x performance improvement** for large batches. 53 | 54 | To enable high-performance serialization, add the Jackson extension to your project: 55 | 56 | ```xml 57 | 58 | com.mixpanel 59 | mixpanel-java-extension-jackson 60 | 1.6.1 61 | 62 | ``` 63 | 64 | Then configure the MixpanelAPI to use it: 65 | 66 | ```java 67 | import com.mixpanel.mixpanelapi.internal.JacksonSerializer; 68 | 69 | MixpanelAPI mixpanel = new MixpanelAPI.Builder() 70 | .jsonSerializer(new JacksonSerializer()) 71 | .build(); 72 | ``` 73 | 74 | **Key benefits:** 75 | - **Significant performance gains**: 2-5x faster serialization for batches of 50+ messages 76 | - **Optimal for `/import`**: Most beneficial when importing large batches (up to 2000 events) 77 | 78 | The performance improvement is most noticeable when: 79 | - Importing historical data via the `/import` endpoint 80 | - Sending batches of 50+ events 81 | - Processing high-volume event streams 82 | 83 | ## Feature Flags 84 | 85 | The Mixpanel Java SDK supports feature flags with both local and remote evaluation modes. 86 | 87 | ### Local Evaluation (Recommended) 88 | 89 | Fast, low-latency flag checks with background polling for flag definitions: 90 | 91 | ```java 92 | import com.mixpanel.mixpanelapi.*; 93 | import com.mixpanel.mixpanelapi.featureflags.config.*; 94 | import java.util.*; 95 | 96 | // Initialize with your project token 97 | LocalFlagsConfig config = LocalFlagsConfig.builder() 98 | .projectToken("YOUR_PROJECT_TOKEN") 99 | .pollingIntervalSeconds(60) 100 | .build(); 101 | 102 | MixpanelAPI mixpanel = new MixpanelAPI.Builder() 103 | .flagsConfig(config) 104 | .build(); 105 | 106 | // Start polling for flag definitions 107 | mixpanel.getLocalFlags().startPollingForDefinitions(); 108 | 109 | // Wait for flags to be ready (optional but recommended) 110 | while (!mixpanel.getLocalFlags().areFlagsReady()) { 111 | Thread.sleep(100); 112 | } 113 | 114 | // Evaluate flags 115 | Map context = new HashMap<>(); 116 | context.put("distinct_id", "user-123"); 117 | 118 | // Check if a feature is enabled 119 | boolean isEnabled = mixpanel.getLocalFlags().isEnabled("new-feature", context); 120 | 121 | // Get a variant value with fallback 122 | String theme = mixpanel.getLocalFlags().getVariantValue("ui-theme", "light", context); 123 | 124 | // Cleanup 125 | mixpanel.close(); 126 | ``` 127 | 128 | ### Remote Evaluation 129 | 130 | Real-time flag evaluation with server-side API calls: 131 | 132 | ```java 133 | import com.mixpanel.mixpanelapi.*; 134 | import com.mixpanel.mixpanelapi.featureflags.config.*; 135 | import java.util.*; 136 | 137 | RemoteFlagsConfig config = RemoteFlagsConfig.builder() 138 | .projectToken("YOUR_PROJECT_TOKEN") 139 | .build(); 140 | 141 | try (MixpanelAPI mixpanel = new MixpanelAPI.Builder().flagsConfig(config).build()) { 142 | Map context = new HashMap<>(); 143 | context.put("distinct_id", "user-456"); 144 | 145 | boolean isEnabled = mixpanel.getRemoteFlags().isEnabled("premium-features", context); 146 | } 147 | ``` 148 | 149 | For complete feature flags documentation, configuration options, advanced usage, and best practices, see: 150 | 151 | https://docs.mixpanel.com/docs/tracking-methods/sdks/java/java-flags 152 | 153 | ## Learn More 154 | 155 | This library in particular has more in-depth documentation at 156 | 157 | https://mixpanel.com/docs/integration-libraries/java 158 | 159 | Mixpanel maintains documentation at 160 | 161 | http://www.mixpanel.com/docs 162 | 163 | The library also contains a simple demo application, that demonstrates 164 | using this library in an asynchronous environment. 165 | 166 | There are also community supported libraries in addition to this library, 167 | that provide a threading model, support for dealing directly with Java Servlet requests, 168 | support for persistent properties, etc. Two interesting ones are at: 169 | 170 | https://github.com/eranation/mixpanel-java 171 | https://github.com/scalascope/mixpanel-java 172 | 173 | ## Other Mixpanel Libraries 174 | 175 | Mixpanel also maintains a full-featured library for tracking events from Android apps at https://github.com/mixpanel/mixpanel-android 176 | 177 | And a full-featured client side library for web applications, in Javascript, that can be loaded 178 | directly from Mixpanel servers. To learn more about our Javascript library, see: https://mixpanel.com/docs/integration-libraries/javascript 179 | 180 | This library is intended for use in back end applications or API services that can't take 181 | advantage of the Android libraries or the Javascript library. 182 | 183 | ## License 184 | 185 | ``` 186 | See LICENSE File for details. The Base64Coder class used by this software 187 | has been licensed from non-Mixpanel sources and modified for use in the library. 188 | Please see Base64Coder.java for details. 189 | ``` 190 | -------------------------------------------------------------------------------- /src/main/java/com/mixpanel/mixpanelapi/featureflags/provider/RemoteFlagsProvider.java: -------------------------------------------------------------------------------- 1 | package com.mixpanel.mixpanelapi.featureflags.provider; 2 | 3 | import com.mixpanel.mixpanelapi.featureflags.EventSender; 4 | import com.mixpanel.mixpanelapi.featureflags.config.RemoteFlagsConfig; 5 | import com.mixpanel.mixpanelapi.featureflags.model.SelectedVariant; 6 | 7 | import org.json.JSONObject; 8 | 9 | import java.io.UnsupportedEncodingException; 10 | import java.net.URLEncoder; 11 | import java.text.SimpleDateFormat; 12 | import java.util.Date; 13 | import java.util.Map; 14 | import java.util.TimeZone; 15 | import java.util.UUID; 16 | import java.util.logging.Level; 17 | import java.util.logging.Logger; 18 | 19 | /** 20 | * Remote feature flags evaluation provider. 21 | *

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

25 | *

26 | * This class is thread-safe. 27 | *

28 | */ 29 | public class RemoteFlagsProvider extends BaseFlagsProvider { 30 | private static final Logger logger = Logger.getLogger(RemoteFlagsProvider.class.getName()); 31 | 32 | /** 33 | * Creates a new RemoteFlagsProvider. 34 | * 35 | * @param config the remote flags configuration 36 | * @param sdkVersion the SDK version string 37 | * @param eventSender the EventSender implementation for tracking exposure events 38 | */ 39 | public RemoteFlagsProvider(RemoteFlagsConfig config, String sdkVersion, EventSender eventSender) { 40 | super(config.getProjectToken(), config, sdkVersion, eventSender); 41 | } 42 | 43 | // #region Evaluation 44 | 45 | /** 46 | * Evaluates a flag remotely and returns the selected variant. 47 | * 48 | * @param flagKey the flag key to evaluate 49 | * @param fallback the fallback variant to return if evaluation fails 50 | * @param context the evaluation context 51 | * @param reportExposure whether to track an exposure event for this flag evaluation 52 | * @param the type of the variant value 53 | * @return the selected variant or fallback 54 | */ 55 | public SelectedVariant getVariant(String flagKey, SelectedVariant fallback, Map context, boolean reportExposure) { 56 | String startTime = getCurrentIso8601Timestamp(); 57 | 58 | try { 59 | String endpoint = buildFlagsUrl(flagKey, context); 60 | 61 | String response = httpGet(endpoint); 62 | 63 | JSONObject root = new JSONObject(response); 64 | JSONObject flags = root.optJSONObject("flags"); 65 | 66 | if (flags == null || !flags.has(flagKey)) { 67 | logger.log(Level.WARNING, "Flag not found in response: " + flagKey); 68 | return fallback; 69 | } 70 | 71 | JSONObject flagData = flags.getJSONObject(flagKey); 72 | String variantKey = flagData.optString("variant_key", null); 73 | Object variantValue = flagData.opt("variant_value"); 74 | 75 | if (variantKey == null) { 76 | return fallback; 77 | } 78 | 79 | // Parse experiment metadata 80 | UUID experimentId = null; 81 | String experimentIdString = flagData.optString("experiment_id", null); 82 | if (experimentIdString != null && !experimentIdString.isEmpty()) { 83 | try { 84 | experimentId = UUID.fromString(experimentIdString); 85 | } catch (IllegalArgumentException e) { 86 | logger.log(Level.WARNING, "Invalid UUID for experiment_id: " + experimentIdString); 87 | } 88 | } 89 | 90 | Boolean isExperimentActive = null; 91 | if (flagData.has("is_experiment_active")) { 92 | isExperimentActive = flagData.optBoolean("is_experiment_active", false); 93 | } 94 | 95 | Boolean isQaTester = null; 96 | if (flagData.has("is_qa_tester")) { 97 | isQaTester = flagData.optBoolean("is_qa_tester", false); 98 | } 99 | 100 | // Track exposure 101 | String completeTime = getCurrentIso8601Timestamp(); 102 | if (reportExposure) { 103 | trackRemoteExposure(context, flagKey, variantKey, startTime, completeTime, experimentId, isExperimentActive, isQaTester); 104 | } 105 | 106 | @SuppressWarnings("unchecked") 107 | SelectedVariant result = new SelectedVariant<>(variantKey, (T) variantValue, experimentId, isExperimentActive, isQaTester); 108 | return result; 109 | 110 | } catch (Exception e) { 111 | logger.log(Level.WARNING, "Error evaluating flag remotely: " + flagKey, e); 112 | return fallback; 113 | } 114 | } 115 | 116 | // #endregion 117 | // #region HTTP Helpers 118 | 119 | /** 120 | * Builds the URL for remote flag evaluation. 121 | */ 122 | private String buildFlagsUrl(String flagKey, Map context) throws UnsupportedEncodingException { 123 | StringBuilder url = new StringBuilder(); 124 | url.append("https://").append(config.getApiHost()).append("/flags"); 125 | url.append("?mp_lib=").append(URLEncoder.encode("jdk", "UTF-8")); 126 | url.append("&lib_version=").append(URLEncoder.encode(sdkVersion, "UTF-8")); 127 | url.append("&token=").append(URLEncoder.encode(projectToken, "UTF-8")); 128 | url.append("&flag_key=").append(URLEncoder.encode(flagKey, "UTF-8")); 129 | 130 | JSONObject contextJson = new JSONObject(context); 131 | String contextString = contextJson.toString(); 132 | url.append("&context=").append(URLEncoder.encode(contextString, "UTF-8")); 133 | 134 | return url.toString(); 135 | } 136 | 137 | /** 138 | * Gets current timestamp in ISO 8601 format. 139 | */ 140 | private String getCurrentIso8601Timestamp() { 141 | SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'"); 142 | sdf.setTimeZone(TimeZone.getTimeZone("UTC")); 143 | return sdf.format(new Date()); 144 | } 145 | 146 | // #endregion 147 | 148 | /** 149 | * Tracks an exposure event for remote evaluation. 150 | */ 151 | private void trackRemoteExposure(Map context, String flagKey, String variantKey, String startTime, String completeTime, UUID experimentId, Boolean isExperimentActive, Boolean isQaTester) { 152 | if (eventSender == null) { 153 | return; 154 | } 155 | 156 | Object distinctIdObj = context.get("distinct_id"); 157 | if (distinctIdObj == null) { 158 | return; 159 | } 160 | 161 | trackExposure(distinctIdObj.toString(), flagKey, variantKey, "remote", properties -> { 162 | properties.put("Variant fetch start time", startTime); 163 | properties.put("Variant fetch complete time", completeTime); 164 | }, experimentId, isExperimentActive, isQaTester); 165 | } 166 | 167 | @Override 168 | protected Logger getLogger() { 169 | return logger; 170 | } 171 | } 172 | -------------------------------------------------------------------------------- /mixpanel-java-extension-jackson/src/test/java/com/mixpanel/mixpanelapi/internal/JacksonSerializerTest.java: -------------------------------------------------------------------------------- 1 | package com.mixpanel.mixpanelapi.internal; 2 | 3 | import junit.framework.TestCase; 4 | import org.json.JSONArray; 5 | import org.json.JSONObject; 6 | import org.skyscreamer.jsonassert.JSONAssert; 7 | import org.skyscreamer.jsonassert.JSONCompareMode; 8 | 9 | import java.math.BigDecimal; 10 | import java.math.BigInteger; 11 | import java.util.ArrayList; 12 | import java.util.Arrays; 13 | import java.util.Calendar; 14 | import java.util.Date; 15 | import java.util.List; 16 | 17 | public class JacksonSerializerTest extends TestCase { 18 | 19 | public void testJacksonMatchesOrgJsonEmptyList() throws Exception { 20 | JsonSerializer jacksonSerializer = new JacksonSerializer(); 21 | JsonSerializer orgSerializer = new OrgJsonSerializer(); 22 | 23 | List messages = new ArrayList<>(); 24 | String jacksonResult = jacksonSerializer.serializeArray(messages); 25 | String orgResult = orgSerializer.serializeArray(messages); 26 | 27 | assertEquals("[]", jacksonResult); 28 | JSONAssert.assertEquals(orgResult, jacksonResult, JSONCompareMode.STRICT); 29 | } 30 | 31 | public void testJacksonMatchesOrgJsonSingleMessage() throws Exception { 32 | JsonSerializer jacksonSerializer = new JacksonSerializer(); 33 | JsonSerializer orgSerializer = new OrgJsonSerializer(); 34 | 35 | JSONObject message = new JSONObject(); 36 | message.put("event", "test_event"); 37 | message.put("value", 123); 38 | List messages = Arrays.asList(message); 39 | 40 | String jacksonResult = jacksonSerializer.serializeArray(messages); 41 | String orgResult = orgSerializer.serializeArray(messages); 42 | 43 | JSONArray array = new JSONArray(jacksonResult); 44 | assertEquals(1, array.length()); 45 | JSONObject parsed = array.getJSONObject(0); 46 | assertEquals("test_event", parsed.getString("event")); 47 | assertEquals(123, parsed.getInt("value")); 48 | JSONAssert.assertEquals(orgResult, jacksonResult, JSONCompareMode.STRICT); 49 | } 50 | 51 | public void testJacksonMatchesOrgJsonComplexObject() throws Exception { 52 | JsonSerializer jacksonSerializer = new JacksonSerializer(); 53 | JsonSerializer orgSerializer = new OrgJsonSerializer(); 54 | 55 | JSONObject message = new JSONObject(); 56 | message.put("event", "complex_event"); 57 | message.put("null_value", JSONObject.NULL); 58 | message.put("boolean_value", false); 59 | message.put("int_value", 42); 60 | message.put("long_value", 9999999999L); 61 | message.put("double_value", 3.14159); 62 | message.put("float_value", 2.5f); 63 | message.put("string_value", "test with \"quotes\" and special chars: \n\t"); 64 | 65 | //This block is testing different serialized types to ensure it matches OrgJsonSerializer 66 | message.put("big_decimal_value", new BigDecimal("1234567890.123456789")); 67 | message.put("big_integer_value", new BigInteger("12345678901234567890")); 68 | message.put("date", new Date(1704067200000L)); 69 | Calendar testCalendar = Calendar.getInstance(); 70 | testCalendar.setTimeInMillis(1704067200000L); // 2024-01-01 00:00:00 UTC 71 | message.put("calendar", testCalendar); 72 | 73 | JSONObject nested = new JSONObject(); 74 | nested.put("level2", new JSONObject().put("level3", "deep value")); 75 | message.put("nested", nested); 76 | 77 | JSONArray array = new JSONArray(); 78 | array.put("string"); 79 | array.put(100); 80 | array.put(false); 81 | array.put(JSONObject.NULL); 82 | array.put(new JSONObject().put("in_array", true)); 83 | message.put("array", array); 84 | 85 | List messages = Arrays.asList(message); 86 | 87 | String jacksonResult = jacksonSerializer.serializeArray(messages); 88 | String orgResult = orgSerializer.serializeArray(messages); 89 | 90 | JSONArray parsedArray = new JSONArray(jacksonResult); 91 | JSONObject parsed = parsedArray.getJSONObject(0); 92 | 93 | assertEquals("complex_event", parsed.getString("event")); 94 | assertTrue(parsed.isNull("null_value")); 95 | assertFalse(parsed.getBoolean("boolean_value")); 96 | assertEquals(42, parsed.getInt("int_value")); 97 | assertEquals(9999999999L, parsed.getLong("long_value")); 98 | assertEquals(3.14159, parsed.getDouble("double_value"), 0.00001); 99 | assertEquals(2.5f, parsed.getFloat("float_value"), 0.01); 100 | assertEquals("test with \"quotes\" and special chars: \n\t", parsed.getString("string_value")); 101 | 102 | assertEquals("deep value", 103 | parsed.getJSONObject("nested") 104 | .getJSONObject("level2") 105 | .getString("level3")); 106 | 107 | JSONArray parsedInnerArray = parsed.getJSONArray("array"); 108 | assertEquals(5, parsedInnerArray.length()); 109 | assertEquals("string", parsedInnerArray.getString(0)); 110 | assertEquals(100, parsedInnerArray.getInt(1)); 111 | assertFalse(parsedInnerArray.getBoolean(2)); 112 | assertTrue(parsedInnerArray.isNull(3)); 113 | assertTrue(parsedInnerArray.getJSONObject(4).getBoolean("in_array")); 114 | // Verify both serializers produce equivalent JSON 115 | JSONAssert.assertEquals(orgResult, jacksonResult, JSONCompareMode.STRICT); 116 | } 117 | 118 | public void testJacksonMatchesOrgJsonMultipleMessages() throws Exception { 119 | JsonSerializer jacksonSerializer = new JacksonSerializer(); 120 | JsonSerializer orgSerializer = new OrgJsonSerializer(); 121 | 122 | List messages = new ArrayList<>(); 123 | 124 | for (int i = 0; i < 10; i++) { 125 | JSONObject message = new JSONObject(); 126 | message.put("event", "event_" + i); 127 | message.put("index", i); 128 | message.put("timestamp", System.currentTimeMillis()); 129 | message.put("properties", new JSONObject() 130 | .put("user_id", "user_" + i) 131 | .put("amount", i * 10.5)); 132 | messages.add(message); 133 | } 134 | 135 | String jacksonResult = jacksonSerializer.serializeArray(messages); 136 | String orgResult = orgSerializer.serializeArray(messages); 137 | 138 | // Verify both serializers produce equivalent JSON 139 | JSONAssert.assertEquals(orgResult, jacksonResult, JSONCompareMode.STRICT); 140 | } 141 | 142 | public void testLargeBatchSerialization() throws Exception { 143 | // Test with a large batch to verify performance and that output matches OrgJson 144 | JsonSerializer jacksonSerializer = new JacksonSerializer(); 145 | JsonSerializer orgSerializer = new OrgJsonSerializer(); 146 | List messages = new ArrayList<>(); 147 | 148 | // Create 2000 messages (max batch size for /import) 149 | for (int i = 0; i < 2000; i++) { 150 | JSONObject message = new JSONObject(); 151 | message.put("event", "batch_event"); 152 | message.put("properties", new JSONObject() 153 | .put("index", i) 154 | .put("timestamp", System.currentTimeMillis()) 155 | .put("data", "Some test data for message " + i)); 156 | messages.add(message); 157 | } 158 | 159 | long jacksonStart = System.currentTimeMillis(); 160 | String jacksonResult = jacksonSerializer.serializeArray(messages); 161 | long jacksonEnd = System.currentTimeMillis(); 162 | 163 | long orgStart = System.currentTimeMillis(); 164 | String orgResult = orgSerializer.serializeArray(messages); 165 | long orgEnd = System.currentTimeMillis(); 166 | 167 | // Verify both produce equivalent JSON 168 | JSONAssert.assertEquals(orgResult, jacksonResult, JSONCompareMode.STRICT); 169 | 170 | // Parse to verify correctness 171 | JSONArray array = new JSONArray(jacksonResult); 172 | assertEquals(2000, array.length()); 173 | assertEquals("batch_event", array.getJSONObject(0).getString("event")); 174 | assertEquals(0, array.getJSONObject(0).getJSONObject("properties").getInt("index")); 175 | assertEquals(1999, array.getJSONObject(1999).getJSONObject("properties").getInt("index")); 176 | 177 | // Log serialization times for comparison 178 | long jacksonTime = jacksonEnd - jacksonStart; 179 | long orgTime = orgEnd - orgStart; 180 | System.out.println("Jackson serialized 2000 messages in " + jacksonTime + "ms"); 181 | System.out.println("OrgJson serialized 2000 messages in " + orgTime + "ms"); 182 | System.out.println("Performance improvement: " + String.format("%.2fx", (double) orgTime / jacksonTime)); 183 | } 184 | } 185 | -------------------------------------------------------------------------------- /src/demo/java/com/mixpanel/mixpanelapi/demo/MixpanelAPIDemo.java: -------------------------------------------------------------------------------- 1 | package com.mixpanel.mixpanelapi.demo; 2 | 3 | import java.io.BufferedReader; 4 | import java.io.IOException; 5 | import java.io.InputStreamReader; 6 | import java.util.HashMap; 7 | import java.util.Map; 8 | import java.util.Queue; 9 | import java.util.concurrent.ConcurrentLinkedQueue; 10 | 11 | import org.json.JSONObject; 12 | 13 | import com.mixpanel.mixpanelapi.ClientDelivery; 14 | import com.mixpanel.mixpanelapi.MessageBuilder; 15 | import com.mixpanel.mixpanelapi.MixpanelAPI; 16 | 17 | /** 18 | * This is a simple demonstration of how you might use the Mixpanel 19 | * Java API in your programs. 20 | * 21 | */ 22 | public class MixpanelAPIDemo { 23 | 24 | 25 | public static String PROJECT_TOKEN = "bf2a25faaefdeed4aecde6e177d111bf"; // "YOUR TOKEN"; 26 | public static long MILLIS_TO_WAIT = 10 * 1000; 27 | 28 | private static class DeliveryThread extends Thread { 29 | public DeliveryThread(Queue messages, boolean useGzipCompression) { 30 | mMixpanel = new MixpanelAPI(useGzipCompression); 31 | mMessageQueue = messages; 32 | mUseGzipCompression = useGzipCompression; 33 | } 34 | 35 | @Override 36 | public void run() { 37 | try { 38 | while(true) { 39 | int messageCount = 0; 40 | ClientDelivery delivery = new ClientDelivery(); 41 | JSONObject message = null; 42 | do { 43 | message = mMessageQueue.poll(); 44 | if (message != null) { 45 | System.out.println("WILL SEND MESSAGE" + (mUseGzipCompression ? " (with gzip compression)" : "") + ":\n" + message.toString()); 46 | 47 | messageCount = messageCount + 1; 48 | delivery.addMessage(message); 49 | } 50 | 51 | } while(message != null); 52 | 53 | mMixpanel.deliver(delivery); 54 | 55 | System.out.println("Sent " + messageCount + " messages" + (mUseGzipCompression ? " with gzip compression" : "") + "."); 56 | Thread.sleep(MILLIS_TO_WAIT); 57 | } 58 | } catch (IOException e) { 59 | throw new RuntimeException("Can't communicate with Mixpanel.", e); 60 | } catch (InterruptedException e) { 61 | System.out.println("Message process interrupted."); 62 | } 63 | } 64 | 65 | private final MixpanelAPI mMixpanel; 66 | private final Queue mMessageQueue; 67 | private final boolean mUseGzipCompression; 68 | } 69 | 70 | public static void printUsage() { 71 | System.out.println("USAGE: java com.mixpanel.mixpanelapi.demo.MixpanelAPIDemo distinct_id"); 72 | System.out.println(""); 73 | System.out.println("This is a simple program demonstrating Mixpanel's Java library."); 74 | System.out.println("It reads lines from standard input and sends them to Mixpanel as events."); 75 | System.out.println(""); 76 | System.out.println("The demo also shows:"); 77 | System.out.println(" - Setting user properties"); 78 | System.out.println(" - Tracking charges"); 79 | System.out.println(" - Importing historical events"); 80 | System.out.println(" - Incrementing user properties"); 81 | System.out.println(" - Using gzip compression"); 82 | } 83 | 84 | /** 85 | * @param args 86 | */ 87 | public static void main(String[] args) 88 | throws IOException, InterruptedException { 89 | Queue messages = new ConcurrentLinkedQueue(); 90 | Queue messagesWithGzip = new ConcurrentLinkedQueue(); 91 | 92 | // Create two delivery threads - one without gzip and one with gzip compression 93 | DeliveryThread worker = new DeliveryThread(messages, false); 94 | DeliveryThread workerWithGzip = new DeliveryThread(messagesWithGzip, true); 95 | 96 | MessageBuilder messageBuilder = new MessageBuilder(PROJECT_TOKEN); 97 | 98 | if (args.length != 1) { 99 | printUsage(); 100 | System.exit(1); 101 | } 102 | 103 | worker.start(); 104 | workerWithGzip.start(); 105 | 106 | String distinctId = args[0]; 107 | BufferedReader inputLines = new BufferedReader(new InputStreamReader(System.in)); 108 | String line = inputLines.readLine(); 109 | 110 | // Set the first name of the associated user (to distinct id) 111 | Map namePropsMap = new HashMap(); 112 | namePropsMap.put("$first_name", distinctId); 113 | JSONObject nameProps = new JSONObject(namePropsMap); 114 | JSONObject nameMessage = messageBuilder.set(distinctId, nameProps); 115 | messages.add(nameMessage); 116 | 117 | // Charge the user $2.50 for using the program :) 118 | JSONObject transactionMessage = messageBuilder.trackCharge(distinctId, 2.50, null); 119 | messages.add(transactionMessage); 120 | 121 | // Import a historical event (30 days ago) with explicit time and $insert_id 122 | long thirtyDaysAgo = System.currentTimeMillis() - (30L * 24L * 60L * 60L * 1000L); 123 | Map importPropsMap = new HashMap(); 124 | importPropsMap.put("time", thirtyDaysAgo); 125 | importPropsMap.put("$insert_id", "demo-import-" + System.currentTimeMillis()); 126 | importPropsMap.put("Event Type", "Historical"); 127 | importPropsMap.put("Source", "Demo Import"); 128 | JSONObject importProps = new JSONObject(importPropsMap); 129 | JSONObject importMessage = messageBuilder.importEvent(distinctId, "Program Started", importProps); 130 | messages.add(importMessage); 131 | 132 | // Import another event using defaults (time and $insert_id auto-generated) 133 | Map simpleImportProps = new HashMap(); 134 | simpleImportProps.put("Source", "Demo Simple Import"); 135 | JSONObject simpleImportMessage = messageBuilder.importEvent(distinctId, "Simple Import Event", new JSONObject(simpleImportProps)); 136 | messages.add(simpleImportMessage); 137 | 138 | // Import event with no properties at all (time and $insert_id both auto-generated) 139 | JSONObject minimalImportMessage = messageBuilder.importEvent(distinctId, "Minimal Import Event", null); 140 | messages.add(minimalImportMessage); 141 | 142 | // Demonstrate gzip compression by sending some messages with compression enabled 143 | System.out.println("\n=== Demonstrating gzip compression ==="); 144 | 145 | // Send a regular event with gzip compression 146 | Map gzipEventProps = new HashMap(); 147 | gzipEventProps.put("Compression", "gzip"); 148 | gzipEventProps.put("Demo", "true"); 149 | JSONObject gzipEvent = messageBuilder.event(distinctId, "Gzip Compressed Event", new JSONObject(gzipEventProps)); 150 | messagesWithGzip.add(gzipEvent); 151 | 152 | // Send an import event with gzip compression 153 | long historicalTime = System.currentTimeMillis() - (60L * 24L * 60L * 60L * 1000L); 154 | Map gzipImportProps = new HashMap(); 155 | gzipImportProps.put("time", historicalTime); 156 | gzipImportProps.put("$insert_id", "gzip-import-" + System.currentTimeMillis()); 157 | gzipImportProps.put("Compression", "gzip"); 158 | gzipImportProps.put("Event Type", "Historical with Gzip"); 159 | JSONObject gzipImportEvent = messageBuilder.importEvent(distinctId, "Gzip Compressed Import", new JSONObject(gzipImportProps)); 160 | messagesWithGzip.add(gzipImportEvent); 161 | 162 | System.out.println("Added events to gzip compression queue\n"); 163 | 164 | while((line != null) && (line.length() > 0)) { 165 | System.out.println("SENDING LINE: " + line); 166 | Map propMap = new HashMap(); 167 | propMap.put("Line Typed", line.trim()); 168 | JSONObject props = new JSONObject(propMap); 169 | JSONObject eventMessage = messageBuilder.event(distinctId, "Typed Line", props); 170 | messages.add(eventMessage); 171 | 172 | Map lineCounter = new HashMap(); 173 | lineCounter.put("Lines Typed", 1L); 174 | JSONObject incrementMessage = messageBuilder.increment(distinctId, lineCounter); 175 | messages.add(incrementMessage); 176 | 177 | line = inputLines.readLine(); 178 | } 179 | 180 | while(! messages.isEmpty() || ! messagesWithGzip.isEmpty()) { 181 | Thread.sleep(1000); 182 | } 183 | 184 | worker.interrupt(); 185 | workerWithGzip.interrupt(); 186 | } 187 | } 188 | -------------------------------------------------------------------------------- /src/main/java/com/mixpanel/mixpanelapi/featureflags/provider/BaseFlagsProvider.java: -------------------------------------------------------------------------------- 1 | package com.mixpanel.mixpanelapi.featureflags.provider; 2 | 3 | import com.mixpanel.mixpanelapi.featureflags.EventSender; 4 | import com.mixpanel.mixpanelapi.featureflags.config.BaseFlagsConfig; 5 | import com.mixpanel.mixpanelapi.featureflags.model.SelectedVariant; 6 | import com.mixpanel.mixpanelapi.featureflags.util.TraceparentUtil; 7 | 8 | import org.json.JSONObject; 9 | 10 | import java.io.IOException; 11 | import java.io.InputStream; 12 | import java.io.InputStreamReader; 13 | import java.net.URL; 14 | import java.net.URLConnection; 15 | import java.nio.charset.StandardCharsets; 16 | import java.util.Map; 17 | import java.util.UUID; 18 | import java.util.function.Consumer; 19 | import java.util.logging.Level; 20 | import java.util.logging.Logger; 21 | 22 | /** 23 | * Base class for feature flags providers. 24 | *

25 | * Contains shared HTTP functionality and common evaluation helpers. 26 | * Subclasses implement specific evaluation strategies (local or remote). 27 | *

28 | * 29 | * @param the config type extending BaseFlagsConfig 30 | */ 31 | public abstract class BaseFlagsProvider { 32 | protected static final int BUFFER_SIZE = 4096; 33 | 34 | protected final String projectToken; 35 | protected final C config; 36 | protected final String sdkVersion; 37 | protected final EventSender eventSender; 38 | 39 | /** 40 | * Creates a new BaseFlagsProvider. 41 | * 42 | * @param projectToken the Mixpanel project token 43 | * @param config the flags configuration 44 | * @param sdkVersion the SDK version string 45 | * @param eventSender the EventSender implementation for tracking exposure events 46 | */ 47 | protected BaseFlagsProvider(String projectToken, C config, String sdkVersion, EventSender eventSender) { 48 | this.projectToken = projectToken; 49 | this.config = config; 50 | this.sdkVersion = sdkVersion; 51 | this.eventSender = eventSender; 52 | } 53 | 54 | // #region HTTP Methods 55 | 56 | /** 57 | * Performs an HTTP GET request with Basic Auth. 58 | *

59 | * This method is protected to allow test subclasses to override HTTP behavior. 60 | *

61 | */ 62 | protected String httpGet(String urlString) throws IOException { 63 | URL url = new URL(urlString); 64 | URLConnection conn = url.openConnection(); 65 | conn.setConnectTimeout(config.getRequestTimeoutSeconds() * 1000); 66 | conn.setReadTimeout(config.getRequestTimeoutSeconds() * 1000); 67 | 68 | // Set Basic Auth header (token as username, empty password) 69 | String auth = projectToken + ":"; 70 | String encodedAuth = java.util.Base64.getEncoder().encodeToString(auth.getBytes(StandardCharsets.UTF_8)); 71 | conn.setRequestProperty("Authorization", "Basic " + encodedAuth); 72 | 73 | // Set custom headers 74 | conn.setRequestProperty("X-Scheme", "https"); 75 | conn.setRequestProperty("X-Forwarded-Proto", "https"); 76 | conn.setRequestProperty("Content-Type", "application/json"); 77 | conn.setRequestProperty("traceparent", TraceparentUtil.generateTraceparent()); 78 | 79 | InputStream responseStream = null; 80 | try { 81 | responseStream = conn.getInputStream(); 82 | return readStream(responseStream); 83 | } finally { 84 | if (responseStream != null) { 85 | try { 86 | responseStream.close(); 87 | } catch (IOException e) { 88 | getLogger().log(Level.WARNING, "Failed to close response stream", e); 89 | } 90 | } 91 | } 92 | } 93 | 94 | /** 95 | * Reads an input stream to a string. 96 | */ 97 | protected String readStream(InputStream in) throws IOException { 98 | StringBuilder out = new StringBuilder(); 99 | InputStreamReader reader = new InputStreamReader(in, StandardCharsets.UTF_8); 100 | 101 | char[] buffer = new char[BUFFER_SIZE]; 102 | int count; 103 | while ((count = reader.read(buffer)) != -1) { 104 | out.append(buffer, 0, count); 105 | } 106 | 107 | return out.toString(); 108 | } 109 | 110 | // #endregion 111 | 112 | // #region Abstract Methods 113 | 114 | /** 115 | * Evaluates a flag and returns the selected variant. 116 | *

117 | * Subclasses must implement this method to provide local or remote evaluation logic. 118 | *

119 | * 120 | * @param flagKey the flag key to evaluate 121 | * @param fallback the fallback variant to return if evaluation fails 122 | * @param context the evaluation context 123 | * @param reportExposure whether to track an exposure event for this flag evaluation 124 | * @param the type of the variant value 125 | * @return the selected variant or fallback 126 | */ 127 | public abstract SelectedVariant getVariant(String flagKey, SelectedVariant fallback, Map context, boolean reportExposure); 128 | 129 | /** 130 | * Evaluates a flag and returns the selected variant. 131 | *

132 | * This is a convenience method that defaults reportExposure to true. 133 | *

134 | * 135 | * @param flagKey the flag key to evaluate 136 | * @param fallback the fallback variant to return if evaluation fails 137 | * @param context the evaluation context 138 | * @param the type of the variant value 139 | * @return the selected variant or fallback 140 | */ 141 | public SelectedVariant getVariant(String flagKey, SelectedVariant fallback, Map context) { 142 | return getVariant(flagKey, fallback, context, true); 143 | } 144 | 145 | /** 146 | * Gets the logger for this provider. 147 | * Subclasses should override this to return their class-specific logger. 148 | * 149 | * @return the logger instance 150 | */ 151 | protected abstract Logger getLogger(); 152 | 153 | // #endregion 154 | 155 | // #region Variant Value Methods 156 | 157 | /** 158 | * Evaluates a flag and returns the variant value. 159 | * 160 | * @param flagKey the flag key to evaluate 161 | * @param fallbackValue the fallback value to return if evaluation fails 162 | * @param context the evaluation context 163 | * @param the type of the variant value 164 | * @return the selected variant value or fallback 165 | */ 166 | public T getVariantValue(String flagKey, T fallbackValue, Map context) { 167 | SelectedVariant fallback = new SelectedVariant<>(fallbackValue); 168 | SelectedVariant result = getVariant(flagKey, fallback, context, true); 169 | return result.getVariantValue(); 170 | } 171 | 172 | /** 173 | * Evaluates a flag and returns whether it is enabled. 174 | *

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

178 | * 179 | * @param flagKey the flag key to evaluate 180 | * @param context the evaluation context 181 | * @return true if the variant value is exactly Boolean true, false otherwise 182 | */ 183 | public boolean isEnabled(String flagKey, Map context) { 184 | SelectedVariant result = getVariant(flagKey, new SelectedVariant<>(false), context, true); 185 | Object value = result.getVariantValue(); 186 | 187 | return value instanceof Boolean && (Boolean) value; 188 | } 189 | 190 | // #endregion 191 | 192 | // #region Exposure Tracking 193 | 194 | /** 195 | * Common helper method for tracking exposure events. 196 | */ 197 | protected void trackExposure(String distinctId, String flagKey, String variantKey, 198 | String evaluationMode, Consumer addTimingProperties, 199 | UUID experimentId, Boolean isExperimentActive, Boolean isQaTester) { 200 | try { 201 | JSONObject properties = new JSONObject(); 202 | properties.put("Experiment name", flagKey); 203 | properties.put("Variant name", variantKey); 204 | properties.put("$experiment_type", "feature_flag"); 205 | properties.put("Flag evaluation mode", evaluationMode); 206 | 207 | // Add experiment metadata 208 | if (experimentId != null) { 209 | properties.put("$experiment_id", experimentId.toString()); 210 | } 211 | if (isExperimentActive != null) { 212 | properties.put("$is_experiment_active", isExperimentActive); 213 | } 214 | if (isQaTester != null) { 215 | properties.put("$is_qa_tester", isQaTester); 216 | } 217 | 218 | // Add timing-specific properties 219 | addTimingProperties.accept(properties); 220 | 221 | // Send via EventSender interface 222 | eventSender.sendEvent(distinctId, "$experiment_started", properties); 223 | 224 | getLogger().log(Level.FINE, "Tracked exposure event for flag: " + flagKey + ", variant: " + variantKey); 225 | } catch (Exception e) { 226 | getLogger().log(Level.WARNING, "Error tracking exposure event for flag: " + flagKey + ", variant: " + variantKey + " - " + e.getMessage(), e); 227 | } 228 | } 229 | 230 | // #endregion 231 | } 232 | -------------------------------------------------------------------------------- /src/test/java/com/mixpanel/mixpanelapi/featureflags/provider/RemoteFlagsProviderTest.java: -------------------------------------------------------------------------------- 1 | package com.mixpanel.mixpanelapi.featureflags.provider; 2 | 3 | import com.mixpanel.mixpanelapi.featureflags.EventSender; 4 | import com.mixpanel.mixpanelapi.featureflags.config.RemoteFlagsConfig; 5 | import com.mixpanel.mixpanelapi.featureflags.model.SelectedVariant; 6 | 7 | import org.json.JSONObject; 8 | import org.junit.After; 9 | import org.junit.Before; 10 | import org.junit.Test; 11 | 12 | import java.io.IOException; 13 | import java.util.*; 14 | 15 | import static org.junit.Assert.*; 16 | 17 | /** 18 | * Unit tests for RemoteFlagsProvider. 19 | * Tests cover error handling, successful variant retrieval, exposure tracking, 20 | * and boolean convenience methods for remote flag evaluation. 21 | */ 22 | public class RemoteFlagsProviderTest extends BaseFlagsProviderTest { 23 | 24 | private TestableRemoteFlagsProvider provider; 25 | private RemoteFlagsConfig config; 26 | private MockEventSender eventSender; 27 | 28 | /** 29 | * Testable subclass of RemoteFlagsProvider that allows mocking HTTP responses. 30 | */ 31 | private static class TestableRemoteFlagsProvider extends RemoteFlagsProvider { 32 | private final MockHttpProvider httpMock = new MockHttpProvider(); 33 | 34 | public TestableRemoteFlagsProvider(RemoteFlagsConfig config, String sdkVersion, EventSender eventSender) { 35 | super(config, sdkVersion, eventSender); 36 | } 37 | 38 | public void setMockResponse(String urlPattern, String response) { 39 | httpMock.setMockResponse(urlPattern, response); 40 | } 41 | 42 | public void setMockException(IOException exception) { 43 | httpMock.setMockException(exception); 44 | } 45 | 46 | @Override 47 | protected String httpGet(String urlString) throws IOException { 48 | return httpMock.mockHttpGet(urlString); 49 | } 50 | } 51 | 52 | private static class MockEventSender implements EventSender { 53 | private final List events = new ArrayList<>(); 54 | 55 | static class ExposureEvent { 56 | String distinctId; 57 | String eventName; 58 | JSONObject properties; 59 | 60 | ExposureEvent(String distinctId, String eventName, JSONObject properties) { 61 | this.distinctId = distinctId; 62 | this.eventName = eventName; 63 | this.properties = properties; 64 | } 65 | } 66 | 67 | @Override 68 | public void sendEvent(String distinctId, String eventName, JSONObject properties) { 69 | events.add(new ExposureEvent(distinctId, eventName, properties)); 70 | } 71 | 72 | public List getEvents() { 73 | return events; 74 | } 75 | 76 | public void reset() { 77 | events.clear(); 78 | } 79 | } 80 | 81 | @Before 82 | public void setUp() { 83 | config = RemoteFlagsConfig.builder() 84 | .projectToken(TEST_TOKEN) 85 | .requestTimeoutSeconds(5) 86 | .build(); 87 | eventSender = new MockEventSender(); 88 | } 89 | 90 | @Override 91 | protected Object getProvider() { 92 | return provider; 93 | } 94 | 95 | // #endregion 96 | 97 | // #region Helper Methods 98 | 99 | /** 100 | * Builds a mock remote flags API response 101 | */ 102 | private String buildRemoteFlagsResponse(String flagKey, String variantKey, Object variantValue) { 103 | try { 104 | JSONObject root = new JSONObject(); 105 | JSONObject flags = new JSONObject(); 106 | JSONObject flagData = new JSONObject(); 107 | flagData.put("variant_key", variantKey); 108 | flagData.put("variant_value", variantValue); 109 | flags.put(flagKey, flagData); 110 | root.put("flags", flags); 111 | return root.toString(); 112 | } catch (Exception e) { 113 | throw new RuntimeException("Failed to build test response", e); 114 | } 115 | } 116 | 117 | /** 118 | * Creates a test provider with custom HTTP response. 119 | * The response will be returned when the flags API URL is called. 120 | */ 121 | private TestableRemoteFlagsProvider createProviderWithResponse(String jsonResponse) { 122 | TestableRemoteFlagsProvider testProvider = new TestableRemoteFlagsProvider(config, SDK_VERSION, eventSender); 123 | 124 | if (jsonResponse != null) { 125 | // Mock the flags endpoint 126 | testProvider.setMockResponse("/flags", jsonResponse); 127 | } else { 128 | // Simulate network error by setting exception 129 | testProvider.setMockException(new IOException("Simulated network error")); 130 | } 131 | 132 | return testProvider; 133 | } 134 | 135 | // #endregion 136 | 137 | // #region Error Handling Tests 138 | 139 | @Test 140 | public void testReturnFallbackWhenAPICallFails() { 141 | // Create provider that will throw IOException 142 | provider = createProviderWithResponse(null); 143 | 144 | Map context = buildContext("user-123"); 145 | String result = provider.getVariantValue("test-flag", "fallback", context); 146 | 147 | // Should return fallback due to network error 148 | assertEquals("fallback", result); 149 | assertEquals(0, eventSender.getEvents().size()); 150 | } 151 | 152 | @Test 153 | public void testReturnFallbackWhenResponseFormatIsInvalid() { 154 | provider = new TestableRemoteFlagsProvider(config, SDK_VERSION, eventSender); 155 | 156 | // Set invalid JSON response 157 | provider.setMockResponse("/flags", "invalid json {{{"); 158 | 159 | Map context = buildContext("user-123"); 160 | String result = provider.getVariantValue("test-flag", "fallback", context); 161 | 162 | // Should return fallback due to JSON parse error 163 | assertEquals("fallback", result); 164 | assertEquals(0, eventSender.getEvents().size()); 165 | } 166 | 167 | @Test 168 | public void testReturnFallbackWhenFlagNotFoundInSuccessfulResponse() { 169 | // Set response with a different flag 170 | String response = buildRemoteFlagsResponse("other-flag", "variant-a", "value-a"); 171 | provider = createProviderWithResponse(response); 172 | 173 | Map context = buildContext("user-123"); 174 | String result = provider.getVariantValue("non-existent-flag", "fallback", context); 175 | 176 | // Should return fallback when flag not found 177 | assertEquals("fallback", result); 178 | assertEquals(0, eventSender.getEvents().size()); 179 | } 180 | 181 | // #endregion 182 | 183 | // #region Successful Variant Retrieval Tests 184 | 185 | @Test 186 | public void testReturnExpectedVariantFromAPI() { 187 | // Set up a successful response 188 | String response = buildRemoteFlagsResponse("test-flag", "variant-a", "test-value"); 189 | provider = createProviderWithResponse(response); 190 | 191 | Map context = buildContext("user-123"); 192 | String result = provider.getVariantValue("test-flag", "fallback", context); 193 | 194 | // Should return the variant value from the API 195 | assertEquals("test-value", result); 196 | } 197 | 198 | // #endregion 199 | 200 | // #region Exposure Tracking Tests 201 | 202 | @Test 203 | public void testTrackExposureWhenVariantIsSelected() { 204 | // Set up a successful response 205 | String response = buildRemoteFlagsResponse("test-flag", "variant-a", "test-value"); 206 | provider = createProviderWithResponse(response); 207 | 208 | Map context = buildContext("user-123"); 209 | provider.getVariantValue("test-flag", "fallback", context); 210 | 211 | // Should track exposure 212 | assertEquals(1, eventSender.getEvents().size()); 213 | MockEventSender.ExposureEvent event = eventSender.getEvents().get(eventSender.getEvents().size() - 1); 214 | assertEquals("user-123", event.distinctId); 215 | assertEquals("$experiment_started", event.eventName); 216 | assertEquals("test-flag", event.properties.getString("Experiment name")); 217 | assertEquals("variant-a", event.properties.getString("Variant name")); 218 | assertEquals("remote", event.properties.getString("Flag evaluation mode")); 219 | assertNotNull(event.properties.getString("Variant fetch start time")); 220 | assertNotNull(event.properties.getString("Variant fetch complete time")); 221 | } 222 | 223 | @Test 224 | public void testDoNotTrackExposureWhenReturningFallback() { 225 | // Create provider that will throw IOException 226 | provider = createProviderWithResponse(null); 227 | 228 | Map context = buildContext("user-123"); 229 | provider.getVariantValue("test-flag", "fallback", context); 230 | 231 | // Should not track exposure when returning fallback 232 | assertEquals(0, eventSender.getEvents().size()); 233 | } 234 | 235 | // #endregion 236 | 237 | // #region Boolean Convenience Method Tests 238 | 239 | @Test 240 | public void testIsEnabledReturnsTrueForBooleanTrueVariant() { 241 | // Set up response with boolean true value 242 | String response = buildRemoteFlagsResponse("test-flag", "enabled", true); 243 | provider = createProviderWithResponse(response); 244 | 245 | Map context = buildContext("user-123"); 246 | boolean result = provider.isEnabled("test-flag", context); 247 | 248 | // Should return true for boolean true variant 249 | assertTrue(result); 250 | } 251 | 252 | @Test 253 | public void testIsEnabledReturnsFalseForBooleanFalseVariant() { 254 | // Set up response with boolean false value 255 | String response = buildRemoteFlagsResponse("test-flag", "disabled", false); 256 | provider = createProviderWithResponse(response); 257 | 258 | Map context = buildContext("user-123"); 259 | boolean result = provider.isEnabled("test-flag", context); 260 | 261 | // Should return false for boolean false variant 262 | assertFalse(result); 263 | } 264 | 265 | // #endregion 266 | } 267 | 268 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2012 Mixpanel, Inc. 2 | 3 | Licensed under the Apache License, Version 2.0 (the "License"); 4 | you may not use this work except in compliance with the License. 5 | You may obtain a copy of the License below, or at: 6 | 7 | http://www.apache.org/licenses/LICENSE-2.0 8 | 9 | Unless required by applicable law or agreed to in writing, software 10 | distributed under the License is distributed on an "AS IS" BASIS, 11 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | See the License for the specific language governing permissions and 13 | limitations under the License. 14 | 15 | Apache License 16 | Version 2.0, January 2004 17 | http://www.apache.org/licenses/ 18 | 19 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 20 | 21 | 1. Definitions. 22 | 23 | "License" shall mean the terms and conditions for use, reproduction, 24 | and distribution as defined by Sections 1 through 9 of this document. 25 | 26 | "Licensor" shall mean the copyright owner or entity authorized by 27 | the copyright owner that is granting the License. 28 | 29 | "Legal Entity" shall mean the union of the acting entity and all 30 | other entities that control, are controlled by, or are under common 31 | control with that entity. For the purposes of this definition, 32 | "control" means (i) the power, direct or indirect, to cause the 33 | direction or management of such entity, whether by contract or 34 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 35 | outstanding shares, or (iii) beneficial ownership of such entity. 36 | 37 | "You" (or "Your") shall mean an individual or Legal Entity 38 | exercising permissions granted by this License. 39 | 40 | "Source" form shall mean the preferred form for making modifications, 41 | including but not limited to software source code, documentation 42 | source, and configuration files. 43 | 44 | "Object" form shall mean any form resulting from mechanical 45 | transformation or translation of a Source form, including but 46 | not limited to compiled object code, generated documentation, 47 | and conversions to other media types. 48 | 49 | "Work" shall mean the work of authorship, whether in Source or 50 | Object form, made available under the License, as indicated by a 51 | copyright notice that is included in or attached to the work 52 | (an example is provided in the Appendix below). 53 | 54 | "Derivative Works" shall mean any work, whether in Source or Object 55 | form, that is based on (or derived from) the Work and for which the 56 | editorial revisions, annotations, elaborations, or other modifications 57 | represent, as a whole, an original work of authorship. For the purposes 58 | of this License, Derivative Works shall not include works that remain 59 | separable from, or merely link (or bind by name) to the interfaces of, 60 | the Work and Derivative Works thereof. 61 | 62 | "Contribution" shall mean any work of authorship, including 63 | the original version of the Work and any modifications or additions 64 | to that Work or Derivative Works thereof, that is intentionally 65 | submitted to Licensor for inclusion in the Work by the copyright owner 66 | or by an individual or Legal Entity authorized to submit on behalf of 67 | the copyright owner. For the purposes of this definition, "submitted" 68 | means any form of electronic, verbal, or written communication sent 69 | to the Licensor or its representatives, including but not limited to 70 | communication on electronic mailing lists, source code control systems, 71 | and issue tracking systems that are managed by, or on behalf of, the 72 | Licensor for the purpose of discussing and improving the Work, but 73 | excluding communication that is conspicuously marked or otherwise 74 | designated in writing by the copyright owner as "Not a Contribution." 75 | 76 | "Contributor" shall mean Licensor and any individual or Legal Entity 77 | on behalf of whom a Contribution has been received by Licensor and 78 | subsequently incorporated within the Work. 79 | 80 | 2. Grant of Copyright License. Subject to the terms and conditions of 81 | this License, each Contributor hereby grants to You a perpetual, 82 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 83 | copyright license to reproduce, prepare Derivative Works of, 84 | publicly display, publicly perform, sublicense, and distribute the 85 | Work and such Derivative Works in Source or Object form. 86 | 87 | 3. Grant of Patent License. Subject to the terms and conditions of 88 | this License, each Contributor hereby grants to You a perpetual, 89 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 90 | (except as stated in this section) patent license to make, have made, 91 | use, offer to sell, sell, import, and otherwise transfer the Work, 92 | where such license applies only to those patent claims licensable 93 | by such Contributor that are necessarily infringed by their 94 | Contribution(s) alone or by combination of their Contribution(s) 95 | with the Work to which such Contribution(s) was submitted. If You 96 | institute patent litigation against any entity (including a 97 | cross-claim or counterclaim in a lawsuit) alleging that the Work 98 | or a Contribution incorporated within the Work constitutes direct 99 | or contributory patent infringement, then any patent licenses 100 | granted to You under this License for that Work shall terminate 101 | as of the date such litigation is filed. 102 | 103 | 4. Redistribution. You may reproduce and distribute copies of the 104 | Work or Derivative Works thereof in any medium, with or without 105 | modifications, and in Source or Object form, provided that You 106 | meet the following conditions: 107 | 108 | (a) You must give any other recipients of the Work or 109 | Derivative Works a copy of this License; and 110 | 111 | (b) You must cause any modified files to carry prominent notices 112 | stating that You changed the files; and 113 | 114 | (c) You must retain, in the Source form of any Derivative Works 115 | that You distribute, all copyright, patent, trademark, and 116 | attribution notices from the Source form of the Work, 117 | excluding those notices that do not pertain to any part of 118 | the Derivative Works; and 119 | 120 | (d) If the Work includes a "NOTICE" text file as part of its 121 | distribution, then any Derivative Works that You distribute must 122 | include a readable copy of the attribution notices contained 123 | within such NOTICE file, excluding those notices that do not 124 | pertain to any part of the Derivative Works, in at least one 125 | of the following places: within a NOTICE text file distributed 126 | as part of the Derivative Works; within the Source form or 127 | documentation, if provided along with the Derivative Works; or, 128 | within a display generated by the Derivative Works, if and 129 | wherever such third-party notices normally appear. The contents 130 | of the NOTICE file are for informational purposes only and 131 | do not modify the License. You may add Your own attribution 132 | notices within Derivative Works that You distribute, alongside 133 | or as an addendum to the NOTICE text from the Work, provided 134 | that such additional attribution notices cannot be construed 135 | as modifying the License. 136 | 137 | You may add Your own copyright statement to Your modifications and 138 | may provide additional or different license terms and conditions 139 | for use, reproduction, or distribution of Your modifications, or 140 | for any such Derivative Works as a whole, provided Your use, 141 | reproduction, and distribution of the Work otherwise complies with 142 | the conditions stated in this License. 143 | 144 | 5. Submission of Contributions. Unless You explicitly state otherwise, 145 | any Contribution intentionally submitted for inclusion in the Work 146 | by You to the Licensor shall be under the terms and conditions of 147 | this License, without any additional terms or conditions. 148 | Notwithstanding the above, nothing herein shall supersede or modify 149 | the terms of any separate license agreement you may have executed 150 | with Licensor regarding such Contributions. 151 | 152 | 6. Trademarks. This License does not grant permission to use the trade 153 | names, trademarks, service marks, or product names of the Licensor, 154 | except as required for reasonable and customary use in describing the 155 | origin of the Work and reproducing the content of the NOTICE file. 156 | 157 | 7. Disclaimer of Warranty. Unless required by applicable law or 158 | agreed to in writing, Licensor provides the Work (and each 159 | Contributor provides its Contributions) on an "AS IS" BASIS, 160 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 161 | implied, including, without limitation, any warranties or conditions 162 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 163 | PARTICULAR PURPOSE. You are solely responsible for determining the 164 | appropriateness of using or redistributing the Work and assume any 165 | risks associated with Your exercise of permissions under this License. 166 | 167 | 8. Limitation of Liability. In no event and under no legal theory, 168 | whether in tort (including negligence), contract, or otherwise, 169 | unless required by applicable law (such as deliberate and grossly 170 | negligent acts) or agreed to in writing, shall any Contributor be 171 | liable to You for damages, including any direct, indirect, special, 172 | incidental, or consequential damages of any character arising as a 173 | result of this License or out of the use or inability to use the 174 | Work (including but not limited to damages for loss of goodwill, 175 | work stoppage, computer failure or malfunction, or any and all 176 | other commercial damages or losses), even if such Contributor 177 | has been advised of the possibility of such damages. 178 | 179 | 9. Accepting Warranty or Additional Liability. While redistributing 180 | the Work or Derivative Works thereof, You may choose to offer, 181 | and charge a fee for, acceptance of support, warranty, indemnity, 182 | or other liability obligations and/or rights consistent with this 183 | License. However, in accepting such obligations, You may act only 184 | on Your own behalf and on Your sole responsibility, not on behalf 185 | of any other Contributor, and only if You agree to indemnify, 186 | defend, and hold each Contributor harmless for any liability 187 | incurred by, or claims asserted against, such Contributor by reason 188 | of your accepting any such warranty or additional liability. 189 | 190 | END OF TERMS AND CONDITIONS 191 | -------------------------------------------------------------------------------- /src/main/java/com/mixpanel/mixpanelapi/featureflags/provider/LocalFlagsProvider.java: -------------------------------------------------------------------------------- 1 | package com.mixpanel.mixpanelapi.featureflags.provider; 2 | 3 | import com.mixpanel.mixpanelapi.featureflags.EventSender; 4 | import com.mixpanel.mixpanelapi.featureflags.config.LocalFlagsConfig; 5 | import com.mixpanel.mixpanelapi.featureflags.model.*; 6 | import com.mixpanel.mixpanelapi.featureflags.util.HashUtils; 7 | 8 | import org.json.JSONArray; 9 | import org.json.JSONObject; 10 | 11 | import java.io.UnsupportedEncodingException; 12 | import java.net.URLEncoder; 13 | import java.util.*; 14 | import java.util.concurrent.Executors; 15 | import java.util.concurrent.ScheduledExecutorService; 16 | import java.util.concurrent.TimeUnit; 17 | import java.util.concurrent.atomic.AtomicBoolean; 18 | import java.util.concurrent.atomic.AtomicReference; 19 | import java.util.logging.Level; 20 | import java.util.logging.Logger; 21 | 22 | /** 23 | * Local feature flags evaluation provider. 24 | *

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

29 | *

30 | * This class is thread-safe and implements AutoCloseable for resource cleanup. 31 | *

32 | */ 33 | public class LocalFlagsProvider extends BaseFlagsProvider implements AutoCloseable { 34 | private static final Logger logger = Logger.getLogger(LocalFlagsProvider.class.getName()); 35 | 36 | private final AtomicReference> flagDefinitions; 37 | private final AtomicBoolean ready; 38 | private final AtomicBoolean closed; 39 | 40 | private ScheduledExecutorService pollingExecutor; 41 | 42 | /** 43 | * Creates a new LocalFlagsProvider. 44 | * 45 | * @param config the local flags configuration 46 | * @param sdkVersion the SDK version string 47 | * @param eventSender the EventSender implementation for tracking exposure events 48 | */ 49 | public LocalFlagsProvider(LocalFlagsConfig config, String sdkVersion, EventSender eventSender) { 50 | super(config.getProjectToken(), config, sdkVersion, eventSender); 51 | 52 | this.flagDefinitions = new AtomicReference<>(new HashMap<>()); 53 | this.ready = new AtomicBoolean(false); 54 | this.closed = new AtomicBoolean(false); 55 | } 56 | 57 | // #region Polling 58 | 59 | /** 60 | * Starts polling for flag definitions. 61 | *

62 | * Performs an initial fetch, then starts background polling if enabled in configuration. 63 | *

64 | */ 65 | public void startPollingForDefinitions() { 66 | if (closed.get()) { 67 | logger.log(Level.WARNING, "Cannot start polling: provider is closed"); 68 | return; 69 | } 70 | 71 | // Initial fetch 72 | fetchDefinitions(); 73 | 74 | // Start background polling if enabled 75 | if (config.isEnablePolling()) { 76 | pollingExecutor = Executors.newSingleThreadScheduledExecutor(r -> { 77 | Thread t = new Thread(r, "mixpanel-flags-poller"); 78 | t.setDaemon(true); 79 | return t; 80 | }); 81 | 82 | pollingExecutor.scheduleAtFixedRate( 83 | this::fetchDefinitions, 84 | config.getPollingIntervalSeconds(), 85 | config.getPollingIntervalSeconds(), 86 | TimeUnit.SECONDS 87 | ); 88 | 89 | logger.log(Level.INFO, "Started polling for flag definitions every " + config.getPollingIntervalSeconds() + " seconds"); 90 | } 91 | } 92 | 93 | /** 94 | * Stops polling for flag definitions and releases resources. 95 | */ 96 | public void stopPollingForDefinitions() { 97 | if (pollingExecutor != null) { 98 | pollingExecutor.shutdown(); 99 | try { 100 | if (!pollingExecutor.awaitTermination(5, TimeUnit.SECONDS)) { 101 | pollingExecutor.shutdownNow(); 102 | } 103 | } catch (InterruptedException e) { 104 | pollingExecutor.shutdownNow(); 105 | Thread.currentThread().interrupt(); 106 | } 107 | pollingExecutor = null; 108 | } 109 | } 110 | 111 | // #endregion 112 | 113 | // #region Fetch Definitions 114 | 115 | /** 116 | * @return true if flag definitions have been successfully fetched at least once 117 | */ 118 | public boolean areFlagsReady() { 119 | return ready.get(); 120 | } 121 | 122 | /** 123 | * Fetches flag definitions from the Mixpanel API. 124 | */ 125 | private void fetchDefinitions() { 126 | try { 127 | String endpoint = buildDefinitionsUrl(); 128 | String response = httpGet(endpoint); 129 | 130 | Map newDefinitions = parseDefinitions(response); 131 | flagDefinitions.set(newDefinitions); 132 | ready.set(true); 133 | 134 | logger.log(Level.FINE, "Successfully fetched " + newDefinitions.size() + " flag definitions"); 135 | } catch (Exception e) { 136 | logger.log(Level.WARNING, "Failed to fetch flag definitions", e); 137 | } 138 | } 139 | 140 | /** 141 | * Builds the URL for fetching flag definitions. 142 | */ 143 | private String buildDefinitionsUrl() throws UnsupportedEncodingException { 144 | StringBuilder url = new StringBuilder(); 145 | url.append("https://").append(config.getApiHost()).append("/flags/definitions"); 146 | url.append("?mp_lib=").append(URLEncoder.encode("java", "UTF-8")); 147 | url.append("&lib_version=").append(URLEncoder.encode(sdkVersion, "UTF-8")); 148 | url.append("&token=").append(URLEncoder.encode(projectToken, "UTF-8")); 149 | return url.toString(); 150 | } 151 | 152 | // #endregion 153 | 154 | // #region JSON Parsing 155 | 156 | /** 157 | * Parses flag definitions from JSON response. 158 | */ 159 | private Map parseDefinitions(String jsonResponse) { 160 | Map definitions = new HashMap<>(); 161 | 162 | try { 163 | JSONObject root = new JSONObject(jsonResponse); 164 | JSONArray flags = root.optJSONArray("flags"); 165 | 166 | if (flags == null) { 167 | return definitions; 168 | } 169 | 170 | for (int i = 0; i < flags.length(); i++) { 171 | JSONObject flagJson = flags.getJSONObject(i); 172 | ExperimentationFlag flag = parseFlag(flagJson); 173 | definitions.put(flag.getKey(), flag); 174 | } 175 | } catch (Exception e) { 176 | logger.log(Level.SEVERE, "Failed to parse flag definitions", e); 177 | } 178 | 179 | return definitions; 180 | } 181 | 182 | /** 183 | * Parses a single flag from JSON. 184 | */ 185 | private ExperimentationFlag parseFlag(JSONObject json) { 186 | String id = json.optString("id", ""); 187 | String name = json.optString("name", ""); 188 | String key = json.optString("key", ""); 189 | String status = json.optString("status", ""); 190 | int projectId = json.optInt("project_id", 0); 191 | String context = json.optString("context", "distinct_id"); 192 | 193 | // Parse experiment metadata 194 | UUID experimentId = null; 195 | String experimentIdString = json.optString("experiment_id", null); 196 | if (experimentIdString != null && !experimentIdString.isEmpty()) { 197 | try { 198 | experimentId = UUID.fromString(experimentIdString); 199 | } catch (IllegalArgumentException e) { 200 | logger.log(Level.WARNING, "Invalid UUID for experiment_id: " + experimentIdString); 201 | } 202 | } 203 | 204 | Boolean isExperimentActive = null; 205 | if (json.has("is_experiment_active")) { 206 | isExperimentActive = json.optBoolean("is_experiment_active", false); 207 | } 208 | 209 | // Parse hash_salt (may be null for legacy flags) 210 | String hashSalt = json.optString("hash_salt", null); 211 | 212 | RuleSet ruleset = parseRuleSet(json.optJSONObject("ruleset")); 213 | 214 | return new ExperimentationFlag(id, name, key, status, projectId, ruleset, context, experimentId, isExperimentActive, hashSalt); 215 | } 216 | 217 | /** 218 | * Parses a ruleset from JSON. 219 | */ 220 | private RuleSet parseRuleSet(JSONObject json) { 221 | if (json == null) { 222 | return new RuleSet(Collections.emptyList(), Collections.emptyList()); 223 | } 224 | 225 | // Parse variants 226 | List variants = new ArrayList<>(); 227 | JSONArray variantsJson = json.optJSONArray("variants"); 228 | if (variantsJson != null) { 229 | for (int i = 0; i < variantsJson.length(); i++) { 230 | variants.add(parseVariant(variantsJson.getJSONObject(i))); 231 | } 232 | } 233 | 234 | // Sort variants by key for consistent ordering 235 | variants.sort(Comparator.comparing(Variant::getKey)); 236 | 237 | // Parse rollouts 238 | List rollouts = new ArrayList<>(); 239 | JSONArray rolloutsJson = json.optJSONArray("rollout"); 240 | if (rolloutsJson != null) { 241 | for (int i = 0; i < rolloutsJson.length(); i++) { 242 | rollouts.add(parseRollout(rolloutsJson.getJSONObject(i))); 243 | } 244 | } 245 | 246 | // Parse test user overrides 247 | Map testOverrides = null; 248 | JSONObject testJson = json.optJSONObject("test"); 249 | if (testJson != null) { 250 | JSONObject usersJson = testJson.optJSONObject("users"); 251 | if (usersJson != null) { 252 | testOverrides = new HashMap<>(); 253 | for (String distinctId : usersJson.keySet()) { 254 | testOverrides.put(distinctId, usersJson.getString(distinctId)); 255 | } 256 | } 257 | } 258 | 259 | return new RuleSet(variants, rollouts, testOverrides); 260 | } 261 | 262 | /** 263 | * Parses a variant from JSON. 264 | */ 265 | private Variant parseVariant(JSONObject json) { 266 | String key = json.optString("key", ""); 267 | Object value = json.opt("value"); 268 | boolean isControl = json.optBoolean("is_control", false); 269 | float split = (float) json.optDouble("split", 0.0); 270 | 271 | return new Variant(key, value, isControl, split); 272 | } 273 | 274 | /** 275 | * Parses a rollout from JSON. 276 | */ 277 | private Rollout parseRollout(JSONObject json) { 278 | float rolloutPercentage = (float) json.optDouble("rollout_percentage", 0.0); 279 | VariantOverride variantOverride = null; 280 | 281 | if (json.has("variant_override") && !json.isNull("variant_override")) { 282 | JSONObject variantObj = json.optJSONObject("variant_override"); 283 | if (variantObj != null) { 284 | String key = variantObj.optString("key", ""); 285 | if (!key.isEmpty()) { 286 | variantOverride = new VariantOverride(key); 287 | } 288 | } 289 | } 290 | 291 | Map runtimeEval = null; 292 | JSONObject runtimeEvalJson = json.optJSONObject("runtime_evaluation_definition"); 293 | if (runtimeEvalJson != null) { 294 | runtimeEval = new HashMap<>(); 295 | for (String key : runtimeEvalJson.keySet()) { 296 | runtimeEval.put(key, runtimeEvalJson.get(key)); 297 | } 298 | } 299 | 300 | Map variantSplits = null; 301 | JSONObject variantSplitsJson = json.optJSONObject("variant_splits"); 302 | if (variantSplitsJson != null) { 303 | variantSplits = new HashMap<>(); 304 | for (String key : variantSplitsJson.keySet()) { 305 | variantSplits.put(key, (float) variantSplitsJson.optDouble(key, 0.0)); 306 | } 307 | } 308 | 309 | return new Rollout(rolloutPercentage, runtimeEval, variantOverride, variantSplits); 310 | } 311 | 312 | // #endregion 313 | 314 | // #region Evaluation 315 | 316 | /** 317 | * Evaluates a flag and returns the selected variant. 318 | * 319 | * @param flagKey the flag key to evaluate 320 | * @param fallback the fallback variant to return if evaluation fails 321 | * @param context the evaluation context (must contain the property specified in flag's context field) 322 | * @param reportExposure whether to track an exposure event for this flag evaluation 323 | * @param the type of the variant value 324 | * @return the selected variant or fallback 325 | */ 326 | public SelectedVariant getVariant(String flagKey, SelectedVariant fallback, Map context, boolean reportExposure) { 327 | long startTime = System.currentTimeMillis(); 328 | 329 | try { 330 | // Get flag definition 331 | Map definitions = flagDefinitions.get(); 332 | ExperimentationFlag flag = definitions.get(flagKey); 333 | 334 | if (flag == null) { 335 | logger.log(Level.WARNING, "Flag not found: " + flagKey); 336 | return fallback; 337 | } 338 | 339 | // Extract context value 340 | String contextProperty = flag.getContext(); 341 | Object contextValueObj = context.get(contextProperty); 342 | if (contextValueObj == null) { 343 | logger.log(Level.WARNING, "Variant assignment key property '" + contextProperty + "' not found for flag: " + flagKey); 344 | return fallback; 345 | } 346 | String contextValue = contextValueObj.toString(); 347 | 348 | // Check test user overrides 349 | RuleSet ruleset = flag.getRuleset(); 350 | Boolean isQaTester = null; 351 | if (ruleset.hasTestUserOverrides()) { 352 | String distinctId = context.get("distinct_id") != null ? context.get("distinct_id").toString() : null; 353 | if (distinctId != null) { 354 | String testVariantKey = ruleset.getTestUserOverrides().get(distinctId); 355 | if (testVariantKey != null) { 356 | Variant variant = findVariantByKey(ruleset.getVariants(), testVariantKey); 357 | if (variant != null) { 358 | isQaTester = true; 359 | @SuppressWarnings("unchecked") 360 | SelectedVariant result = new SelectedVariant<>( 361 | variant.getKey(), 362 | (T) variant.getValue(), 363 | flag.getExperimentId(), 364 | flag.getIsExperimentActive(), 365 | isQaTester 366 | ); 367 | if (reportExposure) { 368 | trackLocalExposure(context, flagKey, variant.getKey(), System.currentTimeMillis() - startTime, flag.getExperimentId(), flag.getIsExperimentActive(), isQaTester); 369 | } 370 | return result; 371 | } 372 | } 373 | } 374 | } 375 | 376 | // Evaluate rollouts 377 | List rollouts = ruleset.getRollouts(); 378 | for (int rolloutIndex = 0; rolloutIndex < rollouts.size(); rolloutIndex++) { 379 | Rollout rollout = rollouts.get(rolloutIndex); 380 | 381 | // Calculate rollout hash 382 | float rolloutHash = calculateRolloutHash(contextValue, flagKey, flag.getHashSalt(), rolloutIndex); 383 | 384 | if (rolloutHash >= rollout.getRolloutPercentage()) { 385 | continue; 386 | } 387 | 388 | // Check runtime evaluation, continue if this rollout has runtime conditions and it doesn't match 389 | if (rollout.hasRuntimeEvaluation()) { 390 | if (!matchesRuntimeConditions(rollout, context)) { 391 | continue; 392 | } 393 | } 394 | 395 | // This rollout is selected - determine variant 396 | Variant selectedVariant = null; 397 | 398 | if (rollout.hasVariantOverride()) { 399 | selectedVariant = findVariantByKey(ruleset.getVariants(), rollout.getVariantOverride().getKey()); 400 | } else { 401 | // Calculate variant hash 402 | float variantHash = calculateVariantHash(contextValue, flagKey, flag.getHashSalt()); 403 | selectedVariant = selectVariantBySplit(ruleset.getVariants(), variantHash, rollout); 404 | } 405 | 406 | if (selectedVariant != null) { 407 | if (isQaTester == null) { 408 | isQaTester = false; 409 | } 410 | @SuppressWarnings("unchecked") 411 | SelectedVariant result = new SelectedVariant<>( 412 | selectedVariant.getKey(), 413 | (T) selectedVariant.getValue(), 414 | flag.getExperimentId(), 415 | flag.getIsExperimentActive(), 416 | isQaTester 417 | ); 418 | if (reportExposure) { 419 | trackLocalExposure(context, flagKey, selectedVariant.getKey(), System.currentTimeMillis() - startTime, flag.getExperimentId(), flag.getIsExperimentActive(), isQaTester); 420 | } 421 | return result; 422 | } 423 | 424 | break; // Rollout selected but no variant found 425 | } 426 | 427 | // No rollout matched 428 | return fallback; 429 | 430 | } catch (Exception e) { 431 | logger.log(Level.WARNING, "Error evaluating flag: " + flagKey, e); 432 | return fallback; 433 | } 434 | } 435 | 436 | /** 437 | * Evaluates runtime conditions for a rollout. 438 | * 439 | * @return true if all runtime conditions match, false otherwise (or if custom_properties is missing) 440 | */ 441 | private boolean matchesRuntimeConditions(Rollout rollout, Map context) { 442 | Map customProperties = getCustomProperties(context); 443 | if (customProperties == null) { 444 | return false; 445 | } 446 | 447 | Map runtimeEval = rollout.getRuntimeEvaluationDefinition(); 448 | for (Map.Entry entry : runtimeEval.entrySet()) { 449 | String key = entry.getKey(); 450 | Object expectedValue = entry.getValue(); 451 | Object actualValue = customProperties.get(key); 452 | 453 | // Case-insensitive comparison for strings 454 | if (!valuesEqual(expectedValue, actualValue)) { 455 | return false; 456 | } 457 | } 458 | 459 | return true; 460 | } 461 | 462 | /** 463 | * Extracts custom_properties from context. 464 | */ 465 | @SuppressWarnings("unchecked") 466 | private Map getCustomProperties(Map context) { 467 | Object customPropsObj = context.get("custom_properties"); 468 | if (customPropsObj instanceof Map) { 469 | return (Map) customPropsObj; 470 | } 471 | return null; 472 | } 473 | 474 | /** 475 | * Compares two values with case-insensitive string comparison. 476 | */ 477 | private boolean valuesEqual(Object expected, Object actual) { 478 | if (expected == null || actual == null) { 479 | return expected == actual; 480 | } 481 | 482 | // Case-insensitive comparison for strings 483 | if (expected instanceof String && actual instanceof String) { 484 | return ((String) expected).equalsIgnoreCase((String) actual); 485 | } 486 | 487 | return expected.equals(actual); 488 | } 489 | 490 | /** 491 | * Finds a variant by key. 492 | */ 493 | private Variant findVariantByKey(List variants, String key) { 494 | for (Variant variant : variants) { 495 | if (variant.getKey().equals(key)) { 496 | return variant; 497 | } 498 | } 499 | return null; 500 | } 501 | 502 | /** 503 | * Applies variant split overrides from a rollout to the flag's variants. 504 | *

505 | * Creates a new list of variants with updated split values where overrides are specified. 506 | * Variants not in the overrides map retain their original split values. 507 | *

508 | * 509 | * @param variants the original list of variants from the flag definition 510 | * @param variantSplits the map of variant key to split percentage overrides 511 | * @return a new list with variant split overrides applied 512 | */ 513 | private List applyVariantSplitOverrides(List variants, Map variantSplits) { 514 | List result = new ArrayList<>(variants.size()); 515 | 516 | for (Variant variant : variants) { 517 | if (variantSplits.containsKey(variant.getKey())) { 518 | // Create new variant with overridden split value 519 | float overriddenSplit = variantSplits.get(variant.getKey()); 520 | Variant updatedVariant = new Variant( 521 | variant.getKey(), 522 | variant.getValue(), 523 | variant.isControl(), 524 | overriddenSplit 525 | ); 526 | result.add(updatedVariant); 527 | } else { 528 | // Keep original variant with its original split 529 | result.add(variant); 530 | } 531 | } 532 | 533 | return result; 534 | } 535 | 536 | /** 537 | * Selects a variant based on hash and split percentages. 538 | *

539 | * If the rollout has variant_splits configured, those override the flag-level splits. 540 | * Otherwise, uses the default split values from the variants. 541 | *

542 | * 543 | * @param variants the list of variants to select from 544 | * @param hash the normalized hash value (0.0 to 1.0) for selection 545 | * @param rollout the rollout being evaluated (may be null or have no variant_splits) 546 | * @return the selected variant, or null if variants list is empty 547 | */ 548 | private Variant selectVariantBySplit(List variants, float hash, Rollout rollout) { 549 | // Apply variant split overrides if the rollout specifies them 550 | List variantsToUse = variants; 551 | if (rollout != null && rollout.hasVariantSplits()) { 552 | variantsToUse = applyVariantSplitOverrides(variants, rollout.getVariantSplits()); 553 | } 554 | 555 | // Select variant using cumulative split percentages 556 | float cumulative = 0.0f; 557 | for (Variant variant : variantsToUse) { 558 | cumulative += variant.getSplit(); 559 | if (hash < cumulative) { 560 | return variant; 561 | } 562 | } 563 | 564 | // If no variant selected (due to rounding), return last variant 565 | return variantsToUse.isEmpty() ? null : variantsToUse.get(variantsToUse.size() - 1); 566 | } 567 | 568 | /** 569 | * Calculates the rollout hash for a given context and rollout index. 570 | *

571 | * This method can be overridden in tests to verify hash parameters. 572 | *

573 | * 574 | * @param contextValue the context value (e.g., user ID) 575 | * @param flagKey the flag key 576 | * @param hashSalt the hash salt (null or empty for legacy behavior) 577 | * @param rolloutIndex the index of the rollout being evaluated 578 | * @return the normalized hash value (0.0 to 1.0) 579 | */ 580 | protected float calculateRolloutHash(String contextValue, String flagKey, 581 | String hashSalt, int rolloutIndex) { 582 | if (hashSalt != null && !hashSalt.isEmpty()) { 583 | return HashUtils.normalizedHash(contextValue + flagKey, hashSalt + rolloutIndex); 584 | } else { 585 | return HashUtils.normalizedHash(contextValue + flagKey, "rollout"); 586 | } 587 | } 588 | 589 | /** 590 | * Calculates the variant hash for a given context. 591 | *

592 | * This method can be overridden in tests to verify hash parameters. 593 | *

594 | * 595 | * @param contextValue the context value (e.g., user ID) 596 | * @param flagKey the flag key 597 | * @param hashSalt the hash salt (null or empty for legacy behavior) 598 | * @return the normalized hash value (0.0 to 1.0) 599 | */ 600 | protected float calculateVariantHash(String contextValue, String flagKey, String hashSalt) { 601 | if (hashSalt != null && !hashSalt.isEmpty()) { 602 | return HashUtils.normalizedHash(contextValue + flagKey, hashSalt + "variant"); 603 | } else { 604 | return HashUtils.normalizedHash(contextValue + flagKey, "variant"); 605 | } 606 | } 607 | 608 | /** 609 | * Evaluates all flags and returns their selected variants. 610 | *

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

614 | * 615 | * @param context the evaluation context 616 | * @param reportExposure whether to track exposure events for flag evaluations 617 | * @return list of selected variants for all flags where a variant was selected 618 | */ 619 | public List> getAllVariants(Map context, boolean reportExposure) { 620 | List> results = new ArrayList<>(); 621 | Map definitions = flagDefinitions.get(); 622 | 623 | for (ExperimentationFlag flag : definitions.values()) { 624 | SelectedVariant fallback = new SelectedVariant<>(null); 625 | SelectedVariant result = getVariant(flag.getKey(), fallback, context, reportExposure); 626 | 627 | // Only include successfully selected variants (not fallbacks) 628 | if (result.isSuccess()) { 629 | results.add(result); 630 | } 631 | } 632 | 633 | return results; 634 | } 635 | 636 | /** 637 | * Tracks an exposure event for local evaluation. 638 | */ 639 | private void trackLocalExposure(Map context, String flagKey, String variantKey, long latencyMs, UUID experimentId, Boolean isExperimentActive, Boolean isQaTester) { 640 | if (eventSender == null) { 641 | return; 642 | } 643 | 644 | Object distinctIdObj = context.get("distinct_id"); 645 | if (distinctIdObj == null) { 646 | return; 647 | } 648 | 649 | trackExposure(distinctIdObj.toString(), flagKey, variantKey, "local", properties -> { 650 | properties.put("Variant fetch latency (ms)", latencyMs); 651 | }, experimentId, isExperimentActive, isQaTester); 652 | } 653 | 654 | // #endregion 655 | 656 | @Override 657 | protected Logger getLogger() { 658 | return logger; 659 | } 660 | 661 | @Override 662 | public void close() { 663 | if (closed.compareAndSet(false, true)) { 664 | stopPollingForDefinitions(); 665 | } 666 | } 667 | } 668 | --------------------------------------------------------------------------------