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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/minimal-flag-called-events.md
Original file line number Diff line number Diff line change
@@ -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/<key>` 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.
14 changes: 10 additions & 4 deletions posthog/api/posthog.api
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
}

Expand All @@ -808,27 +810,30 @@ 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 <init> (ZLjava/util/Map;Ljava/util/Map;Ljava/util/Map;Ljava/util/List;Ljava/lang/String;Ljava/lang/Long;)V
public synthetic fun <init> (ZLjava/util/Map;Ljava/util/Map;Ljava/util/Map;Ljava/util/List;Ljava/lang/String;Ljava/lang/Long;ILkotlin/jvm/internal/DefaultConstructorMarker;)V
public fun <init> (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 <init> (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;
public final fun component4 ()Ljava/util/Map;
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
Expand Down Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions posthog/src/main/java/com/posthog/PostHog.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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?,
Expand Down
62 changes: 61 additions & 1 deletion posthog/src/main/java/com/posthog/PostHogStateless.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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<String, Any>): Map<String, Any> {
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,
Expand Down Expand Up @@ -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<String>()

@PostHogVisibleForTesting
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -24,4 +26,5 @@ public data class PostHogFlagsResponse(
val quotaLimited: List<String>? = null,
val requestId: String?,
val evaluatedAt: Long?,
val minimalFlagCalledEvents: Boolean? = null,
) : PostHogRemoteConfigResponse()
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<Survey>? = null

@Volatile
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -981,13 +999,16 @@ 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
this.featureFlags = featureFlags
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
Expand Down Expand Up @@ -1240,6 +1261,13 @@ public class PostHogRemoteConfig(
}
}

override fun isMinimalFlagCalledEventsEnabled(): Boolean {
loadFeatureFlagsFromCacheIfNeeded()
synchronized(featureFlagsLock) {
return minimalFlagCalledEvents
}
}

public fun getSurveys(): List<Survey>? {
synchronized(remoteConfigLock) {
return surveys
Expand All @@ -1253,13 +1281,15 @@ public class PostHogRemoteConfig(
this.flags = null
this.requestId = null
this.evaluatedAt = null
this.minimalFlagCalledEvents = false

config.cachePreferences?.let { preferences ->
preferences.remove(FLAGS)
preferences.remove(FEATURE_FLAGS)
preferences.remove(FEATURE_FLAGS_PAYLOAD)
preferences.remove(FEATURE_FLAG_REQUEST_ID)
preferences.remove(FEATURE_FLAG_EVALUATED_AT)
preferences.remove(MINIMAL_FLAG_CALLED_EVENTS)
}
}

Expand Down
34 changes: 34 additions & 0 deletions posthog/src/test/java/com/posthog/PostHogBootstrapTest.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Loading
Loading