From 9335136f270d3a33e55a3f662815f5beaeebef0d Mon Sep 17 00:00:00 2001 From: "Gustavo H. Strassburger" Date: Thu, 4 Jun 2026 13:17:36 -0300 Subject: [PATCH 1/2] feat(feature-flags): support early_exit in local evaluation 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 --- .changeset/feature-flag-early-exit.md | 5 ++ lib/posthog/feature_flags.rb | 39 +++++++++++--- spec/posthog/flags_spec.rb | 74 +++++++++++++++++++++++++++ 3 files changed, 112 insertions(+), 6 deletions(-) create mode 100644 .changeset/feature-flag-early-exit.md 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..0c96efb 100644 --- a/lib/posthog/feature_flags.rb +++ b/lib/posthog/feature_flags.rb @@ -961,6 +961,12 @@ def match_feature_flag_properties(flag, distinct_id, properties, evaluation_cach flag_conditions = flag_filters[:groups] || [] flag_aggregation = flag_filters[:aggregation_group_type_index] + # When early_exit is enabled, 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. Mirrors the server-side + # Rust evaluation engine (and posthog-node / posthog-python). + early_exit = flag_filters[:early_exit] == true is_inconclusive = false result = nil @@ -997,8 +1003,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 +1016,12 @@ def match_feature_flag_properties(flag, distinct_id, properties, evaluation_cach end result = variant || true break + when :out_of_rollout_bound + # Property filters matched (or there were none) but the rollout + # percentage excluded the user. With early_exit enabled, stop and + # return a definitive disabled result instead of evaluating later + # condition groups. + break if early_exit end rescue RequiresServerEvaluation # Static cohort or other missing server-side data - must fallback to API @@ -1030,6 +1043,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 +1065,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..d2661eb 100644 --- a/spec/posthog/flags_spec.rb +++ b/spec/posthog/flags_spec.rb @@ -1641,6 +1641,80 @@ 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 + 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) } From 3fe2cee10486cc193ef325e5fff5d348df219325 Mon Sep 17 00:00:00 2001 From: "Gustavo H. Strassburger" Date: Fri, 5 Jun 2026 11:23:12 -0300 Subject: [PATCH 2/2] chore(feature-flags): trim redundant comments and add multivariate early_exit test --- lib/posthog/feature_flags.rb | 9 --------- spec/posthog/flags_spec.rb | 18 ++++++++++++++++++ 2 files changed, 18 insertions(+), 9 deletions(-) diff --git a/lib/posthog/feature_flags.rb b/lib/posthog/feature_flags.rb index 0c96efb..987189f 100644 --- a/lib/posthog/feature_flags.rb +++ b/lib/posthog/feature_flags.rb @@ -961,11 +961,6 @@ def match_feature_flag_properties(flag, distinct_id, properties, evaluation_cach flag_conditions = flag_filters[:groups] || [] flag_aggregation = flag_filters[:aggregation_group_type_index] - # When early_exit is enabled, 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. Mirrors the server-side - # Rust evaluation engine (and posthog-node / posthog-python). early_exit = flag_filters[:early_exit] == true is_inconclusive = false result = nil @@ -1017,10 +1012,6 @@ def match_feature_flag_properties(flag, distinct_id, properties, evaluation_cach result = variant || true break when :out_of_rollout_bound - # Property filters matched (or there were none) but the rollout - # percentage excluded the user. With early_exit enabled, stop and - # return a definitive disabled result instead of evaluating later - # condition groups. break if early_exit end rescue RequiresServerEvaluation diff --git a/spec/posthog/flags_spec.rb b/spec/posthog/flags_spec.rb index d2661eb..823e6d3 100644 --- a/spec/posthog/flags_spec.rb +++ b/spec/posthog/flags_spec.rb @@ -1713,6 +1713,24 @@ def build_flag(early_exit:) 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