Repository navigation
feat(flags): honor experiment holdouts in local flag evaluation #254
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. Weβll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,5 @@ | ||
| --- | ||
| "posthog-php": minor | ||
| --- | ||
|
|
||
| Honor experiment holdouts when evaluating feature flags locally, so a held-out identifier resolves to `holdout-<id>` instead of an ordinary variant. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -845,6 +845,74 @@ private static function hash($key, $distinctId, $salt = "") | |
| return $hashVal / self::LONG_SCALE; | ||
| } | ||
|
|
||
| /** | ||
| * Resolve the holdout variant for a flag, when its bucketing identity falls inside the holdout. | ||
| * | ||
| * @param mixed $holdout The flag's filters.holdout configuration, if any. | ||
| * @param string $bucketingValue Flag-level bucketing identity (distinct id or group key). | ||
| * @return string|null The "holdout-<id>" variant, or null when no holdout applies. | ||
| */ | ||
| private static function matchHoldout($holdout, $bucketingValue) | ||
| { | ||
| $percentage = FeatureFlag::holdoutPercentage($holdout); | ||
| if (is_null($percentage)) { | ||
| return null; | ||
| } | ||
|
|
||
| $holdoutKey = "holdout-" . $holdout["id"]; | ||
|
|
||
| if ($percentage >= 100.0) { | ||
| return $holdoutKey; | ||
| } | ||
|
|
||
| return FeatureFlag::holdoutHash($bucketingValue) <= $percentage / 100 ? $holdoutKey : null; | ||
| } | ||
|
|
||
| /** | ||
| * @param mixed $holdout The flag's filters.holdout configuration, if any. | ||
| * @return float|null Exclusion percentage clamped to 0-100, or null when the holdout is unusable. | ||
| */ | ||
| private static function holdoutPercentage($holdout): ?float | ||
| { | ||
| if (!is_array($holdout) || is_null($holdout["id"] ?? null)) { | ||
| return null; | ||
| } | ||
|
|
||
| $exclusionPercentage = $holdout["exclusion_percentage"] ?? null; | ||
| if (!is_numeric($exclusionPercentage)) { | ||
| return null; | ||
| } | ||
|
|
||
| // Fractional percentages are preserved; only the 0-100 range is clamped. | ||
| return min(100.0, max(0.0, (float) $exclusionPercentage)); | ||
| } | ||
|
|
||
| private static function hasActiveHoldout($holdout): bool | ||
| { | ||
| return (FeatureFlag::holdoutPercentage($holdout) ?? 0.0) > 0.0; | ||
| } | ||
|
|
||
| private static function aggregationKey($aggregation): ?string | ||
| { | ||
| return is_null($aggregation) ? null : strval($aggregation); | ||
| } | ||
|
|
||
| /** | ||
| * Hash a bucketing identity for holdout membership, matching the backend. | ||
| * | ||
| * Unlike the ordinary flag hash, neither the flag key nor the holdout id participates: | ||
| * the digest is taken over "holdout-<bucketing_value>" with no separator or salt. | ||
| * | ||
| * @param string $bucketingValue Flag-level bucketing identity (distinct id or group key). | ||
| * @return float Hash in the range [0, 1). | ||
| */ | ||
| private static function holdoutHash($bucketingValue) | ||
| { | ||
| $hashVal = base_convert(substr(sha1("holdout-" . $bucketingValue), 0, 15), 16, 10); | ||
|
|
||
| return $hashVal / self::LONG_SCALE; | ||
| } | ||
|
|
||
| private static function getMatchingVariant($flag, $distinctId) | ||
| { | ||
| $variants = FeatureFlag::variantLookupTable($flag); | ||
|
|
@@ -909,8 +977,49 @@ public static function matchFeatureFlagProperties( | |
| $groupProperties = [], | ||
| $groupTypeMapping = [], | ||
| $propertyMatchingVersion = 1 | ||
| ) { | ||
| return FeatureFlag::matchFeatureFlagPropertiesForIdentity( | ||
| $flag, | ||
| $distinctId, | ||
| $properties, | ||
| $cohorts, | ||
| $flagsByKey, | ||
| $evaluationCache, | ||
| $groups, | ||
| $groupProperties, | ||
| $groupTypeMapping, | ||
| $propertyMatchingVersion, | ||
| ($flag["filters"] ?? [])["aggregation_group_type_index"] ?? null | ||
| ); | ||
| } | ||
|
|
||
| /** | ||
| * Same as matchFeatureFlagProperties, but bucketing by an identity whose aggregation is already known. | ||
| * Dependency recursion keeps the root flag's identity, which may differ from the dependency's own. | ||
| * | ||
| * @param int|string|null $identityAggregation Null for a person, a group type index otherwise. | ||
| */ | ||
| private static function matchFeatureFlagPropertiesForIdentity( | ||
| $flag, | ||
| $distinctId, | ||
| $properties, | ||
| $cohorts, | ||
| $flagsByKey, | ||
| $evaluationCache, | ||
| $groups, | ||
| $groupProperties, | ||
| $groupTypeMapping, | ||
| $propertyMatchingVersion, | ||
| $identityAggregation | ||
| ) { | ||
| $flagFilters = $flag["filters"] ?? []; | ||
|
|
||
| // Experiment holdouts win over every release condition, variant override, and rollout. | ||
| $holdoutVariant = FeatureFlag::matchHoldout($flagFilters["holdout"] ?? null, $distinctId); | ||
| if (!is_null($holdoutVariant)) { | ||
| return $holdoutVariant; | ||
| } | ||
|
|
||
| $flagConditions = $flagFilters["groups"] ?? []; | ||
| $flagAggregation = $flagFilters["aggregation_group_type_index"] ?? null; | ||
| $earlyExitEnabled = $flagFilters["early_exit"] ?? false; | ||
|
|
@@ -928,6 +1037,7 @@ public static function matchFeatureFlagProperties( | |
|
|
||
| $effectiveProperties = $properties; | ||
| $effectiveBucketing = $distinctId; | ||
| $effectiveAggregation = $identityAggregation; | ||
|
|
||
| // Mixed-override path: condition-level aggregation differs from flag-level. | ||
| // This assumes flag-level aggregation is null for mixed flags. | ||
|
|
@@ -943,6 +1053,7 @@ public static function matchFeatureFlagProperties( | |
| } | ||
| $effectiveProperties = $groupProperties[$groupName]; | ||
| $effectiveBucketing = $groups[$groupName]; | ||
| $effectiveAggregation = $conditionAggregation; | ||
| } | ||
| } | ||
|
|
||
|
|
@@ -954,7 +1065,8 @@ public static function matchFeatureFlagProperties( | |
| $cohorts, | ||
| $flagsByKey, | ||
| $evaluationCache, | ||
| $propertyMatchingVersion | ||
| $propertyMatchingVersion, | ||
| $effectiveAggregation | ||
| ); | ||
|
|
||
| if ($matchResult === ConditionMatch::Match) { | ||
|
|
@@ -1010,9 +1122,10 @@ private static function isConditionMatch( | |
| $condition, | ||
| $properties, | ||
| $cohorts, | ||
| $flagsByKey = null, | ||
| $evaluationCache = null, | ||
| $propertyMatchingVersion = 1 | ||
| $flagsByKey, | ||
| $evaluationCache, | ||
| $propertyMatchingVersion, | ||
| $identityAggregation | ||
| ): ConditionMatch { | ||
| $rolloutPercentage = array_key_exists("rollout_percentage", $condition) ? $condition["rollout_percentage"] : null; | ||
|
|
||
|
|
@@ -1031,14 +1144,15 @@ private static function isConditionMatch( | |
| $propertyMatchingVersion | ||
| ); | ||
| } elseif ($propertyType == 'flag') { | ||
| $matches = FeatureFlag::evaluateFlagDependency( | ||
| $matches = FeatureFlag::evaluateFlagDependencyForIdentity( | ||
| $property, | ||
| $flagsByKey, | ||
| $evaluationCache, | ||
| $distinctId, | ||
| $properties, | ||
| $cohorts, | ||
| $propertyMatchingVersion | ||
| $propertyMatchingVersion, | ||
| $identityAggregation | ||
| ); | ||
| } else { | ||
| $matches = FeatureFlag::matchProperty($property, $properties, $propertyMatchingVersion); | ||
|
|
@@ -1114,6 +1228,32 @@ public static function evaluateFlagDependency( | |
| $properties, | ||
| $cohortProperties, | ||
| $propertyMatchingVersion = 1 | ||
| ) { | ||
| return FeatureFlag::evaluateFlagDependencyForIdentity( | ||
| $property, | ||
| $flagsByKey, | ||
| $evaluationCache, | ||
| $distinctId, | ||
| $properties, | ||
| $cohortProperties, | ||
| $propertyMatchingVersion, | ||
| false | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. blocking: Preserve aggregation context through cohort dependencies β |
||
| ); | ||
| } | ||
|
|
||
| /** | ||
| * @param int|string|null|false $identityAggregation Aggregation of the identity being bucketed (null for a | ||
| * person, a group type index otherwise). False means unknown and skips the holdout aggregation guard. | ||
| */ | ||
| private static function evaluateFlagDependencyForIdentity( | ||
| $property, | ||
| $flagsByKey, | ||
| $evaluationCache, | ||
| $distinctId, | ||
| $properties, | ||
| $cohortProperties, | ||
| $propertyMatchingVersion, | ||
| $identityAggregation | ||
| ) { | ||
| if ($flagsByKey === null || $evaluationCache === null) { | ||
| throw new InconclusiveMatchException(sprintf( | ||
|
|
@@ -1165,16 +1305,35 @@ public static function evaluateFlagDependency( | |
| if (!($depFlag["active"] ?? false)) { | ||
| $evaluationCache[$depFlagKey] = false; | ||
| } else { | ||
| // Holdout bucketing uses the evaluated identity, which can't be resolved for a | ||
| // dependency aggregated differently, so defer to server evaluation. | ||
| $depFilters = $depFlag["filters"] ?? []; | ||
| if ( | ||
| $identityAggregation !== false && | ||
| FeatureFlag::hasActiveHoldout($depFilters["holdout"] ?? null) && | ||
| FeatureFlag::aggregationKey($depFilters["aggregation_group_type_index"] ?? null) !== | ||
| FeatureFlag::aggregationKey($identityAggregation) | ||
| ) { | ||
| throw new InconclusiveMatchException(sprintf( | ||
| "Cannot evaluate flag dependency '%s' with a holdout aggregated differently from the dependent flag", //phpcs:ignore | ||
| $depFlagKey | ||
| )); | ||
| } | ||
|
|
||
| // Recursively evaluate the dependency | ||
| try { | ||
| $depResult = FeatureFlag::matchFeatureFlagProperties( | ||
| $depResult = FeatureFlag::matchFeatureFlagPropertiesForIdentity( | ||
| $depFlag, | ||
| $distinctId, | ||
| $properties, | ||
| $cohortProperties, | ||
| $flagsByKey, | ||
| $evaluationCache, | ||
| propertyMatchingVersion: $propertyMatchingVersion | ||
| [], | ||
| [], | ||
| [], | ||
| $propertyMatchingVersion, | ||
| $identityAggregation | ||
| ); | ||
| $evaluationCache[$depFlagKey] = $depResult; | ||
| } catch (InconclusiveMatchException $e) { | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
[P1] Resolve a dependency's own identity before applying its holdout
Location:
lib/FeatureFlag.php:963(RIGHT).The new holdout check treats
$distinctIdas the evaluated flag's bucketing identity, but recursiveevaluateFlagDependency()passes the parent condition's identity without resolving the referenced flag's aggregation (lib/FeatureFlag.php:1224β1232). Direct group evaluation correctly supplies the group key (lib/Client.php:1411β1425).A valid public call can therefore return contradictory results:
With a group-aggregated
checkoutflag carrying holdout 727 at 20%, otherwise selectingcontrol, and a personcheckout-bannerflag requiringcheckout == control:checkoutcontrolcontrolcontrolcheckout-bannertruefalsetrueThe company
user-5hashes to approximately 0.6563813925994418, outside the holdout. The personuser-1hashes to approximately 0.17805599206573022, inside it. Only recursive evaluation returnsholdout-727, incorrectly failing the banner condition. Both results are conclusive, so enabling normal remote fallback does not repair this.Inherited-identity recursion existed at base, but 100% ordinary rollout and 100%
controlneutralize its old rollout/variant-bucketing defect in this example. Base correctly returnscontrolthrough both paths; the new holdout branch alone changes the dependent flag. Impact is incorrect application gating and emitted flag values for partial-holdout dependencies whose own identity differs from the parent's; direct and remote-only evaluation remain unaffected.The governing pinned specification requires shared dependency semantics and the flag-level group identity. The backend resolves the referenced flag's aggregation, and Node re-enters full evaluation for dependencies. Backend reference validation permits this person-parent/group-dependency configuration.
Consider resolving each dependency's own flag-level identity while retaining the original person/group context through recursion. When required context is unavailable, use the existing inconclusive path rather than substituting an unrelated identity. No new user-facing API is necessary.
Test sketch β NOT EXECUTED: Add this method to
FeatureFlagHoldoutTest, using its existingholdoutFlag()builder. Explicit condition aggregation mirrors backend normalization; the noop capture consumer isolates flag-request assertions.Expected: both values match the assertions without remote evaluation in either mode. Reviewed-source implication: checkout is
control, but banner isfalse, failing the second assertion. The equivalent fixture and builder are expected to pass against base and after correction. These outcomes are inferred, not executed.Proposed command, not run:
Also cover the opposite membership direction: person
user-5, companyuser-1, dependency expectingholdout-727, with checkoutholdout-727and bannertrue. This companion tests newly supported holdouts; unlike the control fixture, it is not a base-passing regression.There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Fixed in 2e8dc97.
evaluateFlagDependency()now carries the root flag's identity aggregation through the recursion, and a dependency with an active holdout aggregated differently from that identity goes inconclusive instead of being bucketed by the parent's distinct id.So
evaluateFlags('user-1', ['company' => 'user-5'])no longer returnsholdout-727for a group-aggregatedcheckout, andcheckout-bannerfalls back to/flagswhen remote evaluation is allowed.I went with deferring to the server rather than resolving the dependency's own group key locally, since that key isn't reachable inside the recursion today. Covered both membership directions plus the same-aggregation and nested-dependency cases in
test/FeatureFlagHoldoutTest.php. No public API change, the identity parameter lives on private workers andapi/public-api.jsonis untouched.