Skip to content

Commit 4adb5b8

Browse files
committed
Add $feature_flag_error property to track flag evaluation failures
Track errors in feature flag evaluation by adding a `$feature_flag_error` property to the `$feature_flag_called` event.
1 parent b6dbff1 commit 4adb5b8

File tree

2 files changed

+246
-4
lines changed

2 files changed

+246
-4
lines changed

posthog/client.py

Lines changed: 32 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -36,12 +36,14 @@
3636
from posthog.request import (
3737
DEFAULT_HOST,
3838
APIError,
39+
QuotaLimitError,
3940
batch_post,
4041
determine_server_host,
4142
flags,
4243
get,
4344
remote_config,
4445
)
46+
import requests.exceptions
4547
from posthog.contexts import (
4648
_get_current_context,
4749
get_context_distinct_id,
@@ -1539,6 +1541,7 @@ def _get_feature_flag_result(
15391541
flag_details = None
15401542
request_id = None
15411543
evaluated_at = None
1544+
feature_flag_error: Optional[str] = None
15421545

15431546
flag_value = self._locally_evaluate_flag(
15441547
key, distinct_id, groups, person_properties, group_properties
@@ -1563,7 +1566,7 @@ def _get_feature_flag_result(
15631566
)
15641567
elif not only_evaluate_locally:
15651568
try:
1566-
flag_details, request_id, evaluated_at = (
1569+
flag_details, request_id, evaluated_at, errors_while_computing = (
15671570
self._get_feature_flag_details_from_server(
15681571
key,
15691572
distinct_id,
@@ -1573,6 +1576,11 @@ def _get_feature_flag_result(
15731576
disable_geoip,
15741577
)
15751578
)
1579+
if errors_while_computing:
1580+
feature_flag_error = "errors_while_computing_flags"
1581+
elif flag_details is None:
1582+
feature_flag_error = "flag_missing"
1583+
15761584
flag_result = FeatureFlagResult.from_flag_details(
15771585
flag_details, override_match_value
15781586
)
@@ -1586,9 +1594,23 @@ def _get_feature_flag_result(
15861594
self.log.debug(
15871595
f"Successfully computed flag remotely: #{key} -> #{flag_result}"
15881596
)
1597+
except QuotaLimitError as e:
1598+
self.log.exception(f"[FEATURE FLAGS] Quota limit exceeded: {e}")
1599+
feature_flag_error = "quota_limited"
1600+
except requests.exceptions.Timeout as e:
1601+
self.log.exception(f"[FEATURE FLAGS] Request timed out: {e}")
1602+
feature_flag_error = "timeout"
1603+
except requests.exceptions.ConnectionError as e:
1604+
self.log.exception(f"[FEATURE FLAGS] Connection error: {e}")
1605+
feature_flag_error = "connection_error"
1606+
except APIError as e:
1607+
self.log.exception(f"[FEATURE FLAGS] API error: {e}")
1608+
feature_flag_error = f"api_error_{e.status}"
15891609
except Exception as e:
15901610
self.log.exception(f"[FEATURE FLAGS] Unable to get flag remotely: {e}")
1611+
feature_flag_error = "unknown_error"
15911612

1613+
if feature_flag_error:
15921614
# Fallback to cached value if remote evaluation fails
15931615
if self.flag_cache:
15941616
stale_result = self.flag_cache.get_stale_cached_flag(
@@ -1612,6 +1634,7 @@ def _get_feature_flag_result(
16121634
request_id,
16131635
evaluated_at,
16141636
flag_details,
1637+
feature_flag_error,
16151638
)
16161639

16171640
return flag_result
@@ -1814,9 +1837,10 @@ def _get_feature_flag_details_from_server(
18141837
person_properties: dict[str, str],
18151838
group_properties: dict[str, str],
18161839
disable_geoip: Optional[bool],
1817-
) -> tuple[Optional[FeatureFlag], Optional[str], Optional[int]]:
1840+
) -> tuple[Optional[FeatureFlag], Optional[str], Optional[int], bool]:
18181841
"""
1819-
Calls /flags and returns the flag details, request id, and evaluated at timestamp
1842+
Calls /flags and returns the flag details, request id, evaluated at timestamp,
1843+
and whether there were errors while computing flags.
18201844
"""
18211845
resp_data = self.get_flags_decision(
18221846
distinct_id,
@@ -1828,9 +1852,10 @@ def _get_feature_flag_details_from_server(
18281852
)
18291853
request_id = resp_data.get("requestId")
18301854
evaluated_at = resp_data.get("evaluatedAt")
1855+
errors_while_computing = resp_data.get("errorsWhileComputingFlags", False)
18311856
flags = resp_data.get("flags")
18321857
flag_details = flags.get(key) if flags else None
1833-
return flag_details, request_id, evaluated_at
1858+
return flag_details, request_id, evaluated_at, errors_while_computing
18341859

18351860
def _capture_feature_flag_called(
18361861
self,
@@ -1844,6 +1869,7 @@ def _capture_feature_flag_called(
18441869
request_id: Optional[str],
18451870
evaluated_at: Optional[int],
18461871
flag_details: Optional[FeatureFlag],
1872+
feature_flag_error: Optional[str] = None,
18471873
):
18481874
feature_flag_reported_key = (
18491875
f"{key}_{'::null::' if response is None else str(response)}"
@@ -1878,6 +1904,8 @@ def _capture_feature_flag_called(
18781904
)
18791905
if flag_details.metadata.id:
18801906
properties["$feature_flag_id"] = flag_details.metadata.id
1907+
if feature_flag_error:
1908+
properties["$feature_flag_error"] = feature_flag_error
18811909

18821910
self.capture(
18831911
"$feature_flag_called",

posthog/test/test_feature_flag_result.py

Lines changed: 214 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -438,6 +438,220 @@ def test_get_feature_flag_result_unknown_flag(self, patch_capture, patch_flags):
438438
"$feature_flag_response": None,
439439
"locally_evaluated": False,
440440
"$feature/no-person-flag": None,
441+
"$feature_flag_error": "flag_missing",
442+
},
443+
groups={},
444+
disable_geoip=None,
445+
)
446+
447+
@mock.patch("posthog.client.flags")
448+
@mock.patch.object(Client, "capture")
449+
def test_get_feature_flag_result_with_errors_while_computing_flags(
450+
self, patch_capture, patch_flags
451+
):
452+
"""Test that errors_while_computing_flags is included in the $feature_flag_called event.
453+
454+
When the server returns errorsWhileComputingFlags=true, it indicates that there
455+
was an error computing one or more flags. We include this in the event so users
456+
can identify and debug flag evaluation issues.
457+
"""
458+
patch_flags.return_value = {
459+
"flags": {
460+
"my-flag": {
461+
"key": "my-flag",
462+
"enabled": True,
463+
"variant": None,
464+
"reason": {"description": "Matched condition set 1"},
465+
"metadata": {"id": 1, "version": 1, "payload": None},
466+
},
467+
},
468+
"requestId": "test-request-id-789",
469+
"errorsWhileComputingFlags": True,
470+
}
471+
472+
flag_result = self.client.get_feature_flag_result("my-flag", "some-distinct-id")
473+
474+
self.assertEqual(flag_result.enabled, True)
475+
patch_capture.assert_called_with(
476+
"$feature_flag_called",
477+
distinct_id="some-distinct-id",
478+
properties={
479+
"$feature_flag": "my-flag",
480+
"$feature_flag_response": True,
481+
"locally_evaluated": False,
482+
"$feature/my-flag": True,
483+
"$feature_flag_request_id": "test-request-id-789",
484+
"$feature_flag_reason": "Matched condition set 1",
485+
"$feature_flag_id": 1,
486+
"$feature_flag_version": 1,
487+
"$feature_flag_error": "errors_while_computing_flags",
488+
},
489+
groups={},
490+
disable_geoip=None,
491+
)
492+
493+
@mock.patch("posthog.client.flags")
494+
@mock.patch.object(Client, "capture")
495+
def test_get_feature_flag_result_flag_not_in_response(
496+
self, patch_capture, patch_flags
497+
):
498+
"""Test that when a flag is not in the API response, we capture flag_missing error.
499+
500+
This happens when a flag doesn't exist or the user doesn't match any conditions.
501+
"""
502+
patch_flags.return_value = {
503+
"flags": {
504+
"other-flag": {
505+
"key": "other-flag",
506+
"enabled": True,
507+
"variant": None,
508+
"reason": {"description": "Matched condition set 1"},
509+
"metadata": {"id": 1, "version": 1, "payload": None},
510+
},
511+
},
512+
"requestId": "test-request-id-456",
513+
}
514+
515+
flag_result = self.client.get_feature_flag_result(
516+
"missing-flag", "some-distinct-id"
517+
)
518+
519+
self.assertIsNone(flag_result)
520+
patch_capture.assert_called_with(
521+
"$feature_flag_called",
522+
distinct_id="some-distinct-id",
523+
properties={
524+
"$feature_flag": "missing-flag",
525+
"$feature_flag_response": None,
526+
"locally_evaluated": False,
527+
"$feature/missing-flag": None,
528+
"$feature_flag_request_id": "test-request-id-456",
529+
"$feature_flag_error": "flag_missing",
530+
},
531+
groups={},
532+
disable_geoip=None,
533+
)
534+
535+
@mock.patch("posthog.client.flags")
536+
@mock.patch.object(Client, "capture")
537+
def test_get_feature_flag_result_unknown_error(self, patch_capture, patch_flags):
538+
"""Test that unexpected exceptions are captured as unknown_error."""
539+
patch_flags.side_effect = Exception("Unexpected error")
540+
541+
flag_result = self.client.get_feature_flag_result("my-flag", "some-distinct-id")
542+
543+
self.assertIsNone(flag_result)
544+
patch_capture.assert_called_with(
545+
"$feature_flag_called",
546+
distinct_id="some-distinct-id",
547+
properties={
548+
"$feature_flag": "my-flag",
549+
"$feature_flag_response": None,
550+
"locally_evaluated": False,
551+
"$feature/my-flag": None,
552+
"$feature_flag_error": "unknown_error",
553+
},
554+
groups={},
555+
disable_geoip=None,
556+
)
557+
558+
@mock.patch("posthog.client.flags")
559+
@mock.patch.object(Client, "capture")
560+
def test_get_feature_flag_result_timeout_error(self, patch_capture, patch_flags):
561+
"""Test that timeout errors are captured specifically."""
562+
import requests.exceptions
563+
564+
patch_flags.side_effect = requests.exceptions.Timeout("Request timed out")
565+
566+
flag_result = self.client.get_feature_flag_result("my-flag", "some-distinct-id")
567+
568+
self.assertIsNone(flag_result)
569+
patch_capture.assert_called_with(
570+
"$feature_flag_called",
571+
distinct_id="some-distinct-id",
572+
properties={
573+
"$feature_flag": "my-flag",
574+
"$feature_flag_response": None,
575+
"locally_evaluated": False,
576+
"$feature/my-flag": None,
577+
"$feature_flag_error": "timeout",
578+
},
579+
groups={},
580+
disable_geoip=None,
581+
)
582+
583+
@mock.patch("posthog.client.flags")
584+
@mock.patch.object(Client, "capture")
585+
def test_get_feature_flag_result_connection_error(self, patch_capture, patch_flags):
586+
"""Test that connection errors are captured specifically."""
587+
import requests.exceptions
588+
589+
patch_flags.side_effect = requests.exceptions.ConnectionError(
590+
"Connection refused"
591+
)
592+
593+
flag_result = self.client.get_feature_flag_result("my-flag", "some-distinct-id")
594+
595+
self.assertIsNone(flag_result)
596+
patch_capture.assert_called_with(
597+
"$feature_flag_called",
598+
distinct_id="some-distinct-id",
599+
properties={
600+
"$feature_flag": "my-flag",
601+
"$feature_flag_response": None,
602+
"locally_evaluated": False,
603+
"$feature/my-flag": None,
604+
"$feature_flag_error": "connection_error",
605+
},
606+
groups={},
607+
disable_geoip=None,
608+
)
609+
610+
@mock.patch("posthog.client.flags")
611+
@mock.patch.object(Client, "capture")
612+
def test_get_feature_flag_result_api_error(self, patch_capture, patch_flags):
613+
"""Test that API errors include the status code."""
614+
from posthog.request import APIError
615+
616+
patch_flags.side_effect = APIError(500, "Internal server error")
617+
618+
flag_result = self.client.get_feature_flag_result("my-flag", "some-distinct-id")
619+
620+
self.assertIsNone(flag_result)
621+
patch_capture.assert_called_with(
622+
"$feature_flag_called",
623+
distinct_id="some-distinct-id",
624+
properties={
625+
"$feature_flag": "my-flag",
626+
"$feature_flag_response": None,
627+
"locally_evaluated": False,
628+
"$feature/my-flag": None,
629+
"$feature_flag_error": "api_error_500",
630+
},
631+
groups={},
632+
disable_geoip=None,
633+
)
634+
635+
@mock.patch("posthog.client.flags")
636+
@mock.patch.object(Client, "capture")
637+
def test_get_feature_flag_result_quota_limited(self, patch_capture, patch_flags):
638+
"""Test that quota limit errors are captured specifically."""
639+
from posthog.request import QuotaLimitError
640+
641+
patch_flags.side_effect = QuotaLimitError(429, "Rate limit exceeded")
642+
643+
flag_result = self.client.get_feature_flag_result("my-flag", "some-distinct-id")
644+
645+
self.assertIsNone(flag_result)
646+
patch_capture.assert_called_with(
647+
"$feature_flag_called",
648+
distinct_id="some-distinct-id",
649+
properties={
650+
"$feature_flag": "my-flag",
651+
"$feature_flag_response": None,
652+
"locally_evaluated": False,
653+
"$feature/my-flag": None,
654+
"$feature_flag_error": "quota_limited",
441655
},
442656
groups={},
443657
disable_geoip=None,

0 commit comments

Comments
 (0)