diff --git a/.changeset/minimal-flag-called-events.md b/.changeset/minimal-flag-called-events.md new file mode 100644 index 00000000..e6c9aa88 --- /dev/null +++ b/.changeset/minimal-flag-called-events.md @@ -0,0 +1,5 @@ +--- +"posthog": minor +--- + +Send minimal `$feature_flag_called` events when the server opts the team in. When the v2 `/flags` response carries a top-level `minimalFlagCalledEvents: true` AND the evaluated flag's `has_experiment` metadata is `false`, the event's properties are reduced to a strict allowlist (`$feature_flag`, `$feature_flag_response`, `$feature_flag_has_experiment`, the `$feature_flag_*` evaluation-debug properties, `$groups`, `$process_person_profile`, `$session_id`, `$window_id`, `$lib`, `$lib_version`), stripping registered super properties, the static/dynamic context envelope, the `$feature/` enumeration, and `$active_feature_flags`. Any missing signal (field absent from the response or cache, legacy response shape, `has_experiment` unknown) keeps the full legacy event shape, and experiment-linked flags always send the full envelope. The gate is cached alongside the flags so it survives restarts. diff --git a/posthog/api/posthog.api b/posthog/api/posthog.api index 116af9bc..d86ad1b6 100644 --- a/posthog/api/posthog.api +++ b/posthog/api/posthog.api @@ -457,6 +457,7 @@ public class com/posthog/PostHogStateless : com/posthog/PostHogStatelessInterfac 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;ZLjava/util/Map;Ljava/util/Map;Ljava/util/Map;)Z + protected fun isMinimalFlagCalledEventsEnabled ()Z public fun isOptOut ()Z protected final fun mergeGroups (Ljava/util/Map;)Ljava/util/Map; public fun optIn ()V @@ -796,6 +797,7 @@ public abstract interface class com/posthog/internal/PostHogFeatureFlagsInterfac public abstract fun getFeatureFlagResult (Ljava/lang/String;Ljava/lang/String;Ljava/util/Map;Ljava/util/Map;Ljava/util/Map;)Lcom/posthog/FeatureFlagResult; public abstract fun getFeatureFlags (Ljava/lang/String;Ljava/util/Map;Ljava/util/Map;Ljava/util/Map;)Ljava/util/Map; public abstract fun getRequestId (Ljava/lang/String;Ljava/util/Map;Ljava/util/Map;Ljava/util/Map;)Ljava/lang/String; + public abstract fun isMinimalFlagCalledEventsEnabled ()Z public abstract fun shutDown ()V } @@ -808,12 +810,13 @@ public final class com/posthog/internal/PostHogFeatureFlagsInterface$DefaultImpl public static synthetic fun getFeatureFlagResult$default (Lcom/posthog/internal/PostHogFeatureFlagsInterface;Ljava/lang/String;Ljava/lang/String;Ljava/util/Map;Ljava/util/Map;Ljava/util/Map;ILjava/lang/Object;)Lcom/posthog/FeatureFlagResult; public static synthetic fun getFeatureFlags$default (Lcom/posthog/internal/PostHogFeatureFlagsInterface;Ljava/lang/String;Ljava/util/Map;Ljava/util/Map;Ljava/util/Map;ILjava/lang/Object;)Ljava/util/Map; public static synthetic fun getRequestId$default (Lcom/posthog/internal/PostHogFeatureFlagsInterface;Ljava/lang/String;Ljava/util/Map;Ljava/util/Map;Ljava/util/Map;ILjava/lang/Object;)Ljava/lang/String; + public static fun isMinimalFlagCalledEventsEnabled (Lcom/posthog/internal/PostHogFeatureFlagsInterface;)Z public static fun shutDown (Lcom/posthog/internal/PostHogFeatureFlagsInterface;)V } public final class com/posthog/internal/PostHogFlagsResponse : com/posthog/internal/PostHogRemoteConfigResponse { - public fun (ZLjava/util/Map;Ljava/util/Map;Ljava/util/Map;Ljava/util/List;Ljava/lang/String;Ljava/lang/Long;)V - public synthetic fun (ZLjava/util/Map;Ljava/util/Map;Ljava/util/Map;Ljava/util/List;Ljava/lang/String;Ljava/lang/Long;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public fun (ZLjava/util/Map;Ljava/util/Map;Ljava/util/Map;Ljava/util/List;Ljava/lang/String;Ljava/lang/Long;Ljava/lang/Boolean;)V + public synthetic fun (ZLjava/util/Map;Ljava/util/Map;Ljava/util/Map;Ljava/util/List;Ljava/lang/String;Ljava/lang/Long;Ljava/lang/Boolean;ILkotlin/jvm/internal/DefaultConstructorMarker;)V public final fun component1 ()Z public final fun component2 ()Ljava/util/Map; public final fun component3 ()Ljava/util/Map; @@ -821,14 +824,16 @@ public final class com/posthog/internal/PostHogFlagsResponse : com/posthog/inter public final fun component5 ()Ljava/util/List; public final fun component6 ()Ljava/lang/String; public final fun component7 ()Ljava/lang/Long; - public final fun copy (ZLjava/util/Map;Ljava/util/Map;Ljava/util/Map;Ljava/util/List;Ljava/lang/String;Ljava/lang/Long;)Lcom/posthog/internal/PostHogFlagsResponse; - public static synthetic fun copy$default (Lcom/posthog/internal/PostHogFlagsResponse;ZLjava/util/Map;Ljava/util/Map;Ljava/util/Map;Ljava/util/List;Ljava/lang/String;Ljava/lang/Long;ILjava/lang/Object;)Lcom/posthog/internal/PostHogFlagsResponse; + public final fun component8 ()Ljava/lang/Boolean; + public final fun copy (ZLjava/util/Map;Ljava/util/Map;Ljava/util/Map;Ljava/util/List;Ljava/lang/String;Ljava/lang/Long;Ljava/lang/Boolean;)Lcom/posthog/internal/PostHogFlagsResponse; + public static synthetic fun copy$default (Lcom/posthog/internal/PostHogFlagsResponse;ZLjava/util/Map;Ljava/util/Map;Ljava/util/Map;Ljava/util/List;Ljava/lang/String;Ljava/lang/Long;Ljava/lang/Boolean;ILjava/lang/Object;)Lcom/posthog/internal/PostHogFlagsResponse; public fun equals (Ljava/lang/Object;)Z public final fun getErrorsWhileComputingFlags ()Z public final fun getEvaluatedAt ()Ljava/lang/Long; public final fun getFeatureFlagPayloads ()Ljava/util/Map; public final fun getFeatureFlags ()Ljava/util/Map; public final fun getFlags ()Ljava/util/Map; + public final fun getMinimalFlagCalledEvents ()Ljava/lang/Boolean; public final fun getQuotaLimited ()Ljava/util/List; public final fun getRequestId ()Ljava/lang/String; public fun hashCode ()I @@ -959,6 +964,7 @@ public final class com/posthog/internal/PostHogRemoteConfig : com/posthog/intern public final fun isAutocaptureExceptionsEnabled ()Z public final fun isCaptureNetworkTimingEnabled ()Z public final fun isConsoleLogRecordingEnabled ()Z + public fun isMinimalFlagCalledEventsEnabled ()Z public final fun isSessionReplayFlagActive ()Z public final fun loadFeatureFlags (Ljava/lang/String;Ljava/lang/String;Ljava/util/Map;Lcom/posthog/PostHogOnFeatureFlags;Lcom/posthog/PostHogOnFeatureFlags;)V public static synthetic fun loadFeatureFlags$default (Lcom/posthog/internal/PostHogRemoteConfig;Ljava/lang/String;Ljava/lang/String;Ljava/util/Map;Lcom/posthog/PostHogOnFeatureFlags;Lcom/posthog/PostHogOnFeatureFlags;ILjava/lang/Object;)V diff --git a/posthog/src/main/java/com/posthog/PostHog.kt b/posthog/src/main/java/com/posthog/PostHog.kt index 7b452a56..f2e140dd 100644 --- a/posthog/src/main/java/com/posthog/PostHog.kt +++ b/posthog/src/main/java/com/posthog/PostHog.kt @@ -1692,6 +1692,10 @@ public class PostHog private constructor( } } + override fun isMinimalFlagCalledEventsEnabled(): Boolean { + return remoteConfig?.isMinimalFlagCalledEventsEnabled() == true + } + public override fun getFeatureFlag( key: String, defaultValue: Any?, diff --git a/posthog/src/main/java/com/posthog/PostHogStateless.kt b/posthog/src/main/java/com/posthog/PostHogStateless.kt index 5b7a75e2..39a0bcf0 100644 --- a/posthog/src/main/java/com/posthog/PostHogStateless.kt +++ b/posthog/src/main/java/com/posthog/PostHogStateless.kt @@ -253,11 +253,18 @@ public open class PostHogStateless protected constructor( appendGroups = !groupIdentify, ) + val eventProperties = + if (event == PostHogEventName.FEATURE_FLAG_CALLED.event) { + minimizeFeatureFlagCalledProperties(mergedProperties) + } else { + mergedProperties + } + val postHogEvent = buildEvent( event, distinctId, - mergedProperties.toMutableMap(), + eventProperties.toMutableMap(), timestamp, ) if (postHogEvent == null) { @@ -493,6 +500,33 @@ public open class PostHogStateless protected constructor( captureStateless(PostHogEventName.FEATURE_FLAG_CALLED.event, distinctId, properties = props, groups = groups) } + /** + * Reduces a `$feature_flag_called` event to the cross-SDK minimal allowlist when the server + * enabled minimal flag-called events for this team AND the evaluated flag is not linked to an + * experiment. Any missing signal (gate absent from the response or cache, `has_experiment` + * unknown) keeps the full legacy shape, and experiment-linked flags always send the full + * envelope because experiment exposure analysis reads it. + */ + private fun minimizeFeatureFlagCalledProperties(properties: Map): Map { + if (!isMinimalFlagCalledEventsEnabled()) { + return properties + } + if (properties["\$feature_flag_has_experiment"] != false) { + return properties + } + + return properties.filterKeys { it in MINIMAL_FEATURE_FLAG_CALLED_PROPERTIES } + } + + /** + * Whether the server enabled minimal `$feature_flag_called` events for this team. The client + * SDK stores its remote config on [PostHogConfig] rather than on [featureFlags], so it + * overrides this to consult it there. + */ + protected open fun isMinimalFlagCalledEventsEnabled(): Boolean { + return featureFlags?.isMinimalFlagCalledEventsEnabled() == true + } + public override fun getFeatureFlagStateless( distinctId: String, key: String, @@ -649,6 +683,32 @@ public open class PostHogStateless protected constructor( private const val GROUP_IDENTIFY = "\$groupidentify" + // Strict allowlist for minimal $feature_flag_called events, defined by the cross-SDK + // contract: everything not listed is stripped, including registered super properties, the + // static and dynamic context envelope, and the $feature_flag_bootstrapped_* / + // $used_bootstrap_value fields this SDK otherwise sets. The list follows the shared + // contract, so it may include properties this SDK does not set yet (e.g. locally_evaluated, + // set only by posthog-server). + private val MINIMAL_FEATURE_FLAG_CALLED_PROPERTIES = + setOf( + "\$feature_flag", + "\$feature_flag_response", + "\$feature_flag_has_experiment", + "\$feature_flag_id", + "\$feature_flag_version", + "\$feature_flag_reason", + "\$feature_flag_request_id", + "\$feature_flag_evaluated_at", + "\$feature_flag_error", + "locally_evaluated", + "\$groups", + "\$process_person_profile", + "\$session_id", + "\$window_id", + "\$lib", + "\$lib_version", + ) + private val apiKeys = mutableSetOf() @PostHogVisibleForTesting diff --git a/posthog/src/main/java/com/posthog/internal/PostHogFeatureFlagsInterface.kt b/posthog/src/main/java/com/posthog/internal/PostHogFeatureFlagsInterface.kt index 5d3f9ec0..7e8e62c1 100644 --- a/posthog/src/main/java/com/posthog/internal/PostHogFeatureFlagsInterface.kt +++ b/posthog/src/main/java/com/posthog/internal/PostHogFeatureFlagsInterface.kt @@ -64,4 +64,13 @@ public interface PostHogFeatureFlagsInterface { ): FeatureFlag? { return null } + + /** + * Whether the server enabled minimal `$feature_flag_called` events for this team, from the + * top-level `minimalFlagCalledEvents` field of the v2 `/flags` response. Default false so + * implementations without the signal keep sending the full event shape (fail-safe). + */ + public fun isMinimalFlagCalledEventsEnabled(): Boolean { + return false + } } diff --git a/posthog/src/main/java/com/posthog/internal/PostHogFlagsResponse.kt b/posthog/src/main/java/com/posthog/internal/PostHogFlagsResponse.kt index 926fc5ce..02f8543f 100644 --- a/posthog/src/main/java/com/posthog/internal/PostHogFlagsResponse.kt +++ b/posthog/src/main/java/com/posthog/internal/PostHogFlagsResponse.kt @@ -12,6 +12,8 @@ import org.codehaus.mojo.animal_sniffer.IgnoreJRERequirement * @property quotaLimited array of quota limited features * @property requestId the request id generated by the flags server on evaluation * @property evaluatedAt the evaluated at timestamp generated by the flags server on evaluation + * @property minimalFlagCalledEvents whether the server enabled minimal `$feature_flag_called` + * events for this team; only the v2 response shape carries it, so it is null on legacy responses */ @IgnoreJRERequirement @PostHogInternal @@ -24,4 +26,5 @@ public data class PostHogFlagsResponse( val quotaLimited: List? = null, val requestId: String?, val evaluatedAt: Long?, + val minimalFlagCalledEvents: Boolean? = null, ) : PostHogRemoteConfigResponse() diff --git a/posthog/src/main/java/com/posthog/internal/PostHogPreferences.kt b/posthog/src/main/java/com/posthog/internal/PostHogPreferences.kt index 1c02b5ff..9c5a01e7 100644 --- a/posthog/src/main/java/com/posthog/internal/PostHogPreferences.kt +++ b/posthog/src/main/java/com/posthog/internal/PostHogPreferences.kt @@ -46,6 +46,7 @@ public interface PostHogPreferences { internal const val FEATURE_FLAGS_PAYLOAD = "featureFlagsPayload" internal const val FEATURE_FLAG_REQUEST_ID = "feature_flag_request_id" internal const val FEATURE_FLAG_EVALUATED_AT = "feature_flag_evaluated_at" + internal const val MINIMAL_FLAG_CALLED_EVENTS = "minimal_flag_called_events" internal const val SESSION_REPLAY = "sessionReplay" internal const val SURVEYS = "surveys" internal const val ERROR_TRACKING = "errorTracking" @@ -79,6 +80,7 @@ public interface PostHogPreferences { STRINGIFIED_KEYS, FEATURE_FLAG_REQUEST_ID, FEATURE_FLAG_EVALUATED_AT, + MINIMAL_FLAG_CALLED_EVENTS, FLAGS, PERSON_PROPERTIES_FOR_FLAGS, GROUP_PROPERTIES_FOR_FLAGS, diff --git a/posthog/src/main/java/com/posthog/internal/PostHogRemoteConfig.kt b/posthog/src/main/java/com/posthog/internal/PostHogRemoteConfig.kt index b9b732d3..59b50407 100644 --- a/posthog/src/main/java/com/posthog/internal/PostHogRemoteConfig.kt +++ b/posthog/src/main/java/com/posthog/internal/PostHogRemoteConfig.kt @@ -12,6 +12,7 @@ import com.posthog.internal.PostHogPreferences.Companion.FEATURE_FLAGS_PAYLOAD import com.posthog.internal.PostHogPreferences.Companion.FEATURE_FLAG_EVALUATED_AT import com.posthog.internal.PostHogPreferences.Companion.FEATURE_FLAG_REQUEST_ID import com.posthog.internal.PostHogPreferences.Companion.FLAGS +import com.posthog.internal.PostHogPreferences.Companion.MINIMAL_FLAG_CALLED_EVENTS import com.posthog.internal.PostHogPreferences.Companion.SESSION_REPLAY import com.posthog.internal.PostHogPreferences.Companion.SURVEYS import com.posthog.surveys.Survey @@ -100,6 +101,11 @@ public class PostHogRemoteConfig( private var requestId: String? = null private var evaluatedAt: Long? = null + // Server-controlled gate for minimal $feature_flag_called events (top-level + // minimalFlagCalledEvents of the v2 /flags response). Absent means full events, so it is + // overwritten from every /flags response rather than merged. Guarded by featureFlagsLock. + private var minimalFlagCalledEvents: Boolean = false + private var surveys: List? = null @Volatile @@ -734,6 +740,10 @@ public class PostHogRemoteConfig( val normalizedResponse = normalizeFlagsResponse(it) + // A response without the gate (legacy shape or ungated team) disables it, + // so full events resume as soon as the server stops sending the field. + minimalFlagCalledEvents = it.minimalFlagCalledEvents == true + if (normalizedResponse.errorsWhileComputingFlags) { // Partial/errored response: merge into the existing served flags (which may be // the bootstrap snapshot) so un-recomputed values, including bootstrapped ones, @@ -823,6 +833,14 @@ public class PostHogRemoteConfig( payloads.filterKeys { it in serverEvaluatedPayloadKeys } } preferences.setValue(FEATURE_FLAGS_PAYLOAD, serverPayloads) + + // Persist the gate alongside the cached flags so it survives restarts; + // an absent cache entry means full events (fail-safe). + if (this.minimalFlagCalledEvents) { + preferences.setValue(MINIMAL_FLAG_CALLED_EVENTS, true) + } else { + preferences.remove(MINIMAL_FLAG_CALLED_EVENTS) + } } isFeatureFlagsLoaded = true setFlagsLoadedFromRemote() @@ -981,6 +999,8 @@ public class PostHogRemoteConfig( val cachedRequestId = preferences.getValue(FEATURE_FLAG_REQUEST_ID) as? String val cachedEvaluatedAt = preferences.getValue(FEATURE_FLAG_EVALUATED_AT) as? Long + // Absent from the cache means full events (fail-safe). + val cachedMinimalFlagCalledEvents = preferences.getValue(MINIMAL_FLAG_CALLED_EVENTS) as? Boolean ?: false synchronized(featureFlagsLock) { this.flags = flags @@ -988,6 +1008,7 @@ public class PostHogRemoteConfig( this.featureFlagPayloads = payloads this.requestId = cachedRequestId this.evaluatedAt = cachedEvaluatedAt + this.minimalFlagCalledEvents = cachedMinimalFlagCalledEvents // Bootstrap is an initial snapshot that wins over persisted flags (spec precedence). applyBootstrapSnapshotLocked() isFeatureFlagsLoaded = true @@ -1240,6 +1261,13 @@ public class PostHogRemoteConfig( } } + override fun isMinimalFlagCalledEventsEnabled(): Boolean { + loadFeatureFlagsFromCacheIfNeeded() + synchronized(featureFlagsLock) { + return minimalFlagCalledEvents + } + } + public fun getSurveys(): List? { synchronized(remoteConfigLock) { return surveys @@ -1253,6 +1281,7 @@ public class PostHogRemoteConfig( this.flags = null this.requestId = null this.evaluatedAt = null + this.minimalFlagCalledEvents = false config.cachePreferences?.let { preferences -> preferences.remove(FLAGS) @@ -1260,6 +1289,7 @@ public class PostHogRemoteConfig( preferences.remove(FEATURE_FLAGS_PAYLOAD) preferences.remove(FEATURE_FLAG_REQUEST_ID) preferences.remove(FEATURE_FLAG_EVALUATED_AT) + preferences.remove(MINIMAL_FLAG_CALLED_EVENTS) } } diff --git a/posthog/src/test/java/com/posthog/PostHogBootstrapTest.kt b/posthog/src/test/java/com/posthog/PostHogBootstrapTest.kt index 60d00b2b..f0e0a6e6 100644 --- a/posthog/src/test/java/com/posthog/PostHogBootstrapTest.kt +++ b/posthog/src/test/java/com/posthog/PostHogBootstrapTest.kt @@ -324,6 +324,40 @@ internal class PostHogBootstrapTest { sut.close() } + @Test + fun `minimal flag called event strips bootstrap fields even though this SDK sets them`() { + // fixture returns minimalFlagCalledEvents: true and IAmInactive with has_experiment: false; + // bootstrap the same key so $feature_flag_bootstrapped_response / $used_bootstrap_value are + // set on the event (PostHog.kt) before minimization runs + val flags = File("src/test/resources/json/basic-flags-minimal-flag-called-events.json").readText() + val http = mockHttp(response = MockResponse().setBody(flags)) + http.enqueue(MockResponse().setBody("")) + val sut = + getSut( + host = http.url("/").toString(), + bootstrap = PostHogBootstrapConfig(featureFlags = mapOf("IAmInactive" to false)), + ) + + sut.reloadFeatureFlags() + remoteConfigExecutor.shutdownAndAwaitTermination() + http.takeRequest() // drop the /flags request + + val value = sut.getFeatureFlag("IAmInactive", sendFeatureFlagEvent = true) + assertEquals(false, value) + + queueExecutor.shutdownAndAwaitTermination() + + val event = firstFeatureFlagCalled(http) + // gate is on and has_experiment is false, so the minimal allowlist applies + assertEquals(false, event.properties?.get("\$feature_flag_has_experiment")) + // the bootstrap fields are set upstream but are not in the allowlist, so they are stripped + assertFalse(event.properties!!.containsKey("\$feature_flag_bootstrapped_response")) + assertFalse(event.properties!!.containsKey("\$used_bootstrap_value")) + + sut.close() + http.shutdown() + } + private fun firstEvent( http: okhttp3.mockwebserver.MockWebServer, event: String, diff --git a/posthog/src/test/java/com/posthog/PostHogTest.kt b/posthog/src/test/java/com/posthog/PostHogTest.kt index 462d61fd..715cbf8e 100644 --- a/posthog/src/test/java/com/posthog/PostHogTest.kt +++ b/posthog/src/test/java/com/posthog/PostHogTest.kt @@ -518,6 +518,270 @@ internal class PostHogTest { sut.close() } + @Test + fun `feature flag called event is minimal when gated and the flag has no experiment`() { + val file = File("src/test/resources/json/basic-flags-minimal-flag-called-events.json") + val responseFlagsApi = file.readText() + + val http = + mockHttp( + response = + MockResponse() + .setBody(responseFlagsApi), + ) + http.enqueue( + MockResponse() + .setBody(""), + ) + val url = http.url("/") + + val myPrefs = PostHogMemoryPreferences() + myPrefs.setValue(GROUPS, groups) + val sut = + getSut( + url.toString(), + preloadFeatureFlags = false, + cachePreferences = myPrefs, + context = TestPostHogContext(), + ) + + // a registered super property must be stripped from the minimal event + sut.register("myCustomProp", "value") + + sut.reloadFeatureFlags() + + remoteConfigExecutor.shutdownAndAwaitTermination() + + // remove from the http queue + http.takeRequest() + + // has_experiment: false in the flag metadata + assertFalse(sut.getFeatureFlag("IAmInactive") as Boolean) + + queueExecutor.shutdownAndAwaitTermination() + + val request = http.takeRequest() + val content = request.body.unGzip() + val batch = serializer.deserialize(content.reader()) + + val theEvent = batch.batch.first() + assertEquals("\$feature_flag_called", theEvent.event) + assertNotNull(theEvent.distinctId) + assertNotNull(theEvent.timestamp) + assertNotNull(theEvent.uuid) + + // strict allowlist: everything else (super props, context envelope, $feature/ + // enumeration, $active_feature_flags, $is_identified, ...) is stripped + val expectedKeys = + setOf( + "\$feature_flag", + "\$feature_flag_response", + "\$feature_flag_has_experiment", + "\$feature_flag_id", + "\$feature_flag_version", + "\$feature_flag_reason", + "\$feature_flag_request_id", + "\$groups", + "\$process_person_profile", + "\$session_id", + "\$lib", + "\$lib_version", + ) + assertEquals(expectedKeys, theEvent.properties!!.keys) + + assertEquals("IAmInactive", theEvent.properties!!["\$feature_flag"]) + assertEquals(false, theEvent.properties!!["\$feature_flag_response"]) + assertEquals(false, theEvent.properties!!["\$feature_flag_has_experiment"]) + assertEquals(2, theEvent.properties!!["\$feature_flag_id"]) + assertEquals(2, theEvent.properties!!["\$feature_flag_version"]) + assertEquals("No matching condition set", theEvent.properties!!["\$feature_flag_reason"]) + assertEquals("171d83c3-4ac2-4bff-961d-efe3a0c3539c", theEvent.properties!!["\$feature_flag_request_id"]) + + sut.close() + } + + @Test + fun `feature flag called event keeps the full shape when gated and the flag has an experiment`() { + val file = File("src/test/resources/json/basic-flags-minimal-flag-called-events.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) + + sut.reloadFeatureFlags() + + remoteConfigExecutor.shutdownAndAwaitTermination() + + // remove from the http queue + http.takeRequest() + + // has_experiment: true in the flag metadata, so the full envelope is kept + assertTrue(sut.getFeatureFlag("4535-funnel-bar-viz") as Boolean) + + queueExecutor.shutdownAndAwaitTermination() + + val request = http.takeRequest() + val content = request.body.unGzip() + val batch = serializer.deserialize(content.reader()) + + val theEvent = batch.batch.first() + assertEquals("\$feature_flag_called", theEvent.event) + assertEquals(true, theEvent.properties!!["\$feature_flag_has_experiment"]) + assertEquals(true, theEvent.properties!!["\$feature/4535-funnel-bar-viz"]) + assertTrue(theEvent.properties!!.containsKey("\$active_feature_flags")) + + sut.close() + } + + @Test + fun `feature flag called event keeps the full shape when gated and has_experiment is absent`() { + val file = File("src/test/resources/json/basic-flags-minimal-flag-called-events.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) + + sut.reloadFeatureFlags() + + remoteConfigExecutor.shutdownAndAwaitTermination() + + // remove from the http queue + http.takeRequest() + + // has_experiment is absent from the flag metadata, so the full envelope is kept + assertEquals("SplashV2", sut.getFeatureFlag("splashScreenName") as String) + + queueExecutor.shutdownAndAwaitTermination() + + val request = http.takeRequest() + val content = request.body.unGzip() + val batch = serializer.deserialize(content.reader()) + + val theEvent = batch.batch.first() + assertEquals("\$feature_flag_called", theEvent.event) + assertFalse(theEvent.properties!!.containsKey("\$feature_flag_has_experiment")) + assertEquals("SplashV2", theEvent.properties!!["\$feature/splashScreenName"]) + assertTrue(theEvent.properties!!.containsKey("\$active_feature_flags")) + + sut.close() + } + + @Test + fun `feature flag called event keeps the full shape when the server does not send the gate`() { + 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) + + sut.reloadFeatureFlags() + + remoteConfigExecutor.shutdownAndAwaitTermination() + + // remove from the http queue + http.takeRequest() + + // has_experiment: false, but the response has no minimalFlagCalledEvents field + assertFalse(sut.getFeatureFlag("IAmInactive") as Boolean) + + queueExecutor.shutdownAndAwaitTermination() + + val request = http.takeRequest() + val content = request.body.unGzip() + val batch = serializer.deserialize(content.reader()) + + val theEvent = batch.batch.first() + assertEquals("\$feature_flag_called", theEvent.event) + assertEquals(false, theEvent.properties!!["\$feature_flag_has_experiment"]) + assertTrue(theEvent.properties!!.containsKey("\$active_feature_flags")) + + sut.close() + } + + @Test + fun `minimal flag called gate persists across SDK restarts`() { + val file = File("src/test/resources/json/basic-flags-minimal-flag-called-events.json") + val responseFlagsApi = file.readText() + + val http = + mockHttp( + response = + MockResponse() + .setBody(responseFlagsApi), + ) + http.enqueue( + MockResponse() + .setBody(""), + ) + val url = http.url("/") + + val cachePreferences = PostHogMemoryPreferences() + val sut = getSut(url.toString(), preloadFeatureFlags = false, cachePreferences = cachePreferences) + + sut.reloadFeatureFlags() + + remoteConfigExecutor.shutdownAndAwaitTermination() + + // remove from the http queue + http.takeRequest() + + sut.close() + + // re-init with the same preferences, serving flags and the gate from the cache + val sut2 = getSut(url.toString(), preloadFeatureFlags = false, reloadFeatureFlags = false, cachePreferences = cachePreferences) + + assertFalse(sut2.getFeatureFlag("IAmInactive") as Boolean) + + queueExecutor.shutdownAndAwaitTermination() + + val request = http.takeRequest() + val content = request.body.unGzip() + val batch = serializer.deserialize(content.reader()) + + val theEvent = batch.batch.first() + assertEquals("\$feature_flag_called", theEvent.event) + assertEquals(false, theEvent.properties!!["\$feature_flag_has_experiment"]) + assertEquals(2, theEvent.properties!!["\$feature_flag_id"]) + assertFalse(theEvent.properties!!.containsKey("\$active_feature_flags")) + assertFalse(theEvent.properties!!.containsKey("\$feature/IAmInactive")) + assertFalse(theEvent.properties!!.containsKey("\$is_identified")) + + sut2.close() + } + @Test fun `isFeatureEnabled captures feature flag event if enabled`() { val file = File("src/test/resources/json/basic-flags-with-non-active-flags.json") diff --git a/posthog/src/test/java/com/posthog/internal/PostHogFeatureFlagsTest.kt b/posthog/src/test/java/com/posthog/internal/PostHogFeatureFlagsTest.kt index f3b162de..ef8f65b2 100644 --- a/posthog/src/test/java/com/posthog/internal/PostHogFeatureFlagsTest.kt +++ b/posthog/src/test/java/com/posthog/internal/PostHogFeatureFlagsTest.kt @@ -5,6 +5,7 @@ import com.posthog.PostHogConfig import com.posthog.awaitExecution import com.posthog.internal.PostHogPreferences.Companion.FEATURE_FLAGS import com.posthog.internal.PostHogPreferences.Companion.FEATURE_FLAGS_PAYLOAD +import com.posthog.internal.PostHogPreferences.Companion.MINIMAL_FLAG_CALLED_EVENTS import com.posthog.mockHttp import com.posthog.shutdownAndAwaitTermination import okhttp3.mockwebserver.MockResponse @@ -123,6 +124,87 @@ internal class PostHogFeatureFlagsTest { assertNull(preferences.getValue(FEATURE_FLAGS_PAYLOAD)) } + @Test + fun `minimal flag called events gate is parsed from the flags response and persisted`() { + val file = File("src/test/resources/json/basic-flags-minimal-flag-called-events.json") + + val http = + mockHttp( + response = + MockResponse() + .setBody(file.readText()), + ) + val url = http.url("/") + + val sut = getSut(host = url.toString()) + + sut.loadFeatureFlags("my_identify", anonymousId = "anonId", emptyMap()) + + executor.shutdownAndAwaitTermination() + + assertTrue(sut.isMinimalFlagCalledEventsEnabled()) + assertEquals(true, preferences.getValue(MINIMAL_FLAG_CALLED_EVENTS)) + } + + @Test + fun `minimal flag called events gate is disabled when the response does not carry it`() { + val file = File("src/test/resources/json/basic-flags-minimal-flag-called-events.json") + + val http = + mockHttp( + response = + MockResponse() + .setBody(file.readText()), + ) + // the next response no longer carries the gate + http.enqueue( + MockResponse() + .setBody(responseFlagsApi), + ) + val url = http.url("/") + + val sut = getSut(host = url.toString()) + + sut.loadFeatureFlags("my_identify", anonymousId = "anonId", emptyMap()) + + executor.awaitExecution() + + assertTrue(sut.isMinimalFlagCalledEventsEnabled()) + + sut.loadFeatureFlags("my_identify", anonymousId = "anonId", emptyMap()) + + executor.shutdownAndAwaitTermination() + + assertFalse(sut.isMinimalFlagCalledEventsEnabled()) + assertNull(preferences.getValue(MINIMAL_FLAG_CALLED_EVENTS)) + } + + @Test + fun `clear removes the minimal flag called events gate`() { + val file = File("src/test/resources/json/basic-flags-minimal-flag-called-events.json") + + val http = + mockHttp( + response = + MockResponse() + .setBody(file.readText()), + ) + val url = http.url("/") + + val sut = getSut(host = url.toString()) + + sut.loadFeatureFlags("my_identify", anonymousId = "anonId", emptyMap()) + + executor.shutdownAndAwaitTermination() + + assertTrue(sut.isMinimalFlagCalledEventsEnabled()) + + sut.clear() + + assertFalse(sut.isMinimalFlagCalledEventsEnabled()) + assertNull(preferences.getValue(MINIMAL_FLAG_CALLED_EVENTS)) + } + @Test fun `returns all feature flags`() { val http = diff --git a/posthog/src/test/resources/json/basic-flags-minimal-flag-called-events.json b/posthog/src/test/resources/json/basic-flags-minimal-flag-called-events.json new file mode 100644 index 00000000..bf9961a0 --- /dev/null +++ b/posthog/src/test/resources/json/basic-flags-minimal-flag-called-events.json @@ -0,0 +1,74 @@ +{ + "autocaptureExceptions": false, + "toolbarParams": {}, + "errorsWhileComputingFlags": false, + "capturePerformance": true, + "autocapture_opt_out": false, + "isAuthenticated": false, + "supportedCompression": [ + "gzip", + "gzip-js" + ], + "config": { + "enable_collect_everything": true + }, + "flags": { + "4535-funnel-bar-viz": { + "key": "4535-funnel-bar-viz", + "enabled": true, + "variant": null, + "reason": { + "code": "condition_match", + "description": "Matched condition set 3", + "condition_index": 2 + }, + "metadata": { + "id": 4535, + "version": 2, + "payload": "true", + "has_experiment": true + } + }, + "IAmInactive": { + "key": "IAmInactive", + "enabled": false, + "variant": null, + "reason": { + "code": "no_condition_match", + "description": "No matching condition set", + "condition_index": null + }, + "metadata": { + "id": 2, + "version": 2, + "payload": "false", + "has_experiment": false + } + }, + "splashScreenName": { + "key": "splashScreenName", + "enabled": true, + "variant": "SplashV2", + "reason": { + "code": "condition_match", + "description": "Matched condition set 3", + "condition_index": 2 + }, + "metadata": { + "id": 3, + "version": 2, + "payload": null + } + } + }, + "sessionRecording": false, + "siteApps": [ + { + "id": 21039.0, + "url": "/site_app/21039/EOsOSePYNyTzHkZ3f4mjrjUap8Hy8o2vUTAc6v1ZMFP/576ac89bc8aed72a21d9b19221c2c626/" + } + ], + "editorParams": {}, + "requestId": "171d83c3-4ac2-4bff-961d-efe3a0c3539c", + "minimalFlagCalledEvents": true +}