Skip to content

Commit 5e01b81

Browse files
committed
feat(flags): 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 5e01b81

File tree

11 files changed

+2581
-56
lines changed

11 files changed

+2581
-56
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: 81 additions & 14 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,
@@ -82,7 +87,10 @@ def get_identity_state(passed) -> tuple[str, bool]:
8287
return (str(uuid4()), True)
8388

8489

85-
def add_context_tags(properties):
90+
def add_context_tags(properties: Optional[Dict[str, Any]]) -> Dict[str, Any]:
91+
if properties is None:
92+
properties = {}
93+
8694
current_context = _get_current_context()
8795
if current_context:
8896
context_tags = current_context.collect_tags()
@@ -179,6 +187,8 @@ def __init__(
179187
self.timeout = timeout
180188
self._feature_flags = None # private variable to store flags
181189
self.feature_flags_by_key = None
190+
self.dependency_graph = None # for flag dependencies
191+
self.id_to_key_mapping = None # maps flag ID to flag key
182192
self.group_type_mapping = None
183193
self.cohorts = None
184194
self.poll_interval = poll_interval
@@ -304,6 +314,20 @@ def feature_flags(self, flags):
304314
"feature_flags_by_key should be initialized when feature_flags is set"
305315
)
306316

317+
# Build dependency graph for flag dependencies
318+
try:
319+
self.dependency_graph, self.id_to_key_mapping = build_dependency_graph(
320+
self._feature_flags
321+
)
322+
self.log.debug(
323+
f"Built dependency graph with {len(self.dependency_graph.flags)} flags"
324+
)
325+
self.log.debug(f"ID to key mapping: {self.id_to_key_mapping}")
326+
except Exception as e:
327+
self.log.warning(f"Failed to build dependency graph: {e}")
328+
self.dependency_graph = None
329+
self.id_to_key_mapping = None
330+
307331
def get_feature_variants(
308332
self,
309333
distinct_id,
@@ -511,7 +535,7 @@ def capture(
511535
if personless and "$process_person_profile" not in properties:
512536
properties["$process_person_profile"] = False
513537

514-
msg = {
538+
msg: Dict[str, Any] = {
515539
"properties": properties,
516540
"timestamp": timestamp,
517541
"distinct_id": distinct_id,
@@ -970,13 +994,14 @@ def join(self):
970994
posthog.join()
971995
```
972996
"""
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
997+
if self.consumers:
998+
for consumer in self.consumers:
999+
consumer.pause()
1000+
try:
1001+
consumer.join()
1002+
except RuntimeError:
1003+
# consumer thread has not started
1004+
pass
9801005

9811006
if self.poller:
9821007
self.poller.stop()
@@ -1135,11 +1160,21 @@ def _compute_flag_locally(
11351160

11361161
focused_group_properties = group_properties[group_name]
11371162
return match_feature_flag_properties(
1138-
feature_flag, groups[group_name], focused_group_properties
1163+
feature_flag,
1164+
groups[group_name],
1165+
focused_group_properties,
1166+
self.cohorts,
1167+
self.dependency_graph,
1168+
self.id_to_key_mapping,
11391169
)
11401170
else:
11411171
return match_feature_flag_properties(
1142-
feature_flag, distinct_id, person_properties, self.cohorts
1172+
feature_flag,
1173+
distinct_id,
1174+
person_properties,
1175+
self.cohorts,
1176+
self.dependency_graph,
1177+
self.id_to_key_mapping,
11431178
)
11441179

11451180
def feature_enabled(
@@ -1408,8 +1443,40 @@ def _locally_evaluate_flag(
14081443
assert self.feature_flags_by_key is not None, (
14091444
"feature_flags_by_key should be initialized when feature_flags is set"
14101445
)
1411-
# Local evaluation
1446+
14121447
flag = self.feature_flags_by_key.get(key)
1448+
if flag and flag.get("ensure_experience_continuity", False):
1449+
# Experience continuity flags cannot be evaluated locally
1450+
self.log.debug(
1451+
f"Flag {key} has experience continuity enabled, skipping local evaluation"
1452+
)
1453+
return None
1454+
1455+
# Check if any flags have dependencies
1456+
if self.dependency_graph and len(self.dependency_graph.flags) > 0:
1457+
# If we have dependencies, use the dependency-aware evaluation
1458+
try:
1459+
# Evaluate all flags with dependencies to ensure dependencies are available
1460+
all_results = evaluate_flags_with_dependencies(
1461+
self.feature_flags,
1462+
distinct_id,
1463+
person_properties,
1464+
self.cohorts,
1465+
requested_flag_keys={
1466+
key
1467+
}, # Only evaluate the requested flag and its dependencies
1468+
)
1469+
response = all_results.get(key)
1470+
if response is not None:
1471+
self.log.debug(
1472+
f"Successfully computed flag with dependencies: {key} -> {response}"
1473+
)
1474+
return response
1475+
except Exception as e:
1476+
self.log.warning(f"Failed to evaluate flag with dependencies: {e}")
1477+
# Fall back to individual evaluation
1478+
1479+
# Fall back to individual flag evaluation
14131480
if flag:
14141481
try:
14151482
response = self._compute_flag_locally(
@@ -1588,7 +1655,7 @@ def _compute_payload_locally(
15881655
if self.feature_flags_by_key is None:
15891656
return payload
15901657

1591-
flag_definition = self.feature_flags_by_key.get(key)
1658+
flag_definition = self.feature_flags_by_key.get(key) # type: ignore[unreachable]
15921659
if flag_definition:
15931660
flag_filters = flag_definition.get("filters") or {}
15941661
flag_payloads = flag_filters.get("payloads") or {}

0 commit comments

Comments
 (0)