diff --git a/posthog-android/CHANGELOG.md b/posthog-android/CHANGELOG.md index bfb96c98..989e5ff8 100644 --- a/posthog-android/CHANGELOG.md +++ b/posthog-android/CHANGELOG.md @@ -1,5 +1,11 @@ ## Next +## 3.28.0 - 2025-12-01 + +- feat: include `evaluated_at` properties in `$feature_flag_called` events ([#321](https://github.com/PostHog/posthog-android/pull/321)) + +## 3.27.0 - 2025-11-24 + - feat: proguard support ([#316](https://github.com/PostHog/posthog-android/pull/316)) ## 3.26.0 - 2025-11-05 diff --git a/posthog-server/CHANGELOG.md b/posthog-server/CHANGELOG.md index 4573efb9..c16e6913 100644 --- a/posthog-server/CHANGELOG.md +++ b/posthog-server/CHANGELOG.md @@ -1,5 +1,9 @@ ## Next +## 2.2.0 - 2025-12-01 + +- feat: include `evaluated_at` properties in `$feature_flag_called` events ([#321](https://github.com/PostHog/posthog-android/pull/321)) + ## 2.0.1 - 2025-11-24 - fix: Local evaluation properly handles cases when flag dependency should be false ([#320](https://github.com/PostHog/posthog-android/pull/320)) diff --git a/posthog-server/src/main/java/com/posthog/server/internal/FeatureFlagCacheEntry.kt b/posthog-server/src/main/java/com/posthog/server/internal/FeatureFlagCacheEntry.kt index 9d82b9ea..75bc3573 100644 --- a/posthog-server/src/main/java/com/posthog/server/internal/FeatureFlagCacheEntry.kt +++ b/posthog-server/src/main/java/com/posthog/server/internal/FeatureFlagCacheEntry.kt @@ -9,6 +9,8 @@ internal data class FeatureFlagCacheEntry( val flags: Map?, val timestamp: Long, val expiresAt: Long, + val requestId: String? = null, + val evaluatedAt: Long? = null, ) { /** * Check if this cache entry has expired @@ -21,6 +23,8 @@ internal data class FeatureFlagCacheEntry( var result = flags?.hashCode() ?: 0 result = 31 * result + (timestamp xor (timestamp ushr 32)).toInt() result = 31 * result + (expiresAt xor (expiresAt ushr 32)).toInt() + result = 31 * result + (requestId?.hashCode() ?: 0) + result = 31 * result + (evaluatedAt?.hashCode() ?: 0) return result } @@ -31,6 +35,8 @@ internal data class FeatureFlagCacheEntry( if (flags != other.flags) return false if (timestamp != other.timestamp) return false if (expiresAt != other.expiresAt) return false + if (requestId != other.requestId) return false + if (evaluatedAt != other.evaluatedAt) return false return true } diff --git a/posthog-server/src/main/java/com/posthog/server/internal/PostHogFeatureFlagCache.kt b/posthog-server/src/main/java/com/posthog/server/internal/PostHogFeatureFlagCache.kt index 3433b649..9a374ccf 100644 --- a/posthog-server/src/main/java/com/posthog/server/internal/PostHogFeatureFlagCache.kt +++ b/posthog-server/src/main/java/com/posthog/server/internal/PostHogFeatureFlagCache.kt @@ -38,6 +38,24 @@ internal class PostHogFeatureFlagCache( return entry.flags } + /** + * Get full cache entry (including requestId and evaluatedAt) if present and not expired + */ + @Synchronized + fun getEntry(key: FeatureFlagCacheKey): FeatureFlagCacheEntry? { + val entry = cache[key] + if (entry == null) { + return null + } + + if (entry.isExpired()) { + cache.remove(key) + return null + } + + return entry + } + /** * Put feature flags into cache with current timestamp */ @@ -45,6 +63,8 @@ internal class PostHogFeatureFlagCache( fun put( key: FeatureFlagCacheKey, flags: Map?, + requestId: String? = null, + evaluatedAt: Long? = null, ) { val currentTime = System.currentTimeMillis() val entry = @@ -52,6 +72,8 @@ internal class PostHogFeatureFlagCache( flags = flags, timestamp = currentTime, expiresAt = currentTime + maxAgeMs, + requestId = requestId, + evaluatedAt = evaluatedAt, ) cache[key] = entry 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 8949917a..1850c1a1 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 @@ -259,7 +259,7 @@ internal class PostHogFeatureFlags( return try { val response = api.flags(distinctId, null, groups, personProperties, groupProperties) val flags = response?.flags - cache.put(cacheKey, flags) + cache.put(cacheKey, flags, response?.requestId, response?.evaluatedAt) flags } catch (e: Throwable) { config.logger.log("Loading remote feature flags failed: $e") @@ -499,4 +499,48 @@ internal class PostHogFeatureFlags( private fun localEvaluationEnabled(): Boolean { return localEvaluation && !personalApiKey.isNullOrBlank() } + + /** + * Get the requestId from the cache for the given distinctId and groups + */ + override fun getRequestId( + distinctId: String?, + groups: Map?, + personProperties: Map?, + groupProperties: Map>?, + ): String? { + if (distinctId == null) { + return null + } + val cacheKey = + FeatureFlagCacheKey( + distinctId = distinctId, + groups = groups, + personProperties = personProperties, + groupProperties = groupProperties, + ) + return cache.getEntry(cacheKey)?.requestId + } + + /** + * Get the evaluatedAt from the cache for the given distinctId and groups + */ + override fun getEvaluatedAt( + distinctId: String?, + groups: Map?, + personProperties: Map?, + groupProperties: Map>?, + ): Long? { + if (distinctId == null) { + return null + } + val cacheKey = + FeatureFlagCacheKey( + distinctId = distinctId, + groups = groups, + personProperties = personProperties, + groupProperties = groupProperties, + ) + return cache.getEntry(cacheKey)?.evaluatedAt + } } diff --git a/posthog/CHANGELOG.md b/posthog/CHANGELOG.md index 0a9900f6..e0948ed9 100644 --- a/posthog/CHANGELOG.md +++ b/posthog/CHANGELOG.md @@ -1,5 +1,10 @@ ## Next +## 5.2.0 - 2025-11-24 + +- feat: include `evaluated_at` properties in `$feature_flag_called` events ([#321](https://github.com/PostHog/posthog-android/pull/321)) + + ## 5.1.0 - 2025-11-06 - feat: Add an optional shutdown override to `FeatureFlagInterface` ([#299](https://github.com/PostHog/posthog-android/pull/299)) diff --git a/posthog/api/posthog.api b/posthog/api/posthog.api index e5d9bdcd..45bb96d6 100644 --- a/posthog/api/posthog.api +++ b/posthog/api/posthog.api @@ -611,32 +611,38 @@ public final class com/posthog/internal/PostHogDeviceDateProvider : com/posthog/ 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 getFeatureFlag (Ljava/lang/String;Ljava/lang/Object;Ljava/lang/String;Ljava/util/Map;Ljava/util/Map;Ljava/util/Map;)Ljava/lang/Object; public abstract fun getFeatureFlagPayload (Ljava/lang/String;Ljava/lang/Object;Ljava/lang/String;Ljava/util/Map;Ljava/util/Map;Ljava/util/Map;)Ljava/lang/Object; public abstract fun getFeatureFlags (Ljava/lang/String;Ljava/util/Map;Ljava/util/Map;Ljava/util/Map;)Ljava/util/Map; + public abstract fun getRequestId (Ljava/lang/String;Ljava/util/Map;Ljava/util/Map;Ljava/util/Map;)Ljava/lang/String; public abstract fun shutDown ()V } 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 synthetic fun getFeatureFlag$default (Lcom/posthog/internal/PostHogFeatureFlagsInterface;Ljava/lang/String;Ljava/lang/Object;Ljava/lang/String;Ljava/util/Map;Ljava/util/Map;Ljava/util/Map;ILjava/lang/Object;)Ljava/lang/Object; public static synthetic fun getFeatureFlagPayload$default (Lcom/posthog/internal/PostHogFeatureFlagsInterface;Ljava/lang/String;Ljava/lang/Object;Ljava/lang/String;Ljava/util/Map;Ljava/util/Map;Ljava/util/Map;ILjava/lang/Object;)Ljava/lang/Object; public static synthetic fun getFeatureFlags$default (Lcom/posthog/internal/PostHogFeatureFlagsInterface;Ljava/lang/String;Ljava/util/Map;Ljava/util/Map;Ljava/util/Map;ILjava/lang/Object;)Ljava/util/Map; + public static synthetic fun getRequestId$default (Lcom/posthog/internal/PostHogFeatureFlagsInterface;Ljava/lang/String;Ljava/util/Map;Ljava/util/Map;Ljava/util/Map;ILjava/lang/Object;)Ljava/lang/String; public static fun shutDown (Lcom/posthog/internal/PostHogFeatureFlagsInterface;)V } public final class com/posthog/internal/PostHogFlagsResponse : com/posthog/internal/PostHogRemoteConfigResponse { - public fun (ZLjava/util/Map;Ljava/util/Map;Ljava/util/Map;Ljava/util/List;Ljava/lang/String;)V - public synthetic fun (ZLjava/util/Map;Ljava/util/Map;Ljava/util/Map;Ljava/util/List;Ljava/lang/String;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public fun (ZLjava/util/Map;Ljava/util/Map;Ljava/util/Map;Ljava/util/List;Ljava/lang/String;Ljava/lang/Long;)V + public synthetic fun (ZLjava/util/Map;Ljava/util/Map;Ljava/util/Map;Ljava/util/List;Ljava/lang/String;Ljava/lang/Long;ILkotlin/jvm/internal/DefaultConstructorMarker;)V public final fun component1 ()Z public final fun component2 ()Ljava/util/Map; public final fun component3 ()Ljava/util/Map; public final fun component4 ()Ljava/util/Map; public final fun component5 ()Ljava/util/List; public final fun component6 ()Ljava/lang/String; - public final fun copy (ZLjava/util/Map;Ljava/util/Map;Ljava/util/Map;Ljava/util/List;Ljava/lang/String;)Lcom/posthog/internal/PostHogFlagsResponse; - public static synthetic fun copy$default (Lcom/posthog/internal/PostHogFlagsResponse;ZLjava/util/Map;Ljava/util/Map;Ljava/util/Map;Ljava/util/List;Ljava/lang/String;ILjava/lang/Object;)Lcom/posthog/internal/PostHogFlagsResponse; + public final fun component7 ()Ljava/lang/Long; + public final fun copy (ZLjava/util/Map;Ljava/util/Map;Ljava/util/Map;Ljava/util/List;Ljava/lang/String;Ljava/lang/Long;)Lcom/posthog/internal/PostHogFlagsResponse; + public static synthetic fun copy$default (Lcom/posthog/internal/PostHogFlagsResponse;ZLjava/util/Map;Ljava/util/Map;Ljava/util/Map;Ljava/util/List;Ljava/lang/String;Ljava/lang/Long;ILjava/lang/Object;)Lcom/posthog/internal/PostHogFlagsResponse; public fun equals (Ljava/lang/Object;)Z public final fun getErrorsWhileComputingFlags ()Z + public final fun getEvaluatedAt ()Ljava/lang/Long; public final fun getFeatureFlagPayloads ()Ljava/util/Map; public final fun getFeatureFlags ()Ljava/util/Map; public final fun getFlags ()Ljava/util/Map; @@ -720,12 +726,13 @@ public final class com/posthog/internal/PostHogRemoteConfig : com/posthog/intern public fun (Lcom/posthog/PostHogConfig;Lcom/posthog/internal/PostHogApi;Ljava/util/concurrent/ExecutorService;Lkotlin/jvm/functions/Function0;)V public synthetic fun (Lcom/posthog/PostHogConfig;Lcom/posthog/internal/PostHogApi;Ljava/util/concurrent/ExecutorService;Lkotlin/jvm/functions/Function0;ILkotlin/jvm/internal/DefaultConstructorMarker;)V public fun clear ()V + public fun getEvaluatedAt (Ljava/lang/String;Ljava/util/Map;Ljava/util/Map;Ljava/util/Map;)Ljava/lang/Long; public fun getFeatureFlag (Ljava/lang/String;Ljava/lang/Object;Ljava/lang/String;Ljava/util/Map;Ljava/util/Map;Ljava/util/Map;)Ljava/lang/Object; public fun getFeatureFlagPayload (Ljava/lang/String;Ljava/lang/Object;Ljava/lang/String;Ljava/util/Map;Ljava/util/Map;Ljava/util/Map;)Ljava/lang/Object; public fun getFeatureFlags (Ljava/lang/String;Ljava/util/Map;Ljava/util/Map;Ljava/util/Map;)Ljava/util/Map; public final fun getFlagDetails (Ljava/lang/String;)Lcom/posthog/internal/FeatureFlag; public final fun getOnRemoteConfigLoaded ()Lkotlin/jvm/functions/Function0; - public final fun getRequestId ()Ljava/lang/String; + public fun getRequestId (Ljava/lang/String;Ljava/util/Map;Ljava/util/Map;Ljava/util/Map;)Ljava/lang/String; public final fun getSurveys ()Ljava/util/List; public final fun isSessionReplayFlagActive ()Z public final fun loadFeatureFlags (Ljava/lang/String;Ljava/lang/String;Ljava/util/Map;Lcom/posthog/PostHogOnFeatureFlags;Lcom/posthog/PostHogOnFeatureFlags;)V diff --git a/posthog/src/main/java/com/posthog/PostHog.kt b/posthog/src/main/java/com/posthog/PostHog.kt index dee7f819..90a67e2b 100644 --- a/posthog/src/main/java/com/posthog/PostHog.kt +++ b/posthog/src/main/java/com/posthog/PostHog.kt @@ -889,12 +889,14 @@ public class PostHog private constructor( remoteConfig?.let { val flagDetails = it.getFlagDetails(key) val requestId = it.getRequestId() + val evaluatedAt = it.getEvaluatedAt() val props = mutableMapOf() props["\$feature_flag"] = key // value should never be nullabe anyway props["\$feature_flag_response"] = value ?: "" - props["\$feature_flag_request_id"] = requestId ?: "" + requestId?.let { props["\$feature_flag_request_id"] = it } + evaluatedAt?.let { props["\$feature_flag_evaluated_at"] = it } flagDetails?.let { props["\$feature_flag_id"] = it.metadata.id props["\$feature_flag_version"] = it.metadata.version diff --git a/posthog/src/main/java/com/posthog/PostHogStateless.kt b/posthog/src/main/java/com/posthog/PostHogStateless.kt index b2456112..91234ef0 100644 --- a/posthog/src/main/java/com/posthog/PostHogStateless.kt +++ b/posthog/src/main/java/com/posthog/PostHogStateless.kt @@ -432,6 +432,8 @@ public open class PostHogStateless protected constructor( distinctId: String, key: String, value: Any?, + requestId: String? = null, + evaluatedAt: Long? = null, ) { if (config?.sendFeatureFlagEvent == true) { val isNewlySeen = featureFlagsCalled?.add(distinctId, key, value) ?: false @@ -440,6 +442,8 @@ public open class PostHogStateless protected constructor( props["\$feature_flag"] = key // value should never be nullable anyway props["\$feature_flag_response"] = value ?: "" + requestId?.let { props["\$feature_flag_request_id"] = it } + evaluatedAt?.let { props["\$feature_flag_evaluated_at"] = it } captureStateless("\$feature_flag_called", distinctId, properties = props) } @@ -467,7 +471,11 @@ public open class PostHogStateless protected constructor( groupProperties, ) ?: defaultValue - sendFeatureFlagCalled(distinctId, key, value) + // Get requestId and evaluatedAt from feature flags + val requestId = featureFlags?.getRequestId(distinctId, groups, personProperties, groupProperties) + val evaluatedAt = featureFlags?.getEvaluatedAt(distinctId, groups, personProperties, groupProperties) + + sendFeatureFlagCalled(distinctId, key, value, requestId, evaluatedAt) return value } diff --git a/posthog/src/main/java/com/posthog/internal/PostHogFeatureFlagsInterface.kt b/posthog/src/main/java/com/posthog/internal/PostHogFeatureFlagsInterface.kt index 1c56fb3a..baf2029a 100644 --- a/posthog/src/main/java/com/posthog/internal/PostHogFeatureFlagsInterface.kt +++ b/posthog/src/main/java/com/posthog/internal/PostHogFeatureFlagsInterface.kt @@ -34,4 +34,18 @@ public interface PostHogFeatureFlagsInterface { public fun shutDown() { // no-op by default } + + public fun getRequestId( + distinctId: String? = null, + groups: Map? = null, + personProperties: Map? = null, + groupProperties: Map>? = null, + ): String? + + public fun getEvaluatedAt( + distinctId: String? = null, + groups: Map? = null, + personProperties: Map? = null, + groupProperties: Map>? = null, + ): Long? } diff --git a/posthog/src/main/java/com/posthog/internal/PostHogFlagsResponse.kt b/posthog/src/main/java/com/posthog/internal/PostHogFlagsResponse.kt index 6e7a0c20..926fc5ce 100644 --- a/posthog/src/main/java/com/posthog/internal/PostHogFlagsResponse.kt +++ b/posthog/src/main/java/com/posthog/internal/PostHogFlagsResponse.kt @@ -10,6 +10,8 @@ import org.codehaus.mojo.animal_sniffer.IgnoreJRERequirement * @property featureFlagPayloads the feature flag payloads * @property flags the feature flags. * @property quotaLimited array of quota limited features + * @property requestId the request id generated by the flags server on evaluation + * @property evaluatedAt the evaluated at timestamp generated by the flags server on evaluation */ @IgnoreJRERequirement @PostHogInternal @@ -21,4 +23,5 @@ public data class PostHogFlagsResponse( val flags: Map? = null, val quotaLimited: List? = null, val requestId: String?, + val evaluatedAt: Long?, ) : PostHogRemoteConfigResponse() diff --git a/posthog/src/main/java/com/posthog/internal/PostHogPreferences.kt b/posthog/src/main/java/com/posthog/internal/PostHogPreferences.kt index 4f0737bb..ed06f51d 100644 --- a/posthog/src/main/java/com/posthog/internal/PostHogPreferences.kt +++ b/posthog/src/main/java/com/posthog/internal/PostHogPreferences.kt @@ -35,6 +35,7 @@ public interface PostHogPreferences { internal const val FEATURE_FLAGS = "featureFlags" internal const val FEATURE_FLAGS_PAYLOAD = "featureFlagsPayload" internal const val FEATURE_FLAG_REQUEST_ID = "feature_flag_request_id" + internal const val FEATURE_FLAG_EVALUATED_AT = "feature_flag_evaluated_at" internal const val SESSION_REPLAY = "sessionReplay" internal const val SURVEYS = "surveys" internal const val PERSON_PROPERTIES_FOR_FLAGS = "personPropertiesForFlags" @@ -61,6 +62,7 @@ public interface PostHogPreferences { BUILD, STRINGIFIED_KEYS, FEATURE_FLAG_REQUEST_ID, + FEATURE_FLAG_EVALUATED_AT, FLAGS, PERSON_PROPERTIES_FOR_FLAGS, GROUP_PROPERTIES_FOR_FLAGS, diff --git a/posthog/src/main/java/com/posthog/internal/PostHogRemoteConfig.kt b/posthog/src/main/java/com/posthog/internal/PostHogRemoteConfig.kt index 79153c94..5bda9cb1 100644 --- a/posthog/src/main/java/com/posthog/internal/PostHogRemoteConfig.kt +++ b/posthog/src/main/java/com/posthog/internal/PostHogRemoteConfig.kt @@ -5,6 +5,7 @@ import com.posthog.PostHogInternal import com.posthog.PostHogOnFeatureFlags import com.posthog.internal.PostHogPreferences.Companion.FEATURE_FLAGS import com.posthog.internal.PostHogPreferences.Companion.FEATURE_FLAGS_PAYLOAD +import com.posthog.internal.PostHogPreferences.Companion.FEATURE_FLAG_EVALUATED_AT import com.posthog.internal.PostHogPreferences.Companion.FEATURE_FLAG_REQUEST_ID import com.posthog.internal.PostHogPreferences.Companion.FLAGS import com.posthog.internal.PostHogPreferences.Companion.SESSION_REPLAY @@ -46,6 +47,7 @@ public class PostHogRemoteConfig( // But for now, we need to support both for back compatibility private var flags: Map? = null private var requestId: String? = null + private var evaluatedAt: Long? = null private var surveys: List? = null @@ -501,12 +503,14 @@ public class PostHogRemoteConfig( ) as? Map ?: mapOf() val cachedRequestId = preferences.getValue(FEATURE_FLAG_REQUEST_ID) as? String + val cachedEvaluatedAt = preferences.getValue(FEATURE_FLAG_EVALUATED_AT) as? Long synchronized(featureFlagsLock) { this.flags = flags this.featureFlags = featureFlags this.featureFlagPayloads = payloads this.requestId = cachedRequestId + this.evaluatedAt = cachedEvaluatedAt isFeatureFlagsLoaded = true } } @@ -549,6 +553,11 @@ public class PostHogRemoteConfig( this.requestId?.let { requestId -> config.cachePreferences?.setValue(FEATURE_FLAG_REQUEST_ID, requestId as Any) } + // Store the evaluatedAt in the cache. + this.evaluatedAt = newResponse.evaluatedAt + this.evaluatedAt?.let { evaluatedAt -> + config.cachePreferences?.setValue(FEATURE_FLAG_EVALUATED_AT, evaluatedAt as Any) + } } return newResponse } @@ -618,13 +627,30 @@ public class PostHogRemoteConfig( public fun isSessionReplayFlagActive(): Boolean = sessionReplayFlagActive - public fun getRequestId(): String? { + override fun getRequestId( + distinctId: String?, + groups: Map?, + personProperties: Map?, + groupProperties: Map>?, + ): String? { loadFeatureFlagsFromCacheIfNeeded() synchronized(featureFlagsLock) { return requestId } } + override fun getEvaluatedAt( + distinctId: String?, + groups: Map?, + personProperties: Map?, + groupProperties: Map>?, + ): Long? { + loadFeatureFlagsFromCacheIfNeeded() + synchronized(featureFlagsLock) { + return evaluatedAt + } + } + public fun getFlagDetails(key: String): FeatureFlag? { loadFeatureFlagsFromCacheIfNeeded() @@ -645,12 +671,14 @@ public class PostHogRemoteConfig( this.featureFlagPayloads = null this.flags = null this.requestId = null + this.evaluatedAt = null config.cachePreferences?.let { preferences -> preferences.remove(FLAGS) preferences.remove(FEATURE_FLAGS) preferences.remove(FEATURE_FLAGS_PAYLOAD) preferences.remove(FEATURE_FLAG_REQUEST_ID) + preferences.remove(FEATURE_FLAG_EVALUATED_AT) } } diff --git a/posthog/src/test/java/com/posthog/PostHogStatelessTest.kt b/posthog/src/test/java/com/posthog/PostHogStatelessTest.kt index 697da3c2..dea8871c 100644 --- a/posthog/src/test/java/com/posthog/PostHogStatelessTest.kt +++ b/posthog/src/test/java/com/posthog/PostHogStatelessTest.kt @@ -133,6 +133,24 @@ internal class PostHogStatelessTest { return flags.toMap() } + override fun getRequestId( + distinctId: String?, + groups: Map?, + personProperties: Map?, + groupProperties: Map>?, + ): String? { + return null + } + + override fun getEvaluatedAt( + distinctId: String?, + groups: Map?, + personProperties: Map?, + groupProperties: Map>?, + ): Long? { + return null + } + override fun clear() { flags.clear() }