Skip to content

Commit c1f668e

Browse files
feat: Add new FeatureFlagResult class and tests (#227)
Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
1 parent a1b81ee commit c1f668e

File tree

8 files changed

+630
-72
lines changed

8 files changed

+630
-72
lines changed

CHANGELOG.md

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,34 @@
1+
## 4.0.0 - 2025-04-24
2+
3+
1. Added new method `get_feature_flag_result` which returns a `FeatureFlagResult` object. This object breaks down the result of a feature flag into its enabled state, variant, and payload. The benefit of this method is it allows you to retrieve the result of a feature flag and its payload in a single API call. You can call `get_value` on the result to get the value of the feature flag, which is the same value returned by `get_feature_flag` (aka the string `variant` if the flag is a multivariate flag or the `boolean` value if the flag is a boolean flag).
4+
5+
Example:
6+
7+
```python
8+
result = posthog.get_feature_flag_result("my-flag", "distinct_id")
9+
print(result.enabled) # True or False
10+
print(result.variant) # 'the-variant-value' or None
11+
print(result.payload) # {'foo': 'bar'}
12+
print(result.get_value()) # 'the-variant-value' or True or False
13+
print(result.reason) # 'matched condition set 2' (Not available for local evaluation)
14+
```
15+
16+
Breaking change:
17+
18+
1. `get_feature_flag_payload` now deserializes payloads from JSON strings to `Any`. Previously, it returned the payload as a JSON encoded string.
19+
20+
Before:
21+
22+
```python
23+
payload = get_feature_flag_payload('key', 'distinct_id') # "{\"some\": \"payload\"}"
24+
```
25+
26+
After:
27+
28+
```python
29+
payload = get_feature_flag_payload('key', 'distinct_id') # {"some": "payload"}
30+
```
31+
132
## 3.25.0 – 2025-04-15
233

334
1. Roll out new `/flags` endpoint to 100% of `/decide` traffic, excluding the top 10 customers.

mypy.ini

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ check_untyped_defs = True
99
warn_unreachable = True
1010
strict_equality = True
1111
ignore_missing_imports = True
12+
exclude = env/.*|venv/.*
1213

1314
[mypy-django.*]
1415
ignore_missing_imports = True

posthog/client.py

Lines changed: 94 additions & 63 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@
3131
)
3232
from posthog.types import (
3333
FeatureFlag,
34+
FeatureFlagResult,
3435
FlagMetadata,
3536
FlagsAndPayloads,
3637
FlagsResponse,
@@ -939,25 +940,19 @@ def feature_enabled(
939940
return None
940941
return bool(response)
941942

942-
def get_feature_flag(
943+
def _get_feature_flag_result(
943944
self,
944945
key,
945946
distinct_id,
946947
*,
948+
override_match_value: Optional[FlagValue] = None,
947949
groups={},
948950
person_properties={},
949951
group_properties={},
950952
only_evaluate_locally=False,
951953
send_feature_flag_events=True,
952954
disable_geoip=None,
953-
) -> Optional[FlagValue]:
954-
"""
955-
Get a feature flag value for a key by evaluating locally or remotely
956-
depending on whether local evaluation is enabled and the flag can be
957-
locally evaluated.
958-
959-
This also captures the $feature_flag_called event unless send_feature_flag_events is False.
960-
"""
955+
) -> Optional[FeatureFlagResult]:
961956
require("key", key, string_types)
962957
require("distinct_id", distinct_id, ID_TYPES)
963958
require("groups", groups, dict)
@@ -969,36 +964,101 @@ def get_feature_flag(
969964
distinct_id, groups, person_properties, group_properties
970965
)
971966

972-
response = self._locally_evaluate_flag(key, distinct_id, groups, person_properties, group_properties)
973-
967+
flag_result = None
974968
flag_details = None
975969
request_id = None
976970

977-
flag_was_locally_evaluated = response is not None
978-
if not flag_was_locally_evaluated and not only_evaluate_locally:
971+
flag_value = self._locally_evaluate_flag(key, distinct_id, groups, person_properties, group_properties)
972+
flag_was_locally_evaluated = flag_value is not None
973+
974+
if flag_was_locally_evaluated:
975+
lookup_match_value = override_match_value or flag_value
976+
payload = self._compute_payload_locally(key, lookup_match_value) if lookup_match_value else None
977+
flag_result = FeatureFlagResult.from_value_and_payload(key, lookup_match_value, payload)
978+
elif not only_evaluate_locally:
979979
try:
980980
flag_details, request_id = self._get_feature_flag_details_from_decide(
981981
key, distinct_id, groups, person_properties, group_properties, disable_geoip
982982
)
983-
response = flag_details.get_value() if flag_details else False
984-
self.log.debug(f"Successfully computed flag remotely: #{key} -> #{response}")
983+
flag_result = FeatureFlagResult.from_flag_details(flag_details, override_match_value)
984+
self.log.debug(f"Successfully computed flag remotely: #{key} -> #{flag_result}")
985985
except Exception as e:
986986
self.log.exception(f"[FEATURE FLAGS] Unable to get flag remotely: {e}")
987987

988988
if send_feature_flag_events:
989989
self._capture_feature_flag_called(
990990
distinct_id,
991991
key,
992-
response or False,
993-
None,
992+
flag_result.get_value() if flag_result else None,
993+
flag_result.payload if flag_result else None,
994994
flag_was_locally_evaluated,
995995
groups,
996996
disable_geoip,
997997
request_id,
998998
flag_details,
999999
)
10001000

1001-
return response
1001+
return flag_result
1002+
1003+
def get_feature_flag_result(
1004+
self,
1005+
key,
1006+
distinct_id,
1007+
*,
1008+
groups={},
1009+
person_properties={},
1010+
group_properties={},
1011+
only_evaluate_locally=False,
1012+
send_feature_flag_events=True,
1013+
disable_geoip=None,
1014+
) -> Optional[FeatureFlagResult]:
1015+
"""
1016+
Get a FeatureFlagResult object which contains the flag result and payload for a key by evaluating locally or remotely
1017+
depending on whether local evaluation is enabled and the flag can be locally evaluated.
1018+
1019+
This also captures the $feature_flag_called event unless send_feature_flag_events is False.
1020+
"""
1021+
return self._get_feature_flag_result(
1022+
key,
1023+
distinct_id,
1024+
groups=groups,
1025+
person_properties=person_properties,
1026+
group_properties=group_properties,
1027+
only_evaluate_locally=only_evaluate_locally,
1028+
send_feature_flag_events=send_feature_flag_events,
1029+
disable_geoip=disable_geoip,
1030+
)
1031+
1032+
def get_feature_flag(
1033+
self,
1034+
key,
1035+
distinct_id,
1036+
*,
1037+
groups={},
1038+
person_properties={},
1039+
group_properties={},
1040+
only_evaluate_locally=False,
1041+
send_feature_flag_events=True,
1042+
disable_geoip=None,
1043+
) -> Optional[FlagValue]:
1044+
"""
1045+
Get a feature flag value for a key by evaluating locally or remotely
1046+
depending on whether local evaluation is enabled and the flag can be
1047+
locally evaluated.
1048+
1049+
This also captures the $feature_flag_called event unless send_feature_flag_events is False.
1050+
"""
1051+
feature_flag_result = self.get_feature_flag_result(
1052+
key,
1053+
distinct_id,
1054+
groups=groups,
1055+
person_properties=person_properties,
1056+
group_properties=group_properties,
1057+
only_evaluate_locally=only_evaluate_locally,
1058+
send_feature_flag_events=send_feature_flag_events,
1059+
disable_geoip=disable_geoip,
1060+
)
1061+
return feature_flag_result.get_value() if feature_flag_result else None
10021062

10031063
def _locally_evaluate_flag(
10041064
self,
@@ -1039,56 +1099,26 @@ def get_feature_flag_payload(
10391099
key,
10401100
distinct_id,
10411101
*,
1042-
match_value=None,
1102+
match_value: Optional[FlagValue] = None,
10431103
groups={},
10441104
person_properties={},
10451105
group_properties={},
10461106
only_evaluate_locally=False,
10471107
send_feature_flag_events=True,
10481108
disable_geoip=None,
10491109
):
1050-
if self.disabled:
1051-
return None
1052-
1053-
if match_value is None:
1054-
person_properties, group_properties = self._add_local_person_and_group_properties(
1055-
distinct_id, groups, person_properties, group_properties
1056-
)
1057-
match_value = self._locally_evaluate_flag(key, distinct_id, groups, person_properties, group_properties)
1058-
1059-
response = None
1060-
payload = None
1061-
flag_details = None
1062-
request_id = None
1063-
1064-
if match_value is not None:
1065-
payload = self._compute_payload_locally(key, match_value)
1066-
1067-
flag_was_locally_evaluated = payload is not None
1068-
if not flag_was_locally_evaluated and not only_evaluate_locally:
1069-
try:
1070-
flag_details, request_id = self._get_feature_flag_details_from_decide(
1071-
key, distinct_id, groups, person_properties, group_properties, disable_geoip
1072-
)
1073-
payload = flag_details.metadata.payload if flag_details else None
1074-
response = flag_details.get_value() if flag_details else False
1075-
except Exception as e:
1076-
self.log.exception(f"[FEATURE FLAGS] Unable to get feature flags and payloads: {e}")
1077-
1078-
if send_feature_flag_events:
1079-
self._capture_feature_flag_called(
1080-
distinct_id,
1081-
key,
1082-
response or False,
1083-
payload,
1084-
flag_was_locally_evaluated,
1085-
groups,
1086-
disable_geoip,
1087-
request_id,
1088-
flag_details,
1089-
)
1090-
1091-
return payload
1110+
feature_flag_result = self._get_feature_flag_result(
1111+
key,
1112+
distinct_id,
1113+
override_match_value=match_value,
1114+
groups=groups,
1115+
person_properties=person_properties,
1116+
group_properties=group_properties,
1117+
only_evaluate_locally=only_evaluate_locally,
1118+
send_feature_flag_events=send_feature_flag_events,
1119+
disable_geoip=disable_geoip,
1120+
)
1121+
return feature_flag_result.payload if feature_flag_result else None
10921122

10931123
def _get_feature_flag_details_from_decide(
10941124
self,
@@ -1112,15 +1142,15 @@ def _capture_feature_flag_called(
11121142
self,
11131143
distinct_id: str,
11141144
key: str,
1115-
response: FlagValue,
1145+
response: Optional[FlagValue],
11161146
payload: Optional[str],
11171147
flag_was_locally_evaluated: bool,
11181148
groups: dict[str, str],
11191149
disable_geoip: Optional[bool],
11201150
request_id: Optional[str],
11211151
flag_details: Optional[FeatureFlag],
11221152
):
1123-
feature_flag_reported_key = f"{key}_{str(response)}"
1153+
feature_flag_reported_key = f"{key}_{'::null::' if response is None else str(response)}"
11241154

11251155
if feature_flag_reported_key not in self.distinct_ids_feature_flags_reported[distinct_id]:
11261156
properties: dict[str, Any] = {
@@ -1131,6 +1161,7 @@ def _capture_feature_flag_called(
11311161
}
11321162

11331163
if payload:
1164+
# if payload is not a string, json serialize it to a string
11341165
properties["$feature_flag_payload"] = payload
11351166

11361167
if request_id:

0 commit comments

Comments
 (0)