diff --git a/.sampo/changesets/feature-flag-early-exit.md b/.sampo/changesets/feature-flag-early-exit.md new file mode 100644 index 00000000..4f97bd09 --- /dev/null +++ b/.sampo/changesets/feature-flag-early-exit.md @@ -0,0 +1,5 @@ +--- +pypi/posthog: minor +--- + +feat(feature-flags): support the `early_exit` condition option in local evaluation. When a flag enables early exit, evaluation now stops and returns `False` as soon as a condition group's property filters match but the rollout percentage excludes the user, instead of falling through to later groups — matching the server-side evaluation behavior. diff --git a/posthog/feature_flags.py b/posthog/feature_flags.py index 845f1d04..4290d58f 100644 --- a/posthog/feature_flags.py +++ b/posthog/feature_flags.py @@ -4,6 +4,7 @@ import logging import re import warnings +from enum import Enum from typing import Optional from posthog import utils @@ -16,6 +17,20 @@ NONE_VALUES_ALLOWED_OPERATORS = ["is_not"] + +class ConditionMatch(Enum): + """Outcome of evaluating a single condition group. + + OUT_OF_ROLLOUT_BOUND means the group's property filters matched (or there were none) + but the rollout percentage excluded the user — the only case that triggers a flag's + ``early_exit`` short-circuit. Mirrors the server-side (Rust) engine's match cases. + """ + + MATCH = "match" + NO_MATCH = "no_match" + OUT_OF_ROLLOUT_BOUND = "out_of_rollout_bound" + + # All operators supported by match_property, grouped by category. EQUALITY_OPERATORS = ("exact", "is_not", "is_set", "is_not_set") STRING_OPERATORS = ("icontains", "not_icontains", "regex", "not_regex") @@ -307,6 +322,7 @@ def match_feature_flag_properties( flag_filters = flag.get("filters") or {} flag_conditions = flag_filters.get("groups") or [] flag_aggregation = flag_filters.get("aggregation_group_type_index") + early_exit_enabled = flag_filters.get("early_exit") is_inconclusive = False cohort_properties = cohort_properties or {} groups = groups or {} @@ -349,7 +365,7 @@ def match_feature_flag_properties( effective_properties = properties effective_bucketing = bucketing_value - if is_condition_match( + match_result = is_condition_match( flag, distinct_id, condition, @@ -359,13 +375,22 @@ def match_feature_flag_properties( evaluation_cache, bucketing_value=effective_bucketing, device_id=device_id, - ): + ) + if match_result == ConditionMatch.MATCH: variant_override = condition.get("variant") if variant_override and variant_override in valid_variant_keys: variant = variant_override else: variant = get_matching_variant(flag, effective_bucketing) return variant or True + elif ( + early_exit_enabled + and match_result == ConditionMatch.OUT_OF_ROLLOUT_BOUND + ): + # The condition's property filters (if any) matched and only the rollout check + # failed, so re-evaluating later groups can't change the outcome. Return a + # deterministic False, mirroring the server-side engine. + return False except RequiresServerEvaluation: # Static cohort or other missing server-side data - must fallback to API raise @@ -395,7 +420,7 @@ def is_condition_match( *, bucketing_value, device_id=None, -) -> bool: +) -> ConditionMatch: rollout_percentage = condition.get("rollout_percentage") if len(condition.get("properties") or []) > 0: for prop in condition.get("properties"): @@ -423,17 +448,19 @@ def is_condition_match( else: matches = match_property(prop, properties) if not matches: - return False + return ConditionMatch.NO_MATCH if rollout_percentage is None: - return True + return ConditionMatch.MATCH + # Property filters (if any) matched; only the rollout check remains. A failure here means + # the user was targeted but excluded by rollout — the server-side engine's OutOfRolloutBound. if rollout_percentage is not None and _hash( feature_flag["key"], bucketing_value ) > (rollout_percentage / 100): - return False + return ConditionMatch.OUT_OF_ROLLOUT_BOUND - return True + return ConditionMatch.MATCH def match_property(property, property_values) -> bool: diff --git a/posthog/test/test_feature_flags.py b/posthog/test/test_feature_flags.py index d9aa5323..09506838 100644 --- a/posthog/test/test_feature_flags.py +++ b/posthog/test/test_feature_flags.py @@ -141,6 +141,184 @@ def test_case_insensitive_matching(self): ) ) + @parameterized.expand( + [ + # (description, early_exit value, expected result) + ("enabled", True, False), + ("not set", None, True), + ("explicitly disabled", False, True), + ] + ) + def test_early_exit(self, _name, early_exit, expected): + # First group's properties match but its rollout (0%) excludes everyone; the second + # group would otherwise match. Mirrors the server-side OutOfRolloutBound short-circuit. + filters = { + "groups": [ + { + "properties": [ + { + "key": "region", + "operator": "exact", + "value": ["USA"], + "type": "person", + } + ], + "rollout_percentage": 0, + }, + { + "properties": [ + { + "key": "region", + "operator": "exact", + "value": ["USA"], + "type": "person", + } + ], + "rollout_percentage": 100, + }, + ], + } + if early_exit is not None: + filters["early_exit"] = early_exit + + self.client.feature_flags = [ + { + "id": 1, + "name": "Early Exit Feature", + "key": "early-exit-flag", + "active": True, + "filters": filters, + } + ] + + self.assertEqual( + self.client.get_feature_flag( + "early-exit-flag", + "some-distinct-id", + person_properties={"region": "USA"}, + ), + expected, + ) + + def test_early_exit_on_rollout_only_group_with_no_property_filters(self): + self.client.feature_flags = [ + { + "id": 1, + "name": "Early Exit Feature", + "key": "early-exit-flag", + "active": True, + "filters": { + "early_exit": True, + "groups": [ + {"rollout_percentage": 0}, + {"rollout_percentage": 100}, + ], + }, + } + ] + + self.assertFalse( + self.client.get_feature_flag("early-exit-flag", "some-distinct-id") + ) + + def test_early_exit_does_not_trigger_on_property_mismatch(self): + # First group fails on its property (region mismatch), not rollout — so even with + # early_exit enabled, evaluation must continue to the second group, which matches. + self.client.feature_flags = [ + { + "id": 1, + "name": "Early Exit Feature", + "key": "early-exit-flag", + "active": True, + "filters": { + "early_exit": True, + "groups": [ + { + "properties": [ + { + "key": "region", + "operator": "exact", + "value": ["Canada"], + "type": "person", + } + ], + "rollout_percentage": 0, + }, + { + "properties": [ + { + "key": "region", + "operator": "exact", + "value": ["USA"], + "type": "person", + } + ], + "rollout_percentage": 100, + }, + ], + }, + } + ] + + self.assertTrue( + self.client.get_feature_flag( + "early-exit-flag", + "some-distinct-id", + person_properties={"region": "USA"}, + ) + ) + + def test_early_exit_on_multivariate_flag(self): + self.client.feature_flags = [ + { + "id": 1, + "name": "Early Exit Multivariate", + "key": "early-exit-multivariate", + "active": True, + "filters": { + "early_exit": True, + "multivariate": { + "variants": [ + {"key": "control", "rollout_percentage": 50}, + {"key": "test", "rollout_percentage": 50}, + ] + }, + "groups": [ + { + "properties": [ + { + "key": "region", + "operator": "exact", + "value": ["USA"], + "type": "person", + } + ], + "rollout_percentage": 0, + }, + { + "properties": [ + { + "key": "region", + "operator": "exact", + "value": ["USA"], + "type": "person", + } + ], + "rollout_percentage": 100, + }, + ], + }, + } + ] + + self.assertFalse( + self.client.get_feature_flag( + "early-exit-multivariate", + "some-distinct-id", + person_properties={"region": "USA"}, + ) + ) + @mock.patch("posthog.client.flags") @mock.patch("posthog.client.get") def test_flag_group_properties(self, patch_get, patch_flags):