diff --git a/posthog-server/CHANGELOG.md b/posthog-server/CHANGELOG.md index a6cffdf1..9ee3cd22 100644 --- a/posthog-server/CHANGELOG.md +++ b/posthog-server/CHANGELOG.md @@ -1,5 +1,8 @@ ## Next +- feat: Add `evaluateFlags()` API for single-call flag evaluation. Returns a `PostHogFeatureFlagEvaluations` snapshot with `isEnabled`/`getFlag`/`getFlagPayload`/`getFlagPayloadAs` accessors plus `onlyAccessed()` and `only(keys)` filters. `capture()` accepts the snapshot via a new `flags` parameter to attach `$feature/` properties without a second `/flags` request, and user-supplied `$feature/` properties now win over snapshot-derived ones. `$feature_flag_called` events include `$feature_flag_id`, `$feature_flag_version`, `$feature_flag_reason`, and propagate `$feature_flag_error` (response-level errors plus `flag_missing` for unknown keys). `flagKeys` and `disableGeoip` are forwarded to the `/flags` request body and contribute to the per-identity cache key. +- chore: Deprecate `isFeatureEnabled`, `getFeatureFlag`, `getFeatureFlagPayload`, `getFeatureFlagResult`, and `capture(appendFeatureFlags = true)` in favour of `evaluateFlags(...)`. The legacy methods keep working unchanged; callers see a Kotlin compile-time warning (silenceable with `@Suppress("DEPRECATION")`) and the `appendFeatureFlags = true` capture path emits a one-line deprecation log. Removal targets the next major. + ## 2.4.1 ### Patch Changes diff --git a/posthog-server/USAGE.md b/posthog-server/USAGE.md index 4cd0a4b5..f1dab34c 100644 --- a/posthog-server/USAGE.md +++ b/posthog-server/USAGE.md @@ -208,6 +208,50 @@ postHog.capture( When `appendFeatureFlags` is `true`, the SDK will fetch feature flags for the user (or use locally evaluated flags if local evaluation is enabled) and include them in the event properties. +### Single-Call Flag Evaluation: `evaluateFlags()` + +When you need to read several flags for the same user, call `evaluateFlags()` once and pass the resulting snapshot around. The snapshot answers `isEnabled` / `getFlag` / `getFlagPayload` from memory, dedups `$feature_flag_called` events per flag, and can be attached to a `capture()` call so the event is enriched without making another `/flags` request. + +#### Kotlin + +```kotlin +val flags = postHog.evaluateFlags("user123") + +if (flags.isEnabled("new-checkout")) { + showNewCheckout() +} +val payload = flags.getFlagPayload("pricing-experiment") + +// Attach only the flags actually consulted on this request to the capture event: +postHog.capture( + distinctId = "user123", + event = "purchase_completed", + properties = mapOf("amount" to 99.99), + flags = flags.onlyAccessed(), +) +``` + +#### Java + +```java +PostHogFeatureFlagEvaluations flags = postHog.evaluateFlags("user123"); + +if (flags.isEnabled("new-checkout")) { + showNewCheckout(); +} + +postHog.capture( + "user123", + "purchase_completed", + PostHogCaptureOptions.builder() + .property("amount", 99.99) + .flags(flags.onlyAccessed()) + .build() +); +``` + +`flagKeys` (an option on `evaluateFlags`) restricts what the server computes; the snapshot's `only(...)` helper, by contrast, filters in memory after the request. + ## Error Tracking PostHog provides error tracking to help you monitor and debug errors in your application. Use the `captureException` API to log exceptions to PostHog. diff --git a/posthog-server/api/posthog-server.api b/posthog-server/api/posthog-server.api index 5448391d..e37f6a5d 100644 --- a/posthog-server/api/posthog-server.api +++ b/posthog-server/api/posthog-server.api @@ -4,13 +4,16 @@ public final class com/posthog/server/PostHog : com/posthog/PostHogStateless, co 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;Z)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;ZLcom/posthog/server/PostHogFeatureFlagEvaluations;)V public fun captureException (Ljava/lang/Throwable;)V public fun captureException (Ljava/lang/Throwable;Ljava/lang/String;)V public fun captureException (Ljava/lang/Throwable;Ljava/lang/String;Ljava/util/Map;)V public fun captureException (Ljava/lang/Throwable;Ljava/util/Map;)V public fun close ()V public fun debug (Z)V + public fun evaluateFlags (Ljava/lang/String;)Lcom/posthog/server/PostHogFeatureFlagEvaluations; + public fun evaluateFlags (Ljava/lang/String;Lcom/posthog/server/PostHogEvaluateFlagsOptions;)Lcom/posthog/server/PostHogFeatureFlagEvaluations; + public fun evaluateFlags (Ljava/lang/String;Ljava/util/Map;Ljava/util/Map;Ljava/util/Map;Ljava/util/List;ZZ)Lcom/posthog/server/PostHogFeatureFlagEvaluations; public fun flush ()V public fun getFeatureFlag (Ljava/lang/String;Ljava/lang/String;)Ljava/lang/Object; public fun getFeatureFlag (Ljava/lang/String;Ljava/lang/String;Lcom/posthog/server/PostHogFeatureFlagOptions;)Ljava/lang/Object; @@ -43,9 +46,10 @@ 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;ZLkotlin/jvm/internal/DefaultConstructorMarker;)V + public synthetic fun (Ljava/util/Map;Ljava/util/Map;Ljava/util/Map;Ljava/util/Map;Ljava/util/Date;ZLcom/posthog/server/PostHogFeatureFlagEvaluations;Lkotlin/jvm/internal/DefaultConstructorMarker;)V public static final fun builder ()Lcom/posthog/server/PostHogCaptureOptions$Builder; public final fun getAppendFeatureFlags ()Z + public final fun getFlags ()Lcom/posthog/server/PostHogFeatureFlagEvaluations; public final fun getGroups ()Ljava/util/Map; public final fun getProperties ()Ljava/util/Map; public final fun getTimestamp ()Ljava/util/Date; @@ -57,7 +61,9 @@ public final class com/posthog/server/PostHogCaptureOptions$Builder { public fun ()V public final fun appendFeatureFlags (Z)Lcom/posthog/server/PostHogCaptureOptions$Builder; public final fun build ()Lcom/posthog/server/PostHogCaptureOptions; + public final fun flags (Lcom/posthog/server/PostHogFeatureFlagEvaluations;)Lcom/posthog/server/PostHogCaptureOptions$Builder; public final fun getAppendFeatureFlags ()Z + public final fun getFlags ()Lcom/posthog/server/PostHogFeatureFlagEvaluations; public final fun getGroups ()Ljava/util/Map; public final fun getProperties ()Ljava/util/Map; public final fun getTimestamp ()Ljava/util/Date; @@ -68,6 +74,7 @@ public final class com/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 setAppendFeatureFlags (Z)V + public final fun setFlags (Lcom/posthog/server/PostHogFeatureFlagEvaluations;)V public final fun setGroups (Ljava/util/Map;)V public final fun setProperties (Ljava/util/Map;)V public final fun setTimestamp (Ljava/util/Date;)V @@ -175,6 +182,67 @@ public final class com/posthog/server/PostHogConfig$Companion { public final fun builder (Ljava/lang/String;)Lcom/posthog/server/PostHogConfig$Builder; } +public final class com/posthog/server/PostHogEvaluateFlagsOptions { + public static final field Companion Lcom/posthog/server/PostHogEvaluateFlagsOptions$Companion; + public synthetic fun (Ljava/util/Map;Ljava/util/Map;Ljava/util/Map;Ljava/util/List;ZZLkotlin/jvm/internal/DefaultConstructorMarker;)V + public static final fun builder ()Lcom/posthog/server/PostHogEvaluateFlagsOptions$Builder; + public final fun getDisableGeoip ()Z + public final fun getFlagKeys ()Ljava/util/List; + 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 class com/posthog/server/PostHogEvaluateFlagsOptions$Builder { + public fun ()V + public final fun build ()Lcom/posthog/server/PostHogEvaluateFlagsOptions; + public final fun disableGeoip (Z)Lcom/posthog/server/PostHogEvaluateFlagsOptions$Builder; + public final fun flagKeys (Ljava/util/List;)Lcom/posthog/server/PostHogEvaluateFlagsOptions$Builder; + public final fun getDisableGeoip ()Z + public final fun getFlagKeys ()Ljava/util/List; + 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 group (Ljava/lang/String;Ljava/lang/String;)Lcom/posthog/server/PostHogEvaluateFlagsOptions$Builder; + public final fun groupProperties (Ljava/util/Map;)Lcom/posthog/server/PostHogEvaluateFlagsOptions$Builder; + public final fun groupProperty (Ljava/lang/String;Ljava/lang/String;Ljava/lang/Object;)Lcom/posthog/server/PostHogEvaluateFlagsOptions$Builder; + public final fun groups (Ljava/util/Map;)Lcom/posthog/server/PostHogEvaluateFlagsOptions$Builder; + public final fun onlyEvaluateLocally (Z)Lcom/posthog/server/PostHogEvaluateFlagsOptions$Builder; + public final fun personProperties (Ljava/util/Map;)Lcom/posthog/server/PostHogEvaluateFlagsOptions$Builder; + public final fun personProperty (Ljava/lang/String;Ljava/lang/Object;)Lcom/posthog/server/PostHogEvaluateFlagsOptions$Builder; + public final fun setDisableGeoip (Z)V + public final fun setFlagKeys (Ljava/util/List;)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 class com/posthog/server/PostHogEvaluateFlagsOptions$Companion { + public final fun builder ()Lcom/posthog/server/PostHogEvaluateFlagsOptions$Builder; +} + +public final class com/posthog/server/PostHogFeatureFlagEvaluations { + public static final field Companion Lcom/posthog/server/PostHogFeatureFlagEvaluations$Companion; + public final fun getDefinitionsLoadedAt ()Ljava/lang/Long; + public final fun getDistinctId ()Ljava/lang/String; + public final fun getEvaluatedAt ()Ljava/lang/Long; + public final fun getFlag (Ljava/lang/String;)Ljava/lang/Object; + public final fun getFlagPayload (Ljava/lang/String;)Ljava/lang/String; + public final fun getFlagPayloadAs (Ljava/lang/String;Ljava/lang/Class;)Ljava/lang/Object; + public final fun getKeys ()Ljava/util/List; + public final fun getRequestId ()Ljava/lang/String; + public final fun isEnabled (Ljava/lang/String;)Z + public final fun only (Ljava/util/Collection;)Lcom/posthog/server/PostHogFeatureFlagEvaluations; + public final fun only ([Ljava/lang/String;)Lcom/posthog/server/PostHogFeatureFlagEvaluations; + public final fun onlyAccessed ()Lcom/posthog/server/PostHogFeatureFlagEvaluations; +} + +public final class com/posthog/server/PostHogFeatureFlagEvaluations$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 @@ -247,13 +315,16 @@ 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;Z)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;ZLcom/posthog/server/PostHogFeatureFlagEvaluations;)V public abstract fun captureException (Ljava/lang/Throwable;)V public abstract fun captureException (Ljava/lang/Throwable;Ljava/lang/String;)V public abstract fun captureException (Ljava/lang/Throwable;Ljava/lang/String;Ljava/util/Map;)V public abstract fun captureException (Ljava/lang/Throwable;Ljava/util/Map;)V public abstract fun close ()V public abstract fun debug (Z)V + public abstract fun evaluateFlags (Ljava/lang/String;)Lcom/posthog/server/PostHogFeatureFlagEvaluations; + public abstract fun evaluateFlags (Ljava/lang/String;Lcom/posthog/server/PostHogEvaluateFlagsOptions;)Lcom/posthog/server/PostHogFeatureFlagEvaluations; + public abstract synthetic fun evaluateFlags (Ljava/lang/String;Ljava/util/Map;Ljava/util/Map;Ljava/util/Map;Ljava/util/List;ZZ)Lcom/posthog/server/PostHogFeatureFlagEvaluations; public abstract fun flush ()V public abstract fun getFeatureFlag (Ljava/lang/String;Ljava/lang/String;)Ljava/lang/Object; public abstract fun getFeatureFlag (Ljava/lang/String;Ljava/lang/String;Lcom/posthog/server/PostHogFeatureFlagOptions;)Ljava/lang/Object; @@ -282,11 +353,14 @@ 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;ZILjava/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;ZLcom/posthog/server/PostHogFeatureFlagEvaluations;ILjava/lang/Object;)V public static fun captureException (Lcom/posthog/server/PostHogInterface;Ljava/lang/Throwable;)V public static fun captureException (Lcom/posthog/server/PostHogInterface;Ljava/lang/Throwable;Ljava/lang/String;)V public static fun captureException (Lcom/posthog/server/PostHogInterface;Ljava/lang/Throwable;Ljava/util/Map;)V public static synthetic fun captureException$default (Lcom/posthog/server/PostHogInterface;Ljava/lang/Throwable;Ljava/lang/String;Ljava/util/Map;ILjava/lang/Object;)V + public static fun evaluateFlags (Lcom/posthog/server/PostHogInterface;Ljava/lang/String;)Lcom/posthog/server/PostHogFeatureFlagEvaluations; + public static fun evaluateFlags (Lcom/posthog/server/PostHogInterface;Ljava/lang/String;Lcom/posthog/server/PostHogEvaluateFlagsOptions;)Lcom/posthog/server/PostHogFeatureFlagEvaluations; + public static synthetic fun evaluateFlags$default (Lcom/posthog/server/PostHogInterface;Ljava/lang/String;Ljava/util/Map;Ljava/util/Map;Ljava/util/Map;Ljava/util/List;ZZILjava/lang/Object;)Lcom/posthog/server/PostHogFeatureFlagEvaluations; 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; public static fun getFeatureFlag (Lcom/posthog/server/PostHogInterface;Ljava/lang/String;Ljava/lang/String;Ljava/lang/Object;)Ljava/lang/Object; 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 0a264b00..0b76bebe 100644 --- a/posthog-server/src/main/java/com/posthog/server/PostHog.kt +++ b/posthog-server/src/main/java/com/posthog/server/PostHog.kt @@ -1,11 +1,30 @@ package com.posthog.server import com.posthog.FeatureFlagResult -import com.posthog.PostHog import com.posthog.PostHogStateless +import com.posthog.internal.FeatureFlag +import com.posthog.server.internal.EvaluationsHost import com.posthog.server.internal.PostHogFeatureFlags +@Suppress("DEPRECATION") public class PostHog : PostHogStateless(), PostHogInterface { + private val evaluationsHost: EvaluationsHost = + object : EvaluationsHost { + override fun captureFeatureFlagCalled( + distinctId: String, + key: String, + value: Any?, + properties: Map, + ) { + if (getConfig()?.sendFeatureFlagEvent == false) return + this@PostHog.captureFeatureFlagCalledEvent(distinctId, key, value, properties) + } + + override fun logWarning(message: String) { + getConfig()?.logger?.log(message) + } + } + override fun setup(config: T) { super.setup(config.asCoreConfig()) } @@ -43,18 +62,36 @@ public class PostHog : PostHogStateless(), PostHogInterface { groups: Map?, timestamp: java.util.Date?, appendFeatureFlags: Boolean, + flags: PostHogFeatureFlagEvaluations?, ) { val mergedProperties = - if (appendFeatureFlags) { - mergeFeatureFlagProperties( - distinctId = distinctId, - groups = groups, - userProperties = userProperties, - groupProperties = null, - properties = properties, - ) - } else { - properties + when { + flags != null -> { + if (appendFeatureFlags) { + getConfig()?.logger?.log( + "capture() received both `flags` and `appendFeatureFlags=true`; " + + "using the supplied snapshot and skipping the redundant /flags fetch.", + ) + } + mergeFeatureFlagPropertiesFromSnapshot(properties, flags) + } + appendFeatureFlags -> { + getConfig()?.logger?.log( + "DEPRECATION: capture(appendFeatureFlags = true) is deprecated and will be " + + "removed in the next major. Call evaluateFlags(distinctId) once and pass the " + + "snapshot via capture(flags = …) instead — that path attaches " + + "\$feature/ properties without a redundant /flags request and lets you " + + "scope which flags to attach via flags.onlyAccessed() or flags.only(...).", + ) + mergeFeatureFlagProperties( + distinctId = distinctId, + groups = groups, + userProperties = userProperties, + groupProperties = null, + properties = properties, + ) + } + else -> properties } super.captureStateless( @@ -68,6 +105,9 @@ public class PostHog : PostHogStateless(), PostHogInterface { ) } + @Deprecated( + message = "Prefer evaluateFlags(distinctId).isEnabled(key). Will be removed in the next major.", + ) override fun isFeatureEnabled( distinctId: String, key: String, @@ -86,6 +126,9 @@ public class PostHog : PostHogStateless(), PostHogInterface { ) } + @Deprecated( + message = "Prefer evaluateFlags(distinctId).getFlag(key). Will be removed in the next major.", + ) override fun getFeatureFlag( distinctId: String, key: String, @@ -104,6 +147,9 @@ public class PostHog : PostHogStateless(), PostHogInterface { ) } + @Deprecated( + message = "Prefer evaluateFlags(distinctId).getFlagPayload(key). Will be removed in the next major.", + ) override fun getFeatureFlagPayload( distinctId: String, key: String, @@ -122,6 +168,11 @@ public class PostHog : PostHogStateless(), PostHogInterface { ) } + @Deprecated( + message = + "Prefer evaluateFlags(distinctId) and read flag values + payload from the snapshot. " + + "Will be removed in the next major.", + ) override fun getFeatureFlagResult( distinctId: String, key: String, @@ -187,7 +238,6 @@ public class PostHog : PostHogStateless(), PostHogInterface { groupProperties: Map>?, properties: Map?, ): Map { - val props = properties?.toMutableMap() ?: mutableMapOf() val flags = (featureFlags as? PostHogFeatureFlags)?.getFeatureFlags( distinctId = distinctId, @@ -195,30 +245,85 @@ public class PostHog : PostHogStateless(), PostHogInterface { groupProperties = groupProperties, personProperties = userProperties, ) + return appendFlagPropertiesFromMap(properties, flags) + } - if (flags != null && flags.isNotEmpty()) { - val activeFlags = mutableListOf() - - for ((key, flag) in flags) { - val flagValue: Any = flag.variant ?: flag.enabled - props["\$feature/$key"] = flagValue - val isActive = - when (flagValue) { - is Boolean -> flagValue - is String -> flagValue.isNotEmpty() - else -> true - } - if (isActive) { - activeFlags.add(key) + private fun mergeFeatureFlagPropertiesFromSnapshot( + properties: Map?, + snapshot: PostHogFeatureFlagEvaluations, + ): Map { + return appendFlagPropertiesFromMap(properties, snapshot.flags) + } + + private fun appendFlagPropertiesFromMap( + properties: Map?, + flags: Map?, + ): Map { + val props = properties?.toMutableMap() ?: mutableMapOf() + if (flags.isNullOrEmpty()) { + return props + } + + val activeFlags = mutableListOf() + for ((key, flag) in flags) { + val flagValue: Any = flag.variant ?: flag.enabled + // User-supplied `$feature/` overrides generated value (matches Python behavior). + props.putIfAbsent("\$feature/$key", flagValue) + val isActive = + when (flagValue) { + is Boolean -> flagValue + is String -> flagValue.isNotEmpty() + else -> true } + if (isActive) { + activeFlags.add(key) } - - props["\$active_feature_flags"] = activeFlags } + props.putIfAbsent("\$active_feature_flags", activeFlags) return props } + override fun evaluateFlags( + distinctId: String, + groups: Map?, + personProperties: Map?, + groupProperties: Map>?, + flagKeys: List?, + onlyEvaluateLocally: Boolean, + disableGeoip: Boolean, + ): PostHogFeatureFlagEvaluations { + if (distinctId.isBlank()) { + return PostHogFeatureFlagEvaluations.empty(evaluationsHost) + } + + val featureFlagsImpl = + featureFlags as? PostHogFeatureFlags + ?: return PostHogFeatureFlagEvaluations.empty(evaluationsHost) + + val result = + featureFlagsImpl.evaluateFlags( + distinctId = distinctId, + groups = groups, + personProperties = personProperties, + groupProperties = groupProperties, + flagKeys = flagKeys, + onlyEvaluateLocally = onlyEvaluateLocally, + disableGeoip = disableGeoip, + ) + + return PostHogFeatureFlagEvaluations( + distinctId = distinctId, + flagMap = result.flags, + locallyEvaluated = result.locallyEvaluated, + requestId = result.requestId, + evaluatedAt = result.evaluatedAt, + definitionsLoadedAt = result.definitionsLoadedAt, + responseError = result.responseError, + host = evaluationsHost, + ) + } + 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 90135da2..b1b2ea41 100644 --- a/posthog-server/src/main/java/com/posthog/server/PostHogCaptureOptions.kt +++ b/posthog-server/src/main/java/com/posthog/server/PostHogCaptureOptions.kt @@ -15,6 +15,7 @@ public class PostHogCaptureOptions private constructor( public val groups: Map?, public val timestamp: Date? = null, public val appendFeatureFlags: Boolean = false, + public val flags: PostHogFeatureFlagEvaluations? = null, ) { public class Builder { public var properties: MutableMap? = null @@ -23,6 +24,7 @@ public class PostHogCaptureOptions private constructor( public var groups: MutableMap? = null public var timestamp: Date? = null public var appendFeatureFlags: Boolean = false + public var flags: PostHogFeatureFlagEvaluations? = null /** * Add a single custom property to the capture options @@ -168,6 +170,17 @@ public class PostHogCaptureOptions private constructor( return this } + /** + * Attach a snapshot returned by [PostHogInterface.evaluateFlags]. The capture event will be + * enriched with `$feature/` properties and `$active_feature_flags` from the snapshot + * without making another `/flags` request. Mutually exclusive with [appendFeatureFlags]; + * the snapshot wins when both are supplied. + */ + public fun flags(flags: PostHogFeatureFlagEvaluations?): Builder { + this.flags = flags + return this + } + public fun build(): PostHogCaptureOptions = PostHogCaptureOptions( properties, @@ -176,6 +189,7 @@ public class PostHogCaptureOptions private constructor( groups, timestamp, appendFeatureFlags, + flags, ) } diff --git a/posthog-server/src/main/java/com/posthog/server/PostHogEvaluateFlagsOptions.kt b/posthog-server/src/main/java/com/posthog/server/PostHogEvaluateFlagsOptions.kt new file mode 100644 index 00000000..0a5fcf25 --- /dev/null +++ b/posthog-server/src/main/java/com/posthog/server/PostHogEvaluateFlagsOptions.kt @@ -0,0 +1,99 @@ +package com.posthog.server + +/** + * Java-friendly options builder for [PostHogInterface.evaluateFlags]. Kotlin callers should prefer + * named arguments on the method itself. + */ +public class PostHogEvaluateFlagsOptions private constructor( + public val groups: Map?, + public val personProperties: Map?, + public val groupProperties: Map>?, + public val flagKeys: List?, + public val onlyEvaluateLocally: Boolean, + public val disableGeoip: Boolean, +) { + public class Builder { + public var groups: MutableMap? = null + public var personProperties: MutableMap? = null + public var groupProperties: MutableMap>? = null + public var flagKeys: MutableList? = null + public var onlyEvaluateLocally: Boolean = false + public var disableGeoip: Boolean = false + + public fun group( + type: String, + key: String, + ): Builder { + this.groups = (groups ?: mutableMapOf()).apply { put(type, key) } + return this + } + + public fun groups(groups: Map): Builder { + this.groups = (this.groups ?: mutableMapOf()).apply { putAll(groups) } + return this + } + + public fun personProperty( + key: String, + value: Any?, + ): Builder { + this.personProperties = (personProperties ?: mutableMapOf()).apply { put(key, value) } + return this + } + + public fun personProperties(personProperties: Map): Builder { + this.personProperties = (this.personProperties ?: mutableMapOf()).apply { putAll(personProperties) } + return this + } + + public fun groupProperty( + type: String, + key: String, + value: Any?, + ): Builder { + val existing = groupProperties?.get(type)?.toMutableMap() ?: mutableMapOf() + existing[key] = value + this.groupProperties = (groupProperties ?: mutableMapOf()).apply { put(type, existing) } + return this + } + + public fun groupProperties(groupProperties: Map>): Builder { + this.groupProperties = (this.groupProperties ?: mutableMapOf()).apply { putAll(groupProperties) } + return this + } + + /** + * Restrict the underlying `/flags` request to the given keys. This scopes what the server + * computes; the snapshot's `only(...)` helper, by contrast, filters in memory. + */ + public fun flagKeys(flagKeys: List): Builder { + this.flagKeys = (this.flagKeys ?: mutableListOf()).apply { addAll(flagKeys) } + return this + } + + public fun onlyEvaluateLocally(onlyEvaluateLocally: Boolean): Builder { + this.onlyEvaluateLocally = onlyEvaluateLocally + return this + } + + public fun disableGeoip(disableGeoip: Boolean): Builder { + this.disableGeoip = disableGeoip + return this + } + + public fun build(): PostHogEvaluateFlagsOptions = + PostHogEvaluateFlagsOptions( + groups, + personProperties, + groupProperties, + flagKeys, + onlyEvaluateLocally, + disableGeoip, + ) + } + + public companion object { + @JvmStatic + public fun builder(): Builder = Builder() + } +} diff --git a/posthog-server/src/main/java/com/posthog/server/PostHogFeatureFlagEvaluations.kt b/posthog-server/src/main/java/com/posthog/server/PostHogFeatureFlagEvaluations.kt new file mode 100644 index 00000000..dea08a26 --- /dev/null +++ b/posthog-server/src/main/java/com/posthog/server/PostHogFeatureFlagEvaluations.kt @@ -0,0 +1,218 @@ +package com.posthog.server + +import com.google.gson.Gson +import com.posthog.internal.FeatureFlag +import com.posthog.server.internal.EvaluationsHost +import com.posthog.server.internal.FeatureFlagError +import java.util.Collections + +/** + * A snapshot of feature flag evaluations for one [distinctId], produced by + * [PostHogInterface.evaluateFlags]. The snapshot lets callers introspect every flag once without + * issuing additional `/flags` requests, and tracks which flags were accessed so that follow-up + * `capture(flags = …)` calls can attach only the relevant subset. + * + * Accessor side-effects: + * - [isEnabled] / [getFlag] fire `$feature_flag_called` (deduped by the same per-distinct-id LRU + * used by [PostHogInterface.getFeatureFlag]). Empty/blank distinctId short-circuits the event. + * Reads for unknown keys still fire a `$feature_flag_called` event with + * `$feature_flag_error: flag_missing` so dashboards see the lookup attempt. + * - [getFlagPayload] does not fire any event. + * + * Filtered clones from [onlyAccessed] / [only] are independent of the parent — accessing flags on + * the clone does not back-propagate into the parent's "accessed" set. + */ +public class PostHogFeatureFlagEvaluations internal constructor( + public val distinctId: String?, + flagMap: Map, + locallyEvaluated: Map, + public val requestId: String?, + public val evaluatedAt: Long?, + public val definitionsLoadedAt: Long?, + private val responseError: String?, + private val host: EvaluationsHost, + initialAccessed: Set = emptySet(), +) { + private val flagMap: Map = Collections.unmodifiableMap(LinkedHashMap(flagMap)) + private val locallyEvaluated: Map = Collections.unmodifiableMap(HashMap(locallyEvaluated)) + + private val accessLock = Any() + private val accessed: MutableSet = HashSet(initialAccessed) + + /** Returns the snapshotted flag keys in iteration order of the underlying map. */ + public val keys: List + get() = flagMap.keys.toList() + + /** + * Internal access to the rich [FeatureFlag] map. Not exposed publicly because [FeatureFlag] + * lives in `com.posthog.internal` and shouldn't leak through the public API. Same-package + * code (the server `PostHog` class) reads this when building `$feature/` properties for + * `capture(flags = …)`. + */ + internal val flags: Map + get() = flagMap + + /** + * Returns whether the flag is enabled. Unknown flags return false. Records access; fires a + * deduped `$feature_flag_called` event (with `$feature_flag_error: flag_missing` for unknown + * keys), except when this snapshot has no resolvable distinctId. + */ + public fun isEnabled(key: String): Boolean { + val flag = flagMap[key] + recordAccess(key, flag) + if (flag == null) return false + return flag.variant?.isNotEmpty() ?: flag.enabled + } + + /** + * Returns the flag value: the variant string, the boolean enabled flag, or null when the flag + * is unknown. Records access; fires a deduped `$feature_flag_called` event (with + * `$feature_flag_error: flag_missing` for unknown keys), except when this snapshot has no + * resolvable distinctId. + */ + public fun getFlag(key: String): Any? { + val flag = flagMap[key] + recordAccess(key, flag) + if (flag == null) return null + return flag.variant ?: flag.enabled + } + + /** + * Returns the raw payload string for the flag, or null when the flag is unknown or has no + * payload. The server returns payloads as JSON-encoded strings; use [getFlagPayloadAs] when + * you want the deserialized value. Does not fire any event and does not record the access. + */ + public fun getFlagPayload(key: String): String? { + return flagMap[key]?.metadata?.payload + } + + /** + * Returns the flag payload deserialized from JSON to type [T], or null when the flag is + * unknown, has no payload, or deserialization fails. + */ + public inline fun getFlagPayloadAs(key: String): T? = getFlagPayloadAs(key, T::class.java) + + /** + * Returns the flag payload deserialized from JSON to [clazz], or null when the flag is + * unknown, has no payload, or deserialization fails. The server returns payloads as + * JSON-encoded strings, so this parses the raw string with Gson. + */ + public fun getFlagPayloadAs( + key: String, + clazz: Class, + ): T? { + val raw = flagMap[key]?.metadata?.payload ?: return null + // Always Gson-parse: a raw JSON string like `"\"hello\""` should deserialize to `hello`, + // not pass through as the quoted form. Same intent for primitives, lists, maps, etc. + return try { + PAYLOAD_GSON.fromJson(raw, clazz) + } catch (e: Exception) { + null + } + } + + /** + * Returns a filtered snapshot containing only the flags accessed on this instance via + * [isEnabled] or [getFlag]. Returns an empty snapshot when no flag has been accessed yet. + */ + public fun onlyAccessed(): PostHogFeatureFlagEvaluations { + val accessedKeys = + synchronized(accessLock) { accessed.toSet() } + return cloneWith(accessedKeys) + } + + /** + * Returns a filtered snapshot containing only the named flags. Unknown keys are dropped and + * each one logs a warning so callers notice typos. + */ + public fun only(keys: Collection): PostHogFeatureFlagEvaluations { + val resolved = LinkedHashSet() + for (key in keys) { + if (flagMap.containsKey(key)) { + resolved.add(key) + } else { + host.logWarning( + "PostHogFeatureFlagEvaluations.only(...) called with unknown flag key '$key'; " + + "dropping it from the filtered snapshot.", + ) + } + } + return cloneWith(resolved) + } + + /** Java-friendly varargs alias of [only]. */ + public fun only(vararg keys: String): PostHogFeatureFlagEvaluations = only(keys.toList()) + + private fun cloneWith(keep: Collection): PostHogFeatureFlagEvaluations { + val filtered = LinkedHashMap() + for (key in keep) { + flagMap[key]?.let { filtered[key] = it } + } + return PostHogFeatureFlagEvaluations( + distinctId = distinctId, + flagMap = filtered, + locallyEvaluated = locallyEvaluated.filterKeys { filtered.containsKey(it) }, + requestId = requestId, + evaluatedAt = evaluatedAt, + definitionsLoadedAt = definitionsLoadedAt, + responseError = responseError, + host = host, + initialAccessed = emptySet(), + ) + } + + private fun recordAccess( + key: String, + flag: FeatureFlag?, + ) { + synchronized(accessLock) { accessed.add(key) } + if (distinctId.isNullOrBlank()) return + + val value: Any = flag?.let { it.variant ?: it.enabled } ?: false + val props = mutableMapOf() + if (flag != null) { + props["\$feature_flag_id"] = flag.metadata.id + props["\$feature_flag_version"] = flag.metadata.version + flag.reason?.description?.let { props["\$feature_flag_reason"] = it } + if (locallyEvaluated[key] == true) { + props["locally_evaluated"] = true + } + } + requestId?.let { props["\$feature_flag_request_id"] = it } + evaluatedAt?.let { props["\$feature_flag_evaluated_at"] = it } + definitionsLoadedAt?.let { props["\$feature_flag_definitions_loaded_at"] = it } + + val error = + buildList { + responseError?.let { add(it) } + if (flag == null) add(FeatureFlagError.FLAG_MISSING) + } + if (error.isNotEmpty()) { + props["\$feature_flag_error"] = error.joinToString(",") + } + + host.captureFeatureFlagCalled(distinctId, key, value, props) + } + + public companion object { + private val PAYLOAD_GSON: Gson = Gson() + + /** + * Builds an empty snapshot. Used internally for the empty-distinctId short-circuit; any + * accessor invocation on an empty snapshot is a no-op. + */ + @JvmSynthetic + internal fun empty(host: EvaluationsHost): PostHogFeatureFlagEvaluations { + return PostHogFeatureFlagEvaluations( + distinctId = null, + flagMap = emptyMap(), + locallyEvaluated = emptyMap(), + requestId = null, + evaluatedAt = null, + definitionsLoadedAt = null, + responseError = null, + host = host, + ) + } + } +} 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 cc1cd3b5..ddd77699 100644 --- a/posthog-server/src/main/java/com/posthog/server/PostHogInterface.kt +++ b/posthog-server/src/main/java/com/posthog/server/PostHogInterface.kt @@ -78,6 +78,9 @@ public sealed interface PostHogInterface { * @param groups the groups, set as a "$groups" property, Docs https://posthog.com/docs/product-analytics/group-analytics * @param timestamp the timestamp for the event * @param appendFeatureFlags when true, enriches the event with feature flag properties + * @param flags optional pre-resolved snapshot from [evaluateFlags]; when supplied, attaches + * `$feature/` and `$active_feature_flags` from the snapshot without making another + * `/flags` request. Takes precedence over [appendFeatureFlags] when both are set. */ @JvmSynthetic public fun capture( @@ -89,6 +92,7 @@ public sealed interface PostHogInterface { groups: Map? = null, timestamp: Date? = null, appendFeatureFlags: Boolean = false, + flags: PostHogFeatureFlagEvaluations? = null, ) /** @@ -111,6 +115,7 @@ public sealed interface PostHogInterface { options.groups, options.timestamp, options.appendFeatureFlags, + options.flags, ) } @@ -130,6 +135,9 @@ public sealed interface PostHogInterface { null, null, null, + null, + false, + null, ) } @@ -140,6 +148,11 @@ public sealed interface PostHogInterface { * @param key the Key * @param defaultValue the default value if not found, false if not given */ + @Deprecated( + message = + "Prefer evaluateFlags(distinctId).isEnabled(key) — fewer /flags requests when " + + "the same identity is consulted multiple times. Will be removed in the next major.", + ) public fun isFeatureEnabled( distinctId: String, key: String, @@ -155,10 +168,14 @@ public sealed interface PostHogInterface { * @param distinctId the distinctId * @param key the Key */ + @Deprecated( + message = "Prefer evaluateFlags(distinctId).isEnabled(key). Will be removed in the next major.", + ) public fun isFeatureEnabled( distinctId: String, key: String, ): Boolean { + @Suppress("DEPRECATION") return isFeatureEnabled( distinctId, key, @@ -176,11 +193,15 @@ public sealed interface PostHogInterface { * @param key the Key * @param defaultValue the default value if not found */ + @Deprecated( + message = "Prefer evaluateFlags(distinctId).isEnabled(key). Will be removed in the next major.", + ) public fun isFeatureEnabled( distinctId: String, key: String, defaultValue: Boolean, ): Boolean { + @Suppress("DEPRECATION") return isFeatureEnabled( distinctId, key, @@ -198,11 +219,15 @@ public sealed interface PostHogInterface { * @param key the Key * @param options the feature flag options containing defaultValue, groups, personProperties, and groupProperties */ + @Deprecated( + message = "Prefer evaluateFlags(distinctId).isEnabled(key). Will be removed in the next major.", + ) public fun isFeatureEnabled( distinctId: String, key: String, options: PostHogFeatureFlagOptions, ): Boolean { + @Suppress("DEPRECATION") return isFeatureEnabled( distinctId, key, @@ -220,6 +245,9 @@ public sealed interface PostHogInterface { * @param key the Key * @param defaultValue the default value if not found */ + @Deprecated( + message = "Prefer evaluateFlags(distinctId).getFlag(key). Will be removed in the next major.", + ) public fun getFeatureFlag( distinctId: String, key: String, @@ -236,10 +264,14 @@ public sealed interface PostHogInterface { * @param key the Key * @return the feature flag value or null if not found */ + @Deprecated( + message = "Prefer evaluateFlags(distinctId).getFlag(key). Will be removed in the next major.", + ) public fun getFeatureFlag( distinctId: String, key: String, ): Any? { + @Suppress("DEPRECATION") return getFeatureFlag( distinctId, key, @@ -257,11 +289,15 @@ public sealed interface PostHogInterface { * @param key the Key * @param options the feature flag options containing defaultValue, groups, personProperties, and groupProperties */ + @Deprecated( + message = "Prefer evaluateFlags(distinctId).getFlag(key). Will be removed in the next major.", + ) public fun getFeatureFlag( distinctId: String, key: String, options: PostHogFeatureFlagOptions, ): Any? { + @Suppress("DEPRECATION") return getFeatureFlag( distinctId, key, @@ -279,11 +315,15 @@ public sealed interface PostHogInterface { * @param key the Key * @param defaultValue the default value if not found */ + @Deprecated( + message = "Prefer evaluateFlags(distinctId).getFlag(key). Will be removed in the next major.", + ) public fun getFeatureFlag( distinctId: String, key: String, defaultValue: Any?, ): Any? { + @Suppress("DEPRECATION") return getFeatureFlag( distinctId, key, @@ -301,6 +341,9 @@ public sealed interface PostHogInterface { * @param key the Key * @param defaultValue the default value if not found */ + @Deprecated( + message = "Prefer evaluateFlags(distinctId).getFlagPayload(key). Will be removed in the next major.", + ) public fun getFeatureFlagPayload( distinctId: String, key: String, @@ -317,10 +360,14 @@ public sealed interface PostHogInterface { * @param key the Key * @return the feature flag payload or null if not found */ + @Deprecated( + message = "Prefer evaluateFlags(distinctId).getFlagPayload(key). Will be removed in the next major.", + ) public fun getFeatureFlagPayload( distinctId: String, key: String, ): Any? { + @Suppress("DEPRECATION") return getFeatureFlagPayload( distinctId, key, @@ -338,11 +385,15 @@ public sealed interface PostHogInterface { * @param key the Key * @param options the feature flag options containing defaultValue, groups, personProperties, and groupProperties */ + @Deprecated( + message = "Prefer evaluateFlags(distinctId).getFlagPayload(key). Will be removed in the next major.", + ) public fun getFeatureFlagPayload( distinctId: String, key: String, options: PostHogFeatureFlagOptions, ): Any? { + @Suppress("DEPRECATION") return getFeatureFlagPayload( distinctId, key, @@ -360,11 +411,15 @@ public sealed interface PostHogInterface { * @param key the Key * @param defaultValue the default value if not found */ + @Deprecated( + message = "Prefer evaluateFlags(distinctId).getFlagPayload(key). Will be removed in the next major.", + ) public fun getFeatureFlagPayload( distinctId: String, key: String, defaultValue: Any?, ): Any? { + @Suppress("DEPRECATION") return getFeatureFlagPayload( distinctId, key, @@ -386,6 +441,11 @@ public sealed interface PostHogInterface { * @param sendFeatureFlagEvent whether to send the $feature_flag_called event, or null to use config default * @return FeatureFlagResult if the flag exists, null otherwise */ + @Deprecated( + message = + "Prefer evaluateFlags(distinctId) and read flag values + payload from the snapshot. " + + "Will be removed in the next major.", + ) public fun getFeatureFlagResult( distinctId: String, key: String, @@ -402,10 +462,16 @@ public sealed interface PostHogInterface { * @param key the Key * @return FeatureFlagResult if the flag exists, null otherwise */ + @Deprecated( + message = + "Prefer evaluateFlags(distinctId) and read flag values + payload from the snapshot. " + + "Will be removed in the next major.", + ) public fun getFeatureFlagResult( distinctId: String, key: String, ): FeatureFlagResult? { + @Suppress("DEPRECATION") return getFeatureFlagResult( distinctId, key, @@ -424,11 +490,17 @@ public sealed interface PostHogInterface { * @param options the feature flag result options containing groups, personProperties, groupProperties, and sendFeatureFlagEvent * @return FeatureFlagResult if the flag exists, null otherwise */ + @Deprecated( + message = + "Prefer evaluateFlags(distinctId) and read flag values + payload from the snapshot. " + + "Will be removed in the next major.", + ) public fun getFeatureFlagResult( distinctId: String, key: String, options: PostHogFeatureFlagResultOptions, ): FeatureFlagResult? { + @Suppress("DEPRECATION") return getFeatureFlagResult( distinctId, key, @@ -480,6 +552,66 @@ public sealed interface PostHogInterface { alias: String, ) + /** + * Evaluate every feature flag for [distinctId] in a single `/flags` round-trip and return a + * snapshot. Repeat lookups against the snapshot do not make additional network requests, and + * `is_enabled` / `getFlag` accesses still emit deduped `$feature_flag_called` events. + * + * @param distinctId the distinctId + * @param groups groups for group-based flags + * @param personProperties person properties for flag evaluation + * @param groupProperties group properties for flag evaluation + * @param flagKeys when non-empty, restricts the underlying request to the given keys; this is + * distinct from [PostHogFeatureFlagEvaluations.only] which filters in memory after the call + * @param onlyEvaluateLocally when true, do not fall back to a `/flags` request if local + * evaluation cannot resolve every flag + * @param disableGeoip when true, send `geoip_disable=true` to the server + */ + @JvmSynthetic + public fun evaluateFlags( + distinctId: String, + groups: Map? = null, + personProperties: Map? = null, + groupProperties: Map>? = null, + flagKeys: List? = null, + onlyEvaluateLocally: Boolean = false, + disableGeoip: Boolean = false, + ): PostHogFeatureFlagEvaluations + + /** + * Evaluate every feature flag for [distinctId] using the supplied options object. + * Java-friendly overload that mirrors the canonical [evaluateFlags] entry point. + */ + public fun evaluateFlags( + distinctId: String, + options: PostHogEvaluateFlagsOptions, + ): PostHogFeatureFlagEvaluations { + return evaluateFlags( + distinctId, + groups = options.groups, + personProperties = options.personProperties, + groupProperties = options.groupProperties, + flagKeys = options.flagKeys, + onlyEvaluateLocally = options.onlyEvaluateLocally, + disableGeoip = options.disableGeoip, + ) + } + + /** + * Evaluate every feature flag for [distinctId] using default options. + */ + public fun evaluateFlags(distinctId: String): PostHogFeatureFlagEvaluations { + return evaluateFlags( + distinctId, + groups = null, + personProperties = null, + groupProperties = null, + flagKeys = null, + onlyEvaluateLocally = false, + disableGeoip = false, + ) + } + /** * Reloads feature flag definitions from the server for use with local evaluation. * Note that feature flag definitions are automatically fetched on initialization and diff --git a/posthog-server/src/main/java/com/posthog/server/internal/EvaluateFlagsResult.kt b/posthog-server/src/main/java/com/posthog/server/internal/EvaluateFlagsResult.kt new file mode 100644 index 00000000..7fb3ec65 --- /dev/null +++ b/posthog-server/src/main/java/com/posthog/server/internal/EvaluateFlagsResult.kt @@ -0,0 +1,21 @@ +package com.posthog.server.internal + +import com.posthog.internal.FeatureFlag + +/** + * The rich envelope returned by [PostHogFeatureFlags.evaluateFlags]. Holds the per-flag results + * plus the request-scoped metadata that the snapshot exposes on `$feature_flag_called` events. + */ +internal data class EvaluateFlagsResult( + val flags: Map, + val locallyEvaluated: Map, + val requestId: String?, + val evaluatedAt: Long?, + val definitionsLoadedAt: Long?, + /** + * Combined response-level error string (e.g. "errors_while_computing_flags", + * "quota_limited", or both joined by ","). Propagated to `$feature_flag_error` on snapshot + * events so they match what the per-flag accessor path emits. + */ + val responseError: String?, +) diff --git a/posthog-server/src/main/java/com/posthog/server/internal/EvaluationsHost.kt b/posthog-server/src/main/java/com/posthog/server/internal/EvaluationsHost.kt new file mode 100644 index 00000000..579c5236 --- /dev/null +++ b/posthog-server/src/main/java/com/posthog/server/internal/EvaluationsHost.kt @@ -0,0 +1,18 @@ +package com.posthog.server.internal + +/** + * The minimal slice of the SDK that [com.posthog.server.PostHogFeatureFlagEvaluations] depends on. + * The snapshot calls back into the host to fire deduped `$feature_flag_called` events and to log + * messages, but does not need a reference to the full client. This keeps the snapshot easy to test + * in isolation with a fake host. + */ +internal interface EvaluationsHost { + fun captureFeatureFlagCalled( + distinctId: String, + key: String, + value: Any?, + properties: Map, + ) + + fun logWarning(message: String) +} diff --git a/posthog-server/src/main/java/com/posthog/server/internal/FeatureFlagCacheKey.kt b/posthog-server/src/main/java/com/posthog/server/internal/FeatureFlagCacheKey.kt index 0a4d657f..98e58327 100644 --- a/posthog-server/src/main/java/com/posthog/server/internal/FeatureFlagCacheKey.kt +++ b/posthog-server/src/main/java/com/posthog/server/internal/FeatureFlagCacheKey.kt @@ -8,6 +8,8 @@ internal data class FeatureFlagCacheKey( val groups: Map?, val personProperties: Map?, val groupProperties: Map>?, + val flagKeys: List? = null, + val disableGeoip: Boolean = false, ) { override fun equals(other: Any?): Boolean { if (this === other) return true @@ -17,6 +19,8 @@ internal data class FeatureFlagCacheKey( if (groups != other.groups) return false if (personProperties != other.personProperties) return false if (groupProperties != other.groupProperties) return false + if (flagKeys != other.flagKeys) return false + if (disableGeoip != other.disableGeoip) return false return true } @@ -26,6 +30,8 @@ internal data class FeatureFlagCacheKey( result = 31 * result + (groups?.hashCode() ?: 0) result = 31 * result + (personProperties?.hashCode() ?: 0) result = 31 * result + (groupProperties?.hashCode() ?: 0) + result = 31 * result + (flagKeys?.hashCode() ?: 0) + result = 31 * result + disableGeoip.hashCode() return result } } 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 d35d1e1d..47a82271 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 @@ -52,6 +52,9 @@ internal class PostHogFeatureFlags( @Volatile private var definitionsLoaded = false + @Volatile + private var definitionsLoadedAt: Long? = null + @Volatile private var isLoading = false @@ -278,6 +281,8 @@ internal class PostHogFeatureFlags( groups: Map?, personProperties: Map?, groupProperties: Map>?, + flagKeys: List? = null, + disableGeoip: Boolean = false, ): Map? { val cacheKey = FeatureFlagCacheKey( @@ -285,6 +290,8 @@ internal class PostHogFeatureFlags( groups = groups, personProperties = personProperties, groupProperties = groupProperties, + flagKeys = flagKeys, + disableGeoip = disableGeoip, ) val cachedFlags = cache.get(cacheKey) @@ -297,9 +304,12 @@ internal class PostHogFeatureFlags( api.flags( distinctId, anonymousId = null, + deviceId = null, groups = groups, personProperties = personProperties, groupProperties = groupProperties, + flagKeys = flagKeys, + disableGeoip = disableGeoip, ) val flags = response?.flags cache.put( @@ -447,6 +457,7 @@ internal class PostHogFeatureFlags( cohorts = apiResponse.cohorts groupTypeMapping = apiResponse.groupTypeMapping definitionsLoaded = true + definitionsLoadedAt = System.currentTimeMillis() } config.logger.log("Loaded ${apiResponse.flags?.size ?: 0} feature flags for local evaluation") @@ -507,7 +518,12 @@ internal class PostHogFeatureFlags( payload = payload, version = flagDef.version, ), - reason = null, + reason = + com.posthog.internal.EvaluationReason( + code = LOCAL_EVALUATION_REASON_CODE, + description = LOCAL_EVALUATION_REASON_DESCRIPTION, + condition_index = null, + ), ) } @@ -665,6 +681,126 @@ internal class PostHogFeatureFlags( * * Multiple errors are joined with commas, e.g., "errors_while_computing_flags,flag_missing" */ + override fun getFeatureFlagDetails( + key: String, + distinctId: String?, + groups: Map?, + personProperties: Map?, + groupProperties: Map>?, + ): FeatureFlag? { + if (distinctId == null) { + return null + } + val cacheKey = + FeatureFlagCacheKey( + distinctId = distinctId, + groups = groups, + personProperties = personProperties, + groupProperties = groupProperties, + ) + return cache.getEntry(cacheKey)?.flags?.get(key) + } + + /** + * Resolve every flag for the given identity in a single pass, returning the rich envelope used + * by the [com.posthog.server.PostHogFeatureFlagEvaluations] snapshot. Reuses the existing + * cache → local-eval → remote tier and additionally records which keys were resolved locally. + */ + internal fun evaluateFlags( + distinctId: String, + groups: Map?, + personProperties: Map?, + groupProperties: Map>?, + flagKeys: List?, + onlyEvaluateLocally: Boolean, + disableGeoip: Boolean, + ): EvaluateFlagsResult { + val cacheKey = + FeatureFlagCacheKey( + distinctId = distinctId, + groups = groups, + personProperties = personProperties, + groupProperties = groupProperties, + flagKeys = flagKeys, + disableGeoip = disableGeoip, + ) + cache.getEntry(cacheKey)?.let { entry -> + val flags = entry.flags ?: emptyMap() + return EvaluateFlagsResult( + flags = flags, + locallyEvaluated = flags.mapValues { isLocallyEvaluated(it.value) }, + requestId = entry.requestId, + evaluatedAt = entry.evaluatedAt, + definitionsLoadedAt = definitionsLoadedAt, + responseError = entry.error, + ) + } + + val localFlags = + getFeatureFlagsFromLocalEvaluation( + distinctId, + groups, + personProperties, + groupProperties, + onlyEvaluateLocally, + ) + if (localFlags != null) { + // Local evaluation evaluates every defined flag — apply `flagKeys` post-hoc so callers + // get the same scoping they'd get from a `/flags` request that honored + // `flag_keys_to_evaluate`. Note: we still evaluate everything; the optimization is + // network-side only. + val scoped = + if (flagKeys.isNullOrEmpty()) { + localFlags + } else { + val keep = flagKeys.toHashSet() + localFlags.filterKeys { it in keep } + } + return EvaluateFlagsResult( + flags = scoped, + locallyEvaluated = scoped.mapValues { true }, + requestId = null, + evaluatedAt = null, + definitionsLoadedAt = definitionsLoadedAt, + responseError = null, + ) + } + + if (onlyEvaluateLocally) { + return EvaluateFlagsResult( + flags = emptyMap(), + locallyEvaluated = emptyMap(), + requestId = null, + evaluatedAt = null, + definitionsLoadedAt = definitionsLoadedAt, + responseError = null, + ) + } + + val remoteFlags = + getFeatureFlagsFromRemote( + distinctId, + groups, + personProperties, + groupProperties, + flagKeys, + disableGeoip, + ) ?: emptyMap() + val entry = cache.getEntry(cacheKey) + return EvaluateFlagsResult( + flags = remoteFlags, + locallyEvaluated = remoteFlags.mapValues { false }, + requestId = entry?.requestId, + evaluatedAt = entry?.evaluatedAt, + definitionsLoadedAt = definitionsLoadedAt, + responseError = entry?.error, + ) + } + + private fun isLocallyEvaluated(flag: FeatureFlag): Boolean { + return flag.reason?.code == LOCAL_EVALUATION_REASON_CODE + } + override fun getFeatureFlagError( key: String, distinctId: String?, @@ -701,4 +837,9 @@ internal class PostHogFeatureFlags( else -> "${entry.error},${FeatureFlagError.FLAG_MISSING}" } } + + internal companion object { + internal const val LOCAL_EVALUATION_REASON_CODE: String = "local_evaluation" + internal const val LOCAL_EVALUATION_REASON_DESCRIPTION: String = "Evaluated locally" + } } diff --git a/posthog-server/src/test/java/com/posthog/server/PostHogEvaluateFlagsTest.kt b/posthog-server/src/test/java/com/posthog/server/PostHogEvaluateFlagsTest.kt new file mode 100644 index 00000000..b66bcf8f --- /dev/null +++ b/posthog-server/src/test/java/com/posthog/server/PostHogEvaluateFlagsTest.kt @@ -0,0 +1,432 @@ +package com.posthog.server + +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.MockWebServer +import okhttp3.mockwebserver.RecordedRequest +import java.util.concurrent.TimeUnit +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +@Suppress("DEPRECATION") +internal class PostHogEvaluateFlagsTest { + private fun drainRequests(server: MockWebServer): List { + val requests = mutableListOf() + var request = server.takeRequest(2, TimeUnit.SECONDS) + while (request != null) { + requests.add(request) + request = server.takeRequest(100, TimeUnit.MILLISECONDS) + } + return requests + } + + @Test + fun `evaluateFlags returns a snapshot and makes exactly one flags request`() { + val mockServer = MockWebServer() + mockServer.enqueue(jsonResponse(createMultipleFlagsResponse("a" to true, "b" to false))) + mockServer.start() + + val postHog = + PostHog.with( + PostHogConfig.builder(TEST_API_KEY) + .host(mockServer.url("/").toString()) + .flushAt(1) + .build(), + ) + + val snapshot = postHog.evaluateFlags("user-1") + + assertEquals(setOf("a", "b"), snapshot.keys.toSet()) + assertTrue(snapshot.isEnabled("a")) + assertFalse(snapshot.isEnabled("b")) + + val requests = drainRequests(mockServer) + val flagsRequests = requests.filter { it.path?.contains("/flags") == true } + assertEquals(1, flagsRequests.size, "expected exactly one /flags request") + + postHog.close() + mockServer.shutdown() + } + + @Test + fun `no feature_flag_called events fire until a flag is accessed`() { + val mockServer = MockWebServer() + mockServer.enqueue(jsonResponse(createFlagsResponse("a", enabled = true))) + mockServer.enqueue(MockResponse().setResponseCode(200)) + mockServer.start() + + val postHog = + PostHog.with( + PostHogConfig.builder(TEST_API_KEY) + .host(mockServer.url("/").toString()) + .flushAt(1) + .build(), + ) + + // build the snapshot but don't access any flag — flush a different event + postHog.evaluateFlags("user-1") + postHog.capture("user-1", "page_view") + + val requests = drainRequests(mockServer) + val batchRequests = requests.filter { it.path?.contains("/batch") == true } + assertEquals(1, batchRequests.size) + + val events = batchRequests.single().parseBatch().batch.map { it.get("event").asString } + assertFalse(events.contains("\$feature_flag_called"), "no \$feature_flag_called expected before access") + + postHog.close() + mockServer.shutdown() + } + + @Test + fun `isEnabled fires feature_flag_called once with full metadata, deduped on second access`() { + val flagsBody = + """ + { + "flags": { + "a": { + "key": "a", + "enabled": true, + "variant": null, + "metadata": { "version": 4, "payload": null, "id": 11 }, + "reason": { "code": "condition_match", "description": "Matched", "condition_index": 0 } + } + }, + "requestId": "req-fixture" + } + """.trimIndent() + val mockServer = MockWebServer() + mockServer.enqueue(jsonResponse(flagsBody)) + mockServer.enqueue(MockResponse().setResponseCode(200)) + mockServer.start() + + val postHog = + PostHog.with( + PostHogConfig.builder(TEST_API_KEY) + .host(mockServer.url("/").toString()) + .flushAt(1) + .build(), + ) + + val snapshot = postHog.evaluateFlags("user-1") + snapshot.isEnabled("a") + snapshot.isEnabled("a") + postHog.flush() + + val requests = drainRequests(mockServer) + val batch = requests.first { it.path?.contains("/batch") == true }.parseBatch() + val flagCalledEvents = batch.batch.filter { it.get("event").asString == "\$feature_flag_called" } + assertEquals(1, flagCalledEvents.size, "second access must dedup") + + val props = batch.eventProperties("\$feature_flag_called") + assertEquals("a", props["\$feature_flag"]) + assertEquals(true, props["\$feature_flag_response"]) + assertEquals(11.0, props["\$feature_flag_id"]) // gson deserializes ints as doubles + assertEquals(4.0, props["\$feature_flag_version"]) + assertEquals("Matched", props["\$feature_flag_reason"]) + assertEquals("req-fixture", props["\$feature_flag_request_id"]) + + postHog.close() + mockServer.shutdown() + } + + @Test + fun `getFlagPayload does not fire a feature_flag_called event`() { + val mockServer = MockWebServer() + mockServer.enqueue(jsonResponse(createFlagsResponse("a", enabled = true, payload = "p"))) + mockServer.enqueue(MockResponse().setResponseCode(200)) + mockServer.start() + + val postHog = + PostHog.with( + PostHogConfig.builder(TEST_API_KEY) + .host(mockServer.url("/").toString()) + .flushAt(1) + .build(), + ) + + val snapshot = postHog.evaluateFlags("user-1") + snapshot.getFlagPayload("a") + postHog.capture("user-1", "page_view") + + val requests = drainRequests(mockServer) + val batch = requests.first { it.path?.contains("/batch") == true }.parseBatch() + val events = batch.batch.map { it.get("event").asString } + assertFalse(events.contains("\$feature_flag_called"), "payload reads must not emit events") + + postHog.close() + mockServer.shutdown() + } + + @Test + fun `capture with flags snapshot attaches feature properties without a second flags request`() { + val mockServer = MockWebServer() + mockServer.enqueue(jsonResponse(createMultipleFlagsResponse("a" to true, "b" to false))) + mockServer.enqueue(MockResponse().setResponseCode(200)) + mockServer.start() + + val postHog = + PostHog.with( + PostHogConfig.builder(TEST_API_KEY) + .host(mockServer.url("/").toString()) + .flushAt(1) + .build(), + ) + + val snapshot = postHog.evaluateFlags("user-1") + postHog.capture( + distinctId = "user-1", + event = "purchase", + properties = mapOf("amount" to 1), + flags = snapshot, + ) + + val requests = drainRequests(mockServer) + val flagsRequests = requests.filter { it.path?.contains("/flags") == true } + assertEquals(1, flagsRequests.size, "capture(flags=…) must NOT issue another /flags call") + + val batch = requests.first { it.path?.contains("/batch") == true }.parseBatch() + val props = batch.eventProperties("purchase") + assertEquals(true, props["\$feature/a"]) + assertEquals(false, props["\$feature/b"]) + + @Suppress("UNCHECKED_CAST") + val active = props["\$active_feature_flags"] as? List + assertNotNull(active) + assertTrue(active.contains("a")) + assertFalse(active.contains("b")) + + postHog.close() + mockServer.shutdown() + } + + @Test + fun `flagKeys is forwarded to the flags request body`() { + val mockServer = MockWebServer() + mockServer.enqueue(jsonResponse(createFlagsResponse("a", enabled = true))) + mockServer.start() + + val postHog = + PostHog.with( + PostHogConfig.builder(TEST_API_KEY) + .host(mockServer.url("/").toString()) + .build(), + ) + + postHog.evaluateFlags("user-1", flagKeys = listOf("a", "b")) + + val request = mockServer.takeRequest(2, TimeUnit.SECONDS) + assertNotNull(request) + val body = request.body.unGzip() + assertTrue( + body.contains("\"flag_keys_to_evaluate\""), + "expected flag_keys_to_evaluate in request body, got: $body", + ) + assertTrue(body.contains("\"a\"")) + assertTrue(body.contains("\"b\"")) + + postHog.close() + mockServer.shutdown() + } + + @Test + fun `evaluateFlags with blank distinctId returns an empty snapshot and fires no events on access`() { + val mockServer = MockWebServer() + mockServer.start() + + val postHog = + PostHog.with( + PostHogConfig.builder(TEST_API_KEY) + .host(mockServer.url("/").toString()) + .flushAt(1) + .build(), + ) + + val snapshot = postHog.evaluateFlags("") + assertTrue(snapshot.keys.isEmpty()) + assertNull(snapshot.distinctId) + + snapshot.isEnabled("anything") + postHog.capture("u", "page_view") + + val requests = drainRequests(mockServer) + val flagsRequests = requests.filter { it.path?.contains("/flags") == true } + assertEquals(0, flagsRequests.size, "blank distinctId must short-circuit /flags") + val batch = requests.firstOrNull { it.path?.contains("/batch") == true }?.parseBatch() + if (batch != null) { + val events = batch.batch.map { it.get("event").asString } + assertFalse(events.contains("\$feature_flag_called")) + } + + postHog.close() + mockServer.shutdown() + } + + @Test + fun `local evaluation snapshot tags events with locally_evaluated and reason`() { + val localEvalResponse = createLocalEvaluationResponse("local-flag") + val mockServer = MockWebServer() + mockServer.enqueue(jsonResponse(localEvalResponse)) + mockServer.enqueue(MockResponse().setResponseCode(200)) + mockServer.start() + + val postHog = + PostHog.with( + PostHogConfig.builder(TEST_API_KEY) + .host(mockServer.url("/").toString()) + .personalApiKey("phx_test_personal_api_key") + .flushAt(1) + .build(), + ) + + val snapshot = postHog.evaluateFlags("user-1") + snapshot.isEnabled("local-flag") + postHog.flush() + + val requests = drainRequests(mockServer) + val batch = requests.first { it.path?.contains("/batch") == true }.parseBatch() + val props = batch.eventProperties("\$feature_flag_called") + assertEquals("Evaluated locally", props["\$feature_flag_reason"]) + assertEquals(true, props["locally_evaluated"]) + assertFalse( + requests.any { it.path?.contains("/flags") == true && !it.path!!.contains("local_evaluation") }, + "local evaluation should not hit /flags", + ) + + postHog.close() + mockServer.shutdown() + } + + @Test + fun `quotaLimited response propagates feature_flag_error to snapshot events`() { + val flagsBody = createFlagsResponseWithQuotaLimited(flagKey = "a", enabled = true) + val mockServer = MockWebServer() + mockServer.enqueue(jsonResponse(flagsBody)) + mockServer.enqueue(MockResponse().setResponseCode(200)) + mockServer.start() + + val postHog = + PostHog.with( + PostHogConfig.builder(TEST_API_KEY) + .host(mockServer.url("/").toString()) + .flushAt(1) + .build(), + ) + + val snapshot = postHog.evaluateFlags("user-1") + snapshot.isEnabled("a") + postHog.flush() + + val requests = drainRequests(mockServer) + val batch = requests.first { it.path?.contains("/batch") == true }.parseBatch() + val props = batch.eventProperties("\$feature_flag_called") + assertEquals("quota_limited", props["\$feature_flag_error"]) + + postHog.close() + mockServer.shutdown() + } + + @Test + fun `capture preserves user-supplied feature properties over snapshot values`() { + val mockServer = MockWebServer() + mockServer.enqueue(jsonResponse(createMultipleFlagsResponse("a" to true, "b" to false))) + mockServer.enqueue(MockResponse().setResponseCode(200)) + mockServer.start() + + val postHog = + PostHog.with( + PostHogConfig.builder(TEST_API_KEY) + .host(mockServer.url("/").toString()) + .flushAt(1) + .build(), + ) + + val snapshot = postHog.evaluateFlags("user-1") + postHog.capture( + distinctId = "user-1", + event = "purchase", + properties = + mapOf( + // user-supplied $feature/a is "user-override" — must win over snapshot's `true` + "\$feature/a" to "user-override", + ), + flags = snapshot, + ) + + val requests = drainRequests(mockServer) + val batch = requests.first { it.path?.contains("/batch") == true }.parseBatch() + val props = batch.eventProperties("purchase") + assertEquals("user-override", props["\$feature/a"]) + // Other flags from snapshot still attached + assertEquals(false, props["\$feature/b"]) + + postHog.close() + mockServer.shutdown() + } + + @Test + fun `evaluateFlags caches per (distinctId, flagKeys, disableGeoip) tuple`() { + // First call with flagKeys=[a] — only "a" comes back + // Second call with flagKeys=[a, b] — must miss the cache and hit /flags again + val mockServer = MockWebServer() + mockServer.enqueue(jsonResponse(createMultipleFlagsResponse("a" to true))) + mockServer.enqueue(jsonResponse(createMultipleFlagsResponse("a" to true, "b" to false))) + mockServer.start() + + val postHog = + PostHog.with( + PostHogConfig.builder(TEST_API_KEY) + .host(mockServer.url("/").toString()) + .build(), + ) + + val first = postHog.evaluateFlags("user-1", flagKeys = listOf("a")) + val second = postHog.evaluateFlags("user-1", flagKeys = listOf("a", "b")) + + assertEquals(setOf("a"), first.keys.toSet()) + assertEquals(setOf("a", "b"), second.keys.toSet()) + + val requests = drainRequests(mockServer) + val flagsRequests = requests.filter { it.path?.contains("/flags") == true } + assertEquals(2, flagsRequests.size, "different flagKeys must miss the cache") + + postHog.close() + mockServer.shutdown() + } + + @Test + fun `capture with appendFeatureFlags=true still attaches feature properties (deprecated path keeps working)`() { + val mockServer = MockWebServer() + mockServer.enqueue(jsonResponse(createFlagsResponse("a", enabled = true))) + mockServer.enqueue(MockResponse().setResponseCode(200)) + mockServer.start() + + val postHog = + PostHog.with( + PostHogConfig.builder(TEST_API_KEY) + .host(mockServer.url("/").toString()) + .flushAt(1) + .build(), + ) + + // The runtime DEPRECATION log fires through the core PostHogConfig logger; we don't have a + // public hook to swap that logger in the server config, so we assert behavior instead: + // the deprecated path still works end-to-end and attaches the same properties. + postHog.capture( + distinctId = "user-1", + event = "page_view", + appendFeatureFlags = true, + ) + + val requests = drainRequests(mockServer) + val batch = requests.first { it.path?.contains("/batch") == true }.parseBatch() + val props = batch.eventProperties("page_view") + assertEquals(true, props["\$feature/a"]) + + postHog.close() + mockServer.shutdown() + } +} diff --git a/posthog-server/src/test/java/com/posthog/server/PostHogFeatureFlagEvaluationsTest.kt b/posthog-server/src/test/java/com/posthog/server/PostHogFeatureFlagEvaluationsTest.kt new file mode 100644 index 00000000..97b18621 --- /dev/null +++ b/posthog-server/src/test/java/com/posthog/server/PostHogFeatureFlagEvaluationsTest.kt @@ -0,0 +1,370 @@ +package com.posthog.server + +import com.posthog.internal.EvaluationReason +import com.posthog.internal.FeatureFlag +import com.posthog.internal.FeatureFlagMetadata +import com.posthog.server.internal.EvaluationsHost +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue + +internal class PostHogFeatureFlagEvaluationsTest { + private class RecordedCall( + val distinctId: String, + val key: String, + val value: Any?, + val properties: Map, + ) + + private class FakeHost : EvaluationsHost { + val captures = mutableListOf() + val warnings = mutableListOf() + + override fun captureFeatureFlagCalled( + distinctId: String, + key: String, + value: Any?, + properties: Map, + ) { + captures.add(RecordedCall(distinctId, key, value, properties)) + } + + override fun logWarning(message: String) { + warnings.add(message) + } + } + + private fun flag( + key: String, + enabled: Boolean = true, + variant: String? = null, + id: Int = 7, + version: Int = 3, + payload: String? = null, + reason: EvaluationReason? = EvaluationReason("condition_match", "Condition matched", 0), + ): FeatureFlag = + FeatureFlag( + key = key, + enabled = enabled, + variant = variant, + metadata = FeatureFlagMetadata(id = id, payload = payload, version = version), + reason = reason, + ) + + private fun snapshot( + distinctId: String? = "user-1", + flags: Map = emptyMap(), + locallyEvaluated: Map = flags.mapValues { false }, + host: EvaluationsHost = FakeHost(), + requestId: String? = "req-1", + evaluatedAt: Long? = 1_700_000_000_000L, + definitionsLoadedAt: Long? = null, + responseError: String? = null, + ) = PostHogFeatureFlagEvaluations( + distinctId = distinctId, + flagMap = flags, + locallyEvaluated = locallyEvaluated, + requestId = requestId, + evaluatedAt = evaluatedAt, + definitionsLoadedAt = definitionsLoadedAt, + responseError = responseError, + host = host, + ) + + @Test + fun `isEnabled returns false for unknown flags and fires flag_missing event`() { + val host = FakeHost() + val snapshot = snapshot(host = host, flags = mapOf("known" to flag("known", enabled = true))) + + val unknown = snapshot.isEnabled("missing") + + assertFalse(unknown) + assertEquals(1, host.captures.size) + val call = host.captures.single() + assertEquals("missing", call.key) + assertEquals(false, call.value) + assertEquals("flag_missing", call.properties["\$feature_flag_error"]) + assertFalse(call.properties.containsKey("\$feature_flag_id")) + } + + @Test + fun `getFlag returns null for unknown flags and fires flag_missing event`() { + val host = FakeHost() + val snapshot = snapshot(host = host, flags = mapOf("known" to flag("known"))) + + assertNull(snapshot.getFlag("missing")) + assertEquals("flag_missing", host.captures.single().properties["\$feature_flag_error"]) + } + + @Test + fun `flag_missing combines with response-level error when both apply`() { + val host = FakeHost() + val snapshot = + snapshot( + host = host, + flags = mapOf("known" to flag("known")), + responseError = "errors_while_computing_flags", + ) + + snapshot.isEnabled("missing") + + assertEquals( + "errors_while_computing_flags,flag_missing", + host.captures.single().properties["\$feature_flag_error"], + ) + } + + @Test + fun `isEnabled fires capture call with full metadata`() { + val host = FakeHost() + val snapshot = + snapshot( + host = host, + flags = mapOf("known" to flag("known", enabled = true, id = 11, version = 4)), + ) + + snapshot.isEnabled("known") + + // Dedup happens inside the real host (PostHogStateless.captureFeatureFlagCalledEvent), + // not in the snapshot — see PostHogEvaluateFlagsTest for the end-to-end dedup case. + val call = host.captures.first() + assertEquals("user-1", call.distinctId) + assertEquals("known", call.key) + assertEquals(true, call.value) + assertEquals(11, call.properties["\$feature_flag_id"]) + assertEquals(4, call.properties["\$feature_flag_version"]) + assertEquals("Condition matched", call.properties["\$feature_flag_reason"]) + assertEquals("req-1", call.properties["\$feature_flag_request_id"]) + } + + @Test + fun `getFlag returns variant string and fires the event with variant value`() { + val host = FakeHost() + val snapshot = + snapshot( + host = host, + flags = mapOf("variant-flag" to flag("variant-flag", enabled = true, variant = "control")), + ) + + val value = snapshot.getFlag("variant-flag") + + assertEquals("control", value) + assertEquals(1, host.captures.size) + assertEquals("control", host.captures.single().value) + } + + @Test + fun `getFlagPayload returns the raw payload string and does not fire an event`() { + val host = FakeHost() + val snapshot = + snapshot( + host = host, + flags = mapOf("payload-flag" to flag("payload-flag", enabled = true, payload = "{\"a\":1}")), + ) + + val payload = snapshot.getFlagPayload("payload-flag") + + assertEquals("{\"a\":1}", payload) + assertTrue(host.captures.isEmpty(), "payload reads should be event-free") + } + + @Test + fun `getFlagPayloadAs deserializes JSON to the requested type`() { + val host = FakeHost() + val snapshot = + snapshot( + host = host, + flags = + mapOf( + "json-object" to flag("json-object", payload = "{\"plan\":\"enterprise\",\"seats\":50}"), + "json-list" to flag("json-list", payload = "[1, 2, 3]"), + "json-string" to flag("json-string", payload = "\"hello\""), + ), + ) + + @Suppress("UNCHECKED_CAST") + val asMap = snapshot.getFlagPayloadAs>("json-object") + assertEquals("enterprise", asMap?.get("plan")) + assertEquals(50.0, asMap?.get("seats")) + + val asList = snapshot.getFlagPayloadAs>("json-list") + assertEquals(listOf(1.0, 2.0, 3.0), asList) + + val asString = snapshot.getFlagPayloadAs("json-string") + assertEquals("hello", asString) + + // Unknown flag → null + assertNull(snapshot.getFlagPayloadAs>("missing")) + } + + @Test + fun `only drops unknown keys with a warning`() { + val host = FakeHost() + val snapshot = + snapshot( + host = host, + flags = + mapOf( + "a" to flag("a"), + "b" to flag("b"), + ), + ) + + val filtered = snapshot.only(listOf("a", "missing")) + + assertEquals(listOf("a"), filtered.keys) + assertEquals(1, host.warnings.size) + assertTrue(host.warnings.single().contains("missing")) + } + + @Test + fun `onlyAccessed returns an empty snapshot when nothing has been accessed`() { + val host = FakeHost() + val snapshot = + snapshot( + host = host, + flags = + mapOf( + "a" to flag("a"), + "b" to flag("b"), + ), + ) + + val filtered = snapshot.onlyAccessed() + + assertTrue(filtered.keys.isEmpty(), "no flags should remain in the empty-access case") + assertTrue(host.warnings.isEmpty(), "empty-access fallback should not emit a warning") + } + + @Test + fun `onlyAccessed returns only previously accessed flags`() { + val host = FakeHost() + val snapshot = + snapshot( + host = host, + flags = + mapOf( + "a" to flag("a"), + "b" to flag("b"), + ), + ) + + snapshot.isEnabled("a") + val filtered = snapshot.onlyAccessed() + + assertEquals(listOf("a"), filtered.keys) + assertTrue(host.warnings.isEmpty()) + } + + @Test + fun `filtered snapshot accessed set is independent of parent`() { + val host = FakeHost() + val parent = + snapshot( + host = host, + flags = + mapOf( + "a" to flag("a"), + "b" to flag("b"), + ), + ) + parent.isEnabled("a") + + val child = parent.onlyAccessed() + child.isEnabled("a") // accessing on the child should not change parent's accessed set + + // Parent re-filtering should still only show "a" + assertEquals(listOf("a"), parent.onlyAccessed().keys) + } + + @Test + fun `empty distinctId snapshot does not fire events but still records access`() { + val host = FakeHost() + val snapshot = + snapshot( + distinctId = "", + host = host, + flags = mapOf("a" to flag("a")), + ) + + snapshot.isEnabled("a") + + assertTrue(host.captures.isEmpty(), "empty distinctId must not leak \$feature_flag_called events") + assertEquals(listOf("a"), snapshot.onlyAccessed().keys) + } + + @Test + fun `locally evaluated flag tags event with locally_evaluated and reason`() { + val host = FakeHost() + val flagDef = + flag( + "local", + enabled = true, + reason = EvaluationReason("local_evaluation", "Evaluated locally", null), + ) + val snapshot = + snapshot( + host = host, + flags = mapOf("local" to flagDef), + locallyEvaluated = mapOf("local" to true), + definitionsLoadedAt = 1_700_000_500_000L, + ) + + snapshot.isEnabled("local") + + val props = host.captures.single().properties + assertEquals(true, props["locally_evaluated"]) + assertEquals("Evaluated locally", props["\$feature_flag_reason"]) + assertEquals(1_700_000_500_000L, props["\$feature_flag_definitions_loaded_at"]) + } + + @Test + fun `keys exposes the snapshotted flag keys`() { + val host = FakeHost() + val snapshot = + snapshot( + host = host, + flags = + linkedMapOf( + "a" to flag("a"), + "b" to flag("b"), + ), + ) + + assertEquals(listOf("a", "b"), snapshot.keys) + } + + @Test + fun `response-level error is propagated to feature_flag_called events`() { + val host = FakeHost() + val snapshot = + snapshot( + host = host, + flags = mapOf("a" to flag("a")), + responseError = "errors_while_computing_flags,quota_limited", + ) + + snapshot.isEnabled("a") + + val props = host.captures.single().properties + assertEquals("errors_while_computing_flags,quota_limited", props["\$feature_flag_error"]) + } + + @Test + fun `null response-level error means no feature_flag_error key on known flags`() { + val host = FakeHost() + val snapshot = + snapshot( + host = host, + flags = mapOf("a" to flag("a")), + responseError = null, + ) + + snapshot.isEnabled("a") + + val props = host.captures.single().properties + assertFalse(props.containsKey("\$feature_flag_error")) + } +} 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 382e7e99..1a3687d4 100644 --- a/posthog-server/src/test/java/com/posthog/server/PostHogTest.kt +++ b/posthog-server/src/test/java/com/posthog/server/PostHogTest.kt @@ -17,6 +17,7 @@ import kotlin.test.assertNotNull import kotlin.test.assertNull import kotlin.test.assertTrue +@Suppress("DEPRECATION") internal class PostHogTest { private fun createMockStateless(): PostHog { return spy(PostHog()) diff --git a/posthog/api/posthog.api b/posthog/api/posthog.api index 6e544840..f6422522 100644 --- a/posthog/api/posthog.api +++ b/posthog/api/posthog.api @@ -392,6 +392,7 @@ public class com/posthog/PostHogStateless : com/posthog/PostHogStatelessInterfac protected final fun buildEvent (Ljava/lang/String;Ljava/lang/String;Ljava/util/Map;Ljava/util/Date;)Lcom/posthog/PostHogEvent; public static synthetic fun buildEvent$default (Lcom/posthog/PostHogStateless;Ljava/lang/String;Ljava/lang/String;Ljava/util/Map;Ljava/util/Date;ILjava/lang/Object;)Lcom/posthog/PostHogEvent; public fun captureExceptionStateless (Ljava/lang/Throwable;Ljava/lang/String;Ljava/util/Map;)V + protected final fun captureFeatureFlagCalledEvent (Ljava/lang/String;Ljava/lang/String;Ljava/lang/Object;Ljava/util/Map;)V public fun captureStateless (Ljava/lang/String;Ljava/lang/String;Ljava/util/Map;Ljava/util/Map;Ljava/util/Map;Ljava/util/Map;Ljava/util/Date;)V public fun close ()V public fun debug (Z)V @@ -642,8 +643,8 @@ public final class com/posthog/internal/MultiVariateConfig { public final class com/posthog/internal/PostHogApi { public fun (Lcom/posthog/PostHogConfig;)V public final fun batch (Ljava/util/List;)V - public final fun flags (Ljava/lang/String;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/lang/String;Ljava/util/Map;Ljava/util/Map;Ljava/util/Map;ILjava/lang/Object;)Lcom/posthog/internal/PostHogFlagsResponse; + public final fun flags (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/util/Map;Ljava/util/Map;Ljava/util/Map;Ljava/util/List;Z)Lcom/posthog/internal/PostHogFlagsResponse; + public static synthetic fun flags$default (Lcom/posthog/internal/PostHogApi;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/util/Map;Ljava/util/Map;Ljava/util/Map;Ljava/util/List;ZILjava/lang/Object;)Lcom/posthog/internal/PostHogFlagsResponse; public final fun localEvaluation (Ljava/lang/String;Ljava/lang/String;)Lcom/posthog/internal/LocalEvaluationApiResponse; public static synthetic fun localEvaluation$default (Lcom/posthog/internal/PostHogApi;Ljava/lang/String;Ljava/lang/String;ILjava/lang/Object;)Lcom/posthog/internal/LocalEvaluationApiResponse; public final fun remoteConfig ()Lcom/posthog/internal/PostHogRemoteConfigResponse; @@ -709,6 +710,7 @@ public abstract interface class com/posthog/internal/PostHogFeatureFlagCalledPro public abstract interface class com/posthog/internal/PostHogFeatureFlagsInterface { public abstract fun clear ()V public abstract fun getEvaluatedAt (Ljava/lang/String;Ljava/util/Map;Ljava/util/Map;Ljava/util/Map;)Ljava/lang/Long; + public abstract fun getFeatureFlagDetails (Ljava/lang/String;Ljava/lang/String;Ljava/util/Map;Ljava/util/Map;Ljava/util/Map;)Lcom/posthog/internal/FeatureFlag; public abstract fun getFeatureFlagError (Ljava/lang/String;Ljava/lang/String;Ljava/util/Map;Ljava/util/Map;Ljava/util/Map;)Ljava/lang/String; public abstract fun getFeatureFlagResult (Ljava/lang/String;Ljava/lang/String;Ljava/util/Map;Ljava/util/Map;Ljava/util/Map;)Lcom/posthog/FeatureFlagResult; public abstract fun getFeatureFlags (Ljava/lang/String;Ljava/util/Map;Ljava/util/Map;Ljava/util/Map;)Ljava/util/Map; @@ -718,6 +720,8 @@ public abstract interface class com/posthog/internal/PostHogFeatureFlagsInterfac public final class com/posthog/internal/PostHogFeatureFlagsInterface$DefaultImpls { public static synthetic fun getEvaluatedAt$default (Lcom/posthog/internal/PostHogFeatureFlagsInterface;Ljava/lang/String;Ljava/util/Map;Ljava/util/Map;Ljava/util/Map;ILjava/lang/Object;)Ljava/lang/Long; + public static fun getFeatureFlagDetails (Lcom/posthog/internal/PostHogFeatureFlagsInterface;Ljava/lang/String;Ljava/lang/String;Ljava/util/Map;Ljava/util/Map;Ljava/util/Map;)Lcom/posthog/internal/FeatureFlag; + public static synthetic fun getFeatureFlagDetails$default (Lcom/posthog/internal/PostHogFeatureFlagsInterface;Ljava/lang/String;Ljava/lang/String;Ljava/util/Map;Ljava/util/Map;Ljava/util/Map;ILjava/lang/Object;)Lcom/posthog/internal/FeatureFlag; public static fun getFeatureFlagError (Lcom/posthog/internal/PostHogFeatureFlagsInterface;Ljava/lang/String;Ljava/lang/String;Ljava/util/Map;Ljava/util/Map;Ljava/util/Map;)Ljava/lang/String; public static synthetic fun getFeatureFlagError$default (Lcom/posthog/internal/PostHogFeatureFlagsInterface;Ljava/lang/String;Ljava/lang/String;Ljava/util/Map;Ljava/util/Map;Ljava/util/Map;ILjava/lang/Object;)Ljava/lang/String; public static synthetic fun getFeatureFlagResult$default (Lcom/posthog/internal/PostHogFeatureFlagsInterface;Ljava/lang/String;Ljava/lang/String;Ljava/util/Map;Ljava/util/Map;Ljava/util/Map;ILjava/lang/Object;)Lcom/posthog/FeatureFlagResult; @@ -843,6 +847,7 @@ public final class com/posthog/internal/PostHogRemoteConfig : com/posthog/intern public final fun getEventTriggers ()Ljava/util/Set; public final fun getFeatureFlag (Ljava/lang/String;Ljava/lang/Object;Ljava/lang/String;Ljava/util/Map;Ljava/util/Map;Ljava/util/Map;)Ljava/lang/Object; public static synthetic fun getFeatureFlag$default (Lcom/posthog/internal/PostHogRemoteConfig;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 fun getFeatureFlagDetails (Ljava/lang/String;Ljava/lang/String;Ljava/util/Map;Ljava/util/Map;Ljava/util/Map;)Lcom/posthog/internal/FeatureFlag; public fun getFeatureFlagError (Ljava/lang/String;Ljava/lang/String;Ljava/util/Map;Ljava/util/Map;Ljava/util/Map;)Ljava/lang/String; public final fun getFeatureFlagPayload (Ljava/lang/String;Ljava/lang/Object;Ljava/lang/String;Ljava/util/Map;Ljava/util/Map;Ljava/util/Map;)Ljava/lang/Object; public static synthetic fun getFeatureFlagPayload$default (Lcom/posthog/internal/PostHogRemoteConfig;Ljava/lang/String;Ljava/lang/Object;Ljava/lang/String;Ljava/util/Map;Ljava/util/Map;Ljava/util/Map;ILjava/lang/Object;)Ljava/lang/Object; diff --git a/posthog/src/main/java/com/posthog/PostHogStateless.kt b/posthog/src/main/java/com/posthog/PostHogStateless.kt index 313163d7..95d90d78 100644 --- a/posthog/src/main/java/com/posthog/PostHogStateless.kt +++ b/posthog/src/main/java/com/posthog/PostHogStateless.kt @@ -442,28 +442,47 @@ public open class PostHogStateless protected constructor( sendFeatureFlagEvent: Boolean? = null, ) { val effectiveSendFeatureFlagEvent = sendFeatureFlagEvent ?: config?.sendFeatureFlagEvent ?: true - if (effectiveSendFeatureFlagEvent) { - val isNewlySeen = featureFlagsCalled?.add(distinctId, key, value) ?: false - if (isNewlySeen) { - val requestId = featureFlags?.getRequestId(distinctId, groups, personProperties, groupProperties) - val evaluatedAt = featureFlags?.getEvaluatedAt(distinctId, groups, personProperties, groupProperties) - - val props = mutableMapOf() - props["\$feature_flag"] = key - props["\$feature_flag_response"] = value ?: "" - requestId?.let { props["\$feature_flag_request_id"] = it } - evaluatedAt?.let { props["\$feature_flag_evaluated_at"] = it } - featureFlags?.getFeatureFlagError( - key, - distinctId, - groups, - personProperties, - groupProperties, - )?.let { props["\$feature_flag_error"] = it } + if (!effectiveSendFeatureFlagEvent) return - captureStateless(PostHogEventName.FEATURE_FLAG_CALLED.event, distinctId, properties = props) - } - } + val props = mutableMapOf() + featureFlags?.getRequestId(distinctId, groups, personProperties, groupProperties) + ?.let { props["\$feature_flag_request_id"] = it } + featureFlags?.getEvaluatedAt(distinctId, groups, personProperties, groupProperties) + ?.let { props["\$feature_flag_evaluated_at"] = it } + featureFlags?.getFeatureFlagError(key, distinctId, groups, personProperties, groupProperties) + ?.let { props["\$feature_flag_error"] = it } + featureFlags?.getFeatureFlagDetails(key, distinctId, groups, personProperties, groupProperties)?.let { details -> + props["\$feature_flag_id"] = details.metadata.id + props["\$feature_flag_version"] = details.metadata.version + details.reason?.description?.let { props["\$feature_flag_reason"] = it } + } + + captureFeatureFlagCalledEvent(distinctId, key, value, props) + } + + /** + * Shared dedup-and-capture path for `$feature_flag_called`. Callers pass a pre-built properties + * map and are responsible for whatever per-call gate they care about; this helper only enforces + * the per-distinct-id dedup and routes through the queue. Both the existing per-flag accessor + * (after applying its `sendFeatureFlagEvent` override) and the new feature-flag-evaluations + * snapshot (after checking its own config) funnel through here so dedup stays uniform. + */ + @PostHogInternal + protected fun captureFeatureFlagCalledEvent( + distinctId: String, + key: String, + value: Any?, + properties: Map, + ) { + val isNewlySeen = featureFlagsCalled?.add(distinctId, key, value) ?: false + if (!isNewlySeen) return + + val props = mutableMapOf() + props.putAll(properties) + props["\$feature_flag"] = key + props["\$feature_flag_response"] = value ?: "" + + captureStateless(PostHogEventName.FEATURE_FLAG_CALLED.event, distinctId, properties = props) } public override fun getFeatureFlagStateless( diff --git a/posthog/src/main/java/com/posthog/internal/PostHogApi.kt b/posthog/src/main/java/com/posthog/internal/PostHogApi.kt index 0211a6e6..435bea87 100644 --- a/posthog/src/main/java/com/posthog/internal/PostHogApi.kt +++ b/posthog/src/main/java/com/posthog/internal/PostHogApi.kt @@ -138,6 +138,8 @@ public class PostHogApi( groups: Map? = null, personProperties: Map? = null, groupProperties: Map>? = null, + flagKeys: List? = null, + disableGeoip: Boolean = false, ): PostHogFlagsResponse? { val flagsRequest = PostHogFlagsRequest( @@ -149,6 +151,8 @@ public class PostHogApi( personProperties, groupProperties, config.evaluationContexts, + flagKeys = flagKeys, + disableGeoip = disableGeoip, ) val url = "$theHost/flags/?v=2" diff --git a/posthog/src/main/java/com/posthog/internal/PostHogFeatureFlagsInterface.kt b/posthog/src/main/java/com/posthog/internal/PostHogFeatureFlagsInterface.kt index 2cb71b20..5d3f9ec0 100644 --- a/posthog/src/main/java/com/posthog/internal/PostHogFeatureFlagsInterface.kt +++ b/posthog/src/main/java/com/posthog/internal/PostHogFeatureFlagsInterface.kt @@ -49,4 +49,19 @@ public interface PostHogFeatureFlagsInterface { ): String? { return null } + + /** + * Returns the rich [FeatureFlag] (with id/version/payload metadata and reason) cached for the + * given identity tuple, or null when no cached value is available. Default implementation + * returns null so older implementations stay binary-compatible. + */ + public fun getFeatureFlagDetails( + key: String, + distinctId: String? = null, + groups: Map? = null, + personProperties: Map? = null, + groupProperties: Map>? = null, + ): FeatureFlag? { + return null + } } diff --git a/posthog/src/main/java/com/posthog/internal/PostHogFlagsRequest.kt b/posthog/src/main/java/com/posthog/internal/PostHogFlagsRequest.kt index e38045e0..ce7a52ae 100644 --- a/posthog/src/main/java/com/posthog/internal/PostHogFlagsRequest.kt +++ b/posthog/src/main/java/com/posthog/internal/PostHogFlagsRequest.kt @@ -14,6 +14,8 @@ internal class PostHogFlagsRequest( personProperties: Map? = null, groupProperties: Map>? = null, evaluationContexts: List? = null, + flagKeys: List? = null, + disableGeoip: Boolean = false, ) : HashMap() { init { this["api_key"] = apiKey @@ -37,5 +39,11 @@ internal class PostHogFlagsRequest( if (evaluationContexts?.isNotEmpty() == true) { this["evaluation_contexts"] = evaluationContexts } + if (flagKeys?.isNotEmpty() == true) { + this["flag_keys_to_evaluate"] = flagKeys + } + if (disableGeoip) { + this["geoip_disable"] = true + } } }