Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions posthog-android/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
4 changes: 4 additions & 0 deletions posthog-server/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ internal data class FeatureFlagCacheEntry(
val flags: Map<String, FeatureFlag>?,
val timestamp: Long,
val expiresAt: Long,
val requestId: String? = null,
val evaluatedAt: Long? = null,
) {
/**
* Check if this cache entry has expired
Expand All @@ -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
}

Expand All @@ -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
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -38,20 +38,42 @@ 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
*/
@Synchronized
fun put(
key: FeatureFlagCacheKey,
flags: Map<String, FeatureFlag>?,
requestId: String? = null,
evaluatedAt: Long? = null,
) {
val currentTime = System.currentTimeMillis()
val entry =
FeatureFlagCacheEntry(
flags = flags,
timestamp = currentTime,
expiresAt = currentTime + maxAgeMs,
requestId = requestId,
evaluatedAt = evaluatedAt,
)

cache[key] = entry
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -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<String, String>?,
personProperties: Map<String, Any?>?,
groupProperties: Map<String, Map<String, Any?>>?,
): 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<String, String>?,
personProperties: Map<String, Any?>?,
groupProperties: Map<String, Map<String, Any?>>?,
): Long? {
if (distinctId == null) {
return null
}
val cacheKey =
FeatureFlagCacheKey(
distinctId = distinctId,
groups = groups,
personProperties = personProperties,
groupProperties = groupProperties,
)
return cache.getEntry(cacheKey)?.evaluatedAt
}
}
5 changes: 5 additions & 0 deletions posthog/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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))
Expand Down
17 changes: 12 additions & 5 deletions posthog/api/posthog.api
Original file line number Diff line number Diff line change
Expand Up @@ -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 <init> (ZLjava/util/Map;Ljava/util/Map;Ljava/util/Map;Ljava/util/List;Ljava/lang/String;)V
public synthetic fun <init> (ZLjava/util/Map;Ljava/util/Map;Ljava/util/Map;Ljava/util/List;Ljava/lang/String;ILkotlin/jvm/internal/DefaultConstructorMarker;)V
public fun <init> (ZLjava/util/Map;Ljava/util/Map;Ljava/util/Map;Ljava/util/List;Ljava/lang/String;Ljava/lang/Long;)V
public synthetic fun <init> (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;
Expand Down Expand Up @@ -720,12 +726,13 @@ public final class com/posthog/internal/PostHogRemoteConfig : com/posthog/intern
public fun <init> (Lcom/posthog/PostHogConfig;Lcom/posthog/internal/PostHogApi;Ljava/util/concurrent/ExecutorService;Lkotlin/jvm/functions/Function0;)V
public synthetic fun <init> (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
Expand Down
4 changes: 3 additions & 1 deletion posthog/src/main/java/com/posthog/PostHog.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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<String, Any>()
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
Expand Down
10 changes: 9 additions & 1 deletion posthog/src/main/java/com/posthog/PostHogStateless.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)
}
Expand Down Expand Up @@ -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
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,4 +34,18 @@ public interface PostHogFeatureFlagsInterface {
public fun shutDown() {
// no-op by default
}

public fun getRequestId(
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There's a PostHogFeatureFlagsInterface implemented somewhere in the tests which will need the new methods.

distinctId: String? = null,
groups: Map<String, String>? = null,
personProperties: Map<String, Any?>? = null,
groupProperties: Map<String, Map<String, Any?>>? = null,
): String?

public fun getEvaluatedAt(
distinctId: String? = null,
groups: Map<String, String>? = null,
personProperties: Map<String, Any?>? = null,
groupProperties: Map<String, Map<String, Any?>>? = null,
): Long?
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -21,4 +23,5 @@ public data class PostHogFlagsResponse(
val flags: Map<String, FeatureFlag>? = null,
val quotaLimited: List<String>? = null,
val requestId: String?,
val evaluatedAt: Long?,
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

add the @property comment docs above, also for requestId just noticed its missing

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

will do!

) : PostHogRemoteConfigResponse()
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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,
Expand Down
Loading
Loading