Skip to content

Commit fb57de2

Browse files
dmarticusdaibhin
andauthored
fix(flags): correctly emit feature flag events with the FF response on get_feature_flag_payload calls (#143)
* this is the fix, needs tests * fix test * tests * yeah * please work * ran the formatter * code review feedback * how'd this get here * bump version add changelog * Update CHANGELOG.md Co-authored-by: David Newell <[email protected]> --------- Co-authored-by: David Newell <[email protected]>
1 parent db565bc commit fb57de2

File tree

3 files changed

+137
-10
lines changed

3 files changed

+137
-10
lines changed

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,7 @@
1+
## 3.7.4 - 2024-11-25
2+
3+
1. Fix bug where this SDK incorrectly sent feature flag events with null values when calling `get_feature_flag_payload`.
4+
15
## 3.7.3 - 2024-11-25
26

37
1. Use personless mode when sending an exception without a provided `distinct_id`.

posthog/client.py

Lines changed: 46 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -173,6 +173,15 @@ def get_feature_payloads(
173173
resp_data = self.get_decide(distinct_id, groups, person_properties, group_properties, disable_geoip)
174174
return resp_data["featureFlagPayloads"]
175175

176+
def get_feature_flags_and_payloads(
177+
self, distinct_id, groups=None, person_properties=None, group_properties=None, disable_geoip=None
178+
):
179+
resp_data = self.get_decide(distinct_id, groups, person_properties, group_properties, disable_geoip)
180+
return {
181+
"featureFlags": resp_data["featureFlags"],
182+
"featureFlagPayloads": resp_data["featureFlagPayloads"],
183+
}
184+
176185
def get_decide(self, distinct_id, groups=None, person_properties=None, group_properties=None, disable_geoip=None):
177186
require("distinct_id", distinct_id, ID_TYPES)
178187

@@ -746,23 +755,52 @@ def get_feature_flag_payload(
746755
groups=groups,
747756
person_properties=person_properties,
748757
group_properties=group_properties,
749-
send_feature_flag_events=send_feature_flag_events,
750-
only_evaluate_locally=True,
758+
send_feature_flag_events=False,
759+
# Disable automatic sending of feature flag events because we're manually handling event dispatch.
760+
# This prevents sending events with empty data when `get_feature_flag` cannot be evaluated locally.
761+
only_evaluate_locally=True, # Enable local evaluation of feature flags to avoid making multiple requests to `/decide`.
751762
disable_geoip=disable_geoip,
752763
)
753764

754765
response = None
766+
payload = None
755767

756768
if match_value is not None:
757-
response = self._compute_payload_locally(key, match_value)
769+
payload = self._compute_payload_locally(key, match_value)
770+
771+
flag_was_locally_evaluated = payload is not None
772+
if not flag_was_locally_evaluated and not only_evaluate_locally:
773+
try:
774+
responses_and_payloads = self.get_feature_flags_and_payloads(
775+
distinct_id, groups, person_properties, group_properties, disable_geoip
776+
)
777+
response = responses_and_payloads["featureFlags"].get(key, None)
778+
payload = responses_and_payloads["featureFlagPayloads"].get(str(key).lower(), None)
779+
except Exception as e:
780+
self.log.exception(f"[FEATURE FLAGS] Unable to get feature flags and payloads: {e}")
758781

759-
if response is None and not only_evaluate_locally:
760-
decide_payloads = self.get_feature_payloads(
761-
distinct_id, groups, person_properties, group_properties, disable_geoip
782+
feature_flag_reported_key = f"{key}_{str(response)}"
783+
784+
if (
785+
feature_flag_reported_key not in self.distinct_ids_feature_flags_reported[distinct_id]
786+
and send_feature_flag_events # noqa: W503
787+
):
788+
self.capture(
789+
distinct_id,
790+
"$feature_flag_called",
791+
{
792+
"$feature_flag": key,
793+
"$feature_flag_response": response,
794+
"$feature_flag_payload": payload,
795+
"locally_evaluated": flag_was_locally_evaluated,
796+
f"$feature/{key}": response,
797+
},
798+
groups=groups,
799+
disable_geoip=disable_geoip,
762800
)
763-
response = decide_payloads.get(str(key).lower(), None)
801+
self.distinct_ids_feature_flags_reported[distinct_id].add(feature_flag_reported_key)
764802

765-
return response
803+
return payload
766804

767805
def _compute_payload_locally(self, key, match_value):
768806
payload = None

posthog/test/test_feature_flags.py

Lines changed: 87 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1632,9 +1632,10 @@ def test_boolean_feature_flag_payloads_local(self, patch_decide):
16321632
)
16331633
self.assertEqual(patch_decide.call_count, 0)
16341634

1635+
@mock.patch.object(Client, "capture")
16351636
@mock.patch("posthog.client.decide")
1636-
def test_boolean_feature_flag_payload_decide(self, patch_decide):
1637-
patch_decide.return_value = {"featureFlagPayloads": {"person-flag": 300}}
1637+
def test_boolean_feature_flag_payload_decide(self, patch_decide, patch_capture):
1638+
patch_decide.return_value = {"featureFlags": {"person-flag": True}, "featureFlagPayloads": {"person-flag": 300}}
16381639
self.assertEqual(
16391640
self.client.get_feature_flag_payload(
16401641
"person-flag", "some-distinct-id", person_properties={"region": "USA"}
@@ -1649,6 +1650,8 @@ def test_boolean_feature_flag_payload_decide(self, patch_decide):
16491650
300,
16501651
)
16511652
self.assertEqual(patch_decide.call_count, 2)
1653+
self.assertEqual(patch_capture.call_count, 1)
1654+
patch_capture.reset_mock()
16521655

16531656
@mock.patch("posthog.client.decide")
16541657
def test_multivariate_feature_flag_payloads(self, patch_decide):
@@ -2334,6 +2337,88 @@ def test_capture_is_called(self, patch_decide, patch_capture):
23342337
disable_geoip=None,
23352338
)
23362339

2340+
@mock.patch.object(Client, "capture")
2341+
@mock.patch("posthog.client.decide")
2342+
def test_capture_is_called_in_get_feature_flag_payload(self, patch_decide, patch_capture):
2343+
patch_decide.return_value = {
2344+
"featureFlags": {"person-flag": True},
2345+
"featureFlagPayloads": {"person-flag": 300},
2346+
}
2347+
client = Client(api_key=FAKE_TEST_API_KEY, personal_api_key=FAKE_TEST_API_KEY)
2348+
2349+
client.feature_flags = [
2350+
{
2351+
"id": 1,
2352+
"name": "Beta Feature",
2353+
"key": "person-flag",
2354+
"is_simple_flag": False,
2355+
"active": True,
2356+
"filters": {
2357+
"groups": [
2358+
{
2359+
"properties": [{"key": "region", "value": "USA"}],
2360+
"rollout_percentage": 100,
2361+
}
2362+
],
2363+
},
2364+
}
2365+
]
2366+
2367+
# Call get_feature_flag_payload with match_value=None to trigger get_feature_flag
2368+
client.get_feature_flag_payload(
2369+
key="person-flag", distinct_id="some-distinct-id", person_properties={"region": "USA", "name": "Aloha"}
2370+
)
2371+
2372+
# Assert that capture was called once, with the correct parameters
2373+
self.assertEqual(patch_capture.call_count, 1)
2374+
patch_capture.assert_called_with(
2375+
"some-distinct-id",
2376+
"$feature_flag_called",
2377+
{
2378+
"$feature_flag": "person-flag",
2379+
"$feature_flag_response": True,
2380+
"$feature_flag_payload": 300,
2381+
"locally_evaluated": False,
2382+
"$feature/person-flag": True,
2383+
},
2384+
groups={},
2385+
disable_geoip=None,
2386+
)
2387+
2388+
# Reset mocks for further tests
2389+
patch_capture.reset_mock()
2390+
patch_decide.reset_mock()
2391+
2392+
# Call get_feature_flag_payload again for the same user; capture should not be called again because we've already reported an event for this distinct_id + flag
2393+
client.get_feature_flag_payload(
2394+
key="person-flag", distinct_id="some-distinct-id", person_properties={"region": "USA", "name": "Aloha"}
2395+
)
2396+
2397+
self.assertEqual(patch_capture.call_count, 0)
2398+
patch_capture.reset_mock()
2399+
2400+
# Call get_feature_flag_payload for a different user; capture should be called
2401+
client.get_feature_flag_payload(
2402+
key="person-flag", distinct_id="some-distinct-id2", person_properties={"region": "USA", "name": "Aloha"}
2403+
)
2404+
2405+
self.assertEqual(patch_capture.call_count, 1)
2406+
patch_capture.assert_called_with(
2407+
"some-distinct-id2",
2408+
"$feature_flag_called",
2409+
{
2410+
"$feature_flag": "person-flag",
2411+
"$feature_flag_response": True,
2412+
"$feature_flag_payload": 300,
2413+
"locally_evaluated": False,
2414+
"$feature/person-flag": True,
2415+
},
2416+
groups={},
2417+
disable_geoip=None,
2418+
)
2419+
2420+
patch_capture.reset_mock()
2421+
23372422
@mock.patch.object(Client, "capture")
23382423
@mock.patch("posthog.client.decide")
23392424
def test_disable_geoip_get_flag_capture_call(self, patch_decide, patch_capture):

0 commit comments

Comments
 (0)