Skip to content

fix(server): include group context in $feature_flag_called dedupe key#533

Merged
gustavohstrassburger merged 5 commits into
mainfrom
posthog-code/fix-flag-called-dedupe-groups
May 27, 2026
Merged

fix(server): include group context in $feature_flag_called dedupe key#533
gustavohstrassburger merged 5 commits into
mainfrom
posthog-code/fix-flag-called-dedupe-groups

Conversation

@gustavohstrassburger
Copy link
Copy Markdown
Contributor

@gustavohstrassburger gustavohstrassburger commented May 25, 2026

💡 Motivation and Context

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.

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). The cache key gets a canonical groupsRepr built from the sorted entries so two equal maps with keys in a different insertion order still dedupe.
  • posthog-server/PostHogFeatureFlagEvaluations now 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.

💚 How did you test it?

Added 4 new tests in PostHogFeatureFlagCalledCacheTest and 1 in PostHogFeatureFlagEvaluationsTest:

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:test is green; ./gradlew :posthog:apiDump :posthog-server:apiDump regenerated the public .api files.

📝 Checklist

  • I reviewed the submitted code.
  • I added tests to verify the changes.
  • I updated the docs if needed.
  • No breaking change or entry added to the changelog.

If releasing new changes

  • Ran pnpm changeset to generate a changeset file

Created with PostHog Code

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
@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented May 25, 2026

posthog-android Compliance Report

Date: 2026-05-27 19:01:48 UTC
Duration: 2829ms

⚠️ Some Tests Failed

0/16 tests passed, 16 failed


Feature_Flags Tests

⚠️ 0/16 tests passed, 16 failed

View Details
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
@gustavohstrassburger gustavohstrassburger marked this pull request as ready for review May 25, 2026 21:08
@gustavohstrassburger gustavohstrassburger requested a review from a team as a code owner May 25, 2026 21:08
@greptile-apps
Copy link
Copy Markdown

greptile-apps Bot commented May 25, 2026

Prompt To Fix All With AI
Fix 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
Copy link
Copy Markdown
Contributor

@ioannisj ioannisj left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LG, left just one comment

Comment thread posthog-server/src/main/java/com/posthog/server/PostHogFeatureFlagEvaluations.kt Outdated
@gustavohstrassburger gustavohstrassburger enabled auto-merge (squash) May 27, 2026 19:00
@gustavohstrassburger gustavohstrassburger merged commit 3deea3d into main May 27, 2026
14 checks passed
@gustavohstrassburger gustavohstrassburger deleted the posthog-code/fix-flag-called-dedupe-groups branch May 27, 2026 19:04
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

$feature_flag_called dedupe does not include group context for group-scoped flags

2 participants