diff --git a/CHANGELOG.md b/CHANGELOG.md index e5c9234f..056bf4ce 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,7 @@ ## Next +- feat: Add a server-side stateless interface([#284](https://github.com/PostHog/posthog-android/pull/284)) + ## 3.21.3 - 2025-09-16 - fix: throttle mechanism wasn't thread safe ([#283](https://github.com/PostHog/posthog-android/pull/283)) @@ -30,16 +32,16 @@ ## 3.20.1 - 2025-07-28 -- fix: call PostHogOnFeatureFlags if remote config fails ([#270](https://github.com/PostHog/posthog-android/pull/270)) +- fix: call PostHogOnFeatureFlags if remote config fails ([#270](https://github.com/PostHog/posthog-android/pull/270)) ## 3.20.0 - 2025-07-23 - feat: add support for beforeSend function to edit or drop events ([#266](https://github.com/PostHog/posthog-android/pull/266)) - - Thanks @KopeikinaDarya + - Thanks @KopeikinaDarya ## 3.19.2 - 2025-07-10 -- fix: enable gzip for /flags endpoint ([#268](https://github.com/PostHog/posthog-android/pull/245)) and ([#268](https://github.com/PostHog/posthog-android/pull/264)) +- fix: enable gzip for /flags endpoint ([#268](https://github.com/PostHog/posthog-android/pull/245)) and ([#268](https://github.com/PostHog/posthog-android/pull/264)) ## 3.19.1 - 2025-06-16 @@ -54,8 +56,8 @@ ## 3.18.0 - 2025-06-12 - feat: add proxy to `PostHogConfig` ([#260](https://github.com/PostHog/posthog-android/issues/260)) - - Thanks @MamboBryan - + - Thanks @MamboBryan + ```kotlin val config = PostHogAndroidConfig("...") config.proxy = Proxy(Proxy.Type.HTTP, InetSocketAddress("proxy.example.com",8080)) @@ -71,7 +73,7 @@ config.proxy = Proxy(Proxy.Type.HTTP, InetSocketAddress("proxy.example.com",8080 ## 3.15.1 - 2025-05-27 -- fix: clear feature flags cache when flags were cleared up server side ([#246](https://github.com/PostHog/posthog-android/pull/246)) +- fix: clear feature flags cache when flags were cleared up server side ([#246](https://github.com/PostHog/posthog-android/pull/246)) ## 3.15.0 - 2025-05-14 @@ -129,7 +131,7 @@ config.proxy = Proxy(Proxy.Type.HTTP, InetSocketAddress("proxy.example.com",8080 ## 3.9.2 - 2024-11-12 -- fix: allow changing person properties after identify ([#205](https://github.com/PostHog/posthog-android/pull/205)) +- fix: allow changing person properties after identify ([#205](https://github.com/PostHog/posthog-android/pull/205)) ## 3.9.1 - 2024-11-11 @@ -233,7 +235,7 @@ config.proxy = Proxy(Proxy.Type.HTTP, InetSocketAddress("proxy.example.com",8080 ## 3.2.2 - 2024-05-21 -- chore: register to sdk console ([#131](https://github.com/PostHog/posthog-android/pull/131)) +- chore: register to sdk console ([#131](https://github.com/PostHog/posthog-android/pull/131)) ## 3.2.1 - 2024-05-08 diff --git a/posthog/api/posthog.api b/posthog/api/posthog.api index 8d7da37e..1d491d8f 100644 --- a/posthog/api/posthog.api +++ b/posthog/api/posthog.api @@ -6,13 +6,12 @@ public final class com/posthog/PersonProfiles : java/lang/Enum { public static fun values ()[Lcom/posthog/PersonProfiles; } -public final class com/posthog/PostHog : com/posthog/PostHogInterface { +public final class com/posthog/PostHog : com/posthog/PostHogStateless, com/posthog/PostHogInterface { public static final field Companion Lcom/posthog/PostHog$Companion; public synthetic fun (Ljava/util/concurrent/ExecutorService;Ljava/util/concurrent/ExecutorService;Ljava/util/concurrent/ExecutorService;Ljava/util/concurrent/ExecutorService;ZLkotlin/jvm/internal/DefaultConstructorMarker;)V public fun alias (Ljava/lang/String;)V public fun capture (Ljava/lang/String;Ljava/lang/String;Ljava/util/Map;Ljava/util/Map;Ljava/util/Map;Ljava/util/Map;)V public fun close ()V - public fun debug (Z)V public fun distinctId ()Ljava/lang/String; public fun endSession ()V public fun flush ()V @@ -160,6 +159,22 @@ public class com/posthog/PostHogConfig { public final class com/posthog/PostHogConfig$Companion { } +public abstract interface class com/posthog/PostHogCoreInterface { + public abstract fun close ()V + public abstract fun debug (Z)V + public abstract fun flush ()V + public abstract fun identify (Ljava/lang/String;Ljava/util/Map;Ljava/util/Map;)V + public abstract fun isOptOut ()Z + public abstract fun optIn ()V + public abstract fun optOut ()V + public abstract fun setup (Lcom/posthog/PostHogConfig;)V +} + +public final class com/posthog/PostHogCoreInterface$DefaultImpls { + public static synthetic fun debug$default (Lcom/posthog/PostHogCoreInterface;ZILjava/lang/Object;)V + public static synthetic fun identify$default (Lcom/posthog/PostHogCoreInterface;Ljava/lang/String;Ljava/util/Map;Ljava/util/Map;ILjava/lang/Object;)V +} + public abstract interface class com/posthog/PostHogEncryption { public abstract fun decrypt (Ljava/io/InputStream;)Ljava/io/InputStream; public abstract fun encrypt (Ljava/io/OutputStream;)Ljava/io/OutputStream; @@ -225,31 +240,23 @@ public final class com/posthog/PostHogIntegration$DefaultImpls { public static fun uninstall (Lcom/posthog/PostHogIntegration;)V } -public abstract interface class com/posthog/PostHogInterface { +public abstract interface class com/posthog/PostHogInterface : com/posthog/PostHogCoreInterface { public abstract fun alias (Ljava/lang/String;)V public abstract fun capture (Ljava/lang/String;Ljava/lang/String;Ljava/util/Map;Ljava/util/Map;Ljava/util/Map;Ljava/util/Map;)V - public abstract fun close ()V - public abstract fun debug (Z)V public abstract fun distinctId ()Ljava/lang/String; public abstract fun endSession ()V - public abstract fun flush ()V public abstract fun getConfig ()Lcom/posthog/PostHogConfig; public abstract fun getFeatureFlag (Ljava/lang/String;Ljava/lang/Object;)Ljava/lang/Object; public abstract fun getFeatureFlagPayload (Ljava/lang/String;Ljava/lang/Object;)Ljava/lang/Object; public abstract fun getSessionId ()Ljava/util/UUID; public abstract fun group (Ljava/lang/String;Ljava/lang/String;Ljava/util/Map;)V - public abstract fun identify (Ljava/lang/String;Ljava/util/Map;Ljava/util/Map;)V public abstract fun isFeatureEnabled (Ljava/lang/String;Z)Z - public abstract fun isOptOut ()Z public abstract fun isSessionActive ()Z public abstract fun isSessionReplayActive ()Z - public abstract fun optIn ()V - public abstract fun optOut ()V 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 screen (Ljava/lang/String;Ljava/util/Map;)V - public abstract fun setup (Lcom/posthog/PostHogConfig;)V public abstract fun startSession ()V public abstract fun startSessionReplay (Z)V public abstract fun stopSessionReplay ()V @@ -258,11 +265,9 @@ public abstract interface class com/posthog/PostHogInterface { public final class com/posthog/PostHogInterface$DefaultImpls { public static synthetic fun capture$default (Lcom/posthog/PostHogInterface;Ljava/lang/String;Ljava/lang/String;Ljava/util/Map;Ljava/util/Map;Ljava/util/Map;Ljava/util/Map;ILjava/lang/Object;)V - public static synthetic fun debug$default (Lcom/posthog/PostHogInterface;ZILjava/lang/Object;)V public static synthetic fun getFeatureFlag$default (Lcom/posthog/PostHogInterface;Ljava/lang/String;Ljava/lang/Object;ILjava/lang/Object;)Ljava/lang/Object; public static synthetic fun getFeatureFlagPayload$default (Lcom/posthog/PostHogInterface;Ljava/lang/String;Ljava/lang/Object;ILjava/lang/Object;)Ljava/lang/Object; 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 identify$default (Lcom/posthog/PostHogInterface;Ljava/lang/String;Ljava/util/Map;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 screen$default (Lcom/posthog/PostHogInterface;Ljava/lang/String;Ljava/util/Map;ILjava/lang/Object;)V @@ -289,6 +294,80 @@ public abstract interface class com/posthog/PostHogPropertiesSanitizer { public abstract fun sanitize (Ljava/util/Map;)Ljava/util/Map; } +public class com/posthog/PostHogStateless : com/posthog/PostHogStatelessInterface { + public static final field Companion Lcom/posthog/PostHogStateless$Companion; + protected field config Lcom/posthog/PostHogConfig; + protected fun ()V + protected fun (Ljava/util/concurrent/ExecutorService;Ljava/util/concurrent/ExecutorService;)V + public synthetic fun (Ljava/util/concurrent/ExecutorService;Ljava/util/concurrent/ExecutorService;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public fun aliasStateless (Ljava/lang/String;Ljava/lang/String;)V + protected final fun buildEvent (Ljava/lang/String;Ljava/lang/String;Ljava/util/Map;)Lcom/posthog/PostHogEvent; + public fun captureStateless (Ljava/lang/String;Ljava/lang/String;Ljava/util/Map;Ljava/util/Map;Ljava/util/Map;Ljava/util/Map;)V + public fun close ()V + public fun debug (Z)V + public fun flush ()V + protected fun getConfig ()Lcom/posthog/PostHogConfig; + protected final fun getEnabled ()Z + public fun getFeatureFlagPayloadStateless (Ljava/lang/String;Ljava/lang/String;Ljava/lang/Object;)Ljava/lang/Object; + public fun getFeatureFlagStateless (Ljava/lang/String;Ljava/lang/String;Ljava/lang/Object;)Ljava/lang/Object; + protected final fun getFeatureFlags ()Lcom/posthog/internal/PostHogFeatureFlagsInterface; + protected final fun getMemoryPreferences ()Lcom/posthog/internal/PostHogPreferences; + protected final fun getOptOutLock ()Ljava/lang/Object; + protected final fun getPreferences ()Lcom/posthog/internal/PostHogPreferences; + protected final fun getQueue ()Lcom/posthog/internal/PostHogQueueInterface; + protected final fun getSetupLock ()Ljava/lang/Object; + public fun groupStateless (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/util/Map;)V + public fun identify (Ljava/lang/String;Ljava/util/Map;Ljava/util/Map;)V + protected final fun isEnabled ()Z + public fun isFeatureEnabledStateless (Ljava/lang/String;Ljava/lang/String;Z)Z + public fun isOptOut ()Z + protected final fun mergeGroups (Ljava/util/Map;)Ljava/util/Map; + public fun optIn ()V + public fun optOut ()V + protected final fun setEnabled (Z)V + protected final fun setFeatureFlags (Lcom/posthog/internal/PostHogFeatureFlagsInterface;)V + protected final fun setMemoryPreferences (Lcom/posthog/internal/PostHogPreferences;)V + protected final fun setQueue (Lcom/posthog/internal/PostHogQueueInterface;)V + public fun setup (Lcom/posthog/PostHogConfig;)V +} + +public final class com/posthog/PostHogStateless$Companion : com/posthog/PostHogStatelessInterface { + public fun aliasStateless (Ljava/lang/String;Ljava/lang/String;)V + public fun captureStateless (Ljava/lang/String;Ljava/lang/String;Ljava/util/Map;Ljava/util/Map;Ljava/util/Map;Ljava/util/Map;)V + public fun close ()V + public fun debug (Z)V + public fun flush ()V + public fun getFeatureFlagPayloadStateless (Ljava/lang/String;Ljava/lang/String;Ljava/lang/Object;)Ljava/lang/Object; + public fun getFeatureFlagStateless (Ljava/lang/String;Ljava/lang/String;Ljava/lang/Object;)Ljava/lang/Object; + public fun groupStateless (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/util/Map;)V + public fun identify (Ljava/lang/String;Ljava/util/Map;Ljava/util/Map;)V + public fun isFeatureEnabledStateless (Ljava/lang/String;Ljava/lang/String;Z)Z + public fun isOptOut ()Z + public fun optIn ()V + public fun optOut ()V + public final fun overrideSharedInstance (Lcom/posthog/PostHogStatelessInterface;)V + public final fun resetSharedInstance ()V + public fun setup (Lcom/posthog/PostHogConfig;)V + public final fun with (Lcom/posthog/PostHogConfig;)Lcom/posthog/PostHogStatelessInterface; +} + +public abstract interface class com/posthog/PostHogStatelessInterface : com/posthog/PostHogCoreInterface { + public abstract fun aliasStateless (Ljava/lang/String;Ljava/lang/String;)V + public abstract fun captureStateless (Ljava/lang/String;Ljava/lang/String;Ljava/util/Map;Ljava/util/Map;Ljava/util/Map;Ljava/util/Map;)V + public abstract fun getFeatureFlagPayloadStateless (Ljava/lang/String;Ljava/lang/String;Ljava/lang/Object;)Ljava/lang/Object; + public abstract fun getFeatureFlagStateless (Ljava/lang/String;Ljava/lang/String;Ljava/lang/Object;)Ljava/lang/Object; + public abstract fun groupStateless (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/util/Map;)V + public abstract fun isFeatureEnabledStateless (Ljava/lang/String;Ljava/lang/String;Z)Z +} + +public final class com/posthog/PostHogStatelessInterface$DefaultImpls { + public static synthetic fun captureStateless$default (Lcom/posthog/PostHogStatelessInterface;Ljava/lang/String;Ljava/lang/String;Ljava/util/Map;Ljava/util/Map;Ljava/util/Map;Ljava/util/Map;ILjava/lang/Object;)V + public static synthetic fun getFeatureFlagPayloadStateless$default (Lcom/posthog/PostHogStatelessInterface;Ljava/lang/String;Ljava/lang/String;Ljava/lang/Object;ILjava/lang/Object;)Ljava/lang/Object; + public static synthetic fun getFeatureFlagStateless$default (Lcom/posthog/PostHogStatelessInterface;Ljava/lang/String;Ljava/lang/String;Ljava/lang/Object;ILjava/lang/Object;)Ljava/lang/Object; + public static synthetic fun groupStateless$default (Lcom/posthog/PostHogStatelessInterface;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/util/Map;ILjava/lang/Object;)V + public static synthetic fun isFeatureEnabledStateless$default (Lcom/posthog/PostHogStatelessInterface;Ljava/lang/String;Ljava/lang/String;ZILjava/lang/Object;)Z +} + public abstract interface annotation class com/posthog/PostHogVisibleForTesting : java/lang/annotation/Annotation { } @@ -313,6 +392,19 @@ public final class com/posthog/internal/PostHogDeviceDateProvider : com/posthog/ public fun nanoTime ()J } +public abstract interface class com/posthog/internal/PostHogFeatureFlagsInterface { + public abstract fun clear ()V + public abstract fun getFeatureFlag (Ljava/lang/String;Ljava/lang/Object;)Ljava/lang/Object; + public abstract fun getFeatureFlagPayload (Ljava/lang/String;Ljava/lang/Object;)Ljava/lang/Object; + public abstract fun getFeatureFlags ()Ljava/util/Map; + public abstract fun isSessionReplayFlagActive ()Z + public abstract fun loadRemoteConfig (Ljava/lang/String;Ljava/lang/String;Ljava/util/Map;Lcom/posthog/PostHogOnFeatureFlags;Lcom/posthog/PostHogOnFeatureFlags;)V +} + +public final class com/posthog/internal/PostHogFeatureFlagsInterface$DefaultImpls { + public static synthetic fun loadRemoteConfig$default (Lcom/posthog/internal/PostHogFeatureFlagsInterface;Ljava/lang/String;Ljava/lang/String;Ljava/util/Map;Lcom/posthog/PostHogOnFeatureFlags;Lcom/posthog/PostHogOnFeatureFlags;ILjava/lang/Object;)V +} + public abstract interface class com/posthog/internal/PostHogLogger { public abstract fun isEnabled ()Z public abstract fun log (Ljava/lang/String;)V @@ -375,6 +467,14 @@ public final class com/posthog/internal/PostHogPrintLogger : com/posthog/interna public fun log (Ljava/lang/String;)V } +public abstract interface class com/posthog/internal/PostHogQueueInterface { + public abstract fun add (Lcom/posthog/PostHogEvent;)V + public abstract fun clear ()V + public abstract fun flush ()V + public abstract fun start ()V + public abstract fun stop ()V +} + public final class com/posthog/internal/PostHogSerializer { public fun (Lcom/posthog/PostHogConfig;)V public final fun deserializeString (Ljava/lang/String;)Ljava/lang/Object; diff --git a/posthog/src/main/java/com/posthog/PostHog.kt b/posthog/src/main/java/com/posthog/PostHog.kt index 3e1bd9f5..f8ccfaee 100644 --- a/posthog/src/main/java/com/posthog/PostHog.kt +++ b/posthog/src/main/java/com/posthog/PostHog.kt @@ -2,9 +2,7 @@ package com.posthog import com.posthog.internal.PostHogApi import com.posthog.internal.PostHogApiEndpoint -import com.posthog.internal.PostHogMemoryPreferences import com.posthog.internal.PostHogNoOpLogger -import com.posthog.internal.PostHogPreferences import com.posthog.internal.PostHogPreferences.Companion.ALL_INTERNAL_KEYS import com.posthog.internal.PostHogPreferences.Companion.ANONYMOUS_ID import com.posthog.internal.PostHogPreferences.Companion.BUILD @@ -46,25 +44,16 @@ public class PostHog private constructor( PostHogThreadFactory("PostHogSendCachedEventsThread"), ), private val reloadFeatureFlags: Boolean = true, -) : PostHogInterface { - @Volatile - private var enabled = false - - private val setupLock = Any() - private val optOutLock = Any() +) : PostHogInterface, PostHogStateless() { private val anonymousLock = Any() private val identifiedLock = Any() - private val personProcessingLock = Any() private val groupsLock = Any() + private val personProcessingLock: Any = Any() private val featureFlagsCalledLock = Any() - private var config: PostHogConfig? = null - private var remoteConfig: PostHogRemoteConfig? = null - private var queue: PostHogQueue? = null private var replayQueue: PostHogQueue? = null - private var memoryPreferences = PostHogMemoryPreferences() private val featureFlagsCalled = mutableMapOf>() private var sessionReplayHandler: PostHogSessionReplayHandler? = null @@ -142,7 +131,7 @@ public class PostHog private constructor( legacyPreferences(config, config.serializer) - enabled = true + super.enabled = true queue.start() @@ -189,10 +178,6 @@ public class PostHog private constructor( } } - private fun getPreferences(): PostHogPreferences { - return config?.cachePreferences ?: memoryPreferences - } - private fun legacyPreferences( config: PostHogConfig, serializer: PostHogSerializer, @@ -307,27 +292,6 @@ public class PostHog private constructor( } } - private var isPersonProcessingEnabled: Boolean = false - get() { - synchronized(personProcessingLock) { - if (!isPersonProcessingLoaded) { - isPersonProcessingEnabled = getPreferences().getValue(PERSON_PROCESSING) as? Boolean - ?: false - isPersonProcessingLoaded = true - } - } - return field - } - set(value) { - synchronized(personProcessingLock) { - // only set if its different to avoid IO since this is called more often - if (field != value) { - field = value - getPreferences().setValue(PERSON_PROCESSING, value) - } - } - } - private fun buildProperties( distinctId: String, properties: Map?, @@ -388,7 +352,6 @@ public class PostHog private constructor( } props["\$is_identified"] = isIdentified - props["\$process_person_profile"] = hasPersonProcessing() } @@ -405,7 +368,7 @@ public class PostHog private constructor( // only Session replay needs $window_id if (!appendSharedProps && isSessionReplayActive) { // Session replay requires $window_id, so we set as the same as $session_id. - // the backend might fallback to $session_id if $window_id is not present next. + // the backend might fall back to $session_id if $window_id is not present next. props["\$window_id"] = tempSessionId } } @@ -425,24 +388,6 @@ public class PostHog private constructor( return props } - private fun mergeGroups(givenGroups: Map?): Map? { - val preferences = getPreferences() - - @Suppress("UNCHECKED_CAST") - val groups = preferences.getValue(GROUPS) as? Map - val newGroups = mutableMapOf() - - groups?.let { - newGroups.putAll(it) - } - - givenGroups?.let { - newGroups.putAll(it) - } - - return newGroups.ifEmpty { null } - } - public override fun capture( event: String, distinctId: String?, @@ -515,7 +460,14 @@ public class PostHog private constructor( if (isSnapshotEvent) { replayQueue?.add(postHogEvent) } else { - queue?.add(postHogEvent) + super.captureStateless( + postHogEvent.event, + newDistinctId, + postHogEvent.properties ?: emptyMap(), + userProperties, + userPropertiesSetOnce, + groups, + ) // Notify surveys integration about the event surveysHandler?.onEvent(event) } @@ -524,35 +476,6 @@ public class PostHog private constructor( } } - @Suppress("DEPRECATION") - private fun buildEvent( - event: String, - distinctId: String, - properties: MutableMap, - ): PostHogEvent? { - // sanitize the properties or fallback to the original properties - val sanitizedProperties = config?.propertiesSanitizer?.sanitize(properties)?.toMutableMap() ?: properties - val postHogEvent = PostHogEvent(event, distinctId, properties = sanitizedProperties) - var eventChecked: PostHogEvent? = postHogEvent - - val beforeSendList = config?.beforeSendList ?: emptyList() - - for (beforeSend in beforeSendList) { - try { - eventChecked = beforeSend.run(postHogEvent) - if (eventChecked == null) { - config?.logger?.log("Event $event was rejected in beforeSend function") - return null - } - } catch (e: Throwable) { - config?.logger?.log("Error in beforeSend function: $e") - return null - } - } - - return eventChecked - } - public override fun optIn() { if (!isEnabled()) { return @@ -719,6 +642,27 @@ public class PostHog private constructor( return true } + private var isPersonProcessingEnabled: Boolean = false + get() { + synchronized(personProcessingLock) { + if (!isPersonProcessingLoaded) { + isPersonProcessingEnabled = getPreferences().getValue(PERSON_PROCESSING) as? Boolean + ?: false + isPersonProcessingLoaded = true + } + } + return field + } + set(value) { + synchronized(personProcessingLock) { + // only set if it's different to avoid IO since this is called more often + if (field != value) { + field = value + getPreferences().setValue(PERSON_PROCESSING, value) + } + } + } + public override fun group( type: String, key: String, @@ -761,7 +705,7 @@ public class PostHog private constructor( preferences.setValue(GROUPS, newGroups) } - capture(PostHogEventName.GROUP_IDENTIFY.event, properties = props) + super.groupStateless(this.distinctId, type, key, groupProperties) // only because of testing in isolation, this flag is always enabled if (reloadFeatureFlags && reloadFeatureFlagsIfNewGroup) { @@ -901,7 +845,7 @@ public class PostHog private constructor( if (!isEnabled()) { return } - queue?.flush() + super.flush() replayQueue?.flush() } @@ -910,7 +854,7 @@ public class PostHog private constructor( return } - // only remove properties, preserve BUILD and VERSION keys in order to to fix over-sending + // only remove properties, preserve BUILD and VERSION keys in order to fix over-sending // of 'Application Installed' events and under-sending of 'Application Updated' events val except = mutableListOf(VERSION, BUILD) // preserve the ANONYMOUS_ID if reuseAnonymousId is enabled (for preserving a guest user @@ -938,13 +882,6 @@ public class PostHog private constructor( } } - private fun isEnabled(): Boolean { - if (!enabled) { - config?.logger?.log("Setup isn't called.") - } - return enabled - } - public override fun register( key: String, value: Any, @@ -973,13 +910,6 @@ public class PostHog private constructor( return distinctId } - override fun debug(enable: Boolean) { - if (!isEnabled()) { - return - } - config?.debug = enable - } - override fun startSession() { if (!isEnabled()) { return @@ -1004,6 +934,11 @@ public class PostHog private constructor( return PostHogSessionManager.isSessionActive() } + override fun getConfig(): T? { + @Suppress("UNCHECKED_CAST") + return super.config as? T + } + private fun isSessionReplayConfigEnabled(): Boolean { return config?.sessionReplay == true } @@ -1077,11 +1012,6 @@ public class PostHog private constructor( return PostHogSessionManager.getActiveSessionId() } - override fun getConfig(): T? { - @Suppress("UNCHECKED_CAST") - return config as? T - } - public companion object : PostHogInterface { private var shared: PostHogInterface = PostHog() private var defaultSharedInstance = shared @@ -1099,7 +1029,7 @@ public class PostHog private constructor( } /** - * Setup the SDK and returns an instance that you can hold and pass it around + * Set up the SDK and returns an instance that you can hold and pass it around * @param T the type of the Config * @property config the Config */ @@ -1249,6 +1179,10 @@ public class PostHog private constructor( shared.endSession() } + override fun getConfig(): T? { + return shared.getConfig() + } + override fun isSessionActive(): Boolean { return shared.isSessionActive() } @@ -1268,9 +1202,5 @@ public class PostHog private constructor( override fun getSessionId(): UUID? { return shared.getSessionId() } - - override fun getConfig(): T? { - return shared.getConfig() - } } } diff --git a/posthog/src/main/java/com/posthog/PostHogCoreInterface.kt b/posthog/src/main/java/com/posthog/PostHogCoreInterface.kt new file mode 100644 index 00000000..0e199fc7 --- /dev/null +++ b/posthog/src/main/java/com/posthog/PostHogCoreInterface.kt @@ -0,0 +1,52 @@ +package com.posthog + +public interface PostHogCoreInterface { + /** + * Setup the SDK + * @param config the SDK configuration + */ + public fun setup(config: T) + + /** + * Closes the SDK + */ + public fun close() + + /** + * Identifies the user + * Docs https://posthog.com/docs/product-analytics/identify + * @param distinctId the distinctId + * @param userProperties the user properties, set as a "$set" property, Docs https://posthog.com/docs/product-analytics/user-properties + * @param userPropertiesSetOnce the user properties to set only once, set as a "$set_once" property, Docs https://posthog.com/docs/product-analytics/user-properties + */ + public fun identify( + distinctId: String, + userProperties: Map? = null, + userPropertiesSetOnce: Map? = null, + ) + + /** + * Flushes all the events in the Queue right away + */ + public fun flush() + + /** + * Enables the SDK to capture events + */ + public fun optIn() + + /** + * Disables the SDK to capture events until you [optIn] again + */ + public fun optOut() + + /** + * Checks if the [optOut] mode is enabled or disabled + */ + public fun isOptOut(): Boolean + + /** + * Enables or disables the debug mode + */ + public fun debug(enable: Boolean = true) +} diff --git a/posthog/src/main/java/com/posthog/PostHogInterface.kt b/posthog/src/main/java/com/posthog/PostHogInterface.kt index 834436c3..c97d581d 100644 --- a/posthog/src/main/java/com/posthog/PostHogInterface.kt +++ b/posthog/src/main/java/com/posthog/PostHogInterface.kt @@ -5,18 +5,7 @@ import java.util.UUID /** * The PostHog SDK entry point */ -public interface PostHogInterface { - /** - * Setup the SDK - * @param config the SDK configuration - */ - public fun setup(config: T) - - /** - * Closes the SDK - */ - public fun close() - +public interface PostHogInterface : PostHogCoreInterface { /** * Captures events * @param distinctId the distinctId, the generated [distinctId] is used if not given @@ -34,19 +23,6 @@ public interface PostHogInterface { groups: Map? = null, ) - /** - * Identifies the user - * Docs https://posthog.com/docs/product-analytics/identify - * @param distinctId the distinctId - * @param userProperties the user properties, set as a "$set" property, Docs https://posthog.com/docs/product-analytics/user-properties - * @param userPropertiesSetOnce the user properties to set only once, set as a "$set_once" property, Docs https://posthog.com/docs/product-analytics/user-properties - */ - public fun identify( - distinctId: String, - userProperties: Map? = null, - userPropertiesSetOnce: Map? = null, - ) - /** * Reloads the feature flags * @param onFeatureFlags the callback to get notified once feature flags is ready to use @@ -86,27 +62,12 @@ public interface PostHogInterface { defaultValue: Any? = null, ): Any? - /** - * Flushes all the events in the Queue right away - */ - public fun flush() - /** * Resets all the cached properties including the [distinctId] * The SDK will behave as its been setup for the first time */ public fun reset() - /** - * Enables the SDK to capture events - */ - public fun optIn() - - /** - * Disables the SDK to capture events until you [optIn] again - */ - public fun optOut() - /** * Creates a group * Docs https://posthog.com/docs/product-analytics/group-analytics @@ -137,11 +98,6 @@ public interface PostHogInterface { */ public fun alias(alias: String) - /** - * Checks if the [optOut] mode is enabled or disabled - */ - public fun isOptOut(): Boolean - /** * Register a property to always be sent with all the following events until you call * [unregister] with the same key @@ -166,11 +122,6 @@ public interface PostHogInterface { */ public fun distinctId(): String - /** - * Enables or disables the debug mode - */ - public fun debug(enable: Boolean = true) - /** * Starts a session * The SDK will automatically start a session when you call [setup] diff --git a/posthog/src/main/java/com/posthog/PostHogStateless.kt b/posthog/src/main/java/com/posthog/PostHogStateless.kt new file mode 100644 index 00000000..95e54b2d --- /dev/null +++ b/posthog/src/main/java/com/posthog/PostHogStateless.kt @@ -0,0 +1,577 @@ +package com.posthog + +import com.posthog.internal.PostHogApi +import com.posthog.internal.PostHogApiEndpoint +import com.posthog.internal.PostHogFeatureFlagsInterface +import com.posthog.internal.PostHogMemoryPreferences +import com.posthog.internal.PostHogNoOpLogger +import com.posthog.internal.PostHogPreferences +import com.posthog.internal.PostHogPreferences.Companion.GROUPS +import com.posthog.internal.PostHogPreferences.Companion.OPT_OUT +import com.posthog.internal.PostHogPrintLogger +import com.posthog.internal.PostHogQueue +import com.posthog.internal.PostHogQueueInterface +import com.posthog.internal.PostHogRemoteConfig +import com.posthog.internal.PostHogThreadFactory +import java.util.concurrent.ExecutorService +import java.util.concurrent.Executors + +public open class PostHogStateless protected constructor( + private val queueExecutor: ExecutorService = + Executors.newSingleThreadScheduledExecutor( + PostHogThreadFactory("PostHogQueueThread"), + ), + private val featureFlagsExecutor: ExecutorService = + Executors.newSingleThreadScheduledExecutor( + PostHogThreadFactory("PostHogFeatureFlagsThread"), + ), +) : PostHogStatelessInterface { + @Volatile + protected var enabled: Boolean = false + + protected val setupLock: Any = Any() + protected val optOutLock: Any = Any() + + @JvmField + protected var config: PostHogConfig? = null + + protected var featureFlags: PostHogFeatureFlagsInterface? = null + protected var queue: PostHogQueueInterface? = null + protected var memoryPreferences: PostHogPreferences = PostHogMemoryPreferences() + + public override fun setup(config: T) { + synchronized(setupLock) { + try { + if (enabled) { + config.logger.log("Setup called despite already being setup!") + return + } + config.logger = if (config.logger is PostHogNoOpLogger) PostHogPrintLogger(config) else config.logger + + if (!apiKeys.add(config.apiKey)) { + config.logger.log("API Key: ${config.apiKey} already has a PostHog instance.") + } + + config.cachePreferences = memoryPreferences + val api = PostHogApi(config) + val queue = PostHogQueue(config, api, PostHogApiEndpoint.BATCH, config.storagePrefix, queueExecutor) + val remoteConfig = PostHogRemoteConfig(config, api, featureFlagsExecutor) + + // no need to lock optOut here since the setup is locked already + val optOut = + getPreferences().getValue( + OPT_OUT, + defaultValue = config.optOut, + ) as? Boolean + optOut?.let { + config.optOut = optOut + } + + this.config = config + this.queue = queue + this.featureFlags = remoteConfig + + enabled = true + + queue.start() + } catch (e: Throwable) { + config.logger.log("Setup failed: $e.") + } + } + } + + protected fun getPreferences(): PostHogPreferences { + return config?.cachePreferences ?: memoryPreferences + } + + public override fun close() { + synchronized(setupLock) { + try { + if (!isEnabled()) { + return + } + + enabled = false + + config?.let { config -> + apiKeys.remove(config.apiKey) + + config.integrations.forEach { + try { + it.uninstall() + } catch (e: Throwable) { + config.logger + .log("Integration ${it.javaClass.name} failed to uninstall: $e.") + } + } + } + + queue?.stop() + } catch (e: Throwable) { + config?.logger?.log("Close failed: $e.") + } + } + } + + private fun buildProperties( + properties: Map?, + userProperties: Map?, + userPropertiesSetOnce: Map?, + groups: Map?, + appendGroups: Boolean = true, + ): Map { + val props = mutableMapOf() + + val registeredPrefs = getPreferences().getAll() + if (registeredPrefs.isNotEmpty()) { + props.putAll(registeredPrefs) + } + + config?.context?.getStaticContext()?.let { + props.putAll(it) + } + + config?.context?.getDynamicContext()?.let { + props.putAll(it) + } + + if (config?.sendFeatureFlagEvent == true) { + featureFlags?.getFeatureFlags()?.let { + if (it.isNotEmpty()) { + val keys = mutableListOf() + for (entry in it.entries) { + props["\$feature/${entry.key}"] = entry.value + + // only add active feature flags + val active = entry.value as? Boolean ?: true + + if (active) { + keys.add(entry.key) + } + } + props["\$active_feature_flags"] = keys + } + } + } + + userProperties?.let { + props["\$set"] = it + } + + userPropertiesSetOnce?.let { + props["\$set_once"] = it + } + + if (appendGroups) { + // merge groups + mergeGroups(groups)?.let { + props["\$groups"] = it + } + } + + // Session replay should have the SDK info as well + config?.context?.getSdkInfo()?.let { + props.putAll(it) + } + + properties?.let { + props.putAll(it) + } + + return props + } + + protected fun mergeGroups(givenGroups: Map?): Map? { + val preferences = getPreferences() + + @Suppress("UNCHECKED_CAST") + val groups = preferences.getValue(GROUPS) as? Map + val newGroups = mutableMapOf() + + groups?.let { + newGroups.putAll(it) + } + + givenGroups?.let { + newGroups.putAll(it) + } + + return newGroups.ifEmpty { null } + } + + public override fun captureStateless( + event: String, + distinctId: String, + properties: Map?, + userProperties: Map?, + userPropertiesSetOnce: Map?, + groups: Map?, + ) { + try { + if (!isEnabled()) { + return + } + + if (config?.optOut == true) { + config?.logger?.log("PostHog is in OptOut state.") + return + } + + var groupIdentify = false + if (event == GROUP_IDENTIFY) { + groupIdentify = true + } + + val mergedProperties = + buildProperties( + properties = properties, + userProperties = userProperties, + userPropertiesSetOnce = userPropertiesSetOnce, + groups = groups, + appendGroups = !groupIdentify, + ) + + val postHogEvent = buildEvent(event, distinctId, mergedProperties.toMutableMap()) + if (postHogEvent == null) { + val originalMessage = "PostHog event $event was dropped" + val message = + if (PostHogEventName.isUnsafeEditable(event)) { + "$originalMessage. This can cause unexpected behavior." + } else { + originalMessage + } + config?.logger?.log(message) + return + } + + queue?.add(postHogEvent) + } catch (e: Throwable) { + config?.logger?.log("Capture failed: $e.") + } + } + + @Suppress("DEPRECATION") + protected fun buildEvent( + event: String, + distinctId: String, + properties: MutableMap, + ): PostHogEvent? { + // sanitize the properties or fallback to the original properties + val sanitizedProperties = config?.propertiesSanitizer?.sanitize(properties)?.toMutableMap() ?: properties + val postHogEvent = PostHogEvent(event, distinctId, properties = sanitizedProperties) + var eventChecked: PostHogEvent? = postHogEvent + + val beforeSendList = config?.beforeSendList ?: emptyList() + + for (beforeSend in beforeSendList) { + try { + eventChecked = beforeSend.run(postHogEvent) + if (eventChecked == null) { + config?.logger?.log("Event $event was rejected in beforeSend function") + return null + } + } catch (e: Throwable) { + config?.logger?.log("Error in beforeSend function: $e") + return null + } + } + + return eventChecked + } + + public override fun optIn() { + if (!isEnabled()) { + return + } + + synchronized(optOutLock) { + config?.optOut = false + getPreferences().setValue(OPT_OUT, false) + } + } + + public override fun optOut() { + if (!isEnabled()) { + return + } + + synchronized(optOutLock) { + config?.optOut = true + getPreferences().setValue(OPT_OUT, true) + } + } + + /** + * Is Opt Out + */ + public override fun isOptOut(): Boolean { + if (!isEnabled()) { + return true + } + return config?.optOut ?: true + } + + public override fun aliasStateless( + distinctId: String, + alias: String, + ) { + if (!isEnabled()) { + return + } + + val props = mutableMapOf() + props["alias"] = alias + + captureStateless("\$create_alias", distinctId, properties = props) + } + + public override fun identify( + distinctId: String, + userProperties: Map?, + userPropertiesSetOnce: Map?, + ) { + if (!isEnabled()) { + return + } + + if (distinctId.isBlank()) { + config?.logger?.log("identify call not allowed, distinctId is invalid: $distinctId.") + return + } + + val props = mutableMapOf() + + captureStateless( + "\$identify", + distinctId = distinctId, + properties = props, + userProperties = userProperties, + userPropertiesSetOnce = userPropertiesSetOnce, + ) + } + + public override fun groupStateless( + distinctId: String, + type: String, + key: String, + groupProperties: Map?, + ) { + if (!isEnabled()) { + return + } + + val props = mutableMapOf() + props["\$group_type"] = type + props["\$group_key"] = key + groupProperties?.let { + props["\$group_set"] = it + } + + captureStateless(GROUP_IDENTIFY, distinctId, properties = props) + } + + public override fun isFeatureEnabledStateless( + distinctId: String, + key: String, + defaultValue: Boolean, + ): Boolean { + val value = getFeatureFlagStateless(distinctId, key, defaultValue) + + if (value is Boolean) { + return value + } + + if (value is String) { + return value.isNotEmpty() + } + + return false + } + + private fun sendFeatureFlagCalled( + distinctId: String, + key: String, + value: Any?, + ) { + if (config?.sendFeatureFlagEvent == true) { + val props = mutableMapOf() + props["\$feature_flag"] = key + // value should never be nullable anyway + props["\$feature_flag_response"] = value ?: "" + + captureStateless("\$feature_flag_called", distinctId, properties = props) + } + } + + public override fun getFeatureFlagStateless( + distinctId: String, + key: String, + defaultValue: Any?, + ): Any? { + if (!isEnabled()) { + return defaultValue + } + val value = featureFlags?.getFeatureFlag(key, defaultValue) ?: defaultValue + + sendFeatureFlagCalled(distinctId, key, value) + + return value + } + + public override fun getFeatureFlagPayloadStateless( + distinctId: String, + key: String, + defaultValue: Any?, + ): Any? { + if (!isEnabled()) { + return defaultValue + } + return featureFlags?.getFeatureFlagPayload(key, defaultValue) ?: defaultValue + } + + public override fun flush() { + if (!isEnabled()) { + return + } + queue?.flush() + } + + protected fun isEnabled(): Boolean { + if (!enabled) { + config?.logger?.log("Setup isn't called.") + } + return enabled + } + + override fun debug(enable: Boolean) { + if (!isEnabled()) { + return + } + config?.debug = enable + } + + protected open fun getConfig(): T? { + @Suppress("UNCHECKED_CAST") + return config as? T + } + + public companion object : PostHogStatelessInterface { + private var shared: PostHogStatelessInterface = PostHogStateless() + private var defaultSharedInstance = shared + + private const val GROUP_IDENTIFY = "\$groupidentify" + + private val apiKeys = mutableSetOf() + + @PostHogVisibleForTesting + public fun overrideSharedInstance(postHog: PostHogStatelessInterface) { + shared = postHog + } + + @PostHogVisibleForTesting + public fun resetSharedInstance() { + shared = defaultSharedInstance + } + + /** + * Set up the SDK and returns an instance that you can hold and pass it around + * @param T the type of the Config + * @property config the Config + */ + public fun with(config: T): PostHogStatelessInterface { + val instance = PostHogStateless() + instance.setup(config) + return instance + } + + public override fun setup(config: T) { + shared.setup(config) + } + + public override fun close() { + shared.close() + } + + public override fun captureStateless( + event: String, + distinctId: String, + properties: Map?, + userProperties: Map?, + userPropertiesSetOnce: Map?, + groups: Map?, + ) { + shared.captureStateless( + event, + distinctId = distinctId, + properties = properties, + userProperties = userProperties, + userPropertiesSetOnce = userPropertiesSetOnce, + groups = groups, + ) + } + + public override fun identify( + distinctId: String, + userProperties: Map?, + userPropertiesSetOnce: Map?, + ) { + shared.identify( + distinctId, + userProperties = userProperties, + userPropertiesSetOnce = userPropertiesSetOnce, + ) + } + + public override fun isFeatureEnabledStateless( + distinctId: String, + key: String, + defaultValue: Boolean, + ): Boolean = shared.isFeatureEnabledStateless(distinctId, key, defaultValue = defaultValue) + + public override fun getFeatureFlagStateless( + distinctId: String, + key: String, + defaultValue: Any?, + ): Any? = shared.getFeatureFlagStateless(distinctId, key, defaultValue = defaultValue) + + public override fun getFeatureFlagPayloadStateless( + distinctId: String, + key: String, + defaultValue: Any?, + ): Any? = shared.getFeatureFlagPayloadStateless(distinctId, key, defaultValue = defaultValue) + + public override fun flush() { + shared.flush() + } + + public override fun optIn() { + shared.optIn() + } + + public override fun optOut() { + shared.optOut() + } + + public override fun groupStateless( + distinctId: String, + type: String, + key: String, + groupProperties: Map?, + ) { + shared.groupStateless(distinctId, type, key, groupProperties = groupProperties) + } + + public override fun aliasStateless( + distinctId: String, + alias: String, + ) { + shared.aliasStateless(distinctId, alias) + } + + public override fun isOptOut(): Boolean = shared.isOptOut() + + override fun debug(enable: Boolean) { + shared.debug(enable) + } + } +} diff --git a/posthog/src/main/java/com/posthog/PostHogStatelessInterface.kt b/posthog/src/main/java/com/posthog/PostHogStatelessInterface.kt new file mode 100644 index 00000000..7b4365b4 --- /dev/null +++ b/posthog/src/main/java/com/posthog/PostHogStatelessInterface.kt @@ -0,0 +1,88 @@ +package com.posthog + +/** + * The Stateless PostHog SDK entry point + */ +public interface PostHogStatelessInterface : PostHogCoreInterface { + /** + * Captures events + * @param distinctId the distinctId of the user performing the event + * @param properties the custom properties + * @param userProperties the user properties, set as a "$set" property, Docs https://posthog.com/docs/product-analytics/user-properties + * @param userPropertiesSetOnce the user properties to set only once, set as a "$set_once" property, Docs https://posthog.com/docs/product-analytics/user-properties + * @param groups the groups, set as a "$groups" property, Docs https://posthog.com/docs/product-analytics/group-analytics + */ + public fun captureStateless( + event: String, + distinctId: String, + properties: Map? = null, + userProperties: Map? = null, + userPropertiesSetOnce: Map? = null, + groups: Map? = null, + ) + + /** + * Returns if a feature flag is enabled, the feature flag must be a Boolean + * Docs https://posthog.com/docs/feature-flags and https://posthog.com/docs/experiments + * @param distinctId the distinctId + * @param key the Key + * @param defaultValue the default value if not found, false if not given + */ + public fun isFeatureEnabledStateless( + distinctId: String, + key: String, + defaultValue: Boolean = false, + ): Boolean + + /** + * Returns the feature flag + * Docs https://posthog.com/docs/feature-flags and https://posthog.com/docs/experiments + * @param distinctId the distinctId + * @param key the Key + * @param defaultValue the default value if not found + */ + public fun getFeatureFlagStateless( + distinctId: String, + key: String, + defaultValue: Any? = null, + ): Any? + + /** + * Returns the feature flag payload + * Docs https://posthog.com/docs/feature-flags and https://posthog.com/docs/experiments + * @param distinctId the distinctId + * @param key the Key + * @param defaultValue the default value if not found + */ + public fun getFeatureFlagPayloadStateless( + distinctId: String, + key: String, + defaultValue: Any? = null, + ): Any? + + /** + * Creates a group + * Docs https://posthog.com/docs/product-analytics/group-analytics + * @param distinctId the distinctId + * @param type the Group type + * @param key the Group key + * @param groupProperties the Group properties, set as a "$group_set" property, Docs https://posthog.com/docs/product-analytics/group-analytics + */ + public fun groupStateless( + distinctId: String, + type: String, + key: String, + groupProperties: Map? = null, + ) + + /** + * Creates an alias for the user + * Docs https://posthog.com/docs/product-analytics/identify#alias-assigning-multiple-distinct-ids-to-the-same-user + * @param distinctId the distinctId + * @param alias the alias + */ + public fun aliasStateless( + distinctId: String, + alias: String, + ) +} diff --git a/posthog/src/main/java/com/posthog/internal/PostHogFeatureFlagsInterface.kt b/posthog/src/main/java/com/posthog/internal/PostHogFeatureFlagsInterface.kt new file mode 100644 index 00000000..36a998a5 --- /dev/null +++ b/posthog/src/main/java/com/posthog/internal/PostHogFeatureFlagsInterface.kt @@ -0,0 +1,29 @@ +package com.posthog.internal + +import com.posthog.PostHogOnFeatureFlags + +public interface PostHogFeatureFlagsInterface { + public fun loadRemoteConfig( + distinctId: String, + anonymousId: String?, + groups: Map?, + internalOnFeatureFlags: PostHogOnFeatureFlags? = null, + onFeatureFlags: PostHogOnFeatureFlags? = null, + ) + + public fun getFeatureFlag( + key: String, + defaultValue: Any?, + ): Any? + + public fun getFeatureFlagPayload( + key: String, + defaultValue: Any?, + ): Any? + + public fun getFeatureFlags(): Map? + + public fun isSessionReplayFlagActive(): Boolean + + public fun clear() +} diff --git a/posthog/src/main/java/com/posthog/internal/PostHogQueue.kt b/posthog/src/main/java/com/posthog/internal/PostHogQueue.kt index 8e04d28b..4079f9b1 100644 --- a/posthog/src/main/java/com/posthog/internal/PostHogQueue.kt +++ b/posthog/src/main/java/com/posthog/internal/PostHogQueue.kt @@ -27,7 +27,7 @@ internal class PostHogQueue( private val endpoint: PostHogApiEndpoint, private val storagePrefix: String?, private val executor: ExecutorService, -) { +) : PostHogQueueInterface { private val deque: ArrayDeque = ArrayDeque() private val dequeLock = Any() private val timerLock = Any() @@ -48,7 +48,7 @@ internal class PostHogQueue( private val delay: Long get() = (config.flushIntervalSeconds * 1000).toLong() - fun add(event: PostHogEvent) { + override fun add(event: PostHogEvent) { executor.executeSafely { var removeFirst = false if (deque.size >= config.maxQueueSize) { @@ -238,7 +238,7 @@ internal class PostHogQueue( } } - fun flush() { + override fun flush() { // only flushes if the queue is above the threshold (not empty in this case) if (!isAboveThreshold(1)) { return @@ -288,7 +288,7 @@ internal class PostHogQueue( } } - fun start() { + override fun start() { synchronized(timerLock) { stopTimer() val timer = Timer(true) @@ -312,13 +312,13 @@ internal class PostHogQueue( timer?.cancel() } - fun stop() { + override fun stop() { synchronized(timerLock) { stopTimer() } } - fun clear() { + override fun clear() { executor.executeSafely { val tempFiles: List synchronized(dequeLock) { diff --git a/posthog/src/main/java/com/posthog/internal/PostHogQueueInterface.kt b/posthog/src/main/java/com/posthog/internal/PostHogQueueInterface.kt new file mode 100644 index 00000000..36a0c188 --- /dev/null +++ b/posthog/src/main/java/com/posthog/internal/PostHogQueueInterface.kt @@ -0,0 +1,15 @@ +package com.posthog.internal + +import com.posthog.PostHogEvent + +public interface PostHogQueueInterface { + public fun add(event: PostHogEvent) + + public fun flush() + + public fun start() + + public fun stop() + + public fun clear() +} diff --git a/posthog/src/main/java/com/posthog/internal/PostHogRemoteConfig.kt b/posthog/src/main/java/com/posthog/internal/PostHogRemoteConfig.kt index d6ea3723..39dad96c 100644 --- a/posthog/src/main/java/com/posthog/internal/PostHogRemoteConfig.kt +++ b/posthog/src/main/java/com/posthog/internal/PostHogRemoteConfig.kt @@ -22,7 +22,7 @@ internal class PostHogRemoteConfig( private val config: PostHogConfig, private val api: PostHogApi, private val executor: ExecutorService, -) { +) : PostHogFeatureFlagsInterface { private var isLoadingFeatureFlags = AtomicBoolean(false) private var isLoadingRemoteConfig = AtomicBoolean(false) @@ -119,12 +119,12 @@ internal class PostHogRemoteConfig( } } - fun loadRemoteConfig( + override fun loadRemoteConfig( distinctId: String, anonymousId: String?, groups: Map?, - internalOnFeatureFlags: PostHogOnFeatureFlags? = null, - onFeatureFlags: PostHogOnFeatureFlags? = null, + internalOnFeatureFlags: PostHogOnFeatureFlags?, + onFeatureFlags: PostHogOnFeatureFlags?, ) { executor.executeSafely { if (config.networkStatus?.isConnected() == false) { @@ -461,7 +461,6 @@ internal class PostHogRemoteConfig( private fun loadFeatureFlagsFromCache() { config.cachePreferences?.let { preferences -> - @Suppress("UNCHECKED_CAST") val flags = preferences.getValue( @@ -558,7 +557,7 @@ internal class PostHogRemoteConfig( return value ?: defaultValue } - fun getFeatureFlag( + override fun getFeatureFlag( key: String, defaultValue: Any?, ): Any? { @@ -568,7 +567,7 @@ internal class PostHogRemoteConfig( return readFeatureFlag(key, defaultValue, featureFlags) } - fun getFeatureFlagPayload( + override fun getFeatureFlagPayload( key: String, defaultValue: Any?, ): Any? { @@ -578,7 +577,7 @@ internal class PostHogRemoteConfig( return readFeatureFlag(key, defaultValue, featureFlagPayloads) } - fun getFeatureFlags(): Map? { + override fun getFeatureFlags(): Map? { val flags: Map? synchronized(featureFlagsLock) { flags = featureFlags?.toMap() @@ -586,7 +585,7 @@ internal class PostHogRemoteConfig( return flags } - fun isSessionReplayFlagActive(): Boolean = sessionReplayFlagActive + override fun isSessionReplayFlagActive(): Boolean = sessionReplayFlagActive fun getRequestId(): String? { loadFeatureFlagsFromCacheIfNeeded() @@ -624,7 +623,7 @@ internal class PostHogRemoteConfig( } } - fun clear() { + override fun clear() { synchronized(featureFlagsLock) { sessionReplayFlagActive = false isFeatureFlagsLoaded = false diff --git a/posthog/src/test/java/com/posthog/PostHogStatelessTest.kt b/posthog/src/test/java/com/posthog/PostHogStatelessTest.kt new file mode 100644 index 00000000..4f485fac --- /dev/null +++ b/posthog/src/test/java/com/posthog/PostHogStatelessTest.kt @@ -0,0 +1,921 @@ +package com.posthog + +import com.posthog.internal.PostHogFeatureFlagsInterface +import com.posthog.internal.PostHogLogger +import com.posthog.internal.PostHogMemoryPreferences +import com.posthog.internal.PostHogPreferences +import com.posthog.internal.PostHogPreferences.Companion.GROUPS +import com.posthog.internal.PostHogQueueInterface +import com.posthog.internal.PostHogSerializer +import com.posthog.internal.PostHogThreadFactory +import org.junit.Rule +import org.junit.rules.TemporaryFolder +import java.io.File +import java.util.concurrent.Executors +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +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 PostHogStatelessTest { + @get:Rule + val tmpDir = TemporaryFolder() + + private val queueExecutor = Executors.newSingleThreadScheduledExecutor(PostHogThreadFactory("TestQueueStateless")) + private val featureFlagsExecutor = Executors.newSingleThreadScheduledExecutor(PostHogThreadFactory("TestFeatureFlagsStateless")) + private val serializer = PostHogSerializer(PostHogConfig(API_KEY)) + private lateinit var config: PostHogConfig + private lateinit var sut: TestablePostHogStateless + + // Testable version of PostHogStateless that exposes protected methods + private class TestablePostHogStateless( + queueExecutor: java.util.concurrent.ExecutorService, + featureFlagsExecutor: java.util.concurrent.ExecutorService, + ) : PostHogStateless(queueExecutor, featureFlagsExecutor) { + fun isEnabledPublic(): Boolean = isEnabled() + + fun setMockQueue(queue: PostHogQueueInterface) { + this.queue = queue + } + + fun setMockFeatureFlags(featureFlags: PostHogFeatureFlagsInterface) { + this.featureFlags = featureFlags + } + + fun testMergeGroups(givenGroups: Map?): Map? { + return mergeGroups(givenGroups) + } + + fun getPreferencesPublic(): PostHogPreferences { + return getPreferences() + } + } + + // Mock classes for testing + private class MockQueue : PostHogQueueInterface { + val events = mutableListOf() + var isStarted = false + var isStopped = false + var flushed = false + + override fun add(event: PostHogEvent) { + events.add(event) + } + + override fun start() { + isStarted = true + } + + override fun stop() { + isStopped = true + } + + override fun flush() { + flushed = true + } + + override fun clear() { + events.clear() + } + } + + private class MockFeatureFlags : PostHogFeatureFlagsInterface { + private val flags = mutableMapOf() + + fun setFlag( + key: String, + value: Any, + ) { + flags[key] = value + } + + override fun loadRemoteConfig( + distinctId: String, + anonymousId: String?, + groups: Map?, + internalOnFeatureFlags: PostHogOnFeatureFlags?, + onFeatureFlags: PostHogOnFeatureFlags?, + ) { + // Mock implementation + } + + override fun getFeatureFlag( + key: String, + defaultValue: Any?, + ): Any? { + return flags[key] ?: defaultValue + } + + override fun getFeatureFlagPayload( + key: String, + defaultValue: Any?, + ): Any? { + return flags[key] ?: defaultValue + } + + override fun getFeatureFlags(): Map { + return flags.toMap() + } + + override fun isSessionReplayFlagActive(): Boolean { + return false + } + + override fun clear() { + flags.clear() + } + } + + @BeforeTest + fun setUp() { + // Reset shared instance to avoid test interference + PostHogStateless.resetSharedInstance() + } + + @AfterTest + fun tearDown() { + if (::sut.isInitialized) { + sut.close() + } + queueExecutor.shutdownAndAwaitTermination() + featureFlagsExecutor.shutdownAndAwaitTermination() + tmpDir.root.deleteRecursively() + } + + private fun createConfig( + host: String = "https://api.posthog.com", + optOut: Boolean = false, + sendFeatureFlagEvent: Boolean = true, + personProfiles: PersonProfiles = PersonProfiles.ALWAYS, + storagePrefix: String = tmpDir.newFolder().absolutePath, + ): PostHogConfig { + return PostHogConfig(API_KEY, host).apply { + this.optOut = optOut + this.sendFeatureFlagEvent = sendFeatureFlagEvent + this.personProfiles = personProfiles + this.storagePrefix = File(storagePrefix, "events").absolutePath + this.cachePreferences = PostHogMemoryPreferences() + } + } + + private fun createStatelessInstance(): TestablePostHogStateless { + return TestablePostHogStateless(queueExecutor, featureFlagsExecutor) + } + + // Setup and Lifecycle Tests + @Test + fun `setup configures instance correctly`() { + sut = createStatelessInstance() + config = createConfig() + + sut.setup(config) + + assertTrue(sut.isEnabledPublic()) + } + + @Test + fun `setup logs warning when called multiple times`() { + sut = createStatelessInstance() + config = createConfig() + val mockLogger = MockLogger() + config.logger = mockLogger + + sut.setup(config) + sut.setup(config) + + assertTrue(mockLogger.messages.any { it.contains("Setup called despite already being setup!") }) + } + + @Test + fun `setup prevents duplicate API keys`() { + val sut1 = createStatelessInstance() + val sut2 = createStatelessInstance() + val config1 = createConfig() + val config2 = createConfig() + val mockLogger = MockLogger() + config2.logger = mockLogger + + sut1.setup(config1) + sut2.setup(config2) + + assertTrue(mockLogger.messages.any { it.contains("already has a PostHog instance") }) + + sut1.close() + sut2.close() + } + + @Test + fun `close disables instance and stops queue`() { + sut = createStatelessInstance() + config = createConfig() + + sut.setup(config) + assertTrue(sut.isEnabledPublic()) + + sut.close() + assertFalse(sut.isEnabledPublic()) + } + + @Test + fun `close handles errors gracefully`() { + sut = createStatelessInstance() + config = createConfig() + val mockLogger = MockLogger() + config.logger = mockLogger + + sut.close() // Close without setup + + // Should not throw exceptions + assertFalse(sut.isEnabledPublic()) + } + + // Configuration Tests + @Test + fun `optOut sets correct state`() { + sut = createStatelessInstance() + config = createConfig() + + sut.setup(config) + assertFalse(sut.isOptOut()) + + sut.optOut() + assertTrue(sut.isOptOut()) + } + + @Test + fun `optIn sets correct state`() { + sut = createStatelessInstance() + config = createConfig(optOut = true) + + sut.setup(config) + assertTrue(sut.isOptOut()) + + sut.optIn() + assertFalse(sut.isOptOut()) + } + + @Test + fun `optOut state persists across instance`() { + sut = createStatelessInstance() + config = createConfig() + + sut.setup(config) + assertFalse(sut.isOptOut()) + + sut.optOut() + assertTrue(sut.isOptOut()) + } + + @Test + fun `isOptOut returns true when not enabled`() { + sut = createStatelessInstance() + + assertTrue(sut.isOptOut()) + } + + @Test + fun `debug mode can be toggled`() { + sut = createStatelessInstance() + config = createConfig() + + sut.setup(config) + assertFalse(config.debug) + + sut.debug(true) + assertTrue(config.debug) + + sut.debug(false) + assertFalse(config.debug) + } + + // Event Capture Tests + @Test + fun `captureStateless creates and queues event`() { + val mockQueue = MockQueue() + sut = createStatelessInstance() + config = createConfig() + + sut.setup(config) + sut.setMockQueue(mockQueue) + + sut.captureStateless( + event = "test_event", + distinctId = "user123", + properties = mapOf("prop1" to "value1"), + userProperties = mapOf("name" to "John"), + userPropertiesSetOnce = mapOf("signup_date" to "2024-01-01"), + groups = mapOf("company" to "acme"), + ) + + assertEquals(1, mockQueue.events.size) + val event = mockQueue.events.first() + assertEquals("test_event", event.event) + assertEquals("user123", event.distinctId) + assertEquals("value1", event.properties!!["prop1"]) + assertEquals(mapOf("name" to "John"), event.properties!!["\$set"]) + assertEquals(mapOf("signup_date" to "2024-01-01"), event.properties!!["\$set_once"]) + assertEquals(mapOf("company" to "acme"), event.properties!!["\$groups"]) + } + + @Test + fun `captureStateless does nothing when not enabled`() { + val mockQueue = MockQueue() + sut = createStatelessInstance() + + sut.captureStateless("test", "user123") + + assertEquals(0, mockQueue.events.size) + } + + @Test + fun `captureStateless does nothing when opted out`() { + val mockQueue = MockQueue() + sut = createStatelessInstance() + config = createConfig(optOut = true) + + sut.setup(config) + sut.setMockQueue(mockQueue) + + sut.captureStateless("test", "user123") + + assertEquals(0, mockQueue.events.size) + } + + @Test + fun `captureStateless handles feature flags when enabled`() { + val mockQueue = MockQueue() + val mockFeatureFlags = MockFeatureFlags() + mockFeatureFlags.setFlag("test_flag", true) + + sut = createStatelessInstance() + config = createConfig(sendFeatureFlagEvent = true) + + sut.setup(config) + sut.setMockQueue(mockQueue) + + sut.setMockFeatureFlags(mockFeatureFlags) + + sut.captureStateless("test", "user123") + + assertEquals(1, mockQueue.events.size) + val event = mockQueue.events.first() + assertEquals(true, event.properties!!["\$feature/test_flag"]) + assertEquals(listOf("test_flag"), event.properties!!["\$active_feature_flags"]) + } + + // Feature Flags Tests + @Test + fun `isFeatureEnabledStateless returns correct value`() { + val mockFeatureFlags = MockFeatureFlags() + mockFeatureFlags.setFlag("test_flag", true) + + sut = createStatelessInstance() + config = createConfig() + + sut.setup(config) + sut.setMockFeatureFlags(mockFeatureFlags) + + assertTrue(sut.isFeatureEnabledStateless("user123", "test_flag")) + assertFalse(sut.isFeatureEnabledStateless("user123", "non_existent_flag")) + } + + @Test + fun `isFeatureEnabledStateless returns default when not enabled`() { + sut = createStatelessInstance() + + assertFalse(sut.isFeatureEnabledStateless("user123", "test_flag")) + assertTrue(sut.isFeatureEnabledStateless("user123", "test_flag", true)) + } + + @Test + fun `getFeatureFlagStateless returns correct value`() { + val mockFeatureFlags = MockFeatureFlags() + mockFeatureFlags.setFlag("string_flag", "variant_a") + + sut = createStatelessInstance() + config = createConfig() + + sut.setup(config) + sut.setMockFeatureFlags(mockFeatureFlags) + + assertEquals("variant_a", sut.getFeatureFlagStateless("user123", "string_flag")) + assertEquals("default", sut.getFeatureFlagStateless("user123", "non_existent", "default")) + } + + @Test + fun `getFeatureFlagPayloadStateless returns correct value`() { + val mockFeatureFlags = MockFeatureFlags() + mockFeatureFlags.setFlag("payload_flag", mapOf("key" to "value")) + + sut = createStatelessInstance() + config = createConfig() + + sut.setup(config) + sut.setMockFeatureFlags(mockFeatureFlags) + + assertEquals(mapOf("key" to "value"), sut.getFeatureFlagPayloadStateless("user123", "payload_flag")) + assertEquals("default", sut.getFeatureFlagPayloadStateless("user123", "non_existent", "default")) + } + + // Identity Management Tests + @Test + fun `identify captures identify event when person processing allowed`() { + val mockQueue = MockQueue() + sut = createStatelessInstance() + config = createConfig(personProfiles = PersonProfiles.ALWAYS) + + sut.setup(config) + sut.setMockQueue(mockQueue) + + sut.identify( + distinctId = "user123", + userProperties = mapOf("name" to "John"), + userPropertiesSetOnce = mapOf("signup_date" to "2024-01-01"), + ) + + assertEquals(1, mockQueue.events.size) + val event = mockQueue.events.first() + assertEquals("\$identify", event.event) + assertEquals("user123", event.distinctId) + } + + @Test + fun `identify does nothing with blank distinctId`() { + val mockQueue = MockQueue() + sut = createStatelessInstance() + config = createConfig() + + sut.setup(config) + sut.setMockQueue(mockQueue) + + sut.identify(" ") + + assertEquals(0, mockQueue.events.size) + } + + @Test + fun `aliasStateless creates alias event`() { + val mockQueue = MockQueue() + sut = createStatelessInstance() + config = createConfig() + + sut.setup(config) + sut.setMockQueue(mockQueue) + + sut.aliasStateless("user123", "alias456") + + assertEquals(1, mockQueue.events.size) + val event = mockQueue.events.first() + assertEquals("\$create_alias", event.event) + assertEquals("user123", event.distinctId) + assertEquals("alias456", event.properties!!["alias"]) + } + + // Group Management Tests + @Test + fun `groupStateless creates group identify event`() { + val mockQueue = MockQueue() + sut = createStatelessInstance() + config = createConfig() + + sut.setup(config) + sut.setMockQueue(mockQueue) + + sut.groupStateless( + distinctId = "user123", + type = "company", + key = "acme", + groupProperties = mapOf("industry" to "tech"), + ) + + assertEquals(1, mockQueue.events.size) + val event = mockQueue.events.first() + assertEquals("\$groupidentify", event.event) + assertEquals("user123", event.distinctId) + assertEquals("company", event.properties!!["\$group_type"]) + assertEquals("acme", event.properties!!["\$group_key"]) + assertEquals(mapOf("industry" to "tech"), event.properties!!["\$group_set"]) + } + + // Error Handling Tests + @Test + fun `operations handle errors gracefully when not enabled`() { + sut = createStatelessInstance() + + // Should not throw exceptions + sut.captureStateless("test", "user123") + sut.identify("user123") + sut.aliasStateless("user123", "alias") + sut.groupStateless("user123", "company", "acme") + sut.flush() + sut.debug(true) + } + + @Test + fun `flush calls queue flush when enabled`() { + val mockQueue = MockQueue() + sut = createStatelessInstance() + config = createConfig() + + sut.setup(config) + sut.setMockQueue(mockQueue) + + sut.flush() + + assertTrue(mockQueue.flushed) + } + + @Test + fun `flush does nothing when not enabled`() { + sut = createStatelessInstance() + + // Should not throw exceptions + sut.flush() + } + + // Companion Object Tests + @Test + fun `with factory method creates configured instance`() { + val config = createConfig() + val instance = PostHogStateless.with(config) + + // Since isEnabled is protected, we can only test through public interface + assertFalse(instance.isOptOut()) + + instance.close() + } + + @Test + fun `shared instance delegates to singleton`() { + val config = createConfig() + + PostHogStateless.setup(config) + assertTrue(PostHogStateless.isOptOut() == false) + + PostHogStateless.optOut() + assertTrue(PostHogStateless.isOptOut()) + + PostHogStateless.close() + } + + @Test + fun `overrideSharedInstance allows test customization`() { + val mockInstance = MockPostHogStateless() + + PostHogStateless.overrideSharedInstance(mockInstance) + PostHogStateless.optOut() + + assertTrue(mockInstance.optOutCalled) + + PostHogStateless.resetSharedInstance() + } + + // Property Merging and Advanced Tests + @Test + fun `buildProperties merges all property sources correctly`() { + val mockQueue = MockQueue() + val mockFeatureFlags = MockFeatureFlags() + mockFeatureFlags.setFlag("test_flag", "variant_a") + + val preferences = PostHogMemoryPreferences() + preferences.setValue("registered_prop", "registered_value") + + sut = createStatelessInstance() + config = + createConfig().apply { + cachePreferences = preferences + sendFeatureFlagEvent = true + } + + sut.setup(config) + sut.setMockQueue(mockQueue) + + sut.setMockFeatureFlags(mockFeatureFlags) + + sut.captureStateless( + event = "test_event", + distinctId = "user123", + properties = mapOf("event_prop" to "event_value"), + userProperties = mapOf("name" to "John"), + userPropertiesSetOnce = mapOf("signup_date" to "2024-01-01"), + groups = mapOf("company" to "acme"), + ) + + assertEquals(1, mockQueue.events.size) + val event = mockQueue.events.first() + + // Check key property sources are merged + assertEquals("event_value", event.properties!!["event_prop"]) + assertEquals(mapOf("name" to "John"), event.properties!!["\$set"]) + assertEquals(mapOf("signup_date" to "2024-01-01"), event.properties!!["\$set_once"]) + assertEquals(mapOf("company" to "acme"), event.properties!!["\$groups"]) + assertEquals("variant_a", event.properties!!["\$feature/test_flag"]) + } + + @Test + fun `mergeGroups handles existing and new groups correctly`() { + sut = createStatelessInstance() + config = createConfig() + + sut.setup(config) + + // Set up existing groups in preferences + val existingGroups = mapOf("existing_group" to "existing_value", "shared_group" to "old_value") + sut.getPreferencesPublic().setValue(GROUPS, existingGroups) + + // Merge with new groups (including one that overwrites existing) + val newGroups = mapOf("new_group" to "new_value", "shared_group" to "new_value") + val result = sut.testMergeGroups(newGroups) + + assertNotNull(result) + assertEquals(3, result.size) + // Existing group should be preserved + assertEquals("existing_value", result["existing_group"]) + // New group should be added + assertEquals("new_value", result["new_group"]) + // Shared group should be overwritten with new value + assertEquals("new_value", result["shared_group"]) + } + + @Test + fun `mergeGroups returns null when no groups exist`() { + sut = createStatelessInstance() + config = createConfig() + + sut.setup(config) + + val result = sut.testMergeGroups(null) + + assertNull(result) + } + + @Test + fun `beforeSend callback can modify events`() { + val mockQueue = MockQueue() + sut = createStatelessInstance() + config = + createConfig().apply { + addBeforeSend { event -> + event.copy( + properties = + event.properties?.toMutableMap()?.apply { + put("modified", true) + }, + ) + } + } + + sut.setup(config) + sut.setMockQueue(mockQueue) + + sut.captureStateless("test", "user123", mapOf("original" to "value")) + + assertEquals(1, mockQueue.events.size) + val event = mockQueue.events.first() + assertEquals(true, event.properties!!["modified"]) + assertEquals("value", event.properties!!["original"]) + } + + @Test + fun `beforeSend callback can reject events`() { + val mockQueue = MockQueue() + sut = createStatelessInstance() + config = + createConfig().apply { + addBeforeSend { null } // Reject all events + } + + sut.setup(config) + sut.setMockQueue(mockQueue) + + sut.captureStateless("test", "user123") + + assertEquals(0, mockQueue.events.size) + } + + @Test + fun `beforeSend error handling does not crash`() { + val mockQueue = MockQueue() + val mockLogger = MockLogger() + sut = createStatelessInstance() + config = + createConfig().apply { + logger = mockLogger + addBeforeSend { throw RuntimeException("Test error") } + } + + sut.setup(config) + sut.setMockQueue(mockQueue) + + sut.captureStateless("test", "user123") + + assertEquals(0, mockQueue.events.size) + assertTrue(mockLogger.messages.any { it.contains("Error in beforeSend function") }) + } + + @Test + fun `properties sanitizer is applied to events`() { + val mockQueue = MockQueue() + sut = createStatelessInstance() + config = + createConfig().apply { + @Suppress("DEPRECATION") + propertiesSanitizer = + PostHogPropertiesSanitizer { properties -> + properties.apply { + remove("sensitive") + put("sanitized", true) + } + } + } + + sut.setup(config) + sut.setMockQueue(mockQueue) + + sut.captureStateless( + "test", + "user123", + mapOf( + "safe" to "value", + "sensitive" to "secret", + ), + ) + + assertEquals(1, mockQueue.events.size) + val event = mockQueue.events.first() + assertEquals("value", event.properties!!["safe"]) + assertNull(event.properties!!["sensitive"]) + assertEquals(true, event.properties!!["sanitized"]) + } + + @Test + fun `feature flag called events are sent when feature flags accessed`() { + val mockQueue = MockQueue() + val mockFeatureFlags = MockFeatureFlags() + mockFeatureFlags.setFlag("test_flag", true) + + sut = createStatelessInstance() + config = createConfig(sendFeatureFlagEvent = true) + + sut.setup(config) + sut.setMockQueue(mockQueue) + + sut.setMockFeatureFlags(mockFeatureFlags) + + // Access feature flag + sut.isFeatureEnabledStateless("user123", "test_flag") + + // Should generate feature flag called event + assertEquals(1, mockQueue.events.size) + val event = mockQueue.events.first() + assertEquals("\$feature_flag_called", event.event) + assertEquals("user123", event.distinctId) + assertEquals("test_flag", event.properties!!["\$feature_flag"]) + assertEquals(true, event.properties!!["\$feature_flag_response"]) + } + + @Test + fun `feature flag called events not sent when disabled`() { + val mockQueue = MockQueue() + val mockFeatureFlags = MockFeatureFlags() + mockFeatureFlags.setFlag("test_flag", true) + + sut = createStatelessInstance() + config = createConfig(sendFeatureFlagEvent = false) + + sut.setup(config) + sut.setMockQueue(mockQueue) + + sut.setMockFeatureFlags(mockFeatureFlags) + + sut.isFeatureEnabledStateless("user123", "test_flag") + + assertEquals(0, mockQueue.events.size) + } + + @Test + fun `group identify event excludes groups from properties`() { + val mockQueue = MockQueue() + sut = createStatelessInstance() + config = createConfig() + + sut.setup(config) + sut.setMockQueue(mockQueue) + + sut.groupStateless("user123", "company", "acme") + + assertEquals(1, mockQueue.events.size) + val event = mockQueue.events.first() + assertEquals("\$groupidentify", event.event) + + // Groups should not be included in $groups property for group identify events + assertNull(event.properties!!["\$groups"]) + } + + // Helper classes + private class MockLogger : PostHogLogger { + val messages = mutableListOf() + + override fun log(message: String) { + messages.add(message) + } + + override fun isEnabled(): Boolean = true + } + + private class MockPostHogStateless : PostHogStatelessInterface { + var setupCalled = false + var closeCalled = false + var optOutCalled = false + var optInCalled = false + var captureCalled = false + var identifyCalled = false + var aliasCalled = false + var groupCalled = false + var flushCalled = false + var debugCalled = false + + override fun setup(config: T) { + setupCalled = true + } + + override fun close() { + closeCalled = true + } + + override fun captureStateless( + event: String, + distinctId: String, + properties: Map?, + userProperties: Map?, + userPropertiesSetOnce: Map?, + groups: Map?, + ) { + captureCalled = true + } + + override fun identify( + distinctId: String, + userProperties: Map?, + userPropertiesSetOnce: Map?, + ) { + identifyCalled = true + } + + override fun flush() { + flushCalled = true + } + + override fun optIn() { + optInCalled = true + } + + override fun optOut() { + optOutCalled = true + } + + override fun isOptOut(): Boolean = false + + override fun debug(enable: Boolean) { + debugCalled = true + } + + override fun isFeatureEnabledStateless( + distinctId: String, + key: String, + defaultValue: Boolean, + ): Boolean = defaultValue + + override fun getFeatureFlagStateless( + distinctId: String, + key: String, + defaultValue: Any?, + ): Any? = defaultValue + + override fun getFeatureFlagPayloadStateless( + distinctId: String, + key: String, + defaultValue: Any?, + ): Any? = defaultValue + + override fun groupStateless( + distinctId: String, + type: String, + key: String, + groupProperties: Map?, + ) { + groupCalled = true + } + + override fun aliasStateless( + distinctId: String, + alias: String, + ) { + aliasCalled = true + } + } +} diff --git a/posthog/src/test/java/com/posthog/internal/PostHogFeatureFlagsTest.kt b/posthog/src/test/java/com/posthog/internal/PostHogFeatureFlagsTest.kt index 2b34859a..fd746726 100644 --- a/posthog/src/test/java/com/posthog/internal/PostHogFeatureFlagsTest.kt +++ b/posthog/src/test/java/com/posthog/internal/PostHogFeatureFlagsTest.kt @@ -403,4 +403,31 @@ internal class PostHogFeatureFlagsTest { sut.clear() } + + @Test + fun `returns session replay enabled after remote config API call`() { + val file = File("src/test/resources/json/basic-remote-config-no-flags.json") + + val http = + mockHttp( + response = + MockResponse() + .setBody(file.readText()), + ) + val url = http.url("/") + + val sut = getSut(host = url.toString()) + + sut.loadRemoteConfig("my_identify", anonymousId = "anonId", emptyMap(), null) + + executor.shutdownAndAwaitTermination() + + assertTrue(sut.isSessionReplayFlagActive()) + assertEquals("/s/", config?.snapshotEndpoint) + assertEquals(1, http.requestCount) + + sut.clear() + + assertFalse(sut.isSessionReplayFlagActive()) + } }