diff --git a/posthog-android/CHANGELOG.md b/posthog-android/CHANGELOG.md index c14bf711..a03b063f 100644 --- a/posthog-android/CHANGELOG.md +++ b/posthog-android/CHANGELOG.md @@ -1,5 +1,8 @@ ## Next +- add param sendFeatureFlagEvent in function getFeatureFlag() for override config's + sendFeatureFlagEvent ([#319](https://github.com/PostHog/posthog-android/pull/319)) + ## 3.26.0 - 2025-11-05 - feat: Cache properties for flag evaluation ([#315](https://github.com/PostHog/posthog-android/pull/315)) 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 59c412aa..9e11e638 100644 --- a/posthog-android/src/test/java/com/posthog/android/PostHogFake.kt +++ b/posthog-android/src/test/java/com/posthog/android/PostHogFake.kt @@ -51,6 +51,7 @@ public class PostHogFake : PostHogInterface { override fun isFeatureEnabled( key: String, defaultValue: Boolean, + sendFeatureFlagEvent: Boolean?, ): Boolean { return false } @@ -58,6 +59,7 @@ public class PostHogFake : PostHogInterface { override fun getFeatureFlag( key: String, defaultValue: Any?, + sendFeatureFlagEvent: Boolean?, ): Any? { return null } diff --git a/posthog/src/main/java/com/posthog/PostHog.kt b/posthog/src/main/java/com/posthog/PostHog.kt index 23d920c2..c0fc077a 100644 --- a/posthog/src/main/java/com/posthog/PostHog.kt +++ b/posthog/src/main/java/com/posthog/PostHog.kt @@ -83,7 +83,8 @@ public class PostHog private constructor( config.logger.log("Setup called despite already being setup!") return } - config.logger = if (config.logger is PostHogNoOpLogger) PostHogPrintLogger(config) else config.logger + 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.") @@ -92,8 +93,22 @@ public class PostHog private constructor( val cachePreferences = config.cachePreferences ?: memoryPreferences config.cachePreferences = cachePreferences 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 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) { getDefaultPersonProperties() @@ -178,7 +193,12 @@ public class PostHog private constructor( // only because of testing in isolation, this flag is always enabled if (reloadFeatureFlags) { when { - config.remoteConfig -> loadRemoteConfigRequest(internalOnFeatureFlagsLoaded, config.onFeatureFlags) + config.remoteConfig -> + loadRemoteConfigRequest( + internalOnFeatureFlagsLoaded, + config.onFeatureFlags, + ) + config.preloadFeatureFlags -> reloadFeatureFlags(config.onFeatureFlags) } } @@ -730,8 +750,9 @@ public class PostHog private constructor( get() { synchronized(personProcessingLock) { if (!isPersonProcessingLoaded) { - isPersonProcessingEnabled = getPreferences().getValue(PERSON_PROCESSING) as? Boolean - ?: false + isPersonProcessingEnabled = + getPreferences().getValue(PERSON_PROCESSING) as? Boolean + ?: false isPersonProcessingLoaded = true } } @@ -804,7 +825,10 @@ public class PostHog private constructor( if (!isEnabled()) { return } - loadFeatureFlagsRequest(internalOnFeatureFlags = internalOnFeatureFlagsLoaded, onFeatureFlags = onFeatureFlags) + loadFeatureFlagsRequest( + internalOnFeatureFlags = internalOnFeatureFlagsLoaded, + onFeatureFlags = onFeatureFlags, + ) } private fun loadFeatureFlagsRequest( @@ -849,14 +873,21 @@ public class PostHog private constructor( anonymousId = this.anonymousId } - remoteConfig?.loadRemoteConfig(distinctId, anonymousId = anonymousId, groups, internalOnFeatureFlags, onFeatureFlags) + remoteConfig?.loadRemoteConfig( + distinctId, + anonymousId = anonymousId, + groups, + internalOnFeatureFlags, + onFeatureFlags, + ) } public override fun isFeatureEnabled( key: String, defaultValue: Boolean, + sendFeatureFlagEvent: Boolean?, ): Boolean { - val value = getFeatureFlag(key, defaultValue) + val value = getFeatureFlag(key, defaultValue, sendFeatureFlagEvent) if (value is Boolean) { return value @@ -872,34 +903,42 @@ public class PostHog private constructor( private fun sendFeatureFlagCalled( key: String, value: Any?, + sendFeatureFlagEvent: Boolean?, ) { - var shouldSendFeatureFlagEvent = true - synchronized(featureFlagsCalledLock) { - val values = featureFlagsCalled[key] ?: mutableListOf() - if (values.contains(value)) { - shouldSendFeatureFlagEvent = false - } else { - values.add(value) - featureFlagsCalled[key] = values + val effectiveSendFeatureFlagEvent = + sendFeatureFlagEvent + ?: config?.sendFeatureFlagEvent + ?: false + + if (effectiveSendFeatureFlagEvent) { + var shouldSendFeatureFlagEvent = true + synchronized(featureFlagsCalledLock) { + val values = featureFlagsCalled[key] ?: mutableListOf() + if (values.contains(value)) { + shouldSendFeatureFlagEvent = false + } else { + values.add(value) + featureFlagsCalled[key] = values + } } - } - if (config?.sendFeatureFlagEvent == true && shouldSendFeatureFlagEvent) { - remoteConfig?.let { - val flagDetails = it.getFlagDetails(key) - val requestId = it.getRequestId() - - val props = mutableMapOf() - props["\$feature_flag"] = key - // value should never be nullabe anyway - props["\$feature_flag_response"] = value ?: "" - props["\$feature_flag_request_id"] = requestId ?: "" - flagDetails?.let { - props["\$feature_flag_id"] = it.metadata.id - props["\$feature_flag_version"] = it.metadata.version - props["\$feature_flag_reason"] = it.reason?.description ?: "" + if (shouldSendFeatureFlagEvent) { + remoteConfig?.let { + val flagDetails = it.getFlagDetails(key) + val requestId = it.getRequestId() + + val props = mutableMapOf() + props["\$feature_flag"] = key + // value should never be nullabe anyway + props["\$feature_flag_response"] = value ?: "" + props["\$feature_flag_request_id"] = requestId ?: "" + flagDetails?.let { + props["\$feature_flag_id"] = it.metadata.id + props["\$feature_flag_version"] = it.metadata.version + props["\$feature_flag_reason"] = it.reason?.description ?: "" + } + capture(PostHogEventName.FEATURE_FLAG_CALLED.event, properties = props) } - capture("\$feature_flag_called", properties = props) } } } @@ -907,14 +946,14 @@ public class PostHog private constructor( public override fun getFeatureFlag( key: String, defaultValue: Any?, + sendFeatureFlagEvent: Boolean?, ): Any? { if (!isEnabled()) { return defaultValue } val value = remoteConfig?.getFeatureFlag(key, defaultValue) ?: defaultValue - sendFeatureFlagCalled(key, value) - + sendFeatureFlagCalled(key, value, sendFeatureFlagEvent) return value } @@ -1258,12 +1297,19 @@ public class PostHog private constructor( public override fun isFeatureEnabled( key: String, defaultValue: Boolean, - ): Boolean = shared.isFeatureEnabled(key, defaultValue = defaultValue) + sendFeatureFlagEvent: Boolean?, + ): Boolean = + shared.isFeatureEnabled( + key, + defaultValue = defaultValue, + sendFeatureFlagEvent = sendFeatureFlagEvent, + ) public override fun getFeatureFlag( key: String, defaultValue: Any?, - ): Any? = shared.getFeatureFlag(key, defaultValue = defaultValue) + sendFeatureFlagEvent: Boolean?, + ): Any? = shared.getFeatureFlag(key, defaultValue = defaultValue, sendFeatureFlagEvent) public override fun getFeatureFlagPayload( key: String, diff --git a/posthog/src/main/java/com/posthog/PostHogInterface.kt b/posthog/src/main/java/com/posthog/PostHogInterface.kt index 0367c35f..cf948439 100644 --- a/posthog/src/main/java/com/posthog/PostHogInterface.kt +++ b/posthog/src/main/java/com/posthog/PostHogInterface.kt @@ -47,10 +47,12 @@ public interface PostHogInterface : PostHogCoreInterface { * Docs https://posthog.com/docs/feature-flags and https://posthog.com/docs/experiments * @param key the Key * @param defaultValue the default value if not found, false if not given + * @param sendFeatureFlagEvent (optional) If false, we won't send an $feature_flag_call event to PostHog. */ public fun isFeatureEnabled( key: String, defaultValue: Boolean = false, + sendFeatureFlagEvent: Boolean? = null, ): Boolean /** @@ -58,10 +60,12 @@ public interface PostHogInterface : PostHogCoreInterface { * Docs https://posthog.com/docs/feature-flags and https://posthog.com/docs/experiments * @param key the Key * @param defaultValue the default value if not found + * @param sendFeatureFlagEvent (optional) If false, we won't send an $feature_flag_call event to PostHog. */ public fun getFeatureFlag( key: String, defaultValue: Any? = null, + sendFeatureFlagEvent: Boolean? = null, ): Any? /** diff --git a/posthog/src/main/java/com/posthog/PostHogStateless.kt b/posthog/src/main/java/com/posthog/PostHogStateless.kt index b2456112..3ec3de84 100644 --- a/posthog/src/main/java/com/posthog/PostHogStateless.kt +++ b/posthog/src/main/java/com/posthog/PostHogStateless.kt @@ -441,7 +441,7 @@ public open class PostHogStateless protected constructor( // value should never be nullable anyway props["\$feature_flag_response"] = value ?: "" - captureStateless("\$feature_flag_called", distinctId, properties = props) + captureStateless(PostHogEventName.FEATURE_FLAG_CALLED.event, distinctId, properties = props) } } } diff --git a/posthog/src/test/java/com/posthog/PostHogFeatureFlagsTest.kt b/posthog/src/test/java/com/posthog/PostHogFeatureFlagsTest.kt new file mode 100644 index 00000000..b6e66c3f --- /dev/null +++ b/posthog/src/test/java/com/posthog/PostHogFeatureFlagsTest.kt @@ -0,0 +1,332 @@ +package com.posthog + +import com.posthog.internal.PostHogBatchEvent +import com.posthog.internal.PostHogContext +import com.posthog.internal.PostHogMemoryPreferences +import com.posthog.internal.PostHogSerializer +import com.posthog.internal.PostHogThreadFactory +import okhttp3.mockwebserver.MockResponse +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.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotEquals + +internal class PostHogFeatureFlagsTest { + @get:Rule + val tmpDir = TemporaryFolder() + + private val queueExecutor = + Executors.newSingleThreadScheduledExecutor(PostHogThreadFactory("TestQueue")) + private val replayQueueExecutor = + Executors.newSingleThreadScheduledExecutor(PostHogThreadFactory("TestReplayQueue")) + private val remoteConfigExecutor = + Executors.newSingleThreadScheduledExecutor(PostHogThreadFactory("TestRemoteConfig")) + private val cachedEventsExecutor = + Executors.newSingleThreadScheduledExecutor(PostHogThreadFactory("TestCachedEvents")) + private val serializer = PostHogSerializer(PostHogConfig(API_KEY)) + private lateinit var config: PostHogConfig + + @Suppress("DEPRECATION") + fun getSut( + host: String, + flushAt: Int = 1, + storagePrefix: String = tmpDir.newFolder().absolutePath, + optOut: Boolean = false, + preloadFeatureFlags: Boolean = true, + reloadFeatureFlags: Boolean = true, + sendFeatureFlagEvent: Boolean = true, + reuseAnonymousId: Boolean = false, + integration: PostHogIntegration? = null, + remoteConfig: Boolean = false, + cachePreferences: PostHogMemoryPreferences = PostHogMemoryPreferences(), + propertiesSanitizer: PostHogPropertiesSanitizer? = null, + beforeSend: PostHogBeforeSend? = null, + evaluationEnvironments: List? = null, + context: PostHogContext? = null, + ): PostHogInterface { + config = + PostHogConfig(API_KEY, host).apply { + // for testing + this.flushAt = flushAt + this.storagePrefix = File(storagePrefix, "events").absolutePath + this.replayStoragePrefix = File(storagePrefix, "snapshots").absolutePath + this.optOut = optOut + this.preloadFeatureFlags = preloadFeatureFlags + if (integration != null) { + addIntegration(integration) + } + this.sendFeatureFlagEvent = sendFeatureFlagEvent + this.reuseAnonymousId = reuseAnonymousId + this.cachePreferences = cachePreferences + this.propertiesSanitizer = propertiesSanitizer + this.evaluationEnvironments = evaluationEnvironments + this.remoteConfig = remoteConfig + if (beforeSend != null) { + addBeforeSend(beforeSend) + } + this.errorTrackingConfig.inAppIncludes.add("com.posthog") + this.context = context + } + return PostHog.withInternal( + config, + queueExecutor, + replayQueueExecutor, + remoteConfigExecutor, + cachedEventsExecutor, + reloadFeatureFlags, + ) + } + + @AfterTest + fun `set down`() { + tmpDir.root.deleteRecursively() + } + + @Test + fun `check function getFeatureFlag where sendFeatureFlagEvent=true and config_sendFeatureFlagEvent=false`() { + val file = File("src/test/resources/json/basic-flags-with-non-active-flags.json") + val responseFlagsApi = file.readText() + + val http = + mockHttp( + response = + MockResponse() + .setBody(responseFlagsApi), + ) + http.enqueue( + MockResponse() + .setBody(""), + ) + val url = http.url("/") + val sut = getSut(url.toString(), preloadFeatureFlags = false, sendFeatureFlagEvent = false) + + sut.reloadFeatureFlags() + + remoteConfigExecutor.shutdownAndAwaitTermination() + + // remove from the http queue + http.takeRequest() + + sut.getFeatureFlag("splashScreenName", sendFeatureFlagEvent = true) + + queueExecutor.shutdownAndAwaitTermination() + + val request = http.takeRequest() + val content = request.body.unGzip() + val batch = serializer.deserialize(content.reader()) + + val theEvent = batch.batch.firstOrNull() + assertEquals("\$feature_flag_called", theEvent?.event) + assertEquals(1, batch.batch.size) + sut.close() + } + + @Test + fun `check function getFeatureFlag where sendFeatureFlagEvent=false and config_sendFeatureFlagEvent=false`() { + val file = File("src/test/resources/json/basic-flags-with-non-active-flags.json") + val responseFlagsApi = file.readText() + + val http = + mockHttp( + response = + MockResponse() + .setBody(responseFlagsApi), + ) + http.enqueue( + MockResponse() + .setBody(""), + ) + val url = http.url("/") + val sut = getSut(url.toString(), preloadFeatureFlags = false, sendFeatureFlagEvent = false) + + sut.reloadFeatureFlags() + remoteConfigExecutor.shutdownAndAwaitTermination() + + // remove from the http queue + http.takeRequest() + sut.getFeatureFlag("splashScreenName", sendFeatureFlagEvent = false) + sut.capture("test_event") + + queueExecutor.shutdownAndAwaitTermination() + + val request = http.takeRequest() + val content = request.body.unGzip() + val batch = serializer.deserialize(content.reader()) + + val theEvent = batch.batch.firstOrNull() + assertEquals(1, batch.batch.size) + assertEquals("test_event", theEvent?.event) + sut.close() + } + + @Test + fun `check function getFeatureFlag where sendFeatureFlagEvent=null and config_sendFeatureFlagEvent=false`() { + val file = File("src/test/resources/json/basic-flags-with-non-active-flags.json") + val responseFlagsApi = file.readText() + + val http = + mockHttp( + response = + MockResponse() + .setBody(responseFlagsApi), + ) + http.enqueue( + MockResponse() + .setBody(""), + ) + val url = http.url("/") + val sut = + getSut( + url.toString(), + preloadFeatureFlags = false, + sendFeatureFlagEvent = false, + ) + + sut.reloadFeatureFlags() + + remoteConfigExecutor.shutdownAndAwaitTermination() + + // remove from the http queue + http.takeRequest() + + sut.getFeatureFlag("splashScreenName", sendFeatureFlagEvent = null) + sut.capture("test_event") + + queueExecutor.shutdownAndAwaitTermination() + + val request = http.takeRequest() + val content = request.body.unGzip() + val batch = serializer.deserialize(content.reader()) + + val theEvent = batch.batch.firstOrNull() + assertEquals("test_event", theEvent?.event) + assertEquals(1, batch.batch.size) + sut.close() + } + + @Test + fun `check function getFeatureFlag where sendFeatureFlagEvent=false and config_sendFeatureFlagEvent=true`() { + val file = File("src/test/resources/json/basic-flags-with-non-active-flags.json") + val responseFlagsApi = file.readText() + + val http = + mockHttp( + response = + MockResponse() + .setBody(responseFlagsApi), + ) + http.enqueue( + MockResponse() + .setBody(""), + ) + val url = http.url("/") + val sut = getSut(url.toString(), preloadFeatureFlags = false, sendFeatureFlagEvent = true) + + sut.reloadFeatureFlags() + + remoteConfigExecutor.shutdownAndAwaitTermination() + + // remove from the http queue + http.takeRequest() + + sut.getFeatureFlag("splashScreenName", sendFeatureFlagEvent = false) + sut.capture("test_event") + queueExecutor.shutdownAndAwaitTermination() + + val request = http.takeRequest() + val content = request.body.unGzip() + val batch = serializer.deserialize(content.reader()) + + val theEvent = batch.batch.firstOrNull() + assertNotEquals("\$feature_flag_called", theEvent?.event) + assertEquals("test_event", theEvent?.event) + assertEquals(1, batch.batch.size) + sut.close() + } + + @Test + fun `check function getFeatureFlag where sendFeatureFlagEvent=true and config_sendFeatureFlagEvent=true`() { + val file = File("src/test/resources/json/basic-flags-with-non-active-flags.json") + val responseFlagsApi = file.readText() + + val http = + mockHttp( + response = + MockResponse() + .setBody(responseFlagsApi), + ) + http.enqueue( + MockResponse() + .setBody(""), + ) + val url = http.url("/") + val sut = getSut(url.toString(), preloadFeatureFlags = false, sendFeatureFlagEvent = true) + + sut.reloadFeatureFlags() + + remoteConfigExecutor.shutdownAndAwaitTermination() + + // remove from the http queue + http.takeRequest() + + sut.getFeatureFlag("splashScreenName", sendFeatureFlagEvent = true) + queueExecutor.shutdownAndAwaitTermination() + + val request = http.takeRequest() + val content = request.body.unGzip() + val batch = serializer.deserialize(content.reader()) + + val theEvent = batch.batch.firstOrNull() + assertEquals("\$feature_flag_called", theEvent?.event) + assertEquals(1, batch.batch.size) + sut.close() + } + + @Test + fun `check function getFeatureFlag where sendFeatureFlagEvent=null and config_sendFeatureFlagEvent=true`() { + val file = File("src/test/resources/json/basic-flags-with-non-active-flags.json") + val responseFlagsApi = file.readText() + + val http = + mockHttp( + response = + MockResponse() + .setBody(responseFlagsApi), + ) + http.enqueue( + MockResponse() + .setBody(""), + ) + val url = http.url("/") + val sut = + getSut( + url.toString(), + preloadFeatureFlags = false, + sendFeatureFlagEvent = true, + ) + + sut.reloadFeatureFlags() + + remoteConfigExecutor.shutdownAndAwaitTermination() + + // remove from the http queue + http.takeRequest() + + sut.getFeatureFlag("splashScreenName", sendFeatureFlagEvent = null) + + queueExecutor.shutdownAndAwaitTermination() + + val request = http.takeRequest() + val content = request.body.unGzip() + val batch = serializer.deserialize(content.reader()) + + val theEvent = batch.batch.firstOrNull() + assertEquals("\$feature_flag_called", theEvent?.event) + assertEquals(1, batch.batch.size) + sut.close() + } +}