Skip to content

Commit 7c3d86a

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 7c3d86a

7 files changed

Lines changed: 2293 additions & 55 deletions

File tree

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/integrations/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: 74 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@
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 InconclusiveMatchError, match_feature_flag_properties, build_dependency_graph, evaluate_flags_with_dependencies
2424
from posthog.poller import Poller
2525
from posthog.request import (
2626
DEFAULT_HOST,
@@ -179,6 +179,8 @@ def __init__(
179179
self.timeout = timeout
180180
self._feature_flags = None # private variable to store flags
181181
self.feature_flags_by_key = None
182+
self.dependency_graph = None # for flag dependencies
183+
self.id_to_key_mapping = None # maps flag ID to flag key
182184
self.group_type_mapping = None
183185
self.cohorts = None
184186
self.poll_interval = poll_interval
@@ -303,6 +305,16 @@ def feature_flags(self, flags):
303305
assert self.feature_flags_by_key is not None, (
304306
"feature_flags_by_key should be initialized when feature_flags is set"
305307
)
308+
309+
# Build dependency graph for flag dependencies
310+
try:
311+
self.dependency_graph, self.id_to_key_mapping = build_dependency_graph(self._feature_flags)
312+
self.log.debug(f"Built dependency graph with {len(self.dependency_graph.flags)} flags")
313+
self.log.debug(f"ID to key mapping: {self.id_to_key_mapping}")
314+
except Exception as e:
315+
self.log.warning(f"Failed to build dependency graph: {e}")
316+
self.dependency_graph = None
317+
self.id_to_key_mapping = None
306318

307319
def get_feature_variants(
308320
self,
@@ -970,13 +982,14 @@ def join(self):
970982
posthog.join()
971983
```
972984
"""
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
985+
if self.consumers:
986+
for consumer in self.consumers:
987+
consumer.pause()
988+
try:
989+
consumer.join()
990+
except RuntimeError:
991+
# consumer thread has not started
992+
pass
980993

981994
if self.poller:
982995
self.poller.stop()
@@ -1135,11 +1148,13 @@ def _compute_flag_locally(
11351148

11361149
focused_group_properties = group_properties[group_name]
11371150
return match_feature_flag_properties(
1138-
feature_flag, groups[group_name], focused_group_properties
1151+
feature_flag, groups[group_name], focused_group_properties, self.cohorts,
1152+
self.dependency_graph, self.id_to_key_mapping
11391153
)
11401154
else:
11411155
return match_feature_flag_properties(
1142-
feature_flag, distinct_id, person_properties, self.cohorts
1156+
feature_flag, distinct_id, person_properties, self.cohorts,
1157+
self.dependency_graph, self.id_to_key_mapping
11431158
)
11441159

11451160
def feature_enabled(
@@ -1408,8 +1423,56 @@ def _locally_evaluate_flag(
14081423
assert self.feature_flags_by_key is not None, (
14091424
"feature_flags_by_key should be initialized when feature_flags is set"
14101425
)
1411-
# Local evaluation
1426+
1427+
# Check if the requested flag has experience continuity enabled
14121428
flag = self.feature_flags_by_key.get(key)
1429+
if flag and flag.get("ensure_experience_continuity", False):
1430+
# For experience continuity flags, we must use individual evaluation
1431+
# which will properly raise InconclusiveMatchError and fall back to /decide
1432+
try:
1433+
response = self._compute_flag_locally(
1434+
flag,
1435+
distinct_id,
1436+
groups=groups,
1437+
person_properties=person_properties,
1438+
group_properties=group_properties,
1439+
)
1440+
self.log.debug(
1441+
f"Successfully computed flag locally: {key} -> {response}"
1442+
)
1443+
return response
1444+
except InconclusiveMatchError as e:
1445+
self.log.debug(f"Failed to compute flag {key} locally: {e}")
1446+
return None
1447+
except Exception as e:
1448+
self.log.exception(
1449+
f"[FEATURE FLAGS] Error while computing variant locally: {e}"
1450+
)
1451+
return None
1452+
1453+
# Check if any flags have dependencies
1454+
if self.dependency_graph and len(self.dependency_graph.flags) > 0:
1455+
# If we have dependencies, use the dependency-aware evaluation
1456+
try:
1457+
# Evaluate all flags with dependencies to ensure dependencies are available
1458+
all_results = evaluate_flags_with_dependencies(
1459+
self.feature_flags,
1460+
distinct_id,
1461+
person_properties,
1462+
self.cohorts,
1463+
requested_flag_keys={key} # Only evaluate the requested flag and its dependencies
1464+
)
1465+
response = all_results.get(key)
1466+
if response is not None:
1467+
self.log.debug(
1468+
f"Successfully computed flag with dependencies: {key} -> {response}"
1469+
)
1470+
return response
1471+
except Exception as e:
1472+
self.log.warning(f"Failed to evaluate flag with dependencies: {e}")
1473+
# Fall back to individual evaluation
1474+
1475+
# Fall back to individual flag evaluation
14131476
if flag:
14141477
try:
14151478
response = self._compute_flag_locally(

0 commit comments

Comments
 (0)