Skip to content

Commit 0b6f019

Browse files
committed
feat(flags): implement local evaluation of flag dependency filters
Add support for evaluating feature flag dependencies in local evaluation mode. Previously, flag dependencies were skipped with a warning. Now they are properly evaluated following the dependency chain algorithm. Key features: - Support for type="flag" properties with `dependency_chain` arrays - Sequential evaluation following pre-computed dependency chain order - Circular dependency detection (empty chain → InconclusiveMatchError) - Missing flag dependency handling (evaluate as InconclusiveMatchError) - Evaluation result caching for performance optimization - Full backward compatibility with existing local evaluation
1 parent 6a3e7ef commit 0b6f019

File tree

3 files changed

+611
-41
lines changed

3 files changed

+611
-41
lines changed

posthog/client.py

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1204,6 +1204,9 @@ def _compute_flag_locally(
12041204
person_properties = person_properties or {}
12051205
group_properties = group_properties or {}
12061206

1207+
# Create evaluation cache for flag dependencies
1208+
evaluation_cache: dict[str, Optional[FlagValue]] = {}
1209+
12071210
if feature_flag.get("ensure_experience_continuity", False):
12081211
raise InconclusiveMatchError("Flag has experience continuity enabled")
12091212

@@ -1237,11 +1240,20 @@ def _compute_flag_locally(
12371240

12381241
focused_group_properties = group_properties[group_name]
12391242
return match_feature_flag_properties(
1240-
feature_flag, groups[group_name], focused_group_properties
1243+
feature_flag,
1244+
groups[group_name],
1245+
focused_group_properties,
1246+
self.feature_flags_by_key,
1247+
evaluation_cache,
12411248
)
12421249
else:
12431250
return match_feature_flag_properties(
1244-
feature_flag, distinct_id, person_properties, self.cohorts
1251+
feature_flag,
1252+
distinct_id,
1253+
person_properties,
1254+
self.cohorts,
1255+
self.feature_flags_by_key,
1256+
evaluation_cache,
12451257
)
12461258

12471259
def feature_enabled(

posthog/feature_flags.py

Lines changed: 169 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -55,8 +55,100 @@ def variant_lookup_table(feature_flag):
5555
return lookup_table
5656

5757

58+
def evaluate_flag_dependency(
59+
property, flags_by_key, evaluation_cache, distinct_id, properties, cohort_properties
60+
):
61+
"""
62+
Evaluate a flag dependency property according to the dependency chain algorithm.
63+
64+
Args:
65+
property: Flag property with type="flag" and dependency_chain
66+
flags_by_key: Dictionary of all flags by their key
67+
evaluation_cache: Cache for storing evaluation results
68+
distinct_id: The distinct ID being evaluated
69+
properties: Person properties for evaluation
70+
cohort_properties: Cohort properties for evaluation
71+
72+
Returns:
73+
bool: True if all dependencies in the chain evaluate to True, False otherwise
74+
"""
75+
if flags_by_key is None or evaluation_cache is None:
76+
# Cannot evaluate flag dependencies without required context
77+
raise InconclusiveMatchError(
78+
f"Cannot evaluate flag dependency on '{property.get('key', 'unknown')}' without flags_by_key and evaluation_cache"
79+
)
80+
81+
# Check if dependency_chain is present - it should always be provided for flag dependencies
82+
if "dependency_chain" not in property:
83+
# Missing dependency_chain indicates malformed server data
84+
raise InconclusiveMatchError(
85+
f"Flag dependency property for '{property.get('key', 'unknown')}' is missing required 'dependency_chain' field"
86+
)
87+
88+
dependency_chain = property.get("dependency_chain", [])
89+
90+
# Handle circular dependency (empty chain means circular)
91+
if len(dependency_chain) == 0:
92+
log.debug(f"Circular dependency detected for flag: {property.get('key')}")
93+
raise InconclusiveMatchError(
94+
f"Circular dependency detected for flag '{property.get('key', 'unknown')}'"
95+
)
96+
97+
# Evaluate all dependencies in the chain order
98+
for dep_flag_key in dependency_chain:
99+
if dep_flag_key not in evaluation_cache:
100+
# Need to evaluate this dependency first
101+
dep_flag = flags_by_key.get(dep_flag_key)
102+
if not dep_flag:
103+
# Missing flag dependency - cannot evaluate locally
104+
evaluation_cache[dep_flag_key] = None
105+
raise InconclusiveMatchError(
106+
f"Cannot evaluate flag dependency '{dep_flag_key}' - flag not found in local flags"
107+
)
108+
else:
109+
# Check if the flag is active (same check as in client._compute_flag_locally)
110+
if not dep_flag.get("active"):
111+
evaluation_cache[dep_flag_key] = False
112+
else:
113+
# Recursively evaluate the dependency
114+
try:
115+
dep_result = match_feature_flag_properties(
116+
dep_flag,
117+
distinct_id,
118+
properties,
119+
cohort_properties,
120+
flags_by_key,
121+
evaluation_cache,
122+
)
123+
evaluation_cache[dep_flag_key] = dep_result
124+
except InconclusiveMatchError as e:
125+
# If we can't evaluate a dependency, store None and propagate the error
126+
evaluation_cache[dep_flag_key] = None
127+
raise InconclusiveMatchError(
128+
f"Cannot evaluate flag dependency '{dep_flag_key}': {e}"
129+
) from e
130+
131+
# Check the cached result
132+
cached_result = evaluation_cache[dep_flag_key]
133+
if cached_result is None:
134+
# Previously inconclusive - raise error again
135+
raise InconclusiveMatchError(
136+
f"Flag dependency '{dep_flag_key}' was previously inconclusive"
137+
)
138+
elif not cached_result:
139+
# Definitive False result - dependency failed
140+
return False
141+
142+
return True
143+
144+
58145
def match_feature_flag_properties(
59-
flag, distinct_id, properties, cohort_properties=None
146+
flag,
147+
distinct_id,
148+
properties,
149+
cohort_properties=None,
150+
flags_by_key=None,
151+
evaluation_cache=None,
60152
) -> FlagValue:
61153
flag_conditions = (flag.get("filters") or {}).get("groups") or []
62154
is_inconclusive = False
@@ -79,7 +171,13 @@ def match_feature_flag_properties(
79171
# if any one condition resolves to True, we can shortcircuit and return
80172
# the matching variant
81173
if is_condition_match(
82-
flag, distinct_id, condition, properties, cohort_properties
174+
flag,
175+
distinct_id,
176+
condition,
177+
properties,
178+
cohort_properties,
179+
flags_by_key,
180+
evaluation_cache,
83181
):
84182
variant_override = condition.get("variant")
85183
if variant_override and variant_override in valid_variant_keys:
@@ -101,22 +199,36 @@ def match_feature_flag_properties(
101199

102200

103201
def is_condition_match(
104-
feature_flag, distinct_id, condition, properties, cohort_properties
202+
feature_flag,
203+
distinct_id,
204+
condition,
205+
properties,
206+
cohort_properties,
207+
flags_by_key=None,
208+
evaluation_cache=None,
105209
) -> bool:
106210
rollout_percentage = condition.get("rollout_percentage")
107211
if len(condition.get("properties") or []) > 0:
108212
for prop in condition.get("properties"):
109213
property_type = prop.get("type")
110214
if property_type == "cohort":
111-
matches = match_cohort(prop, properties, cohort_properties)
215+
matches = match_cohort(
216+
prop,
217+
properties,
218+
cohort_properties,
219+
flags_by_key,
220+
evaluation_cache,
221+
distinct_id,
222+
)
112223
elif property_type == "flag":
113-
log.warning(
114-
"Flag dependency filters are not supported in local evaluation. "
115-
"Skipping condition for flag '%s' with dependency on flag '%s'",
116-
feature_flag.get("key", "unknown"),
117-
prop.get("key", "unknown"),
224+
matches = evaluate_flag_dependency(
225+
prop,
226+
flags_by_key,
227+
evaluation_cache,
228+
distinct_id,
229+
properties,
230+
cohort_properties,
118231
)
119-
continue
120232
else:
121233
matches = match_property(prop, properties)
122234
if not matches:
@@ -264,7 +376,14 @@ def compare(lhs, rhs, operator):
264376
raise InconclusiveMatchError(f"Unknown operator {operator}")
265377

266378

267-
def match_cohort(property, property_values, cohort_properties) -> bool:
379+
def match_cohort(
380+
property,
381+
property_values,
382+
cohort_properties,
383+
flags_by_key=None,
384+
evaluation_cache=None,
385+
distinct_id=None,
386+
) -> bool:
268387
# Cohort properties are in the form of property groups like this:
269388
# {
270389
# "cohort_id": {
@@ -281,10 +400,24 @@ def match_cohort(property, property_values, cohort_properties) -> bool:
281400
)
282401

283402
property_group = cohort_properties[cohort_id]
284-
return match_property_group(property_group, property_values, cohort_properties)
403+
return match_property_group(
404+
property_group,
405+
property_values,
406+
cohort_properties,
407+
flags_by_key,
408+
evaluation_cache,
409+
distinct_id,
410+
)
285411

286412

287-
def match_property_group(property_group, property_values, cohort_properties) -> bool:
413+
def match_property_group(
414+
property_group,
415+
property_values,
416+
cohort_properties,
417+
flags_by_key=None,
418+
evaluation_cache=None,
419+
distinct_id=None,
420+
) -> bool:
288421
if not property_group:
289422
return True
290423

@@ -301,7 +434,14 @@ def match_property_group(property_group, property_values, cohort_properties) ->
301434
# a nested property group
302435
for prop in properties:
303436
try:
304-
matches = match_property_group(prop, property_values, cohort_properties)
437+
matches = match_property_group(
438+
prop,
439+
property_values,
440+
cohort_properties,
441+
flags_by_key,
442+
evaluation_cache,
443+
distinct_id,
444+
)
305445
if property_group_type == "AND":
306446
if not matches:
307447
return False
@@ -324,14 +464,23 @@ def match_property_group(property_group, property_values, cohort_properties) ->
324464
for prop in properties:
325465
try:
326466
if prop.get("type") == "cohort":
327-
matches = match_cohort(prop, property_values, cohort_properties)
467+
matches = match_cohort(
468+
prop,
469+
property_values,
470+
cohort_properties,
471+
flags_by_key,
472+
evaluation_cache,
473+
distinct_id,
474+
)
328475
elif prop.get("type") == "flag":
329-
log.warning(
330-
"Flag dependency filters are not supported in local evaluation. "
331-
"Skipping condition with dependency on flag '%s'",
332-
prop.get("key", "unknown"),
476+
matches = evaluate_flag_dependency(
477+
prop,
478+
flags_by_key,
479+
evaluation_cache,
480+
distinct_id,
481+
property_values,
482+
cohort_properties,
333483
)
334-
continue
335484
else:
336485
matches = match_property(prop, property_values)
337486

0 commit comments

Comments
 (0)