From 12d19a168da6a99d802f9b2dac9113f467b50a30 Mon Sep 17 00:00:00 2001 From: "Gustavo H. Strassburger" Date: Mon, 25 May 2026 15:51:49 -0300 Subject: [PATCH 1/3] fix: include group context in $feature_flag_called dedupe key MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit In `Client::captureFlagCalledIfNeeded`, the per-`distinct_id` dedupe element only included the distinct ID under the flag key. 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. Append a canonical JSON of the sorted `groups` map to the dedup element (`$distinctId . canonicalGroupsRepr($groups)`) so the same `(distinct_id, flag)` fires once per distinct group context. Repeated calls under the same group context still dedupe; calls that pass the same array with keys inserted in a different order also dedupe (the array is canonicalized via `ksort` before being JSON-encoded). 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 --- lib/Client.php | 31 ++++++++-- test/FeatureFlagEvaluationsTest.php | 87 +++++++++++++++++++++++++++++ 2 files changed, 112 insertions(+), 6 deletions(-) diff --git a/lib/Client.php b/lib/Client.php index ff3f082..3851a13 100644 --- a/lib/Client.php +++ b/lib/Client.php @@ -936,10 +936,11 @@ public function evaluateFlags( } /** - * Fire a $feature_flag_called event the first time a (flag key, distinct id) pair is seen by - * this Client, deduped via the per-distinct_id cache shared with every other flag-reading code - * path. Properties are built by the caller so each call site can shape the payload to match its - * available metadata. + * Fire a $feature_flag_called event the first time a (flag key, distinct id, groups) tuple is + * seen by this Client, deduped via the per-distinct_id cache shared with every other + * flag-reading code path. Group context is included so that group-scoped flags fire a separate + * event for each group a user is evaluated under. Properties are built by the caller so each + * call site can shape the payload to match its available metadata. * * @param string $distinctId The distinct ID that accessed the flag. * @param string $key Feature flag key. @@ -953,7 +954,8 @@ public function captureFlagCalledIfNeeded( array $properties, array $groups = [] ): void { - if ($this->distinctIdsFeatureFlagsReported->contains($key, $distinctId)) { + $dedupElement = $distinctId . self::canonicalGroupsRepr($groups); + if ($this->distinctIdsFeatureFlagsReported->contains($key, $dedupElement)) { return; } @@ -963,7 +965,24 @@ public function captureFlagCalledIfNeeded( 'event' => '$feature_flag_called', '$groups' => $groups, ]); - $this->distinctIdsFeatureFlagsReported->add($key, $distinctId); + $this->distinctIdsFeatureFlagsReported->add($key, $dedupElement); + } + + /** + * Canonicalize the groups map so two equal arrays with keys in a different order produce the + * same dedup suffix. Empty / missing groups produce an empty string so the legacy "no groups" + * dedupe shape is preserved. + * + * @param array $groups + * @return string + */ + private static function canonicalGroupsRepr(array $groups): string + { + if (empty($groups)) { + return ''; + } + ksort($groups); + return '_' . json_encode($groups, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); } /** diff --git a/test/FeatureFlagEvaluationsTest.php b/test/FeatureFlagEvaluationsTest.php index 8de45db..da698c6 100644 --- a/test/FeatureFlagEvaluationsTest.php +++ b/test/FeatureFlagEvaluationsTest.php @@ -563,6 +563,93 @@ public function testSnapshotDedupesAcrossClientPaths(): void $this->assertCount(1, $batches[0]['batch']); } + public function testFeatureFlagCalledFiresPerGroupContext(): void + { + $this->makeClient(); + + // Same user, same flag, two different group contexts must produce two events. + $snapA = PostHog::evaluateFlags('user-1', groups: ['organization' => 'org-a']); + $snapA->isEnabled('simple-test'); + + $snapB = PostHog::evaluateFlags('user-1', groups: ['organization' => 'org-b']); + $snapB->isEnabled('simple-test'); + + PostHog::flush(); + + $events = []; + foreach ($this->batchRequests() as $batch) { + foreach ($batch['batch'] as $event) { + if ($event['event'] === '$feature_flag_called' + && ($event['properties']['$feature_flag'] ?? null) === 'simple-test') { + $events[] = $event; + } + } + } + + $this->assertCount(2, $events, 'expected one $feature_flag_called event per group context'); + $seen = array_map( + fn($e) => $e['properties']['$groups']['organization'] ?? null, + $events + ); + $this->assertContains('org-a', $seen); + $this->assertContains('org-b', $seen); + } + + public function testFeatureFlagCalledDedupesAcrossRepeatedCallsUnderSameGroup(): void + { + $this->makeClient(); + + $snap = PostHog::evaluateFlags('user-1', groups: ['organization' => 'org-a']); + $snap->isEnabled('simple-test'); + $snap->isEnabled('simple-test'); + $snap->isEnabled('simple-test'); + + PostHog::flush(); + + $count = 0; + foreach ($this->batchRequests() as $batch) { + foreach ($batch['batch'] as $event) { + if ($event['event'] === '$feature_flag_called' + && ($event['properties']['$feature_flag'] ?? null) === 'simple-test') { + $count++; + } + } + } + $this->assertSame(1, $count, 'expected a single deduped $feature_flag_called event'); + } + + public function testFeatureFlagCalledDedupesAcrossGroupKeyOrder(): void + { + $this->makeClient(); + + // Same groups, two different array insertion orders. Both must produce the same canonical + // dedup element so only one event is fired. + $snapA = PostHog::evaluateFlags( + 'user-1', + groups: ['organization' => 'org-a', 'team' => 'red'] + ); + $snapA->isEnabled('simple-test'); + + $snapB = PostHog::evaluateFlags( + 'user-1', + groups: ['team' => 'red', 'organization' => 'org-a'] + ); + $snapB->isEnabled('simple-test'); + + PostHog::flush(); + + $count = 0; + foreach ($this->batchRequests() as $batch) { + foreach ($batch['batch'] as $event) { + if ($event['event'] === '$feature_flag_called' + && ($event['properties']['$feature_flag'] ?? null) === 'simple-test') { + $count++; + } + } + } + $this->assertSame(1, $count, 'expected a single deduped $feature_flag_called event'); + } + /** * @return list the deprecation messages emitted while running $callable */ From 1210d6c18d801e5f84b7c20f5de1c6460938d82a Mon Sep 17 00:00:00 2001 From: "Gustavo H. Strassburger" Date: Mon, 25 May 2026 16:16:31 -0300 Subject: [PATCH 2/3] chore: add changeset Generated-By: PostHog Code Task-Id: d94308d9-7655-4bac-8f15-c61478b5fca1 --- .changeset/include-group-context-in-flag-called-dedupe.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/include-group-context-in-flag-called-dedupe.md diff --git a/.changeset/include-group-context-in-flag-called-dedupe.md b/.changeset/include-group-context-in-flag-called-dedupe.md new file mode 100644 index 0000000..aadedf1 --- /dev/null +++ b/.changeset/include-group-context-in-flag-called-dedupe.md @@ -0,0 +1,5 @@ +--- +'posthog-php': patch +--- + +Include group context in the `$feature_flag_called` dedupe element 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 `(distinct_id, flag)` was seen under. From a43ad37be867948fb9913c5f55526df415feec0c Mon Sep 17 00:00:00 2001 From: "Gustavo H. Strassburger" Date: Wed, 27 May 2026 15:43:36 -0300 Subject: [PATCH 3/3] fix: add JSON_THROW_ON_ERROR to group dedup key encoding and combine dedup tests under dataProvider --- lib/Client.php | 2 +- test/FeatureFlagEvaluationsTest.php | 68 ++++++++++++++--------------- 2 files changed, 33 insertions(+), 37 deletions(-) diff --git a/lib/Client.php b/lib/Client.php index 3851a13..af41bf3 100644 --- a/lib/Client.php +++ b/lib/Client.php @@ -982,7 +982,7 @@ private static function canonicalGroupsRepr(array $groups): string return ''; } ksort($groups); - return '_' . json_encode($groups, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); + return '_' . json_encode($groups, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR); } /** diff --git a/test/FeatureFlagEvaluationsTest.php b/test/FeatureFlagEvaluationsTest.php index da698c6..0c1b176 100644 --- a/test/FeatureFlagEvaluationsTest.php +++ b/test/FeatureFlagEvaluationsTest.php @@ -595,46 +595,42 @@ public function testFeatureFlagCalledFiresPerGroupContext(): void $this->assertContains('org-b', $seen); } - public function testFeatureFlagCalledDedupesAcrossRepeatedCallsUnderSameGroup(): void - { - $this->makeClient(); - - $snap = PostHog::evaluateFlags('user-1', groups: ['organization' => 'org-a']); - $snap->isEnabled('simple-test'); - $snap->isEnabled('simple-test'); - $snap->isEnabled('simple-test'); - - PostHog::flush(); - - $count = 0; - foreach ($this->batchRequests() as $batch) { - foreach ($batch['batch'] as $event) { - if ($event['event'] === '$feature_flag_called' - && ($event['properties']['$feature_flag'] ?? null) === 'simple-test') { - $count++; - } - } - } - $this->assertSame(1, $count, 'expected a single deduped $feature_flag_called event'); + /** + * @return array>, int}> + */ + public static function dedupCallsProvider(): array + { + return [ + 'repeated calls under same group' => [ + [ + ['organization' => 'org-a'], + ['organization' => 'org-a'], + ['organization' => 'org-a'], + ], + 1, + ], + 'same groups different key insertion order' => [ + [ + ['organization' => 'org-a', 'team' => 'red'], + ['team' => 'red', 'organization' => 'org-a'], + ], + 1, + ], + ]; } - public function testFeatureFlagCalledDedupesAcrossGroupKeyOrder(): void + /** + * @dataProvider dedupCallsProvider + * @param list> $groupsPerCall + */ + public function testFeatureFlagCalledDedupes(array $groupsPerCall, int $expectedCount): void { $this->makeClient(); - // Same groups, two different array insertion orders. Both must produce the same canonical - // dedup element so only one event is fired. - $snapA = PostHog::evaluateFlags( - 'user-1', - groups: ['organization' => 'org-a', 'team' => 'red'] - ); - $snapA->isEnabled('simple-test'); - - $snapB = PostHog::evaluateFlags( - 'user-1', - groups: ['team' => 'red', 'organization' => 'org-a'] - ); - $snapB->isEnabled('simple-test'); + foreach ($groupsPerCall as $groups) { + $snap = PostHog::evaluateFlags('user-1', groups: $groups); + $snap->isEnabled('simple-test'); + } PostHog::flush(); @@ -647,7 +643,7 @@ public function testFeatureFlagCalledDedupesAcrossGroupKeyOrder(): void } } } - $this->assertSame(1, $count, 'expected a single deduped $feature_flag_called event'); + $this->assertSame($expectedCount, $count, 'unexpected $feature_flag_called event count'); } /**