feat(flags): support early_exit in local evaluation#160
feat(flags): support early_exit in local evaluation#160gustavohstrassburger wants to merge 4 commits into
Conversation
Read the boolean `early_exit` from a flag's `filters` during local evaluation. When enabled and a condition group's property filters match (or there are none) but the rollout percentage excludes the user, stop evaluating and return false immediately, mirroring the server-side (Rust) engine. `isConditionMatch` now returns a tri-state `ConditionMatch` enum (Match / NoMatch / OutOfRolloutBound) to distinguish rollout-exclusion from property-mismatch. Property-mismatch still falls through to later groups even when early_exit is enabled; behaviour is unchanged when early_exit is unset or false. Generated-By: PostHog Code Task-Id: 707b13a5-0e5d-4764-915a-21e1f2a80c63
Prompt To Fix All With AIFix the following 1 code review issue. Work through them one at a time, proposing concise fixes.
---
### Issue 1 of 1
test/FeatureFlagLocalEvaluationTest.php:4589-4612
**Prefer parameterised tests over three near-identical methods**
`testEarlyExitEnabledReturnsFalseWithoutEvaluatingLaterMatchingGroup`, `testEarlyExitUnsetFallsThroughToMatchingGroup`, and `testEarlyExitFalseFallsThroughToMatchingGroup` have identical structure: call `earlyExitFlag($earlyExit)`, call `matchFeatureFlagProperties`, and assert a boolean. Per the team standard, these should be a single `#[DataProvider]`-driven method. Converting `earlyExitFlag` to a `public static` data provider (e.g. `earlyExitFallThroughProvider`) and collapsing the three methods into one parameterised test expresses the same coverage with less duplication.
Reviews (1): Last reviewed commit: "feat(feature-flags): support early_exit ..." | Re-trigger Greptile |
dustinbyrne
left a comment
There was a problem hiding this comment.
Validated the early_exit edge case locally; leaving one blocking correctness note inline.
| } else { | ||
| return FeatureFlag::getMatchingVariant($flag, $effectiveBucketing) ?? true; | ||
| } | ||
| } elseif ($earlyExitEnabled && $matchResult === ConditionMatch::OutOfRolloutBound) { |
There was a problem hiding this comment.
Blocking correctness issue: this returns a definitive false even if a previous condition was inconclusive.
$isInconclusive can already be true from an earlier condition, but this branch returns before the final inconclusive fallback runs. That means local evaluation can incorrectly disable the flag instead of falling back to server evaluation.
Repro shape:
- earlier condition throws/catches
InconclusiveMatchException - later condition is
OutOfRolloutBound filters.early_exit=true
Current result: false
Expected: throw InconclusiveMatchException / server fallback
Please guard this branch with $isInconclusive and throw/propagate inconclusive rather than returning false.
…inconclusive conditions
… in early_exit test
dustinbyrne
left a comment
There was a problem hiding this comment.
Take a look at the following test, it's still a failing condition. Once the early exit / out of rollout band condition is reached, later groups shouldn't be considered. But because an earlier condition was inconclusive, local evaluation also can’t safely return false, so it should preserve the inconclusive/server-fallback path.
public function testEarlyExitAfterInconclusiveDoesNotContinueToMatch(): void
{
$flag = [
"key" => "early-exit-flag",
"filters" => [
"early_exit" => true,
"groups" => [
[
"properties" => [["key" => "missing_prop", "value" => "x", "operator" => "exact"]],
"rollout_percentage" => 100,
],
[
"properties" => [["key" => "region", "value" => "us", "operator" => "exact"]],
"rollout_percentage" => 0,
],
[
"properties" => [["key" => "region", "value" => "us", "operator" => "exact"]],
"rollout_percentage" => 100,
],
],
],
];
$this->expectException(InconclusiveMatchException::class);
FeatureFlag::matchFeatureFlagProperties($flag, "test-user", ["region" => "us"]);
}
💡 Motivation and Context
PostHog added an "early exit" option to feature flags in the server-side (Rust) evaluation engine. When a flag's
filters.early_exitistrue, local condition evaluation must STOP and return a definitivefalse/disabled as soon as a condition group's property filters match (or the group has no property filters) but the rollout percentage EXCLUDES the user — instead of falling through to evaluate later condition groups.This was already ported to
posthog-nodeandposthog-python; this PR ports the same behavior toposthog-phpso local evaluation matches the server.Behavior
For each condition group, evaluation now distinguishes three outcomes:
early_exitis enabled.When
early_exitis enabled AND a group's outcome isOUT_OF_ROLLOUT_BOUND, evaluation returnsfalseimmediately. Whenearly_exitis unset orfalse, existing behavior is preserved exactly.Implementation:
isConditionMatchnow returns a tri-stateConditionMatchenum (Match/NoMatch/OutOfRolloutBound) instead of a bool, lettingmatchFeatureFlagPropertiesshort-circuit on rollout exclusion only.💚 How did you test it?
Added unit tests to
FeatureFlagLocalEvaluationTestcovering:early_exitenabled returnsfalsewithout evaluating a later matching group;early_exitunset falls through (regression);early_exitexplicitlyfalsefalls through; and property-filter failure (not rollout) does NOT early-exit even when enabled.Full suite green:
vendor/bin/phpunit→ 334 tests, 3362 assertions, 0 failures.vendor/bin/phpcs(PSR12) → 0 errors on changed files.📝 Checklist
If releasing new changes
pnpm changesetto generate a changeset fileCreated with PostHog Code