From f60ec172ee2a4047cef6317eca86ff32ea6aca65 Mon Sep 17 00:00:00 2001 From: dylan Date: Mon, 27 Apr 2026 13:05:50 -0700 Subject: [PATCH 1/2] feat(server): add evaluateFlags() API for single-call flag evaluation Adds a `PostHogFeatureFlagEvaluations` snapshot returned by `PostHogInterface.evaluateFlags(distinctId)`. The snapshot exposes `isEnabled`/`getFlag`/`getFlagPayload` plus `onlyAccessed()` and `only(keys)` filters; accessor calls fire deduped `$feature_flag_called` events with `$feature_flag_id`/`$feature_flag_version`/`$feature_flag_reason` metadata, an empty-distinctId snapshot short-circuits all events, and filtered clones keep their own access set. `capture()` gains a `flags` parameter that takes a snapshot and attaches `$feature/` and `$active_feature_flags` without making a second `/flags` request. The dedup helper on `PostHogStateless` is split into `captureFeatureFlagCalledEvent` so both the per-flag accessor and the snapshot share the same per-distinct-id LRU. Locally-evaluated flags carry a "local_evaluation" reason and the snapshot stamps `locally_evaluated=true` plus `$feature_flag_definitions_loaded_at` to match posthog-node and posthog-python. Adds `featureFlagsLogWarnings` config option to silence filter-helper warnings, threads `flagKeys` and `geoip_disable` into the `/flags` request body, and ships JUnit coverage for the snapshot, dedup, empty-distinctId, local-evaluation, and `capture(flags=)` paths. The existing `appendFeatureFlags = true` capture path is preserved unchanged; deprecation of the per-flag accessors is Phase 2. Generated-By: PostHog Code Task-Id: 87de4c67-f607-4432-b8ee-3c059e368f81 --- posthog-server/CHANGELOG.md | 2 + posthog-server/USAGE.md | 44 +++ posthog-server/api/posthog-server.api | 89 +++++- .../main/java/com/posthog/server/PostHog.kt | 143 +++++++-- .../posthog/server/PostHogCaptureOptions.kt | 14 + .../java/com/posthog/server/PostHogConfig.kt | 12 + .../server/PostHogEvaluateFlagsOptions.kt | 99 ++++++ .../server/PostHogFeatureFlagEvaluations.kt | 173 ++++++++++ .../com/posthog/server/PostHogInterface.kt | 68 ++++ .../server/internal/EvaluateFlagsResult.kt | 15 + .../server/internal/EvaluationsHost.kt | 20 ++ .../server/internal/PostHogFeatureFlags.kt | 124 ++++++- .../server/PostHogEvaluateFlagsTest.kt | 301 +++++++++++++++++ .../PostHogFeatureFlagEvaluationsTest.kt | 302 ++++++++++++++++++ posthog/api/posthog.api | 9 +- .../main/java/com/posthog/PostHogStateless.kt | 61 ++-- .../java/com/posthog/internal/PostHogApi.kt | 4 + .../internal/PostHogFeatureFlagsInterface.kt | 15 + .../posthog/internal/PostHogFlagsRequest.kt | 8 + 19 files changed, 1445 insertions(+), 58 deletions(-) create mode 100644 posthog-server/src/main/java/com/posthog/server/PostHogEvaluateFlagsOptions.kt create mode 100644 posthog-server/src/main/java/com/posthog/server/PostHogFeatureFlagEvaluations.kt create mode 100644 posthog-server/src/main/java/com/posthog/server/internal/EvaluateFlagsResult.kt create mode 100644 posthog-server/src/main/java/com/posthog/server/internal/EvaluationsHost.kt create mode 100644 posthog-server/src/test/java/com/posthog/server/PostHogEvaluateFlagsTest.kt create mode 100644 posthog-server/src/test/java/com/posthog/server/PostHogFeatureFlagEvaluationsTest.kt diff --git a/posthog-server/CHANGELOG.md b/posthog-server/CHANGELOG.md index a6cffdf1e..46dd8eb66 100644 --- a/posthog-server/CHANGELOG.md +++ b/posthog-server/CHANGELOG.md @@ -1,5 +1,7 @@ ## Next +- feat: Add `evaluateFlags()` API for single-call flag evaluation. Returns a `PostHogFeatureFlagEvaluations` snapshot with `isEnabled`/`getFlag`/`getFlagPayload` 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. `$feature_flag_called` events now also include `$feature_flag_id`, `$feature_flag_version`, and `$feature_flag_reason` metadata. New `featureFlagsLogWarnings` config option silences filter warnings. + ## 2.4.1 ### Patch Changes diff --git a/posthog-server/USAGE.md b/posthog-server/USAGE.md index 4cd0a4b56..f1dab34cd 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 5448391d9..ff8818813 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 @@ -101,8 +108,8 @@ public class com/posthog/server/PostHogConfig { 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;IIIZLjava/lang/String;ILjava/util/List;)V - public synthetic fun (Ljava/lang/String;Ljava/lang/String;ZZZZIIIILcom/posthog/PostHogEncryption;Lcom/posthog/PostHogOnFeatureFlags;Ljava/net/Proxy;IIIZLjava/lang/String;ILjava/util/List;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public fun (Ljava/lang/String;Ljava/lang/String;ZZZZIIIILcom/posthog/PostHogEncryption;Lcom/posthog/PostHogOnFeatureFlags;Ljava/net/Proxy;IIIZLjava/lang/String;ILjava/util/List;Z)V + public synthetic fun (Ljava/lang/String;Ljava/lang/String;ZZZZIIIILcom/posthog/PostHogEncryption;Lcom/posthog/PostHogOnFeatureFlags;Ljava/net/Proxy;IIIZLjava/lang/String;ILjava/util/List;ZILkotlin/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; @@ -113,6 +120,7 @@ public class com/posthog/server/PostHogConfig { public final fun getFeatureFlagCacheMaxAgeMs ()I public final fun getFeatureFlagCacheSize ()I public final fun getFeatureFlagCalledCacheSize ()I + public final fun getFeatureFlagsLogWarnings ()Z public final fun getFlushAt ()I public final fun getFlushIntervalSeconds ()I public final fun getHost ()Ljava/lang/String; @@ -133,6 +141,7 @@ public class com/posthog/server/PostHogConfig { public final fun setFeatureFlagCacheMaxAgeMs (I)V public final fun setFeatureFlagCacheSize (I)V public final fun setFeatureFlagCalledCacheSize (I)V + public final fun setFeatureFlagsLogWarnings (Z)V public final fun setFlushAt (I)V public final fun setFlushIntervalSeconds (I)V public final fun setLocalEvaluation (Z)V @@ -156,6 +165,7 @@ public final class com/posthog/server/PostHogConfig$Builder { public final fun featureFlagCacheMaxAgeMs (I)Lcom/posthog/server/PostHogConfig$Builder; public final fun featureFlagCacheSize (I)Lcom/posthog/server/PostHogConfig$Builder; public final fun featureFlagCalledCacheSize (I)Lcom/posthog/server/PostHogConfig$Builder; + public final fun featureFlagsLogWarnings (Z)Lcom/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; @@ -175,6 +185,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/Object; + public final fun getFlags ()Ljava/util/Map; + 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 +318,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 +356,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 0a264b00c..1fee4267d 100644 --- a/posthog-server/src/main/java/com/posthog/server/PostHog.kt +++ b/posthog-server/src/main/java/com/posthog/server/PostHog.kt @@ -1,12 +1,37 @@ 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 public class PostHog : PostHogStateless(), PostHogInterface { + @Volatile + private var serverConfig: PostHogConfig? = null + + private val evaluationsHost: EvaluationsHost = + object : EvaluationsHost { + override val warningsEnabled: Boolean + get() = serverConfig?.featureFlagsLogWarnings ?: true + + 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) { + serverConfig = config super.setup(config.asCoreConfig()) } @@ -43,18 +68,28 @@ 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 -> + mergeFeatureFlagProperties( + distinctId = distinctId, + groups = groups, + userProperties = userProperties, + groupProperties = null, + properties = properties, + ) + else -> properties } super.captureStateless( @@ -187,7 +222,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 +229,83 @@ 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 + props["\$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["\$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, + 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 90135da26..b1b2ea419 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/PostHogConfig.kt b/posthog-server/src/main/java/com/posthog/server/PostHogConfig.kt index 53ade041b..dc9b657b1 100644 --- a/posthog-server/src/main/java/com/posthog/server/PostHogConfig.kt +++ b/posthog-server/src/main/java/com/posthog/server/PostHogConfig.kt @@ -137,6 +137,13 @@ public open class PostHogConfig constructor( * Defaults to null */ public var evaluationContexts: List? = null, + /** + * Whether the SDK logs warnings emitted by `PostHogFeatureFlagEvaluations` filter helpers + * (`onlyAccessed()` with no recorded access, `only([...])` with unknown keys). Set to false + * to silence those messages. + * Defaults to true. + */ + public var featureFlagsLogWarnings: Boolean = true, ) { /** * The PostHog API Key @@ -267,6 +274,7 @@ public open class PostHogConfig constructor( private var personalApiKey: String? = null private var pollIntervalSeconds: Int = DEFAULT_POLL_INTERVAL_SECONDS private var evaluationContexts: List? = null + private var featureFlagsLogWarnings: Boolean = true public fun host(host: String): Builder = apply { this.host = host } @@ -319,6 +327,9 @@ public open class PostHogConfig constructor( public fun evaluationContexts(evaluationContexts: List?): Builder = apply { this.evaluationContexts = evaluationContexts } + public fun featureFlagsLogWarnings(featureFlagsLogWarnings: Boolean): Builder = + apply { this.featureFlagsLogWarnings = featureFlagsLogWarnings } + @Suppress("DEPRECATION") public fun build(): PostHogConfig = PostHogConfig( @@ -342,6 +353,7 @@ public open class PostHogConfig constructor( personalApiKey = personalApiKey, pollIntervalSeconds = pollIntervalSeconds, evaluationContexts = evaluationContexts, + featureFlagsLogWarnings = featureFlagsLogWarnings, ) } } 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 000000000..0a5fcf252 --- /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 000000000..5d34048a4 --- /dev/null +++ b/posthog-server/src/main/java/com/posthog/server/PostHogFeatureFlagEvaluations.kt @@ -0,0 +1,173 @@ +package com.posthog.server + +import com.posthog.internal.FeatureFlag +import com.posthog.server.internal.EvaluationsHost + +/** + * 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. + * - [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?, + private val flagMap: Map, + private val locallyEvaluated: Map, + public val requestId: String?, + public val evaluatedAt: Long?, + public val definitionsLoadedAt: Long?, + private val host: EvaluationsHost, + initialAccessed: Set = emptySet(), +) { + 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() + + /** Returns the immutable flag map this snapshot was built from. */ + public val flags: Map + get() = flagMap + + /** + * Returns whether the flag is enabled. Unknown flags return false. Records access and fires a + * deduped `$feature_flag_called` event, 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 and fires a deduped `$feature_flag_called` event, 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 flag payload. Does not fire any event and does not record the access — payloads + * are an inert read. + */ + public fun getFlagPayload(key: String): Any? { + return flagMap[key]?.metadata?.payload + } + + /** + * Returns a filtered snapshot containing only the flags accessed on this instance via + * [isEnabled] or [getFlag]. When no flag has been accessed yet, logs a warning and returns a + * clone containing every flag instead of silently dropping data. + */ + public fun onlyAccessed(): PostHogFeatureFlagEvaluations { + val accessedKeys = + synchronized(accessLock) { accessed.toSet() } + if (accessedKeys.isEmpty()) { + if (host.warningsEnabled) { + host.logWarning( + "PostHogFeatureFlagEvaluations.onlyAccessed() called before any flag was " + + "accessed; returning all $${flagMap.size} flags instead of an empty snapshot.", + ) + } + return cloneWith(flagMap.keys) + } + 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 if (host.warningsEnabled) { + 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, + host = host, + initialAccessed = emptySet(), + ) + } + + private fun recordAccess( + key: String, + flag: FeatureFlag?, + ) { + synchronized(accessLock) { accessed.add(key) } + if (flag == null) return + if (distinctId.isNullOrBlank()) return + + val value: Any = flag.variant ?: flag.enabled + val props = mutableMapOf() + props["\$feature_flag_id"] = flag.metadata.id + props["\$feature_flag_version"] = flag.metadata.version + flag.reason?.description?.let { props["\$feature_flag_reason"] = it } + 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 } + if (locallyEvaluated[key] == true) { + props["locally_evaluated"] = true + } + + host.captureFeatureFlagCalled(distinctId, key, value, props) + } + + public companion object { + /** + * 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, + 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 cc1cd3b58..0b8f75aaa 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, ) } @@ -480,6 +488,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 000000000..5ae17d720 --- /dev/null +++ b/posthog-server/src/main/java/com/posthog/server/internal/EvaluateFlagsResult.kt @@ -0,0 +1,15 @@ +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?, +) 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 000000000..9f286243b --- /dev/null +++ b/posthog-server/src/main/java/com/posthog/server/internal/EvaluationsHost.kt @@ -0,0 +1,20 @@ +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 + * filter warnings, 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 { + val warningsEnabled: Boolean + + 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/PostHogFeatureFlags.kt b/posthog-server/src/main/java/com/posthog/server/internal/PostHogFeatureFlags.kt index d35d1e1d7..7f4010be8 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( @@ -297,9 +302,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 +455,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 +516,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 +679,109 @@ 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, + ) + 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, + ) + } + + val localFlags = + getFeatureFlagsFromLocalEvaluation( + distinctId, + groups, + personProperties, + groupProperties, + onlyEvaluateLocally, + ) + if (localFlags != null) { + return EvaluateFlagsResult( + flags = localFlags, + locallyEvaluated = localFlags.mapValues { true }, + requestId = null, + evaluatedAt = null, + definitionsLoadedAt = definitionsLoadedAt, + ) + } + + if (onlyEvaluateLocally) { + return EvaluateFlagsResult( + flags = emptyMap(), + locallyEvaluated = emptyMap(), + requestId = null, + evaluatedAt = null, + definitionsLoadedAt = definitionsLoadedAt, + ) + } + + 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, + ) + } + + private fun isLocallyEvaluated(flag: FeatureFlag): Boolean { + return flag.reason?.code == LOCAL_EVALUATION_REASON_CODE + } + override fun getFeatureFlagError( key: String, distinctId: String?, @@ -701,4 +818,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 000000000..3783bc3f3 --- /dev/null +++ b/posthog-server/src/test/java/com/posthog/server/PostHogEvaluateFlagsTest.kt @@ -0,0 +1,301 @@ +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 + +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() + } +} 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 000000000..33af77437 --- /dev/null +++ b/posthog-server/src/test/java/com/posthog/server/PostHogFeatureFlagEvaluationsTest.kt @@ -0,0 +1,302 @@ +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(override val warningsEnabled: Boolean = true) : 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, + ) = PostHogFeatureFlagEvaluations( + distinctId = distinctId, + flagMap = flags, + locallyEvaluated = locallyEvaluated, + requestId = requestId, + evaluatedAt = evaluatedAt, + definitionsLoadedAt = definitionsLoadedAt, + host = host, + ) + + @Test + fun `isEnabled returns false for unknown flags and does not fire an event`() { + val host = FakeHost() + val snapshot = snapshot(host = host, flags = mapOf("known" to flag("known", enabled = true))) + + val unknown = snapshot.isEnabled("missing") + + assertFalse(unknown) + assertTrue(host.captures.isEmpty(), "no event should fire when the flag is unknown") + } + + @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 does not fire an event and does not record access`() { + 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") + // onlyAccessed() falls back to all flags because nothing was actually accessed + val filtered = snapshot.onlyAccessed() + assertEquals(listOf("payload-flag"), filtered.keys) + } + + @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 warns and falls back to all flags when nothing was accessed`() { + val host = FakeHost() + val snapshot = + snapshot( + host = host, + flags = + mapOf( + "a" to flag("a"), + "b" to flag("b"), + ), + ) + + val filtered = snapshot.onlyAccessed() + + assertEquals(setOf("a", "b"), filtered.keys.toSet()) + assertEquals(1, host.warnings.size) + assertTrue(host.warnings.single().contains("onlyAccessed")) + } + + @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 `featureFlagsLogWarnings disabled host suppresses filter warnings`() { + val quietHost = FakeHost(warningsEnabled = false) + val snapshot = + snapshot( + host = quietHost, + flags = mapOf("a" to flag("a")), + ) + + snapshot.only(listOf("missing")) + snapshot.onlyAccessed() + + assertTrue(quietHost.warnings.isEmpty(), "warnings should be suppressed when disabled") + } + + @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 `getFlag returns null for unknown keys`() { + val host = FakeHost() + val snapshot = snapshot(host = host, flags = mapOf("known" to flag("known"))) + + assertNull(snapshot.getFlag("missing")) + } +} diff --git a/posthog/api/posthog.api b/posthog/api/posthog.api index 6e5448401..aad634318 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 + public 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 313163d73..31473027a 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 + public 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 0211a6e66..435bea877 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 2cb71b20c..5d3f9ec03 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 e38045e0d..ce7a52ae4 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 + } } } From b0a81f7ad2ed261f2fba8110c3a4b98c2f78dd84 Mon Sep 17 00:00:00 2001 From: dylan Date: Wed, 29 Apr 2026 14:11:41 -0700 Subject: [PATCH 2/2] feat(server): combine Phase 1 + Phase 2 of evaluateFlags() rollout MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 1 (already on this branch): `evaluateFlags(distinctId)` snapshot, `capture(flags = …)`, dedup helper extraction, full metadata on `$feature_flag_called`. Phase 2 (new in this commit): - `@Deprecated` annotations on `isFeatureEnabled`, `getFeatureFlag`, `getFeatureFlagPayload`, and `getFeatureFlagResult` (all overloads on both `PostHogInterface` and the `PostHog` server class), with messages pointing callers at `evaluateFlags(...)`. - Runtime deprecation log when `capture(appendFeatureFlags = true)` is used — mirrors the Python PR's "only-when-truthy" runtime warning, since Kotlin can't deprecate a single parameter value at compile time. - All legacy paths keep working unchanged; deprecations can be silenced with `@Suppress("DEPRECATION")`. Removal is targeted at the next major. Also: response-level errors (`errors_while_computing_flags`, `quota_limited`) are now propagated into snapshot `$feature_flag_called` events as `$feature_flag_error`, matching the granularity the per-flag accessor path emits. New tests: - `responseError` propagates to `$feature_flag_called` (unit + integration). - `appendFeatureFlags = true` deprecated path still attaches feature properties end-to-end. Generated-By: PostHog Code Task-Id: 87de4c67-f607-4432-b8ee-3c059e368f81 --- posthog-server/CHANGELOG.md | 3 +- .../main/java/com/posthog/server/PostHog.kt | 26 +++++++- .../server/PostHogFeatureFlagEvaluations.kt | 4 ++ .../com/posthog/server/PostHogInterface.kt | 64 +++++++++++++++++++ .../server/internal/EvaluateFlagsResult.kt | 6 ++ .../server/internal/PostHogFeatureFlags.kt | 4 ++ .../server/PostHogEvaluateFlagsTest.kt | 63 ++++++++++++++++++ .../PostHogFeatureFlagEvaluationsTest.kt | 34 ++++++++++ .../java/com/posthog/server/PostHogTest.kt | 1 + 9 files changed, 203 insertions(+), 2 deletions(-) diff --git a/posthog-server/CHANGELOG.md b/posthog-server/CHANGELOG.md index 46dd8eb66..f3b0b8520 100644 --- a/posthog-server/CHANGELOG.md +++ b/posthog-server/CHANGELOG.md @@ -1,6 +1,7 @@ ## Next -- feat: Add `evaluateFlags()` API for single-call flag evaluation. Returns a `PostHogFeatureFlagEvaluations` snapshot with `isEnabled`/`getFlag`/`getFlagPayload` 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. `$feature_flag_called` events now also include `$feature_flag_id`, `$feature_flag_version`, and `$feature_flag_reason` metadata. New `featureFlagsLogWarnings` config option silences filter warnings. +- feat: Add `evaluateFlags()` API for single-call flag evaluation. Returns a `PostHogFeatureFlagEvaluations` snapshot with `isEnabled`/`getFlag`/`getFlagPayload` 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. `$feature_flag_called` events now also include `$feature_flag_id`, `$feature_flag_version`, `$feature_flag_reason`, and propagate response-level `$feature_flag_error` metadata. New `featureFlagsLogWarnings` config option silences filter warnings. +- 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 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 1fee4267d..398461cd9 100644 --- a/posthog-server/src/main/java/com/posthog/server/PostHog.kt +++ b/posthog-server/src/main/java/com/posthog/server/PostHog.kt @@ -6,6 +6,7 @@ import com.posthog.internal.FeatureFlag import com.posthog.server.internal.EvaluationsHost import com.posthog.server.internal.PostHogFeatureFlags +@Suppress("DEPRECATION") public class PostHog : PostHogStateless(), PostHogInterface { @Volatile private var serverConfig: PostHogConfig? = null @@ -81,7 +82,14 @@ public class PostHog : PostHogStateless(), PostHogInterface { } mergeFeatureFlagPropertiesFromSnapshot(properties, flags) } - appendFeatureFlags -> + 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, @@ -89,6 +97,7 @@ public class PostHog : PostHogStateless(), PostHogInterface { groupProperties = null, properties = properties, ) + } else -> properties } @@ -103,6 +112,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, @@ -121,6 +133,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, @@ -139,6 +154,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, @@ -157,6 +175,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, @@ -302,6 +325,7 @@ public class PostHog : PostHogStateless(), PostHogInterface { requestId = result.requestId, evaluatedAt = result.evaluatedAt, definitionsLoadedAt = result.definitionsLoadedAt, + responseError = result.responseError, host = evaluationsHost, ) } diff --git a/posthog-server/src/main/java/com/posthog/server/PostHogFeatureFlagEvaluations.kt b/posthog-server/src/main/java/com/posthog/server/PostHogFeatureFlagEvaluations.kt index 5d34048a4..de3405cb8 100644 --- a/posthog-server/src/main/java/com/posthog/server/PostHogFeatureFlagEvaluations.kt +++ b/posthog-server/src/main/java/com/posthog/server/PostHogFeatureFlagEvaluations.kt @@ -24,6 +24,7 @@ public class PostHogFeatureFlagEvaluations internal constructor( public val requestId: String?, public val evaluatedAt: Long?, public val definitionsLoadedAt: Long?, + private val responseError: String?, private val host: EvaluationsHost, initialAccessed: Set = emptySet(), ) { @@ -124,6 +125,7 @@ public class PostHogFeatureFlagEvaluations internal constructor( requestId = requestId, evaluatedAt = evaluatedAt, definitionsLoadedAt = definitionsLoadedAt, + responseError = responseError, host = host, initialAccessed = emptySet(), ) @@ -145,6 +147,7 @@ public class PostHogFeatureFlagEvaluations internal constructor( 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 } + responseError?.let { props["\$feature_flag_error"] = it } if (locallyEvaluated[key] == true) { props["locally_evaluated"] = true } @@ -166,6 +169,7 @@ public class PostHogFeatureFlagEvaluations internal constructor( 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 0b8f75aaa..ddd776992 100644 --- a/posthog-server/src/main/java/com/posthog/server/PostHogInterface.kt +++ b/posthog-server/src/main/java/com/posthog/server/PostHogInterface.kt @@ -148,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, @@ -163,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, @@ -184,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, @@ -206,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, @@ -228,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, @@ -244,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, @@ -265,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, @@ -287,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, @@ -309,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, @@ -325,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, @@ -346,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, @@ -368,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, @@ -394,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, @@ -410,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, @@ -432,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, 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 index 5ae17d720..7fb3ec65e 100644 --- a/posthog-server/src/main/java/com/posthog/server/internal/EvaluateFlagsResult.kt +++ b/posthog-server/src/main/java/com/posthog/server/internal/EvaluateFlagsResult.kt @@ -12,4 +12,10 @@ internal data class EvaluateFlagsResult( 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/PostHogFeatureFlags.kt b/posthog-server/src/main/java/com/posthog/server/internal/PostHogFeatureFlags.kt index 7f4010be8..8036dc5a8 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 @@ -728,6 +728,7 @@ internal class PostHogFeatureFlags( requestId = entry.requestId, evaluatedAt = entry.evaluatedAt, definitionsLoadedAt = definitionsLoadedAt, + responseError = entry.error, ) } @@ -746,6 +747,7 @@ internal class PostHogFeatureFlags( requestId = null, evaluatedAt = null, definitionsLoadedAt = definitionsLoadedAt, + responseError = null, ) } @@ -756,6 +758,7 @@ internal class PostHogFeatureFlags( requestId = null, evaluatedAt = null, definitionsLoadedAt = definitionsLoadedAt, + responseError = null, ) } @@ -775,6 +778,7 @@ internal class PostHogFeatureFlags( requestId = entry?.requestId, evaluatedAt = entry?.evaluatedAt, definitionsLoadedAt = definitionsLoadedAt, + responseError = entry?.error, ) } diff --git a/posthog-server/src/test/java/com/posthog/server/PostHogEvaluateFlagsTest.kt b/posthog-server/src/test/java/com/posthog/server/PostHogEvaluateFlagsTest.kt index 3783bc3f3..821a9ca92 100644 --- a/posthog-server/src/test/java/com/posthog/server/PostHogEvaluateFlagsTest.kt +++ b/posthog-server/src/test/java/com/posthog/server/PostHogEvaluateFlagsTest.kt @@ -11,6 +11,7 @@ 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() @@ -298,4 +299,66 @@ internal class PostHogEvaluateFlagsTest { 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 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 index 33af77437..1b2d2a979 100644 --- a/posthog-server/src/test/java/com/posthog/server/PostHogFeatureFlagEvaluationsTest.kt +++ b/posthog-server/src/test/java/com/posthog/server/PostHogFeatureFlagEvaluationsTest.kt @@ -61,6 +61,7 @@ internal class PostHogFeatureFlagEvaluationsTest { requestId: String? = "req-1", evaluatedAt: Long? = 1_700_000_000_000L, definitionsLoadedAt: Long? = null, + responseError: String? = null, ) = PostHogFeatureFlagEvaluations( distinctId = distinctId, flagMap = flags, @@ -68,6 +69,7 @@ internal class PostHogFeatureFlagEvaluationsTest { requestId = requestId, evaluatedAt = evaluatedAt, definitionsLoadedAt = definitionsLoadedAt, + responseError = responseError, host = host, ) @@ -299,4 +301,36 @@ internal class PostHogFeatureFlagEvaluationsTest { assertNull(snapshot.getFlag("missing")) } + + @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`() { + 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 382e7e992..1a3687d44 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())