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. diff --git a/lib/Client.php b/lib/Client.php index ff3f082..af41bf3 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 | JSON_THROW_ON_ERROR); } /** diff --git a/test/FeatureFlagEvaluationsTest.php b/test/FeatureFlagEvaluationsTest.php index 8de45db..0c1b176 100644 --- a/test/FeatureFlagEvaluationsTest.php +++ b/test/FeatureFlagEvaluationsTest.php @@ -563,6 +563,89 @@ 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); + } + + /** + * @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, + ], + ]; + } + + /** + * @dataProvider dedupCallsProvider + * @param list> $groupsPerCall + */ + public function testFeatureFlagCalledDedupes(array $groupsPerCall, int $expectedCount): void + { + $this->makeClient(); + + foreach ($groupsPerCall as $groups) { + $snap = PostHog::evaluateFlags('user-1', groups: $groups); + $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($expectedCount, $count, 'unexpected $feature_flag_called event count'); + } + /** * @return list the deprecation messages emitted while running $callable */