Skip to content

feat(flags): support early_exit in local evaluation#160

Open
gustavohstrassburger wants to merge 4 commits into
mainfrom
posthog-code/feature-flag-early-exit
Open

feat(flags): support early_exit in local evaluation#160
gustavohstrassburger wants to merge 4 commits into
mainfrom
posthog-code/feature-flag-early-exit

Conversation

@gustavohstrassburger

Copy link
Copy Markdown
Contributor

💡 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_exit is true, local condition evaluation must STOP and return a definitive false/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-node and posthog-python; this PR ports the same behavior to posthog-php so local evaluation matches the server.

Behavior

For each condition group, evaluation now distinguishes three outcomes:

  • MATCH — property filters matched AND rollout included the user → flag matches (existing behavior).
  • NO_MATCH — a property filter did NOT match → continue to the next group. Always falls through, even when early_exit is enabled.
  • OUT_OF_ROLLOUT_BOUND — property filters matched (or none) but rollout EXCLUDED the user.

When early_exit is enabled AND a group's outcome is OUT_OF_ROLLOUT_BOUND, evaluation returns false immediately. When early_exit is unset or false, existing behavior is preserved exactly.

Implementation: isConditionMatch now returns a tri-state ConditionMatch enum (Match / NoMatch / OutOfRolloutBound) instead of a bool, letting matchFeatureFlagProperties short-circuit on rollout exclusion only.

💚 How did you test it?

Added unit tests to FeatureFlagLocalEvaluationTest covering: early_exit enabled returns false without evaluating a later matching group; early_exit unset falls through (regression); early_exit explicitly false falls 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

  • I reviewed the submitted code.
  • I added tests to verify the changes.
  • I updated the docs if needed.
  • No breaking change or entry added to the changelog.

If releasing new changes

  • Ran pnpm changeset to generate a changeset file

Created with PostHog Code

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
@gustavohstrassburger gustavohstrassburger marked this pull request as ready for review June 5, 2026 13:29
@gustavohstrassburger gustavohstrassburger requested a review from a team as a code owner June 5, 2026 13:29
@greptile-apps

greptile-apps Bot commented Jun 5, 2026

Copy link
Copy Markdown
Prompt To Fix All With AI
Fix 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

Comment thread test/FeatureFlagLocalEvaluationTest.php Outdated

@dustinbyrne dustinbyrne left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Validated the early_exit edge case locally; leaving one blocking correctness note inline.

Comment thread lib/FeatureFlag.php
} else {
return FeatureFlag::getMatchingVariant($flag, $effectiveBucketing) ?? true;
}
} elseif ($earlyExitEnabled && $matchResult === ConditionMatch::OutOfRolloutBound) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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:

  1. earlier condition throws/catches InconclusiveMatchException
  2. later condition is OutOfRolloutBound
  3. 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.

@dustinbyrne dustinbyrne left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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"]);
}

@dustinbyrne dustinbyrne requested a review from a team June 8, 2026 20:26
@gustavohstrassburger gustavohstrassburger changed the title feat(feature-flags): support early_exit in local evaluation feat(flags): support early_exit in local evaluation Jun 11, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants