Skip to content

feat(feature-flags): support early_exit in local evaluation#166

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

feat(feature-flags): support early_exit in local evaluation#166
gustavohstrassburger wants to merge 2 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, and it has already been ported to posthog-node and posthog-python. This PR ports the same behavior to the Ruby SDK's local evaluation.

When a flag's filters.early_exit is true, local condition evaluation must STOP and return a definitive disabled result 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.

To mirror the Rust engine exactly, the single-group matcher now returns a tri-state outcome instead of a boolean:

  • :match — property filters matched AND rollout included the user (existing behavior).
  • :no_match — a property filter did NOT match -> always falls through to the next group, even with early_exit enabled.
  • :out_of_rollout_bound — property filters matched (or there were none) but the rollout percentage excluded the user. With early_exit enabled, the loop returns a definitive disabled result immediately; otherwise existing fall-through behavior is preserved.

early_exit is read from the flag's filters and defaults to false/absent, so behavior is unchanged for existing flags.

💚 How did you test it?

  • Added unit tests against match_feature_flag_properties covering: (a) early_exit enabled returns false without evaluating a later group that would match; (b) early_exit unset falls through to the later matching group (returns true); (c) early_exit explicitly false falls through (returns true); (d) a group failing on a property filter (not rollout) does NOT early-exit even when enabled and falls through to a later matching group. The first group uses matching properties with rollout_percentage: 0 and the second uses matching properties with rollout_percentage: 100.
  • Existing condition_match tests still pass via a thin boolean wrapper over the new tri-state method.
  • Ran the full suite: bundle exec rspec -> 446 examples, 0 failures.
  • Ran the linter/formatter: bundle exec rubocop -> no offenses 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

gustavohstrassburger and others added 2 commits June 4, 2026 13:17
Port the server-side early_exit behavior to Ruby local feature flag evaluation. When filters.early_exit is true and a condition group's property filters match (or it has none) but the rollout percentage excludes the user, return a definitive disabled result instead of falling through to later groups. Property-mismatch groups still fall through, and behavior 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 14:30
@gustavohstrassburger gustavohstrassburger requested a review from a team as a code owner June 5, 2026 14:30
@greptile-apps
Copy link
Copy Markdown

greptile-apps Bot commented Jun 5, 2026

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
spec/posthog/flags_spec.rb:1671-1693
Three of the new tests are structurally identical — they all call `build_flag` with a different `early_exit` value and assert on the result. The repo's stated convention is to always prefer parameterised tests; these three contexts fit that pattern exactly and could be collapsed into a single table-driven loop.

```suggestion
    [
      [true,  false, 'enabled'],
      [nil,   true,  'unset'],
      [false, true,  'explicitly false']
    ].each do |early_exit_val, expected, description|
      context "when early_exit is #{description}" do
        it "returns #{expected}" do
          flag = build_flag(early_exit: early_exit_val)
          result = poller.send(:match_feature_flag_properties, flag, distinct_id, properties, evaluation_cache)
          expect(result).to be expected
        end
      end
    end
```

Reviews (1): Last reviewed commit: "chore(feature-flags): trim redundant com..." | Re-trigger Greptile

Comment on lines +1671 to +1693
context 'when early_exit is enabled' do
it 'returns false without evaluating a later matching group' do
flag = build_flag(early_exit: true)
result = poller.send(:match_feature_flag_properties, flag, distinct_id, properties, evaluation_cache)
expect(result).to be false
end
end

context 'when early_exit is unset' do
it 'falls through to the later matching group and returns true' do
flag = build_flag(early_exit: nil)
result = poller.send(:match_feature_flag_properties, flag, distinct_id, properties, evaluation_cache)
expect(result).to be true
end
end

context 'when early_exit is explicitly false' do
it 'falls through to the later matching group and returns true' do
flag = build_flag(early_exit: false)
result = poller.send(:match_feature_flag_properties, flag, distinct_id, properties, evaluation_cache)
expect(result).to be true
end
end
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Three of the new tests are structurally identical — they all call build_flag with a different early_exit value and assert on the result. The repo's stated convention is to always prefer parameterised tests; these three contexts fit that pattern exactly and could be collapsed into a single table-driven loop.

Suggested change
context 'when early_exit is enabled' do
it 'returns false without evaluating a later matching group' do
flag = build_flag(early_exit: true)
result = poller.send(:match_feature_flag_properties, flag, distinct_id, properties, evaluation_cache)
expect(result).to be false
end
end
context 'when early_exit is unset' do
it 'falls through to the later matching group and returns true' do
flag = build_flag(early_exit: nil)
result = poller.send(:match_feature_flag_properties, flag, distinct_id, properties, evaluation_cache)
expect(result).to be true
end
end
context 'when early_exit is explicitly false' do
it 'falls through to the later matching group and returns true' do
flag = build_flag(early_exit: false)
result = poller.send(:match_feature_flag_properties, flag, distinct_id, properties, evaluation_cache)
expect(result).to be true
end
end
[
[true, false, 'enabled'],
[nil, true, 'unset'],
[false, true, 'explicitly false']
].each do |early_exit_val, expected, description|
context "when early_exit is #{description}" do
it "returns #{expected}" do
flag = build_flag(early_exit: early_exit_val)
result = poller.send(:match_feature_flag_properties, flag, distinct_id, properties, evaluation_cache)
expect(result).to be expected
end
end
end

Context Used: Do not attempt to comment on incorrect alphabetica... (source)

Prompt To Fix With AI
This is a comment left during a code review.
Path: spec/posthog/flags_spec.rb
Line: 1671-1693

Comment:
Three of the new tests are structurally identical — they all call `build_flag` with a different `early_exit` value and assert on the result. The repo's stated convention is to always prefer parameterised tests; these three contexts fit that pattern exactly and could be collapsed into a single table-driven loop.

```suggestion
    [
      [true,  false, 'enabled'],
      [nil,   true,  'unset'],
      [false, true,  'explicitly false']
    ].each do |early_exit_val, expected, description|
      context "when early_exit is #{description}" do
        it "returns #{expected}" do
          flag = build_flag(early_exit: early_exit_val)
          result = poller.send(:match_feature_flag_properties, flag, distinct_id, properties, evaluation_cache)
          expect(result).to be expected
        end
      end
    end
```

**Context Used:** Do not attempt to comment on incorrect alphabetica... ([source](https://app.greptile.com/review/custom-context?memory=instruction-0))

How can I resolve this? If you propose a fix, please make it concise.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Copy link
Copy Markdown
Contributor

@dustinbyrne dustinbyrne left a comment

Choose a reason for hiding this comment

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

greptile loves parameterization but this looks good

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