Skip to content

Commit fe5cb38

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 fe5cb38

File tree

2 files changed

+245
-4
lines changed

2 files changed

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

0 commit comments

Comments
 (0)