From 5e5e8b38e443881bce069cc8ec45aa0492b45ee3 Mon Sep 17 00:00:00 2001 From: Dustin Byrne Date: Mon, 6 Oct 2025 17:52:05 -0400 Subject: [PATCH 01/27] feat(core): Add `localEvaluation` API --- posthog/api/posthog.api | 16 ++++++++++ .../java/com/posthog/internal/PostHogApi.kt | 31 +++++++++++++++++++ .../PostHogLocalEvaluationResponse.kt | 18 +++++++++++ 3 files changed, 65 insertions(+) create mode 100644 posthog/src/main/java/com/posthog/internal/PostHogLocalEvaluationResponse.kt diff --git a/posthog/api/posthog.api b/posthog/api/posthog.api index 9363c219..cf04ea8c 100644 --- a/posthog/api/posthog.api +++ b/posthog/api/posthog.api @@ -537,6 +537,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/PostHogLocalEvaluationResponse; public final fun remoteConfig ()Lcom/posthog/internal/PostHogRemoteConfigResponse; public final fun snapshot (Ljava/util/List;)V } @@ -612,6 +613,21 @@ public final class com/posthog/internal/PostHogFlagsResponse : com/posthog/inter public fun toString ()Ljava/lang/String; } +public final class com/posthog/internal/PostHogLocalEvaluationResponse : com/posthog/internal/PostHogRemoteConfigResponse { + public fun (Ljava/util/List;Ljava/util/Map;Ljava/util/Map;)V + public final fun component1 ()Ljava/util/List; + public final fun component2 ()Ljava/util/Map; + public final fun component3 ()Ljava/util/Map; + public final fun copy (Ljava/util/List;Ljava/util/Map;Ljava/util/Map;)Lcom/posthog/internal/PostHogLocalEvaluationResponse; + public static synthetic fun copy$default (Lcom/posthog/internal/PostHogLocalEvaluationResponse;Ljava/util/List;Ljava/util/Map;Ljava/util/Map;ILjava/lang/Object;)Lcom/posthog/internal/PostHogLocalEvaluationResponse; + public fun equals (Ljava/lang/Object;)Z + public final fun getCohorts ()Ljava/util/Map; + public final fun getFlags ()Ljava/util/List; + public final fun getGroupTypeMapping ()Ljava/util/Map; + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + public abstract interface class com/posthog/internal/PostHogLogger { public abstract fun isEnabled ()Z public abstract fun log (Ljava/lang/String;)V diff --git a/posthog/src/main/java/com/posthog/internal/PostHogApi.kt b/posthog/src/main/java/com/posthog/internal/PostHogApi.kt index 5bff5b13..4972aa00 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): PostHogLocalEvaluationResponse? { + 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/PostHogLocalEvaluationResponse.kt b/posthog/src/main/java/com/posthog/internal/PostHogLocalEvaluationResponse.kt new file mode 100644 index 00000000..aadf9a95 --- /dev/null +++ b/posthog/src/main/java/com/posthog/internal/PostHogLocalEvaluationResponse.kt @@ -0,0 +1,18 @@ +package com.posthog.internal + +import com.posthog.PostHogInternal +import org.codehaus.mojo.animal_sniffer.IgnoreJRERequirement + +/** + * The response data structure for calling the /api/feature_flag/local_evaluation API + * @property flags the feature flag definitions for local evaluation + * @property groupTypeMapping the mapping of group type IDs to group types + * @property cohorts the cohort definitions for local evaluation + */ +@IgnoreJRERequirement +@PostHogInternal +public data class PostHogLocalEvaluationResponse( + val flags: List>?, + val groupTypeMapping: Map?, + val cohorts: Map>?, +) : PostHogRemoteConfigResponse() From 4c4d34417ead565712cdd300b440159f37b904e9 Mon Sep 17 00:00:00 2001 From: Dustin Byrne Date: Mon, 6 Oct 2025 17:52:45 -0400 Subject: [PATCH 02/27] feat(core): Add an optional shutdown override to Feature Flags --- posthog/api/posthog.api | 3 +++ posthog/src/main/java/com/posthog/PostHogStateless.kt | 1 + .../java/com/posthog/internal/PostHogFeatureFlagsInterface.kt | 4 ++++ 3 files changed, 8 insertions(+) diff --git a/posthog/api/posthog.api b/posthog/api/posthog.api index cf04ea8c..a741c9be 100644 --- a/posthog/api/posthog.api +++ b/posthog/api/posthog.api @@ -583,12 +583,14 @@ public abstract interface class com/posthog/internal/PostHogFeatureFlagsInterfac 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 { @@ -714,6 +716,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 { diff --git a/posthog/src/main/java/com/posthog/PostHogStateless.kt b/posthog/src/main/java/com/posthog/PostHogStateless.kt index d70108cf..4eb6aded 100644 --- a/posthog/src/main/java/com/posthog/PostHogStateless.kt +++ b/posthog/src/main/java/com/posthog/PostHogStateless.kt @@ -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/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 + } } From 45a13fca88bfd06305404312e40ca7b073e0c7ce Mon Sep 17 00:00:00 2001 From: Dustin Byrne Date: Mon, 6 Oct 2025 17:55:22 -0400 Subject: [PATCH 03/27] docs(core): Update CHANGELOG --- posthog/CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/posthog/CHANGELOG.md b/posthog/CHANGELOG.md index 0f837846..d4e3411f 100644 --- a/posthog/CHANGELOG.md +++ b/posthog/CHANGELOG.md @@ -1,6 +1,8 @@ ## 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)) ## 4.2.0 - 2025-10-23 From 6d60133396868c958111853f0b06ff6a03c92211 Mon Sep 17 00:00:00 2001 From: Dustin Byrne Date: Mon, 6 Oct 2025 18:04:41 -0400 Subject: [PATCH 04/27] feat(server): Add feature flags local evaluation --- posthog-server/api/posthog-server.api | 14 +- .../java/com/posthog/server/PostHogConfig.kt | 39 +- .../server/internal/FlagDefinitionModels.kt | 86 ++ .../posthog/server/internal/FlagEvaluator.kt | 803 +++++++++++++ .../server/internal/LocalEvaluationPoller.kt | 67 ++ .../server/internal/PostHogFeatureFlags.kt | 437 ++++++- .../server/internal/FlagEvaluatorTest.kt | 1054 +++++++++++++++++ .../internal/PostHogFeatureFlagsTest.kt | 2 +- 8 files changed, 2483 insertions(+), 19 deletions(-) create mode 100644 posthog-server/src/main/java/com/posthog/server/internal/FlagDefinitionModels.kt create mode 100644 posthog-server/src/main/java/com/posthog/server/internal/FlagEvaluator.kt create mode 100644 posthog-server/src/main/java/com/posthog/server/internal/LocalEvaluationPoller.kt create mode 100644 posthog-server/src/test/java/com/posthog/server/internal/FlagEvaluatorTest.kt diff --git a/posthog-server/api/posthog-server.api b/posthog-server/api/posthog-server.api index bcae56f7..0a3b7826 100644 --- a/posthog-server/api/posthog-server.api +++ b/posthog-server/api/posthog-server.api @@ -86,10 +86,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 +103,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 +121,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 +144,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; 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..6add3f9f 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,6 +107,25 @@ 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() @@ -145,6 +165,10 @@ public open class PostHogConfig constructor( api, cacheMaxAgeMs = featureFlagCacheMaxAgeMs, cacheMaxSize = featureFlagCacheSize, + localEvaluation = localEvaluation, + personalApiKey = personalApiKey, + pollIntervalSeconds = pollIntervalSeconds, + onFeatureFlags = onFeatureFlags, ) }, queueProvider = { config, api, endpoint, _, executor -> @@ -181,6 +205,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 +227,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 = false + 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 +263,12 @@ 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 } + + public fun pollIntervalSeconds(pollIntervalSeconds: Int): Builder = apply { this.pollIntervalSeconds = pollIntervalSeconds } + public fun build(): PostHogConfig = PostHogConfig( apiKey = apiKey, @@ -253,6 +287,9 @@ public open class PostHogConfig constructor( featureFlagCacheSize = featureFlagCacheSize, featureFlagCacheMaxAgeMs = featureFlagCacheMaxAgeMs, featureFlagCalledCacheSize = featureFlagCalledCacheSize, + localEvaluation = localEvaluation, + personalApiKey = personalApiKey, + pollIntervalSeconds = pollIntervalSeconds, ) } } diff --git a/posthog-server/src/main/java/com/posthog/server/internal/FlagDefinitionModels.kt b/posthog-server/src/main/java/com/posthog/server/internal/FlagDefinitionModels.kt new file mode 100644 index 00000000..845379f1 --- /dev/null +++ b/posthog-server/src/main/java/com/posthog/server/internal/FlagDefinitionModels.kt @@ -0,0 +1,86 @@ +package com.posthog.server.internal + +import com.google.gson.annotations.SerializedName + +/** + * Response from /api/feature_flag/local_evaluation/ + */ +internal data class LocalEvaluationResponse( + val flags: List?, + @SerializedName("group_type_mapping") + val groupTypeMapping: Map?, + val cohorts: Map?, +) + +/** + * Complete feature flag definition for local evaluation + */ +internal data class FlagDefinition( + val id: Int, + val name: String, + val key: String, + val active: Boolean, + val filters: FlagFilters, + val version: Int, +) + +/** + * Flag filters containing groups and multivariate config + */ +internal data class FlagFilters( + val groups: List?, + val multivariate: MultiVariateConfig?, + val payloads: Map?, +) + +/** + * A condition group with properties and rollout percentage + */ +internal data class FlagConditionGroup( + val properties: List?, + @SerializedName("rollout_percentage") + val rolloutPercentage: Int?, + val variant: String?, +) + +/** + * A property condition for flag evaluation + */ +internal data class FlagProperty( + val key: String, + val value: Any?, + val operator: String?, + val type: String?, + val negation: Boolean?, + @SerializedName("dependency_chain") + val dependencyChain: List?, +) + +/** + * Multivariate configuration for A/B testing + */ +internal data class MultiVariateConfig( + val variants: List?, +) + +/** + * A variant definition with key and rollout percentage + */ +internal data class VariantDefinition( + val key: String, + @SerializedName("rollout_percentage") + val rolloutPercentage: Double, +) + +/** + * Cohort definition for matching cohort properties + */ +internal data class CohortDefinition( + val type: String?, + val values: List?, +) + +/** + * 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/FlagEvaluator.kt b/posthog-server/src/main/java/com/posthog/server/internal/FlagEvaluator.kt new file mode 100644 index 00000000..0146ce3f --- /dev/null +++ b/posthog-server/src/main/java/com/posthog/server/internal/FlagEvaluator.kt @@ -0,0 +1,803 @@ +package com.posthog.server.internal + +import com.posthog.PostHogConfig +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("is_not") + private val REGEX_COMBINING_MARKS = "\\p{M}+".toRegex() + + // Date formatters for parsing various date formats + 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 + + val variants = flag.filters.multivariate?.variants ?: emptyList() + 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 operator = property.operator ?: "exact" + val value = property.value + + // 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 (operator == "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 (operator !in NONE_VALUES_ALLOWED_OPERATORS && overrideValue == null) { + return false + } + + return when (operator) { + "exact", "is_not" -> { + val matches = computeExactMatch(value, overrideValue) + if (operator == "exact") matches else !matches + } + + "is_set" -> propertyValues.containsKey(key) + "icontains" -> + stringContains( + overrideValue.toString(), + value.toString(), + ignoreCase = true, + ) + + "not_icontains" -> + !stringContains( + overrideValue.toString(), + value.toString(), + ignoreCase = true, + ) + + "regex" -> matchesRegex(value.toString(), overrideValue.toString()) + "not_regex" -> !matchesRegex(value.toString(), overrideValue.toString()) + "gt", "gte", "lt", "lte" -> compareValues(overrideValue, value, operator) + "is_date_before", "is_date_after" -> compareDates(overrideValue, value, operator) + else -> throw InconclusiveMatchException("Unknown operator: $operator") + } + } + + private fun computeExactMatch( + value: 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 { + value is List<*> -> { + value.any { v -> + v == expectedValue || (v != null && casefold(v.toString()) == expectedValue) + } + } + + else -> value == expectedValue || (value != null && casefold(value.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, + value: String, + ): Boolean { + return try { + Regex(pattern).find(value) != null + } catch (e: PatternSyntaxException) { + false + } + } + + private fun compareValues( + overrideValue: Any?, + value: Any?, + operator: String, + ): Boolean { + val numericValue = value?.toString()?.toDoubleOrNull() + + return if (numericValue != null && overrideValue != null) { + when (overrideValue) { + is String -> compareStrings(overrideValue, value.toString(), operator) + is Number -> compareNumbers(overrideValue.toDouble(), numericValue, operator) + else -> compareStrings(overrideValue.toString(), value.toString(), operator) + } + } else { + // String comparison if numeric parsing fails + compareStrings(overrideValue.toString(), value.toString(), operator) + } + } + + private fun compareNumbers( + lhs: Double, + rhs: Double, + operator: String, + ): Boolean { + return when (operator) { + "gt" -> lhs > rhs + "gte" -> lhs >= rhs + "lt" -> lhs < rhs + "lte" -> lhs <= rhs + else -> false + } + } + + private fun compareStrings( + lhs: String, + rhs: String, + operator: String, + ): Boolean { + return when (operator) { + "gt" -> lhs > rhs + "gte" -> lhs >= rhs + "lt" -> lhs < rhs + "lte" -> lhs <= rhs + else -> false + } + } + + private fun compareDates( + overrideValue: Any?, + value: Any?, + operator: String, + ): Boolean { + val parsedDate = + try { + parseDateValue(value.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 (operator) { + "is_date_before" -> overrideDate.isBefore(parsedDate) + "is_date_after" -> overrideDate.isAfter(parsedDate) + else -> false + } + } + + /** + * Parse date value from flag definition, supporting relative dates + */ + private fun parseDateValue(value: String): ZonedDateTime { + // Try relative date first (e.g., "-1d", "-2w", "-3m", "-1y") + val relativeDate = parseRelativeDate(value) + if (relativeDate != null) { + return relativeDate + } + + // Fall back to absolute date parsing + return parseOverrideDate(value) + } + + /** + * Parse relative date format (e.g., "-1d" or "1d" for 1 day ago). Always produces a date in the past. + */ + private fun parseRelativeDate(value: String): ZonedDateTime? { + val regex = Regex("^-?([0-9]+)([hdwmy])$") + val match = regex.find(value) ?: 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(value: String): ZonedDateTime { + try { + // Try ISO 8601 with timezone (standard format with 'T') + return ZonedDateTime.parse(value) + } catch (e: DateTimeParseException) { + // fall through + } + + try { + // Try ISO_DATE_TIME + return ZonedDateTime.parse(value, DateTimeFormatter.ISO_DATE_TIME) + } catch (e: DateTimeParseException) { + // fall through + } + + try { + // Try date only: "2022-05-01" + return java.time.LocalDate.parse(value, DateTimeFormatter.ISO_DATE).atStartOfDay(ZoneId.systemDefault()) + } catch (e: DateTimeParseException) { + // fall through + } + + try { + // Try Instant (UTC) + return Instant.parse(value).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(value, 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(value, 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(value, DATE_FORMATTER_NO_TZ).atZone(ZoneId.systemDefault()) + } catch (e: DateTimeParseException) { + // All formats failed + } + + throw DateTimeParseException("Unable to parse date: $value", value, 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.value?.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 + */ + @Suppress("UNCHECKED_CAST") + fun matchPropertyGroup( + propertyGroup: CohortDefinition, + propertyValues: Map, + cohortProperties: Map, + flagsByKey: Map?, + evaluationCache: MutableMap?, + distinctId: String?, + ): Boolean { + val groupType = propertyGroup.type + val properties = propertyGroup.values ?: return true + + if (properties.isEmpty()) { + // Empty groups are no-ops, always match + return true + } + + var errorMatchingLocally = false + + // Check if this is a nested property group + val firstProperty = properties.firstOrNull() + if (firstProperty is Map<*, *> && firstProperty.containsKey("values")) { + // Nested property groups + for (prop in properties) { + if (prop !is Map<*, *>) continue + + try { + val nestedGroup = + CohortDefinition( + type = prop["type"] as? String, + values = prop["values"] as? List, + ) + val matches = + matchPropertyGroup( + nestedGroup, + propertyValues, + cohortProperties, + flagsByKey, + evaluationCache, + distinctId, + ) + + if (groupType == "AND") { + if (!matches) return false + } else { + // OR group + if (matches) return true + } + } catch (e: InconclusiveMatchException) { + config.logger.log("Failed to compute property $prop 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 == "AND" + } + + // Regular properties + for (prop in properties) { + if (prop !is Map<*, *>) continue + + try { + val property = + FlagProperty( + key = prop["key"] as? String ?: "", + value = prop["value"], + operator = prop["operator"] as? String, + type = prop["type"] as? String, + negation = prop["negation"] as? Boolean, + dependencyChain = prop["dependency_chain"] as? List, + ) + + val matches = + when (property.type) { + "cohort" -> + matchCohort( + property, + propertyValues, + cohortProperties, + flagsByKey, + evaluationCache, + distinctId, + ) + + "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 == "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 $prop 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 == "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) { + "cohort" -> + matchCohort( + prop, + properties, + cohortProperties, + flagsByKey, + evaluationCache, + distinctId, + ) + + "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.value + val operator = property.operator ?: "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 (operator == "flag_evaluates_to") { + return matchesDependencyValue(expectedValue, actualValue) + } else { + throw InconclusiveMatchException("Flag dependency property for '${property.key}' has invalid operator '$operator'") + } + } + + // 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/LocalEvaluationPoller.kt b/posthog-server/src/main/java/com/posthog/server/internal/LocalEvaluationPoller.kt new file mode 100644 index 00000000..a7e7ad52 --- /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 = false + } + } + + 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..6d25cdfc 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,6 +1,7 @@ package com.posthog.server.internal import com.posthog.PostHogConfig +import com.posthog.PostHogOnFeatureFlags import com.posthog.internal.FeatureFlag import com.posthog.internal.PostHogApi import com.posthog.internal.PostHogFeatureFlagsInterface @@ -10,6 +11,10 @@ internal class PostHogFeatureFlags( 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 +22,32 @@ internal class PostHogFeatureFlags( maxAgeMs = cacheMaxAgeMs, ) + // Local evaluation state + @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 +56,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) + ) return flag?.variant ?: flag?.enabled ?: defaultValue } @@ -43,15 +78,108 @@ 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 + )?.metadata?.payload ?: defaultValue } + private fun fetchRemoteFlags( + distinctId: String, + groups: Map?, + personProperties: Map?, + groupProperties: Map?, + ): Map? { + val cacheKey = + FeatureFlagCacheKey( + distinctId = distinctId, + groups = groups, + personProperties = personProperties, + groupProperties = groupProperties, + ) + + val cachedFlags = cache.get(cacheKey) + if (cachedFlags != null) { + return cachedFlags + } + + return try { + val response = api.flags(distinctId, null, groups, personProperties, groupProperties) + val flags = response?.flags + cache.put(cacheKey, flags) + flags + } catch (e: Throwable) { + config.logger.log("Loading remote feature flags failed: $e") + null + } + } + + private fun resolveFeatureFlag( + key: String, + distinctId: String, + groups: Map?, + personProperties: Map?, + groupProperties: Map?, + ): FeatureFlag? { + val cacheKey = + FeatureFlagCacheKey( + distinctId = distinctId, + groups = groups, + personProperties = personProperties, + groupProperties = groupProperties, + ) + + val cachedFlags = cache.get(cacheKey) + if (cachedFlags != null) { + config.logger.log("Feature flags cache hit for distinctId: $distinctId") + val flag = cachedFlags[key] + if (flag != null) { + return flag + } + } + + if (localEvaluation) { + 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 flag + } catch (e: InconclusiveMatchException) { + config.logger.log("Local evaluation inconclusive for flag '$key': ${e.message}") + // Fall through to remote evaluation + } catch (e: Throwable) { + config.logger.log("Local evaluation failed for flag '$key': ${e.message}") + // Fall through to remote evaluation + } + } + } + + // 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") + return fetchRemoteFlags(distinctId, groups, personProperties, groupProperties)?.get(key) + } + override fun getFeatureFlags( distinctId: String?, groups: Map?, @@ -63,7 +191,6 @@ internal class PostHogFeatureFlags( return null } - // Create cache key from parameters val cacheKey = FeatureFlagCacheKey( distinctId = distinctId, @@ -79,21 +206,301 @@ internal class PostHogFeatureFlags( return cachedFlags } - // 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 - } catch (e: Throwable) { - config.logger.log("Loading feature flags failed: $e") - null + // Try local evaluation if enabled and flags are loaded + val currentFlagDefinitions = flagDefinitions ?: emptyMap() + if (localEvaluation && currentFlagDefinitions.isNotEmpty()) { + try { + 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}") + // Skip this flag, it will be fetched from API as a fallback below + } + } + + if (localFlags.isNotEmpty()) { + config.logger.log("Local evaluation successful for ${localFlags.size} flags") + // Don't cache locally evaluated flags, as they depend on properties + return localFlags + } + } catch (e: Throwable) { + config.logger.log("Local evaluation failed: ${e.message}") + // Fall through to API call + } } + + // Cache miss or local evaluation failed - fall back to API + config.logger.log("Feature flags cache miss for distinctId: $distinctId, calling API") + return fetchRemoteFlags(distinctId, groups, personProperties, groupProperties) } 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 + } + + try { + config.logger.log("Loading feature flags for local evaluation") + val response = api.localEvaluation(personalApiKey) + + if (response != null) { + // Parse flag definitions + val flags = + response.flags?.mapNotNull { flagMap -> + try { + parseFlagDefinition(flagMap) + } catch (e: Exception) { + config.logger.log("Failed to parse flag definition: ${e.message}") + null + } + } + + featureFlags = flags + flagDefinitions = flags?.associateBy { it.key } + cohorts = response.cohorts?.mapValues { parseCohortDefinition(it.value) } + groupTypeMapping = response.groupTypeMapping + + config.logger.log("Loaded ${flags?.size ?: 0} feature flags for local evaluation") + + if (!definitionsLoaded) { + 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, + ) + } + + /** + * Parse a flag definition from JSON map + */ + @Suppress("UNCHECKED_CAST") + private fun parseFlagDefinition(flagMap: Map): FlagDefinition { + return FlagDefinition( + id = (flagMap["id"] as? Number)?.toInt() ?: 0, + name = flagMap["name"] as? String ?: "", + key = flagMap["key"] as? String ?: "", + active = flagMap["active"] as? Boolean ?: false, + filters = parseFilters(flagMap["filters"] as? Map), + version = (flagMap["version"] as? Number)?.toInt() ?: 0, + ) + } + + /** + * Parse filters from JSON map + */ + @Suppress("UNCHECKED_CAST") + private fun parseFilters(filtersMap: Map?): FlagFilters { + val groups = + (filtersMap?.get("groups") as? List>)?.map { parseConditionGroup(it) } + val multivariate = parseMultiVariate(filtersMap?.get("multivariate") as? Map) + val payloads = filtersMap?.get("payloads") as? Map + + return FlagFilters( + groups = groups, + multivariate = multivariate, + payloads = payloads, + ) + } + + /** + * Parse a condition group from JSON map + */ + @Suppress("UNCHECKED_CAST") + private fun parseConditionGroup(groupMap: Map): FlagConditionGroup { + val properties = + (groupMap["properties"] as? List>)?.map { parseProperty(it) } + val rolloutPercentage = (groupMap["rollout_percentage"] as? Number)?.toInt() + val variant = groupMap["variant"] as? String + + return FlagConditionGroup( + properties = properties, + rolloutPercentage = rolloutPercentage, + variant = variant, + ) + } + + /** + * Parse a property from JSON map + */ + @Suppress("UNCHECKED_CAST") + private fun parseProperty(propMap: Map): FlagProperty { + return FlagProperty( + key = propMap["key"] as? String ?: "", + value = propMap["value"], + operator = propMap["operator"] as? String, + type = propMap["type"] as? String, + negation = propMap["negation"] as? Boolean, + dependencyChain = propMap["dependency_chain"] as? List, + ) + } + + /** + * Parse multivariate config from JSON map + */ + @Suppress("UNCHECKED_CAST") + private fun parseMultiVariate(multivariateMap: Map?): MultiVariateConfig? { + if (multivariateMap == null) return null + + val variants = + (multivariateMap["variants"] as? List>)?.map { variantMap -> + VariantDefinition( + key = variantMap["key"] as? String ?: "", + rolloutPercentage = + (variantMap["rollout_percentage"] as? Number)?.toDouble() + ?: 0.0, + ) + } + + return MultiVariateConfig(variants = variants) + } + + /** + * Parse cohort definition from JSON map + */ + @Suppress("UNCHECKED_CAST") + private fun parseCohortDefinition(cohortMap: Map): CohortDefinition { + return CohortDefinition( + type = cohortMap["type"] as? String, + values = cohortMap["values"] as? List, + ) + } + + /** + * 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 + } + } + + /** + * Compute a flag locally using the evaluation engine + */ + @Suppress("UNUSED_PARAMETER") + private fun computeFlagLocally( + key: String, + distinctId: String, + personProperties: Map, + groups: Map?, + groupProperties: Map?, + ): Any? { + val flags = this.flagDefinitions ?: return null + val flag = flags[key] ?: return null + + if (!flag.active) { + return false + } + + // Merge person and group properties for evaluation + val allProperties = personProperties.toMutableMap() + // Add group properties if available + groupProperties?.forEach { (k, v) -> allProperties[k] = v } + + val evaluationCache = mutableMapOf() + return evaluator.matchFeatureFlagProperties( + flag = flag, + distinctId = distinctId, + properties = allProperties, + cohortProperties = cohorts ?: emptyMap(), + flagsByKey = flags, + evaluationCache = evaluationCache, + ) + } } 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..b1196e4a --- /dev/null +++ b/posthog-server/src/test/java/com/posthog/server/internal/FlagEvaluatorTest.kt @@ -0,0 +1,1054 @@ +package com.posthog.server.internal + +import com.posthog.PostHogConfig +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", + value = "test@example.com", + operator = "exact", + type = "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", + value = "TEST@EXAMPLE.COM", + operator = "exact", + type = "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", + value = "Straße", + operator = "exact", + type = "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", + value = "ſun", + operator = "exact", + type = "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", + value = listOf("Straße", "München"), + operator = "exact", + type = "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", + value = listOf("chrome", "firefox"), + operator = "exact", + type = "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", + value = "other@example.com", + operator = "is_not", + type = "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", + value = null, + operator = "is_set", + type = "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", + value = "example", + operator = "icontains", + type = "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", + value = "Istanbul", + operator = "icontains", + type = "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", + value = "gmail", + operator = "not_icontains", + type = "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", + value = ".*@example\\.com", + operator = "regex", + type = "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", + value = ".*@gmail\\.com", + operator = "not_regex", + type = "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", + value = "18", + operator = "gt", + type = "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", + value = "18", + operator = "gte", + type = "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", + value = "65", + operator = "lt", + type = "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", + value = "65", + operator = "lte", + type = "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", + value = "2024-01-01T00:00:00Z", + operator = "is_date_before", + type = "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", + value = "2022-05-01", + operator = "is_date_before", + type = "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", + value = "2022-04-05 12:34:12 +01:00", + operator = "is_date_before", + type = "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", + value = "2022-04-05 12:34:12+01:00", + operator = "is_date_before", + type = "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", + value = "2022-05-01 00:00:00", + operator = "is_date_before", + type = "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", + value = "2024-01-01T00:00:00Z", + operator = "is_date_after", + type = "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", + value = "-7d", + operator = "is_date_after", + type = "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 flag = + FlagDefinition( + id = 1, + name = "Test Flag", + key = "test-flag", + active = true, + filters = + FlagFilters( + groups = + listOf( + FlagConditionGroup( + properties = + listOf( + FlagProperty( + key = "email", + value = "test@example.com", + operator = "exact", + type = "person", + negation = false, + dependencyChain = null, + ), + ), + rolloutPercentage = 100, + variant = null, + ), + ), + multivariate = null, + payloads = null, + ), + version = 1, + ) + + val properties = mapOf("email" to "test@example.com") + val result = evaluator.matchFeatureFlagProperties(flag, "user-123", properties) + assertEquals(true, result) + } + + @Test + internal fun testMatchFeatureFlagPropertiesNoMatch() { + val flag = + FlagDefinition( + id = 1, + name = "Test Flag", + key = "test-flag", + active = true, + filters = + FlagFilters( + groups = + listOf( + FlagConditionGroup( + properties = + listOf( + FlagProperty( + key = "email", + value = "test@example.com", + operator = "exact", + type = "person", + negation = false, + dependencyChain = null, + ), + ), + rolloutPercentage = 100, + variant = null, + ), + ), + multivariate = null, + payloads = null, + ), + version = 1, + ) + + val properties = mapOf("email" to "other@example.com") + val result = evaluator.matchFeatureFlagProperties(flag, "user-123", properties) + assertEquals(false, result) + } + + @Test + internal fun testMatchFeatureFlagPropertiesWithRollout() { + val flag = + FlagDefinition( + id = 1, + name = "Test Flag", + key = "test-flag", + active = true, + filters = + FlagFilters( + groups = + listOf( + FlagConditionGroup( + properties = emptyList(), + rolloutPercentage = 50, + variant = null, + ), + ), + multivariate = null, + payloads = null, + ), + version = 1, + ) + + // Test multiple users to verify some match and some don't + var matchCount = 0 + for (i in 1..100) { + val result = evaluator.matchFeatureFlagProperties(flag, "user-$i", emptyMap()) + if (result == true) matchCount++ + } + + // With 50% rollout, we should get roughly 50 matches out of 100 + // Allow some variance (40-60) + assertTrue("Expected ~50 matches, got $matchCount", matchCount in 40..60) + } + + @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", + value = "test", + operator = "exact", + type = "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 { + return FlagDefinition( + id = 1, + name = "Simple Flag", + key = "simple-flag", + active = true, + filters = + FlagFilters( + groups = + listOf( + FlagConditionGroup( + properties = emptyList(), + rolloutPercentage = 100, + variant = null, + ), + ), + multivariate = + MultiVariateConfig( + variants = + listOf( + VariantDefinition(key = "control", rolloutPercentage = 50.0), + VariantDefinition(key = "test", rolloutPercentage = 50.0), + ), + ), + payloads = null, + ), + version = 1, + ) + } + + @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 flag = + FlagDefinition( + id = 26, + name = "Cohort Member", + key = "cohort-member", + active = true, + filters = + FlagFilters( + groups = + listOf( + FlagConditionGroup( + properties = + listOf( + FlagProperty( + key = "id", + value = 2, + operator = "in", + type = "cohort", + negation = false, + dependencyChain = null, + ), + ), + rolloutPercentage = 100, + variant = null, + ), + ), + multivariate = null, + payloads = null, + ), + version = 2, + ) + + val cohortProperties = + mapOf( + "2" to + CohortDefinition( + type = "AND", + values = + listOf( + mapOf( + "type" to "AND", + "values" to + listOf( + mapOf( + "key" to "email", + "operator" to "not_regex", + "type" to "person", + "value" to "@hedgebox.net$", + ), + ), + ), + mapOf( + "type" to "AND", + "values" to + listOf( + mapOf( + "key" to "id", + "type" to "cohort", + "negation" to true, + "value" to 3, + ), + mapOf( + "key" to "email", + "operator" to "is_set", + "type" to "person", + "negation" to false, + "value" to "is_set", + ), + ), + ), + ), + ), + "3" to + CohortDefinition( + type = "OR", + values = + listOf( + mapOf( + "type" to "AND", + "values" to + listOf( + mapOf( + "key" to "email", + "operator" to "regex", + "type" to "person", + "negation" to false, + "value" to "@gmail.com", + ), + ), + ), + ), + ), + ) + + // 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 { + return FlagDefinition( + id = 25, + name = "Mixed Conditions", + key = "mixed-conditions", + active = true, + filters = + FlagFilters( + groups = + listOf( + FlagConditionGroup( + properties = + listOf( + FlagProperty( + key = "email", + value = listOf("example@example.com"), + operator = "exact", + type = "person", + negation = false, + dependencyChain = null, + ), + FlagProperty( + key = "email", + value = listOf("not_example@example.com", "also_not_example@example.com"), + operator = "is_not", + type = "person", + negation = false, + dependencyChain = null, + ), + FlagProperty( + key = "email", + value = "example", + operator = "icontains", + type = "person", + negation = false, + dependencyChain = null, + ), + FlagProperty( + key = "email", + value = ".net", + operator = "not_icontains", + type = "person", + negation = false, + dependencyChain = null, + ), + FlagProperty( + key = "email", + value = "\\w+@\\w+\\.\\w+", + operator = "regex", + type = "person", + negation = false, + dependencyChain = null, + ), + FlagProperty( + key = "email", + value = "@yahoo.com$", + operator = "not_regex", + type = "person", + negation = false, + dependencyChain = null, + ), + FlagProperty( + key = "email", + value = "is_set", + operator = "is_set", + type = "person", + negation = false, + dependencyChain = null, + ), + ), + rolloutPercentage = 100, + variant = null, + ), + ), + multivariate = null, + payloads = null, + ), + version = 1, + ) + } + + internal fun createCohortMemberFlag(): FlagDefinition { + return FlagDefinition( + id = 26, + name = "Cohort Member", + key = "cohort-member", + active = true, + filters = + FlagFilters( + groups = + listOf( + FlagConditionGroup( + properties = + listOf( + FlagProperty( + key = "id", + value = 2, + operator = "in", + type = "cohort", + negation = false, + dependencyChain = null, + ), + ), + rolloutPercentage = 100, + variant = null, + ), + ), + multivariate = null, + payloads = null, + ), + version = 2, + ) + } + + internal fun createCohortProperties(): Map { + return mapOf( + "2" to + CohortDefinition( + type = "AND", + values = + listOf( + mapOf( + "type" to "AND", + "values" to + listOf( + mapOf( + "key" to "email", + "operator" to "not_regex", + "type" to "person", + "value" to "@hedgebox.net$", + ), + ), + ), + mapOf( + "type" to "AND", + "values" to + listOf( + mapOf( + "key" to "id", + "type" to "cohort", + "negation" to true, + "value" to 3, + ), + mapOf( + "key" to "email", + "operator" to "is_set", + "type" to "person", + "negation" to false, + "value" to "is_set", + ), + ), + ), + ), + ), + "3" to + CohortDefinition( + type = "OR", + values = + listOf( + mapOf( + "type" to "AND", + "values" to + listOf( + mapOf( + "key" to "email", + "operator" to "regex", + "type" to "person", + "negation" to false, + "value" to "@gmail.com", + ), + ), + ), + ), + ), + ) + } + + internal fun createMultiVariateFlag(): FlagDefinition { + return FlagDefinition( + id = 1, + name = "Multi Variate Flag", + key = "multi-variate-flag", + active = true, + filters = + FlagFilters( + groups = + listOf( + FlagConditionGroup( + properties = emptyList(), + rolloutPercentage = 100, + variant = null, + ), + ), + multivariate = + MultiVariateConfig( + variants = + listOf( + VariantDefinition(key = "control", rolloutPercentage = 50.0), + VariantDefinition(key = "test", rolloutPercentage = 50.0), + ), + ), + payloads = null, + ), + version = 1, + ) + } +} 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..1edc50d7 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 @@ -212,7 +212,7 @@ internal class PostHogFeatureFlagsTest { ) assertNull(result) - assertTrue(logger.containsLog("Loading feature flags failed")) + assertTrue(logger.containsLog("Loading remote feature flags failed")) mockServer.shutdown() } From bf16cacbe9585cb78bcf1562015e898eb93845d4 Mon Sep 17 00:00:00 2001 From: Dustin Byrne Date: Mon, 6 Oct 2025 18:05:24 -0400 Subject: [PATCH 05/27] docs(server): Update CHANGELOG --- posthog-server/CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) 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 From f45a3555507f6ccafc89511664540e2c2db02664 Mon Sep 17 00:00:00 2001 From: Dustin Byrne Date: Mon, 6 Oct 2025 18:05:48 -0400 Subject: [PATCH 06/27] docs(server): Update USAGE --- posthog-server/USAGE.md | 55 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 55 insertions(+) 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 From 6f3db02f2cee0c7b7fe95a6a7ef8e4d2728d17d8 Mon Sep 17 00:00:00 2001 From: Dustin Byrne Date: Mon, 6 Oct 2025 18:07:01 -0400 Subject: [PATCH 07/27] chore: Update Java sample to include local eval --- .../java/sample/PostHogJavaExample.java | 57 ++++++++++++------- 1 file changed, 37 insertions(+), 20 deletions(-) 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..dc1ff4fe 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,23 +1,55 @@ 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 java.util.HashMap; -import java.util.Map; /** * Simple Java 1.8 example demonstrating PostHog usage */ public class PostHogJavaExample { + private static PostHogInterface postHog; + public static void main(String[] args) { PostHogConfig config = PostHogConfig - .builder("phc_wz4KZkikEluCCdfY2B2h7MXYygNGdTqFgjbU7I1ZdVR") + .builder("phc_qYXiHw5odMiVWF7Dwh2sHWS7Hj6FsutBNp2SEaMqS0A") + .personalApiKey("phx_example") + .host("http://localhost:8010") + .localEvaluation(true) + .debug(true) + .onFeatureFlags(() -> { + 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"); + String flagVariate = flagValue instanceof String ? (String) flagValue : "default"; + Object flagPayload = postHog.getFeatureFlagPayload("distinct-id", "multi-variate-flag"); + + System.out.println("The flag variant was: " + flagVariate); + System.out.println("Received flag payload: " + flagPayload); + + 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.flush(); + postHog.close(); + }) .build(); - PostHogInterface postHog = PostHog.with(config); + postHog = PostHog.with(config); postHog.group("distinct-id", "company", "some-company-id"); postHog.capture( @@ -36,24 +68,9 @@ public static void main(String[] args) { // AVOID - Anonymous inner class holds reference to outer class. // The following won't serialize properly. // postHog.identify("user-123", new HashMap() {{ - // put("key", "value"); + // put("key", "value"); // }}); postHog.alias("distinct-id", "alias-id"); - - - 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"); - String flagVariate = flagValue instanceof String ? (String) flagValue : "default"; - 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(); } } \ No newline at end of file From 9684198d288b849fe93fe000a53c819101aa2b9f Mon Sep 17 00:00:00 2001 From: Dustin Byrne Date: Wed, 8 Oct 2025 10:57:41 -0400 Subject: [PATCH 08/27] Don't shadow reserved keyword identifiers --- .../server/internal/FlagDefinitionModels.kt | 6 +- .../posthog/server/internal/FlagEvaluator.kt | 155 ++++++++------ .../server/internal/PostHogFeatureFlags.kt | 4 +- .../server/internal/FlagEvaluatorTest.kt | 192 ++++++++++-------- 4 files changed, 214 insertions(+), 143 deletions(-) diff --git a/posthog-server/src/main/java/com/posthog/server/internal/FlagDefinitionModels.kt b/posthog-server/src/main/java/com/posthog/server/internal/FlagDefinitionModels.kt index 845379f1..61e3c176 100644 --- a/posthog-server/src/main/java/com/posthog/server/internal/FlagDefinitionModels.kt +++ b/posthog-server/src/main/java/com/posthog/server/internal/FlagDefinitionModels.kt @@ -48,8 +48,10 @@ internal data class FlagConditionGroup( */ internal data class FlagProperty( val key: String, - val value: Any?, - val operator: String?, + @SerializedName("value") + val propertyValue: Any?, + @SerializedName("operator") + val propertyOperator: String?, val type: String?, val negation: Boolean?, @SerializedName("dependency_chain") 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 index 0146ce3f..766baa88 100644 --- a/posthog-server/src/main/java/com/posthog/server/internal/FlagEvaluator.kt +++ b/posthog-server/src/main/java/com/posthog/server/internal/FlagEvaluator.kt @@ -24,8 +24,10 @@ internal class FlagEvaluator( private val REGEX_COMBINING_MARKS = "\\p{M}+".toRegex() // Date formatters for parsing various date formats - 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_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 { @@ -111,8 +113,8 @@ internal class FlagEvaluator( propertyValues: Map, ): Boolean { val key = property.key - val operator = property.operator ?: "exact" - val value = property.value + val propertyOperator = property.propertyOperator ?: "exact" + val propertyValue = property.propertyValue // Check if property key exists in values if (!propertyValues.containsKey(key)) { @@ -120,48 +122,60 @@ internal class FlagEvaluator( } // is_not_set operator can't be evaluated locally - if (operator == "is_not_set") { + if (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 (operator !in NONE_VALUES_ALLOWED_OPERATORS && overrideValue == null) { + if (propertyOperator !in NONE_VALUES_ALLOWED_OPERATORS && overrideValue == null) { return false } - return when (operator) { + return when (propertyOperator) { "exact", "is_not" -> { - val matches = computeExactMatch(value, overrideValue) - if (operator == "exact") matches else !matches + val matches = computeExactMatch(propertyValue, overrideValue) + if (propertyOperator == "exact") matches else !matches } "is_set" -> propertyValues.containsKey(key) "icontains" -> stringContains( overrideValue.toString(), - value.toString(), + propertyValue.toString(), ignoreCase = true, ) "not_icontains" -> !stringContains( overrideValue.toString(), - value.toString(), + propertyValue.toString(), ignoreCase = true, ) - "regex" -> matchesRegex(value.toString(), overrideValue.toString()) - "not_regex" -> !matchesRegex(value.toString(), overrideValue.toString()) - "gt", "gte", "lt", "lte" -> compareValues(overrideValue, value, operator) - "is_date_before", "is_date_after" -> compareDates(overrideValue, value, operator) - else -> throw InconclusiveMatchException("Unknown operator: $operator") + "regex" -> matchesRegex(propertyValue.toString(), overrideValue.toString()) + "not_regex" -> !matchesRegex(propertyValue.toString(), overrideValue.toString()) + "gt", "gte", "lt", "lte" -> + compareValues( + overrideValue, + propertyValue, + propertyOperator, + ) + + "is_date_before", "is_date_after" -> + compareDates( + overrideValue, + propertyValue, + propertyOperator, + ) + + else -> throw InconclusiveMatchException("Unknown operator: $propertyOperator") } } private fun computeExactMatch( - value: Any?, + propertyValue: Any?, overrideValue: Any?, ): Boolean { // Lowercase to uppercase to normalize locale (e.g., Turkish i, German ß) @@ -169,13 +183,18 @@ internal class FlagEvaluator( // https://kotlinlang.org/api/core/1.3/kotlin-stdlib/kotlin.text/equals.html val expectedValue = overrideValue?.let { casefold(it.toString()) } return when { - value is List<*> -> { - value.any { v -> + propertyValue is List<*> -> { + propertyValue.any { v -> v == expectedValue || (v != null && casefold(v.toString()) == expectedValue) } } - else -> value == expectedValue || (value != null && casefold(value.toString()) == expectedValue) + else -> + propertyValue == expectedValue || ( + propertyValue != null && casefold( + propertyValue.toString(), + ) == expectedValue + ) } } @@ -192,10 +211,10 @@ internal class FlagEvaluator( private fun matchesRegex( pattern: String, - value: String, + propertyValue: String, ): Boolean { return try { - Regex(pattern).find(value) != null + Regex(pattern).find(propertyValue) != null } catch (e: PatternSyntaxException) { false } @@ -203,29 +222,46 @@ internal class FlagEvaluator( private fun compareValues( overrideValue: Any?, - value: Any?, - operator: String, + propertyValue: Any?, + propertyOperator: String, ): Boolean { - val numericValue = value?.toString()?.toDoubleOrNull() + val numericValue = propertyValue?.toString()?.toDoubleOrNull() return if (numericValue != null && overrideValue != null) { when (overrideValue) { - is String -> compareStrings(overrideValue, value.toString(), operator) - is Number -> compareNumbers(overrideValue.toDouble(), numericValue, operator) - else -> compareStrings(overrideValue.toString(), value.toString(), operator) + 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(), value.toString(), operator) + compareStrings(overrideValue.toString(), propertyValue.toString(), propertyOperator) } } private fun compareNumbers( lhs: Double, rhs: Double, - operator: String, + propertyOperator: String, ): Boolean { - return when (operator) { + return when (propertyOperator) { "gt" -> lhs > rhs "gte" -> lhs >= rhs "lt" -> lhs < rhs @@ -237,9 +273,9 @@ internal class FlagEvaluator( private fun compareStrings( lhs: String, rhs: String, - operator: String, + propertyOperator: String, ): Boolean { - return when (operator) { + return when (propertyOperator) { "gt" -> lhs > rhs "gte" -> lhs >= rhs "lt" -> lhs < rhs @@ -250,12 +286,12 @@ internal class FlagEvaluator( private fun compareDates( overrideValue: Any?, - value: Any?, - operator: String, + propertyValue: Any?, + propertyOperator: String, ): Boolean { val parsedDate = try { - parseDateValue(value.toString()) + parseDateValue(propertyValue.toString()) } catch (e: Exception) { throw InconclusiveMatchException("The date set on the flag is not a valid format") } @@ -272,10 +308,11 @@ internal class FlagEvaluator( 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 (operator) { + return when (propertyOperator) { "is_date_before" -> overrideDate.isBefore(parsedDate) "is_date_after" -> overrideDate.isAfter(parsedDate) else -> false @@ -285,23 +322,23 @@ internal class FlagEvaluator( /** * Parse date value from flag definition, supporting relative dates */ - private fun parseDateValue(value: String): ZonedDateTime { + private fun parseDateValue(propertyValue: String): ZonedDateTime { // Try relative date first (e.g., "-1d", "-2w", "-3m", "-1y") - val relativeDate = parseRelativeDate(value) + val relativeDate = parseRelativeDate(propertyValue) if (relativeDate != null) { return relativeDate } // Fall back to absolute date parsing - return parseOverrideDate(value) + 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(value: String): ZonedDateTime? { + private fun parseRelativeDate(propertyValue: String): ZonedDateTime? { val regex = Regex("^-?([0-9]+)([hdwmy])$") - val match = regex.find(value) ?: return null + val match = regex.find(propertyValue) ?: return null val number = match.groupValues[1].toIntOrNull() ?: return null val interval = match.groupValues[2] @@ -325,57 +362,59 @@ internal class FlagEvaluator( /** * Parse absolute date from string */ - private fun parseOverrideDate(value: String): ZonedDateTime { + private fun parseOverrideDate(propertyValue: String): ZonedDateTime { try { // Try ISO 8601 with timezone (standard format with 'T') - return ZonedDateTime.parse(value) + return ZonedDateTime.parse(propertyValue) } catch (e: DateTimeParseException) { // fall through } try { // Try ISO_DATE_TIME - return ZonedDateTime.parse(value, DateTimeFormatter.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(value, DateTimeFormatter.ISO_DATE).atStartOfDay(ZoneId.systemDefault()) + return java.time.LocalDate.parse(propertyValue, DateTimeFormatter.ISO_DATE) + .atStartOfDay(ZoneId.systemDefault()) } catch (e: DateTimeParseException) { // fall through } try { // Try Instant (UTC) - return Instant.parse(value).atZone(ZoneId.systemDefault()) + 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(value, DATE_FORMATTER_WITH_SPACE_TZ) + 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(value, DATE_FORMATTER_NO_SPACE_TZ) + 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(value, DATE_FORMATTER_NO_TZ).atZone(ZoneId.systemDefault()) + 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: $value", value, 0) + throw DateTimeParseException("Unable to parse date: $propertyValue", propertyValue, 0) } /** @@ -390,7 +429,7 @@ internal class FlagEvaluator( distinctId: String?, ): Boolean { val cohortId = - property.value?.toString() + property.propertyValue?.toString() ?: throw InconclusiveMatchException("Cohort property missing value") if (!cohortProperties.containsKey(cohortId)) { @@ -483,8 +522,8 @@ internal class FlagEvaluator( val property = FlagProperty( key = prop["key"] as? String ?: "", - value = prop["value"], - operator = prop["operator"] as? String, + propertyValue = prop["value"], + propertyOperator = prop["operator"] as? String, type = prop["type"] as? String, negation = prop["negation"] as? Boolean, dependencyChain = prop["dependency_chain"] as? List, @@ -748,8 +787,8 @@ internal class FlagEvaluator( // 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.value - val operator = property.operator ?: "exact" + val expectedValue = property.propertyValue + val propertyOperator = property.propertyOperator ?: "exact" if (expectedValue != null) { // Get the actual value of the flag we're checking @@ -761,10 +800,10 @@ internal class FlagEvaluator( } // For flag dependencies, we need to compare the actual flag result with expected value - if (operator == "flag_evaluates_to") { + if (propertyOperator == "flag_evaluates_to") { return matchesDependencyValue(expectedValue, actualValue) } else { - throw InconclusiveMatchException("Flag dependency property for '${property.key}' has invalid operator '$operator'") + throw InconclusiveMatchException("Flag dependency property for '${property.key}' has invalid operator '$propertyOperator'") } } 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 6d25cdfc..88571023 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 @@ -395,8 +395,8 @@ internal class PostHogFeatureFlags( private fun parseProperty(propMap: Map): FlagProperty { return FlagProperty( key = propMap["key"] as? String ?: "", - value = propMap["value"], - operator = propMap["operator"] as? String, + propertyValue = propMap["value"], + propertyOperator = propMap["operator"] as? String, type = propMap["type"] as? String, negation = propMap["negation"] as? Boolean, dependencyChain = propMap["dependency_chain"] as? List, 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 index b1196e4a..8f5e0380 100644 --- a/posthog-server/src/test/java/com/posthog/server/internal/FlagEvaluatorTest.kt +++ b/posthog-server/src/test/java/com/posthog/server/internal/FlagEvaluatorTest.kt @@ -32,8 +32,8 @@ internal class FlagEvaluatorTest { val property = FlagProperty( key = "email", - value = "test@example.com", - operator = "exact", + propertyValue = "test@example.com", + propertyOperator = "exact", type = "person", negation = false, dependencyChain = null, @@ -47,8 +47,8 @@ internal class FlagEvaluatorTest { val property = FlagProperty( key = "email", - value = "TEST@EXAMPLE.COM", - operator = "exact", + propertyValue = "TEST@EXAMPLE.COM", + propertyOperator = "exact", type = "person", negation = false, dependencyChain = null, @@ -63,8 +63,8 @@ internal class FlagEvaluatorTest { val propertyStrasse = FlagProperty( key = "location", - value = "Straße", - operator = "exact", + propertyValue = "Straße", + propertyOperator = "exact", type = "person", negation = false, dependencyChain = null, @@ -80,8 +80,8 @@ internal class FlagEvaluatorTest { val propertyLongS = FlagProperty( key = "star", - value = "ſun", - operator = "exact", + propertyValue = "ſun", + propertyOperator = "exact", type = "person", negation = false, dependencyChain = null, @@ -100,8 +100,8 @@ internal class FlagEvaluatorTest { val property = FlagProperty( key = "location", - value = listOf("Straße", "München"), - operator = "exact", + propertyValue = listOf("Straße", "München"), + propertyOperator = "exact", type = "person", negation = false, dependencyChain = null, @@ -117,8 +117,8 @@ internal class FlagEvaluatorTest { val property = FlagProperty( key = "browser", - value = listOf("chrome", "firefox"), - operator = "exact", + propertyValue = listOf("chrome", "firefox"), + propertyOperator = "exact", type = "person", negation = false, dependencyChain = null, @@ -132,8 +132,8 @@ internal class FlagEvaluatorTest { val property = FlagProperty( key = "email", - value = "other@example.com", - operator = "is_not", + propertyValue = "other@example.com", + propertyOperator = "is_not", type = "person", negation = false, dependencyChain = null, @@ -147,8 +147,8 @@ internal class FlagEvaluatorTest { val property = FlagProperty( key = "email", - value = null, - operator = "is_set", + propertyValue = null, + propertyOperator = "is_set", type = "person", negation = false, dependencyChain = null, @@ -170,8 +170,8 @@ internal class FlagEvaluatorTest { val property = FlagProperty( key = "email", - value = "example", - operator = "icontains", + propertyValue = "example", + propertyOperator = "icontains", type = "person", negation = false, dependencyChain = null, @@ -188,8 +188,8 @@ internal class FlagEvaluatorTest { val property = FlagProperty( key = "city", - value = "Istanbul", - operator = "icontains", + propertyValue = "Istanbul", + propertyOperator = "icontains", type = "person", negation = false, dependencyChain = null, @@ -206,8 +206,8 @@ internal class FlagEvaluatorTest { val property = FlagProperty( key = "email", - value = "gmail", - operator = "not_icontains", + propertyValue = "gmail", + propertyOperator = "not_icontains", type = "person", negation = false, dependencyChain = null, @@ -221,8 +221,8 @@ internal class FlagEvaluatorTest { val property = FlagProperty( key = "email", - value = ".*@example\\.com", - operator = "regex", + propertyValue = ".*@example\\.com", + propertyOperator = "regex", type = "person", negation = false, dependencyChain = null, @@ -236,8 +236,8 @@ internal class FlagEvaluatorTest { val property = FlagProperty( key = "email", - value = ".*@gmail\\.com", - operator = "not_regex", + propertyValue = ".*@gmail\\.com", + propertyOperator = "not_regex", type = "person", negation = false, dependencyChain = null, @@ -251,8 +251,8 @@ internal class FlagEvaluatorTest { val property = FlagProperty( key = "age", - value = "18", - operator = "gt", + propertyValue = "18", + propertyOperator = "gt", type = "person", negation = false, dependencyChain = null, @@ -269,8 +269,8 @@ internal class FlagEvaluatorTest { val property = FlagProperty( key = "age", - value = "18", - operator = "gte", + propertyValue = "18", + propertyOperator = "gte", type = "person", negation = false, dependencyChain = null, @@ -284,8 +284,8 @@ internal class FlagEvaluatorTest { val property = FlagProperty( key = "age", - value = "65", - operator = "lt", + propertyValue = "65", + propertyOperator = "lt", type = "person", negation = false, dependencyChain = null, @@ -299,8 +299,8 @@ internal class FlagEvaluatorTest { val property = FlagProperty( key = "age", - value = "65", - operator = "lte", + propertyValue = "65", + propertyOperator = "lte", type = "person", negation = false, dependencyChain = null, @@ -314,8 +314,8 @@ internal class FlagEvaluatorTest { val property = FlagProperty( key = "signup_date", - value = "2024-01-01T00:00:00Z", - operator = "is_date_before", + propertyValue = "2024-01-01T00:00:00Z", + propertyOperator = "is_date_before", type = "person", negation = false, dependencyChain = null, @@ -330,8 +330,8 @@ internal class FlagEvaluatorTest { val propertyIsoDate = FlagProperty( key = "signup_date", - value = "2022-05-01", - operator = "is_date_before", + propertyValue = "2022-05-01", + propertyOperator = "is_date_before", type = "person", negation = false, dependencyChain = null, @@ -344,33 +344,48 @@ internal class FlagEvaluatorTest { val propertyWithSpace = FlagProperty( key = "key", - value = "2022-04-05 12:34:12 +01:00", - operator = "is_date_before", + propertyValue = "2022-04-05 12:34:12 +01:00", + propertyOperator = "is_date_before", type = "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"))) + 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", - value = "2022-04-05 12:34:12+01:00", - operator = "is_date_before", + propertyValue = "2022-04-05 12:34:12+01:00", + propertyOperator = "is_date_before", type = "person", negation = false, dependencyChain = null, ) - assertTrue(evaluator.matchProperty(propertyNoSpace, mapOf("key" to "2022-04-05 12:34:11+01:00"))) + assertTrue( + evaluator.matchProperty( + propertyNoSpace, + mapOf("key" to "2022-04-05 12:34:11+01:00"), + ), + ) // ISO datetime without timezone val propertyNoTz = FlagProperty( key = "key", - value = "2022-05-01 00:00:00", - operator = "is_date_before", + propertyValue = "2022-05-01 00:00:00", + propertyOperator = "is_date_before", type = "person", negation = false, dependencyChain = null, @@ -383,8 +398,8 @@ internal class FlagEvaluatorTest { val property = FlagProperty( key = "signup_date", - value = "2024-01-01T00:00:00Z", - operator = "is_date_after", + propertyValue = "2024-01-01T00:00:00Z", + propertyOperator = "is_date_after", type = "person", negation = false, dependencyChain = null, @@ -398,8 +413,8 @@ internal class FlagEvaluatorTest { val property = FlagProperty( key = "last_seen", - value = "-7d", - operator = "is_date_after", + propertyValue = "-7d", + propertyOperator = "is_date_after", type = "person", negation = false, dependencyChain = null, @@ -442,8 +457,8 @@ internal class FlagEvaluatorTest { listOf( FlagProperty( key = "email", - value = "test@example.com", - operator = "exact", + propertyValue = "test@example.com", + propertyOperator = "exact", type = "person", negation = false, dependencyChain = null, @@ -481,8 +496,8 @@ internal class FlagEvaluatorTest { listOf( FlagProperty( key = "email", - value = "test@example.com", - operator = "exact", + propertyValue = "test@example.com", + propertyOperator = "exact", type = "person", negation = false, dependencyChain = null, @@ -554,8 +569,8 @@ internal class FlagEvaluatorTest { val property = FlagProperty( key = "missing_key", - value = "test", - operator = "exact", + propertyValue = "test", + propertyOperator = "exact", type = "person", negation = false, dependencyChain = null, @@ -606,7 +621,8 @@ internal class FlagEvaluatorTest { internal fun testMixedConditionsFlag() { val flag = createMixedConditionsFlag() val withoutSpaces = mapOf("email" to "example@example.com") - val resultWithoutSpaces = evaluator.matchFeatureFlagProperties(flag, "user-123", withoutSpaces) + val resultWithoutSpaces = + evaluator.matchFeatureFlagProperties(flag, "user-123", withoutSpaces) assertEquals(true, resultWithoutSpaces) } @@ -701,8 +717,8 @@ internal class FlagEvaluatorTest { listOf( FlagProperty( key = "id", - value = 2, - operator = "in", + propertyValue = 2, + propertyOperator = "in", type = "cohort", negation = false, dependencyChain = null, @@ -782,7 +798,13 @@ internal class FlagEvaluatorTest { // 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) + val result = + evaluator.matchFeatureFlagProperties( + flag, + "user-123", + matchingProperties, + cohortProperties, + ) assertEquals(true, result) } @@ -793,7 +815,8 @@ internal class FlagEvaluatorTest { // 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) + val result = + evaluator.matchFeatureFlagProperties(flag, "user-123", properties, cohortProperties) assertEquals(false, result) } @@ -804,7 +827,8 @@ internal class FlagEvaluatorTest { // 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) + val result = + evaluator.matchFeatureFlagProperties(flag, "user-123", properties, cohortProperties) assertEquals(false, result) } @@ -830,7 +854,8 @@ internal class FlagEvaluatorTest { // 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) + val result = + evaluator.matchFeatureFlagProperties(flag, "user-123", properties, cohortProperties) assertEquals(true, result) } @@ -841,7 +866,8 @@ internal class FlagEvaluatorTest { // 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) + val result = + evaluator.matchFeatureFlagProperties(flag, "user-123", properties, cohortProperties) assertEquals(true, result) } @@ -860,56 +886,60 @@ internal class FlagEvaluatorTest { listOf( FlagProperty( key = "email", - value = listOf("example@example.com"), - operator = "exact", + propertyValue = listOf("example@example.com"), + propertyOperator = "exact", type = "person", negation = false, dependencyChain = null, ), FlagProperty( key = "email", - value = listOf("not_example@example.com", "also_not_example@example.com"), - operator = "is_not", + propertyValue = + listOf( + "not_example@example.com", + "also_not_example@example.com", + ), + propertyOperator = "is_not", type = "person", negation = false, dependencyChain = null, ), FlagProperty( key = "email", - value = "example", - operator = "icontains", + propertyValue = "example", + propertyOperator = "icontains", type = "person", negation = false, dependencyChain = null, ), FlagProperty( key = "email", - value = ".net", - operator = "not_icontains", + propertyValue = ".net", + propertyOperator = "not_icontains", type = "person", negation = false, dependencyChain = null, ), FlagProperty( key = "email", - value = "\\w+@\\w+\\.\\w+", - operator = "regex", + propertyValue = "\\w+@\\w+\\.\\w+", + propertyOperator = "regex", type = "person", negation = false, dependencyChain = null, ), FlagProperty( key = "email", - value = "@yahoo.com$", - operator = "not_regex", + propertyValue = "@yahoo.com$", + propertyOperator = "not_regex", type = "person", negation = false, dependencyChain = null, ), FlagProperty( key = "email", - value = "is_set", - operator = "is_set", + propertyValue = "is_set", + propertyOperator = "is_set", type = "person", negation = false, dependencyChain = null, @@ -941,8 +971,8 @@ internal class FlagEvaluatorTest { listOf( FlagProperty( key = "id", - value = 2, - operator = "in", + propertyValue = 2, + propertyOperator = "in", type = "cohort", negation = false, dependencyChain = null, From bde72bdd430b832acb0e04cc7777c3f3ce74b174 Mon Sep 17 00:00:00 2001 From: Dustin Byrne Date: Wed, 8 Oct 2025 11:07:31 -0400 Subject: [PATCH 09/27] Drop a list allocation --- .../posthog/server/internal/FlagEvaluator.kt | 23 ++++++++++--------- 1 file changed, 12 insertions(+), 11 deletions(-) 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 index 766baa88..c86fc629 100644 --- a/posthog-server/src/main/java/com/posthog/server/internal/FlagEvaluator.kt +++ b/posthog-server/src/main/java/com/posthog/server/internal/FlagEvaluator.kt @@ -89,17 +89,18 @@ internal class FlagEvaluator( val lookupTable = mutableListOf() var valueMin = 0.0 - val variants = flag.filters.multivariate?.variants ?: emptyList() - for (variant in variants) { - val valueMax = valueMin + (variant.rolloutPercentage / 100.0) - lookupTable.add( - VariantLookupEntry( - key = variant.key, - valueMin = valueMin, - valueMax = valueMax, - ), - ) - valueMin = valueMax + 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 } From 59c2681eecb53b66611852cae56f42646bd2cfb3 Mon Sep 17 00:00:00 2001 From: Dustin Byrne Date: Wed, 8 Oct 2025 12:01:59 -0400 Subject: [PATCH 10/27] Add enums for property operators and types --- .../server/internal/FlagDefinitionModels.kt | 4 +- .../posthog/server/internal/FlagEvaluator.kt | 78 +++++----- .../server/internal/PostHogFeatureFlags.kt | 4 +- .../server/internal/PropertyOperator.kt | 50 ++++++ .../posthog/server/internal/PropertyType.kt | 23 +++ .../server/internal/FlagEvaluatorTest.kt | 144 +++++++++--------- 6 files changed, 193 insertions(+), 110 deletions(-) create mode 100644 posthog-server/src/main/java/com/posthog/server/internal/PropertyOperator.kt create mode 100644 posthog-server/src/main/java/com/posthog/server/internal/PropertyType.kt diff --git a/posthog-server/src/main/java/com/posthog/server/internal/FlagDefinitionModels.kt b/posthog-server/src/main/java/com/posthog/server/internal/FlagDefinitionModels.kt index 61e3c176..38e41f30 100644 --- a/posthog-server/src/main/java/com/posthog/server/internal/FlagDefinitionModels.kt +++ b/posthog-server/src/main/java/com/posthog/server/internal/FlagDefinitionModels.kt @@ -51,8 +51,8 @@ internal data class FlagProperty( @SerializedName("value") val propertyValue: Any?, @SerializedName("operator") - val propertyOperator: String?, - val type: String?, + val propertyOperator: PropertyOperator?, + val type: PropertyType?, val negation: Boolean?, @SerializedName("dependency_chain") val dependencyChain: List?, 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 index c86fc629..85661f55 100644 --- a/posthog-server/src/main/java/com/posthog/server/internal/FlagEvaluator.kt +++ b/posthog-server/src/main/java/com/posthog/server/internal/FlagEvaluator.kt @@ -20,7 +20,7 @@ internal class FlagEvaluator( ) { companion object { private const val LONG_SCALE = 0xFFFFFFFFFFFFFFF.toDouble() - private val NONE_VALUES_ALLOWED_OPERATORS = setOf("is_not") + private val NONE_VALUES_ALLOWED_OPERATORS = setOf(PropertyOperator.IS_NOT) private val REGEX_COMBINING_MARKS = "\\p{M}+".toRegex() // Date formatters for parsing various date formats @@ -114,7 +114,7 @@ internal class FlagEvaluator( propertyValues: Map, ): Boolean { val key = property.key - val propertyOperator = property.propertyOperator ?: "exact" + val propertyOperator = property.propertyOperator ?: PropertyOperator.EXACT val propertyValue = property.propertyValue // Check if property key exists in values @@ -123,7 +123,7 @@ internal class FlagEvaluator( } // is_not_set operator can't be evaluated locally - if (propertyOperator == "is_not_set") { + if (propertyOperator == PropertyOperator.IS_NOT_SET) { throw InconclusiveMatchException("Can't match properties with operator is_not_set") } @@ -135,36 +135,46 @@ internal class FlagEvaluator( } return when (propertyOperator) { - "exact", "is_not" -> { + PropertyOperator.EXACT, PropertyOperator.IS_NOT -> { val matches = computeExactMatch(propertyValue, overrideValue) - if (propertyOperator == "exact") matches else !matches + if (propertyOperator == PropertyOperator.EXACT) matches else !matches } - "is_set" -> propertyValues.containsKey(key) - "icontains" -> + PropertyOperator.IS_SET -> propertyValues.containsKey(key) + PropertyOperator.ICONTAINS -> stringContains( overrideValue.toString(), propertyValue.toString(), ignoreCase = true, ) - "not_icontains" -> + PropertyOperator.NOT_ICONTAINS -> !stringContains( overrideValue.toString(), propertyValue.toString(), ignoreCase = true, ) - "regex" -> matchesRegex(propertyValue.toString(), overrideValue.toString()) - "not_regex" -> !matchesRegex(propertyValue.toString(), overrideValue.toString()) - "gt", "gte", "lt", "lte" -> + 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, ) - "is_date_before", "is_date_after" -> + PropertyOperator.IS_DATE_BEFORE, PropertyOperator.IS_DATE_AFTER -> compareDates( overrideValue, propertyValue, @@ -224,7 +234,7 @@ internal class FlagEvaluator( private fun compareValues( overrideValue: Any?, propertyValue: Any?, - propertyOperator: String, + propertyOperator: PropertyOperator, ): Boolean { val numericValue = propertyValue?.toString()?.toDoubleOrNull() @@ -260,13 +270,13 @@ internal class FlagEvaluator( private fun compareNumbers( lhs: Double, rhs: Double, - propertyOperator: String, + propertyOperator: PropertyOperator, ): Boolean { return when (propertyOperator) { - "gt" -> lhs > rhs - "gte" -> lhs >= rhs - "lt" -> lhs < rhs - "lte" -> lhs <= rhs + PropertyOperator.GT -> lhs > rhs + PropertyOperator.GTE -> lhs >= rhs + PropertyOperator.LT -> lhs < rhs + PropertyOperator.LTE -> lhs <= rhs else -> false } } @@ -274,13 +284,13 @@ internal class FlagEvaluator( private fun compareStrings( lhs: String, rhs: String, - propertyOperator: String, + propertyOperator: PropertyOperator, ): Boolean { return when (propertyOperator) { - "gt" -> lhs > rhs - "gte" -> lhs >= rhs - "lt" -> lhs < rhs - "lte" -> lhs <= rhs + PropertyOperator.GT -> lhs > rhs + PropertyOperator.GTE -> lhs >= rhs + PropertyOperator.LT -> lhs < rhs + PropertyOperator.LTE -> lhs <= rhs else -> false } } @@ -288,7 +298,7 @@ internal class FlagEvaluator( private fun compareDates( overrideValue: Any?, propertyValue: Any?, - propertyOperator: String, + propertyOperator: PropertyOperator, ): Boolean { val parsedDate = try { @@ -314,8 +324,8 @@ internal class FlagEvaluator( } return when (propertyOperator) { - "is_date_before" -> overrideDate.isBefore(parsedDate) - "is_date_after" -> overrideDate.isAfter(parsedDate) + PropertyOperator.IS_DATE_BEFORE -> overrideDate.isBefore(parsedDate) + PropertyOperator.IS_DATE_AFTER -> overrideDate.isAfter(parsedDate) else -> false } } @@ -524,15 +534,15 @@ internal class FlagEvaluator( FlagProperty( key = prop["key"] as? String ?: "", propertyValue = prop["value"], - propertyOperator = prop["operator"] as? String, - type = prop["type"] as? String, + propertyOperator = PropertyOperator.fromStringOrNull(prop["operator"] as? String), + type = PropertyType.fromStringOrNull(prop["type"] as? String), negation = prop["negation"] as? Boolean, dependencyChain = prop["dependency_chain"] as? List, ) val matches = when (property.type) { - "cohort" -> + PropertyType.COHORT -> matchCohort( property, propertyValues, @@ -542,7 +552,7 @@ internal class FlagEvaluator( distinctId, ) - "flag" -> + PropertyType.FLAG -> evaluateFlagDependency( property, flagsByKey @@ -603,7 +613,7 @@ internal class FlagEvaluator( for (prop in conditionProperties) { val matches = when (prop.type) { - "cohort" -> + PropertyType.COHORT -> matchCohort( prop, properties, @@ -613,7 +623,7 @@ internal class FlagEvaluator( distinctId, ) - "flag" -> + PropertyType.FLAG -> evaluateFlagDependency( prop, flagsByKey @@ -789,7 +799,7 @@ internal class FlagEvaluator( // 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 ?: "exact" + val propertyOperator = property.propertyOperator ?: PropertyOperator.EXACT if (expectedValue != null) { // Get the actual value of the flag we're checking @@ -801,7 +811,7 @@ internal class FlagEvaluator( } // For flag dependencies, we need to compare the actual flag result with expected value - if (propertyOperator == "flag_evaluates_to") { + if (propertyOperator == PropertyOperator.FLAG_EVALUATES_TO) { return matchesDependencyValue(expectedValue, actualValue) } else { throw InconclusiveMatchException("Flag dependency property for '${property.key}' has invalid operator '$propertyOperator'") 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 88571023..83f9c0cb 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 @@ -396,8 +396,8 @@ internal class PostHogFeatureFlags( return FlagProperty( key = propMap["key"] as? String ?: "", propertyValue = propMap["value"], - propertyOperator = propMap["operator"] as? String, - type = propMap["type"] as? String, + propertyOperator = PropertyOperator.fromStringOrNull(propMap["operator"] as? String), + type = PropertyType.fromStringOrNull(propMap["type"] as? String), negation = propMap["negation"] as? Boolean, dependencyChain = propMap["dependency_chain"] as? List, ) diff --git a/posthog-server/src/main/java/com/posthog/server/internal/PropertyOperator.kt b/posthog-server/src/main/java/com/posthog/server/internal/PropertyOperator.kt new file mode 100644 index 00000000..681cf5ca --- /dev/null +++ b/posthog-server/src/main/java/com/posthog/server/internal/PropertyOperator.kt @@ -0,0 +1,50 @@ +package com.posthog.server.internal + +internal enum class PropertyOperator { + UNKNOWN, + EXACT, + IS_NOT, + IS_SET, + IS_NOT_SET, + ICONTAINS, + NOT_ICONTAINS, + REGEX, + NOT_REGEX, + IN, + GT, + GTE, + LT, + LTE, + IS_DATE_BEFORE, + IS_DATE_AFTER, + FLAG_EVALUATES_TO, + ; + + companion object { + fun fromString(value: String): PropertyOperator { + return when (value) { + "exact" -> EXACT + "is_not" -> IS_NOT + "is_set" -> IS_SET + "is_not_set" -> IS_NOT_SET + "icontains" -> ICONTAINS + "not_icontains" -> NOT_ICONTAINS + "regex" -> REGEX + "not_regex" -> NOT_REGEX + "in" -> IN + "gt" -> GT + "gte" -> GTE + "lt" -> LT + "lte" -> LTE + "is_date_before" -> IS_DATE_BEFORE + "is_date_after" -> IS_DATE_AFTER + "flag_evaluates_to" -> FLAG_EVALUATES_TO + else -> UNKNOWN + } + } + + fun fromStringOrNull(str: String?): PropertyOperator? { + return str?.let { fromString(it) } + } + } +} diff --git a/posthog-server/src/main/java/com/posthog/server/internal/PropertyType.kt b/posthog-server/src/main/java/com/posthog/server/internal/PropertyType.kt new file mode 100644 index 00000000..3f6c6bf1 --- /dev/null +++ b/posthog-server/src/main/java/com/posthog/server/internal/PropertyType.kt @@ -0,0 +1,23 @@ +package com.posthog.server.internal + +internal enum class PropertyType { + COHORT, + FLAG, + PERSON, + ; + + companion object { + fun fromString(value: String): PropertyType { + return when (value) { + "cohort" -> COHORT + "flag" -> FLAG + "person" -> PERSON + else -> PERSON + } + } + + fun fromStringOrNull(str: String?): PropertyType? { + return str?.let { fromString(it) } + } + } +} 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 index 8f5e0380..ecb5b066 100644 --- a/posthog-server/src/test/java/com/posthog/server/internal/FlagEvaluatorTest.kt +++ b/posthog-server/src/test/java/com/posthog/server/internal/FlagEvaluatorTest.kt @@ -33,8 +33,8 @@ internal class FlagEvaluatorTest { FlagProperty( key = "email", propertyValue = "test@example.com", - propertyOperator = "exact", - type = "person", + propertyOperator = PropertyOperator.EXACT, + type = PropertyType.PERSON, negation = false, dependencyChain = null, ) @@ -48,8 +48,8 @@ internal class FlagEvaluatorTest { FlagProperty( key = "email", propertyValue = "TEST@EXAMPLE.COM", - propertyOperator = "exact", - type = "person", + propertyOperator = PropertyOperator.EXACT, + type = PropertyType.PERSON, negation = false, dependencyChain = null, ) @@ -64,8 +64,8 @@ internal class FlagEvaluatorTest { FlagProperty( key = "location", propertyValue = "Straße", - propertyOperator = "exact", - type = "person", + propertyOperator = PropertyOperator.EXACT, + type = PropertyType.PERSON, negation = false, dependencyChain = null, ) @@ -81,8 +81,8 @@ internal class FlagEvaluatorTest { FlagProperty( key = "star", propertyValue = "ſun", - propertyOperator = "exact", - type = "person", + propertyOperator = PropertyOperator.EXACT, + type = PropertyType.PERSON, negation = false, dependencyChain = null, ) @@ -101,8 +101,8 @@ internal class FlagEvaluatorTest { FlagProperty( key = "location", propertyValue = listOf("Straße", "München"), - propertyOperator = "exact", - type = "person", + propertyOperator = PropertyOperator.EXACT, + type = PropertyType.PERSON, negation = false, dependencyChain = null, ) @@ -118,8 +118,8 @@ internal class FlagEvaluatorTest { FlagProperty( key = "browser", propertyValue = listOf("chrome", "firefox"), - propertyOperator = "exact", - type = "person", + propertyOperator = PropertyOperator.EXACT, + type = PropertyType.PERSON, negation = false, dependencyChain = null, ) @@ -133,8 +133,8 @@ internal class FlagEvaluatorTest { FlagProperty( key = "email", propertyValue = "other@example.com", - propertyOperator = "is_not", - type = "person", + propertyOperator = PropertyOperator.IS_NOT, + type = PropertyType.PERSON, negation = false, dependencyChain = null, ) @@ -148,8 +148,8 @@ internal class FlagEvaluatorTest { FlagProperty( key = "email", propertyValue = null, - propertyOperator = "is_set", - type = "person", + propertyOperator = PropertyOperator.IS_SET, + type = PropertyType.PERSON, negation = false, dependencyChain = null, ) @@ -171,8 +171,8 @@ internal class FlagEvaluatorTest { FlagProperty( key = "email", propertyValue = "example", - propertyOperator = "icontains", - type = "person", + propertyOperator = PropertyOperator.ICONTAINS, + type = PropertyType.PERSON, negation = false, dependencyChain = null, ) @@ -189,8 +189,8 @@ internal class FlagEvaluatorTest { FlagProperty( key = "city", propertyValue = "Istanbul", - propertyOperator = "icontains", - type = "person", + propertyOperator = PropertyOperator.ICONTAINS, + type = PropertyType.PERSON, negation = false, dependencyChain = null, ) @@ -207,8 +207,8 @@ internal class FlagEvaluatorTest { FlagProperty( key = "email", propertyValue = "gmail", - propertyOperator = "not_icontains", - type = "person", + propertyOperator = PropertyOperator.NOT_ICONTAINS, + type = PropertyType.PERSON, negation = false, dependencyChain = null, ) @@ -222,8 +222,8 @@ internal class FlagEvaluatorTest { FlagProperty( key = "email", propertyValue = ".*@example\\.com", - propertyOperator = "regex", - type = "person", + propertyOperator = PropertyOperator.REGEX, + type = PropertyType.PERSON, negation = false, dependencyChain = null, ) @@ -237,8 +237,8 @@ internal class FlagEvaluatorTest { FlagProperty( key = "email", propertyValue = ".*@gmail\\.com", - propertyOperator = "not_regex", - type = "person", + propertyOperator = PropertyOperator.NOT_REGEX, + type = PropertyType.PERSON, negation = false, dependencyChain = null, ) @@ -252,8 +252,8 @@ internal class FlagEvaluatorTest { FlagProperty( key = "age", propertyValue = "18", - propertyOperator = "gt", - type = "person", + propertyOperator = PropertyOperator.GT, + type = PropertyType.PERSON, negation = false, dependencyChain = null, ) @@ -270,8 +270,8 @@ internal class FlagEvaluatorTest { FlagProperty( key = "age", propertyValue = "18", - propertyOperator = "gte", - type = "person", + propertyOperator = PropertyOperator.GTE, + type = PropertyType.PERSON, negation = false, dependencyChain = null, ) @@ -285,8 +285,8 @@ internal class FlagEvaluatorTest { FlagProperty( key = "age", propertyValue = "65", - propertyOperator = "lt", - type = "person", + propertyOperator = PropertyOperator.LT, + type = PropertyType.PERSON, negation = false, dependencyChain = null, ) @@ -300,8 +300,8 @@ internal class FlagEvaluatorTest { FlagProperty( key = "age", propertyValue = "65", - propertyOperator = "lte", - type = "person", + propertyOperator = PropertyOperator.LTE, + type = PropertyType.PERSON, negation = false, dependencyChain = null, ) @@ -315,8 +315,8 @@ internal class FlagEvaluatorTest { FlagProperty( key = "signup_date", propertyValue = "2024-01-01T00:00:00Z", - propertyOperator = "is_date_before", - type = "person", + propertyOperator = PropertyOperator.IS_DATE_BEFORE, + type = PropertyType.PERSON, negation = false, dependencyChain = null, ) @@ -331,8 +331,8 @@ internal class FlagEvaluatorTest { FlagProperty( key = "signup_date", propertyValue = "2022-05-01", - propertyOperator = "is_date_before", - type = "person", + propertyOperator = PropertyOperator.IS_DATE_BEFORE, + type = PropertyType.PERSON, negation = false, dependencyChain = null, ) @@ -345,8 +345,8 @@ internal class FlagEvaluatorTest { FlagProperty( key = "key", propertyValue = "2022-04-05 12:34:12 +01:00", - propertyOperator = "is_date_before", - type = "person", + propertyOperator = PropertyOperator.IS_DATE_BEFORE, + type = PropertyType.PERSON, negation = false, dependencyChain = null, ) @@ -368,8 +368,8 @@ internal class FlagEvaluatorTest { FlagProperty( key = "key", propertyValue = "2022-04-05 12:34:12+01:00", - propertyOperator = "is_date_before", - type = "person", + propertyOperator = PropertyOperator.IS_DATE_BEFORE, + type = PropertyType.PERSON, negation = false, dependencyChain = null, ) @@ -385,8 +385,8 @@ internal class FlagEvaluatorTest { FlagProperty( key = "key", propertyValue = "2022-05-01 00:00:00", - propertyOperator = "is_date_before", - type = "person", + propertyOperator = PropertyOperator.IS_DATE_BEFORE, + type = PropertyType.PERSON, negation = false, dependencyChain = null, ) @@ -399,8 +399,8 @@ internal class FlagEvaluatorTest { FlagProperty( key = "signup_date", propertyValue = "2024-01-01T00:00:00Z", - propertyOperator = "is_date_after", - type = "person", + propertyOperator = PropertyOperator.IS_DATE_AFTER, + type = PropertyType.PERSON, negation = false, dependencyChain = null, ) @@ -414,8 +414,8 @@ internal class FlagEvaluatorTest { FlagProperty( key = "last_seen", propertyValue = "-7d", - propertyOperator = "is_date_after", - type = "person", + propertyOperator = PropertyOperator.IS_DATE_AFTER, + type = PropertyType.PERSON, negation = false, dependencyChain = null, ) @@ -458,8 +458,8 @@ internal class FlagEvaluatorTest { FlagProperty( key = "email", propertyValue = "test@example.com", - propertyOperator = "exact", - type = "person", + propertyOperator = PropertyOperator.EXACT, + type = PropertyType.PERSON, negation = false, dependencyChain = null, ), @@ -497,8 +497,8 @@ internal class FlagEvaluatorTest { FlagProperty( key = "email", propertyValue = "test@example.com", - propertyOperator = "exact", - type = "person", + propertyOperator = PropertyOperator.EXACT, + type = PropertyType.PERSON, negation = false, dependencyChain = null, ), @@ -570,8 +570,8 @@ internal class FlagEvaluatorTest { FlagProperty( key = "missing_key", propertyValue = "test", - propertyOperator = "exact", - type = "person", + propertyOperator = PropertyOperator.EXACT, + type = PropertyType.PERSON, negation = false, dependencyChain = null, ) @@ -718,8 +718,8 @@ internal class FlagEvaluatorTest { FlagProperty( key = "id", propertyValue = 2, - propertyOperator = "in", - type = "cohort", + propertyOperator = PropertyOperator.IN, + type = PropertyType.COHORT, negation = false, dependencyChain = null, ), @@ -887,8 +887,8 @@ internal class FlagEvaluatorTest { FlagProperty( key = "email", propertyValue = listOf("example@example.com"), - propertyOperator = "exact", - type = "person", + propertyOperator = PropertyOperator.EXACT, + type = PropertyType.PERSON, negation = false, dependencyChain = null, ), @@ -899,48 +899,48 @@ internal class FlagEvaluatorTest { "not_example@example.com", "also_not_example@example.com", ), - propertyOperator = "is_not", - type = "person", + propertyOperator = PropertyOperator.IS_NOT, + type = PropertyType.PERSON, negation = false, dependencyChain = null, ), FlagProperty( key = "email", propertyValue = "example", - propertyOperator = "icontains", - type = "person", + propertyOperator = PropertyOperator.ICONTAINS, + type = PropertyType.PERSON, negation = false, dependencyChain = null, ), FlagProperty( key = "email", propertyValue = ".net", - propertyOperator = "not_icontains", - type = "person", + propertyOperator = PropertyOperator.NOT_ICONTAINS, + type = PropertyType.PERSON, negation = false, dependencyChain = null, ), FlagProperty( key = "email", propertyValue = "\\w+@\\w+\\.\\w+", - propertyOperator = "regex", - type = "person", + propertyOperator = PropertyOperator.REGEX, + type = PropertyType.PERSON, negation = false, dependencyChain = null, ), FlagProperty( key = "email", propertyValue = "@yahoo.com$", - propertyOperator = "not_regex", - type = "person", + propertyOperator = PropertyOperator.NOT_REGEX, + type = PropertyType.PERSON, negation = false, dependencyChain = null, ), FlagProperty( key = "email", propertyValue = "is_set", - propertyOperator = "is_set", - type = "person", + propertyOperator = PropertyOperator.IS_SET, + type = PropertyType.PERSON, negation = false, dependencyChain = null, ), @@ -972,8 +972,8 @@ internal class FlagEvaluatorTest { FlagProperty( key = "id", propertyValue = 2, - propertyOperator = "in", - type = "cohort", + propertyOperator = PropertyOperator.IN, + type = PropertyType.COHORT, negation = false, dependencyChain = null, ), From 9ae1a8d9915b4f252a613bf1acd5b89eb363f139 Mon Sep 17 00:00:00 2001 From: Dustin Byrne Date: Wed, 8 Oct 2025 12:04:15 -0400 Subject: [PATCH 11/27] Regex patterns are static --- .../main/java/com/posthog/server/internal/FlagEvaluator.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 index 85661f55..2b00e89f 100644 --- a/posthog-server/src/main/java/com/posthog/server/internal/FlagEvaluator.kt +++ b/posthog-server/src/main/java/com/posthog/server/internal/FlagEvaluator.kt @@ -22,6 +22,7 @@ internal class FlagEvaluator( 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() // Date formatters for parsing various date formats private val DATE_FORMATTER_WITH_SPACE_TZ = @@ -348,8 +349,7 @@ internal class FlagEvaluator( * 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 regex = Regex("^-?([0-9]+)([hdwmy])$") - val match = regex.find(propertyValue) ?: return null + val match = REGEX_RELATIVE_DATE.find(propertyValue) ?: return null val number = match.groupValues[1].toIntOrNull() ?: return null val interval = match.groupValues[2] From a2722a129c0e6c58c8dcefc239d01c800277bc8c Mon Sep 17 00:00:00 2001 From: Dustin Byrne Date: Thu, 9 Oct 2025 17:25:10 -0400 Subject: [PATCH 12/27] Separate duties in feature flags This cleans things up for future `onlyEvaluateLocally` config and sending feature flags on capture --- .../server/internal/PostHogFeatureFlags.kt | 194 ++++++++++-------- 1 file changed, 109 insertions(+), 85 deletions(-) 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 83f9c0cb..1f81ab71 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 @@ -91,36 +91,6 @@ internal class PostHogFeatureFlags( ?: defaultValue } - private fun fetchRemoteFlags( - distinctId: String, - groups: Map?, - personProperties: Map?, - groupProperties: Map?, - ): Map? { - val cacheKey = - FeatureFlagCacheKey( - distinctId = distinctId, - groups = groups, - personProperties = personProperties, - groupProperties = groupProperties, - ) - - val cachedFlags = cache.get(cacheKey) - if (cachedFlags != null) { - return cachedFlags - } - - return try { - val response = api.flags(distinctId, null, groups, personProperties, groupProperties) - val flags = response?.flags - cache.put(cacheKey, flags) - flags - } catch (e: Throwable) { - config.logger.log("Loading remote feature flags failed: $e") - null - } - } - private fun resolveFeatureFlag( key: String, distinctId: String, @@ -128,15 +98,8 @@ internal class PostHogFeatureFlags( personProperties: Map?, groupProperties: Map?, ): FeatureFlag? { - val cacheKey = - FeatureFlagCacheKey( - distinctId = distinctId, - groups = groups, - personProperties = personProperties, - groupProperties = groupProperties, - ) - - val cachedFlags = cache.get(cacheKey) + val cachedFlags = + getFeatureFlagsFromCache(distinctId, groups, personProperties, groupProperties) if (cachedFlags != null) { config.logger.log("Feature flags cache hit for distinctId: $distinctId") val flag = cachedFlags[key] @@ -177,20 +140,83 @@ internal class PostHogFeatureFlags( // 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") - return fetchRemoteFlags(distinctId, groups, personProperties, groupProperties)?.get(key) + return getFeatureFlagsFromRemote( + distinctId, + groups, + personProperties, + groupProperties, + )?.get(key) } - override fun getFeatureFlags( - distinctId: String?, + private fun getFeatureFlagsFromCache( + distinctId: String, groups: Map?, - personProperties: Map?, - groupProperties: 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 } + 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?, + ): Map? { val cacheKey = FeatureFlagCacheKey( distinctId = distinctId, @@ -199,54 +225,52 @@ 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 } - // Try local evaluation if enabled and flags are loaded - val currentFlagDefinitions = flagDefinitions ?: emptyMap() - if (localEvaluation && currentFlagDefinitions.isNotEmpty()) { - try { - config.logger.log("Attempting local evaluation for distinctId: $distinctId") - val localFlags = mutableMapOf() - val props = (personProperties ?: emptyMap()).toMutableMap() + return try { + val response = api.flags(distinctId, null, groups, personProperties, groupProperties) + val flags = response?.flags + cache.put(cacheKey, flags) + flags + } catch (e: Throwable) { + config.logger.log("Loading remote feature flags failed: $e") + null + } + } - // 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}") - // Skip this flag, it will be fetched from API as a fallback below - } - } + override fun getFeatureFlags( + distinctId: String?, + groups: Map?, + personProperties: Map?, + groupProperties: Map>?, + ): Map? { + if (distinctId == null) { + config.logger.log("getFeatureFlags called but no distinctId available for API call") + return null + } - if (localFlags.isNotEmpty()) { - config.logger.log("Local evaluation successful for ${localFlags.size} flags") - // Don't cache locally evaluated flags, as they depend on properties - return localFlags - } - } catch (e: Throwable) { - config.logger.log("Local evaluation failed: ${e.message}") - // Fall through to API call - } + val cached = getFeatureFlagsFromCache(distinctId, groups, personProperties, groupProperties) + if (cached != null) { + return cached + } + + // If no cached flags, try local evaluation + val localFlags = + getFeatureFlagsFromLocalEvaluation( + distinctId, + groups, + personProperties, + groupProperties, + ) + if (localFlags != null) { + return localFlags } - // Cache miss or local evaluation failed - fall back to API - config.logger.log("Feature flags cache miss for distinctId: $distinctId, calling API") - return fetchRemoteFlags(distinctId, groups, personProperties, groupProperties) + // Finally, fall back to remote fetch + return getFeatureFlagsFromRemote(distinctId, groups, personProperties, groupProperties) } override fun clear() { From 4e7cfc255c320ecff4e187f43f93481e5f662c22 Mon Sep 17 00:00:00 2001 From: Dustin Byrne Date: Tue, 21 Oct 2025 14:18:18 -0400 Subject: [PATCH 13/27] refactor: Move local eval models to core Deserialization is handled here, paired closely with the Api. This simplifies deserialization in the server SDK. --- posthog/api/posthog.api | 29 +- .../internal/GsonPropertyOperatorAdapter.kt | 17 + .../internal/GsonPropertyTypeAdapter.kt | 17 + .../java/com/posthog/internal/PostHogApi.kt | 2 +- .../PostHogLocalEvaluationDeserializers.kt | 165 +++++ .../PostHogLocalEvaluationResponse.kt | 18 - .../com/posthog/internal/PostHogSerializer.kt | 5 + .../PostHogLocalEvaluationModelsTest.kt | 663 ++++++++++++++++++ 8 files changed, 881 insertions(+), 35 deletions(-) create mode 100644 posthog/src/main/java/com/posthog/internal/GsonPropertyOperatorAdapter.kt create mode 100644 posthog/src/main/java/com/posthog/internal/GsonPropertyTypeAdapter.kt create mode 100644 posthog/src/main/java/com/posthog/internal/PostHogLocalEvaluationDeserializers.kt delete mode 100644 posthog/src/main/java/com/posthog/internal/PostHogLocalEvaluationResponse.kt create mode 100644 posthog/src/test/java/com/posthog/internal/PostHogLocalEvaluationModelsTest.kt diff --git a/posthog/api/posthog.api b/posthog/api/posthog.api index a741c9be..382802dc 100644 --- a/posthog/api/posthog.api +++ b/posthog/api/posthog.api @@ -537,7 +537,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/PostHogLocalEvaluationResponse; + 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 } @@ -615,21 +615,6 @@ public final class com/posthog/internal/PostHogFlagsResponse : com/posthog/inter public fun toString ()Ljava/lang/String; } -public final class com/posthog/internal/PostHogLocalEvaluationResponse : com/posthog/internal/PostHogRemoteConfigResponse { - public fun (Ljava/util/List;Ljava/util/Map;Ljava/util/Map;)V - public final fun component1 ()Ljava/util/List; - public final fun component2 ()Ljava/util/Map; - public final fun component3 ()Ljava/util/Map; - public final fun copy (Ljava/util/List;Ljava/util/Map;Ljava/util/Map;)Lcom/posthog/internal/PostHogLocalEvaluationResponse; - public static synthetic fun copy$default (Lcom/posthog/internal/PostHogLocalEvaluationResponse;Ljava/util/List;Ljava/util/Map;Ljava/util/Map;ILjava/lang/Object;)Lcom/posthog/internal/PostHogLocalEvaluationResponse; - public fun equals (Ljava/lang/Object;)Z - public final fun getCohorts ()Ljava/util/Map; - public final fun getFlags ()Ljava/util/List; - public final fun getGroupTypeMapping ()Ljava/util/Map; - public fun hashCode ()I - public fun toString ()Ljava/lang/String; -} - public abstract interface class com/posthog/internal/PostHogLogger { public abstract fun isEnabled ()Z public abstract fun log (Ljava/lang/String;)V @@ -769,6 +754,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; @@ -839,6 +830,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/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 4972aa00..720ba666 100644 --- a/posthog/src/main/java/com/posthog/internal/PostHogApi.kt +++ b/posthog/src/main/java/com/posthog/internal/PostHogApi.kt @@ -196,7 +196,7 @@ public class PostHogApi( } @Throws(PostHogApiError::class, IOException::class) - public fun localEvaluation(personalApiKey: String): PostHogLocalEvaluationResponse? { + public fun localEvaluation(personalApiKey: String): LocalEvaluationResponse? { val url = "$theHost/api/feature_flag/local_evaluation/?token=${config.apiKey}&send_cohorts" val request = 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/PostHogLocalEvaluationResponse.kt b/posthog/src/main/java/com/posthog/internal/PostHogLocalEvaluationResponse.kt deleted file mode 100644 index aadf9a95..00000000 --- a/posthog/src/main/java/com/posthog/internal/PostHogLocalEvaluationResponse.kt +++ /dev/null @@ -1,18 +0,0 @@ -package com.posthog.internal - -import com.posthog.PostHogInternal -import org.codehaus.mojo.animal_sniffer.IgnoreJRERequirement - -/** - * The response data structure for calling the /api/feature_flag/local_evaluation API - * @property flags the feature flag definitions for local evaluation - * @property groupTypeMapping the mapping of group type IDs to group types - * @property cohorts the cohort definitions for local evaluation - */ -@IgnoreJRERequirement -@PostHogInternal -public data class PostHogLocalEvaluationResponse( - val flags: List>?, - val groupTypeMapping: Map?, - val cohorts: Map>?, -) : PostHogRemoteConfigResponse() 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)) + } +} From 2a4e99e3469674f4f084a1f3fe9412a87444bfe0 Mon Sep 17 00:00:00 2001 From: Dustin Byrne Date: Tue, 21 Oct 2025 15:04:59 -0400 Subject: [PATCH 14/27] refactor: Use deserialization from core --- .../server/internal/FlagDefinitionModels.kt | 88 -------- .../posthog/server/internal/FlagEvaluator.kt | 210 ++++++++---------- .../internal/InconclusiveMatchException.kt | 6 + .../server/internal/PostHogFeatureFlags.kt | 141 ++---------- .../server/internal/PropertyOperator.kt | 50 ----- .../posthog/server/internal/PropertyType.kt | 23 -- 6 files changed, 125 insertions(+), 393 deletions(-) delete mode 100644 posthog-server/src/main/java/com/posthog/server/internal/FlagDefinitionModels.kt create mode 100644 posthog-server/src/main/java/com/posthog/server/internal/InconclusiveMatchException.kt delete mode 100644 posthog-server/src/main/java/com/posthog/server/internal/PropertyOperator.kt delete mode 100644 posthog-server/src/main/java/com/posthog/server/internal/PropertyType.kt diff --git a/posthog-server/src/main/java/com/posthog/server/internal/FlagDefinitionModels.kt b/posthog-server/src/main/java/com/posthog/server/internal/FlagDefinitionModels.kt deleted file mode 100644 index 38e41f30..00000000 --- a/posthog-server/src/main/java/com/posthog/server/internal/FlagDefinitionModels.kt +++ /dev/null @@ -1,88 +0,0 @@ -package com.posthog.server.internal - -import com.google.gson.annotations.SerializedName - -/** - * Response from /api/feature_flag/local_evaluation/ - */ -internal data class LocalEvaluationResponse( - val flags: List?, - @SerializedName("group_type_mapping") - val groupTypeMapping: Map?, - val cohorts: Map?, -) - -/** - * Complete feature flag definition for local evaluation - */ -internal data class FlagDefinition( - val id: Int, - val name: String, - val key: String, - val active: Boolean, - val filters: FlagFilters, - val version: Int, -) - -/** - * Flag filters containing groups and multivariate config - */ -internal data class FlagFilters( - val groups: List?, - val multivariate: MultiVariateConfig?, - val payloads: Map?, -) - -/** - * A condition group with properties and rollout percentage - */ -internal data class FlagConditionGroup( - val properties: List?, - @SerializedName("rollout_percentage") - val rolloutPercentage: Int?, - val variant: String?, -) - -/** - * A property condition for flag evaluation - */ -internal data class FlagProperty( - val key: String, - @SerializedName("value") - val propertyValue: Any?, - @SerializedName("operator") - val propertyOperator: PropertyOperator?, - val type: PropertyType?, - val negation: Boolean?, - @SerializedName("dependency_chain") - val dependencyChain: List?, -) - -/** - * Multivariate configuration for A/B testing - */ -internal data class MultiVariateConfig( - val variants: List?, -) - -/** - * A variant definition with key and rollout percentage - */ -internal data class VariantDefinition( - val key: String, - @SerializedName("rollout_percentage") - val rolloutPercentage: Double, -) - -/** - * Cohort definition for matching cohort properties - */ -internal data class CohortDefinition( - val type: String?, - val values: List?, -) - -/** - * 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/FlagEvaluator.kt b/posthog-server/src/main/java/com/posthog/server/internal/FlagEvaluator.kt index 2b00e89f..db61d53c 100644 --- a/posthog-server/src/main/java/com/posthog/server/internal/FlagEvaluator.kt +++ b/posthog-server/src/main/java/com/posthog/server/internal/FlagEvaluator.kt @@ -1,6 +1,14 @@ 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 @@ -24,7 +32,6 @@ internal class FlagEvaluator( private val REGEX_COMBINING_MARKS = "\\p{M}+".toRegex() private val REGEX_RELATIVE_DATE = "^-?([0-9]+)([hdwmy])$".toRegex() - // Date formatters for parsing various date formats private val DATE_FORMATTER_WITH_SPACE_TZ = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss XXX") private val DATE_FORMATTER_NO_SPACE_TZ = @@ -434,14 +441,13 @@ internal class FlagEvaluator( fun matchCohort( property: FlagProperty, propertyValues: Map, - cohortProperties: Map, + cohortProperties: Map, flagsByKey: Map?, evaluationCache: MutableMap?, distinctId: String?, ): Boolean { - val cohortId = - property.propertyValue?.toString() - ?: throw InconclusiveMatchException("Cohort property missing value") + 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") @@ -463,88 +469,30 @@ internal class FlagEvaluator( /** * Match a property group (AND/OR) against property values */ - @Suppress("UNCHECKED_CAST") fun matchPropertyGroup( - propertyGroup: CohortDefinition, + propertyGroup: PropertyGroup, propertyValues: Map, - cohortProperties: Map, + cohortProperties: Map, flagsByKey: Map?, evaluationCache: MutableMap?, distinctId: String?, ): Boolean { val groupType = propertyGroup.type - val properties = propertyGroup.values ?: return true + val properties = propertyGroup.values - if (properties.isEmpty()) { - // Empty groups are no-ops, always match - return true - } + // Empty properties always match + if (properties == null || properties.isEmpty()) return true var errorMatchingLocally = false - // Check if this is a nested property group - val firstProperty = properties.firstOrNull() - if (firstProperty is Map<*, *> && firstProperty.containsKey("values")) { - // Nested property groups - for (prop in properties) { - if (prop !is Map<*, *>) continue - - try { - val nestedGroup = - CohortDefinition( - type = prop["type"] as? String, - values = prop["values"] as? List, - ) - val matches = - matchPropertyGroup( - nestedGroup, - propertyValues, - cohortProperties, - flagsByKey, - evaluationCache, - distinctId, - ) - - if (groupType == "AND") { - if (!matches) return false - } else { - // OR group - if (matches) return true - } - } catch (e: InconclusiveMatchException) { - config.logger.log("Failed to compute property $prop 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 == "AND" - } - - // Regular properties - for (prop in properties) { - if (prop !is Map<*, *>) continue - - try { - val property = - FlagProperty( - key = prop["key"] as? String ?: "", - propertyValue = prop["value"], - propertyOperator = PropertyOperator.fromStringOrNull(prop["operator"] as? String), - type = PropertyType.fromStringOrNull(prop["type"] as? String), - negation = prop["negation"] as? Boolean, - dependencyChain = prop["dependency_chain"] as? List, - ) - - val matches = - when (property.type) { - PropertyType.COHORT -> - matchCohort( - property, + // 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, @@ -552,45 +500,83 @@ internal class FlagEvaluator( 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) + 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 } + } - val negation = property.negation ?: false - - if (groupType == "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 + if (errorMatchingLocally) { + throw InconclusiveMatchException("Can't match cohort without a given cohort property value") } - } catch (e: InconclusiveMatchException) { - config.logger.log("Failed to compute property $prop locally: ${e.message}") - errorMatchingLocally = true + + // If we get here, all matched in AND case, or none matched in OR case + return groupType == LogicalOperator.AND } - } - if (errorMatchingLocally) { - throw InconclusiveMatchException("Can't match cohort without a given cohort property value") - } + 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 == "AND" + // If we get here, all matched in AND case, or none matched in OR case + return groupType == LogicalOperator.AND + } + } } /** @@ -601,7 +587,7 @@ internal class FlagEvaluator( distinctId: String, condition: FlagConditionGroup, properties: Map, - cohortProperties: Map, + cohortProperties: Map, flagsByKey: Map?, evaluationCache: MutableMap?, ): Boolean { @@ -669,7 +655,7 @@ internal class FlagEvaluator( flag: FlagDefinition, distinctId: String, properties: Map, - cohortProperties: Map = emptyMap(), + cohortProperties: Map = emptyMap(), flagsByKey: Map? = null, evaluationCache: MutableMap? = null, ): Any? { @@ -731,7 +717,7 @@ internal class FlagEvaluator( evaluationCache: MutableMap, distinctId: String, properties: Map, - cohortProperties: Map, + cohortProperties: Map, ): Boolean { // Check if dependency_chain is present val dependencyChain = property.dependencyChain 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/PostHogFeatureFlags.kt b/posthog-server/src/main/java/com/posthog/server/internal/PostHogFeatureFlags.kt index 1f81ab71..ebf78f93 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 @@ -3,8 +3,10 @@ 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 class PostHogFeatureFlags( private val config: PostHogConfig, @@ -22,7 +24,6 @@ internal class PostHogFeatureFlags( maxAgeMs = cacheMaxAgeMs, ) - // Local evaluation state @Volatile private var featureFlags: List? = null @@ -30,7 +31,7 @@ internal class PostHogFeatureFlags( private var flagDefinitions: Map? = null @Volatile - private var cohorts: Map? = null + private var cohorts: Map? = null @Volatile private var groupTypeMapping: Map? = null @@ -290,30 +291,25 @@ internal class PostHogFeatureFlags( return } - try { - config.logger.log("Loading feature flags for local evaluation") - val response = api.localEvaluation(personalApiKey) - - if (response != null) { - // Parse flag definitions - val flags = - response.flags?.mapNotNull { flagMap -> - try { - parseFlagDefinition(flagMap) - } catch (e: Exception) { - config.logger.log("Failed to parse flag definition: ${e.message}") - null - } - } + 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) - featureFlags = flags - flagDefinitions = flags?.associateBy { it.key } - cohorts = response.cohorts?.mapValues { parseCohortDefinition(it.value) } - groupTypeMapping = response.groupTypeMapping + 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 ${flags?.size ?: 0} feature flags for local evaluation") + config.logger.log("Loaded ${apiResponse.flags?.size ?: 0} feature flags for local evaluation") - if (!definitionsLoaded) { definitionsLoaded = true try { onFeatureFlags?.loaded() @@ -321,9 +317,9 @@ internal class PostHogFeatureFlags( 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}") } - } catch (e: Throwable) { - config.logger.log("Failed to load feature flags for local evaluation: ${e.message}") } } @@ -363,101 +359,6 @@ internal class PostHogFeatureFlags( ) } - /** - * Parse a flag definition from JSON map - */ - @Suppress("UNCHECKED_CAST") - private fun parseFlagDefinition(flagMap: Map): FlagDefinition { - return FlagDefinition( - id = (flagMap["id"] as? Number)?.toInt() ?: 0, - name = flagMap["name"] as? String ?: "", - key = flagMap["key"] as? String ?: "", - active = flagMap["active"] as? Boolean ?: false, - filters = parseFilters(flagMap["filters"] as? Map), - version = (flagMap["version"] as? Number)?.toInt() ?: 0, - ) - } - - /** - * Parse filters from JSON map - */ - @Suppress("UNCHECKED_CAST") - private fun parseFilters(filtersMap: Map?): FlagFilters { - val groups = - (filtersMap?.get("groups") as? List>)?.map { parseConditionGroup(it) } - val multivariate = parseMultiVariate(filtersMap?.get("multivariate") as? Map) - val payloads = filtersMap?.get("payloads") as? Map - - return FlagFilters( - groups = groups, - multivariate = multivariate, - payloads = payloads, - ) - } - - /** - * Parse a condition group from JSON map - */ - @Suppress("UNCHECKED_CAST") - private fun parseConditionGroup(groupMap: Map): FlagConditionGroup { - val properties = - (groupMap["properties"] as? List>)?.map { parseProperty(it) } - val rolloutPercentage = (groupMap["rollout_percentage"] as? Number)?.toInt() - val variant = groupMap["variant"] as? String - - return FlagConditionGroup( - properties = properties, - rolloutPercentage = rolloutPercentage, - variant = variant, - ) - } - - /** - * Parse a property from JSON map - */ - @Suppress("UNCHECKED_CAST") - private fun parseProperty(propMap: Map): FlagProperty { - return FlagProperty( - key = propMap["key"] as? String ?: "", - propertyValue = propMap["value"], - propertyOperator = PropertyOperator.fromStringOrNull(propMap["operator"] as? String), - type = PropertyType.fromStringOrNull(propMap["type"] as? String), - negation = propMap["negation"] as? Boolean, - dependencyChain = propMap["dependency_chain"] as? List, - ) - } - - /** - * Parse multivariate config from JSON map - */ - @Suppress("UNCHECKED_CAST") - private fun parseMultiVariate(multivariateMap: Map?): MultiVariateConfig? { - if (multivariateMap == null) return null - - val variants = - (multivariateMap["variants"] as? List>)?.map { variantMap -> - VariantDefinition( - key = variantMap["key"] as? String ?: "", - rolloutPercentage = - (variantMap["rollout_percentage"] as? Number)?.toDouble() - ?: 0.0, - ) - } - - return MultiVariateConfig(variants = variants) - } - - /** - * Parse cohort definition from JSON map - */ - @Suppress("UNCHECKED_CAST") - private fun parseCohortDefinition(cohortMap: Map): CohortDefinition { - return CohortDefinition( - type = cohortMap["type"] as? String, - values = cohortMap["values"] as? List, - ) - } - /** * Start the poller for local evaluation if enabled */ diff --git a/posthog-server/src/main/java/com/posthog/server/internal/PropertyOperator.kt b/posthog-server/src/main/java/com/posthog/server/internal/PropertyOperator.kt deleted file mode 100644 index 681cf5ca..00000000 --- a/posthog-server/src/main/java/com/posthog/server/internal/PropertyOperator.kt +++ /dev/null @@ -1,50 +0,0 @@ -package com.posthog.server.internal - -internal enum class PropertyOperator { - UNKNOWN, - EXACT, - IS_NOT, - IS_SET, - IS_NOT_SET, - ICONTAINS, - NOT_ICONTAINS, - REGEX, - NOT_REGEX, - IN, - GT, - GTE, - LT, - LTE, - IS_DATE_BEFORE, - IS_DATE_AFTER, - FLAG_EVALUATES_TO, - ; - - companion object { - fun fromString(value: String): PropertyOperator { - return when (value) { - "exact" -> EXACT - "is_not" -> IS_NOT - "is_set" -> IS_SET - "is_not_set" -> IS_NOT_SET - "icontains" -> ICONTAINS - "not_icontains" -> NOT_ICONTAINS - "regex" -> REGEX - "not_regex" -> NOT_REGEX - "in" -> IN - "gt" -> GT - "gte" -> GTE - "lt" -> LT - "lte" -> LTE - "is_date_before" -> IS_DATE_BEFORE - "is_date_after" -> IS_DATE_AFTER - "flag_evaluates_to" -> FLAG_EVALUATES_TO - else -> UNKNOWN - } - } - - fun fromStringOrNull(str: String?): PropertyOperator? { - return str?.let { fromString(it) } - } - } -} diff --git a/posthog-server/src/main/java/com/posthog/server/internal/PropertyType.kt b/posthog-server/src/main/java/com/posthog/server/internal/PropertyType.kt deleted file mode 100644 index 3f6c6bf1..00000000 --- a/posthog-server/src/main/java/com/posthog/server/internal/PropertyType.kt +++ /dev/null @@ -1,23 +0,0 @@ -package com.posthog.server.internal - -internal enum class PropertyType { - COHORT, - FLAG, - PERSON, - ; - - companion object { - fun fromString(value: String): PropertyType { - return when (value) { - "cohort" -> COHORT - "flag" -> FLAG - "person" -> PERSON - else -> PERSON - } - } - - fun fromStringOrNull(str: String?): PropertyType? { - return str?.let { fromString(it) } - } - } -} From e911234167e68864c7f84b2b141b21983128aeca Mon Sep 17 00:00:00 2001 From: Dustin Byrne Date: Tue, 21 Oct 2025 15:05:50 -0400 Subject: [PATCH 15/27] fix: Flag definitions are loaded synchronously if missing --- .../server/internal/PostHogFeatureFlags.kt | 10 ++++ .../internal/PostHogFeatureFlagsTest.kt | 46 +++++++++++++++++++ 2 files changed, 56 insertions(+) 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 ebf78f93..9407fefd 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 @@ -110,6 +110,11 @@ internal class PostHogFeatureFlags( } 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 { @@ -177,6 +182,11 @@ internal class PostHogFeatureFlags( return null } + if (flagDefinitions == null && !definitionsLoaded) { + config.logger.log("Flag definitions not loaded, loading now") + loadFeatureFlagDefinitions() + } + val currentFlagDefinitions = flagDefinitions if (currentFlagDefinitions == null) { return null 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 1edc50d7..1a1390fc 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,6 +4,7 @@ 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 @@ -370,4 +371,49 @@ 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() + } } From 4774b8509c0f0e3f403ca7aef6db8ea02b0e1e9c Mon Sep 17 00:00:00 2001 From: Dustin Byrne Date: Tue, 21 Oct 2025 15:05:57 -0400 Subject: [PATCH 16/27] fix: Use group properties when evaluating group flags --- .../server/internal/PostHogFeatureFlags.kt | 35 ++- .../src/test/java/com/posthog/server/Utils.kt | 46 ++++ .../internal/PostHogFeatureFlagsTest.kt | 211 ++++++++++++++++++ 3 files changed, 285 insertions(+), 7 deletions(-) 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 9407fefd..4ab663ee 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 @@ -408,7 +408,6 @@ internal class PostHogFeatureFlags( /** * Compute a flag locally using the evaluation engine */ - @Suppress("UNUSED_PARAMETER") private fun computeFlagLocally( key: String, distinctId: String, @@ -423,16 +422,38 @@ internal class PostHogFeatureFlags( return false } - // Merge person and group properties for evaluation - val allProperties = personProperties.toMutableMap() - // Add group properties if available - groupProperties?.forEach { (k, v) -> allProperties[k] = v } + // 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 = distinctId, - properties = allProperties, + distinctId = evaluationId, + properties = evaluationProperties ?: emptyMap(), cohortProperties = cohorts ?: emptyMap(), flagsByKey = flags, evaluationCache = evaluationCache, 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/PostHogFeatureFlagsTest.kt b/posthog-server/src/test/java/com/posthog/server/internal/PostHogFeatureFlagsTest.kt index 1a1390fc..fc3cf2ac 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 @@ -416,4 +416,215 @@ internal class PostHogFeatureFlagsTest { 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, // organization + ) + + // 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("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, // organization + ) + + // 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, // No groups provided + ) + + // 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, // person-based flag + ) + + 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() + } } From 2195c01d9f1cc7c05a6549d5bcef4727fb5ae896 Mon Sep 17 00:00:00 2001 From: Dustin Byrne Date: Tue, 21 Oct 2025 15:02:54 -0400 Subject: [PATCH 17/27] test: Use JSON objects --- .../server/internal/FlagEvaluatorTest.kt | 740 ++++++++---------- 1 file changed, 332 insertions(+), 408 deletions(-) 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 index ecb5b066..9a6e5343 100644 --- a/posthog-server/src/test/java/com/posthog/server/internal/FlagEvaluatorTest.kt +++ b/posthog-server/src/test/java/com/posthog/server/internal/FlagEvaluatorTest.kt @@ -1,6 +1,16 @@ package com.posthog.server.internal +import com.google.gson.reflect.TypeToken import com.posthog.PostHogConfig +import com.posthog.internal.FlagConditionGroup +import com.posthog.internal.FlagDefinition +import com.posthog.internal.FlagFilters +import com.posthog.internal.FlagProperty +import com.posthog.internal.MultiVariateConfig +import com.posthog.internal.PropertyGroup +import com.posthog.internal.PropertyOperator +import com.posthog.internal.PropertyType +import com.posthog.internal.VariantDefinition import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertNotNull @@ -442,37 +452,33 @@ internal class FlagEvaluatorTest { @Test internal fun testMatchFeatureFlagPropertiesSimpleMatch() { - val flag = - FlagDefinition( - id = 1, - name = "Test Flag", - key = "test-flag", - active = true, - filters = - FlagFilters( - groups = - listOf( - FlagConditionGroup( - properties = - listOf( - FlagProperty( - key = "email", - propertyValue = "test@example.com", - propertyOperator = PropertyOperator.EXACT, - type = PropertyType.PERSON, - negation = false, - dependencyChain = null, - ), - ), - rolloutPercentage = 100, - variant = null, - ), - ), - multivariate = null, - payloads = null, - ), - version = 1, - ) + 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) @@ -481,37 +487,33 @@ internal class FlagEvaluatorTest { @Test internal fun testMatchFeatureFlagPropertiesNoMatch() { - val flag = - FlagDefinition( - id = 1, - name = "Test Flag", - key = "test-flag", - active = true, - filters = - FlagFilters( - groups = - listOf( - FlagConditionGroup( - properties = - listOf( - FlagProperty( - key = "email", - propertyValue = "test@example.com", - propertyOperator = PropertyOperator.EXACT, - type = PropertyType.PERSON, - negation = false, - dependencyChain = null, - ), - ), - rolloutPercentage = 100, - variant = null, - ), - ), - multivariate = null, - payloads = null, - ), - version = 1, - ) + 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) @@ -520,38 +522,34 @@ internal class FlagEvaluatorTest { @Test internal fun testMatchFeatureFlagPropertiesWithRollout() { - val flag = - FlagDefinition( - id = 1, - name = "Test Flag", - key = "test-flag", - active = true, - filters = - FlagFilters( - groups = - listOf( - FlagConditionGroup( - properties = emptyList(), - rolloutPercentage = 50, - variant = null, - ), - ), - multivariate = null, - payloads = null, - ), - version = 1, - ) + 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..100) { + for (i in 1..1000) { val result = evaluator.matchFeatureFlagProperties(flag, "user-$i", emptyMap()) if (result == true) matchCount++ } - // With 50% rollout, we should get roughly 50 matches out of 100 - // Allow some variance (40-60) - assertTrue("Expected ~50 matches, got $matchCount", matchCount in 40..60) + assertTrue("Expected ~500 matches, got $matchCount", matchCount in 400..600) } @Test @@ -588,33 +586,37 @@ internal class FlagEvaluatorTest { // Helper functions internal fun createSimpleFlag(): FlagDefinition { - return FlagDefinition( - id = 1, - name = "Simple Flag", - key = "simple-flag", - active = true, - filters = - FlagFilters( - groups = - listOf( - FlagConditionGroup( - properties = emptyList(), - rolloutPercentage = 100, - variant = null, - ), - ), - multivariate = - MultiVariateConfig( - variants = - listOf( - VariantDefinition(key = "control", rolloutPercentage = 50.0), - VariantDefinition(key = "test", rolloutPercentage = 50.0), - ), - ), - payloads = null, - ), - version = 1, - ) + 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 @@ -702,99 +704,35 @@ internal class FlagEvaluatorTest { @Test internal fun testCohortMemberFlag() { - val flag = - FlagDefinition( - id = 26, - name = "Cohort Member", - key = "cohort-member", - active = true, - filters = - FlagFilters( - groups = - listOf( - FlagConditionGroup( - properties = - listOf( - FlagProperty( - key = "id", - propertyValue = 2, - propertyOperator = PropertyOperator.IN, - type = PropertyType.COHORT, - negation = false, - dependencyChain = null, - ), - ), - rolloutPercentage = 100, - variant = null, - ), - ), - multivariate = null, - payloads = null, - ), - version = 2, - ) + 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 = - mapOf( - "2" to - CohortDefinition( - type = "AND", - values = - listOf( - mapOf( - "type" to "AND", - "values" to - listOf( - mapOf( - "key" to "email", - "operator" to "not_regex", - "type" to "person", - "value" to "@hedgebox.net$", - ), - ), - ), - mapOf( - "type" to "AND", - "values" to - listOf( - mapOf( - "key" to "id", - "type" to "cohort", - "negation" to true, - "value" to 3, - ), - mapOf( - "key" to "email", - "operator" to "is_set", - "type" to "person", - "negation" to false, - "value" to "is_set", - ), - ), - ), - ), - ), - "3" to - CohortDefinition( - type = "OR", - values = - listOf( - mapOf( - "type" to "AND", - "values" to - listOf( - mapOf( - "key" to "email", - "operator" to "regex", - "type" to "person", - "negation" to false, - "value" to "@gmail.com", - ), - ), - ), - ), - ), - ) + 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") @@ -872,213 +810,199 @@ internal class FlagEvaluatorTest { } internal fun createMixedConditionsFlag(): FlagDefinition { - return FlagDefinition( - id = 25, - name = "Mixed Conditions", - key = "mixed-conditions", - active = true, - filters = - FlagFilters( - groups = - listOf( - FlagConditionGroup( - properties = - listOf( - FlagProperty( - key = "email", - propertyValue = listOf("example@example.com"), - propertyOperator = PropertyOperator.EXACT, - type = PropertyType.PERSON, - negation = false, - dependencyChain = null, - ), - FlagProperty( - key = "email", - propertyValue = - listOf( - "not_example@example.com", - "also_not_example@example.com", - ), - propertyOperator = PropertyOperator.IS_NOT, - type = PropertyType.PERSON, - negation = false, - dependencyChain = null, - ), - FlagProperty( - key = "email", - propertyValue = "example", - propertyOperator = PropertyOperator.ICONTAINS, - type = PropertyType.PERSON, - negation = false, - dependencyChain = null, - ), - FlagProperty( - key = "email", - propertyValue = ".net", - propertyOperator = PropertyOperator.NOT_ICONTAINS, - type = PropertyType.PERSON, - negation = false, - dependencyChain = null, - ), - FlagProperty( - key = "email", - propertyValue = "\\w+@\\w+\\.\\w+", - propertyOperator = PropertyOperator.REGEX, - type = PropertyType.PERSON, - negation = false, - dependencyChain = null, - ), - FlagProperty( - key = "email", - propertyValue = "@yahoo.com$", - propertyOperator = PropertyOperator.NOT_REGEX, - type = PropertyType.PERSON, - negation = false, - dependencyChain = null, - ), - FlagProperty( - key = "email", - propertyValue = "is_set", - propertyOperator = PropertyOperator.IS_SET, - type = PropertyType.PERSON, - negation = false, - dependencyChain = null, - ), - ), - rolloutPercentage = 100, - variant = null, - ), - ), - multivariate = null, - payloads = null, - ), - version = 1, - ) + 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 { - return FlagDefinition( - id = 26, - name = "Cohort Member", - key = "cohort-member", - active = true, - filters = - FlagFilters( - groups = - listOf( - FlagConditionGroup( - properties = - listOf( - FlagProperty( - key = "id", - propertyValue = 2, - propertyOperator = PropertyOperator.IN, - type = PropertyType.COHORT, - negation = false, - dependencyChain = null, - ), - ), - rolloutPercentage = 100, - variant = null, - ), - ), - multivariate = null, - payloads = null, - ), - version = 2, - ) - } - - internal fun createCohortProperties(): Map { - return mapOf( - "2" to - CohortDefinition( - type = "AND", - values = - listOf( - mapOf( - "type" to "AND", - "values" to - listOf( - mapOf( - "key" to "email", - "operator" to "not_regex", - "type" to "person", - "value" to "@hedgebox.net$", - ), - ), - ), - mapOf( - "type" to "AND", - "values" to - listOf( - mapOf( - "key" to "id", - "type" to "cohort", - "negation" to true, - "value" to 3, - ), - mapOf( - "key" to "email", - "operator" to "is_set", - "type" to "person", - "negation" to false, - "value" to "is_set", - ), - ), - ), - ), - ), - "3" to - CohortDefinition( - type = "OR", - values = - listOf( - mapOf( - "type" to "AND", - "values" to - listOf( - mapOf( - "key" to "email", - "operator" to "regex", - "type" to "person", - "negation" to false, - "value" to "@gmail.com", - ), - ), - ), - ), - ), - ) + 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 { - return FlagDefinition( - id = 1, - name = "Multi Variate Flag", - key = "multi-variate-flag", - active = true, - filters = - FlagFilters( - groups = - listOf( - FlagConditionGroup( - properties = emptyList(), - rolloutPercentage = 100, - variant = null, - ), - ), - multivariate = - MultiVariateConfig( - variants = - listOf( - VariantDefinition(key = "control", rolloutPercentage = 50.0), - VariantDefinition(key = "test", rolloutPercentage = 50.0), - ), - ), - payloads = null, - ), - version = 1, - ) + 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) } } From 9f1bea110e01be5d6fae418d4d74db35305110d8 Mon Sep 17 00:00:00 2001 From: Dustin Byrne Date: Tue, 21 Oct 2025 15:30:11 -0400 Subject: [PATCH 18/27] test: Add flag dependency tests This brings method coverage to 92% overall, line 85% overall. --- .../server/internal/FlagEvaluatorTest.kt | 835 ++++++++++++++++++ 1 file changed, 835 insertions(+) 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 index 9a6e5343..26ab80c3 100644 --- a/posthog-server/src/test/java/com/posthog/server/internal/FlagEvaluatorTest.kt +++ b/posthog-server/src/test/java/com/posthog/server/internal/FlagEvaluatorTest.kt @@ -1005,4 +1005,839 @@ internal class FlagEvaluatorTest { 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"]) + } } From 712eb6f69d3a39a2df53cf0975dbcaabbb03d08c Mon Sep 17 00:00:00 2001 From: Dustin Byrne Date: Tue, 21 Oct 2025 15:47:13 -0400 Subject: [PATCH 19/27] fix: Mark the poller as daemon It'll automatically clean up in case the developer forgets to call posthog.close() --- .../java/com/posthog/server/internal/LocalEvaluationPoller.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 index a7e7ad52..972cd7a1 100644 --- a/posthog-server/src/main/java/com/posthog/server/internal/LocalEvaluationPoller.kt +++ b/posthog-server/src/main/java/com/posthog/server/internal/LocalEvaluationPoller.kt @@ -16,7 +16,7 @@ internal class LocalEvaluationPoller( private val executor: ScheduledExecutorService = Executors.newSingleThreadScheduledExecutor { r -> Thread(r, "PostHog-LocalEvaluationPoller").apply { - isDaemon = false + isDaemon = true } } From 27ef01bad17bf3bdba696064beacdefa098011ba Mon Sep 17 00:00:00 2001 From: Dustin Byrne Date: Tue, 21 Oct 2025 15:54:33 -0400 Subject: [PATCH 20/27] chore(java-sample): Move logic out of onFeatureFlags It's not necessary considering the first request to trigger local evaluation will synchronously retrieve flag definitions if they're not yet loaded. --- .../java/sample/PostHogJavaExample.java | 69 +++++++++---------- 1 file changed, 34 insertions(+), 35 deletions(-) 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 dc1ff4fe..7b74f169 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 @@ -12,47 +12,20 @@ * Simple Java 1.8 example demonstrating PostHog usage */ public class PostHogJavaExample { - private static PostHogInterface postHog; public static void main(String[] args) { PostHogConfig config = PostHogConfig - .builder("phc_qYXiHw5odMiVWF7Dwh2sHWS7Hj6FsutBNp2SEaMqS0A") - .personalApiKey("phx_example") + .builder("phc_wxtaSxv9yC8UYxUAxNojluoAf41L8p6SJZmiTMtS8jA") + .personalApiKey("phs_DuaFTmUtxQNj5R2W03emB1jMLIX5XwDvrt3DKfi5uYNcxzd") .host("http://localhost:8010") .localEvaluation(true) .debug(true) - .onFeatureFlags(() -> { - 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"); - String flagVariate = flagValue instanceof String ? (String) flagValue : "default"; - Object flagPayload = postHog.getFeatureFlagPayload("distinct-id", "multi-variate-flag"); - - System.out.println("The flag variant was: " + flagVariate); - System.out.println("Received flag payload: " + flagPayload); - - 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.flush(); - postHog.close(); - }) .build(); - 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 @@ -63,14 +36,40 @@ 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() {{ + // posthog.identify("user-123", new HashMap() {{ // put("key", "value"); // }}); - postHog.alias("distinct-id", "alias-id"); + posthog.alias("distinct-id", "alias-id"); + + // 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"); + String flagVariate = flagValue instanceof String ? (String) flagValue : "default"; + Object flagPayload = posthog.getFeatureFlagPayload("distinct-id", "multi-variate-flag"); + + System.out.println("The flag variant was: " + flagVariate); + System.out.println("Received flag payload: " + flagPayload); + + 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.flush(); + posthog.close(); } } \ No newline at end of file From bee69217116016e4e472c76e2b74d34b763a6e25 Mon Sep 17 00:00:00 2001 From: Dustin Byrne Date: Tue, 21 Oct 2025 15:56:26 -0400 Subject: [PATCH 21/27] docs(core): Update CHANGELOG --- posthog/CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/posthog/CHANGELOG.md b/posthog/CHANGELOG.md index d4e3411f..ce500a2a 100644 --- a/posthog/CHANGELOG.md +++ b/posthog/CHANGELOG.md @@ -3,6 +3,7 @@ - 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 From f7ae8a9011a1481b2fc3ce1d5442e61ea6b0e5bb Mon Sep 17 00:00:00 2001 From: Dustin Byrne Date: Tue, 21 Oct 2025 16:31:18 -0400 Subject: [PATCH 22/27] chore(server): apply formatter --- .../posthog/server/internal/FlagEvaluator.kt | 9 +- .../server/internal/FlagEvaluatorTest.kt | 134 +++++++++------- .../internal/PostHogFeatureFlagsTest.kt | 144 ++++++++++-------- 3 files changed, 162 insertions(+), 125 deletions(-) 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 index db61d53c..c3d33f8e 100644 --- a/posthog-server/src/main/java/com/posthog/server/internal/FlagEvaluator.kt +++ b/posthog-server/src/main/java/com/posthog/server/internal/FlagEvaluator.kt @@ -446,8 +446,9 @@ internal class FlagEvaluator( evaluationCache: MutableMap?, distinctId: String?, ): Boolean { - val cohortId = property.propertyValue?.toString() - ?: throw InconclusiveMatchException("Cohort property missing value") + 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") @@ -542,7 +543,9 @@ internal class FlagEvaluator( flagsByKey ?: throw InconclusiveMatchException("Cannot evaluate flag dependencies without flagsByKey"), evaluationCache - ?: throw InconclusiveMatchException("Cannot evaluate flag dependencies without evaluationCache"), + ?: throw InconclusiveMatchException( + "Cannot evaluate flag dependencies without evaluationCache", + ), distinctId ?: throw InconclusiveMatchException("Cannot evaluate flag dependencies without distinctId"), propertyValues, 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 index 26ab80c3..15aa6beb 100644 --- a/posthog-server/src/test/java/com/posthog/server/internal/FlagEvaluatorTest.kt +++ b/posthog-server/src/test/java/com/posthog/server/internal/FlagEvaluatorTest.kt @@ -2,15 +2,11 @@ package com.posthog.server.internal import com.google.gson.reflect.TypeToken import com.posthog.PostHogConfig -import com.posthog.internal.FlagConditionGroup import com.posthog.internal.FlagDefinition -import com.posthog.internal.FlagFilters import com.posthog.internal.FlagProperty -import com.posthog.internal.MultiVariateConfig import com.posthog.internal.PropertyGroup import com.posthog.internal.PropertyOperator import com.posthog.internal.PropertyType -import com.posthog.internal.VariantDefinition import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertNotNull @@ -452,7 +448,8 @@ internal class FlagEvaluatorTest { @Test internal fun testMatchFeatureFlagPropertiesSimpleMatch() { - val json = """ + val json = + """ { "id": 1, "name": "Test Flag", @@ -476,7 +473,7 @@ internal class FlagEvaluatorTest { }, "version": 1 } - """.trimIndent() + """.trimIndent() val flag = config.serializer.gson.fromJson(json, FlagDefinition::class.java) @@ -487,7 +484,8 @@ internal class FlagEvaluatorTest { @Test internal fun testMatchFeatureFlagPropertiesNoMatch() { - val json = """ + val json = + """ { "id": 1, "name": "Test Flag", @@ -511,7 +509,7 @@ internal class FlagEvaluatorTest { }, "version": 1 } - """.trimIndent() + """.trimIndent() val flag = config.serializer.gson.fromJson(json, FlagDefinition::class.java) @@ -522,7 +520,8 @@ internal class FlagEvaluatorTest { @Test internal fun testMatchFeatureFlagPropertiesWithRollout() { - val json = """ + val json = + """ { "id": 1, "name": "Test Flag", @@ -538,7 +537,7 @@ internal class FlagEvaluatorTest { }, "version": 1 } - """.trimIndent() + """.trimIndent() val flag = config.serializer.gson.fromJson(json, FlagDefinition::class.java) @@ -586,7 +585,8 @@ internal class FlagEvaluatorTest { // Helper functions internal fun createSimpleFlag(): FlagDefinition { - val json = """ + val json = + """ { "id": 1, "name": "Simple Flag", @@ -614,7 +614,7 @@ internal class FlagEvaluatorTest { }, "version": 1 } - """.trimIndent() + """.trimIndent() return config.serializer.gson.fromJson(json, FlagDefinition::class.java) } @@ -704,7 +704,8 @@ internal class FlagEvaluatorTest { @Test internal fun testCohortMemberFlag() { - val json = """ + val json = + """ { "id": 26, "name": "Cohort Member", @@ -728,7 +729,7 @@ internal class FlagEvaluatorTest { }, "version": 2 } - """.trimIndent() + """.trimIndent() val flag = config.serializer.gson.fromJson(json, FlagDefinition::class.java) @@ -810,7 +811,8 @@ internal class FlagEvaluatorTest { } internal fun createMixedConditionsFlag(): FlagDefinition { - val json = """ + val json = + """ { "id": 25, "name": "Mixed Conditions", @@ -876,13 +878,14 @@ internal class FlagEvaluatorTest { }, "version": 1 } - """.trimIndent() + """.trimIndent() return config.serializer.gson.fromJson(json, FlagDefinition::class.java) } internal fun createCohortMemberFlag(): FlagDefinition { - val json = """ + val json = + """ { "id": 26, "name": "Cohort Member", @@ -906,13 +909,14 @@ internal class FlagEvaluatorTest { }, "version": 2 } - """.trimIndent() + """.trimIndent() return config.serializer.gson.fromJson(json, FlagDefinition::class.java) } internal fun createCohortProperties(): Map { - val json = """ + val json = + """ { "2": { "type": "AND", @@ -966,14 +970,15 @@ internal class FlagEvaluatorTest { ] } } - """.trimIndent() + """.trimIndent() val type = object : TypeToken>() {}.type return config.serializer.gson.fromJson(json, type) } internal fun createMultiVariateFlag(): FlagDefinition { - val json = """ + val json = + """ { "id": 1, "name": "Multi Variate Flag", @@ -1001,7 +1006,7 @@ internal class FlagEvaluatorTest { }, "version": 1 } - """.trimIndent() + """.trimIndent() return config.serializer.gson.fromJson(json, FlagDefinition::class.java) } @@ -1100,7 +1105,8 @@ internal class FlagEvaluatorTest { @Test internal fun testFlagDependencyInactiveFlag() { - val inactiveFlagJson = """ + val inactiveFlagJson = + """ { "id": 1, "name": "Inactive Flag", @@ -1116,7 +1122,7 @@ internal class FlagEvaluatorTest { }, "version": 1 } - """.trimIndent() + """.trimIndent() val inactiveFlag = config.serializer.gson.fromJson(inactiveFlagJson, FlagDefinition::class.java) @@ -1149,7 +1155,8 @@ internal class FlagEvaluatorTest { @Test internal fun testFlagDependencySimpleMatch() { - val dependencyFlagJson = """ + val dependencyFlagJson = + """ { "id": 1, "name": "Dependency Flag", @@ -1165,7 +1172,7 @@ internal class FlagEvaluatorTest { }, "version": 1 } - """.trimIndent() + """.trimIndent() val dependencyFlag = config.serializer.gson.fromJson(dependencyFlagJson, FlagDefinition::class.java) @@ -1198,7 +1205,8 @@ internal class FlagEvaluatorTest { @Test internal fun testFlagDependencyWithFalseValue() { - val dependencyFlagJson = """ + val dependencyFlagJson = + """ { "id": 1, "name": "Dependency Flag", @@ -1214,7 +1222,7 @@ internal class FlagEvaluatorTest { }, "version": 1 } - """.trimIndent() + """.trimIndent() val dependencyFlag = config.serializer.gson.fromJson(dependencyFlagJson, FlagDefinition::class.java) @@ -1248,7 +1256,8 @@ internal class FlagEvaluatorTest { @Test internal fun testFlagDependencyVariantMatch() { - val multivariateFlagJson = """ + val multivariateFlagJson = + """ { "id": 1, "name": "Multivariate Flag", @@ -1272,7 +1281,7 @@ internal class FlagEvaluatorTest { }, "version": 1 } - """.trimIndent() + """.trimIndent() val multivariateFlag = config.serializer.gson.fromJson(multivariateFlagJson, FlagDefinition::class.java) @@ -1305,7 +1314,8 @@ internal class FlagEvaluatorTest { @Test internal fun testFlagDependencyVariantMismatch() { - val multivariateFlagJson = """ + val multivariateFlagJson = + """ { "id": 1, "name": "Multivariate Flag", @@ -1329,7 +1339,7 @@ internal class FlagEvaluatorTest { }, "version": 1 } - """.trimIndent() + """.trimIndent() val multivariateFlag = config.serializer.gson.fromJson(multivariateFlagJson, FlagDefinition::class.java) @@ -1361,7 +1371,8 @@ internal class FlagEvaluatorTest { @Test internal fun testFlagDependencyVariantMatchesBoolean() { - val multivariateFlagJson = """ + val multivariateFlagJson = + """ { "id": 1, "name": "Multivariate Flag", @@ -1385,7 +1396,7 @@ internal class FlagEvaluatorTest { }, "version": 1 } - """.trimIndent() + """.trimIndent() val multivariateFlag = config.serializer.gson.fromJson(multivariateFlagJson, FlagDefinition::class.java) @@ -1417,7 +1428,8 @@ internal class FlagEvaluatorTest { @Test internal fun testFlagDependencyChainedDependencies() { - val flag1Json = """ + val flag1Json = + """ { "id": 1, "name": "Flag 1", @@ -1433,9 +1445,10 @@ internal class FlagEvaluatorTest { }, "version": 1 } - """.trimIndent() + """.trimIndent() - val flag2Json = """ + val flag2Json = + """ { "id": 2, "name": "Flag 2", @@ -1451,7 +1464,7 @@ internal class FlagEvaluatorTest { }, "version": 1 } - """.trimIndent() + """.trimIndent() val flag1 = config.serializer.gson.fromJson(flag1Json, FlagDefinition::class.java) val flag2 = config.serializer.gson.fromJson(flag2Json, FlagDefinition::class.java) @@ -1486,7 +1499,8 @@ internal class FlagEvaluatorTest { @Test internal fun testFlagDependencyChainFailsEarly() { - val flag1Json = """ + val flag1Json = + """ { "id": 1, "name": "Flag 1", @@ -1502,9 +1516,10 @@ internal class FlagEvaluatorTest { }, "version": 1 } - """.trimIndent() + """.trimIndent() - val flag2Json = """ + val flag2Json = + """ { "id": 2, "name": "Flag 2", @@ -1520,7 +1535,7 @@ internal class FlagEvaluatorTest { }, "version": 1 } - """.trimIndent() + """.trimIndent() val flag1 = config.serializer.gson.fromJson(flag1Json, FlagDefinition::class.java) val flag2 = config.serializer.gson.fromJson(flag2Json, FlagDefinition::class.java) @@ -1554,7 +1569,8 @@ internal class FlagEvaluatorTest { @Test internal fun testFlagDependencyNoExpectedValue() { - val dependencyFlagJson = """ + val dependencyFlagJson = + """ { "id": 1, "name": "Dependency Flag", @@ -1570,7 +1586,7 @@ internal class FlagEvaluatorTest { }, "version": 1 } - """.trimIndent() + """.trimIndent() val dependencyFlag = config.serializer.gson.fromJson(dependencyFlagJson, FlagDefinition::class.java) @@ -1603,7 +1619,8 @@ internal class FlagEvaluatorTest { @Test internal fun testFlagDependencyInvalidOperator() { - val dependencyFlagJson = """ + val dependencyFlagJson = + """ { "id": 1, "name": "Dependency Flag", @@ -1619,7 +1636,7 @@ internal class FlagEvaluatorTest { }, "version": 1 } - """.trimIndent() + """.trimIndent() val dependencyFlag = config.serializer.gson.fromJson(dependencyFlagJson, FlagDefinition::class.java) @@ -1653,7 +1670,8 @@ internal class FlagEvaluatorTest { @Test internal fun testFlagDependencyWithPropertyConditions() { - val dependencyFlagJson = """ + val dependencyFlagJson = + """ { "id": 1, "name": "Dependency Flag", @@ -1677,7 +1695,7 @@ internal class FlagEvaluatorTest { }, "version": 1 } - """.trimIndent() + """.trimIndent() val dependencyFlag = config.serializer.gson.fromJson(dependencyFlagJson, FlagDefinition::class.java) @@ -1711,7 +1729,8 @@ internal class FlagEvaluatorTest { @Test internal fun testMatchFeatureFlagPropertiesWithFlagDependency() { - val dependencyFlagJson = """ + val dependencyFlagJson = + """ { "id": 1, "name": "Dependency Flag", @@ -1727,9 +1746,10 @@ internal class FlagEvaluatorTest { }, "version": 1 } - """.trimIndent() + """.trimIndent() - val mainFlagJson = """ + val mainFlagJson = + """ { "id": 2, "name": "Main Flag", @@ -1754,7 +1774,7 @@ internal class FlagEvaluatorTest { }, "version": 1 } - """.trimIndent() + """.trimIndent() val dependencyFlag = config.serializer.gson.fromJson(dependencyFlagJson, FlagDefinition::class.java) val mainFlag = config.serializer.gson.fromJson(mainFlagJson, FlagDefinition::class.java) @@ -1777,7 +1797,8 @@ internal class FlagEvaluatorTest { @Test internal fun testMatchFeatureFlagPropertiesWithFailedFlagDependency() { - val dependencyFlagJson = """ + val dependencyFlagJson = + """ { "id": 1, "name": "Dependency Flag", @@ -1793,9 +1814,10 @@ internal class FlagEvaluatorTest { }, "version": 1 } - """.trimIndent() + """.trimIndent() - val mainFlagJson = """ + val mainFlagJson = + """ { "id": 2, "name": "Main Flag", @@ -1820,7 +1842,7 @@ internal class FlagEvaluatorTest { }, "version": 1 } - """.trimIndent() + """.trimIndent() val dependencyFlag = config.serializer.gson.fromJson(dependencyFlagJson, FlagDefinition::class.java) val mainFlag = config.serializer.gson.fromJson(mainFlagJson, FlagDefinition::class.java) 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 fc3cf2ac..7ffb4856 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 @@ -375,14 +375,16 @@ internal class PostHogFeatureFlagsTest { @Test fun `local evaluation poller loads flag definitions`() { val logger = TestLogger() - val localEvalResponse = createLocalEvaluationResponse( - flagKey = "test-flag", - aggregationGroupTypeIndex = null, - ) + val localEvalResponse = + createLocalEvaluationResponse( + flagKey = "test-flag", + aggregationGroupTypeIndex = null, + ) - val mockServer = createMockHttp( - jsonResponse(localEvalResponse), - ) + val mockServer = + createMockHttp( + jsonResponse(localEvalResponse), + ) val url = mockServer.url("/") val config = createTestConfig(logger, url.toString()) @@ -404,14 +406,17 @@ internal class PostHogFeatureFlagsTest { // Check that we made the API call assertTrue( mockServer.requestCount >= 1, - "Expected at least 1 request, got ${mockServer.requestCount}" + "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" - ) - }) + assertTrue( + logger.containsLog("Loaded 1 feature flags for local evaluation") || + logger.logs.any { + it.contains( + "Loaded", + ) + }, + ) remoteConfig.shutDown() mockServer.shutdown() @@ -420,16 +425,18 @@ internal class PostHogFeatureFlagsTest { @Test fun `group-based flag evaluates correctly when group is provided`() { val logger = TestLogger() - val localEvalResponse = createLocalEvaluationResponse( - flagKey = "org-feature", - aggregationGroupTypeIndex = 2, // organization - ) + 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 mockServer = + createMockHttp( + jsonResponse(localEvalResponse), + jsonResponse(createEmptyFlagsResponse()), + ) val url = mockServer.url("/") val config = createTestConfig(logger, url.toString()) @@ -468,16 +475,18 @@ internal class PostHogFeatureFlagsTest { @Test fun `group-based flag returns false when required group is missing`() { val logger = TestLogger() - val localEvalResponse = createLocalEvaluationResponse( - flagKey = "org-feature", - aggregationGroupTypeIndex = 2, // organization - ) + val localEvalResponse = + createLocalEvaluationResponse( + flagKey = "org-feature", + aggregationGroupTypeIndex = 2, + ) // Add fallback response in case local evaluation fails - val mockServer = createMockHttp( - jsonResponse(localEvalResponse), - jsonResponse(createEmptyFlagsResponse()), - ) + val mockServer = + createMockHttp( + jsonResponse(localEvalResponse), + jsonResponse(createEmptyFlagsResponse()), + ) val url = mockServer.url("/") val config = createTestConfig(logger, url.toString()) @@ -498,7 +507,7 @@ internal class PostHogFeatureFlagsTest { key = "org-feature", defaultValue = "default", distinctId = "user-123", - groups = null, // No groups provided + groups = null, ) // Debug logging @@ -519,39 +528,40 @@ internal class PostHogFeatureFlagsTest { // 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": {} - } + { + "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 mockServer = + createMockHttp( + jsonResponse(localEvalResponse), + jsonResponse(apiFlagsResponse), + ) val url = mockServer.url("/") val config = createTestConfig(logger, url.toString()) @@ -588,14 +598,16 @@ internal class PostHogFeatureFlagsTest { @Test fun `person-based flag still works with local evaluation`() { val logger = TestLogger() - val localEvalResponse = createLocalEvaluationResponse( - flagKey = "person-feature", - aggregationGroupTypeIndex = null, // person-based flag - ) + val localEvalResponse = + createLocalEvaluationResponse( + flagKey = "person-feature", + aggregationGroupTypeIndex = null, + ) - val mockServer = createMockHttp( - jsonResponse(localEvalResponse), - ) + val mockServer = + createMockHttp( + jsonResponse(localEvalResponse), + ) val url = mockServer.url("/") val config = createTestConfig(logger, url.toString()) From 518dc86a2e2d2f95b563b16e4052e0c28a84e38b Mon Sep 17 00:00:00 2001 From: Dustin Byrne Date: Tue, 28 Oct 2025 12:05:40 -0400 Subject: [PATCH 23/27] fix: Properly type `userProperties`/`groupProperties` --- .../server/internal/PostHogFeatureFlags.kt | 20 +++++++++---------- .../internal/PostHogFeatureFlagsTest.kt | 2 +- 2 files changed, 11 insertions(+), 11 deletions(-) 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 4ab663ee..96125753 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 @@ -96,8 +96,8 @@ internal class PostHogFeatureFlags( key: String, distinctId: String, groups: Map?, - personProperties: Map?, - groupProperties: Map?, + personProperties: Map?, + groupProperties: Map>?, ): FeatureFlag? { val cachedFlags = getFeatureFlagsFromCache(distinctId, groups, personProperties, groupProperties) @@ -157,8 +157,8 @@ internal class PostHogFeatureFlags( private fun getFeatureFlagsFromCache( distinctId: String, groups: Map?, - personProperties: Map?, - groupProperties: Map?, + personProperties: Map?, + groupProperties: Map>?, ): Map? { val cacheKey = FeatureFlagCacheKey( @@ -174,8 +174,8 @@ internal class PostHogFeatureFlags( private fun getFeatureFlagsFromLocalEvaluation( distinctId: String, groups: Map?, - personProperties: Map?, - groupProperties: Map?, + personProperties: Map?, + groupProperties: Map>?, onlyEvaluateLocally: Boolean = false, ): Map? { if (!localEvaluation) { @@ -225,8 +225,8 @@ internal class PostHogFeatureFlags( private fun getFeatureFlagsFromRemote( distinctId: String, groups: Map?, - personProperties: Map?, - groupProperties: Map?, + personProperties: Map?, + groupProperties: Map>?, ): Map? { val cacheKey = FeatureFlagCacheKey( @@ -411,9 +411,9 @@ internal class PostHogFeatureFlags( private fun computeFlagLocally( key: String, distinctId: String, - personProperties: Map, groups: Map?, - groupProperties: Map?, + personProperties: Map?, + groupProperties: Map>?, ): Any? { val flags = this.flagDefinitions ?: return null val flag = flags[key] ?: return null 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 7ffb4856..4c8deb58 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 @@ -457,7 +457,7 @@ internal class PostHogFeatureFlagsTest { defaultValue = false, distinctId = "user-123", groups = mapOf("organization" to "org-456"), - groupProperties = mapOf("plan" to "enterprise"), + groupProperties = mapOf("org-456" to mapOf("plan" to "enterprise")), ) // Debug logging From 887a3cf231c9881ea1430daf97dc1c6fe994fcb5 Mon Sep 17 00:00:00 2001 From: Dustin Byrne Date: Tue, 28 Oct 2025 14:39:37 -0400 Subject: [PATCH 24/27] feat: Setting `personalApiKey` turns local eval on by default --- .../java/com/posthog/server/PostHogConfig.kt | 12 +++++-- .../com/posthog/server/PostHogConfigTest.kt | 34 +++++++++++++++++++ 2 files changed, 43 insertions(+), 3 deletions(-) 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 6add3f9f..88bd7af6 100644 --- a/posthog-server/src/main/java/com/posthog/server/PostHogConfig.kt +++ b/posthog-server/src/main/java/com/posthog/server/PostHogConfig.kt @@ -227,7 +227,7 @@ 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 = false + private var localEvaluation: Boolean? = null private var personalApiKey: String? = null private var pollIntervalSeconds: Int = DEFAULT_POLL_INTERVAL_SECONDS @@ -265,7 +265,13 @@ public open class PostHogConfig constructor( public fun localEvaluation(localEvaluation: Boolean): Builder = apply { this.localEvaluation = localEvaluation } - public fun personalApiKey(personalApiKey: String?): Builder = apply { this.personalApiKey = personalApiKey } + 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 } @@ -287,7 +293,7 @@ public open class PostHogConfig constructor( featureFlagCacheSize = featureFlagCacheSize, featureFlagCacheMaxAgeMs = featureFlagCacheMaxAgeMs, featureFlagCalledCacheSize = featureFlagCalledCacheSize, - localEvaluation = localEvaluation, + localEvaluation = localEvaluation ?: false, personalApiKey = personalApiKey, pollIntervalSeconds = pollIntervalSeconds, ) 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) + } } From 2e1d5f67ca4054b42806d0938c7e9f249f65e2c0 Mon Sep 17 00:00:00 2001 From: Dustin Byrne Date: Fri, 10 Oct 2025 14:35:00 -0400 Subject: [PATCH 25/27] feat: `sendFeatureFlags` config is respected --- .../main/java/com/posthog/server/PostHog.kt | 221 +++++- .../posthog/server/PostHogCaptureOptions.kt | 17 + .../java/com/posthog/server/PostHogConfig.kt | 4 +- .../server/PostHogFeatureFlagOptions.kt | 24 + .../com/posthog/server/PostHogInterface.kt | 5 +- .../server/PostHogSendFeatureFlagOptions.kt | 92 +++ .../server/internal/PostHogFeatureFlags.kt | 93 ++- .../java/com/posthog/server/PostHogTest.kt | 688 +++--------------- .../internal/PostHogFeatureFlagsTest.kt | 166 +++++ posthog/api/posthog.api | 10 + posthog/src/main/java/com/posthog/PostHog.kt | 56 +- .../main/java/com/posthog/PostHogStateless.kt | 2 +- .../internal/PostHogFeatureFlagCalledCache.kt | 8 +- 13 files changed, 715 insertions(+), 671 deletions(-) create mode 100644 posthog-server/src/main/java/com/posthog/server/PostHogSendFeatureFlagOptions.kt 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..f79d05d0 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,112 @@ 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) + } + } + } + + private fun appendFlagCaptureProperties( + distinctId: String, + properties: MutableMap?, + groups: Map?, + options: PostHogSendFeatureFlagOptions?, + ) { + if (options == null || properties == null) { + return + } + + val response = + (featureFlags as? PostHogFeatureFlags)?.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() + } + } + 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 88bd7af6..fd922c79 100644 --- a/posthog-server/src/main/java/com/posthog/server/PostHogConfig.kt +++ b/posthog-server/src/main/java/com/posthog/server/PostHogConfig.kt @@ -129,6 +129,7 @@ public open class PostHogConfig constructor( ) { private val beforeSendCallbacks = mutableListOf() private val integrations = mutableListOf() + internal var featureFlags: PostHogFeatureFlags? = null public fun addBeforeSend(beforeSend: PostHogBeforeSend) { beforeSendCallbacks.add(beforeSend) @@ -149,7 +150,6 @@ public open class PostHogConfig constructor( apiKey = apiKey, host = host, debug = debug, - sendFeatureFlagEvent = sendFeatureFlagEvent, preloadFeatureFlags = preloadFeatureFlags, remoteConfig = remoteConfig, flushAt = flushAt, @@ -174,6 +174,8 @@ public open class PostHogConfig constructor( 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 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..ab017c95 --- /dev/null +++ b/posthog-server/src/main/java/com/posthog/server/PostHogSendFeatureFlagOptions.kt @@ -0,0 +1,92 @@ +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, + value: String, + ): Builder { + personProperties = + (personProperties ?: mutableMapOf()).apply { + put(key, value) + } + 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( + key: String, + value: String, + ): Builder { + groupProperties = + (groupProperties ?: mutableMapOf()).apply { + put(key, value) + } + 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 { + putAll(groupProperties) + } + 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/PostHogFeatureFlags.kt b/posthog-server/src/main/java/com/posthog/server/internal/PostHogFeatureFlags.kt index 96125753..f296f2ec 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 @@ -8,6 +8,32 @@ 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, @@ -67,7 +93,7 @@ internal class PostHogFeatureFlags( groups, personProperties, groupProperties, - ) + )?.results?.get(key) return flag?.variant ?: flag?.enabled ?: defaultValue } @@ -88,16 +114,17 @@ internal class PostHogFeatureFlags( groups, personProperties, groupProperties, - )?.metadata?.payload + )?.results?.get(key)?.metadata?.payload ?: defaultValue } - private fun resolveFeatureFlag( + internal fun resolveFeatureFlag( key: String, distinctId: String, groups: Map?, personProperties: Map?, groupProperties: Map>?, + onlyEvaluateLocally: Boolean = false, ): FeatureFlag? { val cachedFlags = getFeatureFlagsFromCache(distinctId, groups, personProperties, groupProperties) @@ -135,12 +162,20 @@ internal class PostHogFeatureFlags( return flag } 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 if (onlyEvaluateLocally) { + return null } // Local evaluation not available or failed - fall back to API @@ -227,7 +262,7 @@ internal class PostHogFeatureFlags( groups: Map?, personProperties: Map?, groupProperties: Map>?, - ): Map? { + ): RemoteFeatureFlagsResponse { val cacheKey = FeatureFlagCacheKey( distinctId = distinctId, @@ -238,17 +273,17 @@ internal class PostHogFeatureFlags( val cachedFlags = cache.get(cacheKey) if (cachedFlags != null) { - return cachedFlags + return RemoteFeatureFlagsResponse(flags = cachedFlags, requestId = null) } 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 remote feature flags failed: $e") - null + RemoteFeatureFlagsResponse(flags = null, requestId = null) } } @@ -258,6 +293,23 @@ internal class PostHogFeatureFlags( 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 @@ -265,7 +317,11 @@ internal class PostHogFeatureFlags( val cached = getFeatureFlagsFromCache(distinctId, groups, personProperties, groupProperties) if (cached != null) { - return cached + return FeatureFlagResultContext( + results = cached, + source = EvaluationSource.CACHE, + exhaustive = true, + ) } // If no cached flags, try local evaluation @@ -275,13 +331,30 @@ internal class PostHogFeatureFlags( groups, personProperties, groupProperties, + onlyEvaluateLocally = onlyEvaluateLocally, ) if (localFlags != null) { - return localFlags + return FeatureFlagResultContext( + results = localFlags, + source = EvaluationSource.LOCAL, + exhaustive = true, + ) } // Finally, fall back to remote fetch - return getFeatureFlagsFromRemote(distinctId, groups, personProperties, groupProperties) + 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() { 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/internal/PostHogFeatureFlagsTest.kt b/posthog-server/src/test/java/com/posthog/server/internal/PostHogFeatureFlagsTest.kt index 4c8deb58..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 @@ -11,6 +11,7 @@ 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 @@ -319,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 diff --git a/posthog/api/posthog.api b/posthog/api/posthog.api index 382802dc..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 @@ -578,6 +580,14 @@ 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; 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 4eb6aded..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 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 From 4618396e380a87d097e51da19b0ac2d88be6db0e Mon Sep 17 00:00:00 2001 From: Dustin Byrne Date: Fri, 10 Oct 2025 14:05:05 -0400 Subject: [PATCH 26/27] chore: Add send feature flags on capture to Java example --- .../posthog/java/sample/PostHogJavaExample.java | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) 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 7b74f169..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 @@ -5,7 +5,7 @@ import com.posthog.server.PostHogConfig; import com.posthog.server.PostHogFeatureFlagOptions; import com.posthog.server.PostHogInterface; - +import com.posthog.server.PostHogSendFeatureFlagOptions; import java.util.HashMap; /** @@ -69,6 +69,20 @@ public static void main(String[] args) { 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(); } From d40388a3d8ecf216a00540be389fa8346c8b1ae3 Mon Sep 17 00:00:00 2001 From: Dustin Byrne Date: Tue, 28 Oct 2025 13:27:55 -0400 Subject: [PATCH 27/27] fix: `groupProperties` and `userProperties` typed correctly --- posthog-server/api/posthog-server.api | 54 ++++++++-- .../main/java/com/posthog/server/PostHog.kt | 32 ++---- .../server/PostHogSendFeatureFlagOptions.kt | 25 +++-- .../server/internal/PostHogFeatureFlags.kt | 102 +++++++++++++++--- 4 files changed, 157 insertions(+), 56 deletions(-) diff --git a/posthog-server/api/posthog-server.api b/posthog-server/api/posthog-server.api index 0a3b7826..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 @@ -162,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 { @@ -177,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 { @@ -198,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 @@ -225,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; @@ -247,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 f79d05d0..b2136b60 100644 --- a/posthog-server/src/main/java/com/posthog/server/PostHog.kt +++ b/posthog-server/src/main/java/com/posthog/server/PostHog.kt @@ -249,36 +249,18 @@ public class PostHog : PostHogInterface, PostHogStateless() { } } - private fun appendFlagCaptureProperties( + internal fun appendFlagCaptureProperties( distinctId: String, properties: MutableMap?, groups: Map?, options: PostHogSendFeatureFlagOptions?, ) { - if (options == null || properties == null) { - return - } - - val response = - (featureFlags as? PostHogFeatureFlags)?.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() - } + (featureFlags as? PostHogFeatureFlags)?.appendFlagEventProperties( + distinctId, + properties, + groups, + options, + ) } public companion object { diff --git a/posthog-server/src/main/java/com/posthog/server/PostHogSendFeatureFlagOptions.kt b/posthog-server/src/main/java/com/posthog/server/PostHogSendFeatureFlagOptions.kt index ab017c95..615043d3 100644 --- a/posthog-server/src/main/java/com/posthog/server/PostHogSendFeatureFlagOptions.kt +++ b/posthog-server/src/main/java/com/posthog/server/PostHogSendFeatureFlagOptions.kt @@ -7,13 +7,13 @@ package com.posthog.server */ public class PostHogSendFeatureFlagOptions private constructor( public val onlyEvaluateLocally: Boolean = false, - public val personProperties: Map?, - public val groupProperties: Map?, + 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 + public var personProperties: MutableMap? = null + public var groupProperties: MutableMap>? = null /** * Sets whether to only evaluate the feature flags locally. @@ -29,11 +29,11 @@ public class PostHogSendFeatureFlagOptions private constructor( */ public fun personProperty( key: String, - value: String, + propValue: Any?, ): Builder { personProperties = (personProperties ?: mutableMapOf()).apply { - put(key, value) + put(key, propValue) } return this } @@ -42,7 +42,7 @@ public class PostHogSendFeatureFlagOptions private constructor( * Appends multiple user properties to the capture options. * @see Documentation: User Properties */ - public fun personProperties(userProperties: Map): Builder { + public fun personProperties(userProperties: Map): Builder { this.personProperties = (this.personProperties ?: mutableMapOf()).apply { putAll(userProperties) @@ -55,12 +55,13 @@ public class PostHogSendFeatureFlagOptions private constructor( * @see Documentation: User Properties */ public fun groupProperty( + group: String, key: String, - value: String, + propValue: Any?, ): Builder { groupProperties = (groupProperties ?: mutableMapOf()).apply { - put(key, value) + getOrPut(group) { mutableMapOf() }[key] = propValue } return this } @@ -69,10 +70,12 @@ public class PostHogSendFeatureFlagOptions private constructor( * Appends multiple user properties (set once) to the capture options. * @see Documentation: User Properties */ - public fun groupProperties(groupProperties: Map): Builder { + public fun groupProperties(groupProperties: Map>): Builder { this.groupProperties = (this.groupProperties ?: mutableMapOf()).apply { - putAll(groupProperties) + groupProperties.forEach { (group, properties) -> + getOrPut(group) { mutableMapOf() }.putAll(properties) + } } return this } 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 f296f2ec..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 @@ -16,8 +16,8 @@ internal enum class EvaluationSource { internal data class FeatureFlagResolutionParameters( val groups: Map? = null, - val personProperties: Map? = null, - val groupProperties: Map? = null, + val personProperties: Map? = null, + val groupProperties: Map>? = null, val onlyEvaluateLocally: Boolean = false, ) @@ -125,14 +125,24 @@ internal class PostHogFeatureFlags( personProperties: Map?, groupProperties: Map>?, onlyEvaluateLocally: Boolean = false, - ): FeatureFlag? { + ): 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 flag + return FeatureFlagResultContext( + results = mapOf(key to flag), + source = EvaluationSource.CACHE, + parameters = + FeatureFlagResolutionParameters( + groups = groups, + personProperties = personProperties, + groupProperties = groupProperties, + onlyEvaluateLocally = onlyEvaluateLocally, + ), + ) } } @@ -159,7 +169,17 @@ internal class PostHogFeatureFlags( val flag = buildFeatureFlagFromResult(key, result, flagDef) config.logger.log("Local evaluation successful for flag '$key'") - return flag + 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) { @@ -174,19 +194,38 @@ internal class PostHogFeatureFlags( // Fall through to remote evaluation } } - } else if if (onlyEvaluateLocally) { + } 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") - return getFeatureFlagsFromRemote( - distinctId, - groups, - personProperties, - groupProperties, - )?.get(key) + 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( @@ -306,8 +345,8 @@ internal class PostHogFeatureFlags( internal fun resolveFeatureFlags( distinctId: String?, groups: Map?, - personProperties: Map?, - groupProperties: Map?, + personProperties: Map?, + groupProperties: Map>?, onlyEvaluateLocally: Boolean = false, ): FeatureFlagResultContext? { if (distinctId == null) { @@ -478,6 +517,41 @@ internal class PostHogFeatureFlags( } } + /** + * 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 */