Skip to content

Commit 7b6a8d8

Browse files
fix(flags): parse locally-evaluated flag payloads in evaluate_flags() (sdk-specs local-feature-flag-evaluator) (#828)
fix(flags): parse locally-evaluated flag payloads in evaluate_flags() The evaluate_flags() snapshot JSON-decoded payloads coming back from /flags but stored locally-evaluated payloads verbatim, so get_flag_payload() returned a dict for remotely-resolved flags and the raw JSON string for locally-resolved ones. The sdk-specs local-feature-flag-evaluator and get-feature-flag-payload contracts both expect the decoded value. Both branches now go through a shared _parse_flag_payload() helper; strings that aren't valid JSON are still passed through unchanged. Generated-By: PostHog Code Task-Id: 9b7661cd-0281-4019-b9ee-516e13639286 Co-authored-by: posthog[bot] <206114724+posthog[bot]@users.noreply.github.com> Co-authored-by: Manoel Aranda Neto <marandaneto@gmail.com>
1 parent e85e64d commit 7b6a8d8

3 files changed

Lines changed: 100 additions & 10 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
pypi/posthog: patch
3+
---
4+
5+
`evaluate_flags()` now JSON-decodes payloads for locally-evaluated flags, the same way it already did for flags resolved remotely. Previously `get_flag_payload()` returned a parsed value (`{"copy": "new"}`) when the flag came back from `/flags` but the raw JSON string (`'{"copy": "new"}'`) when the poller evaluated it locally, so the payload's type depended on where the flag happened to resolve. The `$feature_flag_payload` property on `$feature_flag_called` events is decoded for locally-evaluated flags too. Payload strings that aren't valid JSON are still passed through unchanged.

‎posthog/client.py‎

Lines changed: 16 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -244,6 +244,20 @@ def _parse_has_experiment(value: Any) -> Optional[bool]:
244244
return value if isinstance(value, bool) else None
245245

246246

247+
def _parse_flag_payload(raw_payload: Any) -> Optional[Any]:
248+
"""Flag payloads are stored as JSON strings, both in the ``/flags`` response
249+
metadata and in the local-evaluation flag definitions, so decode them before
250+
handing them to callers. A string that isn't valid JSON is passed through as-is."""
251+
if isinstance(raw_payload, str):
252+
if not raw_payload:
253+
return None
254+
try:
255+
return json.loads(raw_payload)
256+
except (json.JSONDecodeError, TypeError):
257+
return raw_payload
258+
return raw_payload
259+
260+
247261
def _metadata_has_experiment(metadata: Any) -> Optional[bool]:
248262
"""Server-reported experiment linkage from flag metadata; ``None`` when absent
249263
(e.g. ``LegacyFlagMetadata``, which doesn't carry the field)."""
@@ -3558,7 +3572,7 @@ def evaluate_flags(
35583572
key=key,
35593573
enabled=value is not False,
35603574
variant=value if isinstance(value, str) else None,
3561-
payload=local_payloads.get(key),
3575+
payload=_parse_flag_payload(local_payloads.get(key)),
35623576
id=flag_def.get("id"),
35633577
# The local-evaluation flag definition does not carry a version field;
35643578
# only the remote ``/flags`` response does via ``metadata.version``.
@@ -3597,19 +3611,11 @@ def evaluate_flags(
35973611
for key, detail in response.get("flags", {}).items():
35983612
if key in locally_evaluated_keys:
35993613
continue
3600-
payload: Optional[Any] = None
3601-
raw_payload = (
3614+
payload = _parse_flag_payload(
36023615
detail.metadata.payload
36033616
if isinstance(detail.metadata, FlagMetadata)
36043617
else getattr(detail.metadata, "payload", None)
36053618
)
3606-
if isinstance(raw_payload, str) and raw_payload:
3607-
try:
3608-
payload = json.loads(raw_payload)
3609-
except (json.JSONDecodeError, TypeError):
3610-
payload = raw_payload
3611-
elif raw_payload is not None:
3612-
payload = raw_payload
36133619
records[key] = _EvaluatedFlagRecord(
36143620
key=key,
36153621
enabled=detail.enabled,

‎posthog/test/test_evaluate_flags.py‎

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -271,6 +271,85 @@ def test_empty_distinct_id_returns_empty_snapshot_without_events(
271271
self.assertEqual(len(feature_flag_called), 0)
272272

273273

274+
class TestEvaluateFlagsLocalPayloads(unittest.TestCase):
275+
"""Locally-evaluated payloads must be decoded the same way remote ones are,
276+
so a flag's payload type doesn't depend on where it happened to resolve."""
277+
278+
def setUp(self):
279+
self.client = Client(FAKE_TEST_API_KEY, secret_key="test")
280+
self.client.feature_flags = [
281+
{
282+
"id": 1,
283+
"name": "Checkout",
284+
"key": "checkout",
285+
"active": True,
286+
"filters": {
287+
"groups": [{"properties": [], "rollout_percentage": 100}],
288+
"multivariate": {
289+
"variants": [{"key": "blue", "rollout_percentage": 100}]
290+
},
291+
"payloads": {"blue": '{"copy": "new"}'},
292+
},
293+
},
294+
{
295+
"id": 2,
296+
"name": "Beta UI",
297+
"key": "beta-ui",
298+
"active": True,
299+
"filters": {
300+
"groups": [{"properties": [], "rollout_percentage": 100}],
301+
"payloads": {"true": '{"color": "green"}'},
302+
},
303+
},
304+
{
305+
"id": 3,
306+
"name": "Plain",
307+
"key": "plain-payload",
308+
"active": True,
309+
"filters": {
310+
"groups": [{"properties": [], "rollout_percentage": 100}],
311+
"payloads": {"true": "not json"},
312+
},
313+
},
314+
]
315+
316+
def tearDown(self):
317+
self.client.shutdown()
318+
319+
@mock.patch("posthog.client.flags")
320+
def test_local_payloads_are_parsed(self, patch_flags):
321+
flags = self.client.evaluate_flags("user-1")
322+
323+
self.assertEqual(flags.get_flag_payload("checkout"), {"copy": "new"})
324+
self.assertEqual(flags.get_flag_payload("beta-ui"), {"color": "green"})
325+
# /flags is not called because everything evaluated locally
326+
self.assertEqual(patch_flags.call_count, 0)
327+
328+
@mock.patch("posthog.client.flags")
329+
def test_non_json_local_payload_is_passed_through(self, patch_flags):
330+
flags = self.client.evaluate_flags("user-1")
331+
332+
self.assertEqual(flags.get_flag_payload("plain-payload"), "not json")
333+
self.assertEqual(patch_flags.call_count, 0)
334+
335+
@mock.patch("posthog.client.flags")
336+
@mock.patch.object(Client, "capture")
337+
def test_flag_called_event_carries_parsed_local_payload(
338+
self, patch_capture, patch_flags
339+
):
340+
flags = self.client.evaluate_flags("user-1")
341+
flags.get_flag("checkout")
342+
343+
feature_flag_called = [
344+
c
345+
for c in patch_capture.call_args_list
346+
if c[0] and c[0][0] == "$feature_flag_called"
347+
]
348+
self.assertEqual(len(feature_flag_called), 1)
349+
properties = feature_flag_called[0][1]["properties"]
350+
self.assertEqual(properties["$feature_flag_payload"], {"copy": "new"})
351+
352+
274353
class TestEvaluateFlagsLocalDeviceBucketing(unittest.TestCase):
275354
def setUp(self):
276355
self.client = Client(FAKE_TEST_API_KEY)

0 commit comments

Comments
 (0)