Skip to content

Commit 132851c

Browse files
committed
Include extra details in $feature_flag_called events
1 parent 61d4114 commit 132851c

File tree

4 files changed

+370
-63
lines changed

4 files changed

+370
-63
lines changed

posthog/client.py

Lines changed: 131 additions & 58 deletions
Original file line numberDiff line numberDiff line change
@@ -18,10 +18,29 @@
1818
from posthog.exception_utils import exc_info_from_error, exceptions_from_error_tuple, handle_in_app
1919
from posthog.feature_flags import InconclusiveMatchError, match_feature_flag_properties
2020
from posthog.poller import Poller
21-
from posthog.request import DEFAULT_HOST, APIError, batch_post, decide, determine_server_host, get, remote_config, DecideResponse
21+
from posthog.request import (
22+
DEFAULT_HOST,
23+
APIError,
24+
batch_post,
25+
decide,
26+
determine_server_host,
27+
get,
28+
remote_config,
29+
DecideResponse,
30+
)
2231
from posthog.utils import SizeLimitedDict, clean, guess_timezone, remove_trailing_slash
2332
from posthog.version import VERSION
24-
from posthog.types import FlagsAndPayloads, FlagValue, to_values, to_payloads, to_flags_and_payloads, normalize_decide_response
33+
from posthog.types import (
34+
FlagsAndPayloads,
35+
FeatureFlag,
36+
FlagValue,
37+
FlagMetadata,
38+
to_values,
39+
to_payloads,
40+
to_flags_and_payloads,
41+
normalize_decide_response,
42+
)
43+
2544
try:
2645
import queue
2746
except ImportError:
@@ -217,10 +236,10 @@ def feature_flags(self, flags):
217236
Set the local evaluation feature flags.
218237
"""
219238
self._feature_flags = flags or []
220-
self.feature_flags_by_key = {
221-
flag["key"]: flag for flag in self._feature_flags if flag.get("key") is not None
222-
}
223-
assert self.feature_flags_by_key is not None, "feature_flags_by_key should be initialized when feature_flags is set"
239+
self.feature_flags_by_key = {flag["key"]: flag for flag in self._feature_flags if flag.get("key") is not None}
240+
assert (
241+
self.feature_flags_by_key is not None
242+
), "feature_flags_by_key should be initialized when feature_flags is set"
224243

225244
def identify(self, distinct_id=None, properties=None, context=None, timestamp=None, uuid=None, disable_geoip=None):
226245
if context is not None:
@@ -271,7 +290,9 @@ def get_feature_flags_and_payloads(
271290
resp = self.get_decide(distinct_id, groups, person_properties, group_properties, disable_geoip)
272291
return to_flags_and_payloads(resp)
273292

274-
def get_decide(self, distinct_id, groups=None, person_properties=None, group_properties=None, disable_geoip=None) -> DecideResponse:
293+
def get_decide(
294+
self, distinct_id, groups=None, person_properties=None, group_properties=None, disable_geoip=None
295+
) -> DecideResponse:
275296
require("distinct_id", distinct_id, ID_TYPES)
276297

277298
if disable_geoip is None:
@@ -802,7 +823,7 @@ def get_feature_flag(
802823
) -> FlagValue | None:
803824
"""
804825
Get a feature flag value for a key by evaluating locally or remotely
805-
depending on whether local evaluation is enabled and the flag can be
826+
depending on whether local evaluation is enabled and the flag can be
806827
locally evaluated.
807828
808829
This also captures the $feature_flag_called event unless send_feature_flag_events is False.
@@ -820,50 +841,51 @@ def get_feature_flag(
820841

821842
response = self._locally_evaluate_flag(key, distinct_id, groups, person_properties, group_properties)
822843

844+
flag_details = None
845+
request_id = None
846+
823847
flag_was_locally_evaluated = response is not None
824848
if not flag_was_locally_evaluated and not only_evaluate_locally:
825849
try:
826-
feature_flags = self.get_feature_variants(
827-
distinct_id,
828-
groups=groups,
829-
person_properties=person_properties,
830-
group_properties=group_properties,
831-
disable_geoip=disable_geoip,
850+
flag_details, request_id = self._get_feature_flag_details_from_decide(
851+
key, distinct_id, groups, person_properties, group_properties, disable_geoip
832852
)
833-
response = feature_flags.get(key)
834-
if response is None:
835-
response = False
853+
response = flag_details.get_value() if flag_details else False
836854
self.log.debug(f"Successfully computed flag remotely: #{key} -> #{response}")
837855
except Exception as e:
838856
self.log.exception(f"[FEATURE FLAGS] Unable to get flag remotely: {e}")
839857

840-
feature_flag_reported_key = f"{key}_{str(response)}"
841-
if (
842-
feature_flag_reported_key not in self.distinct_ids_feature_flags_reported[distinct_id]
843-
and send_feature_flag_events # noqa: W503
844-
):
845-
self.capture(
858+
if send_feature_flag_events:
859+
self._capture_feature_flag_called(
846860
distinct_id,
847-
"$feature_flag_called",
848-
{
849-
"$feature_flag": key,
850-
"$feature_flag_response": response,
851-
"locally_evaluated": flag_was_locally_evaluated,
852-
f"$feature/{key}": response,
853-
},
854-
groups=groups,
855-
disable_geoip=disable_geoip,
861+
key,
862+
response,
863+
None,
864+
flag_was_locally_evaluated,
865+
groups,
866+
disable_geoip,
867+
request_id,
868+
flag_details,
856869
)
857-
self.distinct_ids_feature_flags_reported[distinct_id].add(feature_flag_reported_key)
870+
858871
return response
859872

860-
def _locally_evaluate_flag(self, key: str, distinct_id: str, groups: dict[str, str], person_properties: dict[str, str], group_properties: dict[str, str]) -> FlagValue | None:
873+
def _locally_evaluate_flag(
874+
self,
875+
key: str,
876+
distinct_id: str,
877+
groups: dict[str, str],
878+
person_properties: dict[str, str],
879+
group_properties: dict[str, str],
880+
) -> FlagValue | None:
861881
if self.feature_flags is None and self.personal_api_key:
862882
self.load_feature_flags()
863883
response = None
864884

865885
if self.feature_flags:
866-
assert self.feature_flags_by_key is not None, "feature_flags_by_key should be initialized when feature_flags is set"
886+
assert (
887+
self.feature_flags_by_key is not None
888+
), "feature_flags_by_key should be initialized when feature_flags is set"
867889
# Local evaluation
868890
flag = self.feature_flags_by_key.get(key)
869891
if flag:
@@ -906,49 +928,101 @@ def get_feature_flag_payload(
906928

907929
response = None
908930
payload = None
931+
flag_details = None
932+
request_id = None
909933

910934
if match_value is not None:
911935
payload = self._compute_payload_locally(key, match_value)
912936

913937
flag_was_locally_evaluated = payload is not None
914938
if not flag_was_locally_evaluated and not only_evaluate_locally:
915939
try:
916-
responses_and_payloads = self.get_feature_flags_and_payloads(
917-
distinct_id, groups, person_properties, group_properties, disable_geoip
940+
flag_details, request_id = self._get_feature_flag_details_from_decide(
941+
key, distinct_id, groups, person_properties, group_properties, disable_geoip
918942
)
919-
featureFlags = responses_and_payloads["featureFlags"]
920-
if featureFlags is not None:
921-
response = featureFlags.get(key, None)
922-
923-
featureFlagPayloads = responses_and_payloads["featureFlagPayloads"]
924-
if featureFlagPayloads is not None:
925-
payload = featureFlagPayloads.get(str(key), None)
943+
payload = flag_details.metadata.payload if flag_details else None
944+
response = flag_details.get_value() if flag_details else False
926945
except Exception as e:
927946
self.log.exception(f"[FEATURE FLAGS] Unable to get feature flags and payloads: {e}")
928947

948+
if send_feature_flag_events:
949+
self._capture_feature_flag_called(
950+
distinct_id,
951+
key,
952+
response,
953+
payload,
954+
flag_was_locally_evaluated,
955+
groups,
956+
disable_geoip,
957+
request_id,
958+
flag_details,
959+
)
960+
961+
return payload
962+
963+
def _get_feature_flag_details_from_decide(
964+
self,
965+
key: str,
966+
distinct_id: str,
967+
groups: dict[str, str],
968+
person_properties: dict[str, str],
969+
group_properties: dict[str, str],
970+
disable_geoip: bool | None,
971+
) -> tuple[FeatureFlag | None, str]:
972+
"""
973+
Calls /decide and returns the flag details and request id
974+
"""
975+
resp_data = self.get_decide(distinct_id, groups, person_properties, group_properties, disable_geoip)
976+
request_id = resp_data.get("requestId")
977+
flags = resp_data.get("flags")
978+
flag_details = flags.get(key)
979+
return flag_details, request_id
980+
981+
def _capture_feature_flag_called(
982+
self,
983+
distinct_id: str,
984+
key: str,
985+
response: FlagValue,
986+
payload: str | None,
987+
flag_was_locally_evaluated: bool,
988+
groups: dict[str, str],
989+
disable_geoip: bool | None,
990+
request_id: str | None,
991+
flag_details: FeatureFlag | None,
992+
):
929993
feature_flag_reported_key = f"{key}_{str(response)}"
930994

931-
if (
932-
feature_flag_reported_key not in self.distinct_ids_feature_flags_reported[distinct_id]
933-
and send_feature_flag_events # noqa: W503
934-
):
995+
if feature_flag_reported_key not in self.distinct_ids_feature_flags_reported[distinct_id]:
996+
properties = {
997+
"$feature_flag": key,
998+
"$feature_flag_response": response,
999+
"locally_evaluated": flag_was_locally_evaluated,
1000+
f"$feature/{key}": response,
1001+
}
1002+
1003+
if payload:
1004+
properties["$feature_flag_payload"] = payload
1005+
1006+
if request_id:
1007+
properties["$feature_flag_request_id"] = request_id
1008+
if isinstance(flag_details, FeatureFlag):
1009+
if flag_details.reason and flag_details.reason.description:
1010+
properties["$feature_flag_reason"] = flag_details.reason.description
1011+
if isinstance(flag_details.metadata, FlagMetadata):
1012+
if flag_details.metadata.version:
1013+
properties["$feature_flag_version"] = flag_details.metadata.version
1014+
if flag_details.metadata.id:
1015+
properties["$feature_flag_id"] = flag_details.metadata.id
1016+
9351017
self.capture(
9361018
distinct_id,
9371019
"$feature_flag_called",
938-
{
939-
"$feature_flag": key,
940-
"$feature_flag_response": response,
941-
"$feature_flag_payload": payload,
942-
"locally_evaluated": flag_was_locally_evaluated,
943-
f"$feature/{key}": response,
944-
},
1020+
properties,
9451021
groups=groups,
9461022
disable_geoip=disable_geoip,
9471023
)
9481024
self.distinct_ids_feature_flags_reported[distinct_id].add(feature_flag_reported_key)
9491025

950-
return payload
951-
9521026
def get_remote_config_payload(self, key: str):
9531027
if self.disabled:
9541028
return None
@@ -1042,7 +1116,6 @@ def get_all_flags_and_payloads(
10421116

10431117
return response
10441118

1045-
10461119
def _get_all_flags_and_payloads_locally(
10471120
self, distinct_id, *, groups={}, person_properties={}, group_properties={}, warn_on_unknown_groups=False
10481121
) -> tuple[FlagsAndPayloads, bool]:

posthog/test/test_feature_flags.py

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2342,6 +2342,93 @@ def test_capture_is_called(self, patch_decide, patch_capture):
23422342
disable_geoip=None,
23432343
)
23442344

2345+
@mock.patch.object(Client, "capture")
2346+
@mock.patch("posthog.client.decide")
2347+
def test_capture_is_called_with_flag_details(self, patch_decide, patch_capture):
2348+
patch_decide.return_value = {
2349+
"flags": {
2350+
"decide-flag": {
2351+
"key": "decide-flag",
2352+
"enabled": True,
2353+
"variant": "decide-variant",
2354+
"reason": {
2355+
"description": "Matched condition set 1",
2356+
},
2357+
"metadata": {
2358+
"id": 23,
2359+
"version": 42,
2360+
},
2361+
}
2362+
},
2363+
"requestId": "18043bf7-9cf6-44cd-b959-9662ee20d371",
2364+
}
2365+
client = Client(FAKE_TEST_API_KEY)
2366+
2367+
self.assertEqual(client.get_feature_flag("decide-flag", "some-distinct-id"), "decide-variant")
2368+
self.assertEqual(patch_capture.call_count, 1)
2369+
patch_capture.assert_called_with(
2370+
"some-distinct-id",
2371+
"$feature_flag_called",
2372+
{
2373+
"$feature_flag": "decide-flag",
2374+
"$feature_flag_response": "decide-variant",
2375+
"locally_evaluated": False,
2376+
"$feature/decide-flag": "decide-variant",
2377+
"$feature_flag_reason": "Matched condition set 1",
2378+
"$feature_flag_id": 23,
2379+
"$feature_flag_version": 42,
2380+
"$feature_flag_request_id": "18043bf7-9cf6-44cd-b959-9662ee20d371",
2381+
},
2382+
groups={},
2383+
disable_geoip=None,
2384+
)
2385+
2386+
@mock.patch.object(Client, "capture")
2387+
@mock.patch("posthog.client.decide")
2388+
def test_capture_is_called_with_flag_details_and_payload(self, patch_decide, patch_capture):
2389+
patch_decide.return_value = {
2390+
"flags": {
2391+
"decide-flag-with-payload": {
2392+
"key": "decide-flag-with-payload",
2393+
"enabled": True,
2394+
"variant": None,
2395+
"reason": {
2396+
"code": "matched_condition",
2397+
"condition_index": 0,
2398+
"description": "Matched condition set 1",
2399+
},
2400+
"metadata": {
2401+
"id": 23,
2402+
"version": 42,
2403+
"payload": "{\"foo\": \"bar\"}",
2404+
},
2405+
}
2406+
},
2407+
"requestId": "18043bf7-9cf6-44cd-b959-9662ee20d371",
2408+
}
2409+
client = Client(FAKE_TEST_API_KEY)
2410+
2411+
self.assertEqual(client.get_feature_flag_payload("decide-flag-with-payload", "some-distinct-id"), "{\"foo\": \"bar\"}")
2412+
self.assertEqual(patch_capture.call_count, 1)
2413+
patch_capture.assert_called_with(
2414+
"some-distinct-id",
2415+
"$feature_flag_called",
2416+
{
2417+
"$feature_flag": "decide-flag-with-payload",
2418+
"$feature_flag_response": True,
2419+
"locally_evaluated": False,
2420+
"$feature/decide-flag-with-payload": True,
2421+
"$feature_flag_reason": "Matched condition set 1",
2422+
"$feature_flag_id": 23,
2423+
"$feature_flag_version": 42,
2424+
"$feature_flag_request_id": "18043bf7-9cf6-44cd-b959-9662ee20d371",
2425+
"$feature_flag_payload": "{\"foo\": \"bar\"}",
2426+
},
2427+
groups={},
2428+
disable_geoip=None,
2429+
)
2430+
2431+
23452432
@mock.patch("posthog.client.decide")
23462433
def test_capture_is_called_but_does_not_add_all_flags(self, patch_decide):
23472434
patch_decide.return_value = {"featureFlags": {"decide-flag": "decide-value"}}

0 commit comments

Comments
 (0)