Skip to content
Draft
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/versioned-property-matching.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
posthog-php: patch
---

Honor the definitions snapshot's `property_matching_version` during local feature flag evaluation, including group, cohort, and flag dependency conditions. Version 2 uses explicit boolean matching; missing or other versions retain legacy matching. Preserve the selector across external definition caches and reloads.
50 changes: 50 additions & 0 deletions api/public-api.json
Original file line number Diff line number Diff line change
Expand Up @@ -2136,6 +2136,16 @@
"default": null,
"defaultConstant": null,
"hasDefault": false
},
{
"name": "propertyMatchingVersion",
"type": null,
"byReference": false,
"variadic": false,
"optional": true,
"default": 1,
"defaultConstant": null,
"hasDefault": true
}
]
},
Expand Down Expand Up @@ -2204,6 +2214,16 @@
"default": null,
"defaultConstant": null,
"hasDefault": true
},
{
"name": "propertyMatchingVersion",
"type": null,
"byReference": false,
"variadic": false,
"optional": true,
"default": 1,
"defaultConstant": null,
"hasDefault": true
}
]
},
Expand Down Expand Up @@ -2302,6 +2322,16 @@
"default": [],
"defaultConstant": null,
"hasDefault": true
},
{
"name": "propertyMatchingVersion",
"type": null,
"byReference": false,
"variadic": false,
"optional": true,
"default": 1,
"defaultConstant": null,
"hasDefault": true
}
]
},
Expand Down Expand Up @@ -2330,6 +2360,16 @@
"default": null,
"defaultConstant": null,
"hasDefault": false
},
{
"name": "propertyMatchingVersion",
"type": null,
"byReference": false,
"variadic": false,
"optional": true,
"default": 1,
"defaultConstant": null,
"hasDefault": true
}
]
},
Expand Down Expand Up @@ -2398,6 +2438,16 @@
"default": null,
"defaultConstant": null,
"hasDefault": true
},
{
"name": "propertyMatchingVersion",
"type": null,
"byReference": false,
"variadic": false,
"optional": true,
"default": 1,
"defaultConstant": null,
"hasDefault": true
}
]
},
Expand Down
56 changes: 44 additions & 12 deletions lib/Client.php
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,9 @@ class Client implements FeatureFlagEvaluationsHost
*/
public $featureFlagsByKey;

/** @var mixed Matching selector from the current definition set; missing defaults to legacy. */
private $propertyMatchingVersion = 1;

/**
* @var SizeLimitedHash
*/
Expand Down Expand Up @@ -783,12 +786,14 @@ private function doGetFeatureFlagResult(
$featureFlagError = null;
$localFlagDefinition = null;

foreach ($this->featureFlags as $flag) {
$definitionSnapshot = $this->flagDefinitionSnapshot();
foreach ($definitionSnapshot['flags'] as $flag) {
if ($flag["key"] == $key) {
$localFlagDefinition = $flag;
try {
$result = $this->computeFlagLocally(
$flag,
$definitionSnapshot,
$distinctId,
$groups,
$personProperties,
Expand Down Expand Up @@ -994,11 +999,13 @@ public function getAllFlags(
$response = [];
$fallbackToFlags = false;

if (count($this->featureFlags) > 0) {
foreach ($this->featureFlags as $flag) {
$definitionSnapshot = $this->flagDefinitionSnapshot();
if (count($definitionSnapshot['flags']) > 0) {
foreach ($definitionSnapshot['flags'] as $flag) {
try {
$response[$flag['key']] = $this->computeFlagLocally(
$flag,
$definitionSnapshot,
$distinctId,
$groups,
$personProperties,
Expand Down Expand Up @@ -1095,10 +1102,11 @@ public function evaluateFlags(
// Local pass: try to resolve any flag we can without going to the server. Track whether
// any flag was inconclusive (which forces a remote round trip) so we can skip /flags
// entirely when local evaluation covered everything we know about.
$hasLocalDefinitions = count($this->featureFlags) > 0;
$definitionSnapshot = $this->flagDefinitionSnapshot();
$hasLocalDefinitions = count($definitionSnapshot['flags']) > 0;
if ($hasLocalDefinitions) {
$localKeys = [];
foreach ($this->featureFlags as $flag) {
foreach ($definitionSnapshot['flags'] as $flag) {
$key = $flag['key'] ?? null;
if (!is_string($key) || $key === '') {
continue;
Expand All @@ -1112,6 +1120,7 @@ public function evaluateFlags(
try {
$value = $this->computeFlagLocally(
$flag,
$definitionSnapshot,
$distinctId,
$groups,
$personProperties,
Expand Down Expand Up @@ -1320,8 +1329,26 @@ public function logWarning(string $message): void
error_log("[PostHog][Client] " . $message);
}

/**
* Capture one definition context for an entire local evaluation, including dependencies.
* PHP arrays are copy-on-write, so a reentrant reload cannot change this snapshot.
*
* @return array<string, mixed>
*/
private function flagDefinitionSnapshot(): array
{
return [
'flags' => $this->featureFlags,
'flags_by_key' => $this->featureFlagsByKey,
'group_type_mapping' => $this->groupTypeMapping,
'cohorts' => $this->cohorts,
'property_matching_version' => $this->propertyMatchingVersion,
];
}

private function computeFlagLocally(
array $featureFlag,
array $definitionSnapshot,
string $distinctId,
array $groups = array(),
array $personProperties = array(),
Expand All @@ -1342,7 +1369,7 @@ private function computeFlagLocally(
$aggregationGroupTypeIndex = $flagFilters["aggregation_group_type_index"] ?? null;

if (!is_null($aggregationGroupTypeIndex)) {
$groupName = $this->groupTypeMapping[strval($aggregationGroupTypeIndex)] ?? null;
$groupName = $definitionSnapshot['group_type_mapping'][strval($aggregationGroupTypeIndex)] ?? null;

if (is_null($groupName)) {
throw new InconclusiveMatchException("Flag has unknown group type index");
Expand All @@ -1357,12 +1384,13 @@ private function computeFlagLocally(
$featureFlag,
$groups[$groupName],
$focusedGroupProperties,
$this->cohorts,
$this->featureFlagsByKey,
$definitionSnapshot['cohorts'],
$definitionSnapshot['flags_by_key'],
$evaluationCache,
$groups,
$groupProperties,
$this->groupTypeMapping
$definitionSnapshot['group_type_mapping'],
$definitionSnapshot['property_matching_version']
);
} else {
$localPersonProperties = $personProperties;
Expand All @@ -1374,12 +1402,13 @@ private function computeFlagLocally(
$featureFlag,
$distinctId,
$localPersonProperties,
$this->cohorts,
$this->featureFlagsByKey,
$definitionSnapshot['cohorts'],
$definitionSnapshot['flags_by_key'],
$evaluationCache,
$groups,
$groupProperties,
$this->groupTypeMapping
$definitionSnapshot['group_type_mapping'],
$definitionSnapshot['property_matching_version']
);
}
}
Expand Down Expand Up @@ -1561,6 +1590,7 @@ private function normalizeFlagDefinitionData(array $data): array
: [],
'cohorts' => isset($data['cohorts']) && is_array($data['cohorts']) ? $data['cohorts'] : [],
'minimal_flag_called_events' => ($data['minimal_flag_called_events'] ?? null) === true,
'property_matching_version' => $data['property_matching_version'] ?? 1,
];
}

Expand Down Expand Up @@ -1598,6 +1628,7 @@ private function normalizeFlagDefinitionCacheData(array $data): ?array
'group_type_mapping' => $groupTypeMapping,
'cohorts' => $data['cohorts'],
'minimal_flag_called_events' => ($data['minimal_flag_called_events'] ?? null) === true,
'property_matching_version' => $data['property_matching_version'] ?? 1,
];
}

Expand All @@ -1612,6 +1643,7 @@ private function applyFlagDefinitions(array $data): void
$this->featureFlags = $data['flags'];
$this->groupTypeMapping = $data['group_type_mapping'];
$this->cohorts = $data['cohorts'];
$this->propertyMatchingVersion = $data['property_matching_version'];
$this->minimalFlagCalledEvents = $data['minimal_flag_called_events'];

// Build flags by key dictionary for dependency resolution
Expand Down
Loading
Loading