Skip to content
Open
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
10 changes: 8 additions & 2 deletions lib/Client.php
Original file line number Diff line number Diff line change
Expand Up @@ -620,7 +620,10 @@ private function computeFlagLocally(
$focusedGroupProperties,
$this->cohorts,
$this->featureFlagsByKey,
$evaluationCache
$evaluationCache,
$groups,
$groupProperties,
$this->groupTypeMapping
);
} else {
return FeatureFlag::matchFeatureFlagProperties(
Expand All @@ -629,7 +632,10 @@ private function computeFlagLocally(
$personProperties,
$this->cohorts,
$this->featureFlagsByKey,
$evaluationCache
$evaluationCache,
$groups,
$groupProperties,
$this->groupTypeMapping
);
}
}
Expand Down
51 changes: 45 additions & 6 deletions lib/FeatureFlag.php
Original file line number Diff line number Diff line change
Expand Up @@ -586,24 +586,63 @@ private static function variantLookupTable($featureFlag)
return $lookupTable;
}

public static function matchFeatureFlagProperties($flag, $distinctId, $properties, $cohorts = [], $flagsByKey = null, $evaluationCache = null)
{
$flagConditions = ($flag["filters"] ?? [])["groups"] ?? [];
public static function matchFeatureFlagProperties(
$flag,
$distinctId,
$properties,
$cohorts = [],
$flagsByKey = null,
$evaluationCache = null,
$groups = [],
$groupProperties = [],
$groupTypeMapping = []
) {
$flagFilters = $flag["filters"] ?? [];
$flagConditions = $flagFilters["groups"] ?? [];
$flagAggregation = $flagFilters["aggregation_group_type_index"] ?? null;
$isInconclusive = false;

foreach ($flagConditions as $condition) {
try {
if (FeatureFlag::isConditionMatch($flag, $distinctId, $condition, $properties, $cohorts, $flagsByKey, $evaluationCache)) {
// Per-condition aggregation overrides only when the condition explicitly
// sets its own aggregation_group_type_index (mixed targeting). When absent,
// use the properties/bucketing already resolved by the caller.
$hasConditionAggregation = array_key_exists("aggregation_group_type_index", $condition);
$conditionAggregation = $hasConditionAggregation
? $condition["aggregation_group_type_index"]
: $flagAggregation;

$effectiveProperties = $properties;
$effectiveBucketing = $distinctId;

// Mixed-override path: condition-level aggregation differs from flag-level.
// This assumes flag-level aggregation is null for mixed flags.
if ($conditionAggregation !== $flagAggregation) {
if (!is_null($conditionAggregation)) {
$groupName = $groupTypeMapping[strval($conditionAggregation)] ?? null;
if (is_null($groupName) || !array_key_exists($groupName, $groups)) {
continue;
}
if (!array_key_exists($groupName, $groupProperties)) {
$isInconclusive = true;
continue;
}
$effectiveProperties = $groupProperties[$groupName];
$effectiveBucketing = $groups[$groupName];
}
}

if (FeatureFlag::isConditionMatch($flag, $effectiveBucketing, $condition, $effectiveProperties, $cohorts, $flagsByKey, $evaluationCache)) {
$variantOverride = $condition["variant"] ?? null;
$flagVariants = (($flag["filters"] ?? [])["multivariate"] ?? [])["variants"] ?? [];
$flagVariants = ($flagFilters["multivariate"] ?? [])["variants"] ?? [];
$variantKeys = array_map(function ($variant) {
return $variant["key"];
}, $flagVariants);

if ($variantOverride && in_array($variantOverride, $variantKeys)) {
return $variantOverride;
} else {
return FeatureFlag::getMatchingVariant($flag, $distinctId) ?? true;
return FeatureFlag::getMatchingVariant($flag, $effectiveBucketing) ?? true;
}
}
} catch (RequiresServerEvaluationException $e) {
Expand Down
77 changes: 77 additions & 0 deletions test/FeatureFlagLocalEvaluationTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -1153,6 +1153,83 @@ public function testFlagGroupProperties()
$this->assertEquals(PostHog::getFeatureFlag('group-flag', 'some-distinct-id', ["company" => "amazon"], [], ["company" => []]), 'decide-fallback-value');
}

/**
* @dataProvider mixedTargetingProvider
*/
public function testMixedTargetingLocalEvaluation(string $flagKey, array $opts, $expected, array $localFlags)
{
$this->http_client = new MockedHttpClient(host: "app.posthog.com", flagEndpointResponse: $localFlags);
$this->client = new Client(self::FAKE_API_KEY, ["debug" => true], $this->http_client, "test");
PostHog::init(null, null, $this->client);

$result = PostHog::getFeatureFlag(
$flagKey,
'test-distinct-id',
$opts['groups'] ?? [],
$opts['person_properties'] ?? [],
$opts['group_properties'] ?? []
);
$this->assertSame($expected, $result);
}

public static function mixedTargetingProvider(): array
{
return [
'person condition matches when no groups passed' => [
'mixed-flag',
['person_properties' => ['email' => 'test@example.com']],
true,
MockedResponses::LOCAL_EVALUATION_MIXED_TARGETING_REQUEST,
],
'group condition matches when group props match' => [
'mixed-flag',
[
'groups' => ['company' => 'acme'],
'group_properties' => ['company' => ['plan' => 'enterprise']],
'person_properties' => ['email' => 'nope@example.com'],
],
true,
MockedResponses::LOCAL_EVALUATION_MIXED_TARGETING_REQUEST,
],
'no match when both person and group fail' => [
'mixed-flag',
[
'groups' => ['company' => 'acme'],
'group_properties' => ['company' => ['plan' => 'free']],
'person_properties' => ['email' => 'nope@example.com'],
],
false,
MockedResponses::LOCAL_EVALUATION_MIXED_TARGETING_REQUEST,
],
'only group condition, no groups passed: returns false without server fallback' => [
'only-group-flag',
[],
false,
MockedResponses::LOCAL_EVALUATION_ONLY_GROUP_CONDITION_REQUEST,
],
];
}

public function testMixedTargetingRolloutBucketing()
{
$this->http_client = new MockedHttpClient(
host: "app.posthog.com",
flagEndpointResponse: MockedResponses::LOCAL_EVALUATION_GROUP_ROLLOUT_REQUEST
);
$this->client = new Client(self::FAKE_API_KEY, ["debug" => true], $this->http_client, "test");
PostHog::init(null, null, $this->client);

// With rollout 100% and a group passed, the group condition resolves locally —
// the matcher must hash on the group key, not the distinct_id.
$this->assertTrue(PostHog::getFeatureFlag(
'rollout-flag',
'any-distinct-id',
["company" => "acme"],
[],
["company" => []]
));
}

public function testFlagComplexDefinition()
{
$this->http_client = new MockedHttpClient(host: "app.posthog.com", flagEndpointResponse: MockedResponses::LOCAL_EVALUATION_COMPLEX_FLAG_REQUEST);
Expand Down
112 changes: 112 additions & 0 deletions test/assests/MockedResponses.php
Original file line number Diff line number Diff line change
Expand Up @@ -768,6 +768,118 @@ class MockedResponses
]
];

public const LOCAL_EVALUATION_MIXED_TARGETING_REQUEST = [
'count' => 1,
'next' => null,
'previous' => null,
'flags' => [
[
"id" => 1,
"name" => "Mixed Flag",
"key" => "mixed-flag",
"filters" => [
"aggregation_group_type_index" => null,
"groups" => [
[
"aggregation_group_type_index" => 0,
"properties" => [
[
"key" => "plan",
"value" => ["enterprise"],
"operator" => "exact",
"type" => "group",
"group_type_index" => 0
]
],
"rollout_percentage" => 100
],
[
"aggregation_group_type_index" => null,
"properties" => [
[
"key" => "email",
"value" => ["test@example.com"],
"operator" => "exact",
"type" => "person"
]
],
"rollout_percentage" => 100
]
]
],
"deleted" => false,
"active" => true,
"is_simple_flag" => false,
"rollout_percentage" => null
]
],
'group_type_mapping' => ["0" => "company"]
];

public const LOCAL_EVALUATION_ONLY_GROUP_CONDITION_REQUEST = [
'count' => 1,
'next' => null,
'previous' => null,
'flags' => [
[
"id" => 2,
"name" => "Only Group Flag",
"key" => "only-group-flag",
"filters" => [
"aggregation_group_type_index" => null,
"groups" => [
[
"aggregation_group_type_index" => 0,
"properties" => [
[
"key" => "plan",
"value" => ["enterprise"],
"operator" => "exact",
"type" => "group",
"group_type_index" => 0
]
],
"rollout_percentage" => 100
]
]
],
"deleted" => false,
"active" => true,
"is_simple_flag" => false,
"rollout_percentage" => null
]
],
'group_type_mapping' => ["0" => "company"]
];

public const LOCAL_EVALUATION_GROUP_ROLLOUT_REQUEST = [
'count' => 1,
'next' => null,
'previous' => null,
'flags' => [
[
"id" => 3,
"name" => "Rollout Flag",
"key" => "rollout-flag",
"filters" => [
"aggregation_group_type_index" => null,
"groups" => [
[
"aggregation_group_type_index" => 0,
"properties" => [],
"rollout_percentage" => 100
]
]
],
"deleted" => false,
"active" => true,
"is_simple_flag" => false,
"rollout_percentage" => null
]
],
'group_type_mapping' => ["0" => "company"]
];

public const LOCAL_EVALUATION_COMPLEX_FLAG_REQUEST = [
'count' => 1,
'next' => null,
Expand Down
Loading