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
5 changes: 5 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,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.
31 changes: 25 additions & 6 deletions lib/Client.php
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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;
}

Expand All @@ -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<string, mixed> $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);
}

/**
Expand Down
83 changes: 83 additions & 0 deletions test/FeatureFlagEvaluationsTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, array{list<array<string,string>>, 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<array<string,string>> $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<string> the deprecation messages emitted while running $callable
*/
Expand Down
Loading