diff --git a/posthog-samples/posthog-java-sample/src/main/java/com/posthog/java/sample/PostHogJavaExample.java b/posthog-samples/posthog-java-sample/src/main/java/com/posthog/java/sample/PostHogJavaExample.java index 8243e6f2..d64b3207 100644 --- a/posthog-samples/posthog-java-sample/src/main/java/com/posthog/java/sample/PostHogJavaExample.java +++ b/posthog-samples/posthog-java-sample/src/main/java/com/posthog/java/sample/PostHogJavaExample.java @@ -1,26 +1,31 @@ package com.posthog.java.sample; +import com.posthog.server.PostHog; import com.posthog.server.PostHogCaptureOptions; import com.posthog.server.PostHogConfig; -import com.posthog.server.PostHog; +import com.posthog.server.PostHogFeatureFlagOptions; import com.posthog.server.PostHogInterface; - +import com.posthog.server.PostHogSendFeatureFlagOptions; import java.util.HashMap; -import java.util.Map; /** * Simple Java 1.8 example demonstrating PostHog usage */ public class PostHogJavaExample { + public static void main(String[] args) { PostHogConfig config = PostHogConfig - .builder("phc_wz4KZkikEluCCdfY2B2h7MXYygNGdTqFgjbU7I1ZdVR") + .builder("phc_wxtaSxv9yC8UYxUAxNojluoAf41L8p6SJZmiTMtS8jA") + .personalApiKey("phs_DuaFTmUtxQNj5R2W03emB1jMLIX5XwDvrt3DKfi5uYNcxzd") + .host("http://localhost:8010") + .localEvaluation(true) + .debug(true) .build(); - PostHogInterface postHog = PostHog.with(config); + PostHogInterface posthog = PostHog.with(config); - postHog.group("distinct-id", "company", "some-company-id"); - postHog.capture( + posthog.group("distinct-id", "company", "some-company-id"); + posthog.capture( "distinct-id", "new-purchase", PostHogCaptureOptions @@ -31,29 +36,54 @@ public static void main(String[] args) { HashMap userProperties = new HashMap<>(); userProperties.put("email", "user@example.com"); - postHog.identify("distinct-id", userProperties); + posthog.identify("distinct-id", userProperties); // AVOID - Anonymous inner class holds reference to outer class. // The following won't serialize properly. - // postHog.identify("user-123", new HashMap() {{ - // put("key", "value"); + // posthog.identify("user-123", new HashMap() {{ + // put("key", "value"); // }}); - postHog.alias("distinct-id", "alias-id"); + posthog.alias("distinct-id", "alias-id"); - - if (postHog.isFeatureEnabled("distinct-id", "beta-feature", false)) { + // Feature flag examples with local evaluation + if (posthog.isFeatureEnabled("distinct-id", "beta-feature", false)) { System.out.println("The feature is enabled."); } - Object flagValue = postHog.getFeatureFlag("distinct-id", "multi-variate-flag", "default"); + Object flagValue = posthog.getFeatureFlag("distinct-id", "multi-variate-flag", "default"); String flagVariate = flagValue instanceof String ? (String) flagValue : "default"; - Object flagPayload = postHog.getFeatureFlagPayload("distinct-id", "multi-variate-flag"); + Object flagPayload = posthog.getFeatureFlagPayload("distinct-id", "multi-variate-flag"); System.out.println("The flag variant was: " + flagVariate); System.out.println("Received flag payload: " + flagPayload); - postHog.flush(); - postHog.close(); + Boolean hasFilePreview = posthog.isFeatureEnabled( + "distinct-id", + "file-previews", + PostHogFeatureFlagOptions + .builder() + .defaultValue(false) + .personProperty("email", "example@example.com") + .build()); + + System.out.println("File previews enabled: " + hasFilePreview); + + posthog.capture( + "distinct-id", + "file_uploaded", + PostHogCaptureOptions + .builder() + .property("file_name", "document.pdf") + .property("file_size", 123456) + .sendFeatureFlags(PostHogSendFeatureFlagOptions + .builder() + .personProperty("email", "example@example.com") + .onlyEvaluateLocally(true) + .build()) + .build()); + + posthog.flush(); + posthog.close(); } } \ No newline at end of file diff --git a/posthog-server/CHANGELOG.md b/posthog-server/CHANGELOG.md index a3260cef..8aa4fb4c 100644 --- a/posthog-server/CHANGELOG.md +++ b/posthog-server/CHANGELOG.md @@ -1,6 +1,7 @@ ## Next - fix!: Restructured `groupProperties` and `userProperties` types to match the API and other SDKs +- feat: Add local evaluation for feature flags ([#299](https://github.com/PostHog/posthog-android/issues/299)) ## 1.1.0 - 2025-10-03 diff --git a/posthog-server/USAGE.md b/posthog-server/USAGE.md index a68dacfa..312d2940 100644 --- a/posthog-server/USAGE.md +++ b/posthog-server/USAGE.md @@ -92,6 +92,9 @@ PostHogConfig config = PostHogConfig.builder("phc_your_api_key_here") - `flushIntervalSeconds`: Interval between automatic flushes (default: `30`) - `featureFlagCacheSize`: The maximum number of feature flags results to cache (default: `1000`) - `featureFlagCacheMaxAgeMs`: The maximum age of a feature flag cache record in memory in milliseconds (default: `300000` or five minutes) +- `localEvaluation`: Enable local evaluation of feature flags (default: `false`) +- `personalApiKey`: Personal API key required for local evaluation (default: `null`) +- `pollIntervalSeconds`: Interval for polling flag definitions for local evaluation (default: `30`) ## Capturing Events @@ -202,6 +205,58 @@ postHog.identify("user123", userProperties, userPropertiesSetOnce); ## Feature Flags +### Local Evaluation (Experimental) + +Local evaluation allows the SDK to evaluate feature flags locally without making API calls for each flag check. This reduces latency and API costs. + +**How it works:** + +1. The SDK periodically polls for flag definitions from PostHog (every 30 seconds by default) +2. Flags are evaluated locally using cached definitions and properties provided by the caller +3. If evaluation is inconclusive (missing properties, etc.), the SDK falls back to the API + +**Requirements:** + +- A feature flags secure API key _or_ a personal API key + - A feature flags secure API key can be obtained via PostHog → Settings → Project → Feature Flags → Feature Flags Secure API key + - A personal API key can be generated via PostHog → Settings → Account → Personal API Keys +- The `localEvaluation` config option set to `true` + +#### Kotlin + +```kotlin +val config = PostHogConfig( + apiKey = "phc_your_api_key_here", + host = "https://your-posthog-instance.com", + localEvaluation = true, + personalApiKey = "phx_your_personal_api_key_here", + pollIntervalSeconds = 30 // Optional: customize polling interval +) +``` + +#### Java + +```java +PostHogConfig config = PostHogConfig.builder("phc_your_api_key_here") + .host("https://your-posthog-instance.com") + .localEvaluation(true) + .personalApiKey("phx_your_personal_api_key_here") + .pollIntervalSeconds(30) // Optional: customize polling interval + .build(); +``` + +**Benefits:** + +- **Reduced latency**: No API call needed for most flag evaluations +- **Lower costs**: Fewer API requests in most cases +- **Offline support**: Flags continue to work with cached definitions + +**Limitations:** + +- Requires person/group properties to be provided with each call +- Falls back to API for cohort-based flags without local cohort data +- May not reflect real-time flag changes (respects polling interval) + ### Check if Feature is Enabled #### Kotlin diff --git a/posthog-server/api/posthog-server.api b/posthog-server/api/posthog-server.api index bcae56f7..f5c3e2e8 100644 --- a/posthog-server/api/posthog-server.api +++ b/posthog-server/api/posthog-server.api @@ -1,10 +1,10 @@ -public final class com/posthog/server/PostHog : com/posthog/server/PostHogInterface { +public final class com/posthog/server/PostHog : com/posthog/PostHogStateless, com/posthog/server/PostHogInterface { public static final field Companion Lcom/posthog/server/PostHog$Companion; public fun ()V public fun alias (Ljava/lang/String;Ljava/lang/String;)V public fun capture (Ljava/lang/String;Ljava/lang/String;)V public fun capture (Ljava/lang/String;Ljava/lang/String;Lcom/posthog/server/PostHogCaptureOptions;)V - public fun capture (Ljava/lang/String;Ljava/lang/String;Ljava/util/Map;Ljava/util/Map;Ljava/util/Map;Ljava/util/Map;Ljava/util/Date;)V + public fun capture (Ljava/lang/String;Ljava/lang/String;Ljava/util/Map;Ljava/util/Map;Ljava/util/Map;Ljava/util/Map;Ljava/util/Date;Lcom/posthog/server/PostHogSendFeatureFlagOptions;)V public fun close ()V public fun debug (Z)V public fun flush ()V @@ -35,10 +35,11 @@ public final class com/posthog/server/PostHog$Companion { public final class com/posthog/server/PostHogCaptureOptions { public static final field Companion Lcom/posthog/server/PostHogCaptureOptions$Companion; - public synthetic fun (Ljava/util/Map;Ljava/util/Map;Ljava/util/Map;Ljava/util/Map;Ljava/util/Date;Lkotlin/jvm/internal/DefaultConstructorMarker;)V + public synthetic fun (Ljava/util/Map;Ljava/util/Map;Ljava/util/Map;Ljava/util/Map;Ljava/util/Date;Lcom/posthog/server/PostHogSendFeatureFlagOptions;Lkotlin/jvm/internal/DefaultConstructorMarker;)V public static final fun builder ()Lcom/posthog/server/PostHogCaptureOptions$Builder; public final fun getGroups ()Ljava/util/Map; public final fun getProperties ()Ljava/util/Map; + public final fun getSendFeatureFlags ()Lcom/posthog/server/PostHogSendFeatureFlagOptions; public final fun getTimestamp ()Ljava/util/Date; public final fun getUserProperties ()Ljava/util/Map; public final fun getUserPropertiesSetOnce ()Ljava/util/Map; @@ -49,6 +50,7 @@ public final class com/posthog/server/PostHogCaptureOptions$Builder { public final fun build ()Lcom/posthog/server/PostHogCaptureOptions; public final fun getGroups ()Ljava/util/Map; public final fun getProperties ()Ljava/util/Map; + public final fun getSendFeatureFlags ()Lcom/posthog/server/PostHogSendFeatureFlagOptions; public final fun getTimestamp ()Ljava/util/Date; public final fun getUserProperties ()Ljava/util/Map; public final fun getUserPropertiesSetOnce ()Ljava/util/Map; @@ -56,8 +58,11 @@ public final class com/posthog/server/PostHogCaptureOptions$Builder { public final fun groups (Ljava/util/Map;)Lcom/posthog/server/PostHogCaptureOptions$Builder; public final fun properties (Ljava/util/Map;)Lcom/posthog/server/PostHogCaptureOptions$Builder; public final fun property (Ljava/lang/String;Ljava/lang/Object;)Lcom/posthog/server/PostHogCaptureOptions$Builder; + public final fun sendFeatureFlags (Lcom/posthog/server/PostHogSendFeatureFlagOptions;)Lcom/posthog/server/PostHogCaptureOptions$Builder; + public final fun sendFeatureFlags (Ljava/lang/Boolean;)Lcom/posthog/server/PostHogCaptureOptions$Builder; public final fun setGroups (Ljava/util/Map;)V public final fun setProperties (Ljava/util/Map;)V + public final fun setSendFeatureFlags (Lcom/posthog/server/PostHogSendFeatureFlagOptions;)V public final fun setTimestamp (Ljava/util/Date;)V public final fun setUserProperties (Ljava/util/Map;)V public final fun setUserPropertiesSetOnce (Ljava/util/Map;)V @@ -86,10 +91,11 @@ public class com/posthog/server/PostHogConfig { public static final field DEFAULT_HOST Ljava/lang/String; public static final field DEFAULT_MAX_BATCH_SIZE I public static final field DEFAULT_MAX_QUEUE_SIZE I + public static final field DEFAULT_POLL_INTERVAL_SECONDS I public static final field DEFAULT_US_ASSETS_HOST Ljava/lang/String; public static final field DEFAULT_US_HOST Ljava/lang/String; - public fun (Ljava/lang/String;Ljava/lang/String;ZZZZIIIILcom/posthog/PostHogEncryption;Lcom/posthog/PostHogOnFeatureFlags;Ljava/net/Proxy;III)V - public synthetic fun (Ljava/lang/String;Ljava/lang/String;ZZZZIIIILcom/posthog/PostHogEncryption;Lcom/posthog/PostHogOnFeatureFlags;Ljava/net/Proxy;IIIILkotlin/jvm/internal/DefaultConstructorMarker;)V + public fun (Ljava/lang/String;Ljava/lang/String;ZZZZIIIILcom/posthog/PostHogEncryption;Lcom/posthog/PostHogOnFeatureFlags;Ljava/net/Proxy;IIIZLjava/lang/String;I)V + public synthetic fun (Ljava/lang/String;Ljava/lang/String;ZZZZIIIILcom/posthog/PostHogEncryption;Lcom/posthog/PostHogOnFeatureFlags;Ljava/net/Proxy;IIIZLjava/lang/String;IILkotlin/jvm/internal/DefaultConstructorMarker;)V public final fun addBeforeSend (Lcom/posthog/PostHogBeforeSend;)V public final fun addIntegration (Lcom/posthog/PostHogIntegration;)V public static final fun builder (Ljava/lang/String;)Lcom/posthog/server/PostHogConfig$Builder; @@ -102,9 +108,12 @@ public class com/posthog/server/PostHogConfig { public final fun getFlushAt ()I public final fun getFlushIntervalSeconds ()I public final fun getHost ()Ljava/lang/String; + public final fun getLocalEvaluation ()Z public final fun getMaxBatchSize ()I public final fun getMaxQueueSize ()I public final fun getOnFeatureFlags ()Lcom/posthog/PostHogOnFeatureFlags; + public final fun getPersonalApiKey ()Ljava/lang/String; + public final fun getPollIntervalSeconds ()I public final fun getPreloadFeatureFlags ()Z public final fun getProxy ()Ljava/net/Proxy; public final fun getRemoteConfig ()Z @@ -117,9 +126,12 @@ public class com/posthog/server/PostHogConfig { public final fun setFeatureFlagCalledCacheSize (I)V public final fun setFlushAt (I)V public final fun setFlushIntervalSeconds (I)V + public final fun setLocalEvaluation (Z)V public final fun setMaxBatchSize (I)V public final fun setMaxQueueSize (I)V public final fun setOnFeatureFlags (Lcom/posthog/PostHogOnFeatureFlags;)V + public final fun setPersonalApiKey (Ljava/lang/String;)V + public final fun setPollIntervalSeconds (I)V public final fun setPreloadFeatureFlags (Z)V public final fun setProxy (Ljava/net/Proxy;)V public final fun setRemoteConfig (Z)V @@ -137,9 +149,12 @@ public final class com/posthog/server/PostHogConfig$Builder { public final fun flushAt (I)Lcom/posthog/server/PostHogConfig$Builder; public final fun flushIntervalSeconds (I)Lcom/posthog/server/PostHogConfig$Builder; public final fun host (Ljava/lang/String;)Lcom/posthog/server/PostHogConfig$Builder; + public final fun localEvaluation (Z)Lcom/posthog/server/PostHogConfig$Builder; public final fun maxBatchSize (I)Lcom/posthog/server/PostHogConfig$Builder; public final fun maxQueueSize (I)Lcom/posthog/server/PostHogConfig$Builder; public final fun onFeatureFlags (Lcom/posthog/PostHogOnFeatureFlags;)Lcom/posthog/server/PostHogConfig$Builder; + public final fun personalApiKey (Ljava/lang/String;)Lcom/posthog/server/PostHogConfig$Builder; + public final fun pollIntervalSeconds (I)Lcom/posthog/server/PostHogConfig$Builder; public final fun preloadFeatureFlags (Z)Lcom/posthog/server/PostHogConfig$Builder; public final fun proxy (Ljava/net/Proxy;)Lcom/posthog/server/PostHogConfig$Builder; public final fun remoteConfig (Z)Lcom/posthog/server/PostHogConfig$Builder; @@ -152,12 +167,14 @@ public final class com/posthog/server/PostHogConfig$Companion { public final class com/posthog/server/PostHogFeatureFlagOptions { public static final field Companion Lcom/posthog/server/PostHogFeatureFlagOptions$Companion; - public synthetic fun (Ljava/lang/Object;Ljava/util/Map;Ljava/util/Map;Ljava/util/Map;Lkotlin/jvm/internal/DefaultConstructorMarker;)V + public synthetic fun (Ljava/lang/Object;Ljava/util/Map;Ljava/util/Map;Ljava/util/Map;ZZLkotlin/jvm/internal/DefaultConstructorMarker;)V public static final fun builder ()Lcom/posthog/server/PostHogFeatureFlagOptions$Builder; public final fun getDefaultValue ()Ljava/lang/Object; public final fun getGroupProperties ()Ljava/util/Map; public final fun getGroups ()Ljava/util/Map; + public final fun getOnlyEvaluateLocally ()Z public final fun getPersonProperties ()Ljava/util/Map; + public final fun getSendFeatureFlagsEvent ()Z } public final class com/posthog/server/PostHogFeatureFlagOptions$Builder { @@ -167,17 +184,23 @@ public final class com/posthog/server/PostHogFeatureFlagOptions$Builder { public final fun getDefaultValue ()Ljava/lang/Object; public final fun getGroupProperties ()Ljava/util/Map; public final fun getGroups ()Ljava/util/Map; + public final fun getOnlyEvaluateLocally ()Z public final fun getPersonProperties ()Ljava/util/Map; + public final fun getSendFeatureFlagsEvent ()Z public final fun group (Ljava/lang/String;Ljava/lang/String;)Lcom/posthog/server/PostHogFeatureFlagOptions$Builder; public final fun groupProperties (Ljava/util/Map;)Lcom/posthog/server/PostHogFeatureFlagOptions$Builder; public final fun groupProperty (Ljava/lang/String;Ljava/lang/String;Ljava/lang/Object;)Lcom/posthog/server/PostHogFeatureFlagOptions$Builder; public final fun groups (Ljava/util/Map;)Lcom/posthog/server/PostHogFeatureFlagOptions$Builder; + public final fun onlyEvaluateLocally (Z)Lcom/posthog/server/PostHogFeatureFlagOptions$Builder; public final fun personProperties (Ljava/util/Map;)Lcom/posthog/server/PostHogFeatureFlagOptions$Builder; public final fun personProperty (Ljava/lang/String;Ljava/lang/Object;)Lcom/posthog/server/PostHogFeatureFlagOptions$Builder; + public final fun sendFeatureFlagsEvent (Z)Lcom/posthog/server/PostHogFeatureFlagOptions$Builder; public final fun setDefaultValue (Ljava/lang/Object;)V public final fun setGroupProperties (Ljava/util/Map;)V public final fun setGroups (Ljava/util/Map;)V + public final fun setOnlyEvaluateLocally (Z)V public final fun setPersonProperties (Ljava/util/Map;)V + public final fun setSendFeatureFlagsEvent (Z)V } public final class com/posthog/server/PostHogFeatureFlagOptions$Companion { @@ -188,7 +211,7 @@ public abstract interface class com/posthog/server/PostHogInterface { public abstract fun alias (Ljava/lang/String;Ljava/lang/String;)V public abstract fun capture (Ljava/lang/String;Ljava/lang/String;)V public abstract fun capture (Ljava/lang/String;Ljava/lang/String;Lcom/posthog/server/PostHogCaptureOptions;)V - public abstract synthetic fun capture (Ljava/lang/String;Ljava/lang/String;Ljava/util/Map;Ljava/util/Map;Ljava/util/Map;Ljava/util/Map;Ljava/util/Date;)V + public abstract synthetic fun capture (Ljava/lang/String;Ljava/lang/String;Ljava/util/Map;Ljava/util/Map;Ljava/util/Map;Ljava/util/Map;Ljava/util/Date;Lcom/posthog/server/PostHogSendFeatureFlagOptions;)V public abstract fun close ()V public abstract fun debug (Z)V public abstract fun flush ()V @@ -215,7 +238,7 @@ public abstract interface class com/posthog/server/PostHogInterface { public final class com/posthog/server/PostHogInterface$DefaultImpls { public static fun capture (Lcom/posthog/server/PostHogInterface;Ljava/lang/String;Ljava/lang/String;)V public static fun capture (Lcom/posthog/server/PostHogInterface;Ljava/lang/String;Ljava/lang/String;Lcom/posthog/server/PostHogCaptureOptions;)V - public static synthetic fun capture$default (Lcom/posthog/server/PostHogInterface;Ljava/lang/String;Ljava/lang/String;Ljava/util/Map;Ljava/util/Map;Ljava/util/Map;Ljava/util/Map;Ljava/util/Date;ILjava/lang/Object;)V + public static synthetic fun capture$default (Lcom/posthog/server/PostHogInterface;Ljava/lang/String;Ljava/lang/String;Ljava/util/Map;Ljava/util/Map;Ljava/util/Map;Ljava/util/Map;Ljava/util/Date;Lcom/posthog/server/PostHogSendFeatureFlagOptions;ILjava/lang/Object;)V public static synthetic fun debug$default (Lcom/posthog/server/PostHogInterface;ZILjava/lang/Object;)V public static fun getFeatureFlag (Lcom/posthog/server/PostHogInterface;Ljava/lang/String;Ljava/lang/String;)Ljava/lang/Object; public static fun getFeatureFlag (Lcom/posthog/server/PostHogInterface;Ljava/lang/String;Ljava/lang/String;Lcom/posthog/server/PostHogFeatureFlagOptions;)Ljava/lang/Object; @@ -237,3 +260,32 @@ public final class com/posthog/server/PostHogInterface$DefaultImpls { public static synthetic fun isFeatureEnabled$default (Lcom/posthog/server/PostHogInterface;Ljava/lang/String;Ljava/lang/String;ZLjava/util/Map;Ljava/util/Map;Ljava/util/Map;ILjava/lang/Object;)Z } +public final class com/posthog/server/PostHogSendFeatureFlagOptions { + public static final field Companion Lcom/posthog/server/PostHogSendFeatureFlagOptions$Companion; + public synthetic fun (ZLjava/util/Map;Ljava/util/Map;Lkotlin/jvm/internal/DefaultConstructorMarker;)V + public static final fun builder ()Lcom/posthog/server/PostHogSendFeatureFlagOptions$Builder; + public final fun getGroupProperties ()Ljava/util/Map; + public final fun getOnlyEvaluateLocally ()Z + public final fun getPersonProperties ()Ljava/util/Map; +} + +public final class com/posthog/server/PostHogSendFeatureFlagOptions$Builder { + public fun ()V + public final fun build ()Lcom/posthog/server/PostHogSendFeatureFlagOptions; + public final fun getGroupProperties ()Ljava/util/Map; + public final fun getOnlyEvaluateLocally ()Z + public final fun getPersonProperties ()Ljava/util/Map; + public final fun groupProperties (Ljava/util/Map;)Lcom/posthog/server/PostHogSendFeatureFlagOptions$Builder; + public final fun groupProperty (Ljava/lang/String;Ljava/lang/String;Ljava/lang/Object;)Lcom/posthog/server/PostHogSendFeatureFlagOptions$Builder; + public final fun onlyEvaluateLocally (Z)Lcom/posthog/server/PostHogSendFeatureFlagOptions$Builder; + public final fun personProperties (Ljava/util/Map;)Lcom/posthog/server/PostHogSendFeatureFlagOptions$Builder; + public final fun personProperty (Ljava/lang/String;Ljava/lang/Object;)Lcom/posthog/server/PostHogSendFeatureFlagOptions$Builder; + public final fun setGroupProperties (Ljava/util/Map;)V + public final fun setOnlyEvaluateLocally (Z)V + public final fun setPersonProperties (Ljava/util/Map;)V +} + +public final class com/posthog/server/PostHogSendFeatureFlagOptions$Companion { + public final fun builder ()Lcom/posthog/server/PostHogSendFeatureFlagOptions$Builder; +} + diff --git a/posthog-server/src/main/java/com/posthog/server/PostHog.kt b/posthog-server/src/main/java/com/posthog/server/PostHog.kt index bb532c51..b2136b60 100644 --- a/posthog-server/src/main/java/com/posthog/server/PostHog.kt +++ b/posthog-server/src/main/java/com/posthog/server/PostHog.kt @@ -1,17 +1,20 @@ package com.posthog.server import com.posthog.PostHogStateless -import com.posthog.PostHogStatelessInterface +import com.posthog.server.internal.EvaluationSource +import com.posthog.server.internal.FeatureFlagResultContext +import com.posthog.server.internal.PostHogFeatureFlags -public class PostHog : PostHogInterface { - private var instance: PostHogStatelessInterface? = null +public class PostHog : PostHogInterface, PostHogStateless() { + private var serverConfig: PostHogConfig? = null override fun setup(config: T) { - instance = PostHogStateless.with(config.asCoreConfig()) + super.setup(config.asCoreConfig()) + this.serverConfig = config } override fun close() { - instance?.close() + super.close() } override fun identify( @@ -19,7 +22,7 @@ public class PostHog : PostHogInterface { userProperties: Map?, userPropertiesSetOnce: Map?, ) { - instance?.identify( + super.identify( distinctId, userProperties, userPropertiesSetOnce, @@ -27,11 +30,11 @@ public class PostHog : PostHogInterface { } override fun flush() { - instance?.flush() + super.flush() } override fun debug(enable: Boolean) { - instance?.debug(enable) + super.debug(enable) } override fun capture( @@ -42,11 +45,27 @@ public class PostHog : PostHogInterface { userPropertiesSetOnce: Map?, groups: Map?, timestamp: java.util.Date?, + sendFeatureFlags: PostHogSendFeatureFlagOptions?, ) { - instance?.captureStateless( + val updatedProperties = + if (sendFeatureFlags == null) { + properties + } else { + mutableMapOf().apply { + properties?.let { putAll(it) } + }.also { props -> + appendFlagCaptureProperties( + distinctId, + props, + groups, + sendFeatureFlags, + ) + } + } + super.captureStateless( event, distinctId, - properties, + updatedProperties, userProperties, userPropertiesSetOnce, groups, @@ -62,14 +81,24 @@ public class PostHog : PostHogInterface { personProperties: Map?, groupProperties: Map>?, ): Boolean { - return instance?.isFeatureEnabledStateless( - distinctId, - key, - defaultValue, - groups, - personProperties, - groupProperties, - ) ?: false + (featureFlags as? PostHogFeatureFlags)?.let { featureFlags -> + val result = + featureFlags.resolveFeatureFlag( + key, + distinctId, + groups, + personProperties, + groupProperties, + ) + sendFeatureFlagCalled( + distinctId, + key, + result, + ) + val flag = result?.results?.get(key) + return flag?.enabled ?: defaultValue + } + return defaultValue } override fun getFeatureFlag( @@ -80,14 +109,24 @@ public class PostHog : PostHogInterface { personProperties: Map?, groupProperties: Map>?, ): Any? { - return instance?.getFeatureFlagStateless( - distinctId, - key, - defaultValue, - groups, - personProperties, - groupProperties, - ) + (featureFlags as? PostHogFeatureFlags)?.let { featureFlags -> + val result = + featureFlags.resolveFeatureFlag( + key, + distinctId, + groups, + personProperties, + groupProperties, + ) + sendFeatureFlagCalled( + distinctId, + key, + result, + ) + val flag = result?.results?.get(key) + return flag?.variant ?: flag?.enabled ?: defaultValue + } + return defaultValue } override fun getFeatureFlagPayload( @@ -98,14 +137,24 @@ public class PostHog : PostHogInterface { personProperties: Map?, groupProperties: Map>?, ): Any? { - return instance?.getFeatureFlagPayloadStateless( - distinctId, - key, - defaultValue, - groups, - personProperties, - groupProperties, - ) + (featureFlags as? PostHogFeatureFlags)?.let { featureFlags -> + val result = + featureFlags.resolveFeatureFlag( + key, + distinctId, + groups, + personProperties, + groupProperties, + ) + sendFeatureFlagCalled( + distinctId, + key, + result, + ) + val flag = result?.results?.get(key) + return flag?.metadata?.payload ?: defaultValue + } + return defaultValue } override fun group( @@ -114,7 +163,7 @@ public class PostHog : PostHogInterface { key: String, groupProperties: Map?, ) { - instance?.groupStateless( + super.groupStateless( distinctId, type, key, @@ -126,12 +175,94 @@ public class PostHog : PostHogInterface { distinctId: String, alias: String, ) { - instance?.aliasStateless( + super.aliasStateless( distinctId, alias, ) } + private fun sendFeatureFlagCalled( + distinctId: String, + key: String, + resultContext: FeatureFlagResultContext?, + ) { + if (serverConfig?.sendFeatureFlagEvent == false || distinctId.isEmpty() || key.isEmpty() || resultContext == null) { + return + } + + if (config?.sendFeatureFlagEvent == true) { + val requestedFlag = resultContext.results?.get(key) + val requestedFlagValue = requestedFlag?.variant ?: requestedFlag?.enabled + val isNewlySeen = featureFlagsCalled?.add(distinctId, key, requestedFlagValue) ?: false + if (isNewlySeen) { + val props = mutableMapOf() + props["\$feature_flag"] = key + props["\$feature_flag_response"] = requestedFlagValue ?: "" + resultContext.requestId?.let { + props["\$feature_flag_request_id"] = it + } + requestedFlag?.metadata?.let { + props["\$feature_flag_id"] = it.id + props["\$feature_flag_version"] = it.version + } + props["\$feature_flag_reason"] = requestedFlag?.reason?.description ?: "" + resultContext.source?.let { + props["\$feature_flag_source"] = it.toString() + if (it == EvaluationSource.LOCAL) { + props["locally_evaluated"] = true + } + } + + var allFlags = resultContext.results + if (!resultContext.exhaustive) { + // we only have partial results so we'll need to resolve the rest + resultContext.parameters?.let { params -> + // this will be cached or evaluated locally + val response = + (featureFlags as? PostHogFeatureFlags)?.resolveFeatureFlags( + distinctId, + params.groups, + params.personProperties, + params.groupProperties, + params.onlyEvaluateLocally, + ) + if (response != null) { + allFlags = response.results + } + } + } + + allFlags?.let { flags -> + val activeFeatureFlags = mutableListOf() + flags.values.forEach { flag -> + val flagValue = flag.variant ?: flag.enabled + props["\$feature/${flag.key}"] = flagValue + if (flagValue != false) { + activeFeatureFlags.add(flag.key) + } + } + props["\$active_feature_flags"] = activeFeatureFlags.toList() + } + + captureStateless("\$feature_flag_called", distinctId, properties = props) + } + } + } + + internal fun appendFlagCaptureProperties( + distinctId: String, + properties: MutableMap?, + groups: Map?, + options: PostHogSendFeatureFlagOptions?, + ) { + (featureFlags as? PostHogFeatureFlags)?.appendFlagEventProperties( + distinctId, + properties, + groups, + options, + ) + } + public companion object { /** * Set up the SDK and returns an instance that you can hold and pass it around diff --git a/posthog-server/src/main/java/com/posthog/server/PostHogCaptureOptions.kt b/posthog-server/src/main/java/com/posthog/server/PostHogCaptureOptions.kt index 083cfd8f..5b0ac190 100644 --- a/posthog-server/src/main/java/com/posthog/server/PostHogCaptureOptions.kt +++ b/posthog-server/src/main/java/com/posthog/server/PostHogCaptureOptions.kt @@ -14,6 +14,7 @@ public class PostHogCaptureOptions private constructor( public val userPropertiesSetOnce: Map?, public val groups: Map?, public val timestamp: Date? = null, + public val sendFeatureFlags: PostHogSendFeatureFlagOptions? = null, ) { public class Builder { public var properties: MutableMap? = null @@ -21,6 +22,7 @@ public class PostHogCaptureOptions private constructor( public var userPropertiesSetOnce: MutableMap? = null public var groups: MutableMap? = null public var timestamp: Date? = null + public var sendFeatureFlags: PostHogSendFeatureFlagOptions? = null /** * Add a single custom property to the capture options @@ -155,6 +157,20 @@ public class PostHogCaptureOptions private constructor( return this } + public fun sendFeatureFlags(toggle: Boolean?): Builder { + if (toggle == true) { + this.sendFeatureFlags = PostHogSendFeatureFlagOptions.builder().build() + } else { + this.sendFeatureFlags = null + } + return this + } + + public fun sendFeatureFlags(options: PostHogSendFeatureFlagOptions?): Builder { + this.sendFeatureFlags = options + return this + } + public fun build(): PostHogCaptureOptions = PostHogCaptureOptions( properties, @@ -162,6 +178,7 @@ public class PostHogCaptureOptions private constructor( userPropertiesSetOnce, groups, timestamp, + sendFeatureFlags, ) } diff --git a/posthog-server/src/main/java/com/posthog/server/PostHogConfig.kt b/posthog-server/src/main/java/com/posthog/server/PostHogConfig.kt index 87bf6923..fd922c79 100644 --- a/posthog-server/src/main/java/com/posthog/server/PostHogConfig.kt +++ b/posthog-server/src/main/java/com/posthog/server/PostHogConfig.kt @@ -78,7 +78,8 @@ public open class PostHogConfig constructor( */ public var encryption: PostHogEncryption? = null, /** - * Hook that is called when feature flags are loaded + * Hook that is called when feature flag definitions are loaded. + * This is called immediately if local evaluation is not enabled. * Defaults to no callback */ public var onFeatureFlags: PostHogOnFeatureFlags? = null, @@ -106,9 +107,29 @@ public open class PostHogConfig constructor( * Defaults to 1000 */ public var featureFlagCalledCacheSize: Int = DEFAULT_FEATURE_FLAG_CALLED_CACHE_SIZE, + /** + * Enable local evaluation of feature flags + * When enabled, the SDK periodically fetches flag definitions and evaluates flags locally + * without making API calls for each flag check. Falls back to API if evaluation is inconclusive. + * Requires personalApiKey to be set. + * Defaults to false + */ + public var localEvaluation: Boolean = false, + /** + * Personal API key for local evaluation + * Required when localEvaluation is true. + * Defaults to null + */ + public var personalApiKey: String? = null, + /** + * Interval in seconds for polling feature flag definitions for local evaluation + * Defaults to 30 seconds + */ + public var pollIntervalSeconds: Int = DEFAULT_POLL_INTERVAL_SECONDS, ) { private val beforeSendCallbacks = mutableListOf() private val integrations = mutableListOf() + internal var featureFlags: PostHogFeatureFlags? = null public fun addBeforeSend(beforeSend: PostHogBeforeSend) { beforeSendCallbacks.add(beforeSend) @@ -129,7 +150,6 @@ public open class PostHogConfig constructor( apiKey = apiKey, host = host, debug = debug, - sendFeatureFlagEvent = sendFeatureFlagEvent, preloadFeatureFlags = preloadFeatureFlags, remoteConfig = remoteConfig, flushAt = flushAt, @@ -145,11 +165,17 @@ public open class PostHogConfig constructor( api, cacheMaxAgeMs = featureFlagCacheMaxAgeMs, cacheMaxSize = featureFlagCacheSize, + localEvaluation = localEvaluation, + personalApiKey = personalApiKey, + pollIntervalSeconds = pollIntervalSeconds, + onFeatureFlags = onFeatureFlags, ) }, queueProvider = { config, api, endpoint, _, executor -> PostHogMemoryQueue(config, api, endpoint, executor) }, + // Don't let the core SDK handle this, we do it ourselves + sendFeatureFlagEvent = false, ) // Apply stored callbacks and integrations @@ -181,6 +207,7 @@ public open class PostHogConfig constructor( public const val DEFAULT_FEATURE_FLAG_CACHE_SIZE: Int = 1000 public const val DEFAULT_FEATURE_FLAG_CACHE_MAX_AGE_MS: Int = 5 * 60 * 1000 // 5 minutes public const val DEFAULT_FEATURE_FLAG_CALLED_CACHE_SIZE: Int = 1000 + public const val DEFAULT_POLL_INTERVAL_SECONDS: Int = 30 @JvmStatic public fun builder(apiKey: String): Builder = Builder(apiKey) @@ -202,6 +229,9 @@ public open class PostHogConfig constructor( private var featureFlagCacheSize: Int = DEFAULT_FEATURE_FLAG_CACHE_SIZE private var featureFlagCacheMaxAgeMs: Int = DEFAULT_FEATURE_FLAG_CACHE_MAX_AGE_MS private var featureFlagCalledCacheSize: Int = DEFAULT_FEATURE_FLAG_CALLED_CACHE_SIZE + private var localEvaluation: Boolean? = null + private var personalApiKey: String? = null + private var pollIntervalSeconds: Int = DEFAULT_POLL_INTERVAL_SECONDS public fun host(host: String): Builder = apply { this.host = host } @@ -235,6 +265,18 @@ public open class PostHogConfig constructor( public fun featureFlagCalledCacheSize(featureFlagCalledCacheSize: Int): Builder = apply { this.featureFlagCalledCacheSize = featureFlagCalledCacheSize } + public fun localEvaluation(localEvaluation: Boolean): Builder = apply { this.localEvaluation = localEvaluation } + + public fun personalApiKey(personalApiKey: String?): Builder = + apply { + this.personalApiKey = personalApiKey + if (localEvaluation == null) { + this.localEvaluation = personalApiKey != null + } + } + + public fun pollIntervalSeconds(pollIntervalSeconds: Int): Builder = apply { this.pollIntervalSeconds = pollIntervalSeconds } + public fun build(): PostHogConfig = PostHogConfig( apiKey = apiKey, @@ -253,6 +295,9 @@ public open class PostHogConfig constructor( featureFlagCacheSize = featureFlagCacheSize, featureFlagCacheMaxAgeMs = featureFlagCacheMaxAgeMs, featureFlagCalledCacheSize = featureFlagCalledCacheSize, + localEvaluation = localEvaluation ?: false, + personalApiKey = personalApiKey, + pollIntervalSeconds = pollIntervalSeconds, ) } } diff --git a/posthog-server/src/main/java/com/posthog/server/PostHogFeatureFlagOptions.kt b/posthog-server/src/main/java/com/posthog/server/PostHogFeatureFlagOptions.kt index d8a798d2..c38811c8 100644 --- a/posthog-server/src/main/java/com/posthog/server/PostHogFeatureFlagOptions.kt +++ b/posthog-server/src/main/java/com/posthog/server/PostHogFeatureFlagOptions.kt @@ -9,12 +9,16 @@ public class PostHogFeatureFlagOptions private constructor( public val groups: Map?, public val personProperties: Map?, public val groupProperties: Map>?, + public val sendFeatureFlagsEvent: Boolean = true, + public val onlyEvaluateLocally: Boolean = false, ) { public class Builder { public var defaultValue: Any? = null public var groups: MutableMap? = null public var personProperties: MutableMap? = null public var groupProperties: MutableMap>? = null + public var sendFeatureFlagsEvent: Boolean = true + public var onlyEvaluateLocally: Boolean = false /** * Sets the default value to return if the feature flag is not found or not enabled @@ -106,12 +110,32 @@ public class PostHogFeatureFlagOptions private constructor( return this } + /** + * Whether to send a feature flag called event + * Defaults to true + */ + public fun sendFeatureFlagsEvent(sendFeatureFlagsEvent: Boolean): Builder { + this.sendFeatureFlagsEvent = sendFeatureFlagsEvent + return this + } + + /** + * Whether to only evaluate the feature flag locally + * Defaults to false + */ + public fun onlyEvaluateLocally(onlyEvaluateLocally: Boolean): Builder { + this.onlyEvaluateLocally = onlyEvaluateLocally + return this + } + public fun build(): PostHogFeatureFlagOptions = PostHogFeatureFlagOptions( defaultValue = defaultValue, groups = groups, personProperties = personProperties, groupProperties = groupProperties, + sendFeatureFlagsEvent = sendFeatureFlagsEvent, + onlyEvaluateLocally = onlyEvaluateLocally, ) } diff --git a/posthog-server/src/main/java/com/posthog/server/PostHogInterface.kt b/posthog-server/src/main/java/com/posthog/server/PostHogInterface.kt index cbd2f221..e2094ef1 100644 --- a/posthog-server/src/main/java/com/posthog/server/PostHogInterface.kt +++ b/posthog-server/src/main/java/com/posthog/server/PostHogInterface.kt @@ -74,6 +74,7 @@ public sealed interface PostHogInterface { * @param userProperties the user properties, set as a "$set" property, Docs https://posthog.com/docs/product-analytics/user-properties * @param userPropertiesSetOnce the user properties to set only once, set as a "$set_once" property, Docs https://posthog.com/docs/product-analytics/user-properties * @param groups the groups, set as a "$groups" property, Docs https://posthog.com/docs/product-analytics/group-analytics + * @param sendFeatureFlags whether to send feature flags with this event, if not provided the default config value will be used */ @JvmSynthetic public fun capture( @@ -84,13 +85,14 @@ public sealed interface PostHogInterface { userPropertiesSetOnce: Map? = null, groups: Map? = null, timestamp: Date? = null, + sendFeatureFlags: PostHogSendFeatureFlagOptions? = null, ) /** * Captures events * @param event the event name * @param distinctId the distinctId - * @param options the capture options containing properties, userProperties, userPropertiesSetOnce, and groups + * @param options the capture options containing properties, userProperties, userPropertiesSetOnce, groups, and sendFeatureFlags */ public fun capture( distinctId: String, @@ -105,6 +107,7 @@ public sealed interface PostHogInterface { options.userPropertiesSetOnce, options.groups, options.timestamp, + options.sendFeatureFlags, ) } diff --git a/posthog-server/src/main/java/com/posthog/server/PostHogSendFeatureFlagOptions.kt b/posthog-server/src/main/java/com/posthog/server/PostHogSendFeatureFlagOptions.kt new file mode 100644 index 00000000..615043d3 --- /dev/null +++ b/posthog-server/src/main/java/com/posthog/server/PostHogSendFeatureFlagOptions.kt @@ -0,0 +1,95 @@ +package com.posthog.server + +/** + * Provides an ergonomic interface when providing options for capturing events + * This is mainly meant to be used from Java, as Kotlin can use named parameters. + * @see Documentation: Capturing events + */ +public class PostHogSendFeatureFlagOptions private constructor( + public val onlyEvaluateLocally: Boolean = false, + public val personProperties: Map?, + public val groupProperties: Map>?, +) { + public class Builder { + public var onlyEvaluateLocally: Boolean = false + public var personProperties: MutableMap? = null + public var groupProperties: MutableMap>? = null + + /** + * Sets whether to only evaluate the feature flags locally. + */ + public fun onlyEvaluateLocally(onlyEvaluateLocally: Boolean): Builder { + this.onlyEvaluateLocally = onlyEvaluateLocally + return this + } + + /** + * Adds a single user property to the capture options + * @see Documentation: User Properties + */ + public fun personProperty( + key: String, + propValue: Any?, + ): Builder { + personProperties = + (personProperties ?: mutableMapOf()).apply { + put(key, propValue) + } + return this + } + + /** + * Appends multiple user properties to the capture options. + * @see Documentation: User Properties + */ + public fun personProperties(userProperties: Map): Builder { + this.personProperties = + (this.personProperties ?: mutableMapOf()).apply { + putAll(userProperties) + } + return this + } + + /** + * Adds a single user property (set once) to the capture options. + * @see Documentation: User Properties + */ + public fun groupProperty( + group: String, + key: String, + propValue: Any?, + ): Builder { + groupProperties = + (groupProperties ?: mutableMapOf()).apply { + getOrPut(group) { mutableMapOf() }[key] = propValue + } + return this + } + + /** + * Appends multiple user properties (set once) to the capture options. + * @see Documentation: User Properties + */ + public fun groupProperties(groupProperties: Map>): Builder { + this.groupProperties = + (this.groupProperties ?: mutableMapOf()).apply { + groupProperties.forEach { (group, properties) -> + getOrPut(group) { mutableMapOf() }.putAll(properties) + } + } + return this + } + + public fun build(): PostHogSendFeatureFlagOptions = + PostHogSendFeatureFlagOptions( + onlyEvaluateLocally = onlyEvaluateLocally, + personProperties = personProperties, + groupProperties = groupProperties, + ) + } + + public companion object { + @JvmStatic + public fun builder(): Builder = Builder() + } +} diff --git a/posthog-server/src/main/java/com/posthog/server/internal/FlagEvaluator.kt b/posthog-server/src/main/java/com/posthog/server/internal/FlagEvaluator.kt new file mode 100644 index 00000000..c3d33f8e --- /dev/null +++ b/posthog-server/src/main/java/com/posthog/server/internal/FlagEvaluator.kt @@ -0,0 +1,842 @@ +package com.posthog.server.internal + +import com.posthog.PostHogConfig +import com.posthog.internal.FlagConditionGroup +import com.posthog.internal.FlagDefinition +import com.posthog.internal.FlagProperty +import com.posthog.internal.LogicalOperator +import com.posthog.internal.PropertyGroup +import com.posthog.internal.PropertyOperator +import com.posthog.internal.PropertyType +import com.posthog.internal.PropertyValue +import java.security.MessageDigest +import java.text.Normalizer +import java.time.Instant +import java.time.ZoneId +import java.time.ZonedDateTime +import java.time.format.DateTimeFormatter +import java.time.format.DateTimeParseException +import java.time.temporal.ChronoUnit +import java.util.Date +import java.util.regex.PatternSyntaxException + +/** + * Local evaluation engine for feature flags + */ +internal class FlagEvaluator( + private val config: PostHogConfig, +) { + companion object { + private const val LONG_SCALE = 0xFFFFFFFFFFFFFFF.toDouble() + private val NONE_VALUES_ALLOWED_OPERATORS = setOf(PropertyOperator.IS_NOT) + private val REGEX_COMBINING_MARKS = "\\p{M}+".toRegex() + private val REGEX_RELATIVE_DATE = "^-?([0-9]+)([hdwmy])$".toRegex() + + private val DATE_FORMATTER_WITH_SPACE_TZ = + DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss XXX") + private val DATE_FORMATTER_NO_SPACE_TZ = + DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ssXXX") + private val DATE_FORMATTER_NO_TZ = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss") + + private fun casefold(input: String): String { + val normalized = Normalizer.normalize(input, Normalizer.Form.NFD) + return REGEX_COMBINING_MARKS.replace(normalized, "").uppercase().lowercase() + } + } + + private data class VariantLookupEntry( + val key: String, + val valueMin: Double, + val valueMax: Double, + ) + + /** + * Hash function for consistent rollout percentages + * Given the same distinct_id and key, it'll always return the same float. + * These floats are uniformly distributed between 0 and 1. + */ + private fun hash( + key: String, + distinctId: String, + salt: String = "", + ): Double { + val hashKey = "$key.$distinctId$salt" + val digest = MessageDigest.getInstance("SHA-1") + val hashBytes = digest.digest(hashKey.toByteArray(Charsets.UTF_8)) + + // Take first 15 hex characters (60 bits) + val hexString = hashBytes.joinToString("") { "%02x".format(it) } + val hashValue = hexString.substring(0, 15).toLong(16) + + return hashValue / LONG_SCALE + } + + /** + * Get the matching variant for a multivariate flag + */ + fun getMatchingVariant( + flag: FlagDefinition, + distinctId: String, + ): String? { + val hashValue = hash(flag.key, distinctId, salt = "variant") + val variants = variantLookupTable(flag) + + for (variant in variants) { + if (hashValue >= variant.valueMin && hashValue < variant.valueMax) { + return variant.key + } + } + return null + } + + /** + * Build variant lookup table for efficient variant selection. Order of the variants matters, + * and this implementation mirrors the ordering provided by the local evaluation API. + */ + private fun variantLookupTable(flag: FlagDefinition): List { + val lookupTable = mutableListOf() + var valueMin = 0.0 + + flag.filters.multivariate?.variants?.let { variants -> + for (variant in variants) { + val valueMax = valueMin + (variant.rolloutPercentage / 100.0) + lookupTable.add( + VariantLookupEntry( + key = variant.key, + valueMin = valueMin, + valueMax = valueMax, + ), + ) + valueMin = valueMax + } + } + return lookupTable + } + + /** + * Match a property condition against property values + * Only looks for matches where key exists in propertyValues + */ + fun matchProperty( + property: FlagProperty, + propertyValues: Map, + ): Boolean { + val key = property.key + val propertyOperator = property.propertyOperator ?: PropertyOperator.EXACT + val propertyValue = property.propertyValue + + // Check if property key exists in values + if (!propertyValues.containsKey(key)) { + throw InconclusiveMatchException("Can't match properties without a given property value") + } + + // is_not_set operator can't be evaluated locally + if (propertyOperator == PropertyOperator.IS_NOT_SET) { + throw InconclusiveMatchException("Can't match properties with operator is_not_set") + } + + val overrideValue = propertyValues[key] + + // Handle null values (only allowed for certain operators) + if (propertyOperator !in NONE_VALUES_ALLOWED_OPERATORS && overrideValue == null) { + return false + } + + return when (propertyOperator) { + PropertyOperator.EXACT, PropertyOperator.IS_NOT -> { + val matches = computeExactMatch(propertyValue, overrideValue) + if (propertyOperator == PropertyOperator.EXACT) matches else !matches + } + + PropertyOperator.IS_SET -> propertyValues.containsKey(key) + PropertyOperator.ICONTAINS -> + stringContains( + overrideValue.toString(), + propertyValue.toString(), + ignoreCase = true, + ) + + PropertyOperator.NOT_ICONTAINS -> + !stringContains( + overrideValue.toString(), + propertyValue.toString(), + ignoreCase = true, + ) + + PropertyOperator.REGEX -> + matchesRegex( + propertyValue.toString(), + overrideValue.toString(), + ) + + PropertyOperator.NOT_REGEX -> + !matchesRegex( + propertyValue.toString(), + overrideValue.toString(), + ) + + PropertyOperator.GT, PropertyOperator.GTE, PropertyOperator.LT, PropertyOperator.LTE -> + compareValues( + overrideValue, + propertyValue, + propertyOperator, + ) + + PropertyOperator.IS_DATE_BEFORE, PropertyOperator.IS_DATE_AFTER -> + compareDates( + overrideValue, + propertyValue, + propertyOperator, + ) + + else -> throw InconclusiveMatchException("Unknown operator: $propertyOperator") + } + } + + private fun computeExactMatch( + propertyValue: Any?, + overrideValue: Any?, + ): Boolean { + // Lowercase to uppercase to normalize locale (e.g., Turkish i, German ß) + // String.equals apparently does this when ignoreCase=true, but it doesn't seem to work. + // https://kotlinlang.org/api/core/1.3/kotlin-stdlib/kotlin.text/equals.html + val expectedValue = overrideValue?.let { casefold(it.toString()) } + return when { + propertyValue is List<*> -> { + propertyValue.any { v -> + v == expectedValue || (v != null && casefold(v.toString()) == expectedValue) + } + } + + else -> + propertyValue == expectedValue || ( + propertyValue != null && casefold( + propertyValue.toString(), + ) == expectedValue + ) + } + } + + private fun stringContains( + haystack: String, + needle: String, + ignoreCase: Boolean, + ): Boolean { + if (ignoreCase) { + return casefold(haystack).contains(casefold(needle), ignoreCase = true) + } + return haystack.contains(needle) + } + + private fun matchesRegex( + pattern: String, + propertyValue: String, + ): Boolean { + return try { + Regex(pattern).find(propertyValue) != null + } catch (e: PatternSyntaxException) { + false + } + } + + private fun compareValues( + overrideValue: Any?, + propertyValue: Any?, + propertyOperator: PropertyOperator, + ): Boolean { + val numericValue = propertyValue?.toString()?.toDoubleOrNull() + + return if (numericValue != null && overrideValue != null) { + when (overrideValue) { + is String -> + compareStrings( + overrideValue, + propertyValue.toString(), + propertyOperator, + ) + + is Number -> + compareNumbers( + overrideValue.toDouble(), + numericValue, + propertyOperator, + ) + + else -> + compareStrings( + overrideValue.toString(), + propertyValue.toString(), + propertyOperator, + ) + } + } else { + // String comparison if numeric parsing fails + compareStrings(overrideValue.toString(), propertyValue.toString(), propertyOperator) + } + } + + private fun compareNumbers( + lhs: Double, + rhs: Double, + propertyOperator: PropertyOperator, + ): Boolean { + return when (propertyOperator) { + PropertyOperator.GT -> lhs > rhs + PropertyOperator.GTE -> lhs >= rhs + PropertyOperator.LT -> lhs < rhs + PropertyOperator.LTE -> lhs <= rhs + else -> false + } + } + + private fun compareStrings( + lhs: String, + rhs: String, + propertyOperator: PropertyOperator, + ): Boolean { + return when (propertyOperator) { + PropertyOperator.GT -> lhs > rhs + PropertyOperator.GTE -> lhs >= rhs + PropertyOperator.LT -> lhs < rhs + PropertyOperator.LTE -> lhs <= rhs + else -> false + } + } + + private fun compareDates( + overrideValue: Any?, + propertyValue: Any?, + propertyOperator: PropertyOperator, + ): Boolean { + val parsedDate = + try { + parseDateValue(propertyValue.toString()) + } catch (e: Exception) { + throw InconclusiveMatchException("The date set on the flag is not a valid format") + } + + val overrideDate = + when (overrideValue) { + is Date -> overrideValue.toInstant().atZone(ZoneId.systemDefault()) + is ZonedDateTime -> overrideValue + is Instant -> overrideValue.atZone(ZoneId.systemDefault()) + is String -> { + try { + parseOverrideDate(overrideValue) + } catch (e: Exception) { + throw InconclusiveMatchException("The date provided is not a valid format") + } + } + + else -> throw InconclusiveMatchException("The date provided must be a string or date object") + } + + return when (propertyOperator) { + PropertyOperator.IS_DATE_BEFORE -> overrideDate.isBefore(parsedDate) + PropertyOperator.IS_DATE_AFTER -> overrideDate.isAfter(parsedDate) + else -> false + } + } + + /** + * Parse date value from flag definition, supporting relative dates + */ + private fun parseDateValue(propertyValue: String): ZonedDateTime { + // Try relative date first (e.g., "-1d", "-2w", "-3m", "-1y") + val relativeDate = parseRelativeDate(propertyValue) + if (relativeDate != null) { + return relativeDate + } + + // Fall back to absolute date parsing + return parseOverrideDate(propertyValue) + } + + /** + * Parse relative date format (e.g., "-1d" or "1d" for 1 day ago). Always produces a date in the past. + */ + private fun parseRelativeDate(propertyValue: String): ZonedDateTime? { + val match = REGEX_RELATIVE_DATE.find(propertyValue) ?: return null + + val number = match.groupValues[1].toIntOrNull() ?: return null + val interval = match.groupValues[2] + + // From the Python SDK: avoid overflow or overly large date ranges + if (number >= 10_000) { + return null + } + + val now = ZonedDateTime.now() + return when (interval) { + "h" -> now.minus(number.toLong(), ChronoUnit.HOURS) + "d" -> now.minus(number.toLong(), ChronoUnit.DAYS) + "w" -> now.minus(number.toLong(), ChronoUnit.WEEKS) + "m" -> now.minus(number.toLong(), ChronoUnit.MONTHS) + "y" -> now.minus(number.toLong(), ChronoUnit.YEARS) + else -> null + } + } + + /** + * Parse absolute date from string + */ + private fun parseOverrideDate(propertyValue: String): ZonedDateTime { + try { + // Try ISO 8601 with timezone (standard format with 'T') + return ZonedDateTime.parse(propertyValue) + } catch (e: DateTimeParseException) { + // fall through + } + + try { + // Try ISO_DATE_TIME + return ZonedDateTime.parse(propertyValue, DateTimeFormatter.ISO_DATE_TIME) + } catch (e: DateTimeParseException) { + // fall through + } + + try { + // Try date only: "2022-05-01" + return java.time.LocalDate.parse(propertyValue, DateTimeFormatter.ISO_DATE) + .atStartOfDay(ZoneId.systemDefault()) + } catch (e: DateTimeParseException) { + // fall through + } + + try { + // Try Instant (UTC) + return Instant.parse(propertyValue).atZone(ZoneId.systemDefault()) + } catch (e: DateTimeParseException) { + // fall through + } + + try { + // Try datetime with space and timezone offset: "2022-04-05 12:34:12 +01:00" + return ZonedDateTime.parse(propertyValue, DATE_FORMATTER_WITH_SPACE_TZ) + } catch (e: DateTimeParseException) { + // fall through + } + + try { + // Try datetime with timezone offset (no space): "2022-04-05 12:34:12+01:00" + return ZonedDateTime.parse(propertyValue, DATE_FORMATTER_NO_SPACE_TZ) + } catch (e: DateTimeParseException) { + // fall through + } + + try { + // Try datetime without timezone: "2022-05-01 00:00:00" + return java.time.LocalDateTime.parse(propertyValue, DATE_FORMATTER_NO_TZ) + .atZone(ZoneId.systemDefault()) + } catch (e: DateTimeParseException) { + // All formats failed + } + + throw DateTimeParseException("Unable to parse date: $propertyValue", propertyValue, 0) + } + + /** + * Match a cohort property against property values + */ + fun matchCohort( + property: FlagProperty, + propertyValues: Map, + cohortProperties: Map, + flagsByKey: Map?, + evaluationCache: MutableMap?, + distinctId: String?, + ): Boolean { + val cohortId = + property.propertyValue?.toString() + ?: throw InconclusiveMatchException("Cohort property missing value") + + if (!cohortProperties.containsKey(cohortId)) { + throw InconclusiveMatchException("Can't match cohort without a given cohort property value") + } + + val propertyGroup = + cohortProperties[cohortId] + ?: throw InconclusiveMatchException("Cohort definition not found") + return matchPropertyGroup( + propertyGroup, + propertyValues, + cohortProperties, + flagsByKey, + evaluationCache, + distinctId, + ) + } + + /** + * Match a property group (AND/OR) against property values + */ + fun matchPropertyGroup( + propertyGroup: PropertyGroup, + propertyValues: Map, + cohortProperties: Map, + flagsByKey: Map?, + evaluationCache: MutableMap?, + distinctId: String?, + ): Boolean { + val groupType = propertyGroup.type + val properties = propertyGroup.values + + // Empty properties always match + if (properties == null || properties.isEmpty()) return true + + var errorMatchingLocally = false + + // Handle based on whether we have nested property groups or flag properties + when (properties) { + is PropertyValue.PropertyGroups -> { + for (nestedGroup in properties.values) { + try { + val matches = + matchPropertyGroup( + nestedGroup, + propertyValues, + cohortProperties, + flagsByKey, + evaluationCache, + distinctId, + ) + + if (groupType == LogicalOperator.AND) { + if (!matches) return false + } else { + // OR group + if (matches) return true + } + } catch (e: InconclusiveMatchException) { + config.logger.log("Failed to compute nested property group locally: ${e.message}") + errorMatchingLocally = true + } + } + + if (errorMatchingLocally) { + throw InconclusiveMatchException("Can't match cohort without a given cohort property value") + } + + // If we get here, all matched in AND case, or none matched in OR case + return groupType == LogicalOperator.AND + } + + is PropertyValue.FlagProperties -> { + // Regular properties + for (property in properties.values) { + try { + val matches = + when (property.type) { + PropertyType.COHORT -> + matchCohort( + property, + propertyValues, + cohortProperties, + flagsByKey, + evaluationCache, + distinctId, + ) + + PropertyType.FLAG -> + evaluateFlagDependency( + property, + flagsByKey + ?: throw InconclusiveMatchException("Cannot evaluate flag dependencies without flagsByKey"), + evaluationCache + ?: throw InconclusiveMatchException( + "Cannot evaluate flag dependencies without evaluationCache", + ), + distinctId + ?: throw InconclusiveMatchException("Cannot evaluate flag dependencies without distinctId"), + propertyValues, + cohortProperties, + ) + + else -> matchProperty(property, propertyValues) + } + + val negation = property.negation ?: false + + if (groupType == LogicalOperator.AND) { + // If negated property, do the inverse + if (!matches && !negation) return false + if (matches && negation) return false + } else { + // OR group + if (matches && !negation) return true + if (!matches && negation) return true + } + } catch (e: InconclusiveMatchException) { + config.logger.log("Failed to compute property ${property.key} locally: ${e.message}") + errorMatchingLocally = true + } + } + + if (errorMatchingLocally) { + throw InconclusiveMatchException("Can't match cohort without a given cohort property value") + } + + // If we get here, all matched in AND case, or none matched in OR case + return groupType == LogicalOperator.AND + } + } + } + + /** + * Check if a condition matches for a given distinct ID + */ + fun isConditionMatch( + featureFlag: FlagDefinition, + distinctId: String, + condition: FlagConditionGroup, + properties: Map, + cohortProperties: Map, + flagsByKey: Map?, + evaluationCache: MutableMap?, + ): Boolean { + val rolloutPercentage = condition.rolloutPercentage + val conditionProperties = condition.properties ?: emptyList() + + // Check all properties match + if (conditionProperties.isNotEmpty()) { + for (prop in conditionProperties) { + val matches = + when (prop.type) { + PropertyType.COHORT -> + matchCohort( + prop, + properties, + cohortProperties, + flagsByKey, + evaluationCache, + distinctId, + ) + + PropertyType.FLAG -> + evaluateFlagDependency( + prop, + flagsByKey + ?: throw InconclusiveMatchException("Cannot evaluate flag dependencies without flagsByKey"), + evaluationCache + ?: throw InconclusiveMatchException("Cannot evaluate flag dependencies without evaluationCache"), + distinctId, + properties, + cohortProperties, + ) + + else -> matchProperty(prop, properties) + } + + if (!matches) { + return false + } + } + + // All properties matched, check rollout + if (rolloutPercentage == null) { + return true + } + } + + // Check rollout percentage + if (rolloutPercentage != null && hash( + featureFlag.key, + distinctId, + ) > (rolloutPercentage / 100.0) + ) { + return false + } + + return true + } + + /** + * Main evaluation function to match feature flag properties + * Returns the flag value (true, false, or variant key) + */ + fun matchFeatureFlagProperties( + flag: FlagDefinition, + distinctId: String, + properties: Map, + cohortProperties: Map = emptyMap(), + flagsByKey: Map? = null, + evaluationCache: MutableMap? = null, + ): Any? { + val flagConditions = flag.filters.groups ?: emptyList() + var isInconclusive = false + + // Get variant keys for validation + val flagVariants = flag.filters.multivariate?.variants ?: emptyList() + val validVariantKeys = flagVariants.map { it.key }.toSet() + + // Sort conditions with variant overrides to the top + // This ensures that if overrides are present, they are evaluated first + val sortedFlagConditions = + flagConditions.sortedBy { + if (it.variant != null) 0 else 1 + } + + for (condition in sortedFlagConditions) { + try { + // If any one condition resolves to True, we can short-circuit and return the matching variant + if (isConditionMatch( + flag, + distinctId, + condition, + properties, + cohortProperties, + flagsByKey, + evaluationCache, + ) + ) { + val variantOverride = condition.variant + val variant = + if (variantOverride != null && variantOverride in validVariantKeys) { + variantOverride + } else { + getMatchingVariant(flag, distinctId) + } + return variant ?: true + } + } catch (e: InconclusiveMatchException) { + isInconclusive = true + } + } + + if (isInconclusive) { + throw InconclusiveMatchException("Can't determine if feature flag is enabled or not with given properties") + } + + // We can only return False when either all conditions are False, or no condition was inconclusive + return false + } + + /** + * Evaluate a flag dependency property + */ + fun evaluateFlagDependency( + property: FlagProperty, + flagsByKey: Map, + evaluationCache: MutableMap, + distinctId: String, + properties: Map, + cohortProperties: Map, + ): Boolean { + // Check if dependency_chain is present + val dependencyChain = property.dependencyChain + if (dependencyChain == null) { + throw InconclusiveMatchException( + "Flag dependency property for '${property.key}' is missing required 'dependency_chain' field", + ) + } + + // Handle circular dependency (empty chain means circular) + if (dependencyChain.isEmpty()) { + config.logger.log("Circular dependency detected for flag: ${property.key}") + throw InconclusiveMatchException("Circular dependency detected for flag '${property.key}'") + } + + // Evaluate all dependencies in the chain order + for (depFlagKey in dependencyChain) { + if (!evaluationCache.containsKey(depFlagKey)) { + // Need to evaluate this dependency first + val depFlag = flagsByKey[depFlagKey] + if (depFlag == null) { + // Missing flag dependency - cannot evaluate locally + evaluationCache[depFlagKey] = null + throw InconclusiveMatchException( + "Cannot evaluate flag dependency '$depFlagKey' - flag not found in local flags", + ) + } else { + // Check if the flag is active + if (!depFlag.active) { + evaluationCache[depFlagKey] = false + } else { + // Recursively evaluate the dependency + try { + val depResult = + matchFeatureFlagProperties( + depFlag, + distinctId, + properties, + cohortProperties, + flagsByKey, + evaluationCache, + ) + evaluationCache[depFlagKey] = depResult + } catch (e: InconclusiveMatchException) { + // If we can't evaluate a dependency, store null and propagate the error + evaluationCache[depFlagKey] = null + throw InconclusiveMatchException("Cannot evaluate flag dependency '$depFlagKey': ${e.message}") + } + } + } + } + + // Check the cached result + val cachedResult = evaluationCache[depFlagKey] + if (cachedResult == null) { + // Previously inconclusive - raise error again + throw InconclusiveMatchException("Flag dependency '$depFlagKey' was previously inconclusive") + } else if (cachedResult == false) { + // Definitive False result - dependency failed + return false + } + } + + // All dependencies in the chain have been evaluated successfully + // Now check if the final flag value matches the expected value in the property + val flagKey = property.key + val expectedValue = property.propertyValue + val propertyOperator = property.propertyOperator ?: PropertyOperator.EXACT + + if (expectedValue != null) { + // Get the actual value of the flag we're checking + val actualValue = evaluationCache[flagKey] + + if (actualValue == null) { + // Flag wasn't evaluated - this shouldn't happen if dependency chain is correct + throw InconclusiveMatchException("Flag '$flagKey' was not evaluated despite being in dependency chain") + } + + // For flag dependencies, we need to compare the actual flag result with expected value + if (propertyOperator == PropertyOperator.FLAG_EVALUATES_TO) { + return matchesDependencyValue(expectedValue, actualValue) + } else { + throw InconclusiveMatchException("Flag dependency property for '${property.key}' has invalid operator '$propertyOperator'") + } + } + + // If no value check needed, return True (all dependencies passed) + return true + } + + /** + * Check if the actual flag value matches the expected dependency value + * + * This follows the same logic as the C# MatchesDependencyValue function: + * - String variant case: check for exact match or boolean true + * - Boolean case: must match expected boolean value + */ + private fun matchesDependencyValue( + expectedValue: Any?, + actualValue: Any?, + ): Boolean { + // String variant case - check forcccccdbbiditecffbtgnkruvgnktfrldecihggnjhguh exact match or boolean true + if (actualValue is String && actualValue.isNotEmpty()) { + return when (expectedValue) { + is Boolean -> expectedValue // Any variant matches boolean true + is String -> actualValue == expectedValue // Variants are case-sensitive + else -> false + } + } + + // Boolean case - must match expected boolean value + if (actualValue is Boolean && expectedValue is Boolean) { + return actualValue == expectedValue + } + + // Default case + return false + } +} diff --git a/posthog-server/src/main/java/com/posthog/server/internal/InconclusiveMatchException.kt b/posthog-server/src/main/java/com/posthog/server/internal/InconclusiveMatchException.kt new file mode 100644 index 00000000..43c0a750 --- /dev/null +++ b/posthog-server/src/main/java/com/posthog/server/internal/InconclusiveMatchException.kt @@ -0,0 +1,6 @@ +package com.posthog.server.internal + +/** + * Exception thrown when flag evaluation cannot be determined locally + */ +internal class InconclusiveMatchException(message: String) : Exception(message) diff --git a/posthog-server/src/main/java/com/posthog/server/internal/LocalEvaluationPoller.kt b/posthog-server/src/main/java/com/posthog/server/internal/LocalEvaluationPoller.kt new file mode 100644 index 00000000..972cd7a1 --- /dev/null +++ b/posthog-server/src/main/java/com/posthog/server/internal/LocalEvaluationPoller.kt @@ -0,0 +1,67 @@ +package com.posthog.server.internal + +import com.posthog.PostHogConfig +import java.util.concurrent.Executors +import java.util.concurrent.ScheduledExecutorService +import java.util.concurrent.TimeUnit + +/** + * Poller for periodically fetching feature flag definitions for local evaluation + */ +internal class LocalEvaluationPoller( + private val config: PostHogConfig, + private val pollIntervalSeconds: Int, + private val execute: () -> Unit, +) { + private val executor: ScheduledExecutorService = + Executors.newSingleThreadScheduledExecutor { r -> + Thread(r, "PostHog-LocalEvaluationPoller").apply { + isDaemon = true + } + } + + private var isStarted = false + + fun start() { + if (isStarted) { + config.logger.log("LocalEvaluationPoller already started") + return + } + + isStarted = true + config.logger.log("Starting LocalEvaluationPoller with interval ${pollIntervalSeconds}s") + + // Schedule the task to run periodically + executor.scheduleAtFixedRate( + { + try { + execute() + } catch (e: Throwable) { + config.logger.log("Error in LocalEvaluationPoller: ${e.message}") + } + }, + 0, + pollIntervalSeconds.toLong(), + TimeUnit.SECONDS, + ) + } + + fun stop() { + if (!isStarted) { + return + } + + config.logger.log("Stopping LocalEvaluationPoller") + isStarted = false + + executor.shutdown() + try { + if (!executor.awaitTermination(5, TimeUnit.SECONDS)) { + executor.shutdownNow() + } + } catch (e: InterruptedException) { + executor.shutdownNow() + Thread.currentThread().interrupt() + } + } +} diff --git a/posthog-server/src/main/java/com/posthog/server/internal/PostHogFeatureFlags.kt b/posthog-server/src/main/java/com/posthog/server/internal/PostHogFeatureFlags.kt index 8c5d5bb6..c98764a5 100644 --- a/posthog-server/src/main/java/com/posthog/server/internal/PostHogFeatureFlags.kt +++ b/posthog-server/src/main/java/com/posthog/server/internal/PostHogFeatureFlags.kt @@ -1,15 +1,48 @@ package com.posthog.server.internal import com.posthog.PostHogConfig +import com.posthog.PostHogOnFeatureFlags import com.posthog.internal.FeatureFlag +import com.posthog.internal.FlagDefinition import com.posthog.internal.PostHogApi import com.posthog.internal.PostHogFeatureFlagsInterface +import com.posthog.internal.PropertyGroup + +internal enum class EvaluationSource { + LOCAL, + REMOTE, + CACHE, +} + +internal data class FeatureFlagResolutionParameters( + val groups: Map? = null, + val personProperties: Map? = null, + val groupProperties: Map>? = null, + val onlyEvaluateLocally: Boolean = false, +) + +internal data class FeatureFlagResultContext( + val results: Map? = null, + val source: EvaluationSource? = null, + val requestId: String? = null, + val exhaustive: Boolean = false, + val parameters: FeatureFlagResolutionParameters? = null, +) + +internal data class RemoteFeatureFlagsResponse( + val flags: Map?, + val requestId: String?, +) internal class PostHogFeatureFlags( private val config: PostHogConfig, private val api: PostHogApi, private val cacheMaxAgeMs: Int, private val cacheMaxSize: Int, + private val localEvaluation: Boolean = false, + private val personalApiKey: String? = null, + private val pollIntervalSeconds: Int = 30, + private val onFeatureFlags: PostHogOnFeatureFlags? = null, ) : PostHogFeatureFlagsInterface { private val cache = PostHogFeatureFlagCache( @@ -17,6 +50,31 @@ internal class PostHogFeatureFlags( maxAgeMs = cacheMaxAgeMs, ) + @Volatile + private var featureFlags: List? = null + + @Volatile + private var flagDefinitions: Map? = null + + @Volatile + private var cohorts: Map? = null + + @Volatile + private var groupTypeMapping: Map? = null + + private val evaluator: FlagEvaluator = FlagEvaluator(config) + + private var poller: LocalEvaluationPoller? = null + + private var definitionsLoaded = false + + init { + startPoller() + if (!localEvaluation) { + onFeatureFlags?.loaded() + } + } + override fun getFeatureFlag( key: String, defaultValue: Any?, @@ -25,13 +83,17 @@ internal class PostHogFeatureFlags( personProperties: Map?, groupProperties: Map>?, ): Any? { + if (distinctId == null) { + return defaultValue + } val flag = - getFeatureFlags( + resolveFeatureFlag( + key, distinctId, groups, personProperties, groupProperties, - )?.get(key) + )?.results?.get(key) return flag?.variant ?: flag?.enabled ?: defaultValue } @@ -43,27 +105,203 @@ internal class PostHogFeatureFlags( personProperties: Map?, groupProperties: Map>?, ): Any? { - return getFeatureFlags( + if (distinctId == null) { + return defaultValue + } + return resolveFeatureFlag( + key, distinctId, groups, personProperties, groupProperties, - )?.get(key)?.metadata?.payload + )?.results?.get(key)?.metadata?.payload ?: defaultValue } - override fun getFeatureFlags( - distinctId: String?, + internal fun resolveFeatureFlag( + key: String, + distinctId: String, + groups: Map?, + personProperties: Map?, + groupProperties: Map>?, + onlyEvaluateLocally: Boolean = false, + ): FeatureFlagResultContext? { + val cachedFlags = + getFeatureFlagsFromCache(distinctId, groups, personProperties, groupProperties) + if (cachedFlags != null) { + config.logger.log("Feature flags cache hit for distinctId: $distinctId") + val flag = cachedFlags[key] + if (flag != null) { + return FeatureFlagResultContext( + results = mapOf(key to flag), + source = EvaluationSource.CACHE, + parameters = + FeatureFlagResolutionParameters( + groups = groups, + personProperties = personProperties, + groupProperties = groupProperties, + onlyEvaluateLocally = onlyEvaluateLocally, + ), + ) + } + } + + if (localEvaluation) { + if (flagDefinitions == null && !definitionsLoaded) { + config.logger.log("Flag definitions not loaded, loading now") + loadFeatureFlagDefinitions() + } + + val flagDef = flagDefinitions?.get(key) + if (flagDef != null) { + try { + config.logger.log("Attempting local evaluation for flag '$key' for distinctId: $distinctId") + val props = (personProperties ?: emptyMap()).toMutableMap() + + val result = + computeFlagLocally( + key = key, + distinctId = distinctId, + personProperties = props, + groups = groups, + groupProperties = groupProperties, + ) + + val flag = buildFeatureFlagFromResult(key, result, flagDef) + config.logger.log("Local evaluation successful for flag '$key'") + return FeatureFlagResultContext( + results = mapOf(key to flag), + source = EvaluationSource.LOCAL, + parameters = + FeatureFlagResolutionParameters( + groups = groups, + personProperties = personProperties, + groupProperties = groupProperties, + onlyEvaluateLocally = onlyEvaluateLocally, + ), + ) + } catch (e: InconclusiveMatchException) { + config.logger.log("Local evaluation inconclusive for flag '$key': ${e.message}") + if (onlyEvaluateLocally) { + return null + } + // Fall through to remote evaluation + } catch (e: Throwable) { + config.logger.log("Local evaluation failed for flag '$key': ${e.message}") + if (onlyEvaluateLocally) { + return null + } + // Fall through to remote evaluation + } + } + } else if (onlyEvaluateLocally) { + return null + } + + // Local evaluation not available or failed - fall back to API + // Fetch and cache all flags, then return the specific one + config.logger.log("Feature flag cache miss for distinctId: $distinctId, calling API") + val remoteFlags = + getFeatureFlagsFromRemote( + distinctId, + groups, + personProperties, + groupProperties, + ) + if (remoteFlags.flags != null) { + val flag = remoteFlags.flags[key] + if (flag != null) { + return FeatureFlagResultContext( + results = mapOf(key to flag), + source = EvaluationSource.REMOTE, + requestId = remoteFlags.requestId, + parameters = + FeatureFlagResolutionParameters( + groups = groups, + personProperties = personProperties, + groupProperties = groupProperties, + onlyEvaluateLocally = onlyEvaluateLocally, + ), + ) + } + } + return null + } + + private fun getFeatureFlagsFromCache( + distinctId: String, groups: Map?, personProperties: Map?, groupProperties: Map>?, ): Map? { - if (distinctId == null) { - config.logger.log("getFeatureFlags called but no distinctId available for API call") + val cacheKey = + FeatureFlagCacheKey( + distinctId = distinctId, + groups = groups, + personProperties = personProperties, + groupProperties = groupProperties, + ) + + return cache.get(cacheKey) + } + + private fun getFeatureFlagsFromLocalEvaluation( + distinctId: String, + groups: Map?, + personProperties: Map?, + groupProperties: Map>?, + onlyEvaluateLocally: Boolean = false, + ): Map? { + if (!localEvaluation) { return null } - // Create cache key from parameters + if (flagDefinitions == null && !definitionsLoaded) { + config.logger.log("Flag definitions not loaded, loading now") + loadFeatureFlagDefinitions() + } + + val currentFlagDefinitions = flagDefinitions + if (currentFlagDefinitions == null) { + return null + } + + config.logger.log("Attempting local evaluation for distinctId: $distinctId") + val localFlags = mutableMapOf() + val props = (personProperties ?: emptyMap()).toMutableMap() + + // Evaluate all flags locally + for ((key, flagDef) in currentFlagDefinitions) { + try { + val result = + computeFlagLocally( + key = key, + distinctId = distinctId, + personProperties = props, + groups = groups, + groupProperties = groupProperties, + ) + + localFlags[key] = buildFeatureFlagFromResult(key, result, flagDef) + } catch (e: InconclusiveMatchException) { + config.logger.log("Local evaluation inconclusive for flag '$key': ${e.message}") + if (!onlyEvaluateLocally) { + // Allow fallback to remote evaluation + return null + } + } + } + + config.logger.log("Local evaluation successful for ${localFlags.size} flags") + return localFlags + } + + private fun getFeatureFlagsFromRemote( + distinctId: String, + groups: Map?, + personProperties: Map?, + groupProperties: Map>?, + ): RemoteFeatureFlagsResponse { val cacheKey = FeatureFlagCacheKey( distinctId = distinctId, @@ -72,28 +310,300 @@ internal class PostHogFeatureFlags( groupProperties = groupProperties, ) - // Check cache first val cachedFlags = cache.get(cacheKey) if (cachedFlags != null) { - config.logger.log("Feature flags cache hit for distinctId: $distinctId") - return cachedFlags + return RemoteFeatureFlagsResponse(flags = cachedFlags, requestId = null) } - // Cache miss - config.logger.log("Feature flags cache miss for distinctId: $distinctId") return try { val response = api.flags(distinctId, null, groups, personProperties, groupProperties) val flags = response?.flags cache.put(cacheKey, flags) - flags + RemoteFeatureFlagsResponse(flags = flags, requestId = response?.requestId) } catch (e: Throwable) { - config.logger.log("Loading feature flags failed: $e") - null + config.logger.log("Loading remote feature flags failed: $e") + RemoteFeatureFlagsResponse(flags = null, requestId = null) } } + override fun getFeatureFlags( + distinctId: String?, + groups: Map?, + personProperties: Map?, + groupProperties: Map>?, + ): Map? { + val result = + resolveFeatureFlags( + distinctId, + groups, + personProperties, + groupProperties, + ) + return result?.results + } + + internal fun resolveFeatureFlags( + distinctId: String?, + groups: Map?, + personProperties: Map?, + groupProperties: Map>?, + onlyEvaluateLocally: Boolean = false, + ): FeatureFlagResultContext? { + if (distinctId == null) { + config.logger.log("getFeatureFlags called but no distinctId available for API call") + return null + } + + val cached = getFeatureFlagsFromCache(distinctId, groups, personProperties, groupProperties) + if (cached != null) { + return FeatureFlagResultContext( + results = cached, + source = EvaluationSource.CACHE, + exhaustive = true, + ) + } + + // If no cached flags, try local evaluation + val localFlags = + getFeatureFlagsFromLocalEvaluation( + distinctId, + groups, + personProperties, + groupProperties, + onlyEvaluateLocally = onlyEvaluateLocally, + ) + if (localFlags != null) { + return FeatureFlagResultContext( + results = localFlags, + source = EvaluationSource.LOCAL, + exhaustive = true, + ) + } + + // Finally, fall back to remote fetch + val result = + getFeatureFlagsFromRemote(distinctId, groups, personProperties, groupProperties) + if (result.flags != null) { + return FeatureFlagResultContext( + results = result.flags, + source = EvaluationSource.REMOTE, + requestId = result.requestId, + exhaustive = true, + ) + } + + // Everything failed + return null + } + override fun clear() { cache.clear() config.logger.log("Feature flags cache cleared") } + + override fun shutDown() { + stopPoller() + } + + /** + * Load feature flag definitions from the API for local evaluation + */ + private fun loadFeatureFlagDefinitions() { + if (!localEvaluation || personalApiKey == null) { + return + } + + synchronized(this) { + if (definitionsLoaded) { + config.logger.log("Definitions already loaded, skipping") + return + } + + try { + config.logger.log("Loading feature flags for local evaluation") + val apiResponse = api.localEvaluation(personalApiKey) + + if (apiResponse != null) { + // apiResponse is now LocalEvaluationResponse with properly typed models + featureFlags = apiResponse.flags + flagDefinitions = apiResponse.flags?.associateBy { it.key } + cohorts = apiResponse.cohorts + groupTypeMapping = apiResponse.groupTypeMapping + + config.logger.log("Loaded ${apiResponse.flags?.size ?: 0} feature flags for local evaluation") + + definitionsLoaded = true + try { + onFeatureFlags?.loaded() + } catch (e: Throwable) { + config.logger.log("Error in onFeatureFlags callback: ${e.message}") + } + } + } catch (e: Throwable) { + config.logger.log("Failed to load feature flags for local evaluation: ${e.message}") + } + } + } + + /** + * Convert evaluation result to FeatureFlag object + */ + private fun buildFeatureFlagFromResult( + key: String, + result: Any?, + flagDef: FlagDefinition, + ): FeatureFlag { + val (enabled, variant) = + when (result) { + is String -> true to result + is Boolean -> result to null + else -> false to null + } + + val payload = + if (result != null) { + flagDef.filters.payloads?.get(result.toString())?.toString() + } else { + null + } + + return FeatureFlag( + key = key, + enabled = enabled, + variant = variant, + metadata = + com.posthog.internal.FeatureFlagMetadata( + id = flagDef.id, + payload = payload, + version = flagDef.version, + ), + reason = null, + ) + } + + /** + * Start the poller for local evaluation if enabled + */ + private fun startPoller() { + if (!localEvaluation) { + return + } + + if (personalApiKey == null) { + config.logger.log("Local evaluation enabled but no personal API key provided") + return + } + + synchronized(this) { + if (poller == null) { + poller = + LocalEvaluationPoller( + config = config, + pollIntervalSeconds = pollIntervalSeconds, + execute = { loadFeatureFlagDefinitions() }, + ) + poller?.start() + } + } + } + + /** + * Stop the local evaluation poller if it is running + */ + private fun stopPoller() { + synchronized(this) { + poller?.stop() + poller = null + } + } + + /** + * Appends feature flag properties to event properties + */ + internal fun appendFlagEventProperties( + distinctId: String, + properties: MutableMap?, + groups: Map?, + options: com.posthog.server.PostHogSendFeatureFlagOptions?, + ) { + if (options == null || properties == null) { + return + } + + val response = + resolveFeatureFlags( + distinctId, + groups, + options.personProperties, + options.groupProperties, + options.onlyEvaluateLocally, + ) + + response?.results?.values?.let { + val activeFeatureFlags = mutableListOf() + it.forEach { flag -> + val flagValue = flag.variant ?: flag.enabled + properties["\$feature/${flag.key}"] = flagValue + if (flagValue != false) { + activeFeatureFlags.add(flag.key) + } + } + properties["\$active_feature_flags"] = activeFeatureFlags.toList() + } + } + + /** + * Compute a flag locally using the evaluation engine + */ + private fun computeFlagLocally( + key: String, + distinctId: String, + groups: Map?, + personProperties: Map?, + groupProperties: Map>?, + ): Any? { + val flags = this.flagDefinitions ?: return null + val flag = flags[key] ?: return null + + if (!flag.active) { + return false + } + + // Check if this is a group-based flag + val aggregationGroupIndex = flag.filters.aggregationGroupTypeIndex + + val (evaluationId, evaluationProperties) = + if (aggregationGroupIndex != null) { + // Group-based flag - evaluate at group level + val groupTypeName = groupTypeMapping?.get(aggregationGroupIndex.toString()) + + if (groupTypeName == null) { + config.logger.log("Unknown group type index $aggregationGroupIndex for flag '$key'") + throw InconclusiveMatchException("Flag has unknown group type index") + } + + val groupKey = groups?.get(groupTypeName) + if (groupKey == null) { + // Group not provided - flag is off, don't failover to API + config.logger.log("Can't compute group flag '$key' without group '$groupTypeName'") + return false + } + + // Use group's key and properties for evaluation + Pair(groupKey, groupProperties) + } else { + // Person-based flag - use person's ID and properties + Pair(distinctId, personProperties) + } + + val evaluationCache = mutableMapOf() + return evaluator.matchFeatureFlagProperties( + flag = flag, + distinctId = evaluationId, + properties = evaluationProperties ?: emptyMap(), + cohortProperties = cohorts ?: emptyMap(), + flagsByKey = flags, + evaluationCache = evaluationCache, + ) + } } diff --git a/posthog-server/src/test/java/com/posthog/server/PostHogConfigTest.kt b/posthog-server/src/test/java/com/posthog/server/PostHogConfigTest.kt index 7797c41c..2beccfaf 100644 --- a/posthog-server/src/test/java/com/posthog/server/PostHogConfigTest.kt +++ b/posthog-server/src/test/java/com/posthog/server/PostHogConfigTest.kt @@ -476,4 +476,38 @@ internal class PostHogConfigTest { assertEquals(coreConfig1.apiKey, coreConfig2.apiKey) assertEquals(coreConfig1.host, coreConfig2.host) } + + @Test + fun `builder personalApiKey enables localEvaluation when not explicitly set`() { + val config = + PostHogConfig.builder(TEST_API_KEY) + .personalApiKey("test-personal-api-key") + .build() + + assertEquals("test-personal-api-key", config.personalApiKey) + assertEquals(true, config.localEvaluation) + } + + @Test + fun `builder personalApiKey with null does not enable localEvaluation when not explicitly set`() { + val config = + PostHogConfig.builder(TEST_API_KEY) + .personalApiKey(null) + .build() + + assertNull(config.personalApiKey) + assertEquals(false, config.localEvaluation) + } + + @Test + fun `builder personalApiKey does not override explicit localEvaluation false`() { + val config = + PostHogConfig.builder(TEST_API_KEY) + .localEvaluation(false) + .personalApiKey("test-personal-api-key") + .build() + + assertEquals("test-personal-api-key", config.personalApiKey) + assertEquals(false, config.localEvaluation) + } } diff --git a/posthog-server/src/test/java/com/posthog/server/PostHogTest.kt b/posthog-server/src/test/java/com/posthog/server/PostHogTest.kt index 0c73ee92..b545d305 100644 --- a/posthog-server/src/test/java/com/posthog/server/PostHogTest.kt +++ b/posthog-server/src/test/java/com/posthog/server/PostHogTest.kt @@ -1,34 +1,12 @@ package com.posthog.server -import com.posthog.PostHogStatelessInterface -import org.mockito.kotlin.mock -import org.mockito.kotlin.verify -import org.mockito.kotlin.whenever -import java.util.Date +import okhttp3.mockwebserver.MockResponse import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFalse import kotlin.test.assertNull internal class PostHogTest { - private fun createMockStateless(): PostHogStatelessInterface { - return mock() - } - - private fun createPostHogWithMock(mockInstance: PostHogStatelessInterface): PostHog { - val postHog = PostHog() - - // We need to mock PostHogStateless.with() to return our mock - // Since we can't easily mock static methods, we'll test the delegation assuming setup works - - // Use reflection to set the private instance field for testing - val instanceField = PostHog::class.java.getDeclaredField("instance") - instanceField.isAccessible = true - instanceField.set(postHog, mockInstance) - - return postHog - } - @Test fun `setup creates PostHogStateless instance with core config`() { val config = PostHogConfig(apiKey = TEST_API_KEY) @@ -41,226 +19,6 @@ internal class PostHogTest { // but we can verify behavior through other methods } - @Test - fun `close delegates to instance close`() { - val mockInstance = createMockStateless() - val postHog = createPostHogWithMock(mockInstance) - - postHog.close() - - verify(mockInstance).close() - } - - @Test - fun `close handles null instance gracefully`() { - val postHog = PostHog() - - // Should not throw when instance is null - postHog.close() - } - - @Test - fun `identify delegates to instance with all parameters`() { - val mockInstance = createMockStateless() - val postHog = createPostHogWithMock(mockInstance) - - val userProperties = mapOf("name" to "John", "age" to 30) - val userPropertiesSetOnce = mapOf("first_login" to true) - - postHog.identify("user123", userProperties, userPropertiesSetOnce) - - verify(mockInstance).identify("user123", userProperties, userPropertiesSetOnce) - } - - @Test - fun `identify handles null instance gracefully`() { - val postHog = PostHog() - - // Should not throw when instance is null - postHog.identify("user123", mapOf("name" to "John"), null) - } - - @Test - fun `flush delegates to instance`() { - val mockInstance = createMockStateless() - val postHog = createPostHogWithMock(mockInstance) - - postHog.flush() - - verify(mockInstance).flush() - } - - @Test - fun `flush handles null instance gracefully`() { - val postHog = PostHog() - - // Should not throw when instance is null - postHog.flush() - } - - @Test - fun `debug delegates to instance`() { - val mockInstance = createMockStateless() - val postHog = createPostHogWithMock(mockInstance) - - postHog.debug(true) - - verify(mockInstance).debug(true) - } - - @Test - fun `debug handles null instance gracefully`() { - val postHog = PostHog() - - // Should not throw when instance is null - postHog.debug(false) - } - - @Test - fun `capture delegates to instance captureStateless with all parameters`() { - val mockInstance = createMockStateless() - val postHog = createPostHogWithMock(mockInstance) - - val properties = mapOf("page" to "home") - val userProperties = mapOf("plan" to "premium") - val userPropertiesSetOnce = mapOf("signup_date" to "2023-01-01") - val groups = mapOf("organization" to "acme") - - postHog.capture( - distinctId = "user123", - event = "page_view", - properties = properties, - userProperties = userProperties, - userPropertiesSetOnce = userPropertiesSetOnce, - groups = groups, - ) - - verify(mockInstance).captureStateless( - "page_view", - "user123", - properties, - userProperties, - userPropertiesSetOnce, - groups, - ) - } - - @Test - fun `capture handles null instance gracefully`() { - val postHog = PostHog() - - // Should not throw when instance is null - postHog.capture("user123", "test_event", mapOf("key" to "value")) - } - - @Test - fun `isFeatureEnabled delegates to instance and returns result`() { - val mockInstance = createMockStateless() - val postHog = createPostHogWithMock(mockInstance) - - whenever(mockInstance.isFeatureEnabledStateless("user123", "feature_key", true)) - .thenReturn(false) - - val result = postHog.isFeatureEnabled("user123", "feature_key", true) - - verify(mockInstance).isFeatureEnabledStateless("user123", "feature_key", true) - assertFalse(result) - } - - @Test - fun `isFeatureEnabled returns false when instance is null`() { - val postHog = PostHog() - - val result = postHog.isFeatureEnabled("user123", "feature_key", true) - - assertFalse(result) - } - - @Test - fun `getFeatureFlag delegates to instance and returns result`() { - val mockInstance = createMockStateless() - val postHog = createPostHogWithMock(mockInstance) - - whenever(mockInstance.getFeatureFlagStateless("user123", "feature_key", "default")) - .thenReturn("variant_a") - - val result = postHog.getFeatureFlag("user123", "feature_key", "default") - - verify(mockInstance).getFeatureFlagStateless("user123", "feature_key", "default") - assertEquals("variant_a", result) - } - - @Test - fun `getFeatureFlag returns null when instance is null`() { - val postHog = PostHog() - - val result = postHog.getFeatureFlag("user123", "feature_key", "default") - - assertNull(result) - } - - @Test - fun `getFeatureFlagPayload delegates to instance and returns result`() { - val mockInstance = createMockStateless() - val postHog = createPostHogWithMock(mockInstance) - - val payloadData = mapOf("config" to "value") - whenever(mockInstance.getFeatureFlagPayloadStateless("user123", "feature_key", null)) - .thenReturn(payloadData) - - val result = postHog.getFeatureFlagPayload("user123", "feature_key", null) - - verify(mockInstance).getFeatureFlagPayloadStateless("user123", "feature_key", null) - assertEquals(payloadData, result) - } - - @Test - fun `getFeatureFlagPayload returns null when instance is null`() { - val postHog = PostHog() - - val result = postHog.getFeatureFlagPayload("user123", "feature_key", "default") - - assertNull(result) - } - - @Test - fun `group delegates to instance groupStateless`() { - val mockInstance = createMockStateless() - val postHog = createPostHogWithMock(mockInstance) - - val groupProperties = mapOf("plan" to "enterprise", "size" to 100) - - postHog.group("user123", "organization", "acme_corp", groupProperties) - - verify(mockInstance).groupStateless("user123", "organization", "acme_corp", groupProperties) - } - - @Test - fun `group handles null instance gracefully`() { - val postHog = PostHog() - - // Should not throw when instance is null - postHog.group("user123", "organization", "acme_corp", mapOf("size" to 10)) - } - - @Test - fun `alias delegates to instance aliasStateless`() { - val mockInstance = createMockStateless() - val postHog = createPostHogWithMock(mockInstance) - - postHog.alias("user123", "john_doe") - - verify(mockInstance).aliasStateless("user123", "john_doe") - } - - @Test - fun `alias handles null instance gracefully`() { - val postHog = PostHog() - - // Should not throw when instance is null - postHog.alias("user123", "john_doe") - } - @Test fun `with companion method creates and sets up PostHog instance`() { val config = PostHogConfig(apiKey = TEST_API_KEY, debug = true) @@ -289,76 +47,6 @@ internal class PostHogTest { assertEquals(PostHog::class, postHogInterface::class) } - @Test - fun `capture with null parameters delegates correctly`() { - val mockInstance = createMockStateless() - val postHog = createPostHogWithMock(mockInstance) - - postHog.capture( - distinctId = "user123", - event = "simple_event", - properties = null, - userProperties = null, - userPropertiesSetOnce = null, - groups = null, - ) - - verify(mockInstance).captureStateless( - "simple_event", - "user123", - null, - null, - null, - null, - ) - } - - @Test - fun `identify with null parameters delegates correctly`() { - val mockInstance = createMockStateless() - val postHog = createPostHogWithMock(mockInstance) - - postHog.identify("user123", null, null) - - verify(mockInstance).identify("user123", null, null) - } - - @Test - fun `group with null groupProperties delegates correctly`() { - val mockInstance = createMockStateless() - val postHog = createPostHogWithMock(mockInstance) - - postHog.group("user123", "organization", "acme_corp", null) - - verify(mockInstance).groupStateless("user123", "organization", "acme_corp", null) - } - - @Test - fun `feature flag methods handle different return types`() { - val mockInstance = createMockStateless() - val postHog = createPostHogWithMock(mockInstance) - - // Test boolean feature flag - whenever(mockInstance.isFeatureEnabledStateless("user123", "bool_flag", false)) - .thenReturn(true) - - // Test string feature flag - whenever(mockInstance.getFeatureFlagStateless("user123", "string_flag", null)) - .thenReturn("variant_b") - - // Test numeric feature flag - whenever(mockInstance.getFeatureFlagStateless("user123", "numeric_flag", 0)) - .thenReturn(42) - - val boolResult = postHog.isFeatureEnabled("user123", "bool_flag", false) - val stringResult = postHog.getFeatureFlag("user123", "string_flag", null) - val numericResult = postHog.getFeatureFlag("user123", "numeric_flag", 0) - - assertEquals(true, boolResult) - assertEquals("variant_b", stringResult) - assertEquals(42, numericResult) - } - @Test fun `all methods work correctly after setup`() { val config = PostHogConfig(apiKey = TEST_API_KEY) @@ -388,317 +76,107 @@ internal class PostHogTest { postHog.close() } - // Timestamp Tests @Test - fun `capture with timestamp delegates to instance captureStateless with timestamp`() { - val mockInstance = createMockStateless() - val postHog = createPostHogWithMock(mockInstance) - - val timestamp = Date(1234567890L) - val properties = mapOf("page" to "home") - val userProperties = mapOf("plan" to "premium") - val userPropertiesSetOnce = mapOf("signup_date" to "2023-01-01") - val groups = mapOf("organization" to "acme") - - postHog.capture( - distinctId = "user123", - event = "page_view", - properties = properties, - userProperties = userProperties, - userPropertiesSetOnce = userPropertiesSetOnce, - groups = groups, - timestamp = timestamp, - ) + fun `capture with sendFeatureFlags appends flags to event properties`() { + val flagsResponse = + """ + { + "flags": { + "flag1": { + "key": "flag1", + "enabled": true, + "variant": "variant_a", + "metadata": { "version": 1, "payload": null, "id": 1 }, + "reason": { "kind": "condition_match", "condition_match_type": "Test", "condition_index": 0 } + }, + "flag2": { + "key": "flag2", + "enabled": true, + "variant": null, + "metadata": { "version": 1, "payload": null, "id": 2 }, + "reason": { "kind": "condition_match", "condition_match_type": "Test", "condition_index": 0 } + }, + "flag3": { + "key": "flag3", + "enabled": false, + "variant": null, + "metadata": { "version": 1, "payload": null, "id": 3 }, + "reason": { "kind": "condition_match", "condition_match_type": "Test", "condition_index": 0 } + } + } + } + """.trimIndent() + + val mockServer = + createMockHttp( + jsonResponse(flagsResponse), + MockResponse().setResponseCode(200).setBody("{}"), + ) + val url = mockServer.url("/") - verify(mockInstance).captureStateless( - "page_view", - "user123", - properties, - userProperties, - userPropertiesSetOnce, - groups, - timestamp, - ) - } + val config = PostHogConfig(apiKey = TEST_API_KEY, host = url.toString()) + val postHog = PostHog() + postHog.setup(config) - @Test - fun `capture with null timestamp delegates correctly`() { - val mockInstance = createMockStateless() - val postHog = createPostHogWithMock(mockInstance) + // Capture with sendFeatureFlags + val sendFeatureFlagOptions = + PostHogSendFeatureFlagOptions.builder() + .personProperty("email", "test@example.com") + .build() postHog.capture( distinctId = "user123", event = "test_event", - properties = mapOf("key" to "value"), + properties = mapOf("prop" to "value"), + userProperties = null, + userPropertiesSetOnce = null, + groups = null, timestamp = null, + sendFeatureFlags = sendFeatureFlagOptions, ) - verify(mockInstance).captureStateless( - "test_event", - "user123", - mapOf("key" to "value"), - null, - null, - null, - null, - ) - } - - @Test - fun `capture with PostHogCaptureOptions containing timestamp passes it through`() { - val mockInstance = createMockStateless() - val postHog = createPostHogWithMock(mockInstance) - - val timestamp = Date(1234567890L) - val options = - PostHogCaptureOptions.builder() - .property("page", "home") - .userProperty("plan", "premium") - .timestamp(timestamp) - .build() - - postHog.capture("user123", "page_view", options) - - verify(mockInstance).captureStateless( - "page_view", - "user123", - options.properties, - options.userProperties, - options.userPropertiesSetOnce, - options.groups, - timestamp, - ) - } - - @Test - fun `capture with PostHogCaptureOptions without timestamp passes null timestamp`() { - val mockInstance = createMockStateless() - val postHog = createPostHogWithMock(mockInstance) - - val options = - PostHogCaptureOptions.builder() - .property("page", "home") - .build() - - postHog.capture("user123", "page_view", options) - - verify(mockInstance).captureStateless( - "page_view", - "user123", - options.properties, - options.userProperties, - options.userPropertiesSetOnce, - options.groups, - null, - ) - } - - @Test - fun `isFeatureEnabled propagates parameters as expected`() { - val mockInstance = createMockStateless() - val postHog = createPostHogWithMock(mockInstance) - - val groups = mapOf("organization" to "org_123") - val personProperties = mapOf("plan" to "premium") - val groupProperties = mapOf("org_123" to mapOf("size" to "large")) - - whenever( - mockInstance.isFeatureEnabledStateless( - "user123", - "feature_key", - true, - groups, - personProperties, - groupProperties, - ), - ).thenReturn(false) - - val result = - postHog.isFeatureEnabled( - "user123", - "feature_key", - true, - groups, - personProperties, - groupProperties, - ) - - verify(mockInstance).isFeatureEnabledStateless( - "user123", - "feature_key", - true, - groups, - personProperties, - groupProperties, - ) - assertFalse(result) - } - - @Test - fun `isFeatureEnabled with PostHogFeatureFlagOptions propagates parameters as expected`() { - val mockInstance = createMockStateless() - val postHog = createPostHogWithMock(mockInstance) - - val options = - PostHogFeatureFlagOptions.builder() - .defaultValue(true) - .group("organization", "org_123") - .personProperty("plan", "premium") - .groupProperty("org_123", "size", "large") - .build() - - whenever( - mockInstance.isFeatureEnabledStateless( - "user123", - "feature_key", - true, - options.groups, - options.personProperties, - options.groupProperties, - ), - ).thenReturn(false) - - val result = postHog.isFeatureEnabled("user123", "feature_key", options) - - verify(mockInstance).isFeatureEnabledStateless( - "user123", - "feature_key", - true, - options.groups, - options.personProperties, - options.groupProperties, - ) - assertFalse(result) - } - - @Test - fun `isFeatureEnabled with PostHogFeatureFlagOptions handles non-boolean default value`() { - val mockInstance = createMockStateless() - val postHog = createPostHogWithMock(mockInstance) - - val options = - PostHogFeatureFlagOptions.builder() - .defaultValue("some_string") - .group("organization", "org_123") - .personProperty("plan", "premium") - .groupProperty("org_123", "size", "large") - .build() - - postHog.isFeatureEnabled("user123", "feature_key", options) - - verify(mockInstance).isFeatureEnabledStateless( - "user123", - "feature_key", - false, - options.groups, - options.personProperties, - options.groupProperties, - ) - } + postHog.flush() - @Test - fun `getFeatureFlag propagates parameters as expected`() { - val mockInstance = createMockStateless() - val postHog = createPostHogWithMock(mockInstance) - - val groups = mapOf("organization" to "org_123") - val personProperties = mapOf("plan" to "premium") - val groupProperties = mapOf("org_123" to mapOf("size" to "large")) - - postHog.getFeatureFlag( - "user123", - "feature_key", - "default", - groups, - personProperties, - groupProperties, - ) + mockServer.takeRequest() // flags request + val batchRequest = mockServer.takeRequest() - verify(mockInstance).getFeatureFlagStateless( - "user123", - "feature_key", - "default", - groups, - personProperties, - groupProperties, - ) - } + // Decompress the batch body if gzipped + val batchBody = + if (batchRequest.getHeader("Content-Encoding") == "gzip") { + batchRequest.body.unGzip() + } else { + batchRequest.body.readUtf8() + } - @Test - fun `getFeatureFlag with PostHogFeatureFlagOptions propagates parameters as expected`() { - val mockInstance = createMockStateless() - val postHog = createPostHogWithMock(mockInstance) - - val options = - PostHogFeatureFlagOptions.builder() - .defaultValue("default") - .group("organization", "org_123") - .personProperty("plan", "premium") - .groupProperty("org_123", "size", "large") - .build() + // Parse the batch request JSON + val gson = com.google.gson.Gson() - postHog.getFeatureFlag("user123", "feature_key", options) + @Suppress("UNCHECKED_CAST") + val batchData = gson.fromJson(batchBody, Map::class.java) as Map - verify(mockInstance).getFeatureFlagStateless( - "user123", - "feature_key", - "default", - options.groups, - options.personProperties, - options.groupProperties, - ) - } + @Suppress("UNCHECKED_CAST") + val batch = batchData["batch"] as List> - @Test - fun `getFeatureFlagPayload propagates parameters as expected`() { - val mockInstance = createMockStateless() - val postHog = createPostHogWithMock(mockInstance) - - val groups = mapOf("organization" to "org_123") - val personProperties = mapOf("plan" to "premium") - val groupProperties = mapOf("org_123" to mapOf("size" to "large")) - - postHog.getFeatureFlagPayload( - "user123", - "feature_key", - null, - groups, - personProperties, - groupProperties, - ) + assertEquals(1, batch.size) - verify(mockInstance).getFeatureFlagPayloadStateless( - "user123", - "feature_key", - null, - groups, - personProperties, - groupProperties, - ) - } + val event = batch[0] + assertEquals("test_event", event["event"]) + assertEquals("user123", event["distinct_id"]) - @Test - fun `getFeatureFlagPayload with PostHogFeatureFlagOptions propagates parameters as expected`() { - val mockInstance = createMockStateless() - val postHog = createPostHogWithMock(mockInstance) - - val options = - PostHogFeatureFlagOptions.builder() - .defaultValue(null) - .group("organization", "org_123") - .personProperty("plan", "premium") - .groupProperty("org_123", "size", "large") - .build() + @Suppress("UNCHECKED_CAST") + val properties = event["properties"] as Map + assertEquals("value", properties["prop"]) + assertEquals("variant_a", properties["\$feature/flag1"]) + assertEquals(true, properties["\$feature/flag2"]) + assertEquals(false, properties["\$feature/flag3"]) - postHog.getFeatureFlagPayload("user123", "feature_key", options) + @Suppress("UNCHECKED_CAST") + val activeFlags = properties["\$active_feature_flags"] as? List + assertEquals(2, activeFlags?.size) + assertEquals(true, activeFlags?.contains("flag1")) + assertEquals(true, activeFlags?.contains("flag2")) - verify(mockInstance).getFeatureFlagPayloadStateless( - "user123", - "feature_key", - null, - options.groups, - options.personProperties, - options.groupProperties, - ) + mockServer.shutdown() + postHog.close() } } diff --git a/posthog-server/src/test/java/com/posthog/server/Utils.kt b/posthog-server/src/test/java/com/posthog/server/Utils.kt index 01b57a11..67a4bcbc 100644 --- a/posthog-server/src/test/java/com/posthog/server/Utils.kt +++ b/posthog-server/src/test/java/com/posthog/server/Utils.kt @@ -262,3 +262,49 @@ public fun createMockIntegration(): com.posthog.PostHogIntegration { // Using default implementations from interface } } + +/** + * Creates a local evaluation API response for testing + */ +public fun createLocalEvaluationResponse( + flagKey: String, + aggregationGroupTypeIndex: Int? = null, + rolloutPercentage: Int = 100, +): String { + val aggregationGroupJson = + if (aggregationGroupTypeIndex != null) { + "\"aggregation_group_type_index\": $aggregationGroupTypeIndex," + } else { + "" + } + + return """ + { + "flags": [ + { + "id": 1, + "name": "$flagKey", + "key": "$flagKey", + "active": true, + "filters": { + $aggregationGroupJson + "groups": [ + { + "properties": [], + "rollout_percentage": $rolloutPercentage + } + ] + }, + "version": 1 + } + ], + "group_type_mapping": { + "0": "account", + "1": "instance", + "2": "organization", + "3": "project" + }, + "cohorts": {} + } + """.trimIndent() +} diff --git a/posthog-server/src/test/java/com/posthog/server/internal/FlagEvaluatorTest.kt b/posthog-server/src/test/java/com/posthog/server/internal/FlagEvaluatorTest.kt new file mode 100644 index 00000000..15aa6beb --- /dev/null +++ b/posthog-server/src/test/java/com/posthog/server/internal/FlagEvaluatorTest.kt @@ -0,0 +1,1865 @@ +package com.posthog.server.internal + +import com.google.gson.reflect.TypeToken +import com.posthog.PostHogConfig +import com.posthog.internal.FlagDefinition +import com.posthog.internal.FlagProperty +import com.posthog.internal.PropertyGroup +import com.posthog.internal.PropertyOperator +import com.posthog.internal.PropertyType +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import java.time.ZonedDateTime + +internal class FlagEvaluatorTest { + private lateinit var config: PostHogConfig + private lateinit var evaluator: FlagEvaluator + + @Before + internal fun setUp() { + config = PostHogConfig(apiKey = "test-key") + evaluator = FlagEvaluator(config) + } + + @Test + internal fun testHashConsistency() { + // Test that hash function returns consistent values for same inputs + val hash1 = evaluator.getMatchingVariant(createSimpleFlag(), "user-123") + val hash2 = evaluator.getMatchingVariant(createSimpleFlag(), "user-123") + assertEquals(hash1, hash2) + } + + @Test + internal fun testMatchPropertyExact() { + val property = + FlagProperty( + key = "email", + propertyValue = "test@example.com", + propertyOperator = PropertyOperator.EXACT, + type = PropertyType.PERSON, + negation = false, + dependencyChain = null, + ) + val properties = mapOf("email" to "test@example.com") + assertTrue(evaluator.matchProperty(property, properties)) + } + + @Test + internal fun testMatchPropertyExactCaseInsensitive() { + val property = + FlagProperty( + key = "email", + propertyValue = "TEST@EXAMPLE.COM", + propertyOperator = PropertyOperator.EXACT, + type = PropertyType.PERSON, + negation = false, + dependencyChain = null, + ) + val properties = mapOf("email" to "test@example.com") + assertTrue(evaluator.matchProperty(property, properties)) + } + + @Test + internal fun testMatchPropertyExactUnicodeNormalization() { + // Test German ß (eszett) - should match "ss" after casefold + val propertyStrasse = + FlagProperty( + key = "location", + propertyValue = "Straße", + propertyOperator = PropertyOperator.EXACT, + type = PropertyType.PERSON, + negation = false, + dependencyChain = null, + ) + + // Should match lowercase ß + assertTrue(evaluator.matchProperty(propertyStrasse, mapOf("location" to "straße"))) + + // Should match "ss" (casefold normalization) + assertTrue(evaluator.matchProperty(propertyStrasse, mapOf("location" to "strasse"))) + + // Test long s (ſ) - should match regular s after casefold + val propertyLongS = + FlagProperty( + key = "star", + propertyValue = "ſun", + propertyOperator = PropertyOperator.EXACT, + type = PropertyType.PERSON, + negation = false, + dependencyChain = null, + ) + + // Should match regular s (casefold normalization) + assertTrue(evaluator.matchProperty(propertyLongS, mapOf("star" to "sun"))) + + // Should match exact long s + assertTrue(evaluator.matchProperty(propertyLongS, mapOf("star" to "ſun"))) + } + + @Test + internal fun testMatchPropertyExactUnicodeNormalizationWithList() { + // Test with list values + val property = + FlagProperty( + key = "location", + propertyValue = listOf("Straße", "München"), + propertyOperator = PropertyOperator.EXACT, + type = PropertyType.PERSON, + negation = false, + dependencyChain = null, + ) + + // Should match with casefold normalization + assertTrue(evaluator.matchProperty(property, mapOf("location" to "strasse"))) + assertTrue(evaluator.matchProperty(property, mapOf("location" to "munchen"))) + } + + @Test + internal fun testMatchPropertyExactList() { + val property = + FlagProperty( + key = "browser", + propertyValue = listOf("chrome", "firefox"), + propertyOperator = PropertyOperator.EXACT, + type = PropertyType.PERSON, + negation = false, + dependencyChain = null, + ) + val properties = mapOf("browser" to "chrome") + assertTrue(evaluator.matchProperty(property, properties)) + } + + @Test + internal fun testMatchPropertyIsNot() { + val property = + FlagProperty( + key = "email", + propertyValue = "other@example.com", + propertyOperator = PropertyOperator.IS_NOT, + type = PropertyType.PERSON, + negation = false, + dependencyChain = null, + ) + val properties = mapOf("email" to "test@example.com") + assertTrue(evaluator.matchProperty(property, properties)) + } + + @Test + internal fun testMatchPropertyIsSet() { + val property = + FlagProperty( + key = "email", + propertyValue = null, + propertyOperator = PropertyOperator.IS_SET, + type = PropertyType.PERSON, + negation = false, + dependencyChain = null, + ) + val properties = mapOf("email" to "test@example.com") + assertTrue(evaluator.matchProperty(property, properties)) + + val propertiesWithout = mapOf("name" to "Test") + try { + evaluator.matchProperty(property, propertiesWithout) + assertTrue("Should have thrown InconclusiveMatchException", false) + } catch (e: InconclusiveMatchException) { + // Expected + } + } + + @Test + internal fun testMatchPropertyIcontains() { + val property = + FlagProperty( + key = "email", + propertyValue = "example", + propertyOperator = PropertyOperator.ICONTAINS, + type = PropertyType.PERSON, + negation = false, + dependencyChain = null, + ) + val properties = mapOf("email" to "test@EXAMPLE.com") + assertTrue(evaluator.matchProperty(property, properties)) + } + + @Test + internal fun testMatchPropertyIcontainsTurkishI() { + // Test Turkish i normalization + // In Turkish locale, uppercase I → ı (dotless i) and lowercase i → İ (dotted I) + // The uppercase().lowercase() normalization should handle this + val property = + FlagProperty( + key = "city", + propertyValue = "Istanbul", + propertyOperator = PropertyOperator.ICONTAINS, + type = PropertyType.PERSON, + negation = false, + dependencyChain = null, + ) + + // Should match with different casing + assertTrue(evaluator.matchProperty(property, mapOf("city" to "istanbul"))) + assertTrue(evaluator.matchProperty(property, mapOf("city" to "ISTANBUL"))) + assertTrue(evaluator.matchProperty(property, mapOf("city" to "İstanbul"))) + } + + @Test + internal fun testMatchPropertyNotIcontains() { + val property = + FlagProperty( + key = "email", + propertyValue = "gmail", + propertyOperator = PropertyOperator.NOT_ICONTAINS, + type = PropertyType.PERSON, + negation = false, + dependencyChain = null, + ) + val properties = mapOf("email" to "test@example.com") + assertTrue(evaluator.matchProperty(property, properties)) + } + + @Test + internal fun testMatchPropertyRegex() { + val property = + FlagProperty( + key = "email", + propertyValue = ".*@example\\.com", + propertyOperator = PropertyOperator.REGEX, + type = PropertyType.PERSON, + negation = false, + dependencyChain = null, + ) + val properties = mapOf("email" to "test@example.com") + assertTrue(evaluator.matchProperty(property, properties)) + } + + @Test + internal fun testMatchPropertyNotRegex() { + val property = + FlagProperty( + key = "email", + propertyValue = ".*@gmail\\.com", + propertyOperator = PropertyOperator.NOT_REGEX, + type = PropertyType.PERSON, + negation = false, + dependencyChain = null, + ) + val properties = mapOf("email" to "test@example.com") + assertTrue(evaluator.matchProperty(property, properties)) + } + + @Test + internal fun testMatchPropertyGreaterThan() { + val property = + FlagProperty( + key = "age", + propertyValue = "18", + propertyOperator = PropertyOperator.GT, + type = PropertyType.PERSON, + negation = false, + dependencyChain = null, + ) + val properties = mapOf("age" to 25) + assertTrue(evaluator.matchProperty(property, properties)) + + val propertiesYounger = mapOf("age" to 15) + assertFalse(evaluator.matchProperty(property, propertiesYounger)) + } + + @Test + internal fun testMatchPropertyGreaterThanOrEqual() { + val property = + FlagProperty( + key = "age", + propertyValue = "18", + propertyOperator = PropertyOperator.GTE, + type = PropertyType.PERSON, + negation = false, + dependencyChain = null, + ) + val properties = mapOf("age" to 18) + assertTrue(evaluator.matchProperty(property, properties)) + } + + @Test + internal fun testMatchPropertyLessThan() { + val property = + FlagProperty( + key = "age", + propertyValue = "65", + propertyOperator = PropertyOperator.LT, + type = PropertyType.PERSON, + negation = false, + dependencyChain = null, + ) + val properties = mapOf("age" to 25) + assertTrue(evaluator.matchProperty(property, properties)) + } + + @Test + internal fun testMatchPropertyLessThanOrEqual() { + val property = + FlagProperty( + key = "age", + propertyValue = "65", + propertyOperator = PropertyOperator.LTE, + type = PropertyType.PERSON, + negation = false, + dependencyChain = null, + ) + val properties = mapOf("age" to 65) + assertTrue(evaluator.matchProperty(property, properties)) + } + + @Test + internal fun testMatchPropertyDateBefore() { + val property = + FlagProperty( + key = "signup_date", + propertyValue = "2024-01-01T00:00:00Z", + propertyOperator = PropertyOperator.IS_DATE_BEFORE, + type = PropertyType.PERSON, + negation = false, + dependencyChain = null, + ) + val properties = mapOf("signup_date" to "2023-06-01T00:00:00Z") + assertTrue(evaluator.matchProperty(property, properties)) + } + + @Test + internal fun testMatchPropertyDateBeforeVariousFormats() { + // ISO date only (YYYY-MM-DD) + val propertyIsoDate = + FlagProperty( + key = "signup_date", + propertyValue = "2022-05-01", + propertyOperator = PropertyOperator.IS_DATE_BEFORE, + type = PropertyType.PERSON, + negation = false, + dependencyChain = null, + ) + assertTrue(evaluator.matchProperty(propertyIsoDate, mapOf("signup_date" to "2022-03-01"))) + assertTrue(evaluator.matchProperty(propertyIsoDate, mapOf("signup_date" to "2022-04-30"))) + assertFalse(evaluator.matchProperty(propertyIsoDate, mapOf("signup_date" to "2022-05-30"))) + + // ISO datetime with timezone offset (with space) + val propertyWithSpace = + FlagProperty( + key = "key", + propertyValue = "2022-04-05 12:34:12 +01:00", + propertyOperator = PropertyOperator.IS_DATE_BEFORE, + type = PropertyType.PERSON, + negation = false, + dependencyChain = null, + ) + assertTrue( + evaluator.matchProperty( + propertyWithSpace, + mapOf("key" to "2022-04-05 12:34:11 +01:00"), + ), + ) + assertFalse( + evaluator.matchProperty( + propertyWithSpace, + mapOf("key" to "2022-04-05 12:34:13 +01:00"), + ), + ) + + // ISO datetime with timezone offset (without space) + val propertyNoSpace = + FlagProperty( + key = "key", + propertyValue = "2022-04-05 12:34:12+01:00", + propertyOperator = PropertyOperator.IS_DATE_BEFORE, + type = PropertyType.PERSON, + negation = false, + dependencyChain = null, + ) + assertTrue( + evaluator.matchProperty( + propertyNoSpace, + mapOf("key" to "2022-04-05 12:34:11+01:00"), + ), + ) + + // ISO datetime without timezone + val propertyNoTz = + FlagProperty( + key = "key", + propertyValue = "2022-05-01 00:00:00", + propertyOperator = PropertyOperator.IS_DATE_BEFORE, + type = PropertyType.PERSON, + negation = false, + dependencyChain = null, + ) + assertTrue(evaluator.matchProperty(propertyNoTz, mapOf("key" to "2022-04-30 22:00:00"))) + } + + @Test + internal fun testMatchPropertyDateAfter() { + val property = + FlagProperty( + key = "signup_date", + propertyValue = "2024-01-01T00:00:00Z", + propertyOperator = PropertyOperator.IS_DATE_AFTER, + type = PropertyType.PERSON, + negation = false, + dependencyChain = null, + ) + val properties = mapOf("signup_date" to "2024-06-01T00:00:00Z") + assertTrue(evaluator.matchProperty(property, properties)) + } + + @Test + internal fun testMatchPropertyRelativeDate() { + val property = + FlagProperty( + key = "last_seen", + propertyValue = "-7d", + propertyOperator = PropertyOperator.IS_DATE_AFTER, + type = PropertyType.PERSON, + negation = false, + dependencyChain = null, + ) + // Date from yesterday should be after 7 days ago + val yesterday = ZonedDateTime.now().minusDays(1) + val properties = mapOf("last_seen" to yesterday) + assertTrue(evaluator.matchProperty(property, properties)) + } + + @Test + internal fun testGetMatchingVariant() { + val flag = createMultiVariateFlag() + val variant1 = evaluator.getMatchingVariant(flag, "user-with-control") + val variant2 = evaluator.getMatchingVariant(flag, "user-with-test") + + // Verify that we get consistent variants + assertNotNull(variant1) + assertNotNull(variant2) + + // Same user should always get same variant + assertEquals(variant1, evaluator.getMatchingVariant(flag, "user-with-control")) + assertEquals(variant2, evaluator.getMatchingVariant(flag, "user-with-test")) + } + + @Test + internal fun testMatchFeatureFlagPropertiesSimpleMatch() { + val json = + """ + { + "id": 1, + "name": "Test Flag", + "key": "test-flag", + "active": true, + "filters": { + "groups": [ + { + "properties": [ + { + "key": "email", + "value": "test@example.com", + "operator": "exact", + "type": "person", + "negation": false + } + ], + "rollout_percentage": 100 + } + ] + }, + "version": 1 + } + """.trimIndent() + + val flag = config.serializer.gson.fromJson(json, FlagDefinition::class.java) + + val properties = mapOf("email" to "test@example.com") + val result = evaluator.matchFeatureFlagProperties(flag, "user-123", properties) + assertEquals(true, result) + } + + @Test + internal fun testMatchFeatureFlagPropertiesNoMatch() { + val json = + """ + { + "id": 1, + "name": "Test Flag", + "key": "test-flag", + "active": true, + "filters": { + "groups": [ + { + "properties": [ + { + "key": "email", + "value": "test@example.com", + "operator": "exact", + "type": "person", + "negation": false + } + ], + "rollout_percentage": 100 + } + ] + }, + "version": 1 + } + """.trimIndent() + + val flag = config.serializer.gson.fromJson(json, FlagDefinition::class.java) + + val properties = mapOf("email" to "other@example.com") + val result = evaluator.matchFeatureFlagProperties(flag, "user-123", properties) + assertEquals(false, result) + } + + @Test + internal fun testMatchFeatureFlagPropertiesWithRollout() { + val json = + """ + { + "id": 1, + "name": "Test Flag", + "key": "test-flag", + "active": true, + "filters": { + "groups": [ + { + "properties": [], + "rollout_percentage": 50 + } + ] + }, + "version": 1 + } + """.trimIndent() + + val flag = config.serializer.gson.fromJson(json, FlagDefinition::class.java) + + // Test multiple users to verify some match and some don't + var matchCount = 0 + for (i in 1..1000) { + val result = evaluator.matchFeatureFlagProperties(flag, "user-$i", emptyMap()) + if (result == true) matchCount++ + } + + assertTrue("Expected ~500 matches, got $matchCount", matchCount in 400..600) + } + + @Test + internal fun testMatchFeatureFlagPropertiesWithVariant() { + val flag = createMultiVariateFlag() + val result = evaluator.matchFeatureFlagProperties(flag, "user-123", emptyMap()) + + // Should return a variant string (control or test) + assertTrue(result is String) + assertTrue(result == "control" || result == "test") + } + + @Test + internal fun testMissingPropertyThrowsException() { + val property = + FlagProperty( + key = "missing_key", + propertyValue = "test", + propertyOperator = PropertyOperator.EXACT, + type = PropertyType.PERSON, + negation = false, + dependencyChain = null, + ) + val properties = mapOf("other_key" to "value") + + try { + evaluator.matchProperty(property, properties) + assertTrue("Should have thrown InconclusiveMatchException", false) + } catch (e: InconclusiveMatchException) { + assertTrue(e.message?.contains("without a given property value") ?: false) + } + } + + // Helper functions + + internal fun createSimpleFlag(): FlagDefinition { + val json = + """ + { + "id": 1, + "name": "Simple Flag", + "key": "simple-flag", + "active": true, + "filters": { + "groups": [ + { + "properties": [], + "rollout_percentage": 100 + } + ], + "multivariate": { + "variants": [ + { + "key": "control", + "rollout_percentage": 50.0 + }, + { + "key": "test", + "rollout_percentage": 50.0 + } + ] + } + }, + "version": 1 + } + """.trimIndent() + + return config.serializer.gson.fromJson(json, FlagDefinition::class.java) + } + + @Test + internal fun testMixedConditionsFlag() { + val flag = createMixedConditionsFlag() + val withoutSpaces = mapOf("email" to "example@example.com") + val resultWithoutSpaces = + evaluator.matchFeatureFlagProperties(flag, "user-123", withoutSpaces) + assertEquals(true, resultWithoutSpaces) + } + + @Test + internal fun testAllConditionsFlagExactMismatch() { + val flag = createMixedConditionsFlag() + + // Negative case: email does not match exact condition + val properties = mapOf("email" to "other@example.com") + val result = evaluator.matchFeatureFlagProperties(flag, "user-123", properties) + assertEquals(false, result) + } + + @Test + internal fun testAllConditionsFlagIsNotViolation() { + val flag = createMixedConditionsFlag() + + // Negative case: email matches is_not exclusion list + val properties = mapOf("email" to "not_example@example.com") + val result = evaluator.matchFeatureFlagProperties(flag, "user-123", properties) + assertEquals(false, result) + } + + @Test + internal fun testAllConditionsFlagIcontainsMismatch() { + val flag = createMixedConditionsFlag() + + // Negative case: email does not contain "example" + val properties = mapOf("email" to "test@test.com") + val result = evaluator.matchFeatureFlagProperties(flag, "user-123", properties) + assertEquals(false, result) + } + + @Test + internal fun testAllConditionsFlagNotIcontainsViolation() { + val flag = createMixedConditionsFlag() + + // Negative case: email contains ".net" + val properties = mapOf("email" to "example@example.net") + val result = evaluator.matchFeatureFlagProperties(flag, "user-123", properties) + assertEquals(false, result) + } + + @Test + internal fun testAllConditionsFlagRegexMismatch() { + val flag = createMixedConditionsFlag() + + // Negative case: email does not match regex pattern (invalid format) + val properties = mapOf("email" to "invalid-email-format") + val result = evaluator.matchFeatureFlagProperties(flag, "user-123", properties) + assertEquals(false, result) + } + + @Test + internal fun testAllConditionsFlagNotRegexViolation() { + val flag = createMixedConditionsFlag() + + // Negative case: email matches not_regex exclusion pattern + val properties = mapOf("email" to "example@example.com@yahoo.com") + val result = evaluator.matchFeatureFlagProperties(flag, "user-123", properties) + assertEquals(false, result) + } + + @Test + internal fun testAllConditionsFlagIsSetViolation() { + val flag = createMixedConditionsFlag() + + // Negative case: email is not set + val properties = mapOf("name" to "Test User") + try { + evaluator.matchFeatureFlagProperties(flag, "user-123", properties) + assertTrue("Should have thrown InconclusiveMatchException", false) + } catch (e: InconclusiveMatchException) { + // Expected + } + } + + @Test + internal fun testCohortMemberFlag() { + val json = + """ + { + "id": 26, + "name": "Cohort Member", + "key": "cohort-member", + "active": true, + "filters": { + "groups": [ + { + "properties": [ + { + "key": "id", + "value": 2, + "operator": "in", + "type": "cohort", + "negation": false + } + ], + "rollout_percentage": 100 + } + ] + }, + "version": 2 + } + """.trimIndent() + + val flag = config.serializer.gson.fromJson(json, FlagDefinition::class.java) + + val cohortProperties = createCohortProperties() + + // Positive case: user is in cohort 2 (not hedgebox.net, not gmail, email is set) + val matchingProperties = mapOf("email" to "example@example.com") + val result = + evaluator.matchFeatureFlagProperties( + flag, + "user-123", + matchingProperties, + cohortProperties, + ) + assertEquals(true, result) + } + + @Test + internal fun testCohortMemberFlagHedgeboxUser() { + val flag = createCohortMemberFlag() + val cohortProperties = createCohortProperties() + + // Negative case: user has hedgebox.net email (fails cohort 2 first condition) + val properties = mapOf("email" to "mark.s@hedgebox.net") + val result = + evaluator.matchFeatureFlagProperties(flag, "user-123", properties, cohortProperties) + assertEquals(false, result) + } + + @Test + internal fun testCohortMemberFlagGmailUser() { + val flag = createCohortMemberFlag() + val cohortProperties = createCohortProperties() + + // Negative case: user has gmail email (in cohort 3, fails cohort 2 negation) + val properties = mapOf("email" to "user@gmail.com") + val result = + evaluator.matchFeatureFlagProperties(flag, "user-123", properties, cohortProperties) + assertEquals(false, result) + } + + @Test + internal fun testCohortMemberFlagEmailNotSet() { + val flag = createCohortMemberFlag() + val cohortProperties = createCohortProperties() + + // Negative case: email is not set (fails cohort 2 second condition) + val properties = mapOf("name" to "Test User") + try { + evaluator.matchFeatureFlagProperties(flag, "user-123", properties, cohortProperties) + assertTrue("Should have thrown InconclusiveMatchException", false) + } catch (e: InconclusiveMatchException) { + // Expected + } + } + + @Test + internal fun testCohortMemberFlagYahooUser() { + val flag = createCohortMemberFlag() + val cohortProperties = createCohortProperties() + + // Positive case: yahoo user is not hedgebox, not gmail, and has email set + val properties = mapOf("email" to "user@yahoo.com") + val result = + evaluator.matchFeatureFlagProperties(flag, "user-123", properties, cohortProperties) + assertEquals(true, result) + } + + @Test + internal fun testCohortMemberFlagOutlookUser() { + val flag = createCohortMemberFlag() + val cohortProperties = createCohortProperties() + + // Positive case: outlook user is not hedgebox, not gmail, and has email set + val properties = mapOf("email" to "user@outlook.com") + val result = + evaluator.matchFeatureFlagProperties(flag, "user-123", properties, cohortProperties) + assertEquals(true, result) + } + + internal fun createMixedConditionsFlag(): FlagDefinition { + val json = + """ + { + "id": 25, + "name": "Mixed Conditions", + "key": "mixed-conditions", + "active": true, + "filters": { + "groups": [ + { + "properties": [ + { + "key": "email", + "value": ["example@example.com"], + "operator": "exact", + "type": "person", + "negation": false + }, + { + "key": "email", + "value": ["not_example@example.com", "also_not_example@example.com"], + "operator": "is_not", + "type": "person", + "negation": false + }, + { + "key": "email", + "value": "example", + "operator": "icontains", + "type": "person", + "negation": false + }, + { + "key": "email", + "value": ".net", + "operator": "not_icontains", + "type": "person", + "negation": false + }, + { + "key": "email", + "value": "\\w+@\\w+\\.\\w+", + "operator": "regex", + "type": "person", + "negation": false + }, + { + "key": "email", + "value": "@yahoo.com$", + "operator": "not_regex", + "type": "person", + "negation": false + }, + { + "key": "email", + "value": "is_set", + "operator": "is_set", + "type": "person", + "negation": false + } + ], + "rollout_percentage": 100 + } + ] + }, + "version": 1 + } + """.trimIndent() + + return config.serializer.gson.fromJson(json, FlagDefinition::class.java) + } + + internal fun createCohortMemberFlag(): FlagDefinition { + val json = + """ + { + "id": 26, + "name": "Cohort Member", + "key": "cohort-member", + "active": true, + "filters": { + "groups": [ + { + "properties": [ + { + "key": "id", + "value": 2, + "operator": "in", + "type": "cohort", + "negation": false + } + ], + "rollout_percentage": 100 + } + ] + }, + "version": 2 + } + """.trimIndent() + + return config.serializer.gson.fromJson(json, FlagDefinition::class.java) + } + + internal fun createCohortProperties(): Map { + val json = + """ + { + "2": { + "type": "AND", + "values": [ + { + "type": "AND", + "values": [ + { + "key": "email", + "operator": "not_regex", + "type": "person", + "value": "@hedgebox.net$" + } + ] + }, + { + "type": "AND", + "values": [ + { + "key": "id", + "type": "cohort", + "negation": true, + "value": 3 + }, + { + "key": "email", + "operator": "is_set", + "type": "person", + "negation": false, + "value": "is_set" + } + ] + } + ] + }, + "3": { + "type": "OR", + "values": [ + { + "type": "AND", + "values": [ + { + "key": "email", + "operator": "regex", + "type": "person", + "negation": false, + "value": "@gmail.com" + } + ] + } + ] + } + } + """.trimIndent() + + val type = object : TypeToken>() {}.type + return config.serializer.gson.fromJson(json, type) + } + + internal fun createMultiVariateFlag(): FlagDefinition { + val json = + """ + { + "id": 1, + "name": "Multi Variate Flag", + "key": "multi-variate-flag", + "active": true, + "filters": { + "groups": [ + { + "properties": [], + "rollout_percentage": 100 + } + ], + "multivariate": { + "variants": [ + { + "key": "control", + "rollout_percentage": 50.0 + }, + { + "key": "test", + "rollout_percentage": 50.0 + } + ] + } + }, + "version": 1 + } + """.trimIndent() + + return config.serializer.gson.fromJson(json, FlagDefinition::class.java) + } + + // Flag Dependency Tests + + @Test + internal fun testFlagDependencyMissingDependencyChain() { + val property = + FlagProperty( + key = "dependent-flag", + propertyValue = true, + propertyOperator = PropertyOperator.FLAG_EVALUATES_TO, + type = PropertyType.FLAG, + negation = false, + dependencyChain = null, + ) + + val flagsByKey = emptyMap() + val evaluationCache = mutableMapOf() + + try { + evaluator.evaluateFlagDependency( + property, + flagsByKey, + evaluationCache, + "user-123", + emptyMap(), + emptyMap(), + ) + assertTrue("Should have thrown InconclusiveMatchException", false) + } catch (e: InconclusiveMatchException) { + assertTrue(e.message?.contains("missing required 'dependency_chain' field") ?: false) + } + } + + @Test + internal fun testFlagDependencyCircularDependency() { + val property = + FlagProperty( + key = "dependent-flag", + propertyValue = true, + propertyOperator = PropertyOperator.FLAG_EVALUATES_TO, + type = PropertyType.FLAG, + negation = false, + dependencyChain = emptyList(), + ) + + val flagsByKey = emptyMap() + val evaluationCache = mutableMapOf() + + try { + evaluator.evaluateFlagDependency( + property, + flagsByKey, + evaluationCache, + "user-123", + emptyMap(), + emptyMap(), + ) + assertTrue("Should have thrown InconclusiveMatchException", false) + } catch (e: InconclusiveMatchException) { + assertTrue(e.message?.contains("Circular dependency") ?: false) + } + } + + @Test + internal fun testFlagDependencyMissingFlag() { + val property = + FlagProperty( + key = "dependent-flag", + propertyValue = true, + propertyOperator = PropertyOperator.FLAG_EVALUATES_TO, + type = PropertyType.FLAG, + negation = false, + dependencyChain = listOf("missing-flag"), + ) + + val flagsByKey = emptyMap() + val evaluationCache = mutableMapOf() + + try { + evaluator.evaluateFlagDependency( + property, + flagsByKey, + evaluationCache, + "user-123", + emptyMap(), + emptyMap(), + ) + assertTrue("Should have thrown InconclusiveMatchException", false) + } catch (e: InconclusiveMatchException) { + assertTrue(e.message?.contains("flag not found in local flags") ?: false) + } + } + + @Test + internal fun testFlagDependencyInactiveFlag() { + val inactiveFlagJson = + """ + { + "id": 1, + "name": "Inactive Flag", + "key": "inactive-flag", + "active": false, + "filters": { + "groups": [ + { + "properties": [], + "rollout_percentage": 100 + } + ] + }, + "version": 1 + } + """.trimIndent() + + val inactiveFlag = config.serializer.gson.fromJson(inactiveFlagJson, FlagDefinition::class.java) + + val property = + FlagProperty( + key = "inactive-flag", + propertyValue = true, + propertyOperator = PropertyOperator.FLAG_EVALUATES_TO, + type = PropertyType.FLAG, + negation = false, + dependencyChain = listOf("inactive-flag"), + ) + + val flagsByKey = mapOf("inactive-flag" to inactiveFlag) + val evaluationCache = mutableMapOf() + + val result = + evaluator.evaluateFlagDependency( + property, + flagsByKey, + evaluationCache, + "user-123", + emptyMap(), + emptyMap(), + ) + + assertFalse(result) + assertEquals(false, evaluationCache["inactive-flag"]) + } + + @Test + internal fun testFlagDependencySimpleMatch() { + val dependencyFlagJson = + """ + { + "id": 1, + "name": "Dependency Flag", + "key": "dependency-flag", + "active": true, + "filters": { + "groups": [ + { + "properties": [], + "rollout_percentage": 100 + } + ] + }, + "version": 1 + } + """.trimIndent() + + val dependencyFlag = config.serializer.gson.fromJson(dependencyFlagJson, FlagDefinition::class.java) + + val property = + FlagProperty( + key = "dependency-flag", + propertyValue = true, + propertyOperator = PropertyOperator.FLAG_EVALUATES_TO, + type = PropertyType.FLAG, + negation = false, + dependencyChain = listOf("dependency-flag"), + ) + + val flagsByKey = mapOf("dependency-flag" to dependencyFlag) + val evaluationCache = mutableMapOf() + + val result = + evaluator.evaluateFlagDependency( + property, + flagsByKey, + evaluationCache, + "user-123", + emptyMap(), + emptyMap(), + ) + + assertTrue(result) + assertEquals(true, evaluationCache["dependency-flag"]) + } + + @Test + internal fun testFlagDependencyWithFalseValue() { + val dependencyFlagJson = + """ + { + "id": 1, + "name": "Dependency Flag", + "key": "dependency-flag", + "active": true, + "filters": { + "groups": [ + { + "properties": [], + "rollout_percentage": 0 + } + ] + }, + "version": 1 + } + """.trimIndent() + + val dependencyFlag = config.serializer.gson.fromJson(dependencyFlagJson, FlagDefinition::class.java) + + val property = + FlagProperty( + key = "dependency-flag", + propertyValue = false, + propertyOperator = PropertyOperator.FLAG_EVALUATES_TO, + type = PropertyType.FLAG, + negation = false, + dependencyChain = listOf("dependency-flag"), + ) + + val flagsByKey = mapOf("dependency-flag" to dependencyFlag) + val evaluationCache = mutableMapOf() + + val result = + evaluator.evaluateFlagDependency( + property, + flagsByKey, + evaluationCache, + "user-123", + emptyMap(), + emptyMap(), + ) + + // When dependency evaluates to false, the dependency check fails regardless of expected value + assertFalse(result) + assertEquals(false, evaluationCache["dependency-flag"]) + } + + @Test + internal fun testFlagDependencyVariantMatch() { + val multivariateFlagJson = + """ + { + "id": 1, + "name": "Multivariate Flag", + "key": "multivariate-flag", + "active": true, + "filters": { + "groups": [ + { + "properties": [], + "rollout_percentage": 100 + } + ], + "multivariate": { + "variants": [ + { + "key": "control", + "rollout_percentage": 100.0 + } + ] + } + }, + "version": 1 + } + """.trimIndent() + + val multivariateFlag = config.serializer.gson.fromJson(multivariateFlagJson, FlagDefinition::class.java) + + val property = + FlagProperty( + key = "multivariate-flag", + propertyValue = "control", + propertyOperator = PropertyOperator.FLAG_EVALUATES_TO, + type = PropertyType.FLAG, + negation = false, + dependencyChain = listOf("multivariate-flag"), + ) + + val flagsByKey = mapOf("multivariate-flag" to multivariateFlag) + val evaluationCache = mutableMapOf() + + val result = + evaluator.evaluateFlagDependency( + property, + flagsByKey, + evaluationCache, + "user-123", + emptyMap(), + emptyMap(), + ) + + assertTrue(result) + assertEquals("control", evaluationCache["multivariate-flag"]) + } + + @Test + internal fun testFlagDependencyVariantMismatch() { + val multivariateFlagJson = + """ + { + "id": 1, + "name": "Multivariate Flag", + "key": "multivariate-flag", + "active": true, + "filters": { + "groups": [ + { + "properties": [], + "rollout_percentage": 100 + } + ], + "multivariate": { + "variants": [ + { + "key": "control", + "rollout_percentage": 100.0 + } + ] + } + }, + "version": 1 + } + """.trimIndent() + + val multivariateFlag = config.serializer.gson.fromJson(multivariateFlagJson, FlagDefinition::class.java) + + val property = + FlagProperty( + key = "multivariate-flag", + propertyValue = "test", + propertyOperator = PropertyOperator.FLAG_EVALUATES_TO, + type = PropertyType.FLAG, + negation = false, + dependencyChain = listOf("multivariate-flag"), + ) + + val flagsByKey = mapOf("multivariate-flag" to multivariateFlag) + val evaluationCache = mutableMapOf() + + val result = + evaluator.evaluateFlagDependency( + property, + flagsByKey, + evaluationCache, + "user-123", + emptyMap(), + emptyMap(), + ) + + assertFalse(result) + } + + @Test + internal fun testFlagDependencyVariantMatchesBoolean() { + val multivariateFlagJson = + """ + { + "id": 1, + "name": "Multivariate Flag", + "key": "multivariate-flag", + "active": true, + "filters": { + "groups": [ + { + "properties": [], + "rollout_percentage": 100 + } + ], + "multivariate": { + "variants": [ + { + "key": "control", + "rollout_percentage": 100.0 + } + ] + } + }, + "version": 1 + } + """.trimIndent() + + val multivariateFlag = config.serializer.gson.fromJson(multivariateFlagJson, FlagDefinition::class.java) + + val property = + FlagProperty( + key = "multivariate-flag", + propertyValue = true, + propertyOperator = PropertyOperator.FLAG_EVALUATES_TO, + type = PropertyType.FLAG, + negation = false, + dependencyChain = listOf("multivariate-flag"), + ) + + val flagsByKey = mapOf("multivariate-flag" to multivariateFlag) + val evaluationCache = mutableMapOf() + + val result = + evaluator.evaluateFlagDependency( + property, + flagsByKey, + evaluationCache, + "user-123", + emptyMap(), + emptyMap(), + ) + + assertTrue(result) + } + + @Test + internal fun testFlagDependencyChainedDependencies() { + val flag1Json = + """ + { + "id": 1, + "name": "Flag 1", + "key": "flag-1", + "active": true, + "filters": { + "groups": [ + { + "properties": [], + "rollout_percentage": 100 + } + ] + }, + "version": 1 + } + """.trimIndent() + + val flag2Json = + """ + { + "id": 2, + "name": "Flag 2", + "key": "flag-2", + "active": true, + "filters": { + "groups": [ + { + "properties": [], + "rollout_percentage": 100 + } + ] + }, + "version": 1 + } + """.trimIndent() + + val flag1 = config.serializer.gson.fromJson(flag1Json, FlagDefinition::class.java) + val flag2 = config.serializer.gson.fromJson(flag2Json, FlagDefinition::class.java) + + val property = + FlagProperty( + key = "flag-2", + propertyValue = true, + propertyOperator = PropertyOperator.FLAG_EVALUATES_TO, + type = PropertyType.FLAG, + negation = false, + dependencyChain = listOf("flag-1", "flag-2"), + ) + + val flagsByKey = mapOf("flag-1" to flag1, "flag-2" to flag2) + val evaluationCache = mutableMapOf() + + val result = + evaluator.evaluateFlagDependency( + property, + flagsByKey, + evaluationCache, + "user-123", + emptyMap(), + emptyMap(), + ) + + assertTrue(result) + assertEquals(true, evaluationCache["flag-1"]) + assertEquals(true, evaluationCache["flag-2"]) + } + + @Test + internal fun testFlagDependencyChainFailsEarly() { + val flag1Json = + """ + { + "id": 1, + "name": "Flag 1", + "key": "flag-1", + "active": true, + "filters": { + "groups": [ + { + "properties": [], + "rollout_percentage": 0 + } + ] + }, + "version": 1 + } + """.trimIndent() + + val flag2Json = + """ + { + "id": 2, + "name": "Flag 2", + "key": "flag-2", + "active": true, + "filters": { + "groups": [ + { + "properties": [], + "rollout_percentage": 100 + } + ] + }, + "version": 1 + } + """.trimIndent() + + val flag1 = config.serializer.gson.fromJson(flag1Json, FlagDefinition::class.java) + val flag2 = config.serializer.gson.fromJson(flag2Json, FlagDefinition::class.java) + + val property = + FlagProperty( + key = "flag-2", + propertyValue = true, + propertyOperator = PropertyOperator.FLAG_EVALUATES_TO, + type = PropertyType.FLAG, + negation = false, + dependencyChain = listOf("flag-1", "flag-2"), + ) + + val flagsByKey = mapOf("flag-1" to flag1, "flag-2" to flag2) + val evaluationCache = mutableMapOf() + + val result = + evaluator.evaluateFlagDependency( + property, + flagsByKey, + evaluationCache, + "user-123", + emptyMap(), + emptyMap(), + ) + + assertFalse(result) + assertEquals(false, evaluationCache["flag-1"]) + } + + @Test + internal fun testFlagDependencyNoExpectedValue() { + val dependencyFlagJson = + """ + { + "id": 1, + "name": "Dependency Flag", + "key": "dependency-flag", + "active": true, + "filters": { + "groups": [ + { + "properties": [], + "rollout_percentage": 100 + } + ] + }, + "version": 1 + } + """.trimIndent() + + val dependencyFlag = config.serializer.gson.fromJson(dependencyFlagJson, FlagDefinition::class.java) + + val property = + FlagProperty( + key = "dependency-flag", + propertyValue = null, + propertyOperator = PropertyOperator.FLAG_EVALUATES_TO, + type = PropertyType.FLAG, + negation = false, + dependencyChain = listOf("dependency-flag"), + ) + + val flagsByKey = mapOf("dependency-flag" to dependencyFlag) + val evaluationCache = mutableMapOf() + + val result = + evaluator.evaluateFlagDependency( + property, + flagsByKey, + evaluationCache, + "user-123", + emptyMap(), + emptyMap(), + ) + + assertTrue(result) + assertEquals(true, evaluationCache["dependency-flag"]) + } + + @Test + internal fun testFlagDependencyInvalidOperator() { + val dependencyFlagJson = + """ + { + "id": 1, + "name": "Dependency Flag", + "key": "dependency-flag", + "active": true, + "filters": { + "groups": [ + { + "properties": [], + "rollout_percentage": 100 + } + ] + }, + "version": 1 + } + """.trimIndent() + + val dependencyFlag = config.serializer.gson.fromJson(dependencyFlagJson, FlagDefinition::class.java) + + val property = + FlagProperty( + key = "dependency-flag", + propertyValue = true, + propertyOperator = PropertyOperator.EXACT, + type = PropertyType.FLAG, + negation = false, + dependencyChain = listOf("dependency-flag"), + ) + + val flagsByKey = mapOf("dependency-flag" to dependencyFlag) + val evaluationCache = mutableMapOf() + + try { + evaluator.evaluateFlagDependency( + property, + flagsByKey, + evaluationCache, + "user-123", + emptyMap(), + emptyMap(), + ) + assertTrue("Should have thrown InconclusiveMatchException", false) + } catch (e: InconclusiveMatchException) { + assertTrue(e.message?.contains("invalid operator") ?: false) + } + } + + @Test + internal fun testFlagDependencyWithPropertyConditions() { + val dependencyFlagJson = + """ + { + "id": 1, + "name": "Dependency Flag", + "key": "dependency-flag", + "active": true, + "filters": { + "groups": [ + { + "properties": [ + { + "key": "email", + "value": "test@example.com", + "operator": "exact", + "type": "person", + "negation": false + } + ], + "rollout_percentage": 100 + } + ] + }, + "version": 1 + } + """.trimIndent() + + val dependencyFlag = config.serializer.gson.fromJson(dependencyFlagJson, FlagDefinition::class.java) + + val property = + FlagProperty( + key = "dependency-flag", + propertyValue = true, + propertyOperator = PropertyOperator.FLAG_EVALUATES_TO, + type = PropertyType.FLAG, + negation = false, + dependencyChain = listOf("dependency-flag"), + ) + + val flagsByKey = mapOf("dependency-flag" to dependencyFlag) + val evaluationCache = mutableMapOf() + val properties = mapOf("email" to "test@example.com") + + val result = + evaluator.evaluateFlagDependency( + property, + flagsByKey, + evaluationCache, + "user-123", + properties, + emptyMap(), + ) + + assertTrue(result) + assertEquals(true, evaluationCache["dependency-flag"]) + } + + @Test + internal fun testMatchFeatureFlagPropertiesWithFlagDependency() { + val dependencyFlagJson = + """ + { + "id": 1, + "name": "Dependency Flag", + "key": "dependency-flag", + "active": true, + "filters": { + "groups": [ + { + "properties": [], + "rollout_percentage": 100 + } + ] + }, + "version": 1 + } + """.trimIndent() + + val mainFlagJson = + """ + { + "id": 2, + "name": "Main Flag", + "key": "main-flag", + "active": true, + "filters": { + "groups": [ + { + "properties": [ + { + "key": "dependency-flag", + "value": true, + "operator": "flag_evaluates_to", + "type": "flag", + "negation": false, + "dependency_chain": ["dependency-flag"] + } + ], + "rollout_percentage": 100 + } + ] + }, + "version": 1 + } + """.trimIndent() + + val dependencyFlag = config.serializer.gson.fromJson(dependencyFlagJson, FlagDefinition::class.java) + val mainFlag = config.serializer.gson.fromJson(mainFlagJson, FlagDefinition::class.java) + val flagsByKey = mapOf("dependency-flag" to dependencyFlag, "main-flag" to mainFlag) + val evaluationCache = mutableMapOf() + + val result = + evaluator.matchFeatureFlagProperties( + mainFlag, + "user-123", + emptyMap(), + emptyMap(), + flagsByKey, + evaluationCache, + ) + + assertEquals(true, result) + assertEquals(true, evaluationCache["dependency-flag"]) + } + + @Test + internal fun testMatchFeatureFlagPropertiesWithFailedFlagDependency() { + val dependencyFlagJson = + """ + { + "id": 1, + "name": "Dependency Flag", + "key": "dependency-flag", + "active": true, + "filters": { + "groups": [ + { + "properties": [], + "rollout_percentage": 0 + } + ] + }, + "version": 1 + } + """.trimIndent() + + val mainFlagJson = + """ + { + "id": 2, + "name": "Main Flag", + "key": "main-flag", + "active": true, + "filters": { + "groups": [ + { + "properties": [ + { + "key": "dependency-flag", + "value": true, + "operator": "flag_evaluates_to", + "type": "flag", + "negation": false, + "dependency_chain": ["dependency-flag"] + } + ], + "rollout_percentage": 100 + } + ] + }, + "version": 1 + } + """.trimIndent() + + val dependencyFlag = config.serializer.gson.fromJson(dependencyFlagJson, FlagDefinition::class.java) + val mainFlag = config.serializer.gson.fromJson(mainFlagJson, FlagDefinition::class.java) + val flagsByKey = mapOf("dependency-flag" to dependencyFlag, "main-flag" to mainFlag) + val evaluationCache = mutableMapOf() + + val result = + evaluator.matchFeatureFlagProperties( + mainFlag, + "user-123", + emptyMap(), + emptyMap(), + flagsByKey, + evaluationCache, + ) + + assertEquals(false, result) + assertEquals(false, evaluationCache["dependency-flag"]) + } +} diff --git a/posthog-server/src/test/java/com/posthog/server/internal/PostHogFeatureFlagsTest.kt b/posthog-server/src/test/java/com/posthog/server/internal/PostHogFeatureFlagsTest.kt index fde2e071..74ebf062 100644 --- a/posthog-server/src/test/java/com/posthog/server/internal/PostHogFeatureFlagsTest.kt +++ b/posthog-server/src/test/java/com/posthog/server/internal/PostHogFeatureFlagsTest.kt @@ -4,12 +4,14 @@ import com.posthog.internal.PostHogApi import com.posthog.server.TestLogger import com.posthog.server.createEmptyFlagsResponse import com.posthog.server.createFlagsResponse +import com.posthog.server.createLocalEvaluationResponse import com.posthog.server.createMockHttp import com.posthog.server.createTestConfig import com.posthog.server.errorResponse import com.posthog.server.jsonResponse import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertFalse import kotlin.test.assertNull import kotlin.test.assertTrue @@ -212,7 +214,7 @@ internal class PostHogFeatureFlagsTest { ) assertNull(result) - assertTrue(logger.containsLog("Loading feature flags failed")) + assertTrue(logger.containsLog("Loading remote feature flags failed")) mockServer.shutdown() } @@ -318,6 +320,171 @@ internal class PostHogFeatureFlagsTest { mockServer.shutdown() } + @Test + fun `appendFlagEventProperties does nothing when options is null`() { + val config = createTestConfig() + val api = PostHogApi(config) + val featureFlags = PostHogFeatureFlags(config, api, 60000, 100) + + val properties = mutableMapOf("existing" to "value") + + featureFlags.appendFlagEventProperties( + distinctId = "user123", + properties = properties, + groups = null, + options = null, + ) + + // Properties should remain unchanged + assertEquals(1, properties.size) + assertEquals("value", properties["existing"]) + } + + @Test + fun `appendFlagEventProperties does nothing when properties is null`() { + val config = createTestConfig() + val api = PostHogApi(config) + val featureFlags = PostHogFeatureFlags(config, api, 60000, 100) + + val options = com.posthog.server.PostHogSendFeatureFlagOptions.builder().build() + + // Should not crash + featureFlags.appendFlagEventProperties( + distinctId = "user123", + properties = null, + groups = null, + options = options, + ) + } + + @Test + fun `appendFlagEventProperties enriches properties with feature flags`() { + val flagsResponse = + """ + { + "flags": { + "string-flag": { + "key": "string-flag", + "enabled": true, + "variant": "control", + "metadata": { "version": 1, "payload": null, "id": 1 }, + "reason": { "kind": "condition_match", "condition_match_type": "Test", "condition_index": 0 } + }, + "boolean-flag": { + "key": "boolean-flag", + "enabled": true, + "variant": null, + "metadata": { "version": 1, "payload": null, "id": 2 }, + "reason": { "kind": "condition_match", "condition_match_type": "Test", "condition_index": 0 } + }, + "disabled-flag": { + "key": "disabled-flag", + "enabled": false, + "variant": null, + "metadata": { "version": 1, "payload": null, "id": 3 }, + "reason": { "kind": "condition_match", "condition_match_type": "Test", "condition_index": 0 } + } + } + } + """.trimIndent() + + val mockServer = createMockHttp(jsonResponse(flagsResponse)) + val url = mockServer.url("/") + + val config = createTestConfig(host = url.toString()) + val api = PostHogApi(config) + val featureFlags = PostHogFeatureFlags(config, api, 60000, 100) + + val properties = mutableMapOf("existing" to "value") + val options = + com.posthog.server.PostHogSendFeatureFlagOptions.builder() + .personProperty("email", "test@example.com") + .build() + + featureFlags.appendFlagEventProperties( + distinctId = "user123", + properties = properties, + groups = null, + options = options, + ) + + // Verify original property preserved + assertEquals("value", properties["existing"]) + + // Verify flags added + assertEquals("control", properties["\$feature/string-flag"]) + assertEquals(true, properties["\$feature/boolean-flag"]) + assertEquals(false, properties["\$feature/disabled-flag"]) + + // Verify active flags list (only enabled flags) + @Suppress("UNCHECKED_CAST") + val activeFlags = properties["\$active_feature_flags"] as? List + assertEquals(2, activeFlags?.size) + assertTrue(activeFlags?.contains("string-flag") == true) + assertTrue(activeFlags?.contains("boolean-flag") == true) + assertFalse(activeFlags?.contains("disabled-flag") == true) + + mockServer.shutdown() + } + + @Test + fun `appendFlagEventProperties handles onlyEvaluateLocally option`() { + val config = createTestConfig() + val api = PostHogApi(config) + val featureFlags = PostHogFeatureFlags(config, api, 60000, 100) + + val properties = mutableMapOf("existing" to "value") + val options = + com.posthog.server.PostHogSendFeatureFlagOptions.builder() + .onlyEvaluateLocally(true) + .build() + + featureFlags.appendFlagEventProperties( + distinctId = "user123", + properties = properties, + groups = null, + options = options, + ) + + // Without local evaluation setup, flags should be null and no properties added + assertEquals(1, properties.size) + assertEquals("value", properties["existing"]) + } + + @Test + fun `appendFlagEventProperties returns early when no flags resolved`() { + val emptyFlagsResponse = createEmptyFlagsResponse() + val mockServer = createMockHttp(jsonResponse(emptyFlagsResponse)) + val url = mockServer.url("/") + + val config = createTestConfig(host = url.toString()) + val api = PostHogApi(config) + val featureFlags = PostHogFeatureFlags(config, api, 60000, 100) + + val properties = mutableMapOf("existing" to "value") + val options = + com.posthog.server.PostHogSendFeatureFlagOptions.builder() + .personProperty("email", "test@example.com") + .build() + + featureFlags.appendFlagEventProperties( + distinctId = "user123", + properties = properties, + groups = null, + options = options, + ) + + // Empty flags map returns an empty active_feature_flags list + assertEquals(2, properties.size) + assertEquals("value", properties["existing"]) + + @Suppress("UNCHECKED_CAST") + val activeFlags = properties["\$active_feature_flags"] as? List + assertEquals(0, activeFlags?.size) + + mockServer.shutdown() + } + @Test fun `getFeatureFlag handles different value types correctly`() { // Need to manually construct this one since we need different variants @@ -370,4 +537,272 @@ internal class PostHogFeatureFlagsTest { mockServer.shutdown() } + + @Test + fun `local evaluation poller loads flag definitions`() { + val logger = TestLogger() + val localEvalResponse = + createLocalEvaluationResponse( + flagKey = "test-flag", + aggregationGroupTypeIndex = null, + ) + + val mockServer = + createMockHttp( + jsonResponse(localEvalResponse), + ) + val url = mockServer.url("/") + + val config = createTestConfig(logger, url.toString()) + val api = PostHogApi(config) + val remoteConfig = + PostHogFeatureFlags( + config, + api, + 60000, + 100, + localEvaluation = true, + personalApiKey = "test-personal-key", + pollIntervalSeconds = 30, + ) + + // Wait for poller to load + Thread.sleep(2000) + + // Check that we made the API call + assertTrue( + mockServer.requestCount >= 1, + "Expected at least 1 request, got ${mockServer.requestCount}", + ) + assertTrue(logger.containsLog("Loading feature flags for local evaluation")) + assertTrue( + logger.containsLog("Loaded 1 feature flags for local evaluation") || + logger.logs.any { + it.contains( + "Loaded", + ) + }, + ) + + remoteConfig.shutDown() + mockServer.shutdown() + } + + @Test + fun `group-based flag evaluates correctly when group is provided`() { + val logger = TestLogger() + val localEvalResponse = + createLocalEvaluationResponse( + flagKey = "org-feature", + aggregationGroupTypeIndex = 2, + ) + + // Mock both local evaluation endpoint and regular flags endpoint + val mockServer = + createMockHttp( + jsonResponse(localEvalResponse), + jsonResponse(createEmptyFlagsResponse()), + ) + val url = mockServer.url("/") + + val config = createTestConfig(logger, url.toString()) + val api = PostHogApi(config) + val featureFlags = + PostHogFeatureFlags( + config, + api, + 60000, + 100, + localEvaluation = true, + personalApiKey = "test-personal-key", + ) + + val result = + featureFlags.getFeatureFlag( + key = "org-feature", + defaultValue = false, + distinctId = "user-123", + groups = mapOf("organization" to "org-456"), + groupProperties = mapOf("org-456" to mapOf("plan" to "enterprise")), + ) + + // Debug logging + if (result != true) { + println("Logger output: ${logger.logs.joinToString("\n")}") + } + + assertEquals(true, result) + assertTrue(logger.containsLog("Local evaluation successful")) + + featureFlags.shutDown() + mockServer.shutdown() + } + + @Test + fun `group-based flag returns false when required group is missing`() { + val logger = TestLogger() + val localEvalResponse = + createLocalEvaluationResponse( + flagKey = "org-feature", + aggregationGroupTypeIndex = 2, + ) + + // Add fallback response in case local evaluation fails + val mockServer = + createMockHttp( + jsonResponse(localEvalResponse), + jsonResponse(createEmptyFlagsResponse()), + ) + val url = mockServer.url("/") + + val config = createTestConfig(logger, url.toString()) + val api = PostHogApi(config) + val featureFlags = + PostHogFeatureFlags( + config, + api, + 60000, + 100, + localEvaluation = true, + personalApiKey = "test-personal-key", + ) + + // Call without the required "organization" group + val result = + featureFlags.getFeatureFlag( + key = "org-feature", + defaultValue = "default", + distinctId = "user-123", + groups = null, + ) + + // Debug logging + if (result != false) { + println("Logger output: ${logger.logs.joinToString("\n")}") + } + + assertEquals(false, result) + assertTrue(logger.containsLog("Can't compute group flag 'org-feature' without group 'organization'")) + + featureFlags.shutDown() + mockServer.shutdown() + } + + @Test + fun `group-based flag falls back to API when group type index is unknown`() { + val logger = TestLogger() + // Create flag with unknown group type index (99 doesn't exist in groupTypeMapping) + val localEvalResponse = + """ + { + "flags": [ + { + "id": 1, + "name": "org-feature", + "key": "org-feature", + "active": true, + "filters": { + "aggregation_group_type_index": 99, + "groups": [ + { + "properties": [], + "rollout_percentage": 100 + } + ] + }, + "version": 1 + } + ], + "group_type_mapping": { + "0": "account", + "2": "organization" + }, + "cohorts": {} + } + """.trimIndent() + + val apiFlagsResponse = createFlagsResponse("org-feature", enabled = true) + + val mockServer = + createMockHttp( + jsonResponse(localEvalResponse), + jsonResponse(apiFlagsResponse), + ) + val url = mockServer.url("/") + + val config = createTestConfig(logger, url.toString()) + val api = PostHogApi(config) + val remoteConfig = + PostHogFeatureFlags( + config, + api, + 60000, + 100, + localEvaluation = true, + personalApiKey = "test-personal-key", + ) + + // Give the poller time to load definitions (async operation) + Thread.sleep(1000) + + val result = + remoteConfig.getFeatureFlag( + key = "org-feature", + defaultValue = false, + distinctId = "user-123", + ) + + // Should fall back to API and get true + assertEquals(true, result) + assertTrue(logger.containsLog("Unknown group type index 99")) + assertTrue(logger.containsLog("Local evaluation inconclusive")) + + remoteConfig.shutDown() + mockServer.shutdown() + } + + @Test + fun `person-based flag still works with local evaluation`() { + val logger = TestLogger() + val localEvalResponse = + createLocalEvaluationResponse( + flagKey = "person-feature", + aggregationGroupTypeIndex = null, + ) + + val mockServer = + createMockHttp( + jsonResponse(localEvalResponse), + ) + val url = mockServer.url("/") + + val config = createTestConfig(logger, url.toString()) + val api = PostHogApi(config) + val remoteConfig = + PostHogFeatureFlags( + config, + api, + 60000, + 100, + localEvaluation = true, + personalApiKey = "test-personal-key", + ) + + // Give the poller time to load definitions (async operation) + Thread.sleep(1000) + + val result = + remoteConfig.getFeatureFlag( + key = "person-feature", + defaultValue = false, + distinctId = "user-123", + personProperties = mapOf("email" to "test@example.com"), + ) + + assertEquals(true, result) + assertTrue(logger.containsLog("Local evaluation successful")) + + remoteConfig.shutDown() + mockServer.shutdown() + } } diff --git a/posthog/CHANGELOG.md b/posthog/CHANGELOG.md index 0f837846..ce500a2a 100644 --- a/posthog/CHANGELOG.md +++ b/posthog/CHANGELOG.md @@ -1,6 +1,9 @@ ## Next - fix: Typed `groupProperties` and `userProperties` types to match the API and other SDKs +- feat: Add an optional shutdown override to `FeatureFlagInterface` ([#299](https://github.com/PostHog/posthog-android/pull/299)) +- feat: Add `localEvaluation` to the `PostHogApi` ([#299](https://github.com/PostHog/posthog-android/pull/299)) +- feat: Add API models for local evaluation ([#299](https://github.com/PostHog/posthog-android/pull/299)) ## 4.2.0 - 2025-10-23 diff --git a/posthog/api/posthog.api b/posthog/api/posthog.api index 9363c219..c2707249 100644 --- a/posthog/api/posthog.api +++ b/posthog/api/posthog.api @@ -327,6 +327,7 @@ public class com/posthog/PostHogStateless : com/posthog/PostHogStatelessInterfac public fun getFeatureFlagPayloadStateless (Ljava/lang/String;Ljava/lang/String;Ljava/lang/Object;Ljava/util/Map;Ljava/util/Map;Ljava/util/Map;)Ljava/lang/Object; public fun getFeatureFlagStateless (Ljava/lang/String;Ljava/lang/String;Ljava/lang/Object;Ljava/util/Map;Ljava/util/Map;Ljava/util/Map;)Ljava/lang/Object; protected final fun getFeatureFlags ()Lcom/posthog/internal/PostHogFeatureFlagsInterface; + protected final fun getFeatureFlagsCalled ()Lcom/posthog/internal/PostHogFeatureFlagCalledCache; protected final fun getMemoryPreferences ()Lcom/posthog/internal/PostHogPreferences; protected final fun getOptOutLock ()Ljava/lang/Object; protected final fun getPreferences ()Lcom/posthog/internal/PostHogPreferences; @@ -342,6 +343,7 @@ public class com/posthog/PostHogStateless : com/posthog/PostHogStatelessInterfac public fun optOut ()V protected final fun setEnabled (Z)V protected final fun setFeatureFlags (Lcom/posthog/internal/PostHogFeatureFlagsInterface;)V + protected final fun setFeatureFlagsCalled (Lcom/posthog/internal/PostHogFeatureFlagCalledCache;)V protected final fun setMemoryPreferences (Lcom/posthog/internal/PostHogPreferences;)V protected final fun setQueue (Lcom/posthog/internal/PostHogQueueInterface;)V public fun setup (Lcom/posthog/PostHogConfig;)V @@ -537,6 +539,7 @@ public final class com/posthog/internal/PostHogApi { public final fun batch (Ljava/util/List;)V public final fun flags (Ljava/lang/String;Ljava/lang/String;Ljava/util/Map;Ljava/util/Map;Ljava/util/Map;)Lcom/posthog/internal/PostHogFlagsResponse; public static synthetic fun flags$default (Lcom/posthog/internal/PostHogApi;Ljava/lang/String;Ljava/lang/String;Ljava/util/Map;Ljava/util/Map;Ljava/util/Map;ILjava/lang/Object;)Lcom/posthog/internal/PostHogFlagsResponse; + public final fun localEvaluation (Ljava/lang/String;)Lcom/posthog/internal/LocalEvaluationResponse; public final fun remoteConfig ()Lcom/posthog/internal/PostHogRemoteConfigResponse; public final fun snapshot (Ljava/util/List;)V } @@ -577,17 +580,27 @@ public final class com/posthog/internal/PostHogDeviceDateProvider : com/posthog/ public fun nanoTime ()J } +public final class com/posthog/internal/PostHogFeatureFlagCalledCache { + public static final field BATCH_EVICTION_FACTOR D + public fun (I)V + public final fun add (Ljava/lang/String;Ljava/lang/String;Ljava/lang/Object;)Z + public final fun clear ()V + public final fun size ()I +} + public abstract interface class com/posthog/internal/PostHogFeatureFlagsInterface { public abstract fun clear ()V public abstract fun getFeatureFlag (Ljava/lang/String;Ljava/lang/Object;Ljava/lang/String;Ljava/util/Map;Ljava/util/Map;Ljava/util/Map;)Ljava/lang/Object; public abstract fun getFeatureFlagPayload (Ljava/lang/String;Ljava/lang/Object;Ljava/lang/String;Ljava/util/Map;Ljava/util/Map;Ljava/util/Map;)Ljava/lang/Object; public abstract fun getFeatureFlags (Ljava/lang/String;Ljava/util/Map;Ljava/util/Map;Ljava/util/Map;)Ljava/util/Map; + public abstract fun shutDown ()V } public final class com/posthog/internal/PostHogFeatureFlagsInterface$DefaultImpls { public static synthetic fun getFeatureFlag$default (Lcom/posthog/internal/PostHogFeatureFlagsInterface;Ljava/lang/String;Ljava/lang/Object;Ljava/lang/String;Ljava/util/Map;Ljava/util/Map;Ljava/util/Map;ILjava/lang/Object;)Ljava/lang/Object; public static synthetic fun getFeatureFlagPayload$default (Lcom/posthog/internal/PostHogFeatureFlagsInterface;Ljava/lang/String;Ljava/lang/Object;Ljava/lang/String;Ljava/util/Map;Ljava/util/Map;Ljava/util/Map;ILjava/lang/Object;)Ljava/lang/Object; public static synthetic fun getFeatureFlags$default (Lcom/posthog/internal/PostHogFeatureFlagsInterface;Ljava/lang/String;Ljava/util/Map;Ljava/util/Map;Ljava/util/Map;ILjava/lang/Object;)Ljava/util/Map; + public static fun shutDown (Lcom/posthog/internal/PostHogFeatureFlagsInterface;)V } public final class com/posthog/internal/PostHogFlagsResponse : com/posthog/internal/PostHogRemoteConfigResponse { @@ -698,6 +711,7 @@ public final class com/posthog/internal/PostHogRemoteConfig : com/posthog/intern public final fun loadRemoteConfig (Ljava/lang/String;Ljava/lang/String;Ljava/util/Map;Lcom/posthog/PostHogOnFeatureFlags;Lcom/posthog/PostHogOnFeatureFlags;)V public static synthetic fun loadRemoteConfig$default (Lcom/posthog/internal/PostHogRemoteConfig;Ljava/lang/String;Ljava/lang/String;Ljava/util/Map;Lcom/posthog/PostHogOnFeatureFlags;Lcom/posthog/PostHogOnFeatureFlags;ILjava/lang/Object;)V public final fun setOnRemoteConfigLoaded (Lkotlin/jvm/functions/Function0;)V + public fun shutDown ()V } public class com/posthog/internal/PostHogRemoteConfigResponse { @@ -750,6 +764,12 @@ public final class com/posthog/internal/PropertyGroup { public fun toString ()Ljava/lang/String; } +public final class com/posthog/internal/PropertyGroupDeserializer : com/google/gson/JsonDeserializer { + public fun ()V + public fun deserialize (Lcom/google/gson/JsonElement;Ljava/lang/reflect/Type;Lcom/google/gson/JsonDeserializationContext;)Lcom/posthog/internal/PropertyGroup; + public synthetic fun deserialize (Lcom/google/gson/JsonElement;Ljava/lang/reflect/Type;Lcom/google/gson/JsonDeserializationContext;)Ljava/lang/Object; +} + public final class com/posthog/internal/PropertyOperator : java/lang/Enum { public static final field Companion Lcom/posthog/internal/PropertyOperator$Companion; public static final field EXACT Lcom/posthog/internal/PropertyOperator; @@ -820,6 +840,12 @@ public final class com/posthog/internal/PropertyValue$PropertyGroups : com/posth public fun toString ()Ljava/lang/String; } +public final class com/posthog/internal/PropertyValueDeserializer : com/google/gson/JsonDeserializer { + public fun ()V + public fun deserialize (Lcom/google/gson/JsonElement;Ljava/lang/reflect/Type;Lcom/google/gson/JsonDeserializationContext;)Lcom/posthog/internal/PropertyValue; + public synthetic fun deserialize (Lcom/google/gson/JsonElement;Ljava/lang/reflect/Type;Lcom/google/gson/JsonDeserializationContext;)Ljava/lang/Object; +} + public final class com/posthog/internal/VariantDefinition { public fun (Ljava/lang/String;D)V public final fun getKey ()Ljava/lang/String; diff --git a/posthog/src/main/java/com/posthog/PostHog.kt b/posthog/src/main/java/com/posthog/PostHog.kt index 6020e7ed..285936c8 100644 --- a/posthog/src/main/java/com/posthog/PostHog.kt +++ b/posthog/src/main/java/com/posthog/PostHog.kt @@ -57,7 +57,7 @@ public class PostHog private constructor( private var remoteConfig: PostHogRemoteConfig? = null private var replayQueue: PostHogQueueInterface? = null - private val featureFlagsCalled = mutableMapOf>() + private val featureFlagsCalledCache = mutableMapOf>() private var sessionReplayHandler: PostHogSessionReplayHandler? = null private var surveysHandler: PostHogSurveysHandler? = null @@ -85,7 +85,8 @@ public class PostHog private constructor( config.logger.log("Setup called despite already being setup!") return } - config.logger = if (config.logger is PostHogNoOpLogger) PostHogPrintLogger(config) else config.logger + config.logger = + if (config.logger is PostHogNoOpLogger) PostHogPrintLogger(config) else config.logger if (!apiKeys.add(config.apiKey)) { config.logger.log("API Key: ${config.apiKey} already has a PostHog instance.") @@ -94,8 +95,22 @@ public class PostHog private constructor( val cachePreferences = config.cachePreferences ?: memoryPreferences config.cachePreferences = cachePreferences val api = PostHogApi(config) - val queue = config.queueProvider(config, api, PostHogApiEndpoint.BATCH, config.storagePrefix, queueExecutor) - val replayQueue = config.queueProvider(config, api, PostHogApiEndpoint.SNAPSHOT, config.replayStoragePrefix, replayExecutor) + val queue = + config.queueProvider( + config, + api, + PostHogApiEndpoint.BATCH, + config.storagePrefix, + queueExecutor, + ) + val replayQueue = + config.queueProvider( + config, + api, + PostHogApiEndpoint.SNAPSHOT, + config.replayStoragePrefix, + replayExecutor, + ) val featureFlags = config.remoteConfigProvider(config, api, remoteConfigExecutor) // no need to lock optOut here since the setup is locked already @@ -177,7 +192,12 @@ public class PostHog private constructor( // only because of testing in isolation, this flag is always enabled if (reloadFeatureFlags) { when { - config.remoteConfig -> loadRemoteConfigRequest(internalOnFeatureFlagsLoaded, config.onFeatureFlags) + config.remoteConfig -> + loadRemoteConfigRequest( + internalOnFeatureFlagsLoaded, + config.onFeatureFlags, + ) + config.preloadFeatureFlags -> reloadFeatureFlags(config.onFeatureFlags) } } @@ -244,7 +264,7 @@ public class PostHog private constructor( queue?.stop() replayQueue?.stop() - featureFlagsCalled.clear() + featureFlagsCalledCache.clear() endSession() } catch (e: Throwable) { @@ -684,8 +704,9 @@ public class PostHog private constructor( get() { synchronized(personProcessingLock) { if (!isPersonProcessingLoaded) { - isPersonProcessingEnabled = getPreferences().getValue(PERSON_PROCESSING) as? Boolean - ?: false + isPersonProcessingEnabled = + getPreferences().getValue(PERSON_PROCESSING) as? Boolean + ?: false isPersonProcessingLoaded = true } } @@ -755,7 +776,10 @@ public class PostHog private constructor( if (!isEnabled()) { return } - loadFeatureFlagsRequest(internalOnFeatureFlags = internalOnFeatureFlagsLoaded, onFeatureFlags = onFeatureFlags) + loadFeatureFlagsRequest( + internalOnFeatureFlags = internalOnFeatureFlagsLoaded, + onFeatureFlags = onFeatureFlags, + ) } private fun loadFeatureFlagsRequest( @@ -800,7 +824,13 @@ public class PostHog private constructor( anonymousId = this.anonymousId } - remoteConfig?.loadRemoteConfig(distinctId, anonymousId = anonymousId, groups, internalOnFeatureFlags, onFeatureFlags) + remoteConfig?.loadRemoteConfig( + distinctId, + anonymousId = anonymousId, + groups, + internalOnFeatureFlags, + onFeatureFlags, + ) } public override fun isFeatureEnabled( @@ -826,12 +856,12 @@ public class PostHog private constructor( ) { var shouldSendFeatureFlagEvent = true synchronized(featureFlagsCalledLock) { - val values = featureFlagsCalled[key] ?: mutableListOf() + val values = featureFlagsCalledCache[key] ?: mutableListOf() if (values.contains(value)) { shouldSendFeatureFlagEvent = false } else { values.add(value) - featureFlagsCalled[key] = values + featureFlagsCalledCache[key] = values } } @@ -902,7 +932,7 @@ public class PostHog private constructor( } getPreferences().clear(except = except.toList()) remoteConfig?.clear() - featureFlagsCalled.clear() + featureFlagsCalledCache.clear() synchronized(identifiedLock) { isIdentifiedLoaded = false } diff --git a/posthog/src/main/java/com/posthog/PostHogStateless.kt b/posthog/src/main/java/com/posthog/PostHogStateless.kt index d70108cf..437f09ad 100644 --- a/posthog/src/main/java/com/posthog/PostHogStateless.kt +++ b/posthog/src/main/java/com/posthog/PostHogStateless.kt @@ -31,7 +31,7 @@ public open class PostHogStateless protected constructor( protected val setupLock: Any = Any() protected val optOutLock: Any = Any() - private var featureFlagsCalled: PostHogFeatureFlagCalledCache? = null + protected var featureFlagsCalled: PostHogFeatureFlagCalledCache? = null @JvmField protected var config: PostHogConfig? = null @@ -118,6 +118,7 @@ public open class PostHogStateless protected constructor( } queue?.stop() + featureFlags?.shutDown() } catch (e: Throwable) { config?.logger?.log("Close failed: $e.") } diff --git a/posthog/src/main/java/com/posthog/internal/GsonPropertyOperatorAdapter.kt b/posthog/src/main/java/com/posthog/internal/GsonPropertyOperatorAdapter.kt new file mode 100644 index 00000000..e4071ee9 --- /dev/null +++ b/posthog/src/main/java/com/posthog/internal/GsonPropertyOperatorAdapter.kt @@ -0,0 +1,17 @@ +package com.posthog.internal + +import com.google.gson.JsonDeserializationContext +import com.google.gson.JsonDeserializer +import com.google.gson.JsonElement +import java.lang.reflect.Type + +internal class GsonPropertyOperatorAdapter : + JsonDeserializer { + override fun deserialize( + json: JsonElement, + typeOfT: Type, + context: JsonDeserializationContext, + ): PropertyOperator? { + return PropertyOperator.fromStringOrNull(json.asString) + } +} diff --git a/posthog/src/main/java/com/posthog/internal/GsonPropertyTypeAdapter.kt b/posthog/src/main/java/com/posthog/internal/GsonPropertyTypeAdapter.kt new file mode 100644 index 00000000..698d7dab --- /dev/null +++ b/posthog/src/main/java/com/posthog/internal/GsonPropertyTypeAdapter.kt @@ -0,0 +1,17 @@ +package com.posthog.internal + +import com.google.gson.JsonDeserializationContext +import com.google.gson.JsonDeserializer +import com.google.gson.JsonElement +import java.lang.reflect.Type + +internal class GsonPropertyTypeAdapter : + JsonDeserializer { + override fun deserialize( + json: JsonElement, + typeOfT: Type, + context: JsonDeserializationContext, + ): PropertyType? { + return PropertyType.fromStringOrNull(json.asString) + } +} diff --git a/posthog/src/main/java/com/posthog/internal/PostHogApi.kt b/posthog/src/main/java/com/posthog/internal/PostHogApi.kt index 5bff5b13..720ba666 100644 --- a/posthog/src/main/java/com/posthog/internal/PostHogApi.kt +++ b/posthog/src/main/java/com/posthog/internal/PostHogApi.kt @@ -195,6 +195,37 @@ public class PostHogApi( } } + @Throws(PostHogApiError::class, IOException::class) + public fun localEvaluation(personalApiKey: String): LocalEvaluationResponse? { + val url = "$theHost/api/feature_flag/local_evaluation/?token=${config.apiKey}&send_cohorts" + + val request = + Request.Builder() + .url(url) + .header("User-Agent", config.userAgent) + .header("Content-Type", APP_JSON_UTF_8) + .header("Authorization", "Bearer $personalApiKey") + .get() + .build() + + client.newCall(request).execute().use { + val response = logResponse(it) + + if (!response.isSuccessful) { + throw PostHogApiError( + response.code, + response.message, + response.body, + ) + } + + response.body?.let { body -> + return config.serializer.deserialize(body.charStream().buffered()) + } + return null + } + } + private fun logResponse(response: Response): Response { if (config.debug) { try { diff --git a/posthog/src/main/java/com/posthog/internal/PostHogFeatureFlagCalledCache.kt b/posthog/src/main/java/com/posthog/internal/PostHogFeatureFlagCalledCache.kt index 8a0a1ab8..e8a5a5c3 100644 --- a/posthog/src/main/java/com/posthog/internal/PostHogFeatureFlagCalledCache.kt +++ b/posthog/src/main/java/com/posthog/internal/PostHogFeatureFlagCalledCache.kt @@ -4,7 +4,7 @@ package com.posthog.internal * LRU cache for tracking which feature flag values have been seen * to deduplicate $feature_flag_called events */ -internal class PostHogFeatureFlagCalledCache( +public class PostHogFeatureFlagCalledCache( private val maxSize: Int, ) { // LinkedHashMap isn't supported in Android API 21. We use a linked list instead @@ -24,7 +24,7 @@ internal class PostHogFeatureFlagCalledCache( * Returns true if this is the first time seeing this combination (was added), false if already seen. */ @Synchronized - fun add( + public fun add( distinctId: String, flagKey: String, value: Any?, @@ -103,7 +103,7 @@ internal class PostHogFeatureFlagCalledCache( * Clear all cached entries */ @Synchronized - fun clear() { + public fun clear() { cache.clear() head = null tail = null @@ -113,7 +113,7 @@ internal class PostHogFeatureFlagCalledCache( * Get current cache size */ @Synchronized - fun size(): Int = cache.size + public fun size(): Int = cache.size private companion object { const val BATCH_EVICTION_FACTOR = 0.2 diff --git a/posthog/src/main/java/com/posthog/internal/PostHogFeatureFlagsInterface.kt b/posthog/src/main/java/com/posthog/internal/PostHogFeatureFlagsInterface.kt index 25b9a8d5..1c56fb3a 100644 --- a/posthog/src/main/java/com/posthog/internal/PostHogFeatureFlagsInterface.kt +++ b/posthog/src/main/java/com/posthog/internal/PostHogFeatureFlagsInterface.kt @@ -30,4 +30,8 @@ public interface PostHogFeatureFlagsInterface { ): Map? public fun clear() + + public fun shutDown() { + // no-op by default + } } diff --git a/posthog/src/main/java/com/posthog/internal/PostHogLocalEvaluationDeserializers.kt b/posthog/src/main/java/com/posthog/internal/PostHogLocalEvaluationDeserializers.kt new file mode 100644 index 00000000..3e567af4 --- /dev/null +++ b/posthog/src/main/java/com/posthog/internal/PostHogLocalEvaluationDeserializers.kt @@ -0,0 +1,165 @@ +package com.posthog.internal + +import com.google.gson.JsonDeserializationContext +import com.google.gson.JsonDeserializer +import com.google.gson.JsonElement +import com.google.gson.stream.MalformedJsonException +import com.posthog.PostHogInternal +import java.lang.reflect.Type + +/** + * Gson deserializer for PropertyGroup + */ +@PostHogInternal +public class PropertyGroupDeserializer : JsonDeserializer { + override fun deserialize( + json: JsonElement, + typeOfT: Type, + context: JsonDeserializationContext, + ): PropertyGroup { + val jsonObject = json.asJsonObject + + // Parse type (AND/OR) + val type = + when (jsonObject.get("type")?.asString) { + "AND" -> LogicalOperator.AND + "OR" -> LogicalOperator.OR + else -> null + } + + // Parse values + val values = + jsonObject.get("values")?.let { valuesElement -> + context.deserialize(valuesElement, PropertyValue::class.java) + } + + return PropertyGroup(type, values) + } +} + +/** + * Gson deserializer for PropertyValue sealed interface + * Determines whether to deserialize as PropertyGroups or FlagProperties based on structure + */ +@PostHogInternal +public class PropertyValueDeserializer : JsonDeserializer { + override fun deserialize( + json: JsonElement, + typeOfT: Type, + context: JsonDeserializationContext, + ): PropertyValue? { + if (!json.isJsonArray) return null + + val array = json.asJsonArray + if (array.size() == 0) return null + + // Check first element to determine type + val first = array.get(0) + if (!first.isJsonObject) return null + + val firstObject = first.asJsonObject + + // Distinguish between PropertyGroup and FlagProperty: + // If first element has "key" field, it's FlagProperties + // If first element has both "type" AND "values" fields (but no "key"), it's nested PropertyGroups + return if (firstObject.has("key")) { + // FlagProperties + val properties = + array.mapNotNull { element -> + if (element.isJsonObject) { + deserializeFlagProperty(element.asJsonObject) + } else { + null + } + } + PropertyValue.FlagProperties(properties) + } else if (firstObject.has("type") && firstObject.has("values")) { + // Nested PropertyGroups + val groups = + array.map { element -> + context.deserialize(element, PropertyGroup::class.java) + } + PropertyValue.PropertyGroups(groups) + } else { + // Otherwise treat as FlagProperties + val properties = + array.mapNotNull { element -> + if (element.isJsonObject) { + deserializeFlagProperty(element.asJsonObject) + } else { + null + } + } + PropertyValue.FlagProperties(properties) + } + } + + private fun deserializeFlagProperty(jsonObject: com.google.gson.JsonObject): FlagProperty? { + val key = jsonObject.get("key")?.asString ?: return null + val value = + jsonObject.get("value")?.let { element -> + when { + element.isJsonPrimitive -> { + val primitive = element.asJsonPrimitive + when { + primitive.isBoolean -> primitive.asBoolean + primitive.isNumber -> { + // Use same logic as GsonNumberPolicy + // Try Int, then Long, then Double + val numStr = primitive.asString + try { + numStr.toInt() + } catch (intE: NumberFormatException) { + try { + numStr.toLong() + } catch (longE: NumberFormatException) { + val d = numStr.toDouble() + if ((d.isInfinite() || d.isNaN())) { + throw MalformedJsonException("failed to parse number: " + d) + } + d + } + } + } + primitive.isString -> primitive.asString + else -> null + } + } + element.isJsonArray -> { + element.asJsonArray.map { it.asString } + } + else -> null + } + } + + val operator = + jsonObject.get("operator")?.asString?.let { + PropertyOperator.fromStringOrNull(it) + } + + val type = + jsonObject.get("type")?.asString?.let { + PropertyType.fromStringOrNull(it) + } + + val negation = jsonObject.get("negation")?.asBoolean + + val dependencyChain = + jsonObject.get("dependency_chain")?.asJsonArray?.mapNotNull { + if (it.isJsonPrimitive && it.asJsonPrimitive.isString) { + it.asString + } else { + null + } + } + + return FlagProperty( + key = key, + propertyValue = value, + propertyOperator = operator, + type = type, + negation = negation, + dependencyChain = dependencyChain, + ) + } +} diff --git a/posthog/src/main/java/com/posthog/internal/PostHogSerializer.kt b/posthog/src/main/java/com/posthog/internal/PostHogSerializer.kt index b4699a59..8d8fb4a9 100644 --- a/posthog/src/main/java/com/posthog/internal/PostHogSerializer.kt +++ b/posthog/src/main/java/com/posthog/internal/PostHogSerializer.kt @@ -63,6 +63,11 @@ public class PostHogSerializer(private val config: PostHogConfig) { registerTypeAdapter(SurveyType::class.java, GsonSurveyTypeAdapter(config)) registerTypeAdapter(SurveyQuestion::class.java, GsonSurveyQuestionAdapter(config)) registerTypeAdapter(SurveyQuestionBranching::class.java, GsonSurveyQuestionBranchingAdapter(config)) + // local evaluation + registerTypeAdapter(PropertyGroup::class.java, PropertyGroupDeserializer()) + registerTypeAdapter(PropertyValue::class.java, PropertyValueDeserializer()) + registerTypeAdapter(PropertyOperator::class.java, GsonPropertyOperatorAdapter()) + registerTypeAdapter(PropertyType::class.java, GsonPropertyTypeAdapter()) }.create() @Throws(JsonIOException::class, IOException::class) diff --git a/posthog/src/test/java/com/posthog/internal/PostHogLocalEvaluationModelsTest.kt b/posthog/src/test/java/com/posthog/internal/PostHogLocalEvaluationModelsTest.kt new file mode 100644 index 00000000..692ac437 --- /dev/null +++ b/posthog/src/test/java/com/posthog/internal/PostHogLocalEvaluationModelsTest.kt @@ -0,0 +1,663 @@ +package com.posthog.internal + +import com.google.gson.GsonBuilder +import com.google.gson.reflect.TypeToken +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +public class PostHogLocalEvaluationModelsTest { + private val gson = + GsonBuilder() + .registerTypeAdapter(PropertyGroup::class.java, PropertyGroupDeserializer()) + .registerTypeAdapter(PropertyValue::class.java, PropertyValueDeserializer()) + .create() + + @Test + public fun testPropertyGroupParseWithFlagProperties() { + val json = + """ + { + "type": "AND", + "values": [ + { + "key": "email", + "operator": "exact", + "value": "test@example.com" + } + ] + } + """.trimIndent() + + val expected = + PropertyGroup( + type = LogicalOperator.AND, + values = + PropertyValue.FlagProperties( + listOf( + FlagProperty( + key = "email", + propertyValue = "test@example.com", + propertyOperator = PropertyOperator.EXACT, + type = null, + negation = null, + dependencyChain = null, + ), + ), + ), + ) + + assertEquals(expected, gson.fromJson(json, PropertyGroup::class.java)) + } + + @Test + public fun testPropertyGroupParseWithNestedPropertyGroups() { + val json = + """ + { + "type": "OR", + "values": [ + { + "type": "AND", + "values": [ + { + "key": "email", + "operator": "icontains", + "value": "example.com" + } + ] + }, + { + "type": "AND", + "values": [ + { + "key": "age", + "operator": "gt", + "value": "18" + } + ] + } + ] + } + """.trimIndent() + + val expected = + PropertyGroup( + type = LogicalOperator.OR, + values = + PropertyValue.PropertyGroups( + listOf( + PropertyGroup( + type = LogicalOperator.AND, + values = + PropertyValue.FlagProperties( + listOf( + FlagProperty( + key = "email", + propertyValue = "example.com", + propertyOperator = PropertyOperator.ICONTAINS, + type = null, + negation = null, + dependencyChain = null, + ), + ), + ), + ), + PropertyGroup( + type = LogicalOperator.AND, + values = + PropertyValue.FlagProperties( + listOf( + FlagProperty( + key = "age", + propertyValue = "18", + propertyOperator = PropertyOperator.GT, + type = null, + negation = null, + dependencyChain = null, + ), + ), + ), + ), + ), + ), + ) + + assertEquals(expected, gson.fromJson(json, PropertyGroup::class.java)) + } + + @Test + public fun testPropertyGroupParseWithCohortProperty() { + val json = + """ + { + "type": "AND", + "values": [ + { + "key": "id", + "value": 123, + "negation": true + } + ] + } + """.trimIndent() + + val expected = + PropertyGroup( + type = LogicalOperator.AND, + values = + PropertyValue.FlagProperties( + listOf( + FlagProperty( + key = "id", + propertyValue = 123, + propertyOperator = null, + type = null, + negation = true, + dependencyChain = null, + ), + ), + ), + ) + + assertEquals(expected, gson.fromJson(json, PropertyGroup::class.java)) + } + + @Test + public fun testPropertyGroupParseWithFlagDependency() { + val json = + """ + { + "type": "AND", + "values": [ + { + "key": "feature-flag-key", + "operator": "flag_evaluates_to", + "value": true, + "dependency_chain": ["dep-flag-1", "dep-flag-2"] + } + ] + } + """.trimIndent() + + val expected = + PropertyGroup( + type = LogicalOperator.AND, + values = + PropertyValue.FlagProperties( + listOf( + FlagProperty( + key = "feature-flag-key", + propertyValue = true, + propertyOperator = PropertyOperator.FLAG_EVALUATES_TO, + type = null, + negation = null, + dependencyChain = listOf("dep-flag-1", "dep-flag-2"), + ), + ), + ), + ) + + assertEquals(expected, gson.fromJson(json, PropertyGroup::class.java)) + } + + @Test + public fun testPropertyGroupParseEmptyValues() { + val json = + """ + { + "type": "AND", + "values": [] + } + """.trimIndent() + + // Empty arrays are deserialized as null + val expected = + PropertyGroup( + type = LogicalOperator.AND, + values = null, + ) + + assertEquals(expected, gson.fromJson(json, PropertyGroup::class.java)) + } + + @Test + public fun testPropertyGroupParseNullValues() { + val json = + """ + { + "type": "OR" + } + """.trimIndent() + + val expected = + PropertyGroup( + type = LogicalOperator.OR, + values = null, + ) + + assertEquals(expected, gson.fromJson(json, PropertyGroup::class.java)) + } + + @Test + public fun testPropertyGroupParseNullType() { + val json = + """ + { + "values": [ + { + "key": "email", + "value": "test@example.com" + } + ] + } + """.trimIndent() + + val expected = + PropertyGroup( + type = null, + values = + PropertyValue.FlagProperties( + listOf( + FlagProperty( + key = "email", + propertyValue = "test@example.com", + propertyOperator = null, + type = null, + negation = null, + dependencyChain = null, + ), + ), + ), + ) + + assertEquals(expected, gson.fromJson(json, PropertyGroup::class.java)) + } + + @Test + public fun testPropertyGroupDeserializesTypeField() { + val json = + """ + { + "type": "AND", + "values": [ + { + "key": "id", + "type": "cohort", + "value": 3, + "negation": true + }, + { + "key": "feature-flag", + "type": "flag", + "operator": "flag_evaluates_to", + "value": true + } + ] + } + """.trimIndent() + + val expected = + PropertyGroup( + type = LogicalOperator.AND, + values = + PropertyValue.FlagProperties( + listOf( + FlagProperty( + key = "id", + propertyValue = 3, + propertyOperator = null, + type = PropertyType.COHORT, + negation = true, + dependencyChain = null, + ), + FlagProperty( + key = "feature-flag", + propertyValue = true, + propertyOperator = PropertyOperator.FLAG_EVALUATES_TO, + type = PropertyType.FLAG, + negation = null, + dependencyChain = null, + ), + ), + ), + ) + + assertEquals(expected, gson.fromJson(json, PropertyGroup::class.java)) + } + + @Test + public fun testPropertyGroupsIsEmpty() { + val emptyGroups = PropertyValue.PropertyGroups(emptyList()) + assertTrue(emptyGroups.isEmpty()) + + val nonEmptyGroups = + PropertyValue.PropertyGroups( + listOf( + PropertyGroup( + type = LogicalOperator.AND, + values = null, + ), + ), + ) + assertTrue(!nonEmptyGroups.isEmpty()) + } + + @Test + public fun testFlagPropertiesIsEmpty() { + val emptyProperties = PropertyValue.FlagProperties(emptyList()) + assertTrue(emptyProperties.isEmpty()) + + val nonEmptyProperties = + PropertyValue.FlagProperties( + listOf( + FlagProperty( + key = "test", + propertyValue = "value", + propertyOperator = PropertyOperator.EXACT, + type = PropertyType.PERSON, + negation = false, + dependencyChain = null, + ), + ), + ) + assertTrue(!nonEmptyProperties.isEmpty()) + } + + @Test + public fun testPropertyOperatorFromString() { + assertEquals(PropertyOperator.EXACT, PropertyOperator.fromStringOrNull("exact")) + assertEquals(PropertyOperator.IS_NOT, PropertyOperator.fromStringOrNull("is_not")) + assertEquals(PropertyOperator.ICONTAINS, PropertyOperator.fromStringOrNull("icontains")) + assertEquals(PropertyOperator.REGEX, PropertyOperator.fromStringOrNull("regex")) + assertEquals(PropertyOperator.GT, PropertyOperator.fromStringOrNull("gt")) + assertEquals(PropertyOperator.IS_DATE_AFTER, PropertyOperator.fromStringOrNull("is_date_after")) + assertEquals(PropertyOperator.UNKNOWN, PropertyOperator.fromStringOrNull("invalid_operator")) + assertNull(PropertyOperator.fromStringOrNull(null)) + } + + @Test + public fun testPropertyTypeFromString() { + assertEquals(PropertyType.PERSON, PropertyType.fromStringOrNull("person")) + assertEquals(PropertyType.COHORT, PropertyType.fromStringOrNull("cohort")) + assertEquals(PropertyType.FLAG, PropertyType.fromStringOrNull("flag")) + assertEquals(PropertyType.PERSON, PropertyType.fromStringOrNull("invalid_type")) + assertNull(PropertyType.fromStringOrNull(null)) + } + + @Test + public fun testDeserializeCohortPropertiesMap() { + // Test that we can deserialize a map of cohort IDs to PropertyGroups + val json = + """ + { + "2": { + "type": "AND", + "values": [ + { + "type": "AND", + "values": [ + { + "key": "email", + "operator": "not_regex", + "type": "person", + "value": "@test.com$" + } + ] + } + ] + }, + "3": { + "type": "OR", + "values": [ + { + "key": "name", + "type": "person", + "value": "Test" + } + ] + } + } + """.trimIndent() + + val expected = + mapOf( + "2" to + PropertyGroup( + type = LogicalOperator.AND, + values = + PropertyValue.PropertyGroups( + listOf( + PropertyGroup( + type = LogicalOperator.AND, + values = + PropertyValue.FlagProperties( + listOf( + FlagProperty( + key = "email", + propertyValue = "@test.com$", + propertyOperator = PropertyOperator.NOT_REGEX, + type = PropertyType.PERSON, + negation = null, + dependencyChain = null, + ), + ), + ), + ), + ), + ), + ), + "3" to + PropertyGroup( + type = LogicalOperator.OR, + values = + PropertyValue.FlagProperties( + listOf( + FlagProperty( + key = "name", + propertyValue = "Test", + propertyOperator = null, + type = PropertyType.PERSON, + negation = null, + dependencyChain = null, + ), + ), + ), + ), + ) + + val type = object : TypeToken>() {}.type + assertEquals(expected, gson.fromJson(json, type)) + } + + @Test + public fun testActualCohortPropertiesStructure() { + // Test the exact structure used in FlagEvaluatorTest.createCohortProperties() + val json = + """ + { + "2": { + "type": "AND", + "values": [ + { + "type": "AND", + "values": [ + { + "key": "email", + "operator": "not_regex", + "type": "person", + "value": "@hedgebox.net$" + } + ] + }, + { + "type": "AND", + "values": [ + { + "key": "id", + "type": "cohort", + "negation": true, + "value": 3 + }, + { + "key": "email", + "operator": "is_set", + "type": "person", + "negation": false, + "value": "is_set" + } + ] + } + ] + } + } + """.trimIndent() + + val expected = + mapOf( + "2" to + PropertyGroup( + type = LogicalOperator.AND, + values = + PropertyValue.PropertyGroups( + listOf( + PropertyGroup( + type = LogicalOperator.AND, + values = + PropertyValue.FlagProperties( + listOf( + FlagProperty( + key = "email", + propertyValue = "@hedgebox.net$", + propertyOperator = PropertyOperator.NOT_REGEX, + type = PropertyType.PERSON, + negation = null, + dependencyChain = null, + ), + ), + ), + ), + PropertyGroup( + type = LogicalOperator.AND, + values = + PropertyValue.FlagProperties( + listOf( + FlagProperty( + key = "id", + propertyValue = 3, + propertyOperator = null, + type = PropertyType.COHORT, + negation = true, + dependencyChain = null, + ), + FlagProperty( + key = "email", + propertyValue = "is_set", + propertyOperator = PropertyOperator.IS_SET, + type = PropertyType.PERSON, + negation = false, + dependencyChain = null, + ), + ), + ), + ), + ), + ), + ), + ) + + val type = object : TypeToken>() {}.type + assertEquals(expected, gson.fromJson(json, type)) + } + + @Test + public fun testComplexNestedCohortStructure() { + val json = + """ + { + "type": "AND", + "values": [ + { + "type": "AND", + "values": [ + { + "key": "email", + "operator": "not_regex", + "value": "@hedgebox.net$" + } + ] + }, + { + "type": "AND", + "values": [ + { + "key": "id", + "negation": true, + "value": 3 + }, + { + "key": "email", + "operator": "is_set", + "negation": false, + "value": "is_set" + } + ] + } + ] + } + """.trimIndent() + + val expected = + PropertyGroup( + type = LogicalOperator.AND, + values = + PropertyValue.PropertyGroups( + listOf( + PropertyGroup( + type = LogicalOperator.AND, + values = + PropertyValue.FlagProperties( + listOf( + FlagProperty( + key = "email", + propertyValue = "@hedgebox.net$", + propertyOperator = PropertyOperator.NOT_REGEX, + type = null, + negation = null, + dependencyChain = null, + ), + ), + ), + ), + PropertyGroup( + type = LogicalOperator.AND, + values = + PropertyValue.FlagProperties( + listOf( + FlagProperty( + key = "id", + propertyValue = 3, + propertyOperator = null, + type = null, + negation = true, + dependencyChain = null, + ), + FlagProperty( + key = "email", + propertyValue = "is_set", + propertyOperator = PropertyOperator.IS_SET, + type = null, + negation = false, + dependencyChain = null, + ), + ), + ), + ), + ), + ), + ) + + assertEquals(expected, gson.fromJson(json, PropertyGroup::class.java)) + } +}