Skip to content
Merged
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
82 changes: 82 additions & 0 deletions spec/posthog/flags_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -1641,6 +1641,88 @@ 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

[
[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 '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