Skip to content

Commit b179280

Browse files
authored
feature: Add $feature_flag_error property to track flag evaluation failures (#390)
* 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. * Refactor requests exception imports through request.py Export RequestsTimeout and RequestsConnectionError from posthog/request.py to keep all requests library imports in one place and avoid mypy issues. * Address PR review feedback - Fix fallback logic to only trigger on actual exceptions, not when errors_while_computing or flag_missing is set from a successful API response - Change log.exception() to log.warning() for expected operational errors (quota limits, timeouts, connection errors, API errors) to reduce log noise - Keep log.exception() only for truly unexpected errors (unknown_error) - Extract stale cache fallback into _get_stale_flag_fallback() helper method * Add tests for stale cache fallback and error absence - Add TestFeatureFlagErrorWithStaleCacheFallback test class with 4 tests: - test_timeout_error_returns_stale_cached_value - test_connection_error_returns_stale_cached_value - test_api_error_returns_stale_cached_value - test_error_without_cache_returns_none - Add negative assertions to verify $feature_flag_error is absent on success: - test_get_feature_flag_result_boolean_local_evaluation - test_get_feature_flag_result_variant_local_evaluation - test_get_feature_flag_result_boolean_decide - test_get_feature_flag_result_variant_decide * Report combined errors when both errors_while_computing and flag_missing When the server returns errorsWhileComputingFlags=true AND the requested flag is not in the response, report both conditions as a comma-separated string: "errors_while_computing_flags,flag_missing" This provides better debugging context when both conditions occur. * Add FeatureFlagError constants class for error type values - Add FeatureFlagError class to types.py with constants: - ERRORS_WHILE_COMPUTING, FLAG_MISSING, QUOTA_LIMITED - TIMEOUT, CONNECTION_ERROR, UNKNOWN_ERROR - api_error(status) static method for dynamic error strings - Update client.py to use FeatureFlagError constants instead of magic strings - Update all tests to use constants for maintainability This improves maintainability by: - Single source of truth for error values - IDE autocomplete and typo detection - Documentation of analytics-stable values * Remove print statements from test failure handlers * Fix mypy type error in FeatureFlagError.api_error method Accept Union[int, str] to match APIError.status type.
1 parent b6dbff1 commit b179280

File tree

4 files changed

+540
-17
lines changed

4 files changed

+540
-17
lines changed

posthog/client.py

Lines changed: 54 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,9 @@
3636
from posthog.request import (
3737
DEFAULT_HOST,
3838
APIError,
39+
QuotaLimitError,
40+
RequestsConnectionError,
41+
RequestsTimeout,
3942
batch_post,
4043
determine_server_host,
4144
flags,
@@ -53,6 +56,7 @@
5356
)
5457
from posthog.types import (
5558
FeatureFlag,
59+
FeatureFlagError,
5660
FeatureFlagResult,
5761
FlagMetadata,
5862
FlagsAndPayloads,
@@ -1506,6 +1510,19 @@ def feature_enabled(
15061510
return None
15071511
return bool(response)
15081512

1513+
def _get_stale_flag_fallback(
1514+
self, distinct_id: ID_TYPES, key: str
1515+
) -> Optional[FeatureFlagResult]:
1516+
"""Returns a stale cached flag value if available, otherwise None."""
1517+
if self.flag_cache:
1518+
stale_result = self.flag_cache.get_stale_cached_flag(distinct_id, key)
1519+
if stale_result:
1520+
self.log.info(
1521+
f"[FEATURE FLAGS] Using stale cached value for flag {key}"
1522+
)
1523+
return stale_result
1524+
return None
1525+
15091526
def _get_feature_flag_result(
15101527
self,
15111528
key: str,
@@ -1539,6 +1556,7 @@ def _get_feature_flag_result(
15391556
flag_details = None
15401557
request_id = None
15411558
evaluated_at = None
1559+
feature_flag_error: Optional[str] = None
15421560

15431561
flag_value = self._locally_evaluate_flag(
15441562
key, distinct_id, groups, person_properties, group_properties
@@ -1563,7 +1581,7 @@ def _get_feature_flag_result(
15631581
)
15641582
elif not only_evaluate_locally:
15651583
try:
1566-
flag_details, request_id, evaluated_at = (
1584+
flag_details, request_id, evaluated_at, errors_while_computing = (
15671585
self._get_feature_flag_details_from_server(
15681586
key,
15691587
distinct_id,
@@ -1573,6 +1591,14 @@ def _get_feature_flag_result(
15731591
disable_geoip,
15741592
)
15751593
)
1594+
errors = []
1595+
if errors_while_computing:
1596+
errors.append(FeatureFlagError.ERRORS_WHILE_COMPUTING)
1597+
if flag_details is None:
1598+
errors.append(FeatureFlagError.FLAG_MISSING)
1599+
if errors:
1600+
feature_flag_error = ",".join(errors)
1601+
15761602
flag_result = FeatureFlagResult.from_flag_details(
15771603
flag_details, override_match_value
15781604
)
@@ -1586,19 +1612,26 @@ def _get_feature_flag_result(
15861612
self.log.debug(
15871613
f"Successfully computed flag remotely: #{key} -> #{flag_result}"
15881614
)
1615+
except QuotaLimitError as e:
1616+
self.log.warning(f"[FEATURE FLAGS] Quota limit exceeded: {e}")
1617+
feature_flag_error = FeatureFlagError.QUOTA_LIMITED
1618+
flag_result = self._get_stale_flag_fallback(distinct_id, key)
1619+
except RequestsTimeout as e:
1620+
self.log.warning(f"[FEATURE FLAGS] Request timed out: {e}")
1621+
feature_flag_error = FeatureFlagError.TIMEOUT
1622+
flag_result = self._get_stale_flag_fallback(distinct_id, key)
1623+
except RequestsConnectionError as e:
1624+
self.log.warning(f"[FEATURE FLAGS] Connection error: {e}")
1625+
feature_flag_error = FeatureFlagError.CONNECTION_ERROR
1626+
flag_result = self._get_stale_flag_fallback(distinct_id, key)
1627+
except APIError as e:
1628+
self.log.warning(f"[FEATURE FLAGS] API error: {e}")
1629+
feature_flag_error = FeatureFlagError.api_error(e.status)
1630+
flag_result = self._get_stale_flag_fallback(distinct_id, key)
15891631
except Exception as e:
15901632
self.log.exception(f"[FEATURE FLAGS] Unable to get flag remotely: {e}")
1591-
1592-
# Fallback to cached value if remote evaluation fails
1593-
if self.flag_cache:
1594-
stale_result = self.flag_cache.get_stale_cached_flag(
1595-
distinct_id, key
1596-
)
1597-
if stale_result:
1598-
self.log.info(
1599-
f"[FEATURE FLAGS] Using stale cached value for flag {key}"
1600-
)
1601-
flag_result = stale_result
1633+
feature_flag_error = FeatureFlagError.UNKNOWN_ERROR
1634+
flag_result = self._get_stale_flag_fallback(distinct_id, key)
16021635

16031636
if send_feature_flag_events:
16041637
self._capture_feature_flag_called(
@@ -1612,6 +1645,7 @@ def _get_feature_flag_result(
16121645
request_id,
16131646
evaluated_at,
16141647
flag_details,
1648+
feature_flag_error,
16151649
)
16161650

16171651
return flag_result
@@ -1814,9 +1848,10 @@ def _get_feature_flag_details_from_server(
18141848
person_properties: dict[str, str],
18151849
group_properties: dict[str, str],
18161850
disable_geoip: Optional[bool],
1817-
) -> tuple[Optional[FeatureFlag], Optional[str], Optional[int]]:
1851+
) -> tuple[Optional[FeatureFlag], Optional[str], Optional[int], bool]:
18181852
"""
1819-
Calls /flags and returns the flag details, request id, and evaluated at timestamp
1853+
Calls /flags and returns the flag details, request id, evaluated at timestamp,
1854+
and whether there were errors while computing flags.
18201855
"""
18211856
resp_data = self.get_flags_decision(
18221857
distinct_id,
@@ -1828,9 +1863,10 @@ def _get_feature_flag_details_from_server(
18281863
)
18291864
request_id = resp_data.get("requestId")
18301865
evaluated_at = resp_data.get("evaluatedAt")
1866+
errors_while_computing = resp_data.get("errorsWhileComputingFlags", False)
18311867
flags = resp_data.get("flags")
18321868
flag_details = flags.get(key) if flags else None
1833-
return flag_details, request_id, evaluated_at
1869+
return flag_details, request_id, evaluated_at, errors_while_computing
18341870

18351871
def _capture_feature_flag_called(
18361872
self,
@@ -1844,6 +1880,7 @@ def _capture_feature_flag_called(
18441880
request_id: Optional[str],
18451881
evaluated_at: Optional[int],
18461882
flag_details: Optional[FeatureFlag],
1883+
feature_flag_error: Optional[str] = None,
18471884
):
18481885
feature_flag_reported_key = (
18491886
f"{key}_{'::null::' if response is None else str(response)}"
@@ -1878,6 +1915,8 @@ def _capture_feature_flag_called(
18781915
)
18791916
if flag_details.metadata.id:
18801917
properties["$feature_flag_id"] = flag_details.metadata.id
1918+
if feature_flag_error:
1919+
properties["$feature_flag_error"] = feature_flag_error
18811920

18821921
self.capture(
18831922
"$feature_flag_called",

posthog/request.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -312,6 +312,12 @@ class QuotaLimitError(APIError):
312312
pass
313313

314314

315+
# Re-export requests exceptions for use in client.py
316+
# This keeps all requests library imports centralized in this module
317+
RequestsTimeout = requests.exceptions.Timeout
318+
RequestsConnectionError = requests.exceptions.ConnectionError
319+
320+
315321
class DatetimeSerializer(json.JSONEncoder):
316322
def default(self, obj: Any):
317323
if isinstance(obj, (date, datetime)):

0 commit comments

Comments
 (0)