diff --git a/.changeset/feature-flag-early-exit.md b/.changeset/feature-flag-early-exit.md new file mode 100644 index 0000000..2488611 --- /dev/null +++ b/.changeset/feature-flag-early-exit.md @@ -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`. diff --git a/lib/posthog/feature_flags.rb b/lib/posthog/feature_flags.rb index da57bc7..987189f 100644 --- a/lib/posthog/feature_flags.rb +++ b/lib/posthog/feature_flags.rb @@ -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 @@ -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] || [] @@ -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 @@ -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? @@ -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. diff --git a/spec/posthog/flags_spec.rb b/spec/posthog/flags_spec.rb index aa008bf..823e6d3 100644 --- a/spec/posthog/flags_spec.rb +++ b/spec/posthog/flags_spec.rb @@ -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 + + 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) }