diff --git a/posthog-android/src/test/java/com/posthog/android/PostHogFake.kt b/posthog-android/src/test/java/com/posthog/android/PostHogFake.kt index 3319418a..59c412aa 100644 --- a/posthog-android/src/test/java/com/posthog/android/PostHogFake.kt +++ b/posthog-android/src/test/java/com/posthog/android/PostHogFake.kt @@ -72,6 +72,28 @@ public class PostHogFake : PostHogInterface { override fun flush() { } + override fun setPersonPropertiesForFlags( + userProperties: Map, + reloadFeatureFlags: Boolean, + ) { + } + + override fun resetPersonPropertiesForFlags(reloadFeatureFlags: Boolean) { + } + + override fun setGroupPropertiesForFlags( + type: String, + groupProperties: Map, + reloadFeatureFlags: Boolean, + ) { + } + + override fun resetGroupPropertiesForFlags( + type: String?, + reloadFeatureFlags: Boolean, + ) { + } + override fun reset() { } diff --git a/posthog-server/src/main/java/com/posthog/server/PostHogConfig.kt b/posthog-server/src/main/java/com/posthog/server/PostHogConfig.kt index 87bf6923..4d30b373 100644 --- a/posthog-server/src/main/java/com/posthog/server/PostHogConfig.kt +++ b/posthog-server/src/main/java/com/posthog/server/PostHogConfig.kt @@ -139,7 +139,7 @@ public open class PostHogConfig constructor( encryption = encryption, onFeatureFlags = onFeatureFlags, proxy = proxy, - remoteConfigProvider = { config, api, _ -> + remoteConfigProvider = { config, api, _, _ -> PostHogFeatureFlags( config, api, diff --git a/posthog/CHANGELOG.md b/posthog/CHANGELOG.md index b8bcd9cb..6992c8c9 100644 --- a/posthog/CHANGELOG.md +++ b/posthog/CHANGELOG.md @@ -1,5 +1,6 @@ ## Next +- feat: Add ability to cache properties for flags ([#315](https://github.com/PostHog/posthog-android/pull/315)) - fix: Typed `groupProperties` and `userProperties` types to match the API and other SDKs ([#312](https://github.com/PostHog/posthog-android/pull/312)) ## 4.2.0 - 2025-10-23 diff --git a/posthog/api/posthog.api b/posthog/api/posthog.api index fde40e78..5a4f8bfb 100644 --- a/posthog/api/posthog.api +++ b/posthog/api/posthog.api @@ -31,7 +31,11 @@ public final class com/posthog/PostHog : com/posthog/PostHogStateless, com/posth public fun register (Ljava/lang/String;Ljava/lang/Object;)V public fun reloadFeatureFlags (Lcom/posthog/PostHogOnFeatureFlags;)V public fun reset ()V + public fun resetGroupPropertiesForFlags (Ljava/lang/String;Z)V + public fun resetPersonPropertiesForFlags (Z)V public fun screen (Ljava/lang/String;Ljava/util/Map;)V + public fun setGroupPropertiesForFlags (Ljava/lang/String;Ljava/util/Map;Z)V + public fun setPersonPropertiesForFlags (Ljava/util/Map;Z)V public fun setup (Lcom/posthog/PostHogConfig;)V public fun startSession ()V public fun startSessionReplay (Z)V @@ -64,8 +68,12 @@ public final class com/posthog/PostHog$Companion : com/posthog/PostHogInterface public fun register (Ljava/lang/String;Ljava/lang/Object;)V public fun reloadFeatureFlags (Lcom/posthog/PostHogOnFeatureFlags;)V public fun reset ()V + public fun resetGroupPropertiesForFlags (Ljava/lang/String;Z)V + public fun resetPersonPropertiesForFlags (Z)V public final fun resetSharedInstance ()V public fun screen (Ljava/lang/String;Ljava/util/Map;)V + public fun setGroupPropertiesForFlags (Ljava/lang/String;Ljava/util/Map;Z)V + public fun setPersonPropertiesForFlags (Ljava/util/Map;Z)V public fun setup (Lcom/posthog/PostHogConfig;)V public fun startSession ()V public fun startSessionReplay (Z)V @@ -86,8 +94,8 @@ public class com/posthog/PostHogConfig { public static final field DEFAULT_HOST Ljava/lang/String; 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;ZZZIZLjava/util/List;ZIIIILcom/posthog/PostHogEncryption;Lcom/posthog/PostHogOnFeatureFlags;ZLcom/posthog/PostHogPropertiesSanitizer;Lkotlin/jvm/functions/Function1;ZLcom/posthog/PersonProfiles;ZLjava/net/Proxy;Lcom/posthog/surveys/PostHogSurveysConfig;Lkotlin/jvm/functions/Function3;Lkotlin/jvm/functions/Function5;Lcom/posthog/errortracking/PostHogErrorTrackingConfig;)V - public synthetic fun (Ljava/lang/String;Ljava/lang/String;ZZZIZLjava/util/List;ZIIIILcom/posthog/PostHogEncryption;Lcom/posthog/PostHogOnFeatureFlags;ZLcom/posthog/PostHogPropertiesSanitizer;Lkotlin/jvm/functions/Function1;ZLcom/posthog/PersonProfiles;ZLjava/net/Proxy;Lcom/posthog/surveys/PostHogSurveysConfig;Lkotlin/jvm/functions/Function3;Lkotlin/jvm/functions/Function5;Lcom/posthog/errortracking/PostHogErrorTrackingConfig;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public fun (Ljava/lang/String;Ljava/lang/String;ZZZIZLjava/util/List;ZZIIIILcom/posthog/PostHogEncryption;Lcom/posthog/PostHogOnFeatureFlags;ZLcom/posthog/PostHogPropertiesSanitizer;Lkotlin/jvm/functions/Function1;ZLcom/posthog/PersonProfiles;ZLjava/net/Proxy;Lcom/posthog/surveys/PostHogSurveysConfig;Lkotlin/jvm/functions/Function4;Lkotlin/jvm/functions/Function5;Lcom/posthog/errortracking/PostHogErrorTrackingConfig;)V + public synthetic fun (Ljava/lang/String;Ljava/lang/String;ZZZIZLjava/util/List;ZZIIIILcom/posthog/PostHogEncryption;Lcom/posthog/PostHogOnFeatureFlags;ZLcom/posthog/PostHogPropertiesSanitizer;Lkotlin/jvm/functions/Function1;ZLcom/posthog/PersonProfiles;ZLjava/net/Proxy;Lcom/posthog/surveys/PostHogSurveysConfig;Lkotlin/jvm/functions/Function4;Lkotlin/jvm/functions/Function5;Lcom/posthog/errortracking/PostHogErrorTrackingConfig;ILkotlin/jvm/internal/DefaultConstructorMarker;)V public final fun addBeforeSend (Lcom/posthog/PostHogBeforeSend;)V public final fun addIntegration (Lcom/posthog/PostHogIntegration;)V public final fun getApiKey ()Ljava/lang/String; @@ -118,7 +126,7 @@ public class com/posthog/PostHogConfig { public final fun getProxy ()Ljava/net/Proxy; public final fun getQueueProvider ()Lkotlin/jvm/functions/Function5; public final fun getRemoteConfig ()Z - public final fun getRemoteConfigProvider ()Lkotlin/jvm/functions/Function3; + public final fun getRemoteConfigProvider ()Lkotlin/jvm/functions/Function4; public final fun getReplayStoragePrefix ()Ljava/lang/String; public final fun getReuseAnonymousId ()Z public final fun getSdkName ()Ljava/lang/String; @@ -126,6 +134,7 @@ public class com/posthog/PostHogConfig { public final fun getSendFeatureFlagEvent ()Z public final fun getSerializer ()Lcom/posthog/internal/PostHogSerializer; public final fun getSessionReplay ()Z + public final fun getSetDefaultPersonProperties ()Z public final fun getSnapshotEndpoint ()Ljava/lang/String; public final fun getStoragePrefix ()Ljava/lang/String; public final fun getSurveys ()Z @@ -160,6 +169,7 @@ public class com/posthog/PostHogConfig { public final fun setSdkVersion (Ljava/lang/String;)V public final fun setSendFeatureFlagEvent (Z)V public final fun setSessionReplay (Z)V + public final fun setSetDefaultPersonProperties (Z)V public final fun setSnapshotEndpoint (Ljava/lang/String;)V public final fun setStoragePrefix (Ljava/lang/String;)V public final fun setSurveys (Z)V @@ -270,7 +280,11 @@ public abstract interface class com/posthog/PostHogInterface : com/posthog/PostH public abstract fun register (Ljava/lang/String;Ljava/lang/Object;)V public abstract fun reloadFeatureFlags (Lcom/posthog/PostHogOnFeatureFlags;)V public abstract fun reset ()V + public abstract fun resetGroupPropertiesForFlags (Ljava/lang/String;Z)V + public abstract fun resetPersonPropertiesForFlags (Z)V public abstract fun screen (Ljava/lang/String;Ljava/util/Map;)V + public abstract fun setGroupPropertiesForFlags (Ljava/lang/String;Ljava/util/Map;Z)V + public abstract fun setPersonPropertiesForFlags (Ljava/util/Map;Z)V public abstract fun startSession ()V public abstract fun startSessionReplay (Z)V public abstract fun stopSessionReplay ()V @@ -285,7 +299,11 @@ public final class com/posthog/PostHogInterface$DefaultImpls { public static synthetic fun group$default (Lcom/posthog/PostHogInterface;Ljava/lang/String;Ljava/lang/String;Ljava/util/Map;ILjava/lang/Object;)V public static synthetic fun isFeatureEnabled$default (Lcom/posthog/PostHogInterface;Ljava/lang/String;ZILjava/lang/Object;)Z public static synthetic fun reloadFeatureFlags$default (Lcom/posthog/PostHogInterface;Lcom/posthog/PostHogOnFeatureFlags;ILjava/lang/Object;)V + public static synthetic fun resetGroupPropertiesForFlags$default (Lcom/posthog/PostHogInterface;Ljava/lang/String;ZILjava/lang/Object;)V + public static synthetic fun resetPersonPropertiesForFlags$default (Lcom/posthog/PostHogInterface;ZILjava/lang/Object;)V public static synthetic fun screen$default (Lcom/posthog/PostHogInterface;Ljava/lang/String;Ljava/util/Map;ILjava/lang/Object;)V + public static synthetic fun setGroupPropertiesForFlags$default (Lcom/posthog/PostHogInterface;Ljava/lang/String;Ljava/util/Map;ZILjava/lang/Object;)V + public static synthetic fun setPersonPropertiesForFlags$default (Lcom/posthog/PostHogInterface;Ljava/util/Map;ZILjava/lang/Object;)V public static synthetic fun startSessionReplay$default (Lcom/posthog/PostHogInterface;ZILjava/lang/Object;)V } @@ -483,6 +501,10 @@ public abstract interface class com/posthog/internal/PostHogContext { public abstract fun getStaticContext ()Ljava/util/Map; } +public final class com/posthog/internal/PostHogContextKt { + public static final fun personPropertiesContext (Lcom/posthog/internal/PostHogContext;)Ljava/util/Map; +} + public abstract interface class com/posthog/internal/PostHogDateProvider { public abstract fun addSecondsToCurrentDate (I)Ljava/util/Date; public abstract fun currentDate ()Ljava/util/Date; @@ -604,7 +626,8 @@ public abstract interface class com/posthog/internal/PostHogQueueInterface { } public final class com/posthog/internal/PostHogRemoteConfig : com/posthog/internal/PostHogFeatureFlagsInterface { - public fun (Lcom/posthog/PostHogConfig;Lcom/posthog/internal/PostHogApi;Ljava/util/concurrent/ExecutorService;)V + 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 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; @@ -618,7 +641,12 @@ public final class com/posthog/internal/PostHogRemoteConfig : com/posthog/intern public static synthetic fun loadFeatureFlags$default (Lcom/posthog/internal/PostHogRemoteConfig;Ljava/lang/String;Ljava/lang/String;Ljava/util/Map;Lcom/posthog/PostHogOnFeatureFlags;Lcom/posthog/PostHogOnFeatureFlags;ILjava/lang/Object;)V public final fun loadRemoteConfig (Ljava/lang/String;Ljava/lang/String;Ljava/util/Map;Lcom/posthog/PostHogOnFeatureFlags;Lcom/posthog/PostHogOnFeatureFlags;)V public static synthetic fun loadRemoteConfig$default (Lcom/posthog/internal/PostHogRemoteConfig;Ljava/lang/String;Ljava/lang/String;Ljava/util/Map;Lcom/posthog/PostHogOnFeatureFlags;Lcom/posthog/PostHogOnFeatureFlags;ILjava/lang/Object;)V + public final fun resetGroupPropertiesForFlags (Ljava/lang/String;)V + public static synthetic fun resetGroupPropertiesForFlags$default (Lcom/posthog/internal/PostHogRemoteConfig;Ljava/lang/String;ILjava/lang/Object;)V + public final fun resetPersonPropertiesForFlags ()V + public final fun setGroupPropertiesForFlags (Ljava/lang/String;Ljava/util/Map;)V public final fun setOnRemoteConfigLoaded (Lkotlin/jvm/functions/Function0;)V + public final fun setPersonPropertiesForFlags (Ljava/util/Map;)V } public class com/posthog/internal/PostHogRemoteConfigResponse { diff --git a/posthog/src/main/java/com/posthog/PostHog.kt b/posthog/src/main/java/com/posthog/PostHog.kt index 6020e7ed..87cb9a7c 100644 --- a/posthog/src/main/java/com/posthog/PostHog.kt +++ b/posthog/src/main/java/com/posthog/PostHog.kt @@ -21,6 +21,7 @@ import com.posthog.internal.PostHogSerializer import com.posthog.internal.PostHogSessionManager import com.posthog.internal.PostHogThreadFactory import com.posthog.internal.errortracking.ThrowableCoercer +import com.posthog.internal.personPropertiesContext import com.posthog.internal.replay.PostHogSessionReplayHandler import com.posthog.internal.surveys.PostHogSurveysHandler import com.posthog.vendor.uuid.TimeBasedEpochGenerator @@ -96,7 +97,10 @@ public class PostHog private constructor( val api = PostHogApi(config) val queue = config.queueProvider(config, api, PostHogApiEndpoint.BATCH, config.storagePrefix, queueExecutor) val replayQueue = config.queueProvider(config, api, PostHogApiEndpoint.SNAPSHOT, config.replayStoragePrefix, replayExecutor) - val featureFlags = config.remoteConfigProvider(config, api, remoteConfigExecutor) + val featureFlags = + config.remoteConfigProvider(config, api, remoteConfigExecutor) { + getDefaultPersonProperties() + } // no need to lock optOut here since the setup is locked already val optOut = @@ -423,6 +427,9 @@ public class PostHog private constructor( requirePersonProcessing("capture", ignoreMessage = true) } + // Automatically set person properties for feature flags during capture event + setPersonPropertiesForFlagsIfNeeded(userProperties, userPropertiesSetOnce) + if (newDistinctId.isBlank()) { config?.logger?.log("capture call not allowed, distinctId is invalid: $newDistinctId.") return @@ -579,6 +586,45 @@ public class PostHog private constructor( capture(PostHogEventName.CREATE_ALIAS.event, properties = props) } + /** + * Returns fresh default device and app properties for feature flag evaluation. + */ + private fun getDefaultPersonProperties(): Map { + if (!isEnabled()) return emptyMap() + if (config?.setDefaultPersonProperties != true) return emptyMap() + + return config?.context?.personPropertiesContext() ?: emptyMap() + } + + private fun setPersonPropertiesForFlagsIfNeeded( + userProperties: Map?, + userPropertiesSetOnce: Map? = null, + ) { + if (!hasPersonProcessing()) return + if (userProperties.isNullOrEmpty() && userPropertiesSetOnce.isNullOrEmpty()) return + + val allProperties = mutableMapOf() + userPropertiesSetOnce?.let { + allProperties.putAll(userPropertiesSetOnce) + } + userProperties?.let { + // User properties override setOnce properties + allProperties.putAll(userProperties) + } + + remoteConfig?.setPersonPropertiesForFlags(allProperties) + } + + private fun setGroupPropertiesForFlagsIfNeeded( + type: String, + groupProperties: Map?, + ) { + if (!hasPersonProcessing()) return + if (groupProperties.isNullOrEmpty()) return + + remoteConfig?.setGroupPropertiesForFlags(type, groupProperties) + } + public override fun identify( distinctId: String, userProperties: Map?, @@ -636,6 +682,9 @@ public class PostHog private constructor( } this.distinctId = distinctId + // Automatically set person properties for feature flags during identify() call + setPersonPropertiesForFlagsIfNeeded(userProperties, userPropertiesSetOnce) + // only because of testing in isolation, this flag is always enabled if (reloadFeatureFlags) { reloadFeatureFlags(config?.onFeatureFlags) @@ -745,6 +794,9 @@ public class PostHog private constructor( super.groupStateless(this.distinctId, type, key, groupProperties) + // Automatically set group properties for feature flags + setGroupPropertiesForFlagsIfNeeded(type, groupProperties) + // only because of testing in isolation, this flag is always enabled if (reloadFeatureFlags && reloadFeatureFlagsIfNewGroup) { reloadFeatureFlags(config?.onFeatureFlags) @@ -887,6 +939,63 @@ public class PostHog private constructor( replayQueue?.flush() } + public override fun setPersonPropertiesForFlags( + userProperties: Map, + reloadFeatureFlags: Boolean, + ) { + if (!isEnabled()) return + if (!hasPersonProcessing()) return + if (userProperties.isEmpty()) return + + remoteConfig?.setPersonPropertiesForFlags(userProperties) + + if (reloadFeatureFlags && this.reloadFeatureFlags) { + this.reloadFeatureFlags() + } + } + + public override fun resetPersonPropertiesForFlags(reloadFeatureFlags: Boolean) { + if (!isEnabled()) return + if (!hasPersonProcessing()) return + + remoteConfig?.resetPersonPropertiesForFlags() + + if (reloadFeatureFlags && this.reloadFeatureFlags) { + this.reloadFeatureFlags() + } + } + + public override fun setGroupPropertiesForFlags( + type: String, + groupProperties: Map, + reloadFeatureFlags: Boolean, + ) { + if (!isEnabled()) return + if (!hasPersonProcessing()) return + + if (groupProperties.isEmpty()) return + + remoteConfig?.setGroupPropertiesForFlags(type, groupProperties) + + if (reloadFeatureFlags && this.reloadFeatureFlags) { + this.reloadFeatureFlags() + } + } + + public override fun resetGroupPropertiesForFlags( + type: String?, + reloadFeatureFlags: Boolean, + ) { + if (!isEnabled()) return + if (!hasPersonProcessing()) return + + remoteConfig?.resetGroupPropertiesForFlags(type) + + if (reloadFeatureFlags && this.reloadFeatureFlags) { + this.reloadFeatureFlags() + } + } + public override fun reset() { if (!isEnabled()) { return @@ -1168,6 +1277,32 @@ public class PostHog private constructor( shared.flush() } + public override fun setPersonPropertiesForFlags( + userProperties: Map, + reloadFeatureFlags: Boolean, + ) { + shared.setPersonPropertiesForFlags(userProperties, reloadFeatureFlags) + } + + public override fun resetPersonPropertiesForFlags(reloadFeatureFlags: Boolean) { + shared.resetPersonPropertiesForFlags(reloadFeatureFlags) + } + + public override fun setGroupPropertiesForFlags( + type: String, + groupProperties: Map, + reloadFeatureFlags: Boolean, + ) { + shared.setGroupPropertiesForFlags(type, groupProperties, reloadFeatureFlags) + } + + public override fun resetGroupPropertiesForFlags( + type: String?, + reloadFeatureFlags: Boolean, + ) { + shared.resetGroupPropertiesForFlags(type, reloadFeatureFlags) + } + public override fun reset() { shared.reset() } diff --git a/posthog/src/main/java/com/posthog/PostHogConfig.kt b/posthog/src/main/java/com/posthog/PostHogConfig.kt index 8aed5a07..71cad02f 100644 --- a/posthog/src/main/java/com/posthog/PostHogConfig.kt +++ b/posthog/src/main/java/com/posthog/PostHogConfig.kt @@ -74,6 +74,25 @@ public open class PostHogConfig( * Defaults to null (evaluate all flags) */ public var evaluationEnvironments: List? = null, + /** + * Automatically set common device and app properties as person properties for feature flag evaluation. + * + * When enabled, the SDK will automatically set the following person properties: + * - $app_version: App version from package info + * - $app_build: App build number from package info + * - $app_namespace: App namespace from package info + * - $os_name: Operating system name (Android) + * - $os_version: Operating system version + * - $device_type: Device type (Mobile, Tablet, TV, etc.) + * - $lib: The identifier of the SDK + * - $lib_version: The version of the SDK + * + * This helps ensure feature flags that rely on these properties work correctly + * without waiting for server-side processing of identify() calls. + * + * Default: true + */ + public var setDefaultPersonProperties: Boolean = true, /** * Preload PostHog remote config automatically * Defaults to true @@ -192,8 +211,20 @@ public open class PostHogConfig( /** * Factory to instantiate a custom [com.posthog.internal.PostHogRemoteConfigInterface] implementation. */ - public val remoteConfigProvider: (PostHogConfig, PostHogApi, ExecutorService) -> PostHogFeatureFlagsInterface = - { config, api, executor -> PostHogRemoteConfig(config, api, executor) }, + public val remoteConfigProvider: ( + PostHogConfig, + PostHogApi, + ExecutorService, + (() -> Map)?, + ) -> PostHogFeatureFlagsInterface = + { + config, + api, + executor, + getDefaultPersonProperties, + -> + PostHogRemoteConfig(config, api, executor, getDefaultPersonProperties ?: { emptyMap() }) + }, /** * Factory to instantiate a custom queue implementation. */ diff --git a/posthog/src/main/java/com/posthog/PostHogInterface.kt b/posthog/src/main/java/com/posthog/PostHogInterface.kt index c9922df1..0367c35f 100644 --- a/posthog/src/main/java/com/posthog/PostHogInterface.kt +++ b/posthog/src/main/java/com/posthog/PostHogInterface.kt @@ -183,6 +183,47 @@ public interface PostHogInterface : PostHogCoreInterface { */ public fun getSessionId(): UUID? + /** + * Sets person properties that will be included in feature flag evaluation requests. + * + * @param properties Dictionary of person properties to include in flag evaluation + * @param reloadFeatureFlags Whether to automatically reload feature flags after setting properties + */ + public fun setPersonPropertiesForFlags( + userProperties: Map, + reloadFeatureFlags: Boolean = true, + ) + + /** + * Resets all person properties that were set for feature flag evaluation. + * @param reloadFeatureFlags Whether to automatically reload feature flags after resetting properties + */ + public fun resetPersonPropertiesForFlags(reloadFeatureFlags: Boolean = true) + + /** + * Sets properties for a specific group type to include when evaluating feature flags. + * + * @param type The group type identifier (e.g., "organization", "team") + * @param properties Dictionary of properties to set for this group type + * @param reloadFeatureFlags Whether to automatically reload feature flags after setting properties + */ + public fun setGroupPropertiesForFlags( + type: String, + groupProperties: Map, + reloadFeatureFlags: Boolean = true, + ) + + /** + * Clears group properties for feature flag evaluation. + * + * @param type Optional group type to clear. If null, clears all group properties. + * @param reloadFeatureFlags Whether to automatically reload feature flags after resetting properties + */ + public fun resetGroupPropertiesForFlags( + type: String? = null, + reloadFeatureFlags: Boolean = true, + ) + @PostHogInternal public fun getConfig(): T? } diff --git a/posthog/src/main/java/com/posthog/PostHogStateless.kt b/posthog/src/main/java/com/posthog/PostHogStateless.kt index d70108cf..72f5a92b 100644 --- a/posthog/src/main/java/com/posthog/PostHogStateless.kt +++ b/posthog/src/main/java/com/posthog/PostHogStateless.kt @@ -64,7 +64,7 @@ public open class PostHogStateless protected constructor( config.storagePrefix, queueExecutor, ) - val remoteConfig = config.remoteConfigProvider(config, api, featureFlagsExecutor) + val remoteConfig = config.remoteConfigProvider(config, api, featureFlagsExecutor, null) // no need to lock optOut here since the setup is locked already val optOut = diff --git a/posthog/src/main/java/com/posthog/internal/PostHogContext.kt b/posthog/src/main/java/com/posthog/internal/PostHogContext.kt index 7512f920..f0377d21 100644 --- a/posthog/src/main/java/com/posthog/internal/PostHogContext.kt +++ b/posthog/src/main/java/com/posthog/internal/PostHogContext.kt @@ -14,3 +14,32 @@ public interface PostHogContext { public fun getSdkInfo(): Map } + +/** + * Returns person properties context by extracting relevant properties from static context. + * This centralizes the logic for determining which properties should be used as person properties. + */ +@PostHogInternal +public fun PostHogContext.personPropertiesContext(): Map { + val sdkInfo = getSdkInfo() + val staticCtx = getStaticContext() + val personProperties = mutableMapOf() + + // App information + staticCtx["\$app_version"]?.let { personProperties["\$app_version"] = it } + staticCtx["\$app_build"]?.let { personProperties["\$app_build"] = it } + staticCtx["\$app_namespace"]?.let { personProperties["\$app_namespace"] = it } + + // Operating system information + staticCtx["\$os_name"]?.let { personProperties["\$os_name"] = it } + staticCtx["\$os_version"]?.let { personProperties["\$os_version"] = it } + + // Device information + staticCtx["\$device_type"]?.let { personProperties["\$device_type"] = it } + + // SDK information + sdkInfo["\$lib"]?.let { personProperties["\$lib"] = it } + sdkInfo["\$lib_version"]?.let { personProperties["\$lib_version"] = it } + + return personProperties +} diff --git a/posthog/src/main/java/com/posthog/internal/PostHogFlagsRequest.kt b/posthog/src/main/java/com/posthog/internal/PostHogFlagsRequest.kt index c66f41f0..5a4daf0c 100644 --- a/posthog/src/main/java/com/posthog/internal/PostHogFlagsRequest.kt +++ b/posthog/src/main/java/com/posthog/internal/PostHogFlagsRequest.kt @@ -19,13 +19,13 @@ internal class PostHogFlagsRequest( this["\$anon_distinct_id"] = anonymousId } if (groups?.isNotEmpty() == true) { - this["\$groups"] = groups + this["groups"] = groups } if (personProperties?.isNotEmpty() == true) { - this["\$properties"] = personProperties + this["person_properties"] = personProperties } if (groupProperties?.isNotEmpty() == true) { - this["\$group_properties"] = groupProperties + this["group_properties"] = groupProperties } if (evaluationEnvironments?.isNotEmpty() == true) { this["evaluation_environments"] = evaluationEnvironments diff --git a/posthog/src/main/java/com/posthog/internal/PostHogPreferences.kt b/posthog/src/main/java/com/posthog/internal/PostHogPreferences.kt index 3dc794a5..4f0737bb 100644 --- a/posthog/src/main/java/com/posthog/internal/PostHogPreferences.kt +++ b/posthog/src/main/java/com/posthog/internal/PostHogPreferences.kt @@ -37,6 +37,8 @@ public interface PostHogPreferences { internal const val FEATURE_FLAG_REQUEST_ID = "feature_flag_request_id" internal const val SESSION_REPLAY = "sessionReplay" internal const val SURVEYS = "surveys" + internal const val PERSON_PROPERTIES_FOR_FLAGS = "personPropertiesForFlags" + internal const val GROUP_PROPERTIES_FOR_FLAGS = "groupPropertiesForFlags" public const val SURVEY_SEEN: String = "surveySeen" public const val VERSION: String = "version" public const val BUILD: String = "build" @@ -60,6 +62,8 @@ public interface PostHogPreferences { STRINGIFIED_KEYS, FEATURE_FLAG_REQUEST_ID, 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 9c9eac23..79153c94 100644 --- a/posthog/src/main/java/com/posthog/internal/PostHogRemoteConfig.kt +++ b/posthog/src/main/java/com/posthog/internal/PostHogRemoteConfig.kt @@ -18,12 +18,14 @@ import java.util.concurrent.atomic.AtomicBoolean * @property config the Config * @property api the API * @property executor the Executor + * @property getDefaultPersonProperties the lambda to get default person properties */ @PostHogInternal public class PostHogRemoteConfig( private val config: PostHogConfig, private val api: PostHogApi, private val executor: ExecutorService, + private val getDefaultPersonProperties: () -> Map = { emptyMap() }, ) : PostHogFeatureFlagsInterface { private var isLoadingFeatureFlags = AtomicBoolean(false) private var isLoadingRemoteConfig = AtomicBoolean(false) @@ -31,6 +33,12 @@ public class PostHogRemoteConfig( private val featureFlagsLock = Any() private val remoteConfigLock = Any() + private val personPropertiesForFlagsLock = Any() + private var personPropertiesForFlags: MutableMap = mutableMapOf() + + private val groupPropertiesForFlagsLock = Any() + private var groupPropertiesForFlags: MutableMap> = mutableMapOf() + private var featureFlags: Map? = null private var featureFlagPayloads: Map? = null @@ -60,6 +68,7 @@ public class PostHogRemoteConfig( init { preloadSessionReplayFlag() preloadSurveys() + loadCachedPropertiesForFlags() } private fun isRecordingActive( @@ -333,7 +342,14 @@ public class PostHogRemoteConfig( } try { - val response = api.flags(distinctId, anonymousId = anonymousId, groups) + val response = + api.flags( + distinctId, + anonymousId = anonymousId, + groups = groups, + personProperties = getPersonPropertiesForFlags(), + groupProperties = getGroupPropertiesForFlags(), + ) response?.let { synchronized(featureFlagsLock) { @@ -638,6 +654,103 @@ public class PostHogRemoteConfig( } } + public fun setPersonPropertiesForFlags(userProperties: Map) { + synchronized(personPropertiesForFlagsLock) { + personPropertiesForFlags.putAll(userProperties) + config.cachePreferences?.setValue( + PostHogPreferences.PERSON_PROPERTIES_FOR_FLAGS, + personPropertiesForFlags, + ) + } + } + + public fun resetPersonPropertiesForFlags() { + synchronized(personPropertiesForFlagsLock) { + personPropertiesForFlags.clear() + config.cachePreferences?.remove(PostHogPreferences.PERSON_PROPERTIES_FOR_FLAGS) + } + } + + public fun setGroupPropertiesForFlags( + type: String, + groupProperties: Map, + ) { + synchronized(groupPropertiesForFlagsLock) { + val existing = groupPropertiesForFlags.getOrPut(type) { mutableMapOf() } + existing.putAll(groupProperties) + config.cachePreferences?.setValue( + PostHogPreferences.GROUP_PROPERTIES_FOR_FLAGS, + groupPropertiesForFlags, + ) + } + } + + public fun resetGroupPropertiesForFlags(type: String? = null) { + synchronized(groupPropertiesForFlagsLock) { + if (type != null) { + groupPropertiesForFlags.remove(type) + config.cachePreferences?.setValue( + PostHogPreferences.GROUP_PROPERTIES_FOR_FLAGS, + groupPropertiesForFlags, + ) + } else { + groupPropertiesForFlags.clear() + config.cachePreferences?.remove(PostHogPreferences.GROUP_PROPERTIES_FOR_FLAGS) + } + } + } + + private fun getPersonPropertiesForFlags(): Map { + synchronized(personPropertiesForFlagsLock) { + val properties = mutableMapOf() + + // Always include fresh default properties if enabled + if (config.setDefaultPersonProperties) { + val defaultProperties = getDefaultPersonProperties() + properties.putAll(defaultProperties) + } + + // User-set properties override default properties + properties.putAll(personPropertiesForFlags) + + return properties + } + } + + private fun getGroupPropertiesForFlags(): Map> { + synchronized(groupPropertiesForFlagsLock) { + return groupPropertiesForFlags.toMap() + } + } + + private fun loadCachedPropertiesForFlags() { + synchronized(personPropertiesForFlagsLock) { + @Suppress("UNCHECKED_CAST") + val cachedPersonProperties = + config.cachePreferences?.getValue( + PostHogPreferences.PERSON_PROPERTIES_FOR_FLAGS, + ) as? Map + + cachedPersonProperties?.let { + personPropertiesForFlags.putAll(it) + } + } + + synchronized(groupPropertiesForFlagsLock) { + @Suppress("UNCHECKED_CAST") + val cachedGroupProperties = + config.cachePreferences?.getValue( + PostHogPreferences.GROUP_PROPERTIES_FOR_FLAGS, + ) as? Map> + + cachedGroupProperties?.let { + it.forEach { (key, cachedValue) -> + groupPropertiesForFlags[key] = cachedValue.toMutableMap() + } + } + } + } + override fun clear() { synchronized(featureFlagsLock) { sessionReplayFlagActive = false @@ -650,6 +763,10 @@ public class PostHogRemoteConfig( clearSurveys() } + // Clear person and group properties for flags + resetPersonPropertiesForFlags() + resetGroupPropertiesForFlags() + config.cachePreferences?.remove(SESSION_REPLAY) } } diff --git a/posthog/src/test/java/com/posthog/PostHogTest.kt b/posthog/src/test/java/com/posthog/PostHogTest.kt index 37a1f844..185b3be9 100644 --- a/posthog/src/test/java/com/posthog/PostHogTest.kt +++ b/posthog/src/test/java/com/posthog/PostHogTest.kt @@ -1,6 +1,7 @@ package com.posthog import com.posthog.internal.PostHogBatchEvent +import com.posthog.internal.PostHogContext import com.posthog.internal.PostHogMemoryPreferences import com.posthog.internal.PostHogPreferences.Companion.GROUPS import com.posthog.internal.PostHogPreferences.Companion.SESSION_REPLAY @@ -55,6 +56,7 @@ internal class PostHogTest { propertiesSanitizer: PostHogPropertiesSanitizer? = null, beforeSend: PostHogBeforeSend? = null, evaluationEnvironments: List? = null, + context: PostHogContext? = null, ): PostHogInterface { config = PostHogConfig(API_KEY, host).apply { @@ -77,6 +79,7 @@ internal class PostHogTest { addBeforeSend(beforeSend) } this.errorTrackingConfig.inAppIncludes.add("com.posthog") + this.context = context } return PostHog.withInternal( config, @@ -1882,4 +1885,238 @@ internal class PostHogTest { sut.close() } + + @Test + fun `sets default person properties on SDK setup when enabled`() { + val http = + mockHttp( + response = + MockResponse() + .setBody(responseFlagsApi), + ) + val url = http.url("/") + + val sut = getSut(url.toString(), preloadFeatureFlags = true, context = TestPostHogContext()) + + remoteConfigExecutor.shutdownAndAwaitTermination() + + // Find the flags request + val flagsRequest = + (0 until http.requestCount).asSequence() + .map { http.takeRequest() } + .firstOrNull { it.path?.contains("/flags/") == true } + + assertNotNull(flagsRequest, "No flags request found") + + val content = flagsRequest.body.unGzip() + val requestBody = serializer.deserialize>(content.reader()) + + // Verify person_properties are present + val personProperties = requestBody["person_properties"] as? Map<*, *> + assertNotNull(personProperties, "Person properties not found in request") + + // Verify expected default properties are set + assertEquals( + mapOf( + "\$app_version" to "1.0.0", + "\$app_build" to "100", + "\$app_namespace" to "my-namespace", + "\$os_name" to "Android", + "\$os_version" to "13", + "\$device_type" to "Mobile", + "\$lib" to "posthog-android", + "\$lib_version" to "1.2.3", + ), + personProperties, + ) + + sut.close() + http.shutdown() + } + + @Test + fun `does not set default person properties when disabled`() { + val http = + mockHttp( + response = + MockResponse() + .setBody(responseFlagsApi), + ) + val url = http.url("/") + + // Create config with setDefaultPersonProperties = false + config = + PostHogConfig(API_KEY, url.toString()).apply { + this.storagePrefix = tmpDir.newFolder().absolutePath + this.setDefaultPersonProperties = false + this.preloadFeatureFlags = false + this.context = TestPostHogContext() + } + + val sut = + PostHog.withInternal( + config, + queueExecutor, + replayQueueExecutor, + remoteConfigExecutor, + cachedEventsExecutor, + true, + ) + + // Manually trigger flags reload + sut.reloadFeatureFlags() + + remoteConfigExecutor.shutdownAndAwaitTermination() + + // Find the flags request + val flagsRequest = + (0 until http.requestCount).asSequence() + .map { http.takeRequest() } + .firstOrNull { it.path?.contains("/flags/") == true } + + assertNotNull(flagsRequest, "No flags request found") + + val content = flagsRequest.body.unGzip() + val requestBody = serializer.deserialize>(content.reader()) + + // Verify person_properties are either absent or empty + val personProperties = requestBody["person_properties"] as? Map<*, *> + // Should be null or empty since setDefaultPersonProperties is false + assertTrue( + personProperties == null || personProperties.isEmpty(), + "Person properties should not be set when disabled", + ) + + sut.close() + http.shutdown() + } + + @Test + fun `automatically sets person properties from identify call`() { + val http = + mockHttp( + response = + MockResponse() + .setBody(responseFlagsApi), + ) + val url = http.url("/") + + val sut = + getSut(url.toString(), preloadFeatureFlags = false, context = TestPostHogContext()) + + val userProps = + mapOf( + "email" to "user@example.com", + "plan" to "premium", + "age" to 30, + ) + + val userPropsOnce = + mapOf( + "initial_signup_date" to "2024-01-01", + ) + + // Call identify with user properties + sut.identify( + "test_user_123", + userProperties = userProps, + userPropertiesSetOnce = userPropsOnce, + ) + + // Reload feature flags to trigger a flags request + sut.reloadFeatureFlags() + + remoteConfigExecutor.shutdownAndAwaitTermination() + + // Find the flags request + val flagsRequest = + (0 until http.requestCount).asSequence() + .map { http.takeRequest() } + .firstOrNull { it.path?.contains("/flags/") == true } + + assertNotNull(flagsRequest, "No flags request found") + + val content = flagsRequest.body.unGzip() + val requestBody = serializer.deserialize>(content.reader()) + + // Verify person_properties are present + val personProperties = requestBody["person_properties"] as? Map<*, *> + assertNotNull(personProperties, "Person properties not found in request") + + // Verify properties from identify() are included + assertEquals("user@example.com", personProperties["email"], "email should match") + assertEquals("premium", personProperties["plan"], "plan should match") + assertEquals(30, personProperties["age"], "age should match") + + // Verify userPropertiesSetOnce are also included + assertEquals( + "2024-01-01", + personProperties["initial_signup_date"], + "initial_signup_date should match", + ) + + // Verify default properties are also present (merged with user properties) + assertNotNull(personProperties["\$app_version"], "app_version should still be present") + assertNotNull(personProperties["\$os_name"], "os_name should still be present") + + sut.close() + http.shutdown() + } + + @Test + fun `person properties from identify override default properties`() { + val http = + mockHttp( + response = + MockResponse() + .setBody(responseFlagsApi), + ) + val url = http.url("/") + + val sut = + getSut(url.toString(), preloadFeatureFlags = false, context = TestPostHogContext()) + + // Set a property that conflicts with a default property + val userProps = + mapOf( + "\$device_type" to "custom_device", + "custom_prop" to "custom_value", + ) + + sut.identify("test_user", userProperties = userProps) + sut.reloadFeatureFlags() + + remoteConfigExecutor.shutdownAndAwaitTermination() + + val flagsRequest = + (0 until http.requestCount).asSequence() + .map { http.takeRequest() } + .firstOrNull { it.path?.contains("/flags/") == true } + + assertNotNull(flagsRequest, "No flags request found") + + val content = flagsRequest.body.unGzip() + val requestBody = serializer.deserialize>(content.reader()) + + val personProperties = requestBody["person_properties"] as? Map<*, *> + assertNotNull(personProperties, "Person properties not found in request") + + // User-provided property should override the default + assertEquals( + "custom_device", + personProperties["${'$'}device_type"], + "\$device_type should be overridden by user value", + ) + assertEquals( + "custom_value", + personProperties["custom_prop"], + "custom_prop should be present", + ) + + // Other default properties should still be present + assertNotNull(personProperties["\$app_version"], "app_version should still be present") + + sut.close() + http.shutdown() + } } diff --git a/posthog/src/test/java/com/posthog/Utils.kt b/posthog/src/test/java/com/posthog/Utils.kt index 294d9258..963bdfe2 100644 --- a/posthog/src/test/java/com/posthog/Utils.kt +++ b/posthog/src/test/java/com/posthog/Utils.kt @@ -1,6 +1,7 @@ package com.posthog import com.google.gson.internal.bind.util.ISO8601Utils +import com.posthog.internal.PostHogContext import okhttp3.mockwebserver.MockResponse import okhttp3.mockwebserver.MockWebServer import okio.Buffer @@ -87,3 +88,23 @@ public fun Buffer.unGzip(): String { source.buffer().use { bufferedSource -> bufferedSource.readUtf8() } } } + +public class TestPostHogContext : PostHogContext { + override fun getStaticContext(): Map = + mapOf( + "\$app_version" to "1.0.0", + "\$app_build" to "100", + "\$app_namespace" to "my-namespace", + "\$os_name" to "Android", + "\$os_version" to "13", + "\$device_type" to "Mobile", + ) + + override fun getDynamicContext(): Map = emptyMap() + + override fun getSdkInfo(): Map = + mapOf( + "\$lib" to "posthog-android", + "\$lib_version" to "1.2.3", + ) +} diff --git a/posthog/src/test/java/com/posthog/internal/PostHogFlagsRequestTest.kt b/posthog/src/test/java/com/posthog/internal/PostHogFlagsRequestTest.kt index 1b613527..fa7e9260 100644 --- a/posthog/src/test/java/com/posthog/internal/PostHogFlagsRequestTest.kt +++ b/posthog/src/test/java/com/posthog/internal/PostHogFlagsRequestTest.kt @@ -10,12 +10,24 @@ import kotlin.test.assertEquals internal class PostHogFlagsRequestTest { @Test fun `sets the flags request content`() { - val request = PostHogFlagsRequest(API_KEY, DISTINCT_ID, anonymousId = ANON_ID, groups) + val personProperties = mapOf("email" to "example@example.com") + val groupProperties = mapOf("org_123" to mapOf("size" to "large")) + val request = + PostHogFlagsRequest( + API_KEY, + DISTINCT_ID, + anonymousId = ANON_ID, + groups, + personProperties = personProperties, + groupProperties = groupProperties, + ) assertEquals(API_KEY, request["api_key"]) assertEquals(DISTINCT_ID, request["distinct_id"]) assertEquals(ANON_ID, request["\$anon_distinct_id"]) - assertEquals(groups, request["\$groups"]) + assertEquals(groups, request["groups"]) + assertEquals(personProperties, request["person_properties"]) + assertEquals(groupProperties, request["group_properties"]) } @Test diff --git a/posthog/src/test/java/com/posthog/internal/PostHogRemoteConfigTest.kt b/posthog/src/test/java/com/posthog/internal/PostHogRemoteConfigTest.kt index 98f7a4fb..255a789e 100644 --- a/posthog/src/test/java/com/posthog/internal/PostHogRemoteConfigTest.kt +++ b/posthog/src/test/java/com/posthog/internal/PostHogRemoteConfigTest.kt @@ -34,7 +34,7 @@ internal class PostHogRemoteConfigTest { cachePreferences = preferences } val api = PostHogApi(config!!) - return PostHogRemoteConfig(config!!, api, executor = executor) + return PostHogRemoteConfig(config!!, api, executor = executor) { emptyMap() } } @BeforeTest @@ -266,4 +266,122 @@ internal class PostHogRemoteConfigTest { fun `on feature flag callbacks are called after flag API call`() { testFlagsCallback("src/test/resources/json/basic-remote-config.json") } + + @Test + fun `setPersonPropertiesForFlags writes to preferences`() { + val http = mockHttp(response = MockResponse().setBody(responseFlagsApi)) + val url = http.url("/") + + val sut = getSut(host = url.toString()) + + val personProps = + mapOf( + "email" to "user@example.com", + "plan" to "premium", + "age" to 30, + ) + + sut.setPersonPropertiesForFlags(personProps) + + val cachedProps = preferences.getValue(PostHogPreferences.PERSON_PROPERTIES_FOR_FLAGS) as? Map<*, *> + assertEquals("user@example.com", cachedProps?.get("email")) + assertEquals("premium", cachedProps?.get("plan")) + assertEquals(30, cachedProps?.get("age")) + + sut.clear() + http.shutdown() + } + + @Test + fun `setGroupPropertiesForFlags writes to preferences`() { + val http = mockHttp(response = MockResponse().setBody(responseFlagsApi)) + val url = http.url("/") + + val sut = getSut(host = url.toString()) + + val orgProps = mapOf("plan" to "enterprise", "seats" to 50) + val teamProps = mapOf("name" to "Engineering", "size" to 10) + + sut.setGroupPropertiesForFlags("organization", orgProps) + sut.setGroupPropertiesForFlags("team", teamProps) + + val cachedProps = preferences.getValue(PostHogPreferences.GROUP_PROPERTIES_FOR_FLAGS) as? Map<*, *> + val cachedOrgProps = cachedProps?.get("organization") as? Map<*, *> + val cachedTeamProps = cachedProps?.get("team") as? Map<*, *> + + assertEquals("enterprise", cachedOrgProps?.get("plan")) + assertEquals(50, cachedOrgProps?.get("seats")) + assertEquals("Engineering", cachedTeamProps?.get("name")) + assertEquals(10, cachedTeamProps?.get("size")) + + sut.clear() + http.shutdown() + } + + @Test + fun `resetPersonPropertiesForFlags removes from preferences`() { + val http = mockHttp(response = MockResponse().setBody(responseFlagsApi)) + val url = http.url("/") + + val sut = getSut(host = url.toString()) + + sut.setPersonPropertiesForFlags(mapOf("email" to "user@example.com")) + + var cachedProps = preferences.getValue(PostHogPreferences.PERSON_PROPERTIES_FOR_FLAGS) as? Map<*, *> + assertEquals("user@example.com", cachedProps?.get("email")) + + sut.resetPersonPropertiesForFlags() + + cachedProps = preferences.getValue(PostHogPreferences.PERSON_PROPERTIES_FOR_FLAGS) as? Map<*, *> + assertTrue(cachedProps == null, "Person properties should be removed from preferences") + + sut.clear() + http.shutdown() + } + + @Test + fun `resetGroupPropertiesForFlags removes all groups from preferences`() { + val http = mockHttp(response = MockResponse().setBody(responseFlagsApi)) + val url = http.url("/") + + val sut = getSut(host = url.toString()) + + sut.setGroupPropertiesForFlags("organization", mapOf("plan" to "enterprise")) + sut.setGroupPropertiesForFlags("team", mapOf("name" to "Engineering")) + + var cachedProps = preferences.getValue(PostHogPreferences.GROUP_PROPERTIES_FOR_FLAGS) as? Map<*, *> + assertTrue(cachedProps?.containsKey("organization") == true) + assertTrue(cachedProps?.containsKey("team") == true) + + sut.resetGroupPropertiesForFlags() + + cachedProps = preferences.getValue(PostHogPreferences.GROUP_PROPERTIES_FOR_FLAGS) as? Map<*, *> + assertTrue(cachedProps == null, "Group properties should be removed from preferences") + + sut.clear() + http.shutdown() + } + + @Test + fun `resetGroupPropertiesForFlags removes specific group from preferences`() { + val http = mockHttp(response = MockResponse().setBody(responseFlagsApi)) + val url = http.url("/") + + val sut = getSut(host = url.toString()) + + sut.setGroupPropertiesForFlags("organization", mapOf("plan" to "enterprise")) + sut.setGroupPropertiesForFlags("team", mapOf("name" to "Engineering")) + + sut.resetGroupPropertiesForFlags("organization") + + val cachedProps = preferences.getValue(PostHogPreferences.GROUP_PROPERTIES_FOR_FLAGS) as? Map<*, *> + assertFalse(cachedProps?.containsKey("organization") == true, "organization should be removed") + assertTrue(cachedProps?.containsKey("team") == true, "team should remain") + + val teamProps = cachedProps?.get("team") as? Map<*, *> + assertEquals("Engineering", teamProps?.get("name")) + + sut.clear() + http.shutdown() + } } diff --git a/posthog/src/test/java/com/posthog/internal/PostHogSerializerTest.kt b/posthog/src/test/java/com/posthog/internal/PostHogSerializerTest.kt index 5c7d5e42..5e1923c9 100644 --- a/posthog/src/test/java/com/posthog/internal/PostHogSerializerTest.kt +++ b/posthog/src/test/java/com/posthog/internal/PostHogSerializerTest.kt @@ -195,7 +195,9 @@ internal class PostHogSerializerTest { val expectedJson = """ { - "${'$'}properties": { + "api_key": "_6SG-F7I1vCuZ-HdJL3VZQqjBlaSb1_20hDPwqMNnGI", + "distinct_id": "test_user", + "person_properties": { "string_prop": "test_value", "int_prop": 42, "long_prop": 1234567890, @@ -209,9 +211,7 @@ internal class PostHogSerializerTest { "field1": "custom", "field2": 999 } - }, - "api_key": "_6SG-F7I1vCuZ-HdJL3VZQqjBlaSb1_20hDPwqMNnGI", - "distinct_id": "test_user" + } } """.replace(" ", "").replace("\n", "")