fix(server): include group context in $feature_flag_called dedupe key#533
Conversation
In `PostHogFeatureFlagCalledCache.add`, the per-`distinctId` LRU dedupe key only included `(distinctId, flagKey, value)`. For group-scoped flags this meant that when the same user was evaluated under a different group, no new `$feature_flag_called` event was fired — causing per-group exposure undercount for experiments scoped to a group key. Add a canonical groups representation (`canonicalGroupsRepr`) into the LRU cache key — built from the sorted group entries so two equal maps with keys inserted in a different order produce the same dedup key. The `groups` map is now threaded through `PostHogStateless.captureFeatureFlagCalledEvent` and the `posthog-server` snapshot accessors (`PostHogFeatureFlagEvaluations.isEnabled/getFlag`) so it can be funneled into the dedup cache and onto the captured `$feature_flag_called` event payload. This affects two modules: - `posthog/` (PostHogStateless / cache) — `captureFeatureFlagCalledEvent` now takes an optional `groups` parameter (added via `@JvmOverloads` so the existing Java-callable signature still exists). - `posthog-server/` — the snapshot carries the groups it was evaluated under and passes them to the host's dedup-aware capture call. Mirrors the posthog-node fix in PostHog/posthog-js#3658 (which closes PostHog/posthog-js#3651). Both SDKs share the same dedupe shape, so backend evaluation needs the same change. Generated-By: PostHog Code Task-Id: d94308d9-7655-4bac-8f15-c61478b5fca1
posthog-android Compliance ReportDate: 2026-05-27 19:01:48 UTC
|
| Test | Status | Duration |
|---|---|---|
| Request Payload.Request With Person Properties Device Id | ❌ | 292ms |
| Request Payload.Flags Request Uses V2 Query Param | ❌ | 34ms |
| Request Payload.Flags Request Hits Flags Path Not Decide | ❌ | 30ms |
| Request Payload.Flags Request Omits Authorization Header | ❌ | 24ms |
| Request Payload.Token In Flags Body Matches Init | ❌ | 22ms |
| Request Payload.Groups Round Trip | ❌ | 18ms |
| Request Payload.Groups Default To Empty Object | ❌ | 15ms |
| Request Payload.Person Properties Distinct Id Auto Populated When Caller Omits It | ❌ | 16ms |
| Request Payload.Disable Geoip False Propagates As Geoip Disable False | ❌ | 18ms |
| Request Payload.Disable Geoip Omitted Defaults To False | ❌ | 15ms |
| Request Payload.Flag Keys To Evaluate Contains Only Requested Key | ❌ | 16ms |
| Request Lifecycle.No Flags Request On Init Alone | ❌ | 10ms |
| Request Lifecycle.No Flags Request On Normal Capture | ❌ | 2047ms |
| Request Lifecycle.Two Flag Calls Produce Two Remote Requests | ❌ | 14ms |
| Request Lifecycle.Mock Response Value Is Returned To Caller | ❌ | 12ms |
| Side Effect Events.Get Feature Flag Captures Feature Flag Called Event | ❌ | 15ms |
Failures
request_payload.request_with_person_properties_device_id
404, message='Not Found', url='http://sdk-adapter:8080/get_feature_flag'
request_payload.flags_request_uses_v2_query_param
404, message='Not Found', url='http://sdk-adapter:8080/get_feature_flag'
request_payload.flags_request_hits_flags_path_not_decide
404, message='Not Found', url='http://sdk-adapter:8080/get_feature_flag'
request_payload.flags_request_omits_authorization_header
404, message='Not Found', url='http://sdk-adapter:8080/get_feature_flag'
request_payload.token_in_flags_body_matches_init
404, message='Not Found', url='http://sdk-adapter:8080/get_feature_flag'
request_payload.groups_round_trip
404, message='Not Found', url='http://sdk-adapter:8080/get_feature_flag'
request_payload.groups_default_to_empty_object
404, message='Not Found', url='http://sdk-adapter:8080/get_feature_flag'
request_payload.person_properties_distinct_id_auto_populated_when_caller_omits_it
404, message='Not Found', url='http://sdk-adapter:8080/get_feature_flag'
request_payload.disable_geoip_false_propagates_as_geoip_disable_false
404, message='Not Found', url='http://sdk-adapter:8080/get_feature_flag'
request_payload.disable_geoip_omitted_defaults_to_false
404, message='Not Found', url='http://sdk-adapter:8080/get_feature_flag'
request_payload.flag_keys_to_evaluate_contains_only_requested_key
404, message='Not Found', url='http://sdk-adapter:8080/get_feature_flag'
request_lifecycle.no_flags_request_on_init_alone
Expected 0 /flags requests, got 1
request_lifecycle.no_flags_request_on_normal_capture
Expected 0 /flags requests, got 1
request_lifecycle.two_flag_calls_produce_two_remote_requests
404, message='Not Found', url='http://sdk-adapter:8080/get_feature_flag'
request_lifecycle.mock_response_value_is_returned_to_caller
404, message='Not Found', url='http://sdk-adapter:8080/get_feature_flag'
side_effect_events.get_feature_flag_captures_feature_flag_called_event
404, message='Not Found', url='http://sdk-adapter:8080/get_feature_flag'
Generated-By: PostHog Code Task-Id: d94308d9-7655-4bac-8f15-c61478b5fca1
Prompt To Fix All With AIFix the following 2 code review issues. Work through them one at a time, proposing concise fixes.
---
### Issue 1 of 2
posthog/src/main/java/com/posthog/internal/PostHogFeatureFlagCalledCache.kt:146-155
**Unsafe separator in canonical representation can cause hash collisions**
The `=` and `;` separators used to build the canonical key are not escaped, so a group value that embeds `;key2=val2` can produce the same string as a genuinely different two-entry map. Concretely, `mapOf("a" to "b;c=d", "e" to "f")` and `mapOf("a" to "b", "c" to "d", "e" to "f")` both encode to `"a=b;c=d;e=f;"` — two distinct group contexts that should fire separate events would instead silently dedupe to one. A safer encoding (e.g., percent-encoding keys and values before concatenating) eliminates the ambiguity.
### Issue 2 of 2
posthog/src/test/java/com/posthog/internal/PostHogFeatureFlagCalledCacheTest.kt:289-299
**Prefer a parameterised test for the "no-group bucket" coverage**
The `null vs empty vs unset groups all dedupe to no-group bucket` test calls `add` three times in sequence on a shared cache, which means only the first call returns `true` — the second and third could be returning `false` because the key is identical rather than because `null`/`emptyMap()` canonicalize the same way. A `@ParameterizedTest` with three independent caches (or three separate assertions from a fresh cache each time) would make the intent clearer and catch a regression where one variant encodes differently.
Reviews (1): Last reviewed commit: "chore: add changeset" | Re-trigger Greptile |
Two fixes from Greptile review on this PR:
1. Escape `=`, `;`, and `\` in `canonicalGroupsRepr` so that values containing the entry separators can't collide with synthetic decompositions of a different map. Previously, `mapOf("a" to "b;c=d", "e" to "f")` and `mapOf("a" to "b", "c" to "d", "e" to "f")` both serialized to `a=b;c=d;e=f;` and would have silently dedup-ed to one event.
2. Split the "null vs empty vs unset" dedup test into three independent caches, plus a new test that verifies values containing the separators hit distinct cache entries. Three-call-on-shared-cache assertions could pass purely from key-identity dedup instead of canonicalization parity.
Generated-By: PostHog Code
Task-Id: d94308d9-7655-4bac-8f15-c61478b5fca1
spotlessKotlinCheck on CI flagged that `appendEscaped(sb: StringBuilder, value: String)` exceeds the project's per-line param width and needs to be broken across lines. Verified locally with `./gradlew spotlessKotlinCheck`. Generated-By: PostHog Code Task-Id: d94308d9-7655-4bac-8f15-c61478b5fca1
ioannisj
left a comment
There was a problem hiding this comment.
LG, left just one comment
💡 Motivation and Context
In
PostHogFeatureFlagCalledCache.add, the per-distinctIdLRU dedupe key only included(distinctId, flagKey, value). For group-scoped flags this meant that when the same user was evaluated under a different group, no new$feature_flag_calledevent was fired — causing per-group exposure undercount for experiments scoped to a group key.This affects two modules:
posthog/(PostHogStateless / cache) —captureFeatureFlagCalledEventnow takes an optionalgroupsparameter (added via@JvmOverloadsso the existing Java-callable signature still exists). The cache key gets a canonicalgroupsReprbuilt from the sorted entries so two equal maps with keys in a different insertion order still dedupe.posthog-server/—PostHogFeatureFlagEvaluationsnow carries thegroupsit was evaluated under and passes them to the host's dedup-aware capture call.Mirrors the posthog-node fix in PostHog/posthog-js#3658 (which closes PostHog/posthog-js#3651). Both SDKs share the same dedupe shape, so backend evaluation needs the same change.
💚 How did you test it?
Added 4 new tests in
PostHogFeatureFlagCalledCacheTestand 1 inPostHogFeatureFlagEvaluationsTest:Cache-level (
posthog/):same flag and value with different group contexts are tracked separately— two cache entries.same flag and value with same group context dedupe— one cache entry.same flag and value with same groups in different key order dedupe— one cache entry (canonicalized).null vs empty vs unset groups all dedupe to no-group bucket— single entry covers all three.Snapshot-level (
posthog-server/):isEnabled propagates groups to the host so dedup can include group context— confirms the snapshot threads its evaluation groups through to the host's capture call../gradlew :posthog:test :posthog-server:testis green;./gradlew :posthog:apiDump :posthog-server:apiDumpregenerated the public.apifiles.📝 Checklist
If releasing new changes
pnpm changesetto generate a changeset fileCreated with PostHog Code