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/include-group-context-in-flag-called-dedupe.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"posthog": patch
"posthog-server": patch
---

Include group context in the `$feature_flag_called` LRU dedupe key so group-scoped flags fire a separate event for each group a user is evaluated under, instead of being dedup-ed against the first group context the same `(distinctId, flagKey, value)` was seen under. The groups are canonicalized order-independently so two equal maps built in different insertion orders still dedupe to one event.
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,10 @@ public class PostHog : PostHogStateless(), PostHogInterface {
key: String,
value: Any?,
properties: Map<String, Any>,
groups: Map<String, String>?,
) {
if (getConfig<com.posthog.PostHogConfig>()?.sendFeatureFlagEvent == false) return
this@PostHog.captureFeatureFlagCalledEvent(distinctId, key, value, properties)
this@PostHog.captureFeatureFlagCalledEvent(distinctId, key, value, properties, groups)
}

override fun logWarning(message: String) {
Expand Down Expand Up @@ -321,6 +322,7 @@ public class PostHog : PostHogStateless(), PostHogInterface {
definitionsLoadedAt = result.definitionsLoadedAt,
responseError = result.responseError,
host = evaluationsHost,
groups = groups,
)
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,9 +32,11 @@ public class PostHogFeatureFlagEvaluations internal constructor(
private val responseError: String?,
private val host: EvaluationsHost,
initialAccessed: Set<String> = emptySet(),
groups: Map<String, String>? = null,
) {
private val flagMap: Map<String, FeatureFlag> = Collections.unmodifiableMap(LinkedHashMap(flagMap))
private val locallyEvaluated: Map<String, Boolean> = Collections.unmodifiableMap(HashMap(locallyEvaluated))
private val groups: Map<String, String>? = groups?.let { Collections.unmodifiableMap(HashMap(it)) }

private val accessLock = Any()
private val accessed: MutableSet<String> = HashSet(initialAccessed)
Expand Down Expand Up @@ -158,6 +160,7 @@ public class PostHogFeatureFlagEvaluations internal constructor(
responseError = responseError,
host = host,
initialAccessed = emptySet(),
groups = groups,
)
}

Expand Down Expand Up @@ -191,7 +194,7 @@ public class PostHogFeatureFlagEvaluations internal constructor(
props["\$feature_flag_error"] = error.joinToString(",")
}

host.captureFeatureFlagCalled(distinctId, key, value, props)
host.captureFeatureFlagCalled(distinctId, key, value, props, groups)
}

public companion object {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ internal interface EvaluationsHost {
key: String,
value: Any?,
properties: Map<String, Any>,
groups: Map<String, String>? = null,
)

fun logWarning(message: String)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ internal class PostHogFeatureFlagEvaluationsTest {
val key: String,
val value: Any?,
val properties: Map<String, Any>,
val groups: Map<String, String>?,
)

private class FakeHost : EvaluationsHost {
Expand All @@ -27,8 +28,9 @@ internal class PostHogFeatureFlagEvaluationsTest {
key: String,
value: Any?,
properties: Map<String, Any>,
groups: Map<String, String>?,
) {
captures.add(RecordedCall(distinctId, key, value, properties))
captures.add(RecordedCall(distinctId, key, value, properties, groups))
}

override fun logWarning(message: String) {
Expand Down Expand Up @@ -62,6 +64,7 @@ internal class PostHogFeatureFlagEvaluationsTest {
evaluatedAt: Long? = 1_700_000_000_000L,
definitionsLoadedAt: Long? = null,
responseError: String? = null,
groups: Map<String, String>? = null,
) = PostHogFeatureFlagEvaluations(
distinctId = distinctId,
flagMap = flags,
Expand All @@ -71,6 +74,7 @@ internal class PostHogFeatureFlagEvaluationsTest {
definitionsLoadedAt = definitionsLoadedAt,
responseError = responseError,
host = host,
groups = groups,
)

@Test
Expand Down Expand Up @@ -116,6 +120,23 @@ internal class PostHogFeatureFlagEvaluationsTest {
)
}

@Test
fun `isEnabled propagates groups to the host so dedup can include group context`() {
val host = FakeHost()
val snapshot =
snapshot(
host = host,
flags = mapOf("group-flag" to flag("group-flag", enabled = true)),
groups = mapOf("organization" to "org-a"),
)

snapshot.isEnabled("group-flag")

assertEquals(1, host.captures.size)
val call = host.captures.single()
assertEquals(mapOf("organization" to "org-a"), call.groups)
}

@Test
fun `isEnabled fires capture call with full metadata`() {
val host = FakeHost()
Expand Down
2 changes: 2 additions & 0 deletions posthog/api/posthog.api
Original file line number Diff line number Diff line change
Expand Up @@ -395,6 +395,8 @@ public class com/posthog/PostHogStateless : com/posthog/PostHogStatelessInterfac
public static synthetic fun buildEvent$default (Lcom/posthog/PostHogStateless;Ljava/lang/String;Ljava/lang/String;Ljava/util/Map;Ljava/util/Date;ILjava/lang/Object;)Lcom/posthog/PostHogEvent;
public fun captureExceptionStateless (Ljava/lang/Throwable;Ljava/lang/String;Ljava/util/Map;)V
protected final fun captureFeatureFlagCalledEvent (Ljava/lang/String;Ljava/lang/String;Ljava/lang/Object;Ljava/util/Map;)V
protected final fun captureFeatureFlagCalledEvent (Ljava/lang/String;Ljava/lang/String;Ljava/lang/Object;Ljava/util/Map;Ljava/util/Map;)V
public static synthetic fun captureFeatureFlagCalledEvent$default (Lcom/posthog/PostHogStateless;Ljava/lang/String;Ljava/lang/String;Ljava/lang/Object;Ljava/util/Map;Ljava/util/Map;ILjava/lang/Object;)V
public fun captureStateless (Ljava/lang/String;Ljava/lang/String;Ljava/util/Map;Ljava/util/Map;Ljava/util/Map;Ljava/util/Map;Ljava/util/Date;)V
public fun close ()V
public fun debug (Z)V
Expand Down
11 changes: 8 additions & 3 deletions posthog/src/main/java/com/posthog/PostHogStateless.kt
Original file line number Diff line number Diff line change
Expand Up @@ -458,7 +458,7 @@ public open class PostHogStateless protected constructor(
details.reason?.description?.let { props["\$feature_flag_reason"] = it }
}

captureFeatureFlagCalledEvent(distinctId, key, value, props)
captureFeatureFlagCalledEvent(distinctId, key, value, props, groups)
}

/**
Expand All @@ -467,23 +467,28 @@ public open class PostHogStateless protected constructor(
* the per-distinct-id dedup and routes through the queue. Both the existing per-flag accessor
* (after applying its `sendFeatureFlagEvent` override) and the new feature-flag-evaluations
* snapshot (after checking its own config) funnel through here so dedup stays uniform.
*
* `groups` is mixed into the dedup key so group-scoped flags fire a separate event for each
* group a user is evaluated under, instead of dedup-ing across groups.
*/
@PostHogInternal
@JvmOverloads
protected fun captureFeatureFlagCalledEvent(
distinctId: String,
key: String,
value: Any?,
properties: Map<String, Any>,
groups: Map<String, String>? = null,
) {
val isNewlySeen = featureFlagsCalled?.add(distinctId, key, value) ?: false
val isNewlySeen = featureFlagsCalled?.add(distinctId, key, value, groups) ?: false
if (!isNewlySeen) return

val props = mutableMapOf<String, Any>()
props.putAll(properties)
props["\$feature_flag"] = key
props["\$feature_flag_response"] = value ?: ""

captureStateless(PostHogEventName.FEATURE_FLAG_CALLED.event, distinctId, properties = props)
captureStateless(PostHogEventName.FEATURE_FLAG_CALLED.event, distinctId, properties = props, groups = groups)
}

public override fun getFeatureFlagStateless(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,14 +22,20 @@ internal class PostHogFeatureFlagCalledCache(
/**
* Atomically check if this combination has been seen before, and if not, mark it as seen.
* Returns true if this is the first time seeing this combination (was added), false if already seen.
*
* `groups` is canonicalized into a sorted-key representation so group-scoped flags
* fire a separate `$feature_flag_called` event for each distinct group context the
* same user is evaluated under. Same groups passed in a different insertion order
* still dedupe to the same cache entry.
*/
@Synchronized
fun add(
distinctId: String,
flagKey: String,
value: Any?,
groups: Map<String, String>? = null,
): Boolean {
val key = FeatureFlagCalledKey(distinctId, flagKey, value)
val key = FeatureFlagCalledKey(distinctId, flagKey, value, canonicalGroupsRepr(groups))

val existingNode = cache.get(key)
if (existingNode != null) {
Expand Down Expand Up @@ -121,10 +127,50 @@ internal class PostHogFeatureFlagCalledCache(
}

/**
* Cache key combining distinct ID, flag key, and value
* Cache key combining distinct ID, flag key, evaluated value, and a canonical
* representation of the group context.
*/
internal data class FeatureFlagCalledKey(
val distinctId: String,
val flagKey: String,
val value: Any?,
val groupsRepr: String,
)

/**
* Build a canonical string from the group map so two equal maps with keys
* inserted in a different order produce the same dedup key. An empty / null
* map returns the empty string so callers that never pass groups keep their
* legacy "no groups" dedup behavior.
*
* Keys and values are escaped so that a literal `=`, `;`, or `\` embedded in
* either cannot collide with the separators used to join entries. Without
* this, e.g. `mapOf("a" to "b;c=d", "e" to "f")` and
* `mapOf("a" to "b", "c" to "d", "e" to "f")` would both serialize to
* `a=b;c=d;e=f;` and silently dedupe to the same cache entry.
*/
internal fun canonicalGroupsRepr(groups: Map<String, String>?): String {
if (groups.isNullOrEmpty()) {
return ""
}
val sb = StringBuilder()
for (key in groups.keys.sorted()) {
appendEscaped(sb, key)
sb.append('=')
appendEscaped(sb, groups[key] ?: "")
sb.append(';')
}
return sb.toString()
}
Comment thread
gustavohstrassburger marked this conversation as resolved.

private fun appendEscaped(
sb: StringBuilder,
value: String,
) {
for (c in value) {
when (c) {
'\\', '=', ';' -> sb.append('\\').append(c)
else -> sb.append(c)
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -260,6 +260,91 @@ internal class PostHogFeatureFlagCalledCacheTest {
assertTrue(cache.add("user1", "flag1", "value1"))
}

@Test
fun `same flag and value with different group contexts are tracked separately`() {
val cache = PostHogFeatureFlagCalledCache(maxSize = 10)

assertTrue(cache.add("user1", "flag1", "value1", mapOf("organization" to "org-a")))
assertTrue(cache.add("user1", "flag1", "value1", mapOf("organization" to "org-b")))

assertEquals(2, cache.size())
}

@Test
fun `same flag and value with same group context dedupe`() {
val cache = PostHogFeatureFlagCalledCache(maxSize = 10)

assertTrue(cache.add("user1", "flag1", "value1", mapOf("organization" to "org-a")))
assertFalse(cache.add("user1", "flag1", "value1", mapOf("organization" to "org-a")))

assertEquals(1, cache.size())
}

@Test
fun `same flag and value with same groups in different key order dedupe`() {
val cache = PostHogFeatureFlagCalledCache(maxSize = 10)

assertTrue(
cache.add(
"user1",
"flag1",
"value1",
linkedMapOf("organization" to "org-a", "team" to "red"),
),
)
assertFalse(
cache.add(
"user1",
"flag1",
"value1",
Comment thread
gustavohstrassburger marked this conversation as resolved.
linkedMapOf("team" to "red", "organization" to "org-a"),
),
)

assertEquals(1, cache.size())
}

@Test
fun `null and unset groups canonicalize to the same no-group bucket`() {
// Independent caches per pair so a returned `false` means the canonical
// repr matched, not merely that the prior call inserted the same key.
val cache = PostHogFeatureFlagCalledCache(maxSize = 10)

assertTrue(cache.add("user1", "flag1", "value1"))
assertFalse(cache.add("user1", "flag1", "value1", null))
assertEquals(1, cache.size())
}

@Test
fun `emptyMap and unset groups canonicalize to the same no-group bucket`() {
val cache = PostHogFeatureFlagCalledCache(maxSize = 10)

assertTrue(cache.add("user1", "flag1", "value1"))
assertFalse(cache.add("user1", "flag1", "value1", emptyMap()))
assertEquals(1, cache.size())
}

@Test
fun `null and emptyMap groups canonicalize to the same no-group bucket`() {
val cache = PostHogFeatureFlagCalledCache(maxSize = 10)

assertTrue(cache.add("user1", "flag1", "value1", null))
assertFalse(cache.add("user1", "flag1", "value1", emptyMap()))
assertEquals(1, cache.size())
}

@Test
fun `values containing the entry separators dedupe distinctly from synthetic decompositions`() {
// Without escaping, mapOf("a" to "b;c=d", "e" to "f") and
// mapOf("a" to "b", "c" to "d", "e" to "f") both serialize to the same
// `a=b;c=d;e=f;` string. Verify they hit two different cache entries.
val cache = PostHogFeatureFlagCalledCache(maxSize = 10)

assertTrue(cache.add("user1", "flag1", "value1", mapOf("a" to "b;c=d", "e" to "f")))
assertTrue(cache.add("user1", "flag1", "value1", mapOf("a" to "b", "c" to "d", "e" to "f")))
assertEquals(2, cache.size())
}

@Test
fun `batch eviction allows cache to grow beyond max before next eviction`() {
val cache = PostHogFeatureFlagCalledCache(maxSize = 10)
Expand Down
Loading