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 .sampo/changesets/feature-flag-early-exit.md
Original file line number Diff line number Diff line change
@@ -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.
41 changes: 34 additions & 7 deletions posthog/feature_flags.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
import logging
import re
import warnings
from enum import Enum
from typing import Optional

from posthog import utils
Expand All @@ -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")
Expand Down Expand Up @@ -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 {}
Expand Down Expand Up @@ -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,
Expand All @@ -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
Expand Down Expand Up @@ -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"):
Expand Down Expand Up @@ -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:
Expand Down
178 changes: 178 additions & 0 deletions posthog/test/test_feature_flags.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
Loading