Skip to content

Commit ae8817b

Browse files
authored
feat(flags): Add support for variant overrides (#77)
1 parent acad2b1 commit ae8817b

File tree

6 files changed

+222
-6
lines changed

6 files changed

+222
-6
lines changed

CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,9 @@
1+
## 2.2.0 - 2022-11-14
2+
3+
Changes:
4+
5+
1. Add support for feature flag variant overrides with local evaluation
6+
17
## 2.1.2 - 2022-09-15
28

39
Changes:

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ Then navigate to `http://127.0.0.1:8080/sentry-debug/` and you should get an eve
3939

4040
### Releasing Versions
4141

42-
Updated are released using GitHub Actions: after bumping `version.py` in `master`, go to [our release workflow's page](https://github.com/PostHog/posthog-python/actions/workflows/release.yaml) and dispatch it manually, using workflow from `master`.
42+
Updated are released using GitHub Actions: after bumping `version.py` in `master` and adding to `CHANGELOG.md`, go to [our release workflow's page](https://github.com/PostHog/posthog-python/actions/workflows/release.yaml) and dispatch it manually, using workflow from `master`.
4343

4444
## Questions?
4545

example.py

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,14 +8,30 @@
88
posthog.debug = True
99

1010
# You can find this key on the /setup page in PostHog
11-
posthog.project_api_key = ""
12-
posthog.personal_api_key = ""
11+
posthog.project_api_key = "phc_gtWmTq3Pgl06u4sZY3TRcoQfp42yfuXHKoe8ZVSR6Kh"
12+
posthog.personal_api_key = "phx_fiRCOQkTA3o2ePSdLrFDAILLHjMu2Mv52vUi8MNruIm"
1313

1414
# Where you host PostHog, with no trailing /.
1515
# You can remove this line if you're using posthog.com
1616
posthog.host = "http://localhost:8000"
1717
posthog.poll_interval = 10
1818

19+
print(
20+
posthog.feature_enabled(
21+
"person-on-events-enabled",
22+
"12345",
23+
groups={"organization": str("0182ee91-8ef7-0000-4cb9-fedc5f00926a")},
24+
group_properties={
25+
"organization": {
26+
"id": "0182ee91-8ef7-0000-4cb9-fedc5f00926a",
27+
"created_at": "2022-06-30 11:44:52.984121+00:00",
28+
}
29+
},
30+
only_evaluate_locally=True,
31+
)
32+
)
33+
34+
exit()
1935

2036
# Capture an event
2137
posthog.capture("distinct_id", "event", {"property1": "value", "property2": "value"}, send_feature_flags=True)

posthog/feature_flags.py

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -46,12 +46,26 @@ def match_feature_flag_properties(flag, distinct_id, properties):
4646
flag_conditions = (flag.get("filters") or {}).get("groups") or []
4747
is_inconclusive = False
4848

49-
for condition in flag_conditions:
49+
# Stable sort conditions with variant overrides to the top. This ensures that if overrides are present, they are
50+
# evaluated first, and the variant override is applied to the first matching condition.
51+
sorted_flag_conditions = sorted(
52+
flag_conditions,
53+
key=lambda condition: 0 if condition.get("variant") else 1,
54+
)
55+
56+
for condition in sorted_flag_conditions:
5057
try:
5158
# if any one condition resolves to True, we can shortcircuit and return
5259
# the matching variant
5360
if is_condition_match(flag, distinct_id, condition, properties):
54-
return get_matching_variant(flag, distinct_id) or True
61+
variant_override = condition.get("variant")
62+
# Some filters can be explicitly set to null, which require accessing variants like so
63+
flag_variants = ((flag.get("filters") or {}).get("multivariate") or {}).get("variants") or []
64+
if variant_override and variant_override in [variant["key"] for variant in flag_variants]:
65+
variant = variant_override
66+
else:
67+
variant = get_matching_variant(flag, distinct_id)
68+
return variant or True
5569
except InconclusiveMatchError:
5670
is_inconclusive = True
5771

posthog/test/test_feature_flags.py

Lines changed: 180 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -969,6 +969,186 @@ def raise_effect():
969969

970970
self.assertFalse(client.feature_enabled("doesnt-exist", "distinct_id"))
971971

972+
@mock.patch("posthog.client.decide")
973+
def test_get_feature_flag_with_variant_overrides(self, patch_decide):
974+
patch_decide.return_value = {"featureFlags": {"beta-feature": "variant-1"}}
975+
client = Client(FAKE_TEST_API_KEY, personal_api_key="test")
976+
client.feature_flags = [
977+
{
978+
"id": 1,
979+
"name": "Beta Feature",
980+
"key": "beta-feature",
981+
"is_simple_flag": False,
982+
"active": True,
983+
"rollout_percentage": 100,
984+
"filters": {
985+
"groups": [
986+
{
987+
"properties": [
988+
{"key": "email", "type": "person", "value": "[email protected]", "operator": "exact"}
989+
],
990+
"rollout_percentage": 100,
991+
"variant": "second-variant",
992+
},
993+
{"rollout_percentage": 50, "variant": "first-variant"},
994+
],
995+
"multivariate": {
996+
"variants": [
997+
{"key": "first-variant", "name": "First Variant", "rollout_percentage": 50},
998+
{"key": "second-variant", "name": "Second Variant", "rollout_percentage": 25},
999+
{"key": "third-variant", "name": "Third Variant", "rollout_percentage": 25},
1000+
]
1001+
},
1002+
},
1003+
}
1004+
]
1005+
self.assertEqual(
1006+
client.get_feature_flag("beta-feature", "test_id", person_properties={"email": "[email protected]"}),
1007+
"second-variant",
1008+
)
1009+
self.assertEqual(client.get_feature_flag("beta-feature", "example_id"), "first-variant")
1010+
# decide not called because this can be evaluated locally
1011+
self.assertEqual(patch_decide.call_count, 0)
1012+
1013+
@mock.patch("posthog.client.decide")
1014+
def test_flag_with_clashing_variant_overrides(self, patch_decide):
1015+
patch_decide.return_value = {"featureFlags": {"beta-feature": "variant-1"}}
1016+
client = Client(FAKE_TEST_API_KEY, personal_api_key="test")
1017+
client.feature_flags = [
1018+
{
1019+
"id": 1,
1020+
"name": "Beta Feature",
1021+
"key": "beta-feature",
1022+
"is_simple_flag": False,
1023+
"active": True,
1024+
"rollout_percentage": 100,
1025+
"filters": {
1026+
"groups": [
1027+
{
1028+
"properties": [
1029+
{"key": "email", "type": "person", "value": "[email protected]", "operator": "exact"}
1030+
],
1031+
"rollout_percentage": 100,
1032+
"variant": "second-variant",
1033+
},
1034+
# since second-variant comes first in the list, it will be the one that gets picked
1035+
{
1036+
"properties": [
1037+
{"key": "email", "type": "person", "value": "[email protected]", "operator": "exact"}
1038+
],
1039+
"rollout_percentage": 100,
1040+
"variant": "first-variant",
1041+
},
1042+
{"rollout_percentage": 50, "variant": "first-variant"},
1043+
],
1044+
"multivariate": {
1045+
"variants": [
1046+
{"key": "first-variant", "name": "First Variant", "rollout_percentage": 50},
1047+
{"key": "second-variant", "name": "Second Variant", "rollout_percentage": 25},
1048+
{"key": "third-variant", "name": "Third Variant", "rollout_percentage": 25},
1049+
]
1050+
},
1051+
},
1052+
}
1053+
]
1054+
self.assertEqual(
1055+
client.get_feature_flag("beta-feature", "test_id", person_properties={"email": "[email protected]"}),
1056+
"second-variant",
1057+
)
1058+
self.assertEqual(
1059+
client.get_feature_flag("beta-feature", "example_id", person_properties={"email": "[email protected]"}),
1060+
"second-variant",
1061+
)
1062+
# decide not called because this can be evaluated locally
1063+
self.assertEqual(patch_decide.call_count, 0)
1064+
1065+
@mock.patch("posthog.client.decide")
1066+
def test_flag_with_invalid_variant_overrides(self, patch_decide):
1067+
patch_decide.return_value = {"featureFlags": {"beta-feature": "variant-1"}}
1068+
client = Client(FAKE_TEST_API_KEY, personal_api_key="test")
1069+
client.feature_flags = [
1070+
{
1071+
"id": 1,
1072+
"name": "Beta Feature",
1073+
"key": "beta-feature",
1074+
"is_simple_flag": False,
1075+
"active": True,
1076+
"rollout_percentage": 100,
1077+
"filters": {
1078+
"groups": [
1079+
{
1080+
"properties": [
1081+
{"key": "email", "type": "person", "value": "[email protected]", "operator": "exact"}
1082+
],
1083+
"rollout_percentage": 100,
1084+
"variant": "second???",
1085+
},
1086+
{"rollout_percentage": 50, "variant": "first??"},
1087+
],
1088+
"multivariate": {
1089+
"variants": [
1090+
{"key": "first-variant", "name": "First Variant", "rollout_percentage": 50},
1091+
{"key": "second-variant", "name": "Second Variant", "rollout_percentage": 25},
1092+
{"key": "third-variant", "name": "Third Variant", "rollout_percentage": 25},
1093+
]
1094+
},
1095+
},
1096+
}
1097+
]
1098+
self.assertEqual(
1099+
client.get_feature_flag("beta-feature", "test_id", person_properties={"email": "[email protected]"}),
1100+
"third-variant",
1101+
)
1102+
self.assertEqual(client.get_feature_flag("beta-feature", "example_id"), "second-variant")
1103+
# decide not called because this can be evaluated locally
1104+
self.assertEqual(patch_decide.call_count, 0)
1105+
1106+
@mock.patch("posthog.client.decide")
1107+
def test_flag_with_multiple_variant_overrides(self, patch_decide):
1108+
patch_decide.return_value = {"featureFlags": {"beta-feature": "variant-1"}}
1109+
client = Client(FAKE_TEST_API_KEY, personal_api_key="test")
1110+
client.feature_flags = [
1111+
{
1112+
"id": 1,
1113+
"name": "Beta Feature",
1114+
"key": "beta-feature",
1115+
"is_simple_flag": False,
1116+
"active": True,
1117+
"rollout_percentage": 100,
1118+
"filters": {
1119+
"groups": [
1120+
{
1121+
"rollout_percentage": 100,
1122+
# The override applies even if the first condition matches all and gives everyone their default group
1123+
},
1124+
{
1125+
"properties": [
1126+
{"key": "email", "type": "person", "value": "[email protected]", "operator": "exact"}
1127+
],
1128+
"rollout_percentage": 100,
1129+
"variant": "second-variant",
1130+
},
1131+
{"rollout_percentage": 50, "variant": "third-variant"},
1132+
],
1133+
"multivariate": {
1134+
"variants": [
1135+
{"key": "first-variant", "name": "First Variant", "rollout_percentage": 50},
1136+
{"key": "second-variant", "name": "Second Variant", "rollout_percentage": 25},
1137+
{"key": "third-variant", "name": "Third Variant", "rollout_percentage": 25},
1138+
]
1139+
},
1140+
},
1141+
}
1142+
]
1143+
self.assertEqual(
1144+
client.get_feature_flag("beta-feature", "test_id", person_properties={"email": "[email protected]"}),
1145+
"second-variant",
1146+
)
1147+
self.assertEqual(client.get_feature_flag("beta-feature", "example_id"), "third-variant")
1148+
self.assertEqual(client.get_feature_flag("beta-feature", "another_id"), "second-variant")
1149+
# decide not called because this can be evaluated locally
1150+
self.assertEqual(patch_decide.call_count, 0)
1151+
9721152

9731153
class TestMatchProperties(unittest.TestCase):
9741154
def property(self, key, value, operator=None):

posthog/version.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
VERSION = "2.1.2"
1+
VERSION = "2.2.0"
22

33
if __name__ == "__main__":
44
print(VERSION, end="")

0 commit comments

Comments
 (0)