Skip to content
Open
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/feature-flag-early-exit.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"posthog-ruby": minor
---

Support the `early_exit` option in local feature flag evaluation. When a flag's `filters.early_exit` is `true`, evaluation stops and returns a definitive disabled result as soon as a condition group's property filters match (or it has none) but the rollout percentage excludes the user, instead of falling through to later condition groups. This mirrors the server-side evaluation engine (and posthog-node / posthog-python). Property-mismatch groups still fall through as before, and behavior is unchanged when `early_exit` is unset or `false`.
30 changes: 24 additions & 6 deletions lib/posthog/feature_flags.rb
Original file line number Diff line number Diff line change
Expand Up @@ -961,6 +961,7 @@ def match_feature_flag_properties(flag, distinct_id, properties, evaluation_cach

flag_conditions = flag_filters[:groups] || []
flag_aggregation = flag_filters[:aggregation_group_type_index]
early_exit = flag_filters[:early_exit] == true
is_inconclusive = false
result = nil

Expand Down Expand Up @@ -997,8 +998,9 @@ def match_feature_flag_properties(flag, distinct_id, properties, evaluation_cach
end
end

if condition_match(flag, effective_bucketing, condition, effective_properties, evaluation_cache,
cohort_properties)
case condition_match_outcome(flag, effective_bucketing, condition, effective_properties, evaluation_cache,
cohort_properties)
when :match
variant_override = condition[:variant]
flag_multivariate = flag_filters[:multivariate] || {}
flag_variants = flag_multivariate[:variants] || []
Expand All @@ -1009,6 +1011,8 @@ def match_feature_flag_properties(flag, distinct_id, properties, evaluation_cach
end
result = variant || true
break
when :out_of_rollout_bound
break if early_exit
end
rescue RequiresServerEvaluation
# Static cohort or other missing server-side data - must fallback to API
Expand All @@ -1030,6 +1034,18 @@ def match_feature_flag_properties(flag, distinct_id, properties, evaluation_cach
end

def condition_match(flag, distinct_id, condition, properties, evaluation_cache, cohort_properties = {})
condition_match_outcome(flag, distinct_id, condition, properties, evaluation_cache,
cohort_properties) == :match
end

# Evaluates a single condition group and returns a tri-state outcome:
# :match - property filters matched AND the rollout included the user
# :no_match - a property filter did NOT match
# :out_of_rollout_bound - property filters matched (or there were none) but the
# rollout percentage excluded the user
# Distinguishing :no_match from :out_of_rollout_bound lets the caller implement the
# early_exit behavior (mirrors the server-side Rust evaluation engine).
def condition_match_outcome(flag, distinct_id, condition, properties, evaluation_cache, cohort_properties = {})
rollout_percentage = condition[:rollout_percentage]

unless (condition[:properties] || []).empty?
Expand All @@ -1040,15 +1056,17 @@ def condition_match(flag, distinct_id, condition, properties, evaluation_cache,
FeatureFlagsPoller.match_property(prop, properties, cohort_properties)
end
end
return false
return :no_match
end

return true if rollout_percentage.nil?
return :match if rollout_percentage.nil?
end

return false if !rollout_percentage.nil? && (_hash(flag[:key], distinct_id) > (rollout_percentage.to_f / 100))
if !rollout_percentage.nil? && (_hash(flag[:key], distinct_id) > (rollout_percentage.to_f / 100))
return :out_of_rollout_bound
end

true
:match
end

# This function takes a distinct_id and a feature flag key and returns a float between 0 and 1.
Expand Down
92 changes: 92 additions & 0 deletions spec/posthog/flags_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -1641,6 +1641,98 @@ def stub_feature_flags(flags)
end
end

describe 'FeatureFlagsPoller#match_feature_flag_properties early_exit' do
let(:client) { Client.new(api_key: API_KEY, personal_api_key: API_KEY, test_mode: true) }
let(:poller) { client.instance_variable_get(:@feature_flags_poller) }
let(:distinct_id) { 'test-user' }
let(:properties) { { email: 'test@example.com' } }
let(:evaluation_cache) { {} }

before do
# Stub the initial feature flag definitions request
stub_request(:get, 'https://us.i.posthog.com/flags/definitions?token=testsecret&send_cohorts=true')
.to_return(status: 200, body: { flags: [] }.to_json)
end

# First group: properties match (none) but rollout 0% excludes the user
# (OUT_OF_ROLLOUT_BOUND). Second group: properties match and rollout 100%
# includes the user (would MATCH).
def build_flag(early_exit:)
filters = {
groups: [
{ properties: [], rollout_percentage: 0 },
{ properties: [], rollout_percentage: 100 }
]
}
filters[:early_exit] = early_exit unless early_exit.nil?
{ key: 'test-flag', filters: filters }
end

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
Comment on lines +1671 to +1693
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!


context 'when a group fails on a property filter (not rollout) and early_exit is enabled' do
it 'does not early-exit and falls through to a later matching group' do
flag = {
key: 'test-flag',
filters: {
early_exit: true,
groups: [
# Property filter does NOT match -> NO_MATCH (must fall through
# even with early_exit enabled), despite rollout 0%.
{
properties: [{ key: 'email', value: 'other@example.com', operator: 'exact', type: 'person' }],
rollout_percentage: 0
},
{ properties: [], rollout_percentage: 100 }
]
}
}
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 enabled on a multivariate flag' do
it 'returns false without leaking a variant from a later group' do
flag = {
key: 'mv-flag',
filters: {
early_exit: true,
multivariate: { variants: [{ key: 'control', rollout_percentage: 100 }] },
groups: [
{ properties: [], rollout_percentage: 0 },
{ properties: [], rollout_percentage: 100 }
]
}
}
result = poller.send(:match_feature_flag_properties, flag, distinct_id, properties, evaluation_cache)
expect(result).to be false
end
end
end

describe 'FeatureFlagsPoller ETag support' do
let(:feature_flag_endpoint) { 'https://us.i.posthog.com/flags/definitions?token=testsecret&send_cohorts=true' }
let(:client) { Client.new(api_key: API_KEY, personal_api_key: API_KEY, test_mode: true) }
Expand Down
Loading