Skip to content

Commit ae6f5f9

Browse files
committed
Implement support for flag dependencies
Flags now support filter conditions that depend on how other flags were evaluated. This brings that support to local evaluation in posthog-python. This commit includes unit and integration tests
1 parent da09639 commit ae6f5f9

File tree

10 files changed

+2602
-54
lines changed

10 files changed

+2602
-54
lines changed

bin/run_integration_tests

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
#!/usr/bin/env bash
2+
#/ Usage: bin/run_integration_tests [--debug]
3+
#/ Description: Runs integration tests for PostHog Python SDK
4+
#/ Options:
5+
#/ --debug Enable debug mode for verbose output
6+
source bin/helpers/_utils.sh
7+
set_source_and_root_dir
8+
9+
ensure_virtual_env
10+
11+
# Parse arguments
12+
DEBUG_MODE=false
13+
while [[ $# -gt 0 ]]; do
14+
case $1 in
15+
--debug)
16+
DEBUG_MODE=true
17+
shift
18+
;;
19+
--help|-h)
20+
grep '^#/' "$0" | cut -c4-
21+
exit 0
22+
;;
23+
*)
24+
error "Unknown option: $1"
25+
echo "Use --help for usage information"
26+
exit 1
27+
;;
28+
esac
29+
done
30+
31+
echo "🚀 PostHog Python SDK Integration Tests"
32+
echo "=" | head -c 40 | tr '\n' '='
33+
echo
34+
35+
# Check if the integration test file exists
36+
INTEGRATION_TEST_FILE="posthog/test/manual_integration/test_flag_dependencies.py"
37+
if [ ! -f "$INTEGRATION_TEST_FILE" ]; then
38+
fatal "❌ Integration test file not found: $INTEGRATION_TEST_FILE"
39+
fi
40+
41+
echo "📋 Running flag dependencies integration test..."
42+
if [ "$DEBUG_MODE" = true ]; then
43+
echo "🔍 Debug mode enabled"
44+
export POSTHOG_DEBUG=true
45+
fi
46+
echo
47+
48+
# Run the integration test
49+
if python "$INTEGRATION_TEST_FILE"; then
50+
echo
51+
echo "🎉 All integration tests passed!"
52+
exit 0
53+
else
54+
echo
55+
error "💥 Integration tests failed!"
56+
exit 1
57+
fi

posthog/client.py

Lines changed: 93 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,12 @@
2020
exception_is_already_captured,
2121
mark_exception_as_captured,
2222
)
23-
from posthog.feature_flags import InconclusiveMatchError, match_feature_flag_properties
23+
from posthog.feature_flags import (
24+
InconclusiveMatchError,
25+
match_feature_flag_properties,
26+
build_dependency_graph,
27+
evaluate_flags_with_dependencies,
28+
)
2429
from posthog.poller import Poller
2530
from posthog.request import (
2631
DEFAULT_HOST,
@@ -179,6 +184,8 @@ def __init__(
179184
self.timeout = timeout
180185
self._feature_flags = None # private variable to store flags
181186
self.feature_flags_by_key = None
187+
self.dependency_graph = None # for flag dependencies
188+
self.id_to_key_mapping = None # maps flag ID to flag key
182189
self.group_type_mapping = None
183190
self.cohorts = None
184191
self.poll_interval = poll_interval
@@ -304,6 +311,20 @@ def feature_flags(self, flags):
304311
"feature_flags_by_key should be initialized when feature_flags is set"
305312
)
306313

314+
# Build dependency graph for flag dependencies
315+
try:
316+
self.dependency_graph, self.id_to_key_mapping = build_dependency_graph(
317+
self._feature_flags
318+
)
319+
self.log.debug(
320+
f"Built dependency graph with {len(self.dependency_graph.flags)} flags"
321+
)
322+
self.log.debug(f"ID to key mapping: {self.id_to_key_mapping}")
323+
except Exception as e:
324+
self.log.warning(f"Failed to build dependency graph: {e}")
325+
self.dependency_graph = None
326+
self.id_to_key_mapping = None
327+
307328
def get_feature_variants(
308329
self,
309330
distinct_id,
@@ -970,13 +991,14 @@ def join(self):
970991
posthog.join()
971992
```
972993
"""
973-
for consumer in self.consumers:
974-
consumer.pause()
975-
try:
976-
consumer.join()
977-
except RuntimeError:
978-
# consumer thread has not started
979-
pass
994+
if self.consumers:
995+
for consumer in self.consumers:
996+
consumer.pause()
997+
try:
998+
consumer.join()
999+
except RuntimeError:
1000+
# consumer thread has not started
1001+
pass
9801002

9811003
if self.poller:
9821004
self.poller.stop()
@@ -1135,11 +1157,21 @@ def _compute_flag_locally(
11351157

11361158
focused_group_properties = group_properties[group_name]
11371159
return match_feature_flag_properties(
1138-
feature_flag, groups[group_name], focused_group_properties
1160+
feature_flag,
1161+
groups[group_name],
1162+
focused_group_properties,
1163+
self.cohorts,
1164+
self.dependency_graph,
1165+
self.id_to_key_mapping,
11391166
)
11401167
else:
11411168
return match_feature_flag_properties(
1142-
feature_flag, distinct_id, person_properties, self.cohorts
1169+
feature_flag,
1170+
distinct_id,
1171+
person_properties,
1172+
self.cohorts,
1173+
self.dependency_graph,
1174+
self.id_to_key_mapping,
11431175
)
11441176

11451177
def feature_enabled(
@@ -1408,8 +1440,58 @@ def _locally_evaluate_flag(
14081440
assert self.feature_flags_by_key is not None, (
14091441
"feature_flags_by_key should be initialized when feature_flags is set"
14101442
)
1411-
# Local evaluation
1443+
1444+
# Check if the requested flag has experience continuity enabled
14121445
flag = self.feature_flags_by_key.get(key)
1446+
if flag and flag.get("ensure_experience_continuity", False):
1447+
# For experience continuity flags, we must use individual evaluation
1448+
# which will properly raise InconclusiveMatchError and fall back to /decide
1449+
try:
1450+
response = self._compute_flag_locally(
1451+
flag,
1452+
distinct_id,
1453+
groups=groups,
1454+
person_properties=person_properties,
1455+
group_properties=group_properties,
1456+
)
1457+
self.log.debug(
1458+
f"Successfully computed flag locally: {key} -> {response}"
1459+
)
1460+
return response
1461+
except InconclusiveMatchError as e:
1462+
self.log.debug(f"Failed to compute flag {key} locally: {e}")
1463+
return None
1464+
except Exception as e:
1465+
self.log.exception(
1466+
f"[FEATURE FLAGS] Error while computing variant locally: {e}"
1467+
)
1468+
return None
1469+
1470+
# Check if any flags have dependencies
1471+
if self.dependency_graph and len(self.dependency_graph.flags) > 0:
1472+
# If we have dependencies, use the dependency-aware evaluation
1473+
try:
1474+
# Evaluate all flags with dependencies to ensure dependencies are available
1475+
all_results = evaluate_flags_with_dependencies(
1476+
self.feature_flags,
1477+
distinct_id,
1478+
person_properties,
1479+
self.cohorts,
1480+
requested_flag_keys={
1481+
key
1482+
}, # Only evaluate the requested flag and its dependencies
1483+
)
1484+
response = all_results.get(key)
1485+
if response is not None:
1486+
self.log.debug(
1487+
f"Successfully computed flag with dependencies: {key} -> {response}"
1488+
)
1489+
return response
1490+
except Exception as e:
1491+
self.log.warning(f"Failed to evaluate flag with dependencies: {e}")
1492+
# Fall back to individual evaluation
1493+
1494+
# Fall back to individual flag evaluation
14131495
if flag:
14141496
try:
14151497
response = self._compute_flag_locally(

0 commit comments

Comments
 (0)