Skip to content
Merged
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
6 changes: 6 additions & 0 deletions .changeset/feature-flag-has-experiment-property.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"posthog": minor
"posthog-server": minor
---

Add a `$feature_flag_has_experiment` boolean property to `$feature_flag_called` events, sourced from the `has_experiment` field the server reports in each flag's metadata (`/flags?v=2` and `/local_evaluation`). The property is only sent when the server explicitly reported `has_experiment`; it is omitted when the server did not report it (older deployments) or when flag details are unavailable.
Original file line number Diff line number Diff line change
Expand Up @@ -216,6 +216,7 @@ public class PostHogFeatureFlagEvaluations internal constructor(
props["locally_evaluated"] = true
}
}
flag?.metadata?.hasExperiment?.let { props["\$feature_flag_has_experiment"] = it }
requestId?.let { props["\$feature_flag_request_id"] = it }
evaluatedAt?.let { props["\$feature_flag_evaluated_at"] = it }
definitionsLoadedAt?.let { props["\$feature_flag_definitions_loaded_at"] = it }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -676,6 +676,7 @@ internal class PostHogFeatureFlags(
id = flagDef.id,
payload = payload,
version = flagDef.version,
hasExperiment = flagDef.hasExperiment,
),
reason =
com.posthog.internal.EvaluationReason(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,51 @@ internal class PostHogEvaluateFlagsTest {
assertEquals(4.0, props["\$feature_flag_version"])
assertEquals("Matched", props["\$feature_flag_reason"])
assertEquals("req-fixture", props["\$feature_flag_request_id"])
// has_experiment absent from the response metadata, so the property is omitted
assertFalse(props.containsKey("\$feature_flag_has_experiment"))

postHog.close()
mockServer.shutdown()
}

@Test
fun `feature_flag_called reports has_experiment true when the response metadata reports it`() {
val flagsBody =
"""
{
"flags": {
"a": {
"key": "a",
"enabled": true,
"variant": null,
"metadata": { "version": 4, "payload": null, "id": 11, "has_experiment": true },
"reason": { "code": "condition_match", "description": "Matched", "condition_index": 0 }
}
},
"requestId": "req-fixture"
}
""".trimIndent()
val mockServer = MockWebServer()
mockServer.enqueue(jsonResponse(flagsBody))
mockServer.enqueue(MockResponse().setResponseCode(200))
mockServer.start()

val postHog =
PostHog.with(
PostHogConfig.builder(TEST_API_KEY)
.host(mockServer.url("/").toString())
.flushAt(1)
.build(),
)

val snapshot = postHog.evaluateFlags("user-1")
snapshot.isEnabled("a")
postHog.flush()

val requests = drainRequests(mockServer)
val batch = requests.first { it.path?.contains("/batch") == true }.parseBatch()
val props = batch.eventProperties("\$feature_flag_called")
assertEquals(true, props["\$feature_flag_has_experiment"])

postHog.close()
mockServer.shutdown()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -46,12 +46,13 @@ internal class PostHogFeatureFlagEvaluationsTest {
version: Int = 3,
payload: String? = null,
reason: EvaluationReason? = EvaluationReason("condition_match", "Condition matched", 0),
hasExperiment: Boolean? = null,
): FeatureFlag =
FeatureFlag(
key = key,
enabled = enabled,
variant = variant,
metadata = FeatureFlagMetadata(id = id, payload = payload, version = version),
metadata = FeatureFlagMetadata(id = id, payload = payload, version = version, hasExperiment = hasExperiment),
reason = reason,
)

Expand Down Expand Up @@ -158,6 +159,32 @@ internal class PostHogFeatureFlagEvaluationsTest {
assertEquals(4, call.properties["\$feature_flag_version"])
assertEquals("Condition matched", call.properties["\$feature_flag_reason"])
assertEquals("req-1", call.properties["\$feature_flag_request_id"])
// the metadata did not report has_experiment, so the property is omitted
assertFalse(call.properties.containsKey("\$feature_flag_has_experiment"))
}

@Test
fun `feature flag called event reports has_experiment from flag metadata`() {
val host = FakeHost()
val snapshot =
snapshot(
host = host,
flags =
mapOf(
"experiment-flag" to flag("experiment-flag", hasExperiment = true),
"plain-flag" to flag("plain-flag", hasExperiment = false),
),
)

snapshot.isEnabled("experiment-flag")
snapshot.isEnabled("plain-flag")
snapshot.isEnabled("missing-flag")

assertEquals(3, host.captures.size)
assertEquals(true, host.captures[0].properties["\$feature_flag_has_experiment"])
assertEquals(false, host.captures[1].properties["\$feature_flag_has_experiment"])
// unknown flags have no metadata, so the property is omitted
assertFalse(host.captures[2].properties.containsKey("\$feature_flag_has_experiment"))
}

@Test
Expand Down
8 changes: 8 additions & 0 deletions posthog-server/src/test/java/com/posthog/server/Utils.kt
Original file line number Diff line number Diff line change
Expand Up @@ -445,13 +445,20 @@ public fun createLocalEvaluationResponse(
flagKey: String,
aggregationGroupTypeIndex: Int? = null,
rolloutPercentage: Int = 100,
hasExperiment: Boolean? = null,
): String {
val aggregationGroupJson =
if (aggregationGroupTypeIndex != null) {
"\"aggregation_group_type_index\": $aggregationGroupTypeIndex,"
} else {
""
}
val hasExperimentJson =
if (hasExperiment != null) {
"\"has_experiment\": $hasExperiment,"
} else {
""
}

return """
{
Expand All @@ -461,6 +468,7 @@ public fun createLocalEvaluationResponse(
"name": "$flagKey",
"key": "$flagKey",
"active": true,
$hasExperimentJson
"filters": {
$aggregationGroupJson
"groups": [
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -448,6 +448,90 @@ internal class PostHogFeatureFlagsTest {
mockServer.shutdown()
}

@Test
fun `local evaluation carries has_experiment from flag definitions into flag details`() {
val localEvalResponse =
createLocalEvaluationResponse(
flagKey = "experiment-flag",
hasExperiment = true,
)

val mockServer = createMockHttp(jsonResponse(localEvalResponse))
val url = mockServer.url("/")

val config = createTestConfig(host = url.toString())
val api = PostHogApi(config)
val loadedLatch = CountDownLatch(1)
val featureFlags =
PostHogFeatureFlags(
config,
api,
60000,
100,
localEvaluation = true,
personalApiKey = "test-personal-key",
pollIntervalSeconds = 30,
onFeatureFlags = { loadedLatch.countDown() },
)
assertTrue(loadedLatch.await(5000, java.util.concurrent.TimeUnit.MILLISECONDS))

val result =
featureFlags.evaluateFlags(
distinctId = "test-user",
groups = null,
personProperties = null,
groupProperties = null,
flagKeys = null,
onlyEvaluateLocally = true,
disableGeoip = false,
)

assertEquals(true, result.flags["experiment-flag"]?.metadata?.hasExperiment)

featureFlags.shutDown()
mockServer.shutdown()
}

@Test
fun `local evaluation leaves has_experiment null when definitions omit it`() {
val localEvalResponse = createLocalEvaluationResponse(flagKey = "plain-flag")

val mockServer = createMockHttp(jsonResponse(localEvalResponse))
val url = mockServer.url("/")

val config = createTestConfig(host = url.toString())
val api = PostHogApi(config)
val loadedLatch = CountDownLatch(1)
val featureFlags =
PostHogFeatureFlags(
config,
api,
60000,
100,
localEvaluation = true,
personalApiKey = "test-personal-key",
pollIntervalSeconds = 30,
onFeatureFlags = { loadedLatch.countDown() },
)
assertTrue(loadedLatch.await(5000, java.util.concurrent.TimeUnit.MILLISECONDS))

val result =
featureFlags.evaluateFlags(
distinctId = "test-user",
groups = null,
personProperties = null,
groupProperties = null,
flagKeys = null,
onlyEvaluateLocally = true,
disableGeoip = false,
)

assertNull(result.flags.getValue("plain-flag").metadata.hasExperiment)

featureFlags.shutDown()
mockServer.shutdown()
}

@Test
fun `poller does not start when pollerEnabled is false`() {
val logger = TestLogger()
Expand Down
14 changes: 9 additions & 5 deletions posthog/api/posthog.api
Original file line number Diff line number Diff line change
Expand Up @@ -588,13 +588,16 @@ public final class com/posthog/internal/FeatureFlag {
}

public final class com/posthog/internal/FeatureFlagMetadata {
public fun <init> (ILjava/lang/String;I)V
public fun <init> (ILjava/lang/String;ILjava/lang/Boolean;)V
public synthetic fun <init> (ILjava/lang/String;ILjava/lang/Boolean;ILkotlin/jvm/internal/DefaultConstructorMarker;)V
public final fun component1 ()I
public final fun component2 ()Ljava/lang/String;
public final fun component3 ()I
public final fun copy (ILjava/lang/String;I)Lcom/posthog/internal/FeatureFlagMetadata;
public static synthetic fun copy$default (Lcom/posthog/internal/FeatureFlagMetadata;ILjava/lang/String;IILjava/lang/Object;)Lcom/posthog/internal/FeatureFlagMetadata;
public final fun component4 ()Ljava/lang/Boolean;
public final fun copy (ILjava/lang/String;ILjava/lang/Boolean;)Lcom/posthog/internal/FeatureFlagMetadata;
public static synthetic fun copy$default (Lcom/posthog/internal/FeatureFlagMetadata;ILjava/lang/String;ILjava/lang/Boolean;ILjava/lang/Object;)Lcom/posthog/internal/FeatureFlagMetadata;
public fun equals (Ljava/lang/Object;)Z
public final fun getHasExperiment ()Ljava/lang/Boolean;
public final fun getId ()I
public final fun getPayload ()Ljava/lang/String;
public final fun getVersion ()I
Expand All @@ -610,11 +613,12 @@ public final class com/posthog/internal/FlagConditionGroup {
}

public final class com/posthog/internal/FlagDefinition {
public fun <init> (ILjava/lang/String;Ljava/lang/String;ZLcom/posthog/internal/FlagFilters;IZ)V
public synthetic fun <init> (ILjava/lang/String;Ljava/lang/String;ZLcom/posthog/internal/FlagFilters;IZILkotlin/jvm/internal/DefaultConstructorMarker;)V
public fun <init> (ILjava/lang/String;Ljava/lang/String;ZLcom/posthog/internal/FlagFilters;IZLjava/lang/Boolean;)V
public synthetic fun <init> (ILjava/lang/String;Ljava/lang/String;ZLcom/posthog/internal/FlagFilters;IZLjava/lang/Boolean;ILkotlin/jvm/internal/DefaultConstructorMarker;)V
public final fun getActive ()Z
public final fun getEnsureExperienceContinuity ()Z
public final fun getFilters ()Lcom/posthog/internal/FlagFilters;
public final fun getHasExperiment ()Ljava/lang/Boolean;
public final fun getId ()I
public final fun getKey ()Ljava/lang/String;
public final fun getName ()Ljava/lang/String;
Expand Down
3 changes: 3 additions & 0 deletions posthog/src/main/java/com/posthog/PostHog.kt
Original file line number Diff line number Diff line change
Expand Up @@ -1509,6 +1509,9 @@ public class PostHog private constructor(
props["\$feature_flag_id"] = it.metadata.id
props["\$feature_flag_version"] = it.metadata.version
props["\$feature_flag_reason"] = it.reason?.description ?: ""
it.metadata.hasExperiment?.let { hasExperiment ->
props["\$feature_flag_has_experiment"] = hasExperiment
}
}
capture(PostHogEventName.FEATURE_FLAG_CALLED.event, properties = props)
}
Expand Down
1 change: 1 addition & 0 deletions posthog/src/main/java/com/posthog/PostHogStateless.kt
Original file line number Diff line number Diff line change
Expand Up @@ -457,6 +457,7 @@ public open class PostHogStateless protected constructor(
props["\$feature_flag_id"] = details.metadata.id
props["\$feature_flag_version"] = details.metadata.version
details.reason?.description?.let { props["\$feature_flag_reason"] = it }
details.metadata.hasExperiment?.let { props["\$feature_flag_has_experiment"] = it }
}

captureFeatureFlagCalledEvent(distinctId, key, value, props, groups)
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package com.posthog.internal

import com.google.gson.annotations.SerializedName
import com.posthog.PostHogInternal
import org.codehaus.mojo.animal_sniffer.IgnoreJRERequirement

Expand All @@ -8,11 +9,15 @@ import org.codehaus.mojo.animal_sniffer.IgnoreJRERequirement
* @property id the id of the feature flag
* @property payload the payload of the feature flag
* @property version the version of the feature flag
* @property hasExperiment whether the flag is linked to an experiment; null when the server
* does not report the field (older deployments)
*/
@IgnoreJRERequirement
@PostHogInternal
public data class FeatureFlagMetadata(
val id: Int,
val payload: String?,
val version: Int,
@SerializedName("has_experiment")
val hasExperiment: Boolean? = null,
)
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@ public class FlagDefinition(
public val version: Int,
@SerializedName("ensure_experience_continuity")
public val ensureExperienceContinuity: Boolean = false,
@SerializedName("has_experiment")
public val hasExperiment: Boolean? = null,
)

/**
Expand Down
Loading
Loading